mirror of
https://github.com/morluto/rea.git
synced 2026-10-18 20:10:13 -05:00
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:
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([]);
|
||||
});
|
||||
Reference in new issue
Block a user