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:
parent
5fa7a52d1f
commit
36db19c59c
|
|
@ -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(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
Loading…
Reference in New Issue