aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_codemode_client.py
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-07 15:14:30 +0200
committeruser <user@clank>2026-08-07 15:14:30 +0200
commit72fce7da1a1fd6527e389ffeb0f951157523589a (patch)
tree3f08a83f0d99f3d3fc7069b7be00d235bab82817 /tests/test_codemode_client.py
parentStop tracking 157MB of core dumps, and ignore them (diff)
parentdocs: upstream findings for the ida-codemode maintainers (diff)
downloadida-tui-72fce7da1a1fd6527e389ffeb0f951157523589a.tar.gz
ida-tui-72fce7da1a1fd6527e389ffeb0f951157523589a.tar.xz
ida-tui-72fce7da1a1fd6527e389ffeb0f951157523589a.zip
Merge the IDA Code Mode port
Replaces the private idalib worker (idatui/worker.py + worker_client.py, with server/patch_server.py injecting tools into ida-pro-mcp) with an ordinary client of ida_codemode.client.DatabaseHandle. A database open by an IDA GUI is reused; otherwise Code Mode starts or shares a managed idalib worker. The TUI no longer owns an IDA process, and closing it releases only its lease. Based on Duncan Ogilvie's port, rebased onto ~150 commits of local work it predated. The rebase itself was mechanical; landing it was not. Nine defects had to be fixed before the feature set was whole again, none of which the patch's own tests could catch: - DatabaseHandle.open() takes image_base, not loading_address: every connect() would have raised TypeError on the first call - five operations our tree had grown were simply missing (flowchart, so the graph view was dead; op_format/pc_nums/pc_num_format, so 'o'/'O' were; survey_binary) - set_comments wrote only the disassembly comment, so comments never appeared in pseudocode - xref_query returned rows in raw IDA order, and 'follow the call' silently followed the fall-through instead - rename accepted one edit per category, so bulk symbol import was dead - decompile ran decomp_map's full per-column ctree sweep to fill in a per-line address anchor - heads shipped without operand extents or the digest protocol - the package became unimportable without ida_codemode installed, which killed the offline test suites Verified against the pre-codemode tag rather than against assumptions: the full suite is 788 passed / 0 failed, and the pilot's 301 checks match the old backend exactly. Performance is within 2x on the listing hot path and faster on decompile, disasm and connect, after fixing two runtime costs that are documented for upstream in docs/CODEMODE_UPSTREAM.md. Test runtime came down from ~9m20s to 115s along the way -- not by removing checks, but by removing four kinds of waiting-on-a-guess that were also hiding real failures.
Diffstat (limited to 'tests/test_codemode_client.py')
-rw-r--r--tests/test_codemode_client.py162
1 files changed, 162 insertions, 0 deletions
diff --git a/tests/test_codemode_client.py b/tests/test_codemode_client.py
new file mode 100644
index 0000000..2f70ba3
--- /dev/null
+++ b/tests/test_codemode_client.py
@@ -0,0 +1,162 @@
+"""IDA-free contract tests for the Code Mode client adapter."""
+from __future__ import annotations
+
+import os
+import sys
+import tempfile
+from dataclasses import dataclass
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import idatui.codemode_client as module # noqa: E402
+from idatui.codemode_client import CodeModeClient, _parse_load_args # noqa: E402
+from idatui.errors import IDAToolError # noqa: E402
+
+#: Pure: fakes the DatabaseHandle, never touches IDA or the Code Mode library.
+NEEDS_IDA = False
+
+PASS = FAIL = 0
+
+
+def check(name: str, condition: bool, detail="") -> None:
+ global PASS, FAIL
+ if condition:
+ PASS += 1
+ print(f" ok {name}")
+ else:
+ FAIL += 1
+ print(f" FAIL {name} {detail}")
+
+
+@dataclass(frozen=True)
+class FakeEntry:
+ pid: int = 123
+ backend: str = "gui"
+ record_id: str = "123-abcdef"
+ exe_path: str = ""
+ idb_path: str = ""
+
+
+class FakeHandle:
+ def __init__(self, path: str) -> None:
+ self.connected = True
+ self.entry = FakeEntry(exe_path=path, idb_path=path + ".i64")
+ self.waited = None
+ self.saved = 0
+ self.closed = False
+ self.code = ""
+ self.code_timeout = None
+
+ def wait_autoanalysis(self, timeout=None):
+ self.waited = timeout
+ return {"complete": True, "status": "complete"}
+
+ def execute_python(self, code, timeout=None):
+ self.code = code
+ self.code_timeout = timeout
+ return {"result": {"sentinel": 7}, "stdout": "", "stderr": ""}
+
+ def save_database(self):
+ self.saved += 1
+ return {"saved": True, "idb_path": self.entry.idb_path}
+
+ def close(self):
+ self.connected = False
+ self.closed = True
+
+
+class FakeDatabaseHandle:
+ opened = None
+ kwargs = None
+
+ @classmethod
+ def open(cls, path, **kwargs):
+ cls.opened = path
+ cls.kwargs = kwargs
+ return FakeHandle(path)
+
+
+def _open_kwargs_are_real(sent: dict):
+ """(ok, detail) for the kwargs the adapter passes to DatabaseHandle.open.
+
+ Skips (passes) when ida_codemode is not installed, so the file stays pure.
+ """
+ try:
+ import inspect
+ from ida_codemode.client import DatabaseHandle as Real
+ except ImportError:
+ return True, "ida_codemode not installed - signature not checked"
+ accepted = set(inspect.signature(Real.open).parameters)
+ unknown = sorted(set(sent) - accepted)
+ return not unknown, f"open() rejects {unknown}"
+
+
+def main() -> int:
+ proc, base, file_type = _parse_load_args("-parm:ARMv7-M -b800000 -TRaw")
+ check("legacy switches map to typed Code Mode options",
+ (proc, base, file_type) == ("arm:ARMv7-M", 0x8000000, "Raw"),
+ (proc, base, file_type))
+ try:
+ _parse_load_args("-parm -zcustom")
+ except ValueError as exc:
+ check("arbitrary IDA switches fail loudly", "cannot represent" in str(exc), exc)
+ else:
+ check("arbitrary IDA switches fail loudly", False)
+
+ original = module.DatabaseHandle
+ module.DatabaseHandle = FakeDatabaseHandle
+ try:
+ with tempfile.TemporaryDirectory() as tmp:
+ path = os.path.join(tmp, "sample.bin")
+ with open(path, "wb") as file:
+ file.write(b"sample")
+ client = CodeModeClient(path, load_args="-parm:ARMv7-A -b100")
+ notes = []
+ client.connect(timeout=42, progress=notes.append)
+ handle = client._handle
+ check("connect delegates database discovery to DatabaseHandle.open",
+ FakeDatabaseHandle.opened == path and handle is not None)
+ check("typed loader options cross the dependency boundary",
+ FakeDatabaseHandle.kwargs["processor"] == "arm:ARMv7-A"
+ and FakeDatabaseHandle.kwargs["image_base"] == 0x1000,
+ FakeDatabaseHandle.kwargs)
+ # A fake that swallows **kwargs cannot catch a keyword the real
+ # library does not have -- which is exactly how this port shipped
+ # `loading_address` (the real name is `image_base`) and would have
+ # raised TypeError on the very first connect. Check the names we
+ # send against the real signature whenever it is importable.
+ check("every open() keyword exists in the real library",
+ *_open_kwargs_are_real(FakeDatabaseHandle.kwargs))
+ check("connect waits for Code Mode autoanalysis",
+ handle.waited == 42, getattr(handle, "waited", None))
+ check("progress distinguishes discovery and backend attachment",
+ len(notes) == 2 and "gui" in notes[-1], notes)
+ result = client.invoke("list_funcs", queries=[{"offset": 0, "count": 2}])
+ check("invoke returns execute_python's result", result == {"sentinel": 7}, result)
+ check("operation scripts use the preloaded ida-domain database",
+ "db.functions.get_all()" in handle.code, handle.code[:200])
+ check("health exposes registry identity",
+ client.health()["record_id"] == "123-abcdef")
+ client.save_database()
+ check("save uses the public Code Mode save route", handle.saved == 1)
+ client.close()
+ check("close releases only the handle lease", handle.closed)
+ check("GUI lifetime is never claimed by the client",
+ client.wait_released(0) is False)
+ finally:
+ module.DatabaseHandle = original
+
+ client = CodeModeClient(__file__)
+ try:
+ client.invoke("not-an-operation")
+ except IDAToolError as exc:
+ check("unknown adapter operations are explicit", exc.tool == "not-an-operation")
+ else:
+ check("unknown adapter operations are explicit", False)
+
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())