Separate source corruption and selected-role errors from unexpected derivation failures. Normalize coordinates before body membership and conflict checks, reuse parsed requests, and account for outer observation arrays before validation copies.
958 lines
30 KiB
TypeScript
958 lines
30 KiB
TypeScript
import { expect, it } from "vitest";
|
|
import { createHash } from "node:crypto";
|
|
import { matchNativeFunctions } from "./NativeFunctionMatchingService.js";
|
|
import { nativeFunctionMatchingSchema } from "../../domain/native/functionMatching.js";
|
|
import { createEvidence } from "../../domain/evidence.js";
|
|
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
|
|
import { functionDossierSchema } from "../../domain/hopperValues.js";
|
|
import { procedureInfoSchema } from "../../domain/native/procedureInfo.js";
|
|
import {
|
|
nativeMatchingInventory,
|
|
nativeMatchingDetails,
|
|
nativeMatchingTarget,
|
|
nativeMatchingProvider,
|
|
} from "../../../tests/fixtures/nativeFunctionMatching.js";
|
|
|
|
const compare = async (input: unknown) => {
|
|
const result = await matchNativeFunctions(input);
|
|
if (!result.ok) throw result.error;
|
|
return {
|
|
...result.value,
|
|
report: nativeFunctionMatchingSchema.parse(
|
|
result.value.evidence.normalized_result,
|
|
),
|
|
};
|
|
};
|
|
|
|
it("retains transitive provenance without treating dependencies as native observations and fails closed for a missing ancestor", async () => {
|
|
const ancestor = createEvidence(undefined, nativeMatchingProvider, {
|
|
operation: "fixture_provenance",
|
|
parameters: {},
|
|
result: { provenance: "Synthetic ancestor" },
|
|
});
|
|
const dependency = createEvidence(undefined, nativeMatchingProvider, {
|
|
operation: "fixture_dependency",
|
|
parameters: {},
|
|
result: { provenance: "Synthetic dependency" },
|
|
evidenceLinks: [ancestor.evidence_id],
|
|
});
|
|
const left = nativeMatchingInventory(
|
|
"1",
|
|
[{ address: "0x10", value: "main" }],
|
|
[dependency.evidence_id],
|
|
);
|
|
const right = nativeMatchingInventory(
|
|
"2",
|
|
[{ address: "0x20", value: "main" }],
|
|
[dependency.evidence_id],
|
|
);
|
|
const dependencies = new Map(
|
|
[ancestor, dependency].map((record) => [record.evidence_id, record]),
|
|
);
|
|
const result = await matchNativeFunctions({ left, right }, undefined, (id) =>
|
|
dependencies.get(id),
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(
|
|
new Set(result.value.sources.map((record) => record.evidence_id)),
|
|
).toEqual(
|
|
new Set(
|
|
[left, right, dependency, ancestor].map((record) => record.evidence_id),
|
|
),
|
|
);
|
|
expect(new Set(result.value.evidence.evidence_links)).toEqual(
|
|
new Set([left.evidence_id, right.evidence_id]),
|
|
);
|
|
expect(
|
|
nativeFunctionMatchingSchema.parse(result.value.evidence.normalized_result)
|
|
.summary.matched,
|
|
).toBe(1);
|
|
const missing = await matchNativeFunctions(
|
|
{ left, right },
|
|
undefined,
|
|
(id) => (id === dependency.evidence_id ? dependency : undefined),
|
|
);
|
|
expect(missing.ok).toBe(false);
|
|
if (missing.ok) throw new Error("Missing ancestor accepted");
|
|
expect(missing.error).toMatchObject({
|
|
evidenceId: ancestor.evidence_id,
|
|
reason: "missing",
|
|
});
|
|
});
|
|
|
|
it("keeps symbol origin unknown when supplied metadata describes a different same-address alias", async () => {
|
|
const left = nativeMatchingInventory("1", [
|
|
{ address: "0x10", value: "reported_name" },
|
|
]);
|
|
const right = nativeMatchingInventory("2", [
|
|
{ address: "0x20", value: "reported_name" },
|
|
]);
|
|
const alias = createEvidence(
|
|
nativeMatchingTarget("1"),
|
|
nativeMatchingProvider,
|
|
{
|
|
operation: "list_names",
|
|
parameters: {},
|
|
result: [
|
|
{
|
|
address: "0x10",
|
|
value: "other_alias",
|
|
symbol: {
|
|
primary: false,
|
|
dynamic: false,
|
|
external: false,
|
|
type: "function",
|
|
source: "user_defined",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
);
|
|
const { report } = await compare({ left, right, left_observations: [alias] });
|
|
expect(report.summary.matched).toBe(1);
|
|
expect(report.matches[0]?.left[0]?.limitations).toContain(
|
|
"Symbol origin unavailable; exact-name matching uses the reported name and the existing automatic-name exclusion rule.",
|
|
);
|
|
expect(report.matches[0]?.left[0]?.evidence_links).toContain(
|
|
alias.evidence_id,
|
|
);
|
|
});
|
|
|
|
it("preserves contradictory symbol origins from inventory/name facts or multiple name observations, independently of byte correspondence", async () => {
|
|
const observe = (operation: string, generated: boolean) =>
|
|
createEvidence(nativeMatchingTarget("1"), nativeMatchingProvider, {
|
|
operation,
|
|
parameters: {},
|
|
result: [
|
|
{
|
|
address: "0x10",
|
|
value: "same_name",
|
|
symbol: {
|
|
primary: true,
|
|
dynamic: generated,
|
|
external: false,
|
|
type: "function",
|
|
source: generated ? "default" : "user_defined",
|
|
},
|
|
},
|
|
],
|
|
});
|
|
const right = nativeMatchingInventory("2", [
|
|
{ address: "0x20", value: "same_name" },
|
|
]);
|
|
for (const [left, names] of [
|
|
[observe("list_procedures", false), [observe("list_names", true)]],
|
|
[observe("list_procedures", true), [observe("list_names", false)]],
|
|
[
|
|
nativeMatchingInventory("1", [{ address: "0x10", value: "same_name" }]),
|
|
[observe("list_names", false), observe("list_names", true)],
|
|
],
|
|
[
|
|
nativeMatchingInventory("1", [{ address: "0x10", value: "same_name" }]),
|
|
[observe("list_names", true), observe("list_names", false)],
|
|
],
|
|
] as const) {
|
|
for (const withBytes of [false, true]) {
|
|
const { report } = await compare({
|
|
left,
|
|
right,
|
|
left_observations: [
|
|
...names,
|
|
...(withBytes ? nativeMatchingDetails("1", "0x10", "c3") : []),
|
|
],
|
|
right_observations: withBytes
|
|
? nativeMatchingDetails("2", "0x20", "c3")
|
|
: [],
|
|
});
|
|
expect(report.summary.matched).toBe(withBytes ? 1 : 0);
|
|
expect(report.matches[0]!.basis).toBe(
|
|
withBytes ? "identical-bytes" : null,
|
|
);
|
|
const member = report.matches[0]!.left[0]!;
|
|
expect(member.limitations).toContain(
|
|
"Conflicting generated and named symbol-origin observations; exact-name matching is unavailable. Independent complete body bytes remain eligible.",
|
|
);
|
|
expect(member.limitations.join(" ")).not.toContain(
|
|
"Symbol origin unavailable",
|
|
);
|
|
for (const source of names)
|
|
expect(member.evidence_links).toContain(source.evidence_id);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("keeps informative CFG/call/string fingerprints as unresolved candidates and refuses incomplete structural observations", async () => {
|
|
const profile = createAnalysisProfile(nativeMatchingProvider, {
|
|
fixture: "structural-candidates-v1",
|
|
});
|
|
const observe = (
|
|
side: "1" | "2",
|
|
operation: string,
|
|
result: Parameters<typeof createEvidence>[2]["result"],
|
|
complete = true,
|
|
) =>
|
|
createEvidence(nativeMatchingTarget(side), nativeMatchingProvider, {
|
|
operation,
|
|
parameters: {},
|
|
result,
|
|
analysisProfile: profile,
|
|
relationshipCoverage: complete
|
|
? { status: "complete" }
|
|
: { status: "unknown", reason: "Missing direct call coverage" },
|
|
});
|
|
const left = observe("1", "list_procedures", [
|
|
{ address: "0x10", value: "old_behavior" },
|
|
]);
|
|
const right = observe("2", "list_procedures", [
|
|
{ address: "0x20", value: "new_behavior" },
|
|
]);
|
|
const dossier = (side: "1" | "2", address: string) => {
|
|
const value = functionDossierSchema.parse(
|
|
nativeMatchingDetails(side, address, "0102", true)[0]!.normalized_result,
|
|
);
|
|
value.basic_blocks = [
|
|
{
|
|
start: address,
|
|
end: `0x${(BigInt(address) + 2n).toString(16)}`,
|
|
successors: [],
|
|
},
|
|
];
|
|
value.callees = [
|
|
{
|
|
address: "0x500",
|
|
name: "puts",
|
|
classification: null,
|
|
body: { available: false, reason: "Synthetic external target" },
|
|
},
|
|
];
|
|
value.unresolved_calls = [];
|
|
value.referenced_strings = [
|
|
{ address: "0x900", value: "marker", source_address: address },
|
|
];
|
|
return value;
|
|
};
|
|
const l = dossier("1", "0x10"),
|
|
r = dossier("2", "0x20");
|
|
const { report } = await compare({
|
|
left,
|
|
right,
|
|
left_observations: [observe("1", "analyze_function", l)],
|
|
right_observations: [observe("2", "analyze_function", r)],
|
|
});
|
|
expect(report.summary).toEqual({
|
|
matched: 0,
|
|
only_left: 0,
|
|
only_right: 0,
|
|
unresolved: 1,
|
|
});
|
|
expect(report.matches[0]?.left[0]?.structural_candidate_sha256).toMatch(
|
|
/^[a-f0-9]{64}$/u,
|
|
);
|
|
expect(report.matches[0]?.left[0]?.structural_candidate_sha256).toBe(
|
|
report.matches[0]?.right[0]?.structural_candidate_sha256,
|
|
);
|
|
expect(report.matches[0]?.compare_functions_input).toBeNull();
|
|
const incomplete = await compare({
|
|
left,
|
|
right,
|
|
left_observations: [observe("1", "analyze_function", l, false)],
|
|
right_observations: [observe("2", "analyze_function", r)],
|
|
});
|
|
expect(incomplete.report.summary.unresolved).toBe(0);
|
|
expect(incomplete.report.source_coverage.left_structural_candidates).toBe(0);
|
|
const legacy = { ...l };
|
|
delete legacy.facet_availability;
|
|
const missingFacets = await compare({
|
|
left,
|
|
right,
|
|
left_observations: [observe("1", "analyze_function", legacy)],
|
|
right_observations: [observe("2", "analyze_function", r)],
|
|
});
|
|
expect(missingFacets.report.source_coverage.left_structural_candidates).toBe(
|
|
0,
|
|
);
|
|
expect(missingFacets.report.summary.unresolved).toBe(0);
|
|
l.callees = [];
|
|
l.referenced_strings = [];
|
|
r.callees = [];
|
|
r.referenced_strings = [];
|
|
const uninformative = await compare({
|
|
left,
|
|
right,
|
|
left_observations: [observe("1", "analyze_function", l)],
|
|
right_observations: [observe("2", "analyze_function", r)],
|
|
});
|
|
expect(uninformative.report.source_coverage.left_structural_candidates).toBe(
|
|
0,
|
|
);
|
|
expect(uninformative.report.summary.unresolved).toBe(0);
|
|
});
|
|
|
|
it("matches symbols before bytes, retains exact renamed bytes, changed pairs, unpaired observations and duplicate candidates", async () => {
|
|
const left = nativeMatchingInventory("1", [
|
|
{ address: "0x1000", value: "stable" },
|
|
{ address: "0x1010", value: "changed" },
|
|
{ address: "0x1020", value: "old_name" },
|
|
{ address: "0x1030", value: "removed_observation" },
|
|
{ address: "0x1040", value: "duplicate" },
|
|
{ address: "0x1050", value: "duplicate" },
|
|
]);
|
|
const right = nativeMatchingInventory("2", [
|
|
{ address: "0x2000", value: "stable" },
|
|
{ address: "0x2010", value: "changed" },
|
|
{ address: "0x2020", value: "renamed" },
|
|
{ address: "0x2030", value: "added_observation" },
|
|
{ address: "0x2040", value: "duplicate" },
|
|
]);
|
|
const result = await compare({
|
|
left,
|
|
right,
|
|
left_observations: [
|
|
...nativeMatchingDetails("1", "0x1010", "0102"),
|
|
...nativeMatchingDetails("1", "0x1020", "030405", true),
|
|
],
|
|
right_observations: [
|
|
...nativeMatchingDetails("2", "0x2010", "aabb"),
|
|
...nativeMatchingDetails("2", "0x2020", "030405", true),
|
|
],
|
|
});
|
|
expect(result.report.summary).toEqual({
|
|
matched: 3,
|
|
only_left: 1,
|
|
only_right: 1,
|
|
unresolved: 1,
|
|
});
|
|
const renamed = result.report.matches.find(
|
|
(row) => row.basis === "identical-bytes",
|
|
)!;
|
|
expect(renamed.left[0]).toMatchObject({
|
|
name: "old_name",
|
|
body_bytes: 3,
|
|
body_sha256: createHash("sha256")
|
|
.update(Buffer.from("030405", "hex"))
|
|
.digest("hex"),
|
|
});
|
|
expect(renamed.right[0]?.name).toBe("renamed");
|
|
expect(renamed.compare_functions_input?.left.evidence_id).toBe(
|
|
renamed.left[0]?.dossier_evidence_id,
|
|
);
|
|
expect(
|
|
result.report.matches.find((row) => row.left[0]?.name === "changed"),
|
|
).toMatchObject({ state: "matched", basis: "exact-symbol" });
|
|
expect(
|
|
result.report.matches.find((row) => row.state === "unresolved"),
|
|
).toMatchObject({
|
|
left: [expect.anything(), expect.anything()],
|
|
right: [expect.anything()],
|
|
});
|
|
expect(new Set(result.evidence.evidence_links)).toEqual(
|
|
new Set(result.sources.map((x) => x.evidence_id)),
|
|
);
|
|
expect(result.report.limitations.join(" ")).toContain("never prove absence");
|
|
});
|
|
|
|
it("does not force repeated identical-byte candidates after an exact name consumes an alias", async () => {
|
|
const left = nativeMatchingInventory("1", [
|
|
{ address: "0x10", value: "known" },
|
|
{ address: "0x20", value: "FUN_20" },
|
|
]);
|
|
const right = nativeMatchingInventory("2", [
|
|
{ address: "0x30", value: "known" },
|
|
{ address: "0x40", value: "FUN_40" },
|
|
]);
|
|
const { report } = await compare({
|
|
left,
|
|
right,
|
|
left_observations: [
|
|
...nativeMatchingDetails("1", "0x10", "c3"),
|
|
...nativeMatchingDetails("1", "0x20", "c3"),
|
|
],
|
|
right_observations: [
|
|
...nativeMatchingDetails("2", "0x30", "c3"),
|
|
...nativeMatchingDetails("2", "0x40", "c3"),
|
|
],
|
|
});
|
|
expect(report.summary).toEqual({
|
|
matched: 1,
|
|
unresolved: 1,
|
|
only_left: 0,
|
|
only_right: 0,
|
|
});
|
|
expect(
|
|
report.matches.find((x) => x.state === "unresolved")?.left[0]?.name,
|
|
).toBe("FUN_20");
|
|
});
|
|
|
|
it("reconstructs discontiguous bodies from adjacent byte reads without next-procedure guesses and keeps address-space/64-bit identity", async () => {
|
|
const address = "ram:0xfffffffffffffff0";
|
|
const left = nativeMatchingInventory("1", [{ address, value: "FUN_f0" }]);
|
|
const right = nativeMatchingInventory("2", [
|
|
{ address: "0x20", value: "FUN_20" },
|
|
]);
|
|
const body = {
|
|
available: true,
|
|
provenance: "synthetic noncontiguous body",
|
|
ranges: [
|
|
{ start: address, end: "ram:0xfffffffffffffff1" },
|
|
{ start: "ram:0xfffffffffffffff8", end: "ram:0xfffffffffffffff8" },
|
|
],
|
|
total_bytes: 3,
|
|
span_bytes: 9,
|
|
non_contiguous: true,
|
|
contains_entry: true,
|
|
};
|
|
const info = createEvidence(
|
|
nativeMatchingTarget("1"),
|
|
nativeMatchingProvider,
|
|
{
|
|
operation: "procedure_info",
|
|
parameters: {},
|
|
result: {
|
|
name: "a",
|
|
entrypoint: address,
|
|
basicblock_count: 1,
|
|
length: 3,
|
|
signature: null,
|
|
locals: [],
|
|
classification: null,
|
|
body,
|
|
},
|
|
},
|
|
);
|
|
const read = (address: string, hex: string) =>
|
|
createEvidence(nativeMatchingTarget("1"), nativeMatchingProvider, {
|
|
operation: "read_bytes",
|
|
parameters: {},
|
|
result: {
|
|
address,
|
|
requested_bytes: hex.length / 2,
|
|
returned_bytes: hex.length / 2,
|
|
bytes_hex: hex,
|
|
complete: true,
|
|
},
|
|
});
|
|
const rightDetails = nativeMatchingDetails("2", "0x20", "010203");
|
|
const { report } = await compare({
|
|
left,
|
|
right,
|
|
left_observations: [
|
|
info,
|
|
read(address, "01"),
|
|
read("ram:0xfffffffffffffff1", "02"),
|
|
read("ram:0xfffffffffffffff8", "03"),
|
|
],
|
|
right_observations: rightDetails,
|
|
});
|
|
// Same concatenated bytes with a different body layout do not become a pair.
|
|
expect(report.summary.matched).toBe(0);
|
|
expect(report.matches[0]?.left[0]).toMatchObject({
|
|
address,
|
|
body_bytes: 3,
|
|
body_sha256: createHash("sha256")
|
|
.update(Buffer.from("010203", "hex"))
|
|
.digest("hex"),
|
|
});
|
|
});
|
|
|
|
it("preserves byte correspondence when consistent dossiers differ only in request identity, without choosing an arbitrary dossier", async () => {
|
|
const left = nativeMatchingInventory("1", [
|
|
{ address: "0x10", value: "old_name" },
|
|
]);
|
|
const right = nativeMatchingInventory("2", [
|
|
{ address: "0x20", value: "new_name" },
|
|
]);
|
|
const leftDetails = nativeMatchingDetails("1", "0x10", "0102", true);
|
|
const another = createEvidence(
|
|
nativeMatchingTarget("1"),
|
|
nativeMatchingProvider,
|
|
{
|
|
operation: "analyze_function",
|
|
parameters: { procedure: "old_name" },
|
|
result: leftDetails[0]!.normalized_result,
|
|
relationshipCoverage: { status: "complete" },
|
|
},
|
|
);
|
|
for (const observations of [
|
|
[...leftDetails, another],
|
|
[another, ...leftDetails],
|
|
]) {
|
|
const { report } = await compare({
|
|
left,
|
|
right,
|
|
left_observations: observations,
|
|
right_observations: nativeMatchingDetails("2", "0x20", "0102", true),
|
|
});
|
|
expect(report.summary.matched).toBe(1);
|
|
expect(report.matches[0]).toMatchObject({
|
|
basis: "identical-bytes",
|
|
compare_functions_input: null,
|
|
left: [
|
|
{
|
|
dossier_evidence_id: null,
|
|
body_bytes: 2,
|
|
body_sha256: createHash("sha256")
|
|
.update(Buffer.from("0102", "hex"))
|
|
.digest("hex"),
|
|
},
|
|
],
|
|
});
|
|
expect(report.matches[0]!.left[0]!.evidence_links).toContain(
|
|
another.evidence_id,
|
|
);
|
|
}
|
|
const conflicting = nativeMatchingDetails("1", "0x10", "010203", true)[0]!;
|
|
const { report } = await compare({
|
|
left,
|
|
right,
|
|
left_observations: [...leftDetails, conflicting],
|
|
right_observations: nativeMatchingDetails("2", "0x20", "0102", true),
|
|
});
|
|
expect(report.summary.matched).toBe(0);
|
|
expect(report.matches[0]!.left[0]!.body_sha256).toBeNull();
|
|
});
|
|
|
|
it("rejects repeated per-occurrence source-link expansion within the shared representation budget", async () => {
|
|
const base = nativeMatchingDetails("1", "0x10", "c3")[0]!;
|
|
const observations = Array.from({ length: 250 }, (_, observation) =>
|
|
createEvidence(nativeMatchingTarget("1"), nativeMatchingProvider, {
|
|
operation: "procedure_info",
|
|
parameters: { procedure: "0x10", observation },
|
|
result: base.normalized_result,
|
|
}),
|
|
);
|
|
const result = await matchNativeFunctions({
|
|
left: nativeMatchingInventory(
|
|
"1",
|
|
Array.from({ length: 1000 }, (_, i) => ({
|
|
address: "0x10",
|
|
value: `alias_${i}`,
|
|
})),
|
|
),
|
|
right: nativeMatchingInventory("2", []),
|
|
left_observations: observations,
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok)
|
|
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
|
|
});
|
|
|
|
it("retains missing bytes and conflicting overlapping observations as unknown instead of exact matches", async () => {
|
|
const left = nativeMatchingInventory("1", [
|
|
{ address: "0x10", value: "FUN_10" },
|
|
]);
|
|
const right = nativeMatchingInventory("2", [
|
|
{ address: "0x20", value: "FUN_20" },
|
|
]);
|
|
const observations = [
|
|
...nativeMatchingDetails("1", "0x10", "0102"),
|
|
nativeMatchingDetails("1", "0x10", "ffff")[1]!,
|
|
];
|
|
const { report } = await compare({
|
|
left,
|
|
right,
|
|
left_observations: observations,
|
|
right_observations: nativeMatchingDetails("2", "0x20", "0102"),
|
|
});
|
|
expect(report.summary.matched).toBe(0);
|
|
expect(report.matches[0]?.left[0]?.body_sha256).toBeNull();
|
|
});
|
|
|
|
it("composes consistent overlapping and partial byte reads without losing complete body coverage", async () => {
|
|
const left = nativeMatchingInventory("1", [
|
|
{ address: "0x10", value: "FUN_10" },
|
|
]);
|
|
const right = nativeMatchingInventory("2", [
|
|
{ address: "0x20", value: "FUN_20" },
|
|
]);
|
|
const info = nativeMatchingDetails("1", "0x10", "01020304")[0]!;
|
|
const read = (address: string, hex: string, requested = hex.length / 2) =>
|
|
createEvidence(nativeMatchingTarget("1"), nativeMatchingProvider, {
|
|
operation: "read_bytes",
|
|
parameters: { address, length: requested },
|
|
result: {
|
|
address,
|
|
requested_bytes: requested,
|
|
returned_bytes: hex.length / 2,
|
|
bytes_hex: hex,
|
|
complete: requested === hex.length / 2,
|
|
},
|
|
});
|
|
const { report } = await compare({
|
|
left,
|
|
right,
|
|
left_observations: [
|
|
info,
|
|
read("0x10", "010203", 6),
|
|
read("0x11", "020304"),
|
|
],
|
|
right_observations: nativeMatchingDetails("2", "0x20", "01020304"),
|
|
});
|
|
expect(report.summary.matched).toBe(1);
|
|
expect(report.source_coverage).toMatchObject({
|
|
status: "complete-within-inputs",
|
|
left_byte_fingerprints: 1,
|
|
right_byte_fingerprints: 1,
|
|
left_inventory_completeness: "unknown",
|
|
});
|
|
});
|
|
|
|
it("does not use generated symbols and preserves literal named FUN_ symbols when producer metadata proves their origin", async () => {
|
|
const entries = [
|
|
{ address: "0x10", value: "FUN_10" },
|
|
{ address: "0x20", value: "apparently_named" },
|
|
];
|
|
const names = (side: "1" | "2") =>
|
|
createEvidence(nativeMatchingTarget(side), nativeMatchingProvider, {
|
|
operation: "list_names",
|
|
parameters: {},
|
|
result: entries.map((entry, i) => ({
|
|
...entry,
|
|
symbol: {
|
|
primary: true,
|
|
dynamic: i === 1,
|
|
external: false,
|
|
type: "function",
|
|
source: i === 0 ? "user_defined" : "default",
|
|
},
|
|
})),
|
|
});
|
|
const { report } = await compare({
|
|
left: nativeMatchingInventory("1", entries),
|
|
right: nativeMatchingInventory("2", entries),
|
|
left_observations: [names("1")],
|
|
right_observations: [names("2")],
|
|
});
|
|
expect(report.summary.matched).toBe(1);
|
|
expect(report.matches[0]?.left[0]?.name).toBe("FUN_10");
|
|
expect(report.matches[0]?.left[0]?.evidence_links).toContain(
|
|
names("1").evidence_id,
|
|
);
|
|
});
|
|
|
|
it("assigns source authentication, role selection, context, and lookup failures to their owning boundary", async () => {
|
|
const left = nativeMatchingInventory("1", [
|
|
{ address: "0x10", value: "main" },
|
|
]);
|
|
const cases = [
|
|
{
|
|
input: { left: { ...left, normalized_result: [] }, right: left },
|
|
tag: "EvidenceIntegrityError",
|
|
},
|
|
{
|
|
input: { left: nativeMatchingDetails("1", "0x10", "c3")[0], right: left },
|
|
tag: "AnalysisInputError",
|
|
path: ["left"],
|
|
},
|
|
{
|
|
input: {
|
|
left,
|
|
right: left,
|
|
left_observations: nativeMatchingDetails("2", "0x10", "c3"),
|
|
},
|
|
tag: "EvidenceIntegrityError",
|
|
},
|
|
{
|
|
input: {
|
|
left: nativeMatchingInventory("1", [
|
|
{ address: "not-an-address", value: "main" },
|
|
]),
|
|
right: left,
|
|
},
|
|
tag: "EvidenceIntegrityError",
|
|
},
|
|
{
|
|
input: { left, right: left, left_observations: [left] },
|
|
tag: "AnalysisInputError",
|
|
path: ["left_observations", 0],
|
|
},
|
|
{
|
|
input: {
|
|
left,
|
|
right: left,
|
|
left_observations: [
|
|
createEvidence(nativeMatchingTarget("1"), nativeMatchingProvider, {
|
|
operation: "procedure_info",
|
|
parameters: { procedure: "0x10" },
|
|
result: {
|
|
...procedureInfoSchema.parse(
|
|
nativeMatchingDetails("1", "0x10", "c3")[0]!.normalized_result,
|
|
),
|
|
entrypoint: "not-an-address",
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
tag: "EvidenceIntegrityError",
|
|
},
|
|
{
|
|
input: {
|
|
left,
|
|
right: left,
|
|
left_observations: [
|
|
{ kind: "retained-evidence", evidence_id: `ev_${"a".repeat(64)}` },
|
|
],
|
|
},
|
|
tag: "EvidenceIntegrityError",
|
|
evidenceId: `ev_${"a".repeat(64)}`,
|
|
reason: "missing",
|
|
},
|
|
] as const;
|
|
for (const { input, tag, ...expected } of cases) {
|
|
const result = await matchNativeFunctions(input);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error._tag).toBe(tag);
|
|
if ("path" in expected) {
|
|
expect(result.error).toMatchObject({
|
|
issues: expect.arrayContaining([
|
|
expect.objectContaining({ path: expected.path }),
|
|
]),
|
|
});
|
|
}
|
|
if ("evidenceId" in expected)
|
|
expect(result.error).toMatchObject({
|
|
evidenceId: expected.evidenceId,
|
|
reason: expected.reason,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
it("cooperatively preserves the exact cancellation reason and rejects expansion before cloning oversized input", async () => {
|
|
const controller = new AbortController(),
|
|
reason = new Error("stop matching");
|
|
const left = nativeMatchingInventory(
|
|
"1",
|
|
Array.from({ length: 2000 }, (_, i) => ({
|
|
address: `0x${i.toString(16)}`,
|
|
value: `item${i}`,
|
|
})),
|
|
);
|
|
const pending = matchNativeFunctions(
|
|
{ left, right: left },
|
|
{ signal: controller.signal },
|
|
);
|
|
setImmediate(() => controller.abort(reason));
|
|
const cancelled = await pending;
|
|
expect(cancelled.ok).toBe(false);
|
|
if (!cancelled.ok) {
|
|
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
|
|
expect(cancelled.error.cause).toBe(reason);
|
|
}
|
|
const large = await matchNativeFunctions({
|
|
left: { padding: "x".repeat(26 * 1024 * 1024) },
|
|
right: left,
|
|
});
|
|
expect(large.ok).toBe(false);
|
|
if (!large.ok)
|
|
expect(large.error._tag).toBe("AnalysisResourceConstraintError");
|
|
});
|
|
|
|
it("keeps duplicate candidate storage linear and emits every input occurrence exactly once", async () => {
|
|
const entries = Array.from({ length: 1000 }, (_, i) => ({
|
|
address: `0x${i.toString(16)}`,
|
|
value: "same",
|
|
}));
|
|
const { report } = await compare({
|
|
left: nativeMatchingInventory("1", entries),
|
|
right: nativeMatchingInventory("2", entries),
|
|
});
|
|
expect(report.matches).toHaveLength(1);
|
|
expect(report.matches[0]?.left).toHaveLength(1000);
|
|
expect(report.matches[0]?.right).toHaveLength(1000);
|
|
expect(new Set(report.matches[0]?.left.map((x) => x.function_id)).size).toBe(
|
|
1000,
|
|
);
|
|
});
|
|
|
|
it("normalizes accepted body range address spaces before matching byte coverage", async () => {
|
|
const body = {
|
|
available: true as const,
|
|
provenance: "synthetic mixed-space body",
|
|
ranges: [
|
|
{ start: "ram:0x10", end: "ram:0x10" },
|
|
{ start: "other%ab:0x20", end: "other%ab:0x20" },
|
|
],
|
|
total_bytes: 2,
|
|
span_bytes: null,
|
|
non_contiguous: true,
|
|
contains_entry: true,
|
|
};
|
|
const info = createEvidence(
|
|
nativeMatchingTarget("1"),
|
|
nativeMatchingProvider,
|
|
{
|
|
operation: "procedure_info",
|
|
parameters: { procedure: "ram:0x10" },
|
|
result: {
|
|
name: "main",
|
|
entrypoint: "ram:0x10",
|
|
basicblock_count: 1,
|
|
length: 2,
|
|
signature: null,
|
|
locals: [],
|
|
classification: null,
|
|
body,
|
|
},
|
|
},
|
|
);
|
|
const read = (address: string, value: string) =>
|
|
createEvidence(nativeMatchingTarget("1"), nativeMatchingProvider, {
|
|
operation: "read_bytes",
|
|
parameters: {},
|
|
result: {
|
|
address,
|
|
requested_bytes: 1,
|
|
returned_bytes: 1,
|
|
bytes_hex: value,
|
|
complete: true,
|
|
},
|
|
});
|
|
const { report } = await compare({
|
|
left: nativeMatchingInventory("1", [
|
|
{ address: "ram:0x10", value: "main" },
|
|
]),
|
|
right: nativeMatchingInventory("2", [{ address: "0x30", value: "main" }]),
|
|
left_observations: [
|
|
info,
|
|
read("ram:0x10", "01"),
|
|
read("other%AB:0x20", "02"),
|
|
],
|
|
});
|
|
expect(report.matches[0]?.left[0]?.body_sha256).toBe(
|
|
createHash("sha256").update(Buffer.from("0102", "hex")).digest("hex"),
|
|
);
|
|
});
|
|
|
|
it("compares repeated bodies and entry membership after address-space normalization", async () => {
|
|
const procedure = (entrypoint: string, rangeAddress: string) =>
|
|
createEvidence(nativeMatchingTarget("1"), nativeMatchingProvider, {
|
|
operation: "procedure_info",
|
|
parameters: { procedure: entrypoint },
|
|
result: {
|
|
name: "main",
|
|
entrypoint,
|
|
basicblock_count: 1,
|
|
length: 2,
|
|
signature: null,
|
|
locals: [],
|
|
classification: null,
|
|
body: {
|
|
available: true,
|
|
provenance: "synthetic escaped-space body",
|
|
ranges: [
|
|
{
|
|
start: `${rangeAddress}:0x10`,
|
|
end: `${rangeAddress}:0x11`,
|
|
},
|
|
],
|
|
total_bytes: 2,
|
|
span_bytes: 2,
|
|
non_contiguous: false,
|
|
contains_entry: true,
|
|
},
|
|
},
|
|
});
|
|
const first = procedure("ram%AB:0x10", "ram%ab");
|
|
const equivalent = procedure("ram%ab:0x10", "ram%AB");
|
|
const bytes = createEvidence(
|
|
nativeMatchingTarget("1"),
|
|
nativeMatchingProvider,
|
|
{
|
|
operation: "read_bytes",
|
|
parameters: { address: "ram%AB:0x10", length: 2 },
|
|
result: {
|
|
address: "ram%AB:0x10",
|
|
requested_bytes: 2,
|
|
returned_bytes: 2,
|
|
bytes_hex: "0102",
|
|
complete: true,
|
|
},
|
|
},
|
|
);
|
|
const { report } = await compare({
|
|
left: nativeMatchingInventory("1", [
|
|
{ address: "ram%AB:0x10", value: "main" },
|
|
]),
|
|
right: nativeMatchingInventory("2", [{ address: "0x20", value: "main" }]),
|
|
left_observations: [first, equivalent, bytes],
|
|
});
|
|
const member = report.matches[0]?.left[0];
|
|
expect(member?.body_sha256).toBe(
|
|
createHash("sha256").update(Buffer.from("0102", "hex")).digest("hex"),
|
|
);
|
|
expect(member?.limitations).not.toContain(
|
|
"Conflicting procedure observations; body and dossier remain unknown.",
|
|
);
|
|
expect(member?.evidence_links).toEqual(
|
|
expect.arrayContaining([first.evidence_id, equivalent.evidence_id]),
|
|
);
|
|
});
|
|
|
|
it("retains zero-byte reads with unavailable addresses when no request coordinate exists", async () => {
|
|
const info = createEvidence(
|
|
nativeMatchingTarget("1"),
|
|
nativeMatchingProvider,
|
|
{
|
|
operation: "procedure_info",
|
|
parameters: { procedure: "0x10" },
|
|
result: {
|
|
name: "main",
|
|
entrypoint: "0x10",
|
|
basicblock_count: 1,
|
|
length: 1,
|
|
signature: null,
|
|
locals: [],
|
|
classification: null,
|
|
body: {
|
|
available: true,
|
|
provenance: "synthetic body",
|
|
ranges: [{ start: "0x10", end: "0x10" }],
|
|
total_bytes: 1,
|
|
span_bytes: 1,
|
|
non_contiguous: false,
|
|
contains_entry: true,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
const emptyRead = createEvidence(
|
|
nativeMatchingTarget("1"),
|
|
nativeMatchingProvider,
|
|
{
|
|
operation: "read_bytes",
|
|
parameters: {},
|
|
result: {
|
|
address: "unavailable",
|
|
requested_bytes: 1,
|
|
returned_bytes: 0,
|
|
bytes_hex: "",
|
|
complete: false,
|
|
},
|
|
},
|
|
);
|
|
const left = nativeMatchingInventory("1", [
|
|
{ address: "0x10", value: "main" },
|
|
]);
|
|
const right = nativeMatchingInventory("2", [
|
|
{ address: "0x20", value: "main" },
|
|
]);
|
|
const result = await matchNativeFunctions({
|
|
left,
|
|
right,
|
|
left_observations: [info, emptyRead],
|
|
});
|
|
if (!result.ok) throw result.error;
|
|
const report = nativeFunctionMatchingSchema.parse(
|
|
result.value.evidence.normalized_result,
|
|
);
|
|
expect(report.matches[0]?.left[0]).toMatchObject({
|
|
body_bytes: 1,
|
|
body_sha256: null,
|
|
});
|
|
expect(result.value.evidence.evidence_links).toContain(emptyRead.evidence_id);
|
|
expect(result.value.sources.map((source) => source.evidence_id)).toContain(
|
|
emptyRead.evidence_id,
|
|
);
|
|
});
|