test(ghidra): preserve address alias restoration failures (#3060)

Cover shared DOS/COM alias restoration after uncertain writes, rejected edits, incorrect readback and simultaneous failures. Reuse the upstream producer restoration unchanged; preserve exact observed naming and immutable input protections.

Closes #2924

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
YsopwnableandKaoru0822 authored and GitHub committed 2026-10-14 14:33:26 +08:00
1 parent e5791229f3
commit 4190630432
2 files changed
+146

No files matched your search

@@ -0,0 +1,8 @@
import type { Client } from "@modelcontextprotocol/client";
/** Verify segmented aliases and restore the entry's observed public name. */
export function verifyGhidraAddressBoundaries(
client: Pick<Client, "callTool">,
entry: string,
cli: (command: string, address: string, flags: string[]) => Promise<unknown>,
): Promise<void>;
@@ -0,0 +1,138 @@
import type { Client } from "@modelcontextprotocol/client";
import { expect, it } from "vitest";
import { verifyGhidraAddressBoundaries } from "../../../../scripts/lib/real-ghidra-address-boundaries.mjs";
const entry = "0x10000";
const originalName = "fixture::entry";
const fixture = (
options: {
failAfterWrite?: Error;
restoration?: "reject" | "wrong-readback";
initialName?: null;
} = {},
) => {
let name: string | null = options.initialName === null ? null : originalName;
const writes: Record<string, unknown>[] = [];
const success = (value: unknown) => ({
content: [],
structuredContent: { normalized_result: value },
});
const error = (operation: string, address: unknown) => ({
code: "invalid_request",
category: "invalid_input",
message: "Address cannot be represented without truncation",
retryable: false,
remediation: { action: "Select a representable address" },
details: {
operation,
issues: [
{ address, message: "cannot be represented without truncation" },
],
},
});
const client: Pick<Client, "callTool"> = {
callTool: async ({ name: operation, arguments: args = {} }) => {
const address = args.address ?? args.procedure;
if (typeof address === "string" && address.includes("100010000"))
return {
isError: true,
content: [
{
type: "text",
text: JSON.stringify({ error: error(operation, address) }),
},
],
};
switch (operation) {
case "analyze_function":
return success({ procedure: { name } });
case "read_bytes":
return success({ bytes_hex: "90" });
case "address_name":
return success(name);
case "set_addresses_names": {
const names = args.names as Record<string, string>;
writes.push(names);
const requested = Object.values(names);
const accepted = new Set(requested).size === 1;
if (accepted) {
name = requested[0]!;
if (options.failAfterWrite !== undefined)
throw options.failAfterWrite;
}
return success(
Object.fromEntries(
Object.keys(names).map((key) => [key, accepted]),
),
);
}
case "set_address_name":
writes.push(args);
if (options.restoration === "reject") return success(false);
name =
options.restoration === "wrong-readback"
? "unexpected_name"
: String(args.name);
return success(true);
default:
throw new Error(`Unmodeled operation: ${operation}`);
}
},
};
const cli = async (command: string, address: string) => {
const operation =
command === "read-bytes" ? "read_bytes" : "annotate_native_function";
throw Object.assign(new Error("Rejected oversized address"), {
code: 1,
stdout: JSON.stringify(error(operation, address)),
});
};
return {
run: () => verifyGhidraAddressBoundaries(client, entry, cli),
observedName: () => name,
writes,
};
};
it("restores the exact observed name before the next verifier consumer", async () => {
const test = fixture();
await test.run();
expect(test.observedName()).toBe(originalName);
});
it("restores an uncertain write and preserves its original failure", async () => {
const failure = new Error("Reply lost after applying the alias");
const test = fixture({ failAfterWrite: failure });
await expect(test.run()).rejects.toBe(failure);
expect(test.observedName()).toBe(originalName);
});
it.each([
["reject", /temporary alias edit must be restored/u],
["wrong-readback", /original function-entry name must survive restoration/u],
] as const)(
"fails closed when restoration has %s",
async (restoration, diagnostic) => {
await expect(fixture({ restoration }).run()).rejects.toThrow(diagnostic);
},
);
it("retains both verification and restoration failures", async () => {
const failure = new Error("Reply lost after applying the alias");
await expect(
fixture({ failAfterWrite: failure, restoration: "reject" }).run(),
).rejects.toMatchObject({
name: "AggregateError",
errors: [failure, expect.any(Error)],
});
});
it("does not mutate an entry without a restorable public name", async () => {
const test = fixture({ initialName: null });
await expect(test.run()).rejects.toThrow(
/observed name that can be restored/u,
);
expect(test.writes).toEqual([]);
});