diff --git a/src/application/native/NativeFunctionMatchingService.test.ts b/src/application/native/NativeFunctionMatchingService.test.ts index 060ab34959..0de9978d11 100644 --- a/src/application/native/NativeFunctionMatchingService.test.ts +++ b/src/application/native/NativeFunctionMatchingService.test.ts @@ -181,112 +181,132 @@ it("preserves contradictory symbol origins from inventory/name facts or multiple } }); -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[2]["result"], - complete = true, - ) => - createEvidence(nativeMatchingTarget(side), nativeMatchingProvider, { - operation, - parameters: {}, - result, - analysisProfile: profile, - relationshipCoverage: complete - ? { status: "complete" } - : { status: "unknown", reason: "Missing direct call coverage" }, +it.each(["", "ram:"])( + "keeps informative %s CFG/call/string fingerprints as unresolved candidates and refuses incomplete structural observations", + async (space) => { + const profile = createAnalysisProfile(nativeMatchingProvider, { + fixture: "structural-candidates-v1", }); - 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, + const observe = ( + side: "1" | "2", + operation: string, + result: Parameters[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: `${space}0x10`, value: "old_behavior" }, + ]); + const right = observe("2", "list_procedures", [ + { address: `${space}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 }, + ]; + value.procedure.address = `${space}${address}`; + if (value.procedure.body.available) + value.procedure.body.ranges = value.procedure.body.ranges.map( + (range) => ({ + start: `${space}${range.start}`, + end: `${space}${range.end}`, + }), + ); + value.basic_blocks = value.basic_blocks.map((block) => ({ + start: `${space}${block.start}`, + end: `${space}${block.end}`, + successors: block.successors.map((successor) => `${space}${successor}`), + })); + value.referenced_strings[0]!.source_address = `${space}${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, ); - 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); -}); + 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", [ diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index 4ebec103f6..bf999f5ef4 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -630,6 +630,65 @@ describe("function comparison ordering controls", () => { }); describe("function comparison CFG address normalization", () => { + it.each([ + { start: "ram:0x1000", end: "rom:0x1004", successors: [] }, + { start: "ram:0x1000", end: "ram:0x1004", successors: ["rom:0x1000"] }, + { start: "ram:0x1000", end: "ram:0x1004", successors: ["0x1000"] }, + { start: "ram::0x1000", end: "ram::0x1004", successors: [] }, + ])("keeps incompatible CFG coordinates unknown: %j", (block) => { + const value = functionDossierSchema.parse({ + ...dossier("return 0;", "0x1000"), + basic_blocks: [block], + }); + const source = functionSnapshotFixture(observe("a", value)); + expect( + compareFunctions(source, source).dimensions.find( + ({ dimension }) => dimension === "cfg", + ), + ).toMatchObject({ status: "unknown" }); + }); + + it("keeps disconnected blocks in different spaces unknown", () => { + const value = functionDossierSchema.parse({ + ...dossier("return 0;", "0x1000"), + basic_blocks: [ + { start: "ram:0x1000", end: "ram:0x1004", successors: [] }, + { start: "rom:0x2000", end: "rom:0x2004", successors: [] }, + ], + }); + const source = functionSnapshotFixture(observe("a", value)); + expect( + compareFunctions(source, source).dimensions.find( + ({ dimension }) => dimension === "cfg", + ), + ).toMatchObject({ status: "unknown" }); + }); + + it.each(["rom:0x1004", "RAM:0x1004", "0x1004", "ram:0x0fff"])( + "keeps non-relative comment and reference sites unknown: %s", + (site) => { + const value = functionDossierSchema.parse({ + ...dossier("return 0;", "0x1000"), + procedure: { + ...procedure("ram:0x1000", "main"), + signature: null, + locals: [], + }, + comments: [{ address: site, kind: "comment", text: "marker" }], + referenced_strings: [ + { address: "rom:0x9000", source_address: site, value: "marker" }, + ], + }); + const source = functionSnapshotFixture(observe("a", value)); + for (const dimension of ["comments", "strings_names"]) + expect( + compareFunctions(source, source).dimensions.find( + (item) => item.dimension === dimension, + ), + ).toMatchObject({ status: "unknown" }); + }, + ); + it("matches CFG successors by numeric address", () => { const make = (base: "0x1000" | "0x2000") => functionDossierSchema.parse({ diff --git a/src/domain/functionComparisonNormalization.ts b/src/domain/functionComparisonNormalization.ts index 4b980dc5dd..f0e1cd9c60 100644 --- a/src/domain/functionComparisonNormalization.ts +++ b/src/domain/functionComparisonNormalization.ts @@ -1,5 +1,6 @@ import { canonicalJson } from "./comparisonSemantics.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; +import { nativeAddressCoordinateInputSchema } from "./native/nativeAddress.js"; import type { FunctionSnapshot } from "./functionDossierEvidence.js"; import type { FunctionComparisonResult } from "./functionComparisonSchemas.js"; @@ -42,11 +43,20 @@ export const normalizeCfg = ( start: bigint; end: bigint; }[] = []; + let space: string | null | undefined; for (const block of blocks) { const start = parseAddress(block.start); const end = parseAddress(block.end); - if (start === null || end === null || end <= start) return null; - parsed.push({ block, start, end }); + if ( + start === null || + end === null || + start.space !== end.space || + (space !== undefined && start.space !== space) || + end.offset <= start.offset + ) + return null; + space = start.space; + parsed.push({ block, start: start.offset, end: end.offset }); } const ordered = parsed.sort((left, right) => left.start < right.start ? -1 : left.start > right.start ? 1 : 0, @@ -59,8 +69,8 @@ export const normalizeCfg = ( const successors: number[] = []; for (const address of block.successors) { const parsedAddress = parseAddress(address); - if (parsedAddress === null) return null; - const index = indices.get(parsedAddress); + if (parsedAddress === null || parsedAddress.space !== space) return null; + const index = indices.get(parsedAddress.offset); if (index === undefined) return null; successors.push(index); } @@ -205,13 +215,18 @@ export const sorted = (values: readonly T[]): readonly T[] => export const isAutoName = (name: string): boolean => /^(?:sub_[0-9a-f]+|fcn\.[0-9a-f]+|FUN_[0-9a-f]+)$/iu.test(name); -const parseAddress = (value: string): bigint | null => - /^0x[0-9a-f]+$/iu.test(value) ? BigInt(value) : null; +const parseAddress = (value: string) => { + const parsed = nativeAddressCoordinateInputSchema.safeParse(value); + return parsed.success ? parsed.data : null; +}; const relativeAddress = (value: string, base: string): string | null => { const parsed = parseAddress(value); const parsedBase = parseAddress(base); - return parsed === null || parsedBase === null || parsed < parsedBase + return parsed === null || + parsedBase === null || + parsed.space !== parsedBase.space || + parsed.offset < parsedBase.offset ? null - : String(parsed - parsedBase); + : String(parsed.offset - parsedBase.offset); }; diff --git a/src/domain/native/nativeAddress.ts b/src/domain/native/nativeAddress.ts index 59c014d3c8..cfc362c8ae 100644 --- a/src/domain/native/nativeAddress.ts +++ b/src/domain/native/nativeAddress.ts @@ -12,14 +12,27 @@ export const nativeAddressSchema = z /^(?:(?:[A-Za-z0-9._~\x2d]|%[0-9A-F]{2})+:)?0x(?:0|[1-9a-f][0-9a-f]*)$/u, ); -/** Accept equivalent hexadecimal spellings without changing address-space identity. */ -export const nativeAddressInputSchema = z +/** Parse caller coordinates once, retaining address-space identity and numeric offset. */ +export const nativeAddressCoordinateInputSchema = z .string() .regex(/^(?:(?:[A-Za-z0-9._~\x2d]|%[0-9a-fA-F]{2})+:)?0[xX][0-9a-fA-F]+$/u) .transform((address) => { const separator = address.lastIndexOf(":") + 1; - const space = address - .slice(0, separator) - .replace(/%[0-9a-fA-F]{2}/gu, (escape) => escape.toUpperCase()); - return `${space}0x${BigInt(address.slice(separator)).toString(16)}`; + const space = + separator === 0 + ? null + : address + .slice(0, separator - 1) + .replace(/%[0-9a-fA-F]{2}/gu, (escape) => escape.toUpperCase()); + return { + space, + offset: BigInt(address.slice(separator)), + }; }); + +/** Accept equivalent hexadecimal spellings without changing address-space identity. */ +export const nativeAddressInputSchema = + nativeAddressCoordinateInputSchema.transform( + ({ space, offset }) => + `${space === null ? "" : `${space}:`}0x${offset.toString(16)}`, + ); diff --git a/tests/boundary/mcp/functionComparisonMcp.test.ts b/tests/boundary/mcp/functionComparisonMcp.test.ts index d44031898b..ec99701d45 100644 --- a/tests/boundary/mcp/functionComparisonMcp.test.ts +++ b/tests/boundary/mcp/functionComparisonMcp.test.ts @@ -17,6 +17,96 @@ import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { createServer } from "../../../src/server/createServer.js"; import { observed } from "../../fixtures/analysisExecution.js"; import { reconstructionVerificationResultSchema } from "../../../src/domain/reconstructionVerificationSchemas.js"; +import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; + +it.each([ + { leftSpace: "ram", rightSpace: "ram" }, + { leftSpace: "overlay%2f1", rightSpace: "overlay%2F1" }, + { leftSpace: "ram", rightSpace: "rom" }, +])( + "compares qualified function coordinates and retains their source spelling: $leftSpace", + async ({ leftSpace, rightSpace }) => { + const session = createTestBinarySession(() => { + throw new Error( + "Retained function comparison must not launch a provider", + ); + }); + const harness = await createApplicationMcpHarness({ session }); + onTestFinished(harness.close); + const source = (side: "1" | "2", space: string) => { + const example = FUNCTION_COMPARISON_EXAMPLE.left; + const value = functionDossierSchema.parse(example.normalized_result); + const entry = `${space}:0x${side}000`; + const site = `${space}:0X0${side}004`; + value.procedure.address = entry; + value.basic_blocks = [ + { start: entry, end: site, successors: [site] }, + { + start: `${space}:0x${side}004`, + end: `${space}:0x${side}008`, + successors: [], + }, + ]; + value.comments = [{ address: site, kind: "comment", text: "marker" }]; + value.referenced_strings = [ + { address: "rom:0x9000", source_address: site, value: "marker" }, + ]; + value.referenced_names = [ + { address: "rom:0xa000", source_address: site, value: "helper" }, + ]; + value.swift_string_literals = [ + { + source_address: site, + value: "swift marker", + representation: "small", + address: null, + registers: ["x0", "x1"], + words: ["0x0", "0xe"], + }, + ]; + return createEvidence( + { + path: `/fixture/function-${side}`, + sha256: side.repeat(64), + format: "elf", + }, + example.provider, + { + operation: "analyze_function", + parameters: { procedure: entry }, + result: jsonValueSchema.parse(value), + confidence: "derived", + relationshipCoverage: { status: "complete" }, + }, + ); + }; + const left = source("1", leftSpace); + const right = source("2", rightSpace); + const response = await harness.client.callTool({ + name: "compare_functions", + arguments: { left, right }, + }); + expect( + response.isError, + JSON.stringify(response.structuredContent), + ).not.toBe(true); + const comparison = parseEvidence(response.structuredContent); + expect(comparison.normalized_result).toMatchObject({ + dimensions: expect.arrayContaining( + ["cfg", "comments", "strings_names"].map((dimension) => + expect.objectContaining({ dimension, status: "unchanged" }), + ), + ), + }); + const retained = session.exportEvidenceBundle(); + for (const original of [left, right]) + expect( + retained.records.find( + ({ evidence_id }) => evidence_id === original.evidence_id, + ), + ).toEqual(original); + }, +); it.each([ { comparisonStatus: "unchanged", verificationStatus: "pass" },