diff options
| author | user <user@clank> | 2026-08-01 17:18:57 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-01 17:18:57 +0200 |
| commit | 14ca7c7e5c56c3fdd059d168957a2b2e3dbe504e (patch) | |
| tree | 7cc8ea9637b7998fc1da3b4e428a1e39537903fa /tests | |
| parent | tests: trace integration over the RPC socket (45 checks) (diff) | |
| download | ida-tui-14ca7c7e5c56c3fdd059d168957a2b2e3dbe504e.tar.gz ida-tui-14ca7c7e5c56c3fdd059d168957a2b2e3dbe504e.tar.xz ida-tui-14ca7c7e5c56c3fdd059d168957a2b2e3dbe504e.zip | |
rpc: make a raw firmware image drivable (load options, define, bulk symbols)
Opening a headerless blob was the one workflow that fell out of the driving
surface entirely, and each gap hid the next:
- `pane spawn` couldn't pass --processor/--base/--ida-args, so the pane came up
"ready" with zero functions (x86 at 0) and the only way through was to
hand-write a project file. It now forwards them to idatui.launch.
- c/p/t/T (code, function, ARM<->Thumb, vector scan) existed as listing
bindings with no verb, so a driver had to guess raw keys -- and raw keys are
swallowed by whatever modal happens to be up. `define {kind,target?}` goes
through the app's own edit worker and reports what IDA actually did.
- every name went through the typed rename prompt: a navigation (listing page +
decompile) plus two prompt round-trips each. A 427-symbol map took tens of
minutes of driving. `rename_many {items|file}` hands IDA's rename tool the
whole list in one call (371 symbols in 3s) and refreshes the caches and the
function table once.
drive gains `define <kind> [target...]` and `syms <file.json>`.
Verified live against a real pane (tests/test_rawimage_rpc.py, 13 checks:
spawn load options, define thumb/func + unknown-kind rejection, rename_many
from a file and inline, with resolve/functions readback).
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_rawimage_rpc.py | 169 |
1 files changed, 169 insertions, 0 deletions
diff --git a/tests/test_rawimage_rpc.py b/tests/test_rawimage_rpc.py new file mode 100644 index 0000000..ab7eb96 --- /dev/null +++ b/tests/test_rawimage_rpc.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""The raw-image workflow over the RPC socket: spawn with load options, define, +bulk-apply a symbol file. + +A headerless firmware image is the case where driving IDA from an agent used to +fall apart: + + * ``pane spawn`` could not pass ``--processor``/``--base``, so the pane came up + ready-but-empty (x86 at 0, zero functions) and the only way through was to + hand-write a project file; + * ``c``/``p``/``t`` (make code / make function / ARM-Thumb) existed as key + bindings but had no verb, so a driver had to guess raw keys and hope no + modal was on top; + * every name had to go through the typed rename prompt — a navigation plus two + prompt round-trips each, which is tens of minutes for a 400-symbol map. + +This test spawns a real pane on a real Thumb blob and checks all three. + +Requires: tmux, IDA (idalib). ~2min. + + ~/ida-venv/bin/python tests/test_rawimage_rpc.py +""" +import json +import os +import subprocess +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from idatui.rpcclient import RpcClient, RpcError # noqa: E402 + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BLOB = os.path.join(REPO, "experiments", "fibonacci.bin") # real Thumb code + +PASS = FAIL = 0 + + +def check(name, ok, detail=""): + global PASS, FAIL + if ok: + PASS += 1 + print(f" ok [{name}]") + else: + FAIL += 1 + print(f" FAIL [{name}] {detail}") + + +def spawn_pane(target, processor, timeout=420): + cmd = [sys.executable, "-m", "idatui.pane", "spawn", "--open", target, + "--processor", processor, "--detached", "--size", "60%", + "--timeout", str(timeout)] + r = subprocess.run(cmd, capture_output=True, text=True, + timeout=timeout + 60, cwd=REPO) + if not r.stdout.strip(): + print(f" spawn produced no JSON: {r.stderr.strip()}", file=sys.stderr) + return None + return json.loads(r.stdout) + + +def stop_pane(sock, timeout=60): + subprocess.run([sys.executable, "-m", "idatui.pane", "stop", "--sock", sock, + "--timeout", str(timeout)], + capture_output=True, text=True, timeout=timeout + 10, cwd=REPO) + + +def main() -> int: + if not os.environ.get("TMUX"): + print("SKIP: not inside tmux") + return 0 + if not os.path.exists(BLOB): + print(f"SKIP: no blob at {BLOB}") + return 0 + + # Work on a copy: the .i64 lands next to the binary and the load options + # only apply to a FIRST open, so a leftover database would silently decide + # what this test measures. + with tempfile.TemporaryDirectory() as tmp: + blob = os.path.join(tmp, "fib.bin") + with open(BLOB, "rb") as src, open(blob, "wb") as dst: + dst.write(src.read()) + + info = spawn_pane(blob, "arm:ARMv7-A") + if not info: + print("SKIP: could not spawn a pane") + return 0 + sock = info["sock"] + try: + # -- load options actually reached IDA --------------------------- # + # Wrong processor => the disassembly is nonsense or absent; ARMv7-A + # also means a 32-bit database, without which Hex-Rays refuses. + check("spawn forwarded --processor", info.get("ok"), + json.dumps(info)) + with RpcClient(sock) as c: + st = c.call("state") + check("pane is drivable", st.get("active") in + ("listing", "decomp", "hex"), json.dumps(st)[:200]) + + # -- define ------------------------------------------------- # + # fibonacci.bin is Thumb at 0x0; as ARM it does not decode. + r = c.call("define", kind="thumb", target="0x0") + d = r.get("define", {}) + check("define thumb ran", "define" in r, json.dumps(r)[:200]) + check("define thumb decoded instructions", + "instruction" in d.get("status", ""), d.get("status", "")) + + r = c.call("define", kind="func", target="0x0") + check("define func created a function", + "function" in r["define"]["status"] + or "already" in r["define"]["status"], + r["define"]["status"]) + + bad = None + try: + c.call("define", kind="nonsense") + except RpcError as e: + bad = str(e) + check("define rejects an unknown kind", bad is not None + and "unknown define kind" in bad, str(bad)) + + # -- rename_many -------------------------------------------- # + fns = c.call("functions", limit=200) + ea = min(f["ea"] for f in fns) if fns else None + check("a function exists to rename", ea is not None) + + symfile = os.path.join(tmp, "syms.json") + with open(symfile, "w") as f: + # 'start' (not 'addr') on purpose: symbol files in the wild + # use it, and accepting only one spelling is how a bulk + # import silently renames nothing. + json.dump([{"start": hex(ea), "name": "bulk_named_fn"}, + {"start": "0xdeadbe", "name": "nowhere"}], f) + r = c.call("rename_many", file=symfile) + m = r.get("rename_many", {}) + check("rename_many applied the good entry", m.get("ok") == 1, + json.dumps(m)) + check("rename_many reports the bad entry", + m.get("failed") == 1 and m.get("errors"), json.dumps(m)) + + # The readback matters more than the return value: a driver + # trusts resolve/functions to decide what work is left. + check("renamed symbol resolves", + c.call("resolve", name="bulk_named_fn").get("ea") == ea, + json.dumps(c.call("resolve", name="bulk_named_fn"))) + names = {f["name"] for f in c.call("functions", limit=200)} + check("function table shows the new name", + "bulk_named_fn" in names, str(sorted(names)[:10])) + + r = c.call("rename_many", items=[{"addr": hex(ea), + "name": "inline_named_fn"}]) + check("rename_many takes inline items", + r["rename_many"]["ok"] == 1, json.dumps(r["rename_many"])) + + empty = None + try: + c.call("rename_many") + except RpcError as e: + empty = str(e) + check("rename_many without items errors", empty is not None + and "items" in empty, str(empty)) + finally: + stop_pane(sock) + + print(f"\n{PASS} passed, {FAIL} failed") + return 1 if FAIL else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) |
