mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 01:45:32 +00:00
* 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>
1002 lines
32 KiB
TypeScript
1002 lines
32 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { FUNCTION_COMPARISON_EXAMPLE } from "../contracts/functionComparisonExample.js";
|
|
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
|
|
import { functionComparisonResultSchema } from "./functionComparisonSchemas.js";
|
|
import { createEvidence, type Evidence } from "./evidence.js";
|
|
import { functionDossierSchema } from "./hopperValues.js";
|
|
import { canonicalJson } from "./comparisonSemantics.js";
|
|
import { digestCanonicalValue } from "./canonicalDigest.js";
|
|
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
|
|
import { jsonValueSchema } from "./jsonValue.js";
|
|
|
|
import { compareFunctions } from "./functionComparison.js";
|
|
import type { RelationshipCoverage } from "./relationshipCoverage.js";
|
|
const procedure = (address: string, name: string) => ({
|
|
address,
|
|
name,
|
|
classification: null,
|
|
body: {
|
|
available: false as const,
|
|
reason:
|
|
"Hopper's public Python API does not expose complete function body ranges",
|
|
},
|
|
});
|
|
const dossier = (
|
|
text: string,
|
|
base: "0x1000" | "0x2000",
|
|
successors: readonly string[] = [],
|
|
) =>
|
|
functionDossierSchema.parse({
|
|
procedure: {
|
|
...procedure(base, "main"),
|
|
signature: "int main(void)",
|
|
locals: [],
|
|
},
|
|
pseudocode: text,
|
|
assembly: ["ret"],
|
|
comments: [],
|
|
callers: [],
|
|
callees: [],
|
|
incoming_references: [],
|
|
outgoing_references: [],
|
|
referenced_strings: [],
|
|
referenced_names: [],
|
|
native_api: null,
|
|
native_value_flow: null,
|
|
basic_blocks: [
|
|
{
|
|
start: base,
|
|
end: base === "0x1000" ? "0x1001" : "0x2001",
|
|
successors,
|
|
},
|
|
],
|
|
limitations: [],
|
|
});
|
|
|
|
const dossierWithReference = (
|
|
base: "0x1000" | "0x2000",
|
|
kind: "call" | "data" | "unavailable",
|
|
) => {
|
|
const target = base === "0x1000" ? "0x1010" : "0x2010";
|
|
return functionDossierSchema.parse({
|
|
...dossier("return helper();", base),
|
|
outgoing_references: [
|
|
{
|
|
source_address: base,
|
|
target_address: target,
|
|
source_procedure: procedure(base, "main"),
|
|
target_procedure: procedure(target, "helper"),
|
|
kind:
|
|
kind === "unavailable"
|
|
? { available: false, reason: "provider has no kind authority" }
|
|
: {
|
|
available: true,
|
|
provenance: "ghidra-reference-manager",
|
|
type: kind === "call" ? "UNCONDITIONAL_CALL" : "READ",
|
|
flow: kind === "call",
|
|
call: kind === "call",
|
|
jump: false,
|
|
data: kind === "data",
|
|
read: kind === "data",
|
|
write: false,
|
|
indirect: false,
|
|
computed: false,
|
|
conditional: false,
|
|
terminal: false,
|
|
primary: true,
|
|
operand_index: 0,
|
|
external: false,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
};
|
|
|
|
const observe = (
|
|
digit: string,
|
|
value: ReturnType<typeof dossier>,
|
|
providerId = "rea-workflow",
|
|
relationshipCoverage: RelationshipCoverage | null = { status: "complete" },
|
|
): Evidence =>
|
|
createEvidence(
|
|
{
|
|
path: `/tmp/function-${digit}`,
|
|
sha256: digit.repeat(64),
|
|
format: "mach-o",
|
|
},
|
|
{ id: providerId, name: providerId, version: "1" },
|
|
{
|
|
operation: "analyze_function",
|
|
parameters: enhancedInputSchemas.analyze_function.parse({
|
|
procedure: "main",
|
|
}),
|
|
result: jsonValueSchema.parse(value),
|
|
confidence: "derived",
|
|
authority: "shipped-artifact",
|
|
...(relationshipCoverage === null ? {} : { relationshipCoverage }),
|
|
},
|
|
);
|
|
|
|
describe("function comparison normalized identity", () => {
|
|
it("keeps differing generated names ambiguous and stable names mismatched", () => {
|
|
const named = (base: "0x1000" | "0x2000", name: string) =>
|
|
functionDossierSchema.parse({
|
|
...dossier("return 0;", base),
|
|
procedure: {
|
|
...procedure(base, name),
|
|
signature: "int helper(void)",
|
|
locals: [],
|
|
},
|
|
});
|
|
const generated = compareFunctions(
|
|
observe("1", named("0x1000", "sub_1000")),
|
|
observe("2", named("0x2000", "sub_2000")),
|
|
);
|
|
expect(generated.function_match.status).toBe("ambiguous");
|
|
expect(generated.status).toBe("unknown");
|
|
expect(
|
|
generated.dimensions.find(({ dimension }) => dimension === "identity"),
|
|
).toMatchObject({ status: "unknown" });
|
|
|
|
const stable = compareFunctions(
|
|
observe("3", named("0x1000", "firstFunction")),
|
|
observe("4", named("0x2000", "secondFunction")),
|
|
);
|
|
expect(stable.function_match.status).toBe("mismatched");
|
|
expect(stable.status).toBe("changed");
|
|
});
|
|
|
|
it.each(["sub_deallocate", "sub_deadbeef_handler", "fcn.dispatch"])(
|
|
"recognizes the explicit symbol %s without a generated-name prefix false positive",
|
|
(name) => {
|
|
const named = (base: "0x1000" | "0x2000") =>
|
|
functionDossierSchema.parse({
|
|
...dossier("return 0;", base),
|
|
procedure: {
|
|
...procedure(base, name),
|
|
name,
|
|
signature: "int helper(void)",
|
|
locals: [],
|
|
},
|
|
});
|
|
const result = compareFunctions(
|
|
observe("b", named("0x1000")),
|
|
observe("c", named("0x2000")),
|
|
);
|
|
expect(result.function_match).toMatchObject({
|
|
status: "matched",
|
|
method: "symbol",
|
|
});
|
|
expect(
|
|
result.dimensions.find(({ dimension }) => dimension === "identity"),
|
|
).toMatchObject({ status: "unchanged" });
|
|
},
|
|
);
|
|
|
|
it.each(["sub_deadbeef", "fcn.00401000", "FUN_deadBEEF"])(
|
|
"keeps %s as an address-derived name",
|
|
(name) => {
|
|
const generated = (base: "0x1000" | "0x2000") =>
|
|
functionDossierSchema.parse({
|
|
...dossier("return 0;", base),
|
|
procedure: {
|
|
...procedure(base, name),
|
|
name,
|
|
signature: "int helper(void)",
|
|
locals: [],
|
|
},
|
|
});
|
|
const result = compareFunctions(
|
|
observe("d", generated("0x1000")),
|
|
observe("e", generated("0x2000")),
|
|
);
|
|
expect(result.function_match.status).toBe("ambiguous");
|
|
expect(
|
|
result.dimensions.find(({ dimension }) => dimension === "identity"),
|
|
).toMatchObject({ status: "unknown" });
|
|
},
|
|
);
|
|
it.each([
|
|
{ name: "sub_deallocate", status: "unchanged" },
|
|
{ name: "sub_deadbeef", status: "unknown" },
|
|
{ name: "FUN_00401000", status: "unknown" },
|
|
{ name: "FUN_initialize", status: "unchanged" },
|
|
])("preserves the calls dimension for callee $name", ({ name, status }) => {
|
|
const calling = (base: "0x1000" | "0x2000") =>
|
|
functionDossierSchema.parse({
|
|
...dossier("return helper();", base),
|
|
callees: [procedure(base === "0x1000" ? "0x1010" : "0x2010", name)],
|
|
});
|
|
const result = compareFunctions(
|
|
observe("6", calling("0x1000")),
|
|
observe("7", calling("0x2000")),
|
|
);
|
|
expect(
|
|
result.dimensions.find(({ dimension }) => dimension === "calls"),
|
|
).toMatchObject({ status });
|
|
});
|
|
});
|
|
|
|
describe("function call relationship coverage", () => {
|
|
const coverageCases: {
|
|
readonly label: string;
|
|
readonly coverage: RelationshipCoverage | null;
|
|
readonly limitation: string;
|
|
}[] = [
|
|
{
|
|
label: "missing",
|
|
coverage: null,
|
|
limitation: "Provider did not report relationship coverage.",
|
|
},
|
|
{
|
|
label: "partial",
|
|
coverage: {
|
|
status: "partial",
|
|
limitations: ["Call graph stopped at the read limit."],
|
|
},
|
|
limitation: "Call graph stopped at the read limit.",
|
|
},
|
|
];
|
|
it.each(coverageCases)(
|
|
"keeps equal empty calls unknown for $label coverage",
|
|
({ coverage, limitation }) => {
|
|
const left = observe("a", dossier("return 0;", "0x1000"));
|
|
const right = observe(
|
|
"b",
|
|
dossier("return 0;", "0x2000"),
|
|
"rea-workflow",
|
|
coverage,
|
|
);
|
|
const result = compareFunctions(left, right);
|
|
const calls = result.dimensions.find(
|
|
({ dimension }) => dimension === "calls",
|
|
);
|
|
expect(calls).toMatchObject({
|
|
status: "unknown",
|
|
conclusion_kind: "unresolved_branch",
|
|
left_count: 0,
|
|
right_count: 0,
|
|
limitations: [`Right: ${limitation}`],
|
|
});
|
|
expect(result.status).toBe("unknown");
|
|
},
|
|
);
|
|
|
|
it("retains complete observations with execution diagnostics as comparable", () => {
|
|
const coverage = {
|
|
status: "complete" as const,
|
|
diagnostics: ["Provider reported a non-call xref warning."],
|
|
};
|
|
const left = observe(
|
|
"c",
|
|
dossier("return 0;", "0x1000"),
|
|
"rea-workflow",
|
|
coverage,
|
|
);
|
|
const right = observe(
|
|
"d",
|
|
dossier("return 0;", "0x2000"),
|
|
"rea-workflow",
|
|
coverage,
|
|
);
|
|
expect(
|
|
compareFunctions(left, right).dimensions.find(
|
|
({ dimension }) => dimension === "calls",
|
|
),
|
|
).toMatchObject({ status: "unchanged", left_count: 0, right_count: 0 });
|
|
});
|
|
|
|
it("keeps unresolved call sites unknown despite complete resolved-set coverage", () => {
|
|
const left = observe("e", dossier("return 0;", "0x1000"));
|
|
const rightDossier = functionDossierSchema.parse({
|
|
...dossier("return 0;", "0x2000"),
|
|
unresolved_calls: [
|
|
{
|
|
address: "0x2010",
|
|
reason: "Indirect dispatch target could not be resolved.",
|
|
},
|
|
],
|
|
});
|
|
const right = observe("f", rightDossier);
|
|
expect(
|
|
compareFunctions(left, right).dimensions.find(
|
|
({ dimension }) => dimension === "calls",
|
|
),
|
|
).toMatchObject({
|
|
status: "unknown",
|
|
limitations: [
|
|
"Right: Unresolved call at 0x2010: Indirect dispatch target could not be resolved.",
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("function collection collation ties", () => {
|
|
const composed = procedure("0x2000", "caf\u00e9");
|
|
const decomposed = procedure("0x3000", "cafe\u0301");
|
|
const calling = (callees: readonly ReturnType<typeof procedure>[]) =>
|
|
functionDossierSchema.parse({
|
|
...dossier("return 0;", "0x1000"),
|
|
callees,
|
|
});
|
|
|
|
it("ignores reordered callees whose distinct names collate equally", () => {
|
|
const result = compareFunctions(
|
|
observe("b", calling([composed, decomposed])),
|
|
observe("c", calling([decomposed, composed])),
|
|
);
|
|
expect(result.status).toBe("unchanged");
|
|
const calls = result.dimensions.find(
|
|
({ dimension }) => dimension === "calls",
|
|
);
|
|
expect(calls).toMatchObject({
|
|
status: "unchanged",
|
|
left_count: 2,
|
|
right_count: 2,
|
|
});
|
|
expect(calls?.left_digest).toBe(calls?.right_digest);
|
|
});
|
|
|
|
it.each([
|
|
["remove composed", [decomposed]],
|
|
["remove decomposed", [composed]],
|
|
["replace composed", [decomposed, decomposed]],
|
|
["replace decomposed", [composed, composed]],
|
|
] as const)("preserves an exact name change: %s", (_label, changed) => {
|
|
const result = compareFunctions(
|
|
observe("b", calling([composed, decomposed])),
|
|
observe("c", calling(changed)),
|
|
);
|
|
expect(result.status).toBe("changed");
|
|
const calls = result.dimensions.find(
|
|
({ dimension }) => dimension === "calls",
|
|
);
|
|
expect(calls).toMatchObject({ status: "changed" });
|
|
expect(calls?.left_digest).not.toBe(calls?.right_digest);
|
|
});
|
|
|
|
it("uses canonical code-point ordering for collection digests", () => {
|
|
const callees = ["zeta", "Alpha", "alpha", "_helper", "beta"].map(
|
|
(name, index) => procedure(`0x${(0x2000 + index).toString(16)}`, name),
|
|
);
|
|
const canonicalProjection = callees
|
|
.map(({ name }) => ({ direction: "out", name }))
|
|
.sort((left, right) =>
|
|
compareUnicodeCodePoints(canonicalJson(left), canonicalJson(right)),
|
|
);
|
|
const result = compareFunctions(
|
|
observe("b", calling(callees)),
|
|
observe("c", calling([...callees].reverse())),
|
|
);
|
|
const calls = result.dimensions.find(
|
|
({ dimension }) => dimension === "calls",
|
|
);
|
|
expect(calls).toMatchObject({
|
|
status: "unchanged",
|
|
left_digest: digestCanonicalValue(canonicalProjection),
|
|
right_digest: digestCanonicalValue(canonicalProjection),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("function comparison ordering controls", () => {
|
|
const composed = procedure("0x2000", "caf\u00e9");
|
|
const decomposed = procedure("0x3000", "cafe\u0301");
|
|
|
|
it("preserves duplicate Unicode collections and nullable endpoints", () => {
|
|
const controlled = (base: "0x1000" | "0x2000", reverse: boolean) => {
|
|
const endpoint = base === "0x1000" ? "0x1010" : "0x2010";
|
|
const edges = [
|
|
{
|
|
source_address: base,
|
|
target_address: endpoint,
|
|
source_procedure: null,
|
|
target_procedure: procedure(endpoint, "caf\u00e9"),
|
|
kind: {
|
|
available: true,
|
|
provenance: "provider-reference-manager",
|
|
type: "UNCONDITIONAL_CALL",
|
|
flow: true,
|
|
call: true,
|
|
jump: false,
|
|
data: false,
|
|
read: false,
|
|
write: false,
|
|
indirect: false,
|
|
computed: false,
|
|
conditional: false,
|
|
terminal: false,
|
|
primary: true,
|
|
operand_index: 0,
|
|
external: false,
|
|
},
|
|
},
|
|
{
|
|
source_address: base,
|
|
target_address: endpoint,
|
|
source_procedure: procedure(base, "dispatch"),
|
|
target_procedure: null,
|
|
kind: {
|
|
available: true,
|
|
provenance: "provider-reference-manager",
|
|
type: "READ",
|
|
flow: false,
|
|
call: false,
|
|
jump: false,
|
|
data: true,
|
|
read: true,
|
|
write: false,
|
|
indirect: false,
|
|
computed: false,
|
|
conditional: false,
|
|
terminal: false,
|
|
primary: true,
|
|
operand_index: 1,
|
|
external: false,
|
|
},
|
|
},
|
|
];
|
|
const strings = [
|
|
{ address: endpoint, source_address: base, value: "caf\u00e9" },
|
|
{ address: endpoint, source_address: base, value: "cafe\u0301" },
|
|
{ address: endpoint, source_address: base, value: "cafe\u0301" },
|
|
];
|
|
return functionDossierSchema.parse({
|
|
...dossier("return 0;", base),
|
|
callees: [composed, decomposed, composed],
|
|
incoming_references: reverse ? [...edges].reverse() : edges,
|
|
outgoing_references: reverse ? [...edges].reverse() : edges,
|
|
referenced_strings: reverse ? [...strings].reverse() : strings,
|
|
referenced_names: reverse ? [...strings].reverse() : strings,
|
|
});
|
|
};
|
|
const makeEvidence = (digit: string, result: unknown): Evidence =>
|
|
createEvidence(
|
|
{
|
|
path: `/tmp/function-${digit}`,
|
|
sha256: digit.repeat(64),
|
|
format: "mach-o",
|
|
},
|
|
{ id: "rea-workflow", name: "REA workflow", version: "1" },
|
|
{
|
|
operation: "analyze_function",
|
|
parameters: enhancedInputSchemas.analyze_function.parse({
|
|
procedure: "dispatch",
|
|
}),
|
|
result: jsonValueSchema.parse(result),
|
|
confidence: "derived",
|
|
authority: "shipped-artifact",
|
|
relationshipCoverage: { status: "complete" },
|
|
},
|
|
);
|
|
const left = controlled("0x1000", false);
|
|
const right = controlled("0x2000", true);
|
|
const rightWithoutNativeApi = { ...right, native_api: null };
|
|
|
|
const comparison = compareFunctions(
|
|
makeEvidence("d", left),
|
|
makeEvidence("e", rightWithoutNativeApi),
|
|
);
|
|
|
|
expect(comparison.status).toBe("unchanged");
|
|
for (const dimension of ["calls", "references", "strings_names"] as const)
|
|
expect(
|
|
comparison.dimensions.find((item) => item.dimension === dimension),
|
|
).toMatchObject({ status: "unchanged" });
|
|
});
|
|
});
|
|
|
|
describe("function comparison CFG address normalization", () => {
|
|
it("matches CFG successors by numeric address", () => {
|
|
const make = (base: "0x1000" | "0x2000") =>
|
|
functionDossierSchema.parse({
|
|
...dossier("return 0;", base),
|
|
basic_blocks: [
|
|
{
|
|
start: base,
|
|
end: base === "0x1000" ? "0x1004" : "0x2004",
|
|
successors: [base === "0x1000" ? "0X01004" : "0x2004"],
|
|
},
|
|
{
|
|
start: base === "0x1000" ? "0x1004" : "0x2004",
|
|
end: base === "0x1000" ? "0x1008" : "0x2008",
|
|
successors: [],
|
|
},
|
|
],
|
|
});
|
|
expect(
|
|
compareFunctions(
|
|
observe("b", make("0x1000")),
|
|
observe("c", make("0x2000")),
|
|
).dimensions.find(({ dimension }) => dimension === "cfg"),
|
|
).toMatchObject({ status: "unchanged" });
|
|
});
|
|
|
|
it("keeps duplicate numeric block starts unknown", () => {
|
|
const duplicate = functionDossierSchema.parse({
|
|
...dossier("return 0;", "0x1000"),
|
|
basic_blocks: [
|
|
{ start: "0x1000", end: "0x1004", successors: [] },
|
|
{ start: "0X01000", end: "0x1008", successors: [] },
|
|
],
|
|
});
|
|
expect(
|
|
compareFunctions(
|
|
observe("d", duplicate),
|
|
observe("e", duplicate),
|
|
).dimensions.find(({ dimension }) => dimension === "cfg"),
|
|
).toMatchObject({ status: "unknown" });
|
|
});
|
|
});
|
|
|
|
describe("function comparison unresolved text", () => {
|
|
it("counts Unicode text consistently for unresolved provider comparisons", () => {
|
|
const left = observe("b", dossier("return '😀';", "0x1000"), "provider-a");
|
|
const right = observe("c", dossier("return '😀';", "0x2000"), "provider-b");
|
|
const comparison = compareFunctions(left, right);
|
|
expect(
|
|
comparison.dimensions.find(({ dimension }) => dimension === "pseudocode"),
|
|
).toMatchObject({ status: "unknown", left_count: 11, right_count: 11 });
|
|
expect(comparison.summary).toEqual({
|
|
unchanged: 6,
|
|
changed: 0,
|
|
unknown: 2,
|
|
});
|
|
expect(
|
|
functionComparisonResultSchema.safeParse({
|
|
...comparison,
|
|
status: "truncated",
|
|
}).success,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("function comparison", () => {
|
|
it("preserves text changes alongside complete inline dossier facets", () => {
|
|
const result = compareFunctions(
|
|
FUNCTION_COMPARISON_EXAMPLE.left,
|
|
FUNCTION_COMPARISON_EXAMPLE.right,
|
|
);
|
|
expect(functionComparisonResultSchema.parse(result)).toMatchObject({
|
|
status: "changed",
|
|
function_match: { status: "matched", method: "symbol" },
|
|
});
|
|
expect(result.dimensions).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ dimension: "pseudocode", status: "changed" }),
|
|
expect.objectContaining({ dimension: "assembly", status: "unchanged" }),
|
|
expect.objectContaining({
|
|
dimension: "references",
|
|
status: "unchanged",
|
|
}),
|
|
]),
|
|
);
|
|
expectInlineComparisonEvidence(result);
|
|
expectFunctionDimensionAlgebra(result);
|
|
});
|
|
|
|
it("normalizes CFG relocation but preserves changed edges and constants", () => {
|
|
const left = observe("2", dossier("return 0x10;", "0x1000"));
|
|
const relocated = observe("3", dossier("return 0x10;", "0x2000"));
|
|
const same = compareFunctions(left, relocated);
|
|
expect(
|
|
same.dimensions.find(({ dimension }) => dimension === "cfg"),
|
|
).toMatchObject({
|
|
status: "unchanged",
|
|
});
|
|
expect(
|
|
same.dimensions.find(({ dimension }) => dimension === "pseudocode"),
|
|
).toMatchObject({ status: "unchanged" });
|
|
|
|
const changed = observe("4", dossier("return 0x20;", "0x2000", ["0x2000"]));
|
|
const comparison = compareFunctions(left, changed);
|
|
expect(
|
|
comparison.dimensions.find(({ dimension }) => dimension === "cfg"),
|
|
).toMatchObject({ status: "changed" });
|
|
expect(
|
|
comparison.dimensions.find(({ dimension }) => dimension === "pseudocode"),
|
|
).toMatchObject({ status: "changed" });
|
|
});
|
|
|
|
it("treats provider-specific text as unknown and validates Evidence", () => {
|
|
const left = observe("5", dossier("return 0;", "0x1000"), "provider-a");
|
|
const right = observe("6", dossier("return 1;", "0x2000"), "provider-b");
|
|
const comparison = compareFunctions(left, right);
|
|
expect(
|
|
comparison.dimensions.find(({ dimension }) => dimension === "pseudocode"),
|
|
).toMatchObject({ status: "unknown" });
|
|
expect(comparison.changes).toHaveLength(2);
|
|
expect(() =>
|
|
compareFunctions({ ...left, operation: "binary_overview" }, right),
|
|
).toThrow(/identifier/u);
|
|
});
|
|
|
|
it("reports fully observed identical dossiers as unchanged", () => {
|
|
const left = observe("7", dossier("return 0;\n", "0x1000"));
|
|
const right = observe("8", dossier("return 0;\n", "0x2000"));
|
|
const comparison = compareFunctions(left, right);
|
|
expect(comparison.status).toBe("unchanged");
|
|
expect(
|
|
comparison.dimensions.every(({ status }) => status === "unchanged"),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("compares exact reference kinds only when both providers expose them", () => {
|
|
const left = observe("d", dossierWithReference("0x1000", "call"));
|
|
const same = observe("e", dossierWithReference("0x2000", "call"));
|
|
expect(
|
|
compareFunctions(left, same).dimensions.find(
|
|
({ dimension }) => dimension === "references",
|
|
),
|
|
).toMatchObject({ status: "unchanged" });
|
|
|
|
const changed = observe("f", dossierWithReference("0x2000", "data"));
|
|
expect(
|
|
compareFunctions(left, changed).dimensions.find(
|
|
({ dimension }) => dimension === "references",
|
|
),
|
|
).toMatchObject({ status: "changed" });
|
|
|
|
const unavailable = observe(
|
|
"1",
|
|
dossierWithReference("0x2000", "unavailable"),
|
|
);
|
|
expect(
|
|
compareFunctions(left, unavailable).dimensions.find(
|
|
({ dimension }) => dimension === "references",
|
|
),
|
|
).toMatchObject({
|
|
status: "unknown",
|
|
limitations: [expect.stringContaining("did not expose reference kinds")],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("function comparison identity and malformed CFG", () => {
|
|
it("does not promote address-derived names through equal signatures", () => {
|
|
const autoDossier = (base: "0x1000" | "0x2000") =>
|
|
functionDossierSchema.parse({
|
|
...dossier("return 0;", base),
|
|
procedure: {
|
|
...procedure(base, "sub_1000"),
|
|
name: "sub_1000",
|
|
signature: "int helper(void)",
|
|
locals: [],
|
|
},
|
|
});
|
|
const comparison = compareFunctions(
|
|
observe("9", autoDossier("0x1000")),
|
|
observe("a", autoDossier("0x2000")),
|
|
);
|
|
expect(comparison.function_match.status).toBe("ambiguous");
|
|
expect(
|
|
comparison.dimensions.find(({ dimension }) => dimension === "identity"),
|
|
).toMatchObject({ status: "unknown" });
|
|
expect(comparison.status).toBe("unknown");
|
|
const changedText = compareFunctions(
|
|
observe("b", autoDossier("0x1000")),
|
|
observe(
|
|
"c",
|
|
functionDossierSchema.parse({
|
|
...autoDossier("0x2000"),
|
|
pseudocode: "return 1;",
|
|
}),
|
|
),
|
|
);
|
|
expect(changedText.dimensions).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ dimension: "pseudocode", status: "changed" }),
|
|
]),
|
|
);
|
|
expect(changedText.status).toBe("unknown");
|
|
});
|
|
|
|
it("rejects invalid CFG normalization and preserves newline differences", () => {
|
|
const leftDossier = dossier("line\r\n", "0x1000");
|
|
const invalidCfg = functionDossierSchema.parse({
|
|
...dossier("line\n", "0x2000"),
|
|
basic_blocks: [
|
|
{ start: "0x2000", end: "0x2001", successors: [] },
|
|
{ start: "0x2000", end: "0x2002", successors: [] },
|
|
],
|
|
});
|
|
const comparison = compareFunctions(
|
|
observe("b", leftDossier),
|
|
observe("c", invalidCfg),
|
|
);
|
|
expect(
|
|
comparison.dimensions.find(({ dimension }) => dimension === "cfg"),
|
|
).toMatchObject({ status: "unknown" });
|
|
const pseudocode = comparison.dimensions.find(
|
|
({ dimension }) => dimension === "pseudocode",
|
|
);
|
|
expect(pseudocode).toMatchObject({ status: "changed" });
|
|
expect(pseudocode?.left_digest).not.toBe(pseudocode?.right_digest);
|
|
});
|
|
});
|
|
|
|
const withLiterals = (base: "0x1000" | "0x2000", values: readonly string[]) =>
|
|
functionDossierSchema.parse({
|
|
...dossier("return 0;", base),
|
|
swift_string_literals: values.map((value) => ({
|
|
source_address: base,
|
|
value,
|
|
representation: "small" as const,
|
|
address: null,
|
|
registers: ["x0", "x1"] as const,
|
|
words: ["0x0", "0xe"] as const,
|
|
})),
|
|
});
|
|
const stringsDimension = (
|
|
leftDigit: string,
|
|
left: ReturnType<typeof withLiterals> | ReturnType<typeof dossier>,
|
|
rightDigit: string,
|
|
right: ReturnType<typeof withLiterals> | ReturnType<typeof dossier>,
|
|
) =>
|
|
compareFunctions(
|
|
observe(leftDigit, left),
|
|
observe(rightDigit, right),
|
|
).dimensions.find(({ dimension }) => dimension === "strings_names");
|
|
|
|
describe("function comparison Swift string literals", () => {
|
|
it("treats differing decoded literals as a strings and names change", () => {
|
|
expect(
|
|
stringsDimension(
|
|
"1",
|
|
withLiterals("0x1000", ["SleepDisabled"]),
|
|
"2",
|
|
withLiterals("0x2000", ["Holding awake"]),
|
|
),
|
|
).toMatchObject({ status: "changed" });
|
|
});
|
|
|
|
it("matches the same decoded literal after the function is relocated", () => {
|
|
expect(
|
|
stringsDimension(
|
|
"3",
|
|
withLiterals("0x1000", ["SleepDisabled"]),
|
|
"4",
|
|
withLiterals("0x2000", ["SleepDisabled"]),
|
|
),
|
|
).toMatchObject({ status: "unchanged" });
|
|
});
|
|
|
|
it("keeps strings and names unknown when only one dossier recorded Swift literals", () => {
|
|
expect(
|
|
stringsDimension(
|
|
"5",
|
|
withLiterals("0x1000", []),
|
|
"6",
|
|
dossier("return 0;", "0x2000"),
|
|
),
|
|
).toMatchObject({
|
|
status: "unknown",
|
|
limitations: [
|
|
"Swift String literal coverage differs or is partial; referenced strings and names match, and complete coverage does not establish a differing literal.",
|
|
],
|
|
});
|
|
});
|
|
|
|
it("reports a referenced name change even when Swift literal coverage differs", () => {
|
|
const named = functionDossierSchema.parse({
|
|
...dossier("return 0;", "0x2000"),
|
|
referenced_names: [
|
|
{ address: "0x3000", value: "helper", source_address: "0x2000" },
|
|
],
|
|
});
|
|
expect(
|
|
stringsDimension("7", withLiterals("0x1000", []), "8", named),
|
|
).toMatchObject({ status: "changed" });
|
|
});
|
|
});
|
|
|
|
describe("function comparison partial Swift literal coverage", () => {
|
|
it("does not equate a partial literal record with a complete one", () => {
|
|
const partial = functionDossierSchema.parse({
|
|
...withLiterals("0x2000", ["SleepDisabled"]),
|
|
swift_string_literal_omissions: [
|
|
{
|
|
source_address: "0x2000",
|
|
representation: "large",
|
|
address: "0x3000",
|
|
byte_length: 64,
|
|
reason: "capacity",
|
|
registers: ["x0", "x1"],
|
|
words: ["0xd000000000000040", "0x8000000000002fe0"],
|
|
},
|
|
],
|
|
});
|
|
expect(
|
|
stringsDimension(
|
|
"9",
|
|
withLiterals("0x1000", ["SleepDisabled"]),
|
|
"a",
|
|
partial,
|
|
),
|
|
).toMatchObject({ status: "unknown" });
|
|
});
|
|
|
|
it("reports a literal that a complete dossier shows absent at a site the partial one decoded", () => {
|
|
const partial = functionDossierSchema.parse({
|
|
...withLiterals("0x2000", ["SleepDisabled"]),
|
|
swift_string_literal_omissions: [
|
|
{
|
|
source_address: "0x2004",
|
|
representation: "small",
|
|
address: null,
|
|
byte_length: 4,
|
|
reason: "capacity",
|
|
registers: ["x0", "x1"],
|
|
words: ["0x732f4220", "0xe400000000000000"],
|
|
},
|
|
],
|
|
});
|
|
expect(
|
|
stringsDimension("d", withLiterals("0x1000", []), "e", partial),
|
|
).toMatchObject({ status: "changed" });
|
|
});
|
|
|
|
it("does not interpret an unexamined path as a missing literal at a retained site", () => {
|
|
const partial = functionDossierSchema.parse({
|
|
...withLiterals("0x2000", ["A"]),
|
|
swift_string_literal_omissions: [
|
|
{
|
|
source_address: "0x2004",
|
|
representation: "small",
|
|
address: null,
|
|
byte_length: 0,
|
|
reason: "capacity",
|
|
registers: ["x0", "x1"],
|
|
words: ["0x0", "0xe000000000000000"],
|
|
},
|
|
],
|
|
});
|
|
const complete = withLiterals("0x1000", ["A", "B"]);
|
|
expect(stringsDimension("1", complete, "2", partial)).toMatchObject({
|
|
status: "unknown",
|
|
});
|
|
expect(stringsDimension("3", partial, "4", complete)).toMatchObject({
|
|
status: "unknown",
|
|
});
|
|
expect(
|
|
stringsDimension("5", withLiterals("0x1000", []), "6", partial),
|
|
).toMatchObject({ status: "changed" });
|
|
});
|
|
|
|
it("treats an omission record without decoded literals as partial coverage", () => {
|
|
const omissionOnly = functionDossierSchema.parse({
|
|
...dossier("return 0;", "0x2000"),
|
|
swift_string_literal_omissions: [
|
|
{
|
|
source_address: "0x2004",
|
|
representation: "small",
|
|
address: null,
|
|
byte_length: 4,
|
|
reason: "capacity",
|
|
registers: ["x0", "x1"],
|
|
words: ["0x732f4220", "0xe400000000000000"],
|
|
},
|
|
],
|
|
});
|
|
expect(
|
|
stringsDimension("f", dossier("return 0;", "0x1000"), "0", omissionOnly),
|
|
).toMatchObject({ status: "unknown" });
|
|
});
|
|
|
|
it("keeps different retained representations unknown when both dossiers are partial", () => {
|
|
const omission = {
|
|
source_address: "0x1004",
|
|
representation: "small",
|
|
address: null,
|
|
byte_length: 4,
|
|
reason: "capacity",
|
|
registers: ["x0", "x1"],
|
|
words: ["0x732f4220", "0xe400000000000000"],
|
|
} as const;
|
|
const small = functionDossierSchema.parse({
|
|
...withLiterals("0x1000", ["SleepDisabled"]),
|
|
swift_string_literal_omissions: [omission],
|
|
});
|
|
const large = functionDossierSchema.parse({
|
|
...dossier("return 0;", "0x2000"),
|
|
swift_string_literals: [
|
|
{
|
|
source_address: "0x2000",
|
|
value: "SleepDisabled",
|
|
representation: "large",
|
|
address: "0x3000",
|
|
registers: ["x0", "x1"],
|
|
words: ["0xd00000000000000d", "0x8000000000002fe0"],
|
|
},
|
|
],
|
|
swift_string_literal_omissions: [
|
|
{ ...omission, source_address: "0x2004" },
|
|
],
|
|
});
|
|
expect(stringsDimension("1", small, "2", large)).toMatchObject({
|
|
status: "unknown",
|
|
});
|
|
});
|
|
|
|
it("keeps different retained literals unknown when both dossiers are partial", () => {
|
|
const partial = (base: "0x1000" | "0x2000", value: string) =>
|
|
functionDossierSchema.parse({
|
|
...withLiterals(base, [value]),
|
|
swift_string_literal_omissions: [
|
|
{
|
|
source_address: base === "0x1000" ? "0x1004" : "0x2004",
|
|
representation: "large",
|
|
address: "0x3000",
|
|
byte_length: 64,
|
|
reason: "capacity",
|
|
registers: ["x0", "x1"],
|
|
words: ["0xd000000000000040", "0x8000000000002fe0"],
|
|
},
|
|
],
|
|
});
|
|
expect(
|
|
stringsDimension(
|
|
"b",
|
|
partial("0x1000", "SleepDisabled"),
|
|
"c",
|
|
partial("0x2000", "Holding awake"),
|
|
),
|
|
).toMatchObject({ status: "unknown" });
|
|
});
|
|
});
|
|
|
|
const expectInlineComparisonEvidence = (
|
|
result: ReturnType<typeof compareFunctions>,
|
|
): void => {
|
|
for (const dimension of result.dimensions)
|
|
expect(dimension.evidence_links).toEqual([
|
|
FUNCTION_COMPARISON_EXAMPLE.left.evidence_id,
|
|
FUNCTION_COMPARISON_EXAMPLE.right.evidence_id,
|
|
]);
|
|
const firstDimension = result.dimensions[0];
|
|
expect(firstDimension).toBeDefined();
|
|
if (firstDimension === undefined) return;
|
|
expect(
|
|
functionComparisonResultSchema.safeParse({
|
|
...result,
|
|
dimensions: result.dimensions.map((dimension, index) =>
|
|
index === 0
|
|
? {
|
|
...dimension,
|
|
evidence_links: Array.from(
|
|
{ length: 201 },
|
|
() => FUNCTION_COMPARISON_EXAMPLE.left.evidence_id,
|
|
),
|
|
}
|
|
: dimension,
|
|
),
|
|
}).success,
|
|
).toBe(true);
|
|
};
|
|
|
|
const expectFunctionDimensionAlgebra = (
|
|
result: ReturnType<typeof compareFunctions>,
|
|
): void => {
|
|
const observed = result.dimensions.find(
|
|
({ status }) => status === "unchanged" || status === "changed",
|
|
);
|
|
expect(observed).toBeDefined();
|
|
if (observed === undefined) return;
|
|
expect(
|
|
functionComparisonResultSchema.safeParse({
|
|
...result,
|
|
dimensions: [
|
|
{ ...observed, left_digest: null, right_digest: null },
|
|
...result.dimensions.slice(1),
|
|
],
|
|
}).success,
|
|
).toBe(false);
|
|
expect(
|
|
functionComparisonResultSchema.safeParse({
|
|
...result,
|
|
function_match: { ...result.function_match, method: "explicit" },
|
|
}).success,
|
|
).toBe(false);
|
|
};
|