diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index bbadef367..d68512d49 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -367,7 +367,7 @@ export const enhancedOutputSchemas: Readonly> = { name: z.string(), start: z.string(), end: z.string(), - length: z.number().min(0), + length: z.number().min(0).nullable(), image_base: z.string().exactOptional(), address_space: z.string().exactOptional(), }), diff --git a/src/domain/hopperValues.test.ts b/src/domain/hopperValues.test.ts index 7764e330e..493d58122 100644 --- a/src/domain/hopperValues.test.ts +++ b/src/domain/hopperValues.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { functionBodySchema, + addressDistance, procedureIdentitySchema, parseDocuments, parseListCount, @@ -12,6 +13,15 @@ import { } from "./hopperValues.js"; describe("Hopper boundary values", () => { + it("distinguishes measured zero, malformed, incomparable, reversed, and oversized extents", () => { + expect(addressDistance("not-an-address", "0x2000")).toBeNull(); + expect(addressDistance("ram:0x1000", "rom:0x2000")).toBeNull(); + expect(addressDistance("0x2000", "0x1000")).toBeNull(); + expect(addressDistance("0x1000", "0x1000")).toBe(0); + expect(addressDistance("0x1000", "0x2000")).toBe(4096); + expect(addressDistance("0x0", "0x20000000000000")).toBeNull(); + }); + it("parses Hopper's address-keyed name and string maps", () => { expect(parseNames({ "0x1000": "_main" })).toEqual({ ok: true, diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index 6596d0d7b..e346ac59f 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -25,17 +25,14 @@ export interface SegmentSummary { } /** Return a bounded byte distance between canonical addresses in one space. */ -export const addressDistance = (start: string, end: string): number => { +export const addressDistance = (start: string, end: string): number | null => { const left = addressCoordinate(start); const right = addressCoordinate(end); - if (left === null || right === null || left.space !== right.space) return 0; + if (left === null || right === null || left.space !== right.space) + return null; const distance = right.offset - left.offset; - if (distance <= 0n) return 0; - return Number( - distance > BigInt(Number.MAX_SAFE_INTEGER) - ? BigInt(Number.MAX_SAFE_INTEGER) - : distance, - ); + if (distance < 0n || distance > BigInt(Number.MAX_SAFE_INTEGER)) return null; + return Number(distance); }; const procedureMapSchema = z.record(z.string(), z.string()); diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index 3d0feeed2..c6bffd2e0 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -153,7 +153,28 @@ describe("headless IDA producer pagination", () => { }); }); -describe("attached IDA observation semantics", () => { +describe("attached IDA readiness and queued cancellation", () => { + it("allows ready-only work after an attached provider has initialized", async () => { + const { producer, client } = await fixture("attached"); + await expect(client.executeIfReady("list_procedures", {})).resolves.toBe( + undefined, + ); + + const startup = await client.execute("list_procedures", {}); + if (!startup.ok) throw startup.error; + const result = await client.executeIfReady("list_procedures", {}); + if (result === undefined) + throw new Error("expected ready attached IDA dispatch"); + if (!result.ok) throw result.error; + expect(result.value.result).toEqual([ + expect.objectContaining({ address: "0x1000", value: "main" }), + ]); + expect( + producer.calls.filter(({ name }) => name === "idb_open"), + ).toHaveLength(0); + await client.close(); + }); + it("returns cold and pending startup calls immediately, then cancels queued work before dispatch", async () => { const { producer, client } = await fixture("headless"); await expect(client.executeIfReady("list_procedures", {})).resolves.toBe( @@ -233,7 +254,9 @@ describe("attached IDA observation semantics", () => { ); await client.close(); }); +}); +describe("attached IDA input identity and validation", () => { it("accepts a normalized spelling of the bound document path", async () => { const { target, producer, client } = await fixture(); const equivalent = diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index d157b5209..3dac1112e 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -162,6 +162,7 @@ export class IdaSessionClient implements AnalysisClient { #databaseCloseCompleted = false; #openAttempted = false; #openCompleted = false; + #ready = false; #blockingSessions: readonly UpstreamSession[] | undefined; #openRejected = false; #callGraphAdvertised = false; @@ -197,11 +198,10 @@ export class IdaSessionClient implements AnalysisClient { parameters: Readonly>, options?: ExecutionOptions, ) { - if (!this.#openCompleted || this.#closed || this.#closing) + if (!this.#ready || this.#closed || this.#closing) return Promise.resolve(undefined); const call = this.#tail.then(() => { - if (!this.#openCompleted || this.#closed || this.#closing) - return undefined; + if (!this.#ready || this.#closed || this.#closing) return undefined; if (wasCancelled(options)) return err(new AnalysisCancelledError(operation)); return this.#execute(operation, parameters, options, true); @@ -601,6 +601,7 @@ export class IdaSessionClient implements AnalysisClient { ); this.#database = opened.session.session_id; } + this.#ready = true; } async #nativeMetadata(): Promise { diff --git a/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts b/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts index c2c0c52e9..ab4760ca3 100644 --- a/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts +++ b/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts @@ -135,7 +135,7 @@ const completeOverviewPort: AnalysisOperationPort = { }, }; -describe("enhanced MCP tools", () => { +describe("enhanced MCP binary overview", () => { it("returns the complete overview inline with exhaustive totals", async () => { const client = await connect(completeOverviewPort); const result = jsonResult( @@ -210,7 +210,9 @@ describe("enhanced MCP tools", () => { string_count: 0, }); }); +}); +describe("enhanced MCP inventory validation", () => { it("rejects paginated provider output", async () => { const client = await connect({ execute: () => @@ -262,6 +264,38 @@ describe("enhanced MCP tools", () => { text: JSON.stringify(parseMcpToolError(result)), }); }); + + it("preserves unknown length for malformed segment coordinates", async () => { + const client = await connect({ + execute: (name) => { + if (name === "list_segments") + return Promise.resolve( + ok([ + { + name: "__TEXT", + start: "unparsed-start", + end: "0x2000", + readable: null, + writable: null, + executable: null, + }, + ]), + ); + if (name === "current_document") return Promise.resolve(ok("fixture")); + if (name === "list_procedures" || name === "list_strings") + return Promise.resolve(ok([])); + throw new Error(`Unexpected analysis operation: ${name}`); + }, + }); + expect( + jsonResult( + await client.callTool({ name: "binary_overview", arguments: {} }), + ), + ).toMatchObject({ + segments: [{ name: "__TEXT", start: "unparsed-start", length: null }], + segment_count: 1, + }); + }); }); const overviewPort = (