fix(providers)!: preserve unknown segment extents and initialized IDA readiness

BREAKING CHANGE: binary_overview segment length is null when Hopper coordinates do not establish a finite same-address-space extent. Consumers must preserve this unknown instead of treating it as zero or a saturated value.
This commit is contained in:
morluto 2026-10-14 09:47:28 +08:00
parent 5fa7a52d1f
commit 36db19c59c
6 changed files with 79 additions and 14 deletions

View File

@ -367,7 +367,7 @@ export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
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(),
}),

View File

@ -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,

View File

@ -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());

View File

@ -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 =

View File

@ -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<Record<string, JsonValue>>,
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<JsonValue> {

View File

@ -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 = (