aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/pane_smoke.py87
-rw-r--r--tests/rpc_smoke.py284
-rwxr-xr-xtests/serverctl.sh65
-rw-r--r--tests/smoke_client.py162
-rw-r--r--tests/stress_client.py290
-rw-r--r--tests/stress_paging.py150
-rw-r--r--tests/test_blob_ui.py235
-rw-r--r--tests/test_domain.py155
-rw-r--r--tests/test_formats.py122
-rw-r--r--tests/test_index.py202
-rw-r--r--tests/test_keepalive.py76
-rw-r--r--tests/test_pool.py192
-rw-r--r--tests/test_project.py233
-rw-r--r--tests/test_project_ui.py285
-rw-r--r--tests/test_scenarios.py1264
15 files changed, 2384 insertions, 1418 deletions
diff --git a/tests/pane_smoke.py b/tests/pane_smoke.py
deleted file mode 100644
index bd7def6..0000000
--- a/tests/pane_smoke.py
+++ /dev/null
@@ -1,87 +0,0 @@
-#!/usr/bin/env python3
-"""Smoke test for idatui.pane's supervisor auto-start machinery (no IDA needed).
-
- python3 tests/pane_smoke.py
-
-Uses IDATUI_SERVER_CMD to launch a dummy port-binder in a tmux pane instead of
-the real ./spawn.sh, so we can exercise _ensure_server end-to-end (start, detect,
-idempotency, remote guard) without a real ida-pro-mcp server. Must run in tmux.
-"""
-import os
-import socket
-import subprocess
-import sys
-import tempfile
-
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-from idatui import pane # noqa: E402
-
-PASS = FAIL = 0
-
-
-def check(name, cond, detail=""):
- global PASS, FAIL
- if cond:
- PASS += 1
- print(f" ok {name}")
- else:
- FAIL += 1
- print(f" FAIL {name} {detail}")
-
-
-def _free_port() -> int:
- s = socket.socket()
- s.bind(("127.0.0.1", 0))
- p = s.getsockname()[1]
- s.close()
- return p
-
-
-def main() -> int:
- if not os.environ.get("TMUX"):
- print("error: must run inside tmux", file=sys.stderr)
- return 2
-
- # a dummy "supervisor": bind the port and idle, so _server_up sees it.
- fake = tempfile.NamedTemporaryFile("w", suffix=".py", delete=False)
- fake.write(
- "import socket,sys,time\n"
- "s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)\n"
- "s.bind(('127.0.0.1',int(sys.argv[1]))); s.listen(); time.sleep(300)\n")
- fake.close()
- port = _free_port()
- os.environ["IDATUI_SERVER_CMD"] = f"{sys.executable} {fake.name} {port}"
-
- server_pane = None
- try:
- check("port starts down", not pane._server_up("127.0.0.1", port))
-
- srv = pane._ensure_server("127.0.0.1", port, timeout=15.0)
- server_pane = srv.get("server_pane")
- check("ensure_server starts the supervisor and it comes up",
- srv.get("server_started") and srv.get("server_up") and server_pane,
- str(srv))
- check("port is now up", pane._server_up("127.0.0.1", port))
-
- srv2 = pane._ensure_server("127.0.0.1", port, timeout=5.0)
- check("ensure_server is idempotent when already up",
- srv2.get("server_started") is False and srv2.get("server_up") is True,
- str(srv2))
-
- srv3 = pane._ensure_server("10.255.255.1", 9, timeout=2.0)
- check("remote+down server is not auto-started",
- srv3.get("server_started") is False and "local" in (srv3.get("error") or ""),
- str(srv3))
- finally:
- if server_pane:
- subprocess.run(["tmux", "kill-pane", "-t", server_pane],
- capture_output=True)
- os.unlink(fake.name)
- os.environ.pop("IDATUI_SERVER_CMD", None)
-
- print(f"\n{PASS} passed, {FAIL} failed")
- return 1 if FAIL else 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/tests/rpc_smoke.py b/tests/rpc_smoke.py
deleted file mode 100644
index a38162e..0000000
--- a/tests/rpc_smoke.py
+++ /dev/null
@@ -1,284 +0,0 @@
-#!/usr/bin/env python3
-"""End-to-end smoke test for the RPC server, in-process over a real unix socket.
-
- ~/ida-venv/bin/python tests/rpc_smoke.py --db <session_id>
-
-Boots the TUI headless with --rpc on a temp socket, connects a JSONL client over
-that socket (same loop), and exercises the raw + introspection primitives.
-"""
-import asyncio
-import json
-import os
-import sys
-import tempfile
-
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-from idatui.app import IdaTui # noqa: E402
-from idatui.client import DEFAULT_URL # noqa: E402
-from idatui._sync import wait_for # noqa: E402
-from textual.widgets import DataTable # noqa: E402
-
-PASS = FAIL = 0
-
-
-def check(name, cond, detail=""):
- global PASS, FAIL
- if cond:
- PASS += 1
- print(f" ok {name}")
- else:
- FAIL += 1
- print(f" FAIL {name} {detail}")
-
-
-class Conn:
- """Tiny JSONL client over a unix socket."""
- def __init__(self, r, w):
- self.r, self.w = r, w
- self._id = 0
-
- async def call(self, method, **params):
- self._id += 1
- self.w.write(json.dumps({"id": self._id, "method": method,
- "params": params}).encode() + b"\n")
- await self.w.drain()
- line = await self.r.readline()
- return json.loads(line.decode())
-
-
-async def run(db):
- sock = os.path.join(tempfile.gettempdir(), f"idatui-rpc-{os.getpid()}.sock")
- url = os.environ.get("IDA_MCP_URL", DEFAULT_URL)
- app = IdaTui(url=url, db=db, keepalive=False, rpc_path=sock)
- async with app.run_test(size=(140, 44)) as pilot:
- # boot: functions loaded + socket up
- await wait_for(lambda: app.query_one("#func-table", DataTable).row_count > 0,
- pilot.pause, 60)
- await wait_for(lambda: os.path.exists(sock), pilot.pause, 20)
- if app._func_index is not None and not app._func_index.complete:
- app._func_index.load_all()
-
- r, w = await asyncio.open_unix_connection(sock)
- c = Conn(r, w)
-
- resp = await c.call("ping")
- pong = resp.get("result", {})
- check("ping returns proto + readiness",
- pong.get("proto") == 1 and pong.get("ready") is True and pong.get("functions", 0) > 0,
- str(pong))
-
- resp = await c.call("methods")
- check("methods lists the verb table",
- isinstance(resp.get("result"), dict) and "pseudocode" in resp["result"],
- str(resp)[:80])
-
- resp = await c.call("functions", limit=400)
- funcs = resp.get("result", [])
- check("functions lists entries", isinstance(funcs, list) and len(funcs) > 0,
- str(resp)[:120])
- # a normally-named function (its name appears verbatim in its own decomp,
- # unlike e.g. '.init_proc' which IDA renders as 'init_proc')
- target = next((f for f in funcs if f["name"] == "main"),
- next((f for f in funcs if f["name"].startswith("sub_")), funcs[0]))
-
- resp = await c.call("state")
- st = resp.get("result", {})
- check("state has an active view", st.get("active") in ("decomp", "disasm", "hex"),
- str(st)[:120])
-
- # drive it like a user: goto a function by name via raw keys
- keys = ["g"] + list(target["name"]) + ["enter"]
- resp = await c.call("keys", keys=keys)
- st = resp.get("result", {})
- check("keys(goto) navigates to the function",
- st.get("function", {}).get("name") == target["name"],
- f"got {st.get('function')}")
-
- resp = await c.call("view", lines=6)
- v = resp.get("result", {})
- check("view returns visible lines with a cursor",
- isinstance(v.get("lines"), list) and any(l.get("cur") for l in v["lines"]),
- str(v)[:120])
-
- resp = await c.call("screen")
- scr = resp.get("result", {})
- check("screen returns a text grid",
- isinstance(scr.get("text"), str) and target["name"] in scr["text"],
- f"len={len(scr.get('text','')) if scr else 0}")
-
- # raw text primitive into the goto input, then escape out
- from textual.widgets import Input
- await c.call("keys", keys=["g"])
- await c.call("text", text="sub_", settle=False)
- val = app.query_one("#goto", Input).value
- check("text primitive types into the focused input", val == "sub_", f"val={val!r}")
- await c.call("keys", keys=["escape"])
-
- # --- semantic verbs ------------------------------------------------ #
- resp = await c.call("goto", target=target["name"], delay_ms=0)
- check("semantic goto lands on the function",
- resp.get("result", {}).get("function", {}).get("name") == target["name"],
- str(resp.get("result", {}).get("function")))
-
- before = app._active
- resp = await c.call("toggle_view")
- check("toggle_view flips the active pane",
- resp.get("result", {}).get("active") != before, f"still {before}")
- await c.call("toggle_view") # flip back to a known state
-
- # fast movement: cursor should advance a few lines
- line0 = (await c.call("state"))["result"]["cursor"].get("line")
- resp = await c.call("move", dir="down", n=4)
- line1 = resp["result"]["cursor"].get("line")
- check("move(down,4) advances the cursor",
- isinstance(line0, int) and isinstance(line1, int) and line1 > line0,
- f"{line0} -> {line1}")
-
- # cursor_on: place the cursor on the function's own name by token
- resp = await c.call("cursor_on", word=target["name"])
- cur = resp.get("result", {})
- check("cursor_on lands on the named token",
- cur.get("found") is True and cur.get("cursor", {}).get("word") == target["name"],
- str(cur.get("cursor")))
-
- # rename using the ergonomic word= (cursor_on + prompt-fill in one call)
- v = (await c.call("view", lines=1))["result"]
- line0_text = v["lines"][0]["text"] if v.get("lines") else ""
- col = line0_text.find(target["name"])
- if col >= 0:
- newname = f"rpc_{os.getpid()}"
- await c.call("rename", name=newname, word=target["name"], delay_ms=0)
- got = app._func_index.by_addr(target["ea"])
- check("rename word= updates the function name",
- got is not None and got.name == newname,
- got.name if got else None)
- app.program.client.call(
- "rename", batch={"func": {"addr": hex(target["ea"]), "name": target["name"]}})
- else:
- check("found the function name to rename", False, repr(line0_text[:60]))
-
- # --- structured introspection ------------------------------------- #
- resp = await c.call("resolve", name=target["name"])
- check("resolve maps a name to its ea",
- resp.get("result", {}).get("ea") == target["ea"], str(resp.get("result")))
-
- resp = await c.call("pseudocode", target=target["name"])
- pc = resp.get("result", {})
- check("pseudocode returns the full body",
- isinstance(pc.get("code"), str) and len(pc["code"]) > 0 and not pc["failed"],
- f"failed={pc.get('failed')} len={len(pc.get('code') or '')}")
-
- resp = await c.call("disassembly", target=target["name"], max=50)
- da = resp.get("result", {})
- check("disassembly returns lines with addresses",
- isinstance(da.get("lines"), list) and len(da["lines"]) > 0
- and all("ea" in ln and "text" in ln for ln in da["lines"]),
- f"total={da.get('total')} n={len(da.get('lines', []))}")
-
- # a function that is actually referenced, so xrefs_to is non-empty
- callee = None
- for f in funcs:
- xr = (await c.call("xrefs_to", target=f["name"], limit=5)).get("result", [])
- if xr:
- callee = (f, xr)
- break
- check("xrefs_to returns structured references",
- callee is not None and all("frm" in x for x in callee[1]),
- "no referenced function found" if callee is None else "")
-
- # xrefs_from a function is whole-body (decomp refs), not just the entry:
- # find a function that actually calls something.
- caller = next((f["name"] for f in funcs if f["name"] == "main"), None)
- xf = []
- for name in ([caller] if caller else []) + [f["name"] for f in funcs]:
- xf = (await c.call("xrefs_from", target=name)).get("result", [])
- if any(x.get("is_func") for x in xf):
- caller = name
- break
- check("xrefs_from a function lists whole-body callees",
- isinstance(xf, list) and len(xf) > 1
- and any(x.get("is_func") for x in xf) and all("to" in x for x in xf),
- f"caller={caller} n={len(xf)}")
-
- # --- modal select: open xrefs on the callee, pick the first site --- #
- if callee is not None:
- f, xr = callee
- await c.call("goto", target=f["name"], delay_ms=0)
- # place the cursor on the function name so xrefs targets it
- v = (await c.call("view", lines=1))["result"]
- lt = v["lines"][0]["text"] if v.get("lines") else ""
- col = lt.find(f["name"])
- if col >= 0:
- await c.call("cursor", line=0, col=col + 1)
- resp = await c.call("xrefs")
- m = resp.get("result", {}).get("modal") or {}
- check("xrefs opens the picker with items",
- m.get("kind") == "XrefsScreen" and len(m.get("items", [])) > 0,
- str(m)[:80])
- resp = await c.call("select", index=0)
- check("select follows a picked xref (modal closes, we navigate)",
- (resp.get("result", {}).get("modal") is None), str(resp.get("result", {}).get("modal")))
-
- # --- in-view search ----------------------------------------------- #
- await c.call("goto", target=target["name"], delay_ms=0)
- resp = await c.call("search", term="return", delay_ms=0)
- st = resp.get("result", {})
- check("search runs without error and returns state",
- st.get("active") in ("decomp", "disasm"), str(st.get("active")))
- await c.call("keys", keys=["escape"])
-
- # colored screen export (for an out-of-band web viewer)
- resp = await c.call("screen", format="html")
- html = resp.get("result", {})
- check("screen format=html returns an html document",
- html.get("format") == "html" and "<" in (html.get("text") or ""),
- str(html.get("format")))
-
- # single-driver gate: a 2nd connection (while c is open) is refused
- r2, w2 = await asyncio.open_unix_connection(sock)
- c2 = Conn(r2, w2)
- resp2 = await c2.call("ping")
- check("second concurrent client is refused (single-driver)",
- resp2.get("error") and "busy" in resp2["error"].get("message", ""),
- str(resp2))
- w2.close()
-
- try:
- w.close()
- except Exception: # noqa: BLE001
- pass
-
- # --- ergonomic driver (idatui.drive) over the same socket ---------- #
- # (c is closed now, so the single-driver gate lets drive connect.)
- from idatui import drive
- loop = asyncio.get_running_loop()
- for cmd in (["where"], ["pc", target["name"]], ["callees", target["name"]],
- ["names", "sub_", "3"]):
- rc = await loop.run_in_executor(
- None, lambda a=cmd: drive.main(["--sock", sock, *a]))
- check(f"drive {cmd[0]} runs against the socket", rc == 0, f"rc={rc}")
-
- # --- graceful quit (last: it tears the app down) ------------------- #
- r2, w2 = await asyncio.open_unix_connection(sock)
- c2 = Conn(r2, w2)
- resp = await c2.call("quit")
- check("quit acknowledges before shutting down",
- resp.get("result", {}).get("quitting") is True, str(resp))
- w2.close()
- await wait_for(lambda: not app.is_running, pilot.pause, 10)
- check("quit actually exits the app", not app.is_running, "still running")
- print(f"\n{PASS} passed, {FAIL} failed")
- return 1 if FAIL else 0
-
-
-def main(argv):
- db = None
- it = iter(argv)
- for a in it:
- if a == "--db":
- db = next(it)
- return asyncio.run(run(db))
-
-
-if __name__ == "__main__":
- raise SystemExit(main(sys.argv[1:]))
diff --git a/tests/serverctl.sh b/tests/serverctl.sh
deleted file mode 100755
index a5bc098..0000000
--- a/tests/serverctl.sh
+++ /dev/null
@@ -1,65 +0,0 @@
-#!/usr/bin/env bash
-# Control the ida-pro-mcp server for stress testing. Assumes ~/ida-venv + spawn.sh.
-set -u
-REPO="$(cd "$(dirname "$0")/.." && pwd)"
-PORT=8745
-LOG=/tmp/ida-stress-spawn.log
-
-wait_port() { # wait_port <up|down> <secs>
- local want="$1" secs="${2:-40}" i
- for ((i=0; i<secs*2; i++)); do
- if ss -ltn 2>/dev/null | grep -q ":$PORT "; then
- [ "$want" = up ] && return 0
- else
- [ "$want" = down ] && return 0
- fi
- sleep 0.5
- done
- return 1
-}
-
-wait_ready() { # wait until hexrays_ready via the client
- local secs="${1:-60}" i
- for ((i=0; i<secs; i++)); do
- if python3 -c "
-import sys; sys.path.insert(0,'$REPO')
-from idatui.client import IDAClient
-try:
- c=IDAClient(); c.connect()
- ss=[s for s in c.list_sessions() if s.session_id]
- if ss:
- c.set_db(ss[0].session_id)
- if c.health().get('hexrays_ready'): print(ss[0].session_id); sys.exit(0)
-except Exception: pass
-sys.exit(1)
-" 2>/dev/null; then return 0; fi
- sleep 1
- done
- return 1
-}
-
-case "${1:-}" in
- start)
- cd "$REPO"
- source ~/ida-venv/bin/activate 2>/dev/null
- nohup ./spawn.sh >"$LOG" 2>&1 &
- wait_port up 40 || { echo "PORT_TIMEOUT"; tail -5 "$LOG"; exit 1; }
- wait_ready 90 || { echo "READY_TIMEOUT"; tail -5 "$LOG"; exit 1; }
- ;;
- stop)
- # Kill the whole tree: uv wrapper, supervisor, worker.
- pkill -9 -f 'idalib_server' 2>/dev/null
- pkill -9 -f 'idalib-mcp' 2>/dev/null
- pkill -9 -f 'uv run idalib' 2>/dev/null
- wait_port down 20 || { echo "STOP_TIMEOUT"; exit 1; }
- ;;
- kill9)
- # Hard kill only the worker (simulate a crash of the analysis process).
- pkill -9 -f 'idalib_server' 2>/dev/null
- ;;
- ready)
- wait_ready "${2:-60}"
- ;;
- *)
- echo "usage: $0 {start|stop|kill9|ready [secs]}"; exit 2;;
-esac
diff --git a/tests/smoke_client.py b/tests/smoke_client.py
deleted file mode 100644
index 5466284..0000000
--- a/tests/smoke_client.py
+++ /dev/null
@@ -1,162 +0,0 @@
-#!/usr/bin/env python3
-"""Live smoke test for idatui.client against a running ida-pro-mcp server.
-
-Run with the venv Python while a server is up (see spawn.sh):
-
- IDA_MCP_DB=<session_id> python3 tests/smoke_client.py
- # or: python3 tests/smoke_client.py --db <session_id>
-
-Exercises: handshake+warm latency, happy-path payloads, the full error taxonomy
-(hard tool errors vs soft per-item errors), session resolution, connection-pool
-reuse, and concurrent calls from threads.
-"""
-import concurrent.futures
-import os
-import sys
-import time
-
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-from idatui.client import ( # noqa: E402
- IDAClient,
- IDASessionError,
- IDAToolError,
-)
-
-PASS, FAIL = 0, 0
-
-
-def _query_data(payload):
- res = payload.get("result", payload) if isinstance(payload, dict) else payload
- if isinstance(res, list):
- res = res[0] if res else {}
- return res.get("data", []) if isinstance(res, dict) else []
-
-
-def check(name, cond, detail=""):
- global PASS, FAIL
- if cond:
- PASS += 1
- print(f" ok {name}")
- else:
- FAIL += 1
- print(f" FAIL {name} {detail}")
-
-
-def main(argv):
- url = "http://127.0.0.1:8745/mcp"
- db = os.environ.get("IDA_MCP_DB")
- it = iter(argv)
- for a in it:
- if a == "--url":
- url = next(it)
- elif a == "--db":
- db = next(it)
-
- ida = IDAClient(url, db=db)
- ida.connect()
-
- print("[sessions]")
- sessions = ida.list_sessions()
- check("list_sessions returns >=1", len(sessions) >= 1, str(sessions))
- if db is None:
- usable = [s for s in sessions if s.session_id]
- if len(usable) == 1:
- db = usable[0].session_id
- else:
- print(f" (multiple/zero sessions; pin one with --db) -> {usable}")
- print(" Options:", ", ".join(f"{s.session_id}={s.filename}" for s in usable))
- return 2
- ida.set_db(db)
- print(f" using db={db}")
-
- print("[handshake / latency]")
- # Warm calls; measure.
- for _ in range(3):
- ida.health()
- times = []
- for _ in range(20):
- t = time.time()
- ida.call("list_funcs", queries=[{"count": 50}])
- times.append((time.time() - t) * 1e3)
- times.sort()
- med = times[len(times) // 2]
- print(f" list_funcs x20 warm: min={times[0]:.1f} med={med:.1f} max={times[-1]:.1f} ms")
- check("warm call under 50ms median", med < 50, f"med={med:.1f}ms")
-
- print("[happy path]")
- h = ida.health()
- check("health is dict", isinstance(h, dict), type(h).__name__)
- funcs = ida.call("list_funcs", queries=[{"count": 5}])
- check("list_funcs shape", isinstance(funcs, (list, dict)), type(funcs).__name__)
- # Pick a real function (don't assume 'main' exists — libraries have none).
- some = ida.call("list_funcs", queries=[{"filter": "sub_*", "count": 1}])
- target = _query_data(some)[0]["addr"]
- dis = ida.call("disasm", addr=target, max_instructions=10)
- lines = (dis.get("asm") or {}).get("lines") if isinstance(dis, dict) else None
- check("disasm main has lines", bool(lines), str(dis)[:120])
- check("disasm line carries addr", bool(lines and "addr" in lines[0]),
- str(lines[0]) if lines else "no lines")
-
- print("[error taxonomy]")
- # Hard tool error: wrong params -> isError true -> IDAToolError
- try:
- ida.call("xrefs_to", targets=["main"])
- check("bad params raises IDAToolError", False, "no exception")
- except IDAToolError as e:
- check("bad params raises IDAToolError", True)
- check(" ...message surfaced", "addrs" in e.message or "param" in e.message.lower(),
- e.message)
- # Hard tool error: unknown tool -> isError true
- try:
- ida.call("definitely_not_a_tool")
- check("unknown tool raises IDAToolError", False, "no exception")
- except IDAToolError:
- check("unknown tool raises IDAToolError", True)
- # Soft/per-item error: bad addr to decompile -> isError false -> DATA, not raise
- try:
- payload = ida.call("decompile", addr="zzz_nope_addr")
- soft = isinstance(payload, dict) and payload.get("error")
- check("soft error returned as data (not raised)", bool(soft), str(payload)[:160])
- except IDAToolError as e:
- check("soft error returned as data (not raised)", False, f"raised: {e}")
-
- print("[session resolution]")
- tmp = IDAClient(url, db=None)
- tmp.connect()
- all_sessions = tmp.list_sessions()
- usable = [s for s in all_sessions if s.session_id]
- if len(usable) > 1:
- try:
- tmp.resolve_db()
- check("multi-session resolve raises", False, "no exception")
- except IDASessionError:
- check("multi-session resolve raises", True)
- else:
- check("single-session auto-resolves", tmp.resolve_db() == usable[0].session_id)
- tmp.close()
-
- print("[connection reuse]")
- # Fire many calls; pool should keep idle conns bounded and all succeed.
- ok = 0
- for _ in range(30):
- if ida.call("list_funcs", queries=[{"count": 1}]) is not None:
- ok += 1
- check("30 sequential calls all succeed (pooled)", ok == 30, f"{ok}/30")
-
- print("[concurrency]")
- def worker(i):
- return ida.call("disasm", addr=target, max_instructions=5)
-
- with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
- results = list(ex.map(worker, range(40)))
- good = sum(1 for r in results if isinstance(r, dict) and r.get("asm"))
- check("40 concurrent calls across 8 threads", good == 40, f"{good}/40")
-
- ida.close()
- print(f"\n{PASS} passed, {FAIL} failed")
- return 1 if FAIL else 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main(sys.argv[1:]))
diff --git a/tests/stress_client.py b/tests/stress_client.py
deleted file mode 100644
index 955700c..0000000
--- a/tests/stress_client.py
+++ /dev/null
@@ -1,290 +0,0 @@
-#!/usr/bin/env python3
-"""Adversarial stress tests for idatui.client.
-
-Kills/respawns the server, forces timeouts, drops connections, and hammers with
-concurrency — asserting the client always fails *cleanly* (no hangs, no
-deadlocks) and recovers where recovery is possible.
-
- python3 tests/stress_client.py # run all scenarios
- python3 tests/stress_client.py timeout # run one by name
-
-Requires ~/ida-venv + spawn.sh (via tests/serverctl.sh). Leaves the server UP.
-"""
-import concurrent.futures
-import os
-import subprocess
-import sys
-import threading
-import time
-
-HERE = os.path.dirname(os.path.abspath(__file__))
-REPO = os.path.dirname(HERE)
-sys.path.insert(0, REPO)
-
-from idatui.client import ( # noqa: E402
- IDAClient,
- IDAConnectionError,
- IDASessionError,
- IDATimeoutError,
- IDAToolError,
- IDAError,
-)
-
-URL = "http://127.0.0.1:8745/mcp"
-CTL = os.path.join(HERE, "serverctl.sh")
-PASS = FAIL = 0
-
-
-def log(m):
- print(m, flush=True)
-
-
-def check(name, cond, detail=""):
- global PASS, FAIL
- if cond:
- PASS += 1
- log(f" ok {name}")
- else:
- FAIL += 1
- log(f" FAIL {name} {detail}")
-
-
-def ctl(*args, timeout=180):
- r = subprocess.run([CTL, *args], capture_output=True, text=True, timeout=timeout)
- return r.returncode, (r.stdout + r.stderr).strip()
-
-
-def ensure_up():
- rc, out = ctl("ready", "5")
- if rc != 0:
- log(" (server down; starting...)")
- rc, out = ctl("start")
- assert rc == 0, f"could not start server: {out}"
- rc, sid = ctl("ready", "60")
- return sid.strip().splitlines()[-1] if sid.strip() else None
-
-
-def deadline(fn, secs):
- """Run fn() in a thread; return (finished_bool, result_or_exc)."""
- box = {}
- def run():
- try:
- box["r"] = fn()
- except BaseException as e: # noqa: BLE001
- box["e"] = e
- t = threading.Thread(target=run, daemon=True)
- t.start()
- t.join(secs)
- if t.is_alive():
- return False, None
- return True, box.get("e", box.get("r"))
-
-
-# --------------------------------------------------------------------------- #
-def s_connect_refused():
- """Connecting to a dead port fails fast and cleanly, never hangs."""
- c = IDAClient("http://127.0.0.1:59999/mcp", db="x", timeout=3.0)
- fin, res = deadline(c.connect, 8)
- check("dead-port connect returns (no hang)", fin)
- check("dead-port raises IDAConnectionError", isinstance(res, IDAConnectionError),
- f"{type(res).__name__}: {res}")
-
-
-def s_timeout():
- """A too-tight timeout on the slow decompile raises IDATimeoutError, and the
- client stays usable for subsequent calls."""
- sid = ensure_up()
- c = IDAClient(URL, db=sid)
- c.connect()
- # force_recompile clears the hexrays cache so decompile is genuinely slow.
- try:
- c.call("force_recompile", addr="main")
- except IDAError:
- pass
- fin, res = deadline(lambda: c.call("decompile", addr="main", timeout=0.001), 10)
- check("tight-timeout returns (no hang)", fin)
- check("tight-timeout raises IDATimeoutError", isinstance(res, IDATimeoutError),
- f"{type(res).__name__}: {res}")
- # Client must still work afterwards (pool not poisoned).
- fin2, res2 = deadline(lambda: c.call("list_funcs", queries=[{"count": 1}]), 10)
- check("client usable after timeout", fin2 and not isinstance(res2, Exception),
- f"{type(res2).__name__}: {res2}")
- c.close()
-
-
-def s_worker_crash():
- """Hard-kill the analysis worker mid-use: calls must raise cleanly (no hang),
- and after a full restart the client recovers by re-resolving the session."""
- sid = ensure_up()
- c = IDAClient(URL, db=sid)
- c.connect()
- check("baseline call ok", isinstance(c.call("list_funcs", queries=[{"count": 1}]), (list, dict)))
-
- ctl("kill9") # kill only the worker; supervisor may stay up
- time.sleep(1.0)
-
- def probe():
- try:
- return c.call("list_funcs", queries=[{"count": 1}])
- except IDAError as e:
- return e
- fin, res = deadline(probe, 15)
- check("post-crash call returns (no hang)", fin, "call hung after worker kill")
- check("post-crash raises IDAError (clean)", isinstance(res, IDAError) or res is not None,
- f"{type(res).__name__}: {res}")
-
- # Full restart and recovery.
- ctl("stop")
- rc, _ = ctl("start")
- check("server restart ok", rc == 0)
- newsid = ensure_up()
- # A fresh client should just work.
- c2 = IDAClient(URL, db=None)
- c2.connect()
- fin3, res3 = deadline(lambda: (c2.set_db(c2.resolve_db()), c2.health())[1], 20)
- check("fresh client recovers after restart", fin3 and isinstance(res3, dict),
- f"{type(res3).__name__}: {res3}")
- check("session id stable across restart" if newsid == sid else "session id changed (expected-ok)",
- True, f"{sid} -> {newsid}")
- c.close(); c2.close()
-
-
-def s_full_restart_same_client():
- """Keep ONE auto-injected client across a full server restart. The FIRST
- naive call after restart must transparently self-heal the stale db pin (no
- internal poking, no manual re-resolve)."""
- sid = ensure_up()
- c = IDAClient(URL, db=None) # auto-resolve -> auto_recover eligible
- c.connect()
- old = c.resolve_db()
- check("pre-restart call ok", isinstance(c.health(), dict))
-
- ctl("stop")
- # While down: calls must fail cleanly, fast.
- fin, res = deadline(lambda: c.health(), 8)
- check("while-down call returns (no hang)", fin)
- check("while-down raises IDAError", isinstance(res, IDAError), f"{type(res).__name__}: {res}")
-
- ctl("start")
- ensure_up()
- # NAIVE call: no internal poking. Auto-recovery must kick in on the stale
- # "Session not found" and retry against the fresh session.
- fin2, res2 = deadline(lambda: c.health(), 25)
- check("naive call auto-recovers after restart", fin2 and isinstance(res2, dict),
- f"{type(res2).__name__}: {res2}")
- check("db pin was re-resolved to new session", c.db is not None and c.db != old,
- f"old={old} new={c.db}")
- c.close()
-
-
-def s_no_autoswitch_when_explicit():
- """With auto_recover disabled (or an explicitly pinned db), a stale session
- must NOT be silently switched — it raises so the caller stays in control."""
- sid = ensure_up()
- c = IDAClient(URL, db=sid, auto_recover_session=False)
- c.connect()
- check("pre-restart call ok", isinstance(c.health(), dict))
- ctl("stop"); ctl("start"); newsid = ensure_up()
- fin, res = deadline(lambda: c.health(), 15)
- check("explicit-pin call returns (no hang)", fin)
- check("explicit-pin raises IDAToolError (no silent switch)",
- isinstance(res, IDAToolError), f"{type(res).__name__}: {res}")
- check("db pin unchanged when recovery disabled", c.db == sid, f"{c.db} vs {sid}")
- c.close()
-
-
-def s_concurrency_high():
- """Heavy concurrency: 300 calls over 16 threads, all succeed, no deadlock."""
- sid = ensure_up()
- c = IDAClient(URL, db=sid, pool_size=8)
- c.connect()
- N = 300
- errors = []
- def work(i):
- try:
- tool = ("list_funcs", "disasm")[i % 2]
- if tool == "list_funcs":
- return bool(c.call("list_funcs", queries=[{"count": 3}]))
- return bool(c.call("disasm", addr="main", max_instructions=5))
- except IDAError as e:
- errors.append(e); return False
- def run_all():
- with concurrent.futures.ThreadPoolExecutor(max_workers=16) as ex:
- return sum(ex.map(work, range(N)))
- fin, ok = deadline(run_all, 60)
- check("300/16 concurrent finished (no deadlock)", fin, "pool deadlocked")
- check("300/16 all succeeded", ok == N, f"{ok}/{N}, errs={errors[:3]}")
- c.close()
-
-
-def s_concurrency_under_churn():
- """Hammer concurrently while the worker is hard-killed mid-flight. No hang,
- no deadlock; errors are clean; after respawn calls succeed again."""
- sid = ensure_up()
- c = IDAClient(URL, db=sid, pool_size=8)
- c.connect()
- stop = threading.Event()
- stats = {"ok": 0, "err": 0, "weird": 0}
- lock = threading.Lock()
- def spinner():
- while not stop.is_set():
- try:
- c.call("list_funcs", queries=[{"count": 1}])
- with lock: stats["ok"] += 1
- except IDAError:
- with lock: stats["err"] += 1
- except Exception: # noqa: BLE001 -- any non-IDAError is a bug
- with lock: stats["weird"] += 1
- time.sleep(0.01)
- threads = [threading.Thread(target=spinner, daemon=True) for _ in range(12)]
- for t in threads: t.start()
- time.sleep(1.0)
- ctl("kill9") # crash the worker under load
- time.sleep(2.0)
- stop.set()
- for t in threads: t.join(10)
- alive = [t for t in threads if t.is_alive()]
- check("no spinner thread hung on worker kill", not alive, f"{len(alive)} stuck")
- check("no non-IDAError leaked during churn", stats["weird"] == 0, str(stats))
- log(f" churn stats: {stats}")
- # Recover.
- ctl("stop"); ctl("start"); ensure_up()
- c.close()
-
-
-SCENARIOS = {
- "connect_refused": s_connect_refused,
- "timeout": s_timeout,
- "worker_crash": s_worker_crash,
- "full_restart_same_client": s_full_restart_same_client,
- "no_autoswitch_when_explicit": s_no_autoswitch_when_explicit,
- "concurrency_high": s_concurrency_high,
- "concurrency_under_churn": s_concurrency_under_churn,
-}
-
-
-def main(argv):
- names = argv or list(SCENARIOS)
- for name in names:
- fn = SCENARIOS.get(name)
- if not fn:
- log(f"unknown scenario: {name} (have: {', '.join(SCENARIOS)})")
- return 2
- log(f"\n[{name}]")
- t0 = time.time()
- try:
- fn()
- except Exception as e: # noqa: BLE001
- global FAIL
- FAIL += 1
- log(f" FAIL {name} raised {type(e).__name__}: {e}")
- log(f" ({time.time() - t0:.1f}s)")
- log("\n== ensuring server is UP for subsequent work ==")
- sid = ensure_up()
- log(f" server ready, db={sid}")
- log(f"\n{PASS} passed, {FAIL} failed")
- return 1 if FAIL else 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main(sys.argv[1:]))
diff --git a/tests/stress_paging.py b/tests/stress_paging.py
deleted file mode 100644
index b821354..0000000
--- a/tests/stress_paging.py
+++ /dev/null
@@ -1,150 +0,0 @@
-#!/usr/bin/env python3
-"""Stress the 'many lines of text' problem: paging huge listings/functions.
-
-Validates the windowing strategy the TUI will use so we never hand a widget more
-than a viewport-sized slice. Prints concise stats only.
-
- python3 tests/stress_paging.py --db <session_id>
-"""
-import os
-import statistics
-import sys
-import time
-
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-from idatui.client import IDAClient # noqa: E402
-
-URL = "http://127.0.0.1:8745/mcp"
-# Verified server cap: list_* honors count up to ~700, then silently collapses
-# to a 10-item default. Clamp with margin.
-SAFE_PAGE = 500
-
-
-def ms(fn):
- t = time.time()
- r = fn()
- return (time.time() - t) * 1e3, r
-
-
-def q0(payload):
- """Unwrap a *_query/list_* payload: {'result':[{'data':[...],'next_offset':N}]}."""
- res = payload.get("result", payload)
- if isinstance(res, list):
- res = res[0] if res else {}
- return res.get("data", []), res.get("next_offset")
-
-
-def page_all_funcs(c, page=SAFE_PAGE):
- """Enumerate the entire function list correctly: advance by len(data), NOT by
- next_offset (which is just offset+count and skips over the per-call cap)."""
- funcs, offset, pages, t0 = [], 0, 0, time.time()
- while True:
- payload = c.call("list_funcs", queries=[{"offset": offset, "count": page}])
- data, _ = q0(payload)
- funcs.extend(data)
- pages += 1
- if len(data) < page: # short page => reached the end
- break
- offset += len(data)
- return funcs, pages, (time.time() - t0) * 1e3
-
-
-def disasm_meta(c, addr):
- # total_instructions is a TOP-LEVEL field, not under 'asm'.
- payload = c.call("disasm", addr=addr, max_instructions=1, include_total=True)
- return payload.get("total_instructions", payload.get("instruction_count"))
-
-
-def main(argv):
- db = None
- it = iter(argv)
- for a in it:
- if a == "--db":
- db = next(it)
- c = IDAClient(URL, db=db)
- c.connect()
- if db is None:
- c.set_db(c.resolve_db())
- print(f"db={c.db} ({c.health().get('module')})")
-
- # ---- 1. Full function list via cursor pagination ------------------- #
- print("\n[1] page entire function list (cursor)")
- funcs, pages, total_ms = page_all_funcs(c, page=SAFE_PAGE)
- n = len(funcs)
- print(f" {n} functions in {pages} pages, {total_ms:.0f}ms "
- f"({total_ms / max(n,1):.3f}ms/func)")
-
- def sz(f):
- s = f["size"]
- return int(s, 16) if isinstance(s, str) else s
- biggest = sorted(funcs, key=sz, reverse=True)[:5]
- print(" biggest by bytes:")
- for f in biggest:
- print(f" {f['addr']:>12} {sz(f):#8x} {f['name']}")
-
- # ---- 2. Instruction counts of the biggest -------------------------- #
- print("\n[2] instruction totals of biggest funcs")
- fattest = None
- fattest_n = 0
- for f in biggest:
- dt, total = ms(lambda f=f: disasm_meta(c, f["addr"]))
- print(f" {f['name']:<28} {str(total):>8} insns (meta {dt:.1f}ms)")
- if isinstance(total, int) and total > fattest_n:
- fattest, fattest_n = f, total
-
- # ---- 3. Windowed paging INTO the fattest function ------------------ #
- print(f"\n[3] window-scroll fattest func {fattest['name']} ({fattest_n} insns)")
- WIN = 60 # a viewport
- offsets = list(range(0, max(fattest_n - WIN, 1), max((fattest_n // 12), 1)))
- times = []
- for off in offsets:
- dt, payload = ms(lambda off=off: c.call(
- "disasm", addr=fattest["addr"], offset=off, max_instructions=WIN))
- lines = payload.get("asm", {}).get("lines", [])
- times.append(dt)
- assert len(lines) <= WIN, f"got {len(lines)} > window {WIN}"
- print(f" {len(offsets)} windowed reads @win={WIN}: "
- f"min={min(times):.1f} med={statistics.median(times):.1f} "
- f"max={max(times):.1f} ms (payload capped to <= {WIN} lines)")
-
- # ---- 3b. O(offset) latency curve (the key hazard) ------------------ #
- print("\n[3b] disasm offset-latency curve (offset paging is O(offset))")
- for off in [0, 1000, 5000, 20000, min(50000, fattest_n - WIN)]:
- if off < 0:
- continue
- dt, _ = ms(lambda off=off: c.call(
- "disasm", addr=fattest["addr"], offset=off, max_instructions=WIN))
- print(f" offset={off:>6}: {dt:6.1f}ms")
-
- # ---- 4. Simulated 'hold page-down' throughput ---------------------- #
- print("\n[4] rapid sequential scroll (hold page-down)")
- reads, t0, off = 0, time.time(), 0
- while time.time() - t0 < 2.0:
- c.call("disasm", addr=fattest["addr"], offset=off, max_instructions=WIN)
- off = (off + WIN) % max(fattest_n - WIN, 1)
- reads += 1
- dur = time.time() - t0
- print(f" {reads} windowed reads in {dur:.1f}s = {reads / dur:.0f} reads/s "
- f"(~{reads / dur * WIN:.0f} lines/s)")
-
- # ---- 5. Decompile: truncation AND hard-failure on monsters --------- #
- print("\n[5] decompile on fattest func (may fail on huge funcs)")
- dt, payload = ms(lambda: c.call("decompile", addr=fattest["addr"]))
- code = payload.get("code") if isinstance(payload, dict) else None
- if not code:
- err = payload.get("error") if isinstance(payload, dict) else payload
- print(f" decompile {dt:.0f}ms -> FAILED (soft error, not raised): {err}")
- else:
- marker = "chars total]" in code[-40:]
- print(f" decompile {dt:.0f}ms, returned {len(code)} chars, "
- f"server-truncated={marker}")
- if marker:
- print(f" tail: ...{code[-60:]!r}")
-
- c.close()
- print("\nOK")
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main(sys.argv[1:]))
diff --git a/tests/test_blob_ui.py b/tests/test_blob_ui.py
new file mode 100644
index 0000000..f930401
--- /dev/null
+++ b/tests/test_blob_ui.py
@@ -0,0 +1,235 @@
+#!/usr/bin/env python3
+"""A blob that analyses to NOTHING must still be usable.
+
+Zero functions is the normal outcome for a raw image described wrongly — and it
+used to leave two empty panes and a status that said "functions still loading…",
+which was a lie: loading had finished, there was simply nothing to land on.
+
+Needs IDA (spawns a real worker). ~40s.
+"""
+import asyncio
+import os
+import sys
+import tempfile
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from textual.widgets import Input, Static # noqa: E402
+
+from idatui.app import ConfirmScreen, IdaTui, ListingView # noqa: E402
+
+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}")
+
+
+async def wait(pred, pilot, t=240.0):
+ for _ in range(int(t / 0.05)):
+ await pilot.pause(0.05)
+ try:
+ if pred():
+ return True
+ except Exception: # noqa: BLE001
+ pass
+ return False
+
+
+async def run() -> int:
+ with tempfile.TemporaryDirectory() as tmp:
+ # Random bytes so IDA finds no functions... but with REAL AArch64
+ # instructions planted at a known offset. Whether arbitrary random bytes
+ # happen to decode is chance, and a test that depends on chance tells you
+ # nothing on the run where it fails.
+ data = bytearray(os.urandom(64 * 1024))
+ # -parm puts IDA in AArch64 mode, so these are A64 encodings; the ARM32
+ # spelling of a nop (0xE1A00000) is NOT decodable there and made this
+ # test fail for a reason that had nothing to do with what it checks.
+ planted = 0x40 # file offset -> ea 0x4040
+ for k, insn in enumerate((0xD503201F, # nop
+ 0xD503201F, # nop
+ 0xD65F03C0)): # ret <- the run must stop here
+ data[planted + k * 4:planted + k * 4 + 4] = insn.to_bytes(4, "little")
+ blob = os.path.join(tmp, "rnd.bin")
+ with open(blob, "wb") as f:
+ f.write(bytes(data))
+
+ # Skip the dialog by answering up front; this test is about what
+ # happens AFTER a described blob turns out to contain nothing.
+ app = IdaTui(open_path=blob, keepalive=False, load_args="-parm -b400")
+ async with app.run_test(size=(140, 44)) as pilot:
+ ok = await wait(lambda: app._func_index is not None
+ and app._func_index.complete, pilot)
+ check("a random blob still finishes loading", ok)
+ check("and really has no functions", len(app._func_index) == 0,
+ f"n={len(app._func_index)}")
+
+ landed = await wait(lambda: app._cur is not None, pilot, 60)
+ check("it lands somewhere instead of leaving empty panes", landed,
+ f"cur={app._cur}")
+ lst = app.query_one(ListingView)
+ check("the listing actually has rows to show",
+ lst.total > 0, f"total={lst.total}")
+ check("landed at the image base", app._cur is not None
+ and app._cur.ea == 0x4000, f"{app._cur.ea if app._cur else None:#x}")
+
+ status = str(app.query_one("#status", Static).render())
+ check("the status says there are no functions (not 'still loading')",
+ "no functions" in status and "still loading" not in status,
+ status[:90])
+ check("and points at the likely cause",
+ "processor" in status and "Ctrl+L" in status, status[:90])
+
+ # The hint is a property of the database, so it must survive moving
+ # around — an earlier version wrote it once and the next status
+ # write erased it.
+ lst.focus()
+ await pilot.press("down")
+ await pilot.press("down")
+ await pilot.pause(0.3)
+ status2 = str(app.query_one("#status", Static).render())
+ check("the hint survives navigating", "no functions" in status2,
+ status2[:90])
+
+ # -- byte-granular carving ---------------------------------- #
+ # An undefined run arrives as ONE head ("db N dup(?)"). It has to
+ # PRESENT as one row per byte or there is no cursor position to
+ # press `c` at, which is how IDA works and the only way to find an
+ # instruction stream that doesn't start at the run's first byte.
+ m = lst.model
+ check("an undefined run presents one row per byte",
+ m.loaded() >= 4096 and len(m._heads) < 64,
+ f"rows={m.loaded()} physical heads={len(m._heads)}")
+ rows = m.window(0, 4)
+ check("each row is a single addressable byte",
+ [h.ea for h in rows] == [0x4000, 0x4001, 0x4002, 0x4003]
+ and all(h.size == 1 for h in rows),
+ f"{[(hex(h.ea), h.size) for h in rows]}")
+ check("and shows its value, not a placeholder",
+ all(h.text.startswith("db ") and "dup" not in h.text
+ for h in rows), f"{[h.text for h in rows]}")
+
+ # The point of all this: land on an arbitrary byte and convert it.
+ i = m.index_of_ea(0x4021)
+ check("an address inside the run resolves to its own row",
+ i >= 0 and m.get(i).ea == 0x4021,
+ f"row={i} ea={m.get(i).ea if i >= 0 else None}")
+
+ target = 0x4000 + planted # a NOP we put there ourselves
+ lst.cursor = m.index_of_ea(target)
+ lst._scroll_cursor_into_view()
+ await pilot.pause(0.1)
+ check("the cursor sits on the byte we aimed at",
+ lst._cursor_ea() == target,
+ f"{lst._cursor_ea():#x} want {target:#x}")
+ await pilot.press("c")
+ await pilot.pause(2.0)
+ # Defining an item REBUILDS the listing model, so re-read it from the
+ # view: holding the old object shows the pre-edit rows and looks
+ # exactly like the edit silently failing.
+ m = lst.model
+ h = m.get(m.index_of_ea(target))
+ check("`c` on a chosen byte carves an instruction there",
+ h is not None and h.kind == "code",
+ f"kind={h.kind if h else None} text={h.text if h else None!r}")
+ if h is not None and h.kind == "code":
+ print(f" carved {target:#x}: {h.text}")
+ # `c` runs until something stops it, like IDA — one instruction
+ # at a time means pressing it once per opcode for the length of
+ # a routine. We planted nop/nop/nop/ret, so it must take all
+ # four and stop AT the ret, not run on into the random bytes
+ # after it.
+ run = [m.get(m.index_of_ea(target + k * 4)) for k in range(3)]
+ check("`c` keeps going until control flow ends",
+ all(x is not None and x.kind == "code" for x in run),
+ f"{[(hex(x.ea), x.kind) for x in run if x]}")
+ check("and stops at the ret instead of running into junk",
+ m.get(m.index_of_ea(target + 12)).kind == "unknown",
+ f"{m.get(m.index_of_ea(target + 12)).text!r}")
+ status = str(app.query_one("#status", Static).render())
+ check("the status reports what the run did",
+ "3 instructions" in status and "control flow" in status,
+ status[:80])
+ check("the carved row spans the instruction, not one byte",
+ h.size == 4, f"size={h.size}")
+ check("bytes before it stay individually addressable",
+ m.get(m.index_of_ea(target - 1)).size == 1
+ and m.get(m.index_of_ea(target - 1)).ea == target - 1)
+
+ # -- an edit must not move the view -------------------------- #
+ # Every mutation rebuilds the model, and row indices don't survive
+ # that: carving collapses four byte rows into one instruction row.
+ # All the edit paths go through ViewAnchor, which remembers
+ # ADDRESSES. This runs in its own app, so unlike the shared scenario
+ # suite it can mutate the database freely.
+ lst.cursor = m.index_of_ea(0x4200)
+ lst._scroll_cursor_into_view()
+ await pilot.pause(0.4)
+ ctop = lst.model.get(round(lst.scroll_offset.y)).ea
+ ccur = lst._cursor_ea()
+ old = lst.model
+ await pilot.press("semicolon")
+ await wait(lambda: app.query_one("#comment", Input).display, pilot, 20)
+ for ch in "note":
+ await pilot.press(ch)
+ await pilot.press("enter")
+ await wait(lambda: lst.model is not old and lst.model is not None,
+ pilot, 30)
+ await pilot.pause(0.4)
+ check("commenting leaves the view where it was",
+ lst.model.get(round(lst.scroll_offset.y)).ea == ctop
+ and lst._cursor_ea() == ccur,
+ f"top {ctop:#x} -> "
+ f"{lst.model.get(round(lst.scroll_offset.y)).ea:#x}, "
+ f"cursor {ccur:#x} -> {lst._cursor_ea():#x}")
+
+ # -- carving must not move the view -------------------------- #
+ # Defining code collapses rows (four byte rows become one
+ # instruction), so anything that remembers a row INDEX puts you
+ # somewhere else afterwards. Scroll down far enough that there is a
+ # viewport to lose, then check the top ADDRESS is unchanged.
+ far = 0x4000 + 0x600
+ lst.cursor = lst.model.index_of_ea(far)
+ lst._scroll_cursor_into_view()
+ await pilot.pause(0.4)
+ top_before = lst.model.get(round(lst.scroll_offset.y)).ea
+ cur_before = lst._cursor_ea()
+ check("scrolled somewhere with rows above us",
+ round(lst.scroll_offset.y) > 0, f"top={lst.scroll_offset.y}")
+ await pilot.press("c")
+ await pilot.pause(2.5)
+ m2 = lst.model
+ top_after = m2.get(round(lst.scroll_offset.y)).ea
+ check("carving leaves the scroll position where it was",
+ top_after == top_before,
+ f"{top_before:#x} -> {top_after:#x}")
+ check("and leaves the cursor on the same address",
+ lst._cursor_ea() == cur_before,
+ f"{cur_before:#x} -> {lst._cursor_ea():#x}")
+
+ await pilot.press("ctrl+l")
+ opened = await wait(lambda: isinstance(app.screen, ConfirmScreen), pilot, 20)
+ check("Ctrl+L offers to reload with different options", opened,
+ f"screen={type(app.screen).__name__}")
+ if opened:
+ note = str(app.screen.query_one("#confirm-note", Static).render())
+ check("and says nothing is lost when there's nothing to lose",
+ "nothing is lost" in note, note[:70])
+ await pilot.press("escape")
+ await pilot.pause(0.3)
+ check("declining leaves the binary open",
+ not isinstance(app.screen, ConfirmScreen) and app._cur is not None)
+
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(asyncio.run(run()))
diff --git a/tests/test_domain.py b/tests/test_domain.py
deleted file mode 100644
index 6875b66..0000000
--- a/tests/test_domain.py
+++ /dev/null
@@ -1,155 +0,0 @@
-#!/usr/bin/env python3
-"""Validate the domain/paging layer against a real, large binary.
-
-Run with a session open on a big binary (e.g. libcrypto.so.3, 10k funcs):
-
- python3 tests/test_domain.py --db <session_id>
-
-Checks: correct full pagination (advance by len, not next_offset), viewport
-slicing across block boundaries, window caching (revisit is instant), prefetch
-warming, cached instruction totals, decompile success + hard-failure handling,
-and address resolution.
-"""
-import os
-import sys
-import time
-
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-from idatui.client import IDAClient # noqa: E402
-from idatui.domain import DISASM_BLOCK, LIST_PAGE, Program # noqa: E402
-
-URL = "http://127.0.0.1:8745/mcp"
-PASS = FAIL = 0
-
-
-def check(name, cond, detail=""):
- global PASS, FAIL
- if cond:
- PASS += 1
- print(f" ok {name}")
- else:
- FAIL += 1
- print(f" FAIL {name} {detail}")
-
-
-def ms(fn):
- t = time.time()
- r = fn()
- return (time.time() - t) * 1e3, r
-
-
-def main(argv):
- db = None
- it = iter(argv)
- for a in it:
- if a == "--db":
- db = next(it)
- c = IDAClient(URL, db=db)
- c.connect()
- if db is None:
- c.set_db(c.resolve_db())
- prog = Program(c)
- module = c.health().get("module")
- total_funcs = c.call("survey_binary").get("statistics", {}).get("total_functions")
- print(f"db={c.db} module={module} survey_total_functions={total_funcs}")
-
- # ---- function index: full enumeration correctness ------------------ #
- print("\n[function index]")
- idx = prog.functions()
- dt, _ = ms(lambda: idx.load_all())
- n = len(idx)
- check("enumerated all functions (matches survey)", n == total_funcs,
- f"got {n} vs survey {total_funcs}")
- print(f" loaded {n} funcs in {dt:.0f}ms ({dt / max(n,1):.3f}ms/func), "
- f"page={LIST_PAGE}")
- # no duplicates, monotonic-ish uniqueness by addr
- addrs = [idx.get(i).addr for i in range(min(n, 3000))]
- check("no duplicate addrs in first 3000", len(addrs) == len(set(addrs)))
- # viewport slice (adapt to binary size)
- wstart = min(1000, max(n - 50, 0))
- wlen = min(50, n - wstart)
- w = idx.window(wstart, wlen)
- check(f"window({wstart},{wlen}) returns {wlen}", len(w) == wlen, str(len(w)))
-
- # ---- filtered index uses server-side glob -------------------------- #
- print("\n[filtered index]")
- sub = prog.functions(filter="sub_*")
- sub.ensure(10)
- check("filter sub_* yields sub_ names", all(f.name.startswith("sub_") for f in sub.window(0, 10)),
- str([f.name for f in sub.window(0, 5)]))
-
- # ---- pick the fattest function for disasm stress ------------------- #
- print("\n[disasm windowing]")
- fattest = max((idx.get(i) for i in range(n)), key=lambda f: f.size)
- dm = prog.disasm(fattest.addr, fattest.name)
- dt, total = ms(dm.total)
- check("total() returns positive count", total > 0, str(total))
- dt2, total2 = ms(dm.total)
- check("total() cached (2nd call ~instant)", dt2 < dt / 2 + 1, f"{dt:.0f}ms -> {dt2:.1f}ms")
- print(f" fattest {fattest.name}: {total} insns, total() {dt:.0f}ms then {dt2:.1f}ms")
-
- # viewport across a block boundary
- start = DISASM_BLOCK - 5
- win = dm.lines(start, 60, prefetch=False)
- check("viewport spans block boundary, right length",
- len(win) == min(60, max(total - start, 0)), f"got {len(win)}")
- # addresses strictly increasing and contiguous slice
- eas = [ln.ea for ln in win]
- check("viewport addrs strictly increasing", all(b > a for a, b in zip(eas, eas[1:])),
- str(eas[:4]))
-
- # deep window: first slow (O(offset)), revisit instant (cached)
- if total > DISASM_BLOCK * 4:
- deep = (total // DISASM_BLOCK - 1) * DISASM_BLOCK
- dt_cold, a = ms(lambda: dm.lines(deep, 60, prefetch=False))
- dt_warm, b = ms(lambda: dm.lines(deep, 60, prefetch=False))
- check("deep window revisit is cached/instant", dt_warm < dt_cold / 2 + 1,
- f"cold={dt_cold:.0f}ms warm={dt_warm:.1f}ms")
- check("cached window identical", [l.ea for l in a] == [l.ea for l in b])
- print(f" deep@{deep}: cold={dt_cold:.0f}ms warm={dt_warm:.1f}ms")
-
- # prefetch warms the next block
- print("\n[prefetch]")
- dm2 = prog.disasm(fattest.addr + 0) # same model (cached by ea)
- fresh = prog.disasm(idx.get(0).addr, idx.get(0).name)
- fresh.lines(0, 60, prefetch=True) # should prefetch block 1
- time.sleep(0.3)
- check("prefetch warmed a neighbor block", fresh.cached_blocks() >= 2,
- f"cached_blocks={fresh.cached_blocks()}")
-
- # ---- decompile: success + hard-failure ----------------------------- #
- print("\n[decompile]")
- # a small function likely decompiles
- small = min((idx.get(i) for i in range(n)), key=lambda f: f.size if f.size > 4 else 1 << 30)
- d_small = prog.decompile(small.addr)
- check("small func decompiles or fails cleanly", isinstance(d_small.failed, bool))
- dt_c, _ = ms(lambda: prog.decompile(small.addr))
- check("decompile cached (2nd ~instant)", dt_c < 5, f"{dt_c:.1f}ms")
- # the monster should hard-fail as a soft error (not raise)
- d_big = prog.decompile(fattest.addr)
- check("monster decompile handled (failed flag, no raise)",
- d_big.failed or d_big.code is not None,
- f"failed={d_big.failed} err={d_big.error}")
- print(f" small {small.name}: failed={d_small.failed} "
- f"trunc={d_small.truncated} chars={d_small.total_chars}")
- print(f" monster {fattest.name}: failed={d_big.failed} err={d_big.error}")
-
- # ---- resolve ------------------------------------------------------- #
- print("\n[resolve]")
- check("resolve hex", prog.resolve(hex(fattest.addr)) == fattest.addr)
- check("resolve int passthrough", prog.resolve(fattest.addr) == fattest.addr)
- named = next((idx.get(i) for i in range(n) if not idx.get(i).name.startswith("sub_")), None)
- if named:
- try:
- r = prog.resolve(named.name)
- check("resolve symbol name", r == named.addr, f"{hex(r)} vs {hex(named.addr)} ({named.name})")
- except KeyError as e:
- check("resolve symbol name", False, str(e))
-
- prog.close()
- print(f"\n{PASS} passed, {FAIL} failed")
- return 1 if FAIL else 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main(sys.argv[1:]))
diff --git a/tests/test_formats.py b/tests/test_formats.py
new file mode 100644
index 0000000..ab72e8d
--- /dev/null
+++ b/tests/test_formats.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+"""Format sniffing + load-switch construction (pure stdlib, no IDA)."""
+import os
+import sys
+import tempfile
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from idatui.formats import (PROCESSORS, load_args, # noqa: E402
+ needs_load_options, sniff)
+
+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 main() -> int:
+ with tempfile.TemporaryDirectory() as tmp:
+ def w(name, data):
+ p = os.path.join(tmp, name)
+ with open(p, "wb") as f:
+ f.write(data)
+ return p
+
+ # -- formats IDA can load on its own: never interrupt the user -------- #
+ for name, head in (("elf", b"\x7fELF\x02\x01\x01"), ("pe", b"MZ\x90\x00"),
+ ("macho", b"\xcf\xfa\xed\xfe"), ("dex", b"dex\n035\x00"),
+ ("wasm", b"\x00asm\x01\x00")):
+ p = w(name, head + bytes(60))
+ check(f"{name} is recognised (no dialog)",
+ sniff(p) is not None and not needs_load_options(p), f"{sniff(p)}")
+
+ # -- the case this exists for ----------------------------------------- #
+ blob = w("fw.bin", bytes(range(256)) * 4)
+ check("a headerless blob is not recognised (ask)",
+ sniff(blob) is None and needs_load_options(blob))
+
+ # Intel HEX / S-records are text containers IDA does load.
+ hexf = w("f.hex", b":10010000214601360121470136007EFE09D2190140\n")
+ check("Intel HEX is recognised", sniff(hexf) == "Intel HEX", f"{sniff(hexf)}")
+ srec = w("f.s19", b"S00600004844521B\nS1130000285F245F2212226A000424290008237C\n")
+ check("Motorola S-records are recognised",
+ sniff(srec) == "Motorola S-record", f"{sniff(srec)}")
+
+ # A binary that merely STARTS with ':' is not Intel HEX. Text formats are
+ # only accepted when the whole head is printable, or half the firmware in
+ # the world gets mis-detected on one byte.
+ colon = w("colon.bin", b":\x00\xff\xfe\x01\x02" + bytes(58))
+ check("a blob starting with ':' is not mistaken for Intel HEX",
+ sniff(colon) is None, f"{sniff(colon)}")
+
+ check("an empty file is not recognised", sniff(w("empty", b"")) is None)
+ check("a missing file never asks (nothing to load)",
+ not needs_load_options(os.path.join(tmp, "nope")))
+ check("a directory never asks", not needs_load_options(tmp))
+
+ # -- switch construction ------------------------------------------------- #
+ # -b is in PARAGRAPHS: 0x8000000 >> 4 == 0x800000. Getting this wrong loads
+ # the image 16x off and every address in the database is wrong.
+ check("base is converted to paragraphs",
+ load_args("arm", 0x8000000) == "-parm -b800000",
+ load_args("arm", 0x8000000))
+ check("processor alone", load_args("mipsb") == "-pmipsb", load_args("mipsb"))
+ check("base alone", load_args("", 0x10000) == "-b1000", load_args("", 0x10000))
+ check("base 0 emits no switch (it's the default)",
+ load_args("arm", 0) == "-parm", load_args("arm", 0))
+ check("nothing in, nothing out", load_args() == "")
+ check("extra switches pass through",
+ load_args("arm", 0, "-T binary") == "-parm -T binary")
+
+ check("the processor list leads with the common targets",
+ [n for n, _ in PROCESSORS[:3]] == ["arm", "armb", "metapc"],
+ f"{[n for n, _ in PROCESSORS[:3]]}")
+
+ # Every offered name must have been checked against a real IDA, because a
+ # wrong one is REJECTED (rc=4) with nothing useful said — handing the user a
+ # dead end from inside the dialog meant to rescue them. This list is the
+ # output of tools/verify_procs.py; adding a processor without re-running it
+ # fails here on purpose.
+ VERIFIED = {
+ "arm", "armb", "metapc", "mipsl", "mipsb", "ppc", "ppcl", "sh4", "68k",
+ "riscv", "tricore", "xtensa", "avr", "z80", "tms320c6", "m32r", "arc",
+ "h8300", "sparcb", "sparcl", "s390",
+ }
+ offered = {n for n, _ in PROCESSORS}
+ check("every offered processor name is IDA-verified",
+ offered <= VERIFIED, f"unverified: {sorted(offered - VERIFIED)}")
+
+ # These are module FILENAMES or common aliases, not -p names. IDA refuses
+ # them; they were in the list until a real run said otherwise.
+ for wrong in ("h8", "sparc", "arm64", "aarch64", "mips", "m68k"):
+ check(f"{wrong!r} is not offered (IDA rejects it)", wrong not in offered)
+
+ # ...but someone WILL type them, so the labels have to carry the alias.
+ def finds(q):
+ ql = q.lower()
+ return [n for n, d in PROCESSORS if ql in n.lower() or ql in d.lower()]
+ check("typing 'arm64' still finds ARM", "arm" in finds("arm64"), f"{finds('arm64')}")
+ check("typing 'aarch64' still finds ARM", "arm" in finds("aarch64"))
+ check("typing 'm68k' still finds 68k", "68k" in finds("m68k"), f"{finds('m68k')}")
+ check("typing 'mips' finds both endiannesses",
+ set(finds("mips")) == {"mipsl", "mipsb"}, f"{finds('mips')}")
+ check("every processor entry has a human label",
+ all(n and d for n, d in PROCESSORS))
+ check("endianness is spelled out where it matters",
+ all(any(w in d.lower() for w in ("endian",))
+ for n, d in PROCESSORS if n in ("arm", "armb", "mipsb", "mipsl")))
+
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_index.py b/tests/test_index.py
new file mode 100644
index 0000000..e14f715
--- /dev/null
+++ b/tests/test_index.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python3
+"""Unit tests for idatui.index (the project-wide symbol/string index).
+
+Pure stdlib: no IDA, no textual, no worker.
+
+ python tests/test_index.py
+"""
+import os
+import sys
+import tempfile
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from idatui.index import (KIND_EXPORT, KIND_FUNC, KIND_IMPORT, # noqa: E402
+ KIND_STRING, ProjectIndex)
+
+PASS = FAIL = 0
+
+
+def check(name, cond, detail=""):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print(f" ok {name}")
+ else:
+ FAIL += 1
+ print(f" FAIL {name} {detail}")
+
+
+def main() -> int:
+ with tempfile.TemporaryDirectory() as tmp:
+ src = os.path.join(tmp, "libfoo.so")
+ with open(src, "wb") as f:
+ f.write(b"\x7fELF binary")
+ idx = ProjectIndex(os.path.join(tmp, "idx", "project.db"))
+
+ check("a fresh index is empty", idx.total() == 0 and idx.counts() == {})
+ check("an unindexed binary is stale", idx.is_stale("libfoo", src))
+
+ n = idx.reindex("libfoo", [
+ (KIND_FUNC, 0x1000, "SSL_CTX_new"),
+ (KIND_FUNC, 0x1100, "SSL_read"),
+ (KIND_FUNC, 0x1200, "sub_1200"),
+ (KIND_STRING, 0x8000, "error opening socket"),
+ (KIND_STRING, 0x8100, "/etc/ssl/certs"),
+ ], source=src)
+ check("reindex reports what it stored", n == 5, f"n={n}")
+ check("the entries are there", idx.total() == 5, f"{idx.total()}")
+ check("an indexed binary is fresh", not idx.is_stale("libfoo", src))
+
+ # -- substring search (the thing a prefix index can't do) ----------- #
+ hits = idx.search("SSL", kind=KIND_FUNC)
+ check("finds symbols by substring", {h.text for h in hits} ==
+ {"SSL_CTX_new", "SSL_read"}, f"{[h.text for h in hits]}")
+ check("matching ignores case across kinds (SSL also hits /etc/ssl)",
+ {h.text for h in idx.search("SSL")} ==
+ {"SSL_CTX_new", "SSL_read", "/etc/ssl/certs"},
+ f"{[h.text for h in idx.search('SSL')]}")
+ hits = idx.search("socket")
+ check("finds strings by substring mid-text",
+ len(hits) == 1 and hits[0].kind == KIND_STRING
+ and hits[0].addr == 0x8000, f"{hits}")
+ check("hits carry the owning binary",
+ all(h.binary == "libfoo" for h in idx.search("SSL")))
+ check("search is case-insensitive",
+ {h.text for h in idx.search("ssl_read")} == {"SSL_read"},
+ f"{[h.text for h in idx.search('ssl_read')]}")
+ check("kind filter narrows to strings",
+ [h.text for h in idx.search("ss", kind=KIND_STRING)] == ["/etc/ssl/certs"],
+ f"{[h.text for h in idx.search('ss', kind=KIND_STRING)]}")
+
+ # -- the <3 char fallback (trigram silently matches nothing) -------- #
+ check("2-char query still works (LIKE fallback)",
+ {h.text for h in idx.search("ss")} == {"SSL_CTX_new", "SSL_read",
+ "/etc/ssl/certs"},
+ f"{[h.text for h in idx.search('ss')]}")
+ check("1-char query still works",
+ len(idx.search("/")) == 1, f"{idx.search('/')}")
+ check("an empty query matches nothing", idx.search(" ") == [])
+ check("a query with FTS operators is treated literally",
+ idx.search('SSL OR "') == [] or True) # must not raise
+
+ # -- multi-binary: the whole point ---------------------------------- #
+ idx.reindex("httpd", [
+ (KIND_FUNC, 0x2000, "handle_ssl_request"),
+ (KIND_STRING, 0x9000, "socket bind failed"),
+ ])
+ hits = idx.search("ssl")
+ check("search spans binaries",
+ {h.binary for h in hits} == {"libfoo", "httpd"},
+ f"{[(h.binary, h.text) for h in hits]}")
+ check("counts are per binary",
+ idx.counts() == {"libfoo": 5, "httpd": 2}, f"{idx.counts()}")
+
+ # -- incremental: reindexing one binary leaves the others alone ----- #
+ idx.reindex("libfoo", [(KIND_FUNC, 0x1000, "SSL_CTX_new_v2")], source=src)
+ check("reindex replaces only that binary's entries",
+ idx.counts() == {"libfoo": 1, "httpd": 2}, f"{idx.counts()}")
+ check("the stale entries are gone",
+ [h.text for h in idx.search("SSL_read")] == [],
+ f"{idx.search('SSL_read')}")
+ check("the other binary survived untouched",
+ len(idx.search("socket bind")) == 1)
+
+ # -- staleness follows the source ----------------------------------- #
+ time.sleep(0.01)
+ with open(src, "wb") as f:
+ f.write(b"\x7fELF binary rebuilt, different size")
+ os.utime(src, (1, 1))
+ check("a changed source goes stale", idx.is_stale("libfoo", src))
+ check("a missing source does NOT wipe the index",
+ not idx.is_stale("libfoo", os.path.join(tmp, "gone")))
+
+ # -- forget ----------------------------------------------------------- #
+ idx.forget("httpd")
+ check("forget drops a binary entirely",
+ idx.counts() == {"libfoo": 1} and idx.search("socket bind") == [],
+ f"{idx.counts()}")
+
+ # -- cross-binary linkage join (phase 3) ------------------------------ #
+ idx.reindex("app", [
+ (KIND_FUNC, 0x1000, "main"),
+ (KIND_IMPORT, 0x2000, "strcmp"),
+ (KIND_IMPORT, 0x2008, "read"),
+ (KIND_IMPORT, 0x2010, "SSL_new"),
+ ])
+ idx.reindex("libc", [
+ (KIND_EXPORT, 0x8000, "strcmp"),
+ (KIND_EXPORT, 0x8100, "read"),
+ (KIND_EXPORT, 0x8200, "pread"),
+ (KIND_EXPORT, 0x8300, "read_line"),
+ (KIND_FUNC, 0x8000, "strcmp"),
+ ])
+ idx.reindex("libssl", [(KIND_EXPORT, 0x9000, "SSL_new")])
+
+ prov = idx.providers("strcmp", exclude="app")
+ check("an import resolves to the binary that exports it",
+ [(h.binary, h.addr) for h in prov] == [("libc", 0x8000)],
+ f"{[(h.binary, hex(h.addr)) for h in prov]}")
+
+ # The whole point of exact(): substring search would drag in pread,
+ # read_line and thread_start, and 'read' is also below the trigram floor
+ # for some engines — an import must bind to its exact name or nothing.
+ prov = idx.providers("read", exclude="app")
+ check("the join is exact, not substring",
+ [(h.binary, h.addr) for h in prov] == [("libc", 0x8100)],
+ f"{[(h.binary, h.text) for h in prov]}")
+
+ check("a short name still resolves (below the trigram floor)",
+ [h.binary for h in idx.providers("SSL_new", exclude="app")] == ["libssl"],
+ f"{idx.providers('SSL_new')}")
+
+ check("an unprovided import resolves to nothing",
+ idx.providers("dlopen", exclude="app") == [])
+
+ check("exclude keeps a binary from resolving to itself",
+ idx.providers("strcmp", exclude="libc") == [],
+ f"{idx.providers('strcmp', exclude='libc')}")
+
+ imp = idx.importers("strcmp")
+ check("the reverse join finds who imports an export",
+ [(h.binary, h.addr) for h in imp] == [("app", 0x2000)],
+ f"{[(h.binary, hex(h.addr)) for h in imp]}")
+
+ check("kind keeps functions out of the linkage join",
+ [h.binary for h in idx.providers("strcmp")] == ["libc"],
+ "a KIND_FUNC row named strcmp must not answer as an export")
+
+ idx.forget("libc")
+ check("forgetting a provider unresolves its imports",
+ idx.providers("strcmp", exclude="app") == [])
+
+ # -- ELF symbol versioning -------------------------------------------- #
+ # The importer sees strrchr@@GLIBC_2.2.5 while the provider may export a
+ # different spelling; raw names would resolve almost nothing. link_name
+ # cuts at the first '@' so both sides meet on the bare symbol.
+ from idatui.domain import link_name
+ check("link_name strips an ELF version suffix",
+ link_name("strrchr@@GLIBC_2.2.5") == "strrchr",
+ link_name("strrchr@@GLIBC_2.2.5"))
+ check("link_name leaves an unversioned name alone",
+ link_name("strrchr") == "strrchr")
+ check("link_name handles a single-@ version",
+ link_name("SSL_new@OPENSSL_3.0.0") == "SSL_new")
+ check("link_name doesn't eat a leading @",
+ link_name("@weird") == "@weird", link_name("@weird"))
+
+ # -- persistence ------------------------------------------------------ #
+ path = idx.path
+ idx.close()
+ idx2 = ProjectIndex(path)
+ check("the index persists across sessions",
+ [h.text for h in idx2.search("SSL_CTX")] == ["SSL_CTX_new_v2"],
+ f"{idx2.search('SSL_CTX')}")
+ idx2.close()
+
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_keepalive.py b/tests/test_keepalive.py
deleted file mode 100644
index 2910e1a..0000000
--- a/tests/test_keepalive.py
+++ /dev/null
@@ -1,76 +0,0 @@
-#!/usr/bin/env python3
-"""Prove that an interactive session can 'just chill' without the worker idling
-out. Uses a deliberately short worker TTL to keep the test fast.
-
- python3 tests/test_keepalive.py
-
-Needs ~/ida-venv + a running server (tests/serverctl.sh) and a writable target.
-"""
-import os
-import sys
-import time
-
-HERE = os.path.dirname(os.path.abspath(__file__))
-REPO = os.path.dirname(HERE)
-sys.path.insert(0, REPO)
-from idatui.client import IDAClient, IDAToolError # noqa: E402
-
-PASS = FAIL = 0
-
-
-def check(name, cond, detail=""):
- global PASS, FAIL
- if cond:
- PASS += 1
- print(f" ok {name}")
- else:
- FAIL += 1
- print(f" FAIL {name} {detail}")
-
-
-def alive(c, sid):
- try:
- c.set_db(sid)
- c.health()
- return True
- except IDAToolError:
- return False
-
-
-def main():
- target = os.path.join(REPO, "targets", "ls_ttl")
- if not os.path.exists(target):
- src = os.path.join(REPO, "bin", "ls")
- os.makedirs(os.path.dirname(target), exist_ok=True)
- import shutil
- shutil.copy(src, target)
-
- c = IDAClient(timeout=300)
- c.connect()
- SHORT = 12 # worker self-exits after ~12s idle unless kept alive
-
- print("[heartbeat keeps a short-TTL worker alive]")
- sid = c.call("idb_open", input_path=target, idle_ttl_sec=SHORT)["session"]["session_id"]
- c.set_db(sid)
- ka = c.keepalive(interval=4.0).start()
- time.sleep(SHORT * 2 + 2) # idle well past the TTL, but heartbeat is beating
- ok = alive(c, sid)
- check("alive past 2x TTL with heartbeat", ok, f"beats={ka.beats}")
- check("heartbeat actually beat", ka.beats >= 4, f"beats={ka.beats}")
- check("heartbeat had no failures", ka.failures == 0, f"failures={ka.failures}")
- ka.stop()
-
- print("\n[bump_idle_ttl makes it effectively immortal]")
- sid = c.call("idb_open", input_path=target, idle_ttl_sec=SHORT)["session"]["session_id"]
- c.set_db(sid)
- c.bump_idle_ttl() # ~1e9 seconds
- time.sleep(SHORT * 2 + 6) # zero requests during this window
- check("alive past 2x TTL after bump, zero requests", alive(c, sid))
-
- c.close()
- print(f"\n{PASS} passed, {FAIL} failed")
- return 1 if FAIL else 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/tests/test_pool.py b/tests/test_pool.py
new file mode 100644
index 0000000..5c6e2c4
--- /dev/null
+++ b/tests/test_pool.py
@@ -0,0 +1,192 @@
+#!/usr/bin/env python3
+"""Unit tests for idatui.pool (worker residency: LRU + memory budget).
+
+Pure stdlib with a fake client injected, so the eviction policy is testable
+without spawning real idalib workers.
+
+ python tests/test_pool.py
+"""
+import os
+import sys
+import tempfile
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from idatui.pool import WorkerPool # noqa: E402
+from idatui.project import Project # noqa: E402
+
+PASS = FAIL = 0
+
+
+def check(name, cond, detail=""):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print(f" ok {name}")
+ else:
+ FAIL += 1
+ print(f" FAIL {name} {detail}")
+
+
+class FakeClient:
+ """Stands in for a WorkerClient: records saves/closes, reports fixed memory."""
+
+ def __init__(self, ref, mem=100):
+ self.ref = ref
+ self.mem = mem
+ self.saved = 0
+ self.closed = False
+ self.connected = False
+
+ def connect(self, progress=None, **kw):
+ self.connected = True
+ return self
+
+ def call(self, tool, **kw):
+ if tool == "idb_save":
+ self.saved += 1
+ return {}
+
+ def close(self, grace=None):
+ self.closed = True
+
+
+def _mkproject(tmp, n=4, size=64):
+ src = os.path.join(tmp, "src")
+ os.makedirs(src, exist_ok=True)
+ paths = []
+ for i in range(n):
+ p = os.path.join(src, f"bin{i}")
+ with open(p, "wb") as f:
+ f.write(b"\x7fELF" + bytes(size))
+ paths.append(p)
+ return Project.create(os.path.join(tmp, "p.json"), paths, name="p")
+
+
+def main() -> int:
+ with tempfile.TemporaryDirectory() as tmp:
+ proj = _mkproject(tmp)
+ made = {}
+
+ def spawn(ref, ttl):
+ c = FakeClient(ref, mem=100)
+ made[ref.label] = c
+ return c
+
+ pool = WorkerPool(proj, budget_mb=350, spawn=spawn,
+ mem_fn=lambda c: c.mem)
+
+ # -- lazy spawn + reuse -------------------------------------------- #
+ a = pool.get("bin0")
+ check("get() spawns a worker on first use", a is made["bin0"] and a.connected)
+ check("get() stages the binary first",
+ os.path.isfile(proj.by_label("bin0").staged))
+ check("get() reuses the resident worker", pool.get("bin0") is a)
+ check("resident() reports it", pool.resident() == ["bin0"], pool.resident())
+
+ # -- LRU ordering ---------------------------------------------------- #
+ pool.get("bin1")
+ pool.get("bin2")
+ pool.get("bin0") # touch: bin0 becomes most-recent
+ check("LRU order tracks use", pool.resident() == ["bin1", "bin2", "bin0"],
+ pool.resident())
+
+ # -- budget eviction -------------------------------------------------- #
+ check("pool reports its memory", pool.memory_mb() == 300, pool.memory_mb())
+ pool.get("bin3") # 400MB > 350MB budget -> evict LRU (bin1)
+ check("exceeding the budget evicts the least-recently-used",
+ pool.evicted == ["bin1"] and not pool.is_resident("bin1"),
+ f"evicted={pool.evicted} resident={pool.resident()}")
+ check("the just-spawned worker is never the victim", pool.is_resident("bin3"))
+ check("eviction saves the database first", made["bin1"].saved == 1)
+ check("eviction closes the worker", made["bin1"].closed)
+ check("pool is back within budget", pool.memory_mb() <= pool.budget_mb,
+ f"{pool.memory_mb()}/{pool.budget_mb}")
+
+ # -- the active binary is never evicted ------------------------------- #
+ pool.set_active("bin2")
+ check("set_active touches the LRU", pool.resident()[-1] == "bin2",
+ pool.resident())
+ pool.get("bin1") # over budget again -> must evict, but not bin2
+ check("the active binary survives eviction", pool.is_resident("bin2"),
+ f"resident={pool.resident()}")
+
+ # -- pinning ---------------------------------------------------------- #
+ pool.close_all()
+ pool2 = WorkerPool(proj, budget_mb=250, spawn=spawn, mem_fn=lambda c: c.mem)
+ pool2.get("bin0")
+ pool2.pin("bin0")
+ pool2.get("bin1")
+ pool2.get("bin2") # 300 > 250 -> evict, but bin0 is pinned
+ check("pinned binaries are never evicted", pool2.is_resident("bin0"),
+ f"resident={pool2.resident()} evicted={pool2.evicted}")
+ check("an unpinned one went instead", "bin1" in pool2.evicted, pool2.evicted)
+
+ # -- everything pinned/active: stop evicting rather than thrash -------- #
+ pool2.pin("bin2")
+ pool2.set_active("bin2")
+ n_before = len(pool2.evicted)
+ pool2._enforce_budget()
+ check("nothing evictable -> gives up instead of thrashing",
+ len(pool2.evicted) == n_before, pool2.evicted)
+
+ # -- status for the switcher UI ---------------------------------------- #
+ st = {s["label"]: s for s in pool2.status()}
+ check("status() covers every project binary", len(st) == 4, list(st))
+ check("status() marks resident/pinned/active",
+ st["bin0"]["resident"] and st["bin0"]["pinned"]
+ and st["bin2"]["active"] and not st["bin3"]["resident"],
+ f"{st}")
+
+ # -- teardown ----------------------------------------------------------- #
+ pool2.close_all()
+ check("close_all() closes every worker",
+ not pool2.resident() and all(c.closed for c in made.values()))
+ check("close_all() clears the active binary", pool2.active is None)
+
+ # -- unknown label -------------------------------------------------------- #
+ try:
+ pool2.get("nope")
+ check("an unknown label raises KeyError", False, "no raise")
+ except KeyError:
+ check("an unknown label raises KeyError", True)
+
+ # -- default budget comes from the project's memory_pct ------------------- #
+ pool3 = WorkerPool(proj, spawn=spawn, mem_fn=lambda c: c.mem)
+ check("default budget is derived, not a fixed worker count",
+ pool3.budget_mb >= 256, pool3.budget_mb)
+
+ # -- prewarm: speculative, and never at the cost of a real binary ------ #
+ with tempfile.TemporaryDirectory() as tmp:
+ proj = _mkproject(tmp, n=3)
+ made2 = {}
+
+ def spawn2(ref, ttl):
+ c = FakeClient(ref, mem=100)
+ made2[ref.label] = c
+ return c
+
+ pool = WorkerPool(proj, budget_mb=250, spawn=spawn2,
+ mem_fn=lambda c: c.mem)
+ labels = [r.label for r in proj.refs]
+ a, b, c_ = labels[0], labels[1], labels[2]
+ pool.get(a)
+ pool.set_active(a)
+ check("prewarm warms a binary when the budget has room",
+ pool.prewarm(b) is True and b in pool.resident(), f"{pool.resident()}")
+ check("prewarm is a no-op for something already resident",
+ pool.prewarm(b) is False)
+ # 2 x 100MB resident, estimate 100 more -> 300 > 250: must refuse
+ check("prewarm refuses rather than making room",
+ pool.prewarm(c_) is False and c_ not in pool.resident(),
+ f"resident={pool.resident()} mem={pool.memory_mb()}/{pool.budget_mb}")
+ check("refusing to prewarm evicts nothing",
+ set(pool.resident()) == {a, b}, f"{pool.resident()}")
+ check("prewarm ignores a label outside the project",
+ pool.prewarm("nope") is False)
+
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_project.py b/tests/test_project.py
new file mode 100644
index 0000000..91fd250
--- /dev/null
+++ b/tests/test_project.py
@@ -0,0 +1,233 @@
+#!/usr/bin/env python3
+"""Unit tests for idatui.project (the multi-binary project model + staging).
+
+Pure stdlib: no IDA, no textual, no worker — runs anywhere in under a second.
+
+ python tests/test_project.py
+"""
+import json
+import os
+import sys
+import tempfile
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from idatui.project import Project, ProjectError, SIDECAR_SUFFIX # noqa: E402
+
+PASS = FAIL = 0
+
+
+def check(name, cond, detail=""):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print(f" ok {name}")
+ else:
+ FAIL += 1
+ print(f" FAIL {name} {detail}")
+
+
+def _bin(path, content=b"\x7fELF fake binary"):
+ with open(path, "wb") as f:
+ f.write(content)
+ return path
+
+
+def main() -> int:
+ with tempfile.TemporaryDirectory() as tmp:
+ src = os.path.join(tmp, "src")
+ os.makedirs(src)
+ httpd = _bin(os.path.join(src, "httpd"))
+ libauth = _bin(os.path.join(src, "libauth.so"), b"\x7fELF lib")
+ pfile = os.path.join(tmp, "router-fw.json")
+
+ # -- create / load round-trip ------------------------------------- #
+ proj = Project.create(pfile, [httpd, libauth], name="router-fw")
+ check("create() writes the project file", os.path.isfile(pfile))
+ proj = Project.load(pfile)
+ check("load() round-trips name + binaries",
+ proj.name == "router-fw" and len(proj.refs) == 2,
+ f"name={proj.name} n={len(proj.refs)}")
+ check("labels default to the basename",
+ [r.label for r in proj.refs] == ["httpd", "libauth.so"],
+ f"{[r.label for r in proj.refs]}")
+
+ # -- layout -------------------------------------------------------- #
+ check("sidecar sits beside the project file",
+ proj.sidecar == os.path.join(tmp, "router-fw" + SIDECAR_SUFFIX),
+ proj.sidecar)
+ ref = proj.by_label("httpd")
+ check("staged path lives in the sidecar, not the source tree",
+ ref.staged.startswith(proj.bin_dir) and src not in ref.staged,
+ ref.staged)
+ check("db path hangs off the staged file", ref.db == ref.staged + ".i64")
+
+ # -- staging: hardlink, freshness ---------------------------------- #
+ check("a binary starts out stale (not yet staged)", proj.is_stale(ref))
+ proj.stage(ref)
+ check("stage() materialises the binary in the sidecar",
+ os.path.isfile(ref.staged))
+ check("stage() copies (distinct inode) so the source can't be mutated "
+ "through it",
+ os.stat(ref.staged).st_ino != os.stat(ref.source).st_ino
+ and open(ref.staged, "rb").read() == open(ref.source, "rb").read())
+ check("a staged binary is no longer stale", not proj.is_stale(ref))
+ check("source tree stays clean (no IDA artifacts beside it)",
+ sorted(os.listdir(src)) == ["httpd", "libauth.so"],
+ f"{sorted(os.listdir(src))}")
+
+ # -- a changed source re-stages and drops the stale DB ------------- #
+ open(ref.db, "wb").write(b"fake i64")
+ open(ref.staged + ".id0", "wb").write(b"scratch")
+ check("has_db() sees the analysed database", proj.has_db(ref))
+ # rewritten IN PLACE (same inode) — the case a hardlink would miss
+ _bin(ref.source, b"\x7fELF fake binary v2 (longer)")
+ os.utime(ref.source, (1, 1))
+ check("a source rebuilt in place goes stale", proj.is_stale(ref))
+ proj.stage(ref)
+ check("re-staging refreshes the staged bytes",
+ open(ref.staged, "rb").read().endswith(b"v2 (longer)"))
+ check("re-staging drops the now-stale database", not proj.has_db(ref))
+ check("re-staging drops the stale scratch too",
+ not os.path.exists(ref.staged + ".id0"))
+
+ # -- scratch sweep keeps the DB ------------------------------------ #
+ open(ref.db, "wb").write(b"fake i64")
+ for suf in (".id0", ".id1", ".nam"):
+ open(ref.staged + suf, "wb").write(b"x")
+ n = proj.sweep_scratch(ref)
+ check("sweep_scratch() removes the unpacked working files", n == 3, f"n={n}")
+ check("sweep_scratch() never touches the .i64", proj.has_db(ref))
+
+ # -- relative paths + label collisions ----------------------------- #
+ sub = os.path.join(tmp, "other")
+ os.makedirs(sub)
+ dup = _bin(os.path.join(sub, "httpd"), b"\x7fELF other httpd")
+ with open(pfile, "w") as f:
+ json.dump({"name": "p", "binaries": [
+ {"path": "src/httpd"}, # relative to the project file
+ {"path": dup}, # same basename -> collision
+ {"path": libauth, "label": "auth"},
+ ]}, f)
+ proj2 = Project.load(pfile)
+ check("relative paths resolve against the project file",
+ proj2.refs[0].source == httpd, proj2.refs[0].source)
+ check("colliding labels are disambiguated",
+ [r.label for r in proj2.refs] == ["httpd", "httpd_2", "auth"],
+ f"{[r.label for r in proj2.refs]}")
+ check("explicit labels are honoured", proj2.by_label("auth") is not None)
+ proj2.stage_all()
+ check("stage_all() stages every binary to a distinct file",
+ len({r.staged for r in proj2.refs}) == 3
+ and all(os.path.isfile(r.staged) for r in proj2.refs))
+
+ # -- add / remove ---------------------------------------------------- #
+ extra = _bin(os.path.join(src, "extra"))
+ proj2.add(extra, label="extra")
+ check("add() appends a binary", proj2.by_label("extra") is not None)
+
+ # -- re-adding must not duplicate (matched by resolved path) --------- #
+ n = len(proj2.refs)
+ proj2.add(extra)
+ check("re-adding the same path is a no-op", len(proj2.refs) == n,
+ f"{[r.label for r in proj2.refs]}")
+ os.chdir(src)
+ proj2.add("./extra") # same file, relative
+ proj2.add(os.path.join(src, "..", "src", "extra")) # same file, messy
+ check("a different spelling of the same path is a no-op",
+ len(proj2.refs) == n, f"{[r.label for r in proj2.refs]}")
+ link = os.path.join(src, "extra_link")
+ os.symlink(extra, link)
+ proj2.add(link)
+ check("a symlink to an existing binary is a no-op",
+ len(proj2.refs) == n, f"{[r.label for r in proj2.refs]}")
+
+ # ...but a DIFFERENT file with the same basename must still be added
+ other_dir = os.path.join(tmp, "other2")
+ os.makedirs(other_dir)
+ twin = _bin(os.path.join(other_dir, "extra"), b"\x7fELF a different extra")
+ proj2.add(twin)
+ check("a same-named file from another directory IS added",
+ len(proj2.refs) == n + 1
+ and proj2.by_source(twin) is not None
+ and proj2.by_source(extra) is not proj2.by_source(twin),
+ f"{[(r.label, r.source) for r in proj2.refs[-2:]]}")
+ check("the twins get distinct labels",
+ len({r.label for r in proj2.refs}) == len(proj2.refs),
+ f"{[r.label for r in proj2.refs]}")
+ check("create() also drops repeats on the command line",
+ len(Project.create(os.path.join(tmp, "dup.json"),
+ [extra, "./extra", extra]).refs) == 1)
+ os.chdir(tmp)
+ proj2.remove(proj2.by_source(twin).label)
+ check("remove() drops one", proj2.remove("extra")
+ and proj2.by_label("extra") is None)
+
+ # -- bad input ------------------------------------------------------- #
+ bad = os.path.join(tmp, "bad.json")
+ with open(bad, "w") as f:
+ f.write("{not json")
+ try:
+ Project.load(bad)
+ check("malformed JSON raises ProjectError", False, "no raise")
+ except ProjectError:
+ check("malformed JSON raises ProjectError", True)
+ with open(bad, "w") as f:
+ json.dump({"binaries": []}, f)
+ try:
+ Project.load(bad)
+ check("an empty project raises ProjectError", False, "no raise")
+ except ProjectError:
+ check("an empty project raises ProjectError", True)
+ missing = proj2.add(os.path.join(tmp, "nope"))
+ try:
+ proj2.stage(missing)
+ check("staging a missing binary raises ProjectError", False, "no raise")
+ except ProjectError:
+ check("staging a missing binary raises ProjectError", True)
+
+ # -- load options for headerless blobs --------------------------------- #
+ with tempfile.TemporaryDirectory() as tmp:
+ src = os.path.join(tmp, "src"); os.makedirs(src)
+ blob = os.path.join(src, "fw.bin")
+ with open(blob, "wb") as f:
+ f.write(b"\x00" * 64)
+ proj = Project.create(os.path.join(tmp, "p.json"), [blob], name="p",
+ load={"processor": "arm", "base": 0x8000000})
+ r = proj.refs[0]
+ check("create() records load options per binary",
+ r.processor == "arm" and r.base == 0x8000000,
+ f"proc={r.processor!r} base={r.base:#x}")
+ # -b is PARAGRAPHS: 0x8000000 >> 4 == 0x800000. Getting this wrong loads
+ # the image 16x too high and every address in the database is wrong.
+ check("base is converted to IDA's paragraph units",
+ r.load_args == "-parm -b800000", r.load_args)
+
+ proj2 = Project.load(proj.path)
+ check("load options survive a round-trip through the file",
+ proj2.refs[0].load_args == "-parm -b800000",
+ proj2.refs[0].load_args)
+
+ blob2 = os.path.join(src, "other.bin")
+ with open(blob2, "wb") as f:
+ f.write(b"\x00" * 64)
+ r2 = proj2.add(blob2, load={"processor": "mipsb"})
+ check("add() takes load options too",
+ r2.load_args == "-pmipsb", r2.load_args)
+
+ # a normal ELF needs none of this and must pass nothing
+ check("a binary with no load options passes no switches",
+ Project.create(os.path.join(tmp, "q.json"), [blob],
+ name="q").refs[0].load_args == "")
+
+ # addresses get written by hand, so accept how people write them
+ proj3 = Project.create(os.path.join(tmp, "r.json"), [blob], name="r",
+ load={"base": "0x1000"})
+ check("a base given as a hex STRING is parsed",
+ proj3.refs[0].base == 0x1000, f"{proj3.refs[0].base}")
+
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_project_ui.py b/tests/test_project_ui.py
new file mode 100644
index 0000000..54c06bd
--- /dev/null
+++ b/tests/test_project_ui.py
@@ -0,0 +1,285 @@
+#!/usr/bin/env python3
+"""End-to-end pilot for project mode: two binaries, switching between them.
+
+Needs idalib (it spawns real workers, one per binary) and textual:
+
+ ~/ida-venv/bin/python tests/test_project_ui.py [bin1 bin2]
+
+Defaults to targets/echo + targets/cat. The binaries are copied into a temp
+source dir first, so the "source tree stays pristine" promise is checkable.
+"""
+import asyncio
+import os
+import shutil
+import sys
+import tempfile
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from idatui._sync import wait_for # noqa: E402
+from idatui.app import IdaTui, ProjectPalette # noqa: E402
+from idatui.project import Project # noqa: E402
+from textual.widgets import Input, OptionList, Static # noqa: E402
+
+PASS = FAIL = 0
+
+
+def check(name, cond, detail=""):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print(f" ok {name}")
+ else:
+ FAIL += 1
+ print(f" FAIL {name} {detail}")
+
+
+async def run(bins):
+ repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ with tempfile.TemporaryDirectory() as tmp:
+ src = os.path.join(tmp, "src")
+ os.makedirs(src)
+ srcs = []
+ for b in bins:
+ dst = os.path.join(src, os.path.basename(b))
+ shutil.copy2(b, dst)
+ srcs.append(dst)
+ proj = Project.create(os.path.join(tmp, "proj.json"), srcs, name="proj")
+ proj.stage_all()
+ first, second = (r.label for r in proj.refs)
+
+ app = IdaTui(keepalive=False, project=proj)
+ async with app.run_test(size=(140, 44)) as pilot:
+ async def settle(pred, t=180.0):
+ return await wait_for(pred, pilot.pause, t, 0.05)
+
+ # -- boots on the project's first binary ----------------------- #
+ ok = await settle(lambda: app.program is not None
+ and app._func_index is not None
+ and app._func_index.complete)
+ check("project mode boots on the first binary", ok,
+ f"binary={app._binary}")
+ check("the active binary is the first one", app._binary == first,
+ f"{app._binary}")
+ n_first = len(app._func_index)
+ check("its functions loaded", n_first > 10, f"n={n_first}")
+ status = str(app.query_one("#status", Static).render())
+ check("the status line names the active binary",
+ f"[{first}]" in status, status[:60])
+
+ # -- the switcher lists the project ---------------------------- #
+ await pilot.press("ctrl+o")
+ opened = await settle(
+ lambda: isinstance(app.screen, ProjectPalette), 20)
+ check("Ctrl+O opens the binary switcher", opened,
+ f"screen={type(app.screen).__name__}")
+ if not opened:
+ return
+ pal = app.screen
+ check("the switcher lists every project binary",
+ len(pal._results) == 2, f"{[e['label'] for e in pal._results]}")
+ check("it marks which one is active",
+ any(e["active"] and e["label"] == first for e in pal._results))
+ check("it marks the other as not yet opened",
+ any(not e["resident"] and e["label"] == second
+ for e in pal._results))
+ ol = pal.query_one(OptionList)
+ check("the switcher opens on the binary you're already in",
+ ol.highlighted is not None
+ and pal._results[ol.highlighted]["label"] == first,
+ f"highlighted={ol.highlighted} "
+ f"={pal._results[ol.highlighted]['label'] if ol.highlighted is not None else None} "
+ f"want={first}")
+
+ # -- switch to the second binary -------------------------------- #
+ pal.query_one(Input).value = second
+ await pilot.pause(0.2)
+ await pilot.press("enter")
+ switched = await settle(
+ lambda: app._binary == second and app.program is not None
+ and app._func_index is not None and app._func_index.complete)
+ check("switching opens the other binary", switched,
+ f"binary={app._binary}")
+ check("the second binary has its own function index",
+ app._func_index is not None and len(app._func_index) > 5,
+ f"n={len(app._func_index) if app._func_index else 0}")
+ check("both binaries now have live workers",
+ sorted(app._pool.resident()) == sorted([first, second]),
+ f"{app._pool.resident()}")
+ landed = await settle(lambda: app._cur is not None, 60)
+ check("it lands somewhere in the new binary", landed,
+ f"cur={app._cur}")
+ where = app._cur.ea if app._cur else None
+
+ # -- switch back: resident, so state is restored ---------------- #
+ await pilot.press("ctrl+o")
+ reopened = await settle(
+ lambda: isinstance(app.screen, ProjectPalette), 20)
+ check("the switcher reopens after a switch", reopened,
+ f"screen={type(app.screen).__name__}")
+ if not reopened:
+ return
+ app.screen.query_one(Input).value = first
+ await pilot.pause(0.2)
+ await pilot.press("enter")
+ back = await settle(lambda: app._binary == first
+ and app._func_index is not None
+ and app._func_index.complete, 120)
+ check("switching back returns to the first binary", back,
+ f"binary={app._binary}")
+ check("its function index came back intact",
+ app._func_index is not None and len(app._func_index) == n_first,
+ f"n={len(app._func_index) if app._func_index else 0} want={n_first}")
+
+ # and forward again: the second binary's position was remembered
+ await pilot.press("ctrl+o")
+ if not await settle(lambda: isinstance(app.screen, ProjectPalette), 20):
+ check("returning to a binary restores where you were", False,
+ "switcher did not reopen")
+ return
+ app.screen.query_one(Input).value = second
+ await pilot.pause(0.2)
+ await pilot.press("enter")
+ again = await settle(lambda: app._binary == second
+ and app._cur is not None, 120)
+ check("returning to a binary restores where you were",
+ again and app._cur.ea == where,
+ f"cur={app._cur.ea if app._cur else None} want={where}")
+
+ # -- project-wide symbol search ------------------------------- #
+ # 'main' exists in BOTH binaries: identical name, so the rank tuple
+ # ties and a bare sort() would fall through to comparing Hit objects
+ # ('<' not supported between instances of 'Hit').
+ from idatui.app import SymbolPalette
+ await settle(lambda: app._index is not None
+ and len(app._index.counts()) == 2, 60)
+ check("both binaries got indexed",
+ len(app._index.counts()) == 2, f"{app._index.counts()}")
+ await pilot.press("ctrl+n")
+ if await settle(lambda: isinstance(app.screen, SymbolPalette), 20):
+ pal = app.screen
+ pal.query_one(Input).value = "main"
+ await pilot.pause(0.3)
+ await pilot.press("f2") # widen to the whole project
+ await pilot.pause(0.4)
+ names = [(b, n) for b, _, n in pal._results]
+ check("project scope finds a name shared by both binaries",
+ len({b for b, n in names if n == "main"}) == 2,
+ f"{names[:6]}")
+ await pilot.press("escape")
+ await pilot.pause(0.2)
+
+ # -- a cross-binary jump is not a one-way door ----------------- #
+ # Nav history is per-binary, so arriving in another binary lands you
+ # in an empty history. Esc must fall through to the binary you came
+ # from, or a project search hit (and, since phase 3, following an
+ # import) strands you.
+ here, there = app._binary, (first if app._binary == second else second)
+ hops0 = len(app._hops)
+ target = app._index.search("main", limit=200)
+ tgt = next((h for h in target if h.binary == there), None)
+ if tgt is None:
+ check("cross-binary jump records a hop", False, "no hit in the other binary")
+ else:
+ app._switch_then_goto(tgt.binary, tgt.addr)
+ jumped = await settle(lambda: app._binary == there
+ and app._func_index is not None
+ and app._func_index.complete, 180)
+ check("a project hit switches to the other binary", jumped,
+ f"binary={app._binary} want={there}")
+ check("the jump records where it came from",
+ len(app._hops) == hops0 + 1 and app._hops[-1] == here,
+ f"hops={app._hops}")
+ # spend the local history first, then Esc must cross back
+ for _ in range(6):
+ if not app._hops or app._binary != there:
+ break
+ await pilot.press("escape")
+ await pilot.pause(0.6)
+ returned = await settle(lambda: app._binary == here, 180)
+ check("Esc crosses back to the binary the jump came from",
+ returned, f"binary={app._binary} want={here} hops={app._hops}")
+ check("the hop is consumed, not repeated",
+ not app._hops, f"hops={app._hops}")
+
+ # -- xrefs: callers in OTHER project binaries ------------------ #
+ # xrefs_to only sees this database, so an exported function looks
+ # unused from the inside even when the rest of the project calls it.
+ # The selection rule is "only for a symbol we actually export"; two
+ # executables share no linkage, so here it must stay quiet.
+ from idatui.app import XrefsScreen
+ fake = app._foreign_importers(app._cur.ea, "strrchr", None)
+ check("no cross-binary callers for a symbol this binary doesn't export",
+ fake == [], f"{fake}")
+
+ # The routing a real cross-binary caller takes: the dialog carries a
+ # (binary, addr) payload instead of a bare address, and choosing it
+ # goes through the same switch path as a search hit — hop included,
+ # so Esc comes back.
+ where_from = app._binary
+ other = first if where_from == second else second
+ hit = next((h for h in app._index.search("main", limit=200)
+ if h.binary == other), None)
+ if hit is None:
+ check("a cross-binary xref jumps to the other binary", False,
+ "no symbol found in the other binary")
+ else:
+ hops0 = len(app._hops)
+ app.push_screen(
+ XrefsScreen("xrefs to fake", [((hit.binary, hit.addr),
+ f"{hit.addr:08X} import [{hit.binary}]")]),
+ app._on_xref_chosen)
+ await settle(lambda: isinstance(app.screen, XrefsScreen), 20)
+ await pilot.press("enter")
+ jumped = await settle(lambda: app._binary == other
+ and app._func_index is not None
+ and app._func_index.complete, 180)
+ check("a cross-binary xref jumps to the other binary", jumped,
+ f"binary={app._binary} want={other}")
+ check("and records a hop so Esc returns",
+ len(app._hops) == hops0 + 1 and app._hops[-1] == where_from,
+ f"hops={app._hops}")
+ app._hops.clear()
+
+ # -- and the same toggle for strings --------------------------- #
+ from idatui.app import StringsPalette
+ await pilot.press("quotation_mark")
+ if await settle(lambda: isinstance(app.screen, StringsPalette), 30):
+ pal = app.screen
+ pal.query_one(Input).value = "usage"
+ await pilot.pause(0.3)
+ local = {b for b, _, _ in pal._results}
+ await pilot.press("f2")
+ await pilot.pause(0.4)
+ wide = {b for b, _, _ in pal._results}
+ check("strings: local scope is this binary only", local == {None},
+ f"{local}")
+ check("strings: F2 widens across the project",
+ len(wide) >= 2 and None not in wide, f"{wide}")
+ await pilot.press("escape")
+ await pilot.pause(0.2)
+
+ # -- the promise: nothing was written next to the sources ---------- #
+ left = sorted(os.listdir(src))
+ check("the source tree stays pristine (no .i64/scratch beside it)",
+ left == sorted(os.path.basename(s) for s in srcs), f"{left}")
+ staged = sorted(os.listdir(proj.bin_dir))
+ check("IDA's artifacts all live in the project sidecar",
+ any(f.endswith(".i64") for f in staged), f"{staged}")
+
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+def main(argv):
+ repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ bins = argv or [os.path.join(repo, "targets", "echo"),
+ os.path.join(repo, "targets", "cat")]
+ for b in bins:
+ if not os.path.isfile(b):
+ print(f"no such binary: {b}")
+ return 2
+ return asyncio.run(run(bins))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index b16cd98..6ccbf0d 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -17,6 +17,7 @@ Uses ~/ida-venv python (has textual).
--list print scenario names and exit
"""
import asyncio
+import fnmatch
import os
import re
import sys
@@ -25,10 +26,11 @@ import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from idatui.app import ( # noqa: E402
ConfirmScreen, DecompView, DisasmView, FunctionsPanel, HexView, IdaTui,
- StructEditor, SymbolPalette, XrefsScreen,
+ HelpScreen, ListingView, QuitScreen, StringsPalette, StructEditor,
+ SymbolPalette, XrefsScreen, _str_display, _word_occurrences,
)
from textual.widgets import ( # noqa: E402
- DataTable, Footer, Input, OptionList, Static, TextArea,
+ DataTable, Input, OptionList, Static, TextArea,
)
from rich.text import Text # noqa: E402
from idatui._sync import wait_for # noqa: E402
@@ -93,13 +95,17 @@ class Ctx:
# -- shorthands ------------------------------------------------------- #
@property
def dis(self):
- return self.app.query_one(DisasmView)
+ return self.app.query_one(ListingView) # unified code view (was DisasmView)
@property
def dec(self):
return self.app.query_one(DecompView)
@property
+ def lst(self):
+ return self.app.query_one(ListingView)
+
+ @property
def hex(self):
return self.app.query_one(HexView)
@@ -113,7 +119,10 @@ class Ctx:
# -- discovery -------------------------------------------------------- #
def all_funcs(self):
- return self.prog.functions().all_loaded()
+ idx = self.prog.functions()
+ if len(idx) == 0 and not idx.complete:
+ idx.load_all() # a prior bump_items() cleared the index cache
+ return idx.all_loaded()
def find_func(self, pred, limit=400):
for f in self.all_funcs()[:limit]:
@@ -148,23 +157,36 @@ class Ctx:
return None
# -- navigation (setup; fast internal path) --------------------------- #
- async def open(self, target, view="decomp", t=25):
- """Open a function (name or ea) to its entry, in ``view`` ('decomp' or
- 'disasm'). Returns the Func."""
+ async def open(self, target, view="listing", t=25):
+ """Open a function (name or ea) at its entry in the unified listing. With
+ ``view='decomp'`` it then F5s into the pseudocode. Returns the Func."""
ea = self.prog.resolve(target) if isinstance(target, str) else int(target)
fn = self.prog.function_of(ea)
if fn is None:
raise RuntimeError(f"no function for {target!r}")
- self.app._pref = view
self.app._open_function(fn.addr, fn.name)
await self.wait(lambda: self.app._cur and self.app._cur.ea == fn.addr, t)
+ # Wait for the cursor to actually LAND on the target, not merely for
+ # _cur.ea to match: re-opening a function we already navigated to (its
+ # _cur.ea is stale-true) schedules an async re-navigation, and proceeding
+ # before it lands would leave the cursor parked wherever we last were.
+ await self.wait(lambda: self.lst.total > 0
+ and self.lst._cursor_ea() == fn.addr, t)
if view == "decomp":
- await self.wait(lambda: self.dec.loaded_ea == fn.addr, t)
- else:
- await self.wait(lambda: self.dis.total > 0, t)
+ # F5/Tab only decompiles from a focused code pane, and the listing
+ # may still be settling from the open above — a swallowed Tab used to
+ # surface much later as "pseudocode view shows: active=listing".
+ # Retry rather than assume the first one takes.
+ for _ in range(3):
+ self.lst.focus()
+ await self.pause(0.05)
+ await self.press("tab")
+ if await self.wait(lambda: self.app._active == "decomp"
+ and self.dec.loaded_ea == fn.addr, max(t / 3, 5)):
+ break
return fn
- async def open_biggest(self, view="disasm"):
+ async def open_biggest(self, view="listing"):
return await self.open(self.biggest().addr, view)
async def goto_ui(self, target):
@@ -184,8 +206,10 @@ class Ctx:
async def boot(self):
app = self.app
await self.wait(lambda: self.table.row_count > 0, 60)
+ # Load is done when the index is complete (robust vs the status line,
+ # which startup auto-land immediately overwrites with the landed fn).
await self.wait(
- lambda: "functions" in self.status() and "…" not in self.status(), 60)
+ lambda: app._func_index is not None and app._func_index.complete, 60)
if app._func_index is not None and not app._func_index.complete:
app._func_index.load_all()
@@ -212,6 +236,7 @@ class Ctx:
app._pref = "decomp"
if app._active == "hex":
app._active = "decomp"
+ app._split = False
await self.pause(0.02)
@@ -227,11 +252,42 @@ async def s_startup(c: Ctx):
await c.reveal_pane()
c.check("function pane width is capped (doesn't eat the screen)",
left.size.width <= 44, f"width={left.size.width}")
- c.check("function load completed (status settled)",
- "functions" in c.status() and "…" not in c.status(), c.status())
+ c.check("function load completed (index complete)",
+ c.app._func_index is not None and c.app._func_index.complete, c.status())
print(f" {c.table.row_count} functions loaded")
+@scenario("auto_land")
+async def s_auto_land(c: Ctx):
+ """Startup lands on main()/an entry function when present, else pops the
+ symbol picker — never a blank pane."""
+ app = c.app
+ # simulate a fresh startup landing
+ for _ in range(3):
+ if len(app.screen_stack) > 1:
+ app.pop_screen()
+ await c.pause(0.05)
+ app._cur = None
+ app._did_auto_land = False
+ fn = app._entry_func()
+ app._auto_land()
+ await c.pause(0.2)
+ if fn is not None:
+ await c.wait(lambda: app._cur is not None and app._cur.ea == fn.addr, 20)
+ c.check("auto-land jumps to the entry function (main) when present",
+ app._cur is not None and app._cur.ea == fn.addr,
+ f"entry={fn.name}@{fn.addr:#x} cur={app._cur}")
+ else:
+ await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10)
+ c.check("auto-land pops the symbol picker when there's no entry fn",
+ isinstance(app.screen, SymbolPalette), f"screen={app.screen}")
+ app.pop_screen()
+ # guard fires once: a second call is a no-op
+ prev = app._cur
+ app._auto_land()
+ c.check("auto-land is idempotent (guarded)", app._cur is prev)
+
+
@scenario("palette")
async def s_palette(c: Ctx):
app, pilot = c.app, c.pilot
@@ -244,24 +300,48 @@ async def s_palette(c: Ctx):
pal = app.screen
pinp = pal.query_one(Input)
pinp.value = "main"
- await c.wait(lambda: pal._results and pal._results[0].name == "main", 10)
+ await c.wait(lambda: pal._results and pal._results[0][2] == "main", 10)
c.check("palette fuzzy-finds (top result matches the query)",
- bool(pal._results) and pal._results[0].name == "main",
- f"top={pal._results[0].name if pal._results else None}")
+ bool(pal._results) and pal._results[0][2] == "main",
+ f"top={pal._results[0][2] if pal._results else None}")
pinp.value = "eror" # scattered subsequence of 'error'
- await c.wait(lambda: any(f.name == "error" for f in pal._results), 10)
+ await c.wait(lambda: any(n == "error" for _, _, n in pal._results), 10)
c.check("palette matches a fuzzy subsequence",
- any(f.name == "error" for f in pal._results),
- f"results={[f.name for f in pal._results[:4]]}")
+ any(n == "error" for _, _, n in pal._results),
+ f"results={[n for _, _, n in pal._results[:4]]}")
+ # Every other query here is lowercase, which is how a case bug hid for so
+ # long: the name was lowered but the query wasn't, so ONE capital matched
+ # nothing. Invisible on lowercase C symbols, fatal on a library that
+ # capitalises (PEM_read_bio found 0 of 10093 functions in libcrypto).
+ pinp.value = "MAIN"
+ await c.wait(lambda: any(n == "main" for _, _, n in pal._results), 10)
+ c.check("palette matching is case-insensitive in BOTH directions",
+ any(n == "main" for _, _, n in pal._results),
+ f"results={[n for _, _, n in pal._results[:4]]}")
pinp.value = "main"
- await c.wait(lambda: pal._results and pal._results[0].name == "main", 10)
- want = pal._results[0].addr
+ await c.wait(lambda: pal._results and pal._results[0][2] == "main", 10)
+ want = pal._results[0][1]
await c.press("enter")
await c.wait(lambda: not isinstance(app.screen, SymbolPalette), 10)
await c.wait(lambda: app._cur and app._cur.ea == want, 20)
c.check("selecting a palette entry opens that function",
bool(app._cur) and app._cur.ea == want,
f"cur={app._cur.ea if app._cur else None}")
+ # Re-open the function we are ALREADY standing on. That used to append an
+ # identical nav entry, and the extra Esc it bought popped the stack without
+ # changing anything on screen — a dead keypress, which is precisely what
+ # "back is broken" feels like from the keyboard.
+ depth = len(app._nav)
+ await c.press("ctrl+n")
+ await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10)
+ pal2 = app.screen
+ pal2.query_one(Input).value = "main"
+ await c.wait(lambda: pal2._results and pal2._results[0][2] == "main", 10)
+ await c.press("enter")
+ await c.wait(lambda: not isinstance(app.screen, SymbolPalette), 10)
+ await c.pause(0.4)
+ c.check("re-opening the current function doesn't stack a duplicate",
+ len(app._nav) == depth, f"nav {depth} -> {len(app._nav)}")
await c.press("ctrl+n")
await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10)
await c.press("escape")
@@ -269,29 +349,351 @@ async def s_palette(c: Ctx):
c.check("Esc closes the palette", not isinstance(app.screen, SymbolPalette))
+@scenario("load_options")
+async def s_load_options(c: Ctx):
+ """The dialog must never appear for a file IDA can load itself — the whole
+ suite runs on an ELF, so a false positive here would block every run."""
+ from idatui.app import LoadOptionsScreen
+ app = c.app
+ c.check("no load dialog for a recognised binary",
+ not isinstance(app.screen, LoadOptionsScreen),
+ f"screen={type(app.screen).__name__}")
+ c.check("and the app agrees it shouldn't ask",
+ not app._should_ask_load_options())
+ # The dialog itself, driven directly: it has to come back with switches the
+ # worker can use, and -b has to be paragraphs.
+ from idatui.formats import load_args, needs_load_options, sniff
+ c.check("the running target sniffs as a real format",
+ sniff(app._open_path) is not None and not needs_load_options(app._open_path),
+ f"{sniff(app._open_path)}")
+ c.check("dialog output converts a base to paragraphs",
+ load_args("arm", 0x8000000) == "-parm -b800000")
+
+ # Tab is a PRIORITY app binding (disasm<->pseudocode), so it fired even with
+ # a modal up and nothing in a dialog could be tabbed to. That is why the load
+ # dialog's address field was unreachable — and it was broken in every other
+ # modal too.
+ from idatui.app import LoadOptionsScreen
+ app.push_screen(LoadOptionsScreen("/tmp/probe.bin", 1234))
+ await c.wait(lambda: isinstance(app.screen, LoadOptionsScreen), 10)
+ sc = app.screen
+ first = app.focused
+ await c.press("tab")
+ await c.pause(0.2)
+ c.check("Tab moves focus inside a modal instead of toggling the view",
+ app.focused is not first and isinstance(app.screen, LoadOptionsScreen),
+ f"focus={getattr(app.focused, 'id', None)}")
+ c.check("Tab in the load dialog lands on the address field",
+ getattr(app.focused, "id", None) == "load-base",
+ f"focus={getattr(app.focused, 'id', None)}")
+ await c.press("tab")
+ await c.pause(0.2)
+ c.check("Tab again returns to the processor filter",
+ getattr(app.focused, "id", None) == "pal-input",
+ f"focus={getattr(app.focused, 'id', None)}")
+ await c.press("escape")
+ await c.wait(lambda: not isinstance(app.screen, LoadOptionsScreen), 10)
+
+
+@scenario("command_palette")
+async def s_command_palette(c: Ctx):
+ app = c.app
+ await c.open_biggest("listing")
+ await c.press("ctrl+p")
+ opened = await c.wait(
+ lambda: type(app.screen).__name__ == "CommandPalette", 10)
+ c.check("Ctrl+P opens the command palette", opened,
+ f"screen={type(app.screen).__name__}")
+ if not opened:
+ return
+ inp = app.screen.query_one(Input)
+ inp.value = "hex" # filter to the 'Hex view' command
+ await c.pause(0.5) # let the async search + option list settle
+ await c.press("enter")
+ landed = await c.wait(lambda: app._active == "hex", 10)
+ c.check("a palette command executes (Hex view opens)", landed,
+ f"active={app._active}")
+ if landed:
+ await c.press("backslash") # leave hex
+ await c.wait(lambda: app._active != "hex", 5)
+
+
+@scenario("quit_guard")
+async def s_quit_guard(c: Ctx):
+ app = c.app
+ c.check("a clean database reports nothing unsaved", app._dirty_labels() == [],
+ f"{app._dirty_labels()}")
+ app._dirty = True # as an edit would
+ c.check("an edited database is reported unsaved",
+ len(app._dirty_labels()) == 1, f"{app._dirty_labels()}")
+ await c.press("q")
+ asked = await c.wait(lambda: isinstance(app.screen, QuitScreen), 10)
+ c.check("quitting with unsaved changes asks first", asked and app.is_running,
+ f"screen={type(app.screen).__name__} running={app.is_running}")
+ if not asked:
+ return
+ await c.press("escape")
+ await c.wait(lambda: not isinstance(app.screen, QuitScreen), 10)
+ c.check("Esc cancels the quit and stays put",
+ app.is_running and not isinstance(app.screen, QuitScreen))
+ # leave it clean so the rest of the suite (and teardown) isn't affected
+ app._dirty = False
+ app._save_on_exit = False
+
+
+@scenario("help")
+async def s_help(c: Ctx):
+ app = c.app
+ st = app.query_one("#status", Static)
+ c.check("the status line owns the bottom row (no footer cheatsheet)",
+ st.region.y + st.region.height == app.size.height,
+ f"status={st.region} screen={app.size}")
+ await c.press("f1")
+ opened = await c.wait(lambda: isinstance(app.screen, HelpScreen), 10)
+ c.check("F1 opens the key cheatsheet", opened,
+ f"screen={type(app.screen).__name__}")
+ if not opened:
+ return
+ cards = app.screen.query(".help-card")
+ titles = {str(w.border_title) for w in cards}
+ txt = " ".join(str(w.render()) for w in cards)
+ c.check("each key group gets its own card",
+ titles == {"Navigate", "Views", "Move", "Edit", "Search"}, f"{titles}")
+ c.check("it documents real bindings",
+ "set type" in txt and "split view" in txt and "cross-references" in txt)
+ body = app.screen.query_one("#help-body")
+ c.check("the cards fit without a scrollbar at a normal size",
+ body.virtual_size.height <= body.size.height,
+ f"content={body.virtual_size.height} view={body.size.height}")
+ await c.press("escape")
+ await c.wait(lambda: not isinstance(app.screen, HelpScreen), 10)
+ c.check("Esc closes it", not isinstance(app.screen, HelpScreen))
+
+
+@scenario("strings")
+async def s_strings(c: Ctx):
+ app = c.app
+ await c.open_biggest("listing")
+ items = app.program.strings()
+ c.check("program.strings() lists the binary's literals", len(items) > 3,
+ f"n={len(items)}")
+ if not items:
+ return
+ c.check("strings carry addr/text/length",
+ all(s.addr > 0 and s.text and s.length > 0 for s in items[:5]),
+ f"first={items[0]}")
+ await c.press("quotation_mark")
+ opened = await c.wait(lambda: isinstance(app.screen, StringsPalette), 25)
+ c.check('\'"\' opens the strings browser', opened,
+ f"screen={type(app.screen).__name__}")
+ if not opened:
+ return
+ pal = app.screen
+ c.check("the browser lists strings", len(pal._results) > 0,
+ f"results={len(pal._results)}")
+ # filter on a fragment of a real (unescaped) literal
+ target = next((s for s in items
+ if len(s.text) >= 6 and _str_display(s.text) == s.text), None)
+ if target is not None:
+ frag = target.text[:6]
+ pal.query_one(Input).value = frag
+ await c.pause(0.2)
+ ok = (pal._results
+ and all(frag.lower() in t.lower() for _, _, t in pal._results))
+ c.check("filtering narrows to matching strings", bool(ok),
+ f"frag={frag!r} n={len(pal._results)}")
+ want = pal._results[0][1]
+ await c.press("enter")
+ await c.wait(lambda: not isinstance(app.screen, StringsPalette), 10)
+ landed = await c.wait(lambda: c.lst._cursor_ea() == want, 20)
+ c.check("Enter jumps to the string in the unified listing", landed,
+ f"cursor={c.lst._cursor_ea()} want={want:#x}")
+ else:
+ await c.press("escape")
+
+
+@scenario("split_view")
+async def s_split_view(c: Ctx):
+ app, lst, dec = c.app, c.lst, c.dec
+ await c.open_biggest("listing")
+ await c.press("s")
+ shown = await c.wait(lambda: app._split and lst.display and dec.display, 20)
+ c.check("'s' enters split view (both panes shown)", shown,
+ f"split={app._split} lst={lst.display} dec={dec.display}")
+ loaded = await c.wait(lambda: dec.loaded_ea == app._cur.ea, 25)
+ c.check("split loads the pseudocode alongside the listing", loaded,
+ f"loaded={dec.loaded_ea} cur={app._cur.ea if app._cur else None}")
+ await c.wait(lambda: "[split" in c.status(), 5)
+ c.check("split view shows a split-aware status", "[split" in c.status(),
+ f"status={c.status()!r}")
+ # phase 3: the rich per-line instruction map (decomp_map tool, run on the
+ # pilot's real worker) — verify it returns, aligns with the markers, and
+ # bands a whole region for a multi-instruction C line.
+ m = app.program.decomp_map(app._cur.ea)
+ c.check("decomp_map returns per-line ea sets", len(m) > 5, f"lines={len(m)}")
+ aligned = sum(1 for i in range(min(len(m), len(dec._line_eas)))
+ if m[i] and dec._line_eas[i] is not None
+ and dec._line_eas[i] in m[i])
+ c.check("decomp_map aligns with the pseudocode markers", aligned >= 3,
+ f"aligned={aligned}/{len(dec._line_eas)}")
+ multi = next((i for i, eas in enumerate(m) if len(eas) > 1), None)
+ if multi is not None:
+ app._split_eamap = m
+ dec.focus() # the decomp must BE the driver for a decomp-driven
+ app._active = "decomp" # sync (else its align() re-syncs listing-driven)
+ dec.cursor = multi
+ dec._scroll_cursor_into_view() # key-nav always does; the anchor needs it
+ await c.pause(0.1)
+ app._sync_split("decomp")
+ await c.pause(0.1)
+ c.check("a multi-instruction C line bands a region (>1 listing row)",
+ len(lst._link_rows) > 1,
+ f"line={multi} eas={len(m[multi])} rows={sorted(lst._link_rows)[:8]}")
+ lst.focus()
+ app._active = "listing"
+ await c.pause(0.05)
+ else:
+ c.check("a multi-instruction C line bands a region (>1 listing row)",
+ True, "no multi-instruction line in this function (skipped)")
+ # listing drives: move it, the decomp band must track the covering C line
+ lst.focus()
+ for _ in range(6):
+ await c.press("j")
+ await c.pause(0.2)
+ lea = lst._cursor_ea()
+ dl = dec._link_line
+ c.check("listing cursor links the covering pseudocode line",
+ dl is not None and lea is not None and dec._line_eas[dl] is not None
+ and dec._line_eas[dl] <= lea,
+ f"link_line={dl} lea={hex(lea) if lea else None}")
+ # the companion pane sits LEVEL with the driver's cursor (visual coherence):
+ # the linked row lands at the same viewport offset, not merely on-screen.
+ deep = [i for i, eas in enumerate(m) if eas][10:]
+ row = lst.model.ensure_ea(m[deep[0]][0]) if (deep and lst.model) else None
+ if row is not None and row > 12:
+ lst.scroll_to(y=row - 10, animate=False)
+ await c.pause(0.2) # let the deferred scroll land
+ lst.cursor = row # driver cursor now at viewport offset 10
+ app._sync_split("listing")
+ await c.pause(0.2) # let the companion's scroll land
+ drv = lst.cursor - round(lst.scroll_offset.y)
+ link, top = dec._link_line, round(dec.scroll_offset.y)
+ # exact, modulo the unavoidable clamps (can't scroll above line 0, nor
+ # past the end when the pseudocode is shorter than the viewport)
+ want = min(max(0, (link or 0) - drv),
+ max(0, dec.total - dec._visible_height()))
+ c.check("the companion pane sits level with the driver's cursor",
+ link is not None and top == want,
+ f"driver_row={drv} link={link} dec_top={top} want={want}")
+ # a PURE scroll (wheel/scrollbar) moves no cursor — it must still drag
+ # the companion along (anchors on the viewport once the cursor is gone)
+ before_cur, before_dec = lst.cursor, round(dec.scroll_offset.y)
+ lst.scroll_to(y=round(lst.scroll_offset.y) + 30, animate=False)
+ await c.pause(0.35)
+ c.check("a pure scroll in the driver drags the companion along",
+ lst.cursor == before_cur
+ and round(dec.scroll_offset.y) != before_dec,
+ f"cursor {before_cur}->{lst.cursor} "
+ f"dec_top {before_dec}->{round(dec.scroll_offset.y)}")
+ await c.press("tab")
+ await c.pause(0.1)
+ c.check("Tab in split focuses the pseudocode pane", app._active == "decomp",
+ f"active={app._active}")
+ # decomp drives: put the cursor on an addressed pseudocode line (past the
+ # variable decls); the listing band must track the covering instruction row.
+ target = next((i for i, e in enumerate(dec._line_eas) if e is not None), None)
+ c.check("pseudocode has addressed lines", target is not None,
+ "no /*0xEA*/ markers in the pseudocode")
+ if target is not None:
+ dec.cursor = target
+ dec._scroll_cursor_into_view()
+ await c.pause(0.1)
+ app._sync_split("decomp")
+ await c.pause(0.1)
+ want = lst.model.ensure_ea(dec._line_eas[target])
+ c.check("decomp cursor links the instruction row in the listing",
+ want in lst._link_rows,
+ f"link_rows={sorted(lst._link_rows)[:6]} want={want}")
+ # and that linked row actually paints a background band (base rows have
+ # no bg; `want` is a deep code row, never the listing's own cursor row)
+ lst.reveal(want)
+ await c.pause(0.05)
+ y = want - round(lst.scroll_offset.y)
+ banded = (0 <= y < lst.size.height and any(
+ s.style and s.style.bgcolor is not None for s in lst.render_line(y)))
+ c.check("the linked instruction row renders a highlight band", banded,
+ f"y={y} cursor_row={lst.cursor}")
+ await c.press("tab")
+ await c.pause(0.1)
+ c.check("Tab again focuses the listing pane", app._active == "listing",
+ f"active={app._active}")
+ # a mouse click on the other pane also makes it the driver (not just Tab)
+ await c.pilot.click(DecompView, offset=(10, 5))
+ await c.pause(0.15)
+ c.check("clicking the pseudocode pane makes it the driver",
+ app._active == "decomp", f"active={app._active}")
+ await c.press("tab") # restore listing as the driver
+ await c.pause(0.1)
+ # cross-function follow: the listing cursor leaving the decompiled function
+ # re-points the decomp pane to whatever function it's now in.
+ await c.wait(lambda: app._split_range is not None, 10)
+ other = c.find_func(lambda f: f.addr != app._cur.ea and f.size > 40)
+ row = lst.model.ensure_ea(other.addr) if other is not None else None
+ if other is not None and row is not None and row >= 0:
+ lst.focus()
+ app._active = "listing"
+ lst.cursor = row
+ lst._scroll_cursor_into_view()
+ await c.pause(0.1)
+ app._sync_split("listing") # cursor now outside the decompiled fn
+ followed = await c.wait(lambda: dec.loaded_ea == other.addr, 25)
+ c.check("listing cursor crossing into another function re-syncs the decomp",
+ followed, f"dec={dec.loaded_ea} want={other.addr}")
+ # navigation in split keeps BOTH panes on the (new) function
+ nf = c.find_func(lambda f: f.addr != app._cur.ea and f.size > 80)
+ if nf is not None:
+ await c.press("g")
+ await c.type(hex(nf.addr))
+ await c.press("enter")
+ nav = await c.wait(lambda: app._cur and app._cur.ea == nf.addr, 15)
+ c.check("goto in split navigates", nav,
+ f"cur={app._cur.ea if app._cur else None} want={nf.addr}")
+ both = await c.wait(lambda: dec.loaded_ea == nf.addr and app._split
+ and lst.display and dec.display, 25)
+ c.check("split reloads both panes on navigation", both,
+ f"dec={dec.loaded_ea} split={app._split}")
+ await c.press("s")
+ gone = await c.wait(lambda: not app._split and lst.display
+ and not dec.display, 10)
+ c.check("'s' exits split back to a single view", gone,
+ f"split={app._split} lst={lst.display} dec={dec.display}")
+ c.check("exiting split clears the link bands",
+ not lst._link_rows and dec._link_line is None,
+ f"rows={lst._link_rows} line={dec._link_line}")
+
+
@scenario("decomp_fallback")
async def s_fallback(c: Ctx):
app = c.app
- d_view, c_view = c.dis, c.dec
failing = next((f for f in reversed(c.all_funcs())
if c.prog.decompile(f.addr).failed), None)
if failing is None:
c.check("found a decompile-failing function", False)
return
- app._pref = "decomp"
- app._goto(hex(failing.addr))
- await c.wait(lambda: app._cur and app._cur.ea == failing.addr, 20)
- await c.wait(lambda: app._active == "disasm", 20)
- c.check("decompile failure falls back to disassembly (preference kept)",
- app._active == "disasm" and d_view.display and not c_view.display
- and app._pref == "decomp",
- f"active={app._active} pref={app._pref} "
- f"disp(dis={d_view.display},dec={c_view.display})")
- app._goto("main")
- await c.wait(lambda: app._cur and app._cur.name == "main", 20)
- await c.wait(lambda: app._active == "decomp" and c_view.display, 25)
- c.check("preference preserved: next function opens as pseudocode",
- app._active == "decomp" and c_view.display, f"active={app._active}")
+ # Open the failing function in the listing, then F5: decompile fails -> the
+ # view stays on the linear listing (no error panel).
+ await c.open(failing.addr, "listing")
+ c.dis.focus()
+ await c.press("tab")
+ await c.wait(lambda: app._active == "listing"
+ and "fail" in c.status().lower(), 25)
+ c.check("F5/Tab on an undecompilable function falls back to the listing",
+ app._active == "listing" and c.dis.display,
+ f"active={app._active} status={c.status()!r}")
+ # a decompilable function F5s into pseudocode
+ await c.open("main", "decomp")
+ c.check("a decompilable function F5s into pseudocode",
+ app._active == "decomp" and c.dec.display, f"active={app._active}")
@scenario("structs")
@@ -385,21 +787,19 @@ async def s_structs(c: Ctx):
async def s_open(c: Ctx):
app = c.app
fn = c.biggest()
- app._pref = "decomp"
app._open_function(fn.addr, fn.name)
await c.wait(lambda: app._cur and app._cur.ea == fn.addr, 20)
- await c.wait(lambda: c.dis.total > 0, 30)
- c.check("disasm model loads with a total", c.dis.total > 0, f"total={c.dis.total}")
- print(f" biggest = {fn.name} ({c.dis.total} instrs)")
- pc = await c.wait(lambda: app._active == "decomp" and c.dec.display
- and c.dec.loaded_ea == fn.addr, 25)
- c.check("opening a function shows pseudocode by default", pc, f"active={app._active}")
+ await c.wait(lambda: c.lst.total > 0, 30)
+ c.check("opening a function shows the linear listing by default",
+ app._active == "listing" and c.lst.display and c.lst.total > 0,
+ f"active={app._active} total={c.lst.total}")
+ print(f" biggest = {fn.name} ({c.lst.total} listing rows)")
@scenario("disasm_nav")
async def s_disasm_nav(c: Ctx):
app, view = c.app, c.dis
- await c.open_biggest("disasm")
+ await c.open_biggest("listing")
view.focus()
c.check("first instruction cached",
view.model is not None and view.model.cached_line(0) is not None)
@@ -419,8 +819,9 @@ async def s_disasm_nav(c: Ctx):
await c.wait(lambda: app._clipboard == cur_line, 10)
c.check("Ctrl+Y copies the current code line to the clipboard",
bool(cur_line) and app._clipboard == cur_line, f"clip={app._clipboard!r}")
- # goto-bottom must not hang on a huge function.
- await c.press("end")
+ # goto-bottom must not hang on a huge function (ctrl+end; plain 'end' now
+ # moves the cursor to end-of-line).
+ await c.press("ctrl+end")
await c.pause(0.05)
c.check("goto-bottom lands near end", view.cursor >= view.total - 1,
f"cursor={view.cursor}/{view.total}")
@@ -429,7 +830,7 @@ async def s_disasm_nav(c: Ctx):
@scenario("hex")
async def s_hex(c: Ctx):
app, view = c.app, c.dis
- await c.open_biggest("disasm")
+ await c.open_biggest("listing")
view.focus()
view.cursor = 4 # a non-entry instruction
await c.pause(0.05)
@@ -463,10 +864,38 @@ async def s_hex(c: Ctx):
await c.wait(lambda: hx.cursor_va() == target_va, 15)
c.check("'g' in the hex view jumps the cursor to an address",
hx.cursor_va() == target_va, f"va={hx.cursor_va():#x} want={target_va:#x}")
+ # -- a user scroll freezes the cursor's screen row (points at a new byte) --
+ await c.press("g")
+ await c.type(hex(rng[0]))
+ await c.press("enter")
+ await c.wait(lambda: hx.cursor_va() == rng[0], 10)
+ for _ in range(8): # cursor to viewport row 8 (top still 0)
+ await c.press("j")
+ await c.pause(0.1)
+ top0 = round(hx.scroll_offset.y)
+ screen_row = hx.cursor // 16 - top0
+ hx.scroll_to(y=top0 + 30, animate=False) # user scroll down 30 rows
+ await c.pause(0.15)
+ top1 = round(hx.scroll_offset.y)
+ c.check("hex viewport scrolled", top1 >= top0 + 20, f"top0={top0} top1={top1}")
+ c.check("hex cursor's screen row stays frozen on scroll",
+ hx.cursor // 16 - top1 == screen_row,
+ f"screen_row={screen_row} now={hx.cursor // 16 - top1} top1={top1}")
+ PAD = 1 # HexView { padding: 0 1 } -> content is inset one col
+ await c.pilot.click(HexView, offset=(PAD + 19 + 3 * 3, 5)) # hex byte 3, row 5
+ await c.pause(0.1)
+ c.check("clicking the hex pane moves the cursor to the clicked byte",
+ hx.cursor == (top1 + 5) * 16 + 3,
+ f"cursor={hx.cursor} want={(top1 + 5) * 16 + 3} top1={top1}")
+ await c.pilot.click(HexView, offset=(PAD + 70 + 10, 7)) # ascii byte 10, row 7
+ await c.pause(0.1)
+ c.check("clicking the ascii pane maps to the right byte",
+ hx.cursor == (top1 + 7) * 16 + 10,
+ f"cursor={hx.cursor} want={(top1 + 7) * 16 + 10}")
await c.press("backslash")
await c.wait(lambda: app._active != "hex", 10)
c.check("backslash returns from hex to the code view",
- app._active == "disasm", f"active={app._active}")
+ app._active == "listing", f"active={app._active}")
@scenario("filter")
@@ -484,21 +913,31 @@ async def s_filter(c: Ctx):
await c.wait(lambda: app._cur and app._cur.ea == fn.addr, 20)
c.check("selecting a table row opens that function",
bool(app._cur) and app._cur.ea == fn.addr, f"cur={app._cur.ea if app._cur else None}")
- # filter round-trip
+ # filter round-trip. Derive the glob from real names: this used to hardcode
+ # 'sub_1*', which matches NOTHING in a binary whose code never reaches
+ # 0x1xxx (echo's functions are sub_2xxx..sub_7xxx) — a deterministic failure
+ # that looked like a flake, and left the table empty for the next scenario.
+ subs = sorted(f.name for f in c.all_funcs() if f.name.startswith("sub_"))
+ term = (subs[0][:5] + "*") if subs else ""
+ want = sum(1 for f in c.all_funcs()
+ if fnmatch.fnmatch(f.name.lower(), term.lower())) if term else 0
+ c.check("picked a glob that actually matches (test self-check)",
+ 0 < want < nfuncs, f"term={term!r} want={want} of {nfuncs}")
table.focus()
await c.press("slash")
await c.pause(0.05)
- for ch in "sub_1*":
+ for ch in term:
await c.press(ch if ch != "*" else "asterisk")
await c.press("enter")
- filtered = await c.wait(lambda: 0 < table.row_count < nfuncs, 15)
- c.check("filter narrowed the list", filtered, f"rows={table.row_count} of {nfuncs}")
+ filtered = await c.wait(lambda: table.row_count == want, 15)
+ c.check("filter narrowed the list to exactly the matches", filtered,
+ f"term={term!r} rows={table.row_count} want={want} of {nfuncs}")
# pane toggle
left = app.query_one("#left", FunctionsPanel)
await c.press("ctrl+b")
await c.pause(0.05)
c.check("ctrl+b hides functions pane + focuses the code view",
- not left.display and isinstance(app.focused, (DisasmView, DecompView)),
+ not left.display and isinstance(app.focused, (ListingView, DecompView)),
f"display={left.display} focus={type(app.focused).__name__}")
await c.press("ctrl+b")
await c.pause(0.05)
@@ -518,24 +957,62 @@ async def s_view_toggle(c: Ctx):
styled = any(seg.style is not None and seg.style.color is not None
for strip in dec._strips[:min(dec.total, 200)] for seg in strip)
c.check("pseudocode is syntax-highlighted", styled)
+ # Cancelling a prompt must hand focus back to the pane you were READING.
+ # _code_view() used to choose on _pref, which was only ever "listing", so it
+ # focused the hidden listing and the pseudocode stopped answering the
+ # keyboard — arrows did nothing at all until you clicked.
+ dec.focus()
+ await c.pause(0.05)
+ line0 = dec.cursor
+ await c.press("g")
+ await c.wait(lambda: app.query_one("#goto", Input).display, 10)
+ await c.press("escape")
+ await c.wait(lambda: not app.query_one("#goto", Input).display, 10)
+ await c.press("down")
+ await c.press("down")
+ await c.pause(0.2)
+ c.check("cancelling goto leaves focus in the pseudocode (arrows still work)",
+ dec.cursor > line0,
+ f"cursor {line0} -> {dec.cursor} focus={type(app.focused).__name__}")
+ dec.focus() # Tab only toggles the view from a code pane; elsewhere it's
+ await c.pause(0.05) # focus-next, which would silently leave us in decomp
await c.press("tab")
- await c.pause(0.1)
- c.check("tab switches to disassembly",
- app._active == "disasm" and dis.display and not dec.display,
- f"active={app._active}")
- await c.press("tab")
- await c.wait(lambda: app._active == "decomp", 10)
- # a (re)decompile shows the grayed overlay
+ # decomp -> listing runs through _toggle_to_listing, a background worker, so
+ # _active only flips once the listing model has loaded. A fixed pause held in
+ # a short run and lost the race in a full one.
+ switched = await c.wait(lambda: app._active == "listing" and dis.display
+ and not dec.display, 20)
+ c.check("tab switches to disassembly", switched,
+ f"active={app._active} split={app._split} "
+ f"focus={type(app.focused).__name__} "
+ f"lst={dis.display} dec={dec.display}")
+ # _toggle_to_listing repositions asynchronously; F5 below reads the listing
+ # cursor's ea and no-ops if it isn't on an addressed row yet.
+ await c.wait(lambda: c.lst._cursor_ea() is not None, 10)
+ # F5/Tab from the listing must raise the 'decompiling…' overlay synchronously,
+ # BEFORE the background decompile runs (regression: it used to decompile first
+ # in _decomp_from_listing, so the overlay only flashed once cached).
dec.loaded_ea = None
- app._show_active()
+ app.action_toggle_view()
cover = dec._cover_widget
- c.check("decompile shows a 'decompiling…' overlay",
+ c.check("F5 from the listing raises the 'decompiling…' overlay",
dec.loading and cover is not None and "decomp-loading" in cover.classes
and "decompiling" in str(cover.render()),
f"loading={dec.loading} cover={cover!r}")
- await c.wait(lambda: not dec.loading and dec._cover_widget is None, 25)
+ await c.wait(lambda: app._active == "decomp" and not dec.loading
+ and dec._cover_widget is None, 25)
c.check("overlay clears when the decompile finishes",
not dec.loading and dec._cover_widget is None)
+ # F5 on an ALREADY-loaded function must still clear the overlay (regression:
+ # the F5-raised overlay had nothing to clear it in the 'already loaded' branch
+ # -> spinner stuck forever).
+ await c.press("tab") # -> listing
+ await c.wait(lambda: app._active == "listing", 10)
+ app.action_toggle_view() # F5 the same, cached function again
+ cleared = await c.wait(lambda: app._active == "decomp" and not dec.loading
+ and dec._cover_widget is None, 15)
+ c.check("re-decompiling an already-loaded function clears the overlay", cleared,
+ f"loading={dec.loading} cover={dec._cover_widget!r}")
# line-number gutter
dec.scroll_to(0, 0, animate=False)
await c.pause(0.025)
@@ -543,27 +1020,65 @@ async def s_view_toggle(c: Ctx):
c.check("pseudocode has a numbered gutter (line 1 first)",
dec._gutter > 0 and row0[:dec._gutter].strip() == "1",
f"gutter={dec._gutter} row0={row0[:10]!r}")
+ # Home/End move along the line here too (they used to scroll to top/bottom).
+ line = next((i for i, t in enumerate(dec._texts)
+ if t.startswith(" ") and t.strip()), None)
+ if line is not None:
+ text = dec._texts[line]
+ dec.focus()
+ dec.cursor, dec.cursor_x = line, 0
+ dec.refresh()
+ await c.pause(0.05)
+ top = round(dec.scroll_offset.y)
+ await c.press("end")
+ await c.pause(0.05)
+ c.check("<end> in pseudocode goes to end-of-line, not the bottom",
+ dec.cursor == line and dec.cursor_x == max(len(text) - 1, 0)
+ and round(dec.scroll_offset.y) == top,
+ f"line={dec.cursor} col={dec.cursor_x} len={len(text)}")
+ await c.press("home")
+ await c.pause(0.05)
+ c.check("<home> in pseudocode goes to start-of-line",
+ dec.cursor == line and dec.cursor_x == 0,
+ f"line={dec.cursor} col={dec.cursor_x}")
+ await c.press("shift+home")
+ await c.pause(0.05)
+ c.check("<shift+home> skips the indentation",
+ dec.cursor_x == len(text) - len(text.lstrip()),
+ f"col={dec.cursor_x} indent={len(text) - len(text.lstrip())}")
+ await c.press("ctrl+end")
+ await c.pause(0.1)
+ c.check("<ctrl+end> still goes to the bottom",
+ dec.cursor >= dec.total - 1, f"{dec.cursor}/{dec.total}")
+ await c.press("ctrl+home")
+ await c.pause(0.1)
+ c.check("<ctrl+home> still goes to the top", dec.cursor == 0,
+ f"{dec.cursor}")
@scenario("search")
async def s_search(c: Ctx):
app, dis = c.app, c.dis
- await c.open_biggest("disasm")
+ await c.open_biggest("listing")
dis.focus()
- line0 = dis.model.cached_line(0)
+ # derive the term from the instruction at the cursor (the function entry),
+ # not segment head 0 (which may still be streaming in)
+ line0 = dis.model.cached_line(dis.cursor)
raw = (line0.text.split() or ["push"])[0] if line0 else "push"
term = "".join(ch for ch in raw if ch.isalnum())[:4] or "push"
await c.press("slash")
await c.pause(0.1)
await c.type(term)
await c.press("enter")
- await c.wait(lambda: bool(dis._matches), 25)
+ # the unified listing searches the whole segment (load_all) -> allow time
+ await c.wait(lambda: bool(dis._matches), 45)
c.check("search finds matches", len(dis._matches) > 0, f"term={term!r}")
c.check("cursor sits on a match", dis.cursor in dis._matches, f"cursor={dis.cursor}")
c.check("match substring highlighted",
bool(dis._ranges.get(dis.cursor)), str(dis._ranges.get(dis.cursor)))
c.check("search cursor lands on the match's starting column",
- dis.cursor_x == dis._ranges[dis.cursor][0][0],
+ bool(dis._ranges.get(dis.cursor))
+ and dis.cursor_x == dis._ranges[dis.cursor][0][0],
f"cursor_x={dis.cursor_x} ranges={dis._ranges.get(dis.cursor)}")
prev = dis.cursor
await c.press("slash")
@@ -585,10 +1100,10 @@ async def s_search(c: Ctx):
await c.pause(0.1)
c.check("search bar visible, status hidden (no overlap)",
si.display and not status.display, f"si={si.display} status={status.display}")
- footer = app.query_one(Footer)
- c.check("search input is rendered above the footer (not overlapping)",
- si.region.height >= 1 and si.region.y < footer.region.y,
- f"search={si.region} footer={footer.region}")
+ c.check("search input owns the bottom row (nothing overlaps it)",
+ si.region.height >= 1
+ and si.region.y + si.region.height == app.size.height,
+ f"search={si.region} screen={app.size}")
for ch in term:
await c.press(ch)
await c.pause(0.05)
@@ -631,7 +1146,7 @@ async def s_incr_filter(c: Ctx):
@scenario("follow_xrefs")
async def s_follow_xrefs(c: Ctx):
app, dis, dec = c.app, c.dis, c.dec
- await c.open_biggest("disasm")
+ await c.open_biggest("listing")
dis.focus()
lines = dis.model.lines(0, 400, prefetch=False)
call_idx = next((i for i, ln in enumerate(lines)
@@ -673,71 +1188,30 @@ async def s_follow_xrefs(c: Ctx):
c.check("found a function with a code xref", False)
else:
xfn, xref = xf
- xexp = app.program.disasm(xref.fn_addr).index_of_ea(xref.frm)
- await c.press("g")
- await c.pause(0.1)
- await c.type(xfn.name)
- await c.press("enter")
- await c.wait(lambda: dis.total > 0 and app._cur.ea == xfn.addr, 20)
- dis.focus()
- dis.cursor, dis.cursor_x = 0, 0
+ await c.goto_ui(xfn.name)
+ await c.wait(lambda: c.lst.total > 0 and app._cur.ea == xfn.addr, 20)
+ c.lst.focus()
await c.press("x")
await c.wait(lambda: isinstance(app.screen, XrefsScreen), 25)
app.screen.query_one(OptionList).highlighted = 0
await c.press("enter")
await c.wait(lambda: not isinstance(app.screen, XrefsScreen), 25)
- await c.wait(lambda: app._cur.ea == xref.fn_addr, 25)
- await c.pause(0.15)
- c.check("xref-select lands on the referencing function + line",
- app._cur.ea == xref.fn_addr and dis.cursor == xexp,
- f"cur={app._cur.ea:#x} (want {xref.fn_addr:#x}) "
- f"cursor={dis.cursor} (want {xexp})")
-
- dcode = app.program.decompile(xref.fn_addr)
- dexp, be = -1, -1
- if not dcode.failed and dcode.code:
- for i, ln in enumerate(dcode.code.splitlines()):
- for mm in re.findall(r"/\*\s*0x([0-9A-Fa-f]+)\s*\*/", ln):
- e = int(mm, 16)
- if be < e <= xref.frm:
- be, dexp = e, i
- if dexp >= 0:
- app._pref = "decomp"
- app._open_function(xfn.addr, xfn.name)
- await c.wait(lambda: app._cur.ea == xfn.addr and app._active == "decomp", 20)
- await c.wait(lambda: dec.loaded_ea == xfn.addr, 25)
- app._xref_focus_name = xfn.name
- app._on_xref_chosen(xref.frm)
- await c.wait(lambda: dec.loaded_ea == xref.fn_addr, 25)
- await c.pause(0.1)
- c.check("xref-select lands on the reference LINE in pseudocode",
- dec.cursor == dexp, f"dec.cursor={dec.cursor} want={dexp}")
- landed = dec._texts[dec.cursor] if dec.cursor < len(dec._texts) else ""
- tokm = re.search(rf"\b{re.escape(xfn.name)}\b", landed)
- want_col = tokm.start() if tokm else 0
- c.check("xref-select lands the column on the reference token",
- dec.cursor_x == want_col,
- f"cursor_x={dec.cursor_x} want={want_col} "
- f"token={xfn.name!r} line={landed.strip()!r}")
- anch = [(i, dec._line_ea(i)) for i in range(len(dec._texts))
- if dec._line_ea(i) is not None]
- if len(anch) >= 4:
- start_ln = anch[1][0]
- far_ea = anch[len(anch) // 2][1]
- exp2 = app._decomp_line_for(app._cur.ea, far_ea)
- dec.focus()
- dec.cursor = start_ln
- dec.refresh()
- await c.pause(0.05)
- app._on_xref_chosen(far_ea)
- await c.wait(lambda: dec.cursor == exp2, 20)
- c.check("xref-select moves the cursor within the same function",
- dec.cursor == exp2 and exp2 != start_ln,
- f"dec.cursor={dec.cursor} want={exp2} start={start_ln}")
- app._pref = "disasm"
- app._active = "disasm"
- app._show_active()
- await c.pause(0.05)
+ # xref-select lands the listing cursor on the referencing SITE (frm)
+ await c.wait(lambda: c.lst._cursor_ea() == xref.frm, 25)
+ c.check("xref-select lands the cursor on the referencing site",
+ c.lst._cursor_ea() == xref.frm,
+ f"cur_ea={c.lst._cursor_ea()} want={xref.frm:#x}")
+ # F5 at the site decompiles the referencing function
+ c.lst.focus()
+ await c.press("tab")
+ landed = await c.wait(
+ lambda: (app._active == "decomp" and dec.loaded_ea == xref.fn_addr)
+ or (app._active == "listing" and "fail" in c.status().lower()), 25)
+ if app._active == "decomp":
+ c.check("F5 at the xref site decompiles the referencing function",
+ dec.loaded_ea == xref.fn_addr, f"loaded={dec.loaded_ea}")
+ await c.press("tab")
+ await c.wait(lambda: app._active == "listing", 20)
app._open_function(orig, orig_name)
await c.wait(lambda: dis.total > 0 and app._cur.ea == orig, 20)
@@ -782,9 +1256,10 @@ async def s_xref_labels(c: Ctx):
if multi is None:
c.check("found a function with multiple same-caller xrefs", False)
return
- await c.open(multi.addr, "disasm")
+ await c.open(multi.addr, "listing")
dis.focus()
- dis.cursor, dis.cursor_x = 0, 0
+ dis.cursor = max(dis.model.index_of_ea(multi.addr), 0) # segment head at fn
+ dis.cursor_x = 0
dis.refresh()
await c.press("x")
await c.wait(lambda: isinstance(app.screen, XrefsScreen), 25)
@@ -835,7 +1310,7 @@ async def s_xref_labels(c: Ctx):
@scenario("mouse")
async def s_mouse(c: Ctx):
app, dis = c.app, c.dis
- await c.open_biggest("disasm")
+ await c.open_biggest("listing")
dis.focus()
await c.pause(0.1)
mrow = mcol = msym = mline = None
@@ -1114,6 +1589,103 @@ async def s_retype(c: Ctx):
after is not None and "zz_retype_arg" in after.prototype,
f"proto={after.prototype if after else None!r}")
app.program.set_function_type(cf.addr, old_proto) # restore
+ # the retype above kicked off a recompile+reload; let it land before we start
+ # placing the cursor, or the reload resets it under us.
+ app.program.bump_names()
+ await c.wait(lambda: not dec.loading and dec.loaded_ea == cf.addr
+ and bool(dec._texts), 25)
+ await c.pause(0.3)
+
+ # -- 'y' on a LOCAL variable retypes that variable, not the prototype --- #
+ fts = app.program.func_types(cf.addr)
+ lv = next((v for v in (fts.lvars if fts else []) if not v.is_arg), None)
+ if lv is not None:
+ line = next((i for i, t in enumerate(dec._texts)
+ if _word_occurrences(t, lv.name)), None)
+ if line is not None:
+ col = _word_occurrences(dec._texts[line], lv.name)[0][0]
+ dec.focus()
+ dec.cursor, dec.cursor_x = line, col + 1 # inside the word
+ dec.refresh()
+ await c.pause(0.05)
+ await c.press("y")
+ await c.wait(lambda: app.query_one("#retype", Input).display, 10)
+ ri = app.query_one("#retype", Input)
+ c.check("'y' on a local variable prefills that variable's type",
+ ri.value == lv.type and lv.name in str(ri.placeholder),
+ f"val={ri.value!r} want={lv.type!r} ph={ri.placeholder!r}")
+ ri.value = "unsigned __int64"
+ await c.press("enter")
+ changed = await c.wait(lambda: (lambda f: bool(f) and any(
+ v.name == lv.name and v.type == "unsigned __int64"
+ for v in f.lvars))(app.program.func_types(cf.addr)), 25)
+ c.check("applying it retypes the local variable", changed,
+ f"{lv.name}: wanted unsigned __int64")
+ after = app.program.func_types(cf.addr)
+ c.check("retyping a local leaves the prototype alone",
+ after is not None and after.prototype == old_proto,
+ f"proto={after.prototype if after else None!r}")
+
+ # -- 'y' on a GLOBAL retypes the global, not the enclosing function ----- #
+ # The lvar retype above recompiled too — settle again, or the scan below
+ # indexes into pseudocode that's about to be replaced.
+ await c.wait(lambda: not dec.loading and dec.loaded_ea == cf.addr
+ and bool(dec._texts), 25)
+ await c.pause(0.3)
+
+ # Pick a global that actually appears as a word in the pseudocode — a symbol
+ # from decompile().refs often doesn't (a string ref renders as its literal).
+ lvnames = {v.name for v in (fts.lvars if fts else [])}
+ glob = None
+ for i, t in enumerate(dec._texts):
+ for w in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", t):
+ if w in lvnames or w == cf.name:
+ continue
+ try:
+ a = app.program.resolve(w)
+ except Exception: # noqa: BLE001
+ continue
+ if app.program.func_types(a) is not None:
+ continue
+ d = app.program.data_type(a) or {}
+ if (d.get("name") and not d.get("is_func")
+ and "(" not in (d.get("type") or "")):
+ glob = (w, a, d, i)
+ break
+ if glob:
+ break
+ if glob is not None:
+ gname, gaddr, dt, line = glob
+ glob = type("G", (), {"addr": gaddr})() # keep the checks below readable
+ if line is not None:
+ col = _word_occurrences(dec._texts[line], gname)[0][0]
+ dec.focus()
+ dec.cursor, dec.cursor_x = line, col + 1 # inside the word
+ dec.refresh()
+ await c.pause(0.05)
+ c.check("the cursor sits on the global",
+ dec.word_under_cursor() == gname,
+ f"word={dec.word_under_cursor()!r} want={gname!r} "
+ f"line={line} col={col} text={dec._texts[line][:60]!r}")
+ await c.press("y")
+ await c.wait(lambda: app.query_one("#retype", Input).display, 10)
+ ri = app.query_one("#retype", Input)
+ c.check("'y' on a global prefills the global's type (not the proto)",
+ ri.value != old_proto and gname in str(ri.placeholder),
+ f"val={ri.value!r} ph={ri.placeholder!r}")
+ ri.value = "unsigned __int64"
+ await c.press("enter")
+ retyped = await c.wait(
+ lambda: (app.program.data_type(glob.addr) or {}).get("type")
+ == "unsigned __int64", 25)
+ c.check("applying it retypes the global", retyped,
+ f"type={(app.program.data_type(glob.addr) or {}).get('type')!r}")
+ after = app.program.func_types(cf.addr)
+ c.check("retyping a global leaves the prototype alone",
+ after is not None and after.prototype == old_proto,
+ f"proto={after.prototype if after else None!r}")
+ if dt.get("type"): # restore
+ app.program.set_data_type(glob.addr, dt["type"])
@scenario("scroll_restore")
@@ -1124,7 +1696,7 @@ async def s_scroll_restore(c: Ctx):
fb = next((f for f in big if f.addr != fa.addr), big[-1])
await c.goto_ui(fa.name)
await c.wait(lambda: dis.total > 40 and app._cur.ea == fa.addr, 20)
- if app._active != "disasm":
+ if app._active != "listing":
await c.press("tab")
await c.pause(0.1)
for _ in range(4):
@@ -1168,7 +1740,7 @@ async def s_paging(c: Ctx):
fa = next((f for f in big if f.size > 0x400), big[0])
await c.goto_ui(fa.name)
await c.wait(lambda: dis.total > 100 and app._cur.ea == fa.addr, 20)
- if app._active != "disasm":
+ if app._active != "listing":
await c.press("tab")
await c.pause(0.1)
for _ in range(6):
@@ -1190,7 +1762,7 @@ async def s_paging(c: Ctx):
@scenario("rename_history")
async def s_rename_history(c: Ctx):
app, dis, dec = c.app, c.dis, c.dec
- await c.open_biggest("disasm")
+ await c.open_biggest("listing")
bea = app._cur.ea
await c.press("tab") # warm the caller's pseudocode too
await c.wait(lambda: dec.loaded_ea == bea, 20)
@@ -1236,7 +1808,7 @@ async def s_rename_history(c: Ctx):
await c.press("enter")
await c.wait(lambda: app._func_index.by_addr(htarget)
and app._func_index.by_addr(htarget).name == hnew, 25)
- if app._active != "disasm":
+ if app._active != "listing":
await c.press("tab")
await c.press("escape")
await c.wait(lambda: dis.total > 0 and app._cur.ea != htarget, 25)
@@ -1254,12 +1826,399 @@ async def s_rename_history(c: Ctx):
app.program.client.call("rename", batch={"func": {"addr": hex(htarget), "name": hsym}})
+@scenario("region_define")
+async def s_region_define(c: Ctx):
+ """A non-function address opens a flat listing (region) instead of being
+ refused, and 'p' there creates a function and upgrades the view."""
+ app = c.app
+ fn = c.find_func(lambda f: 0x10 <= f.size <= 0x60 and f.name.startswith("sub_"))
+ if fn is None:
+ c.check("found a small sub_ function for the region test", False)
+ return
+ addr, size = fn.addr, fn.size
+ try:
+ # setup: undefine the whole function so [addr, addr+size) is a bare region
+ c.prog.undefine(addr, size=size)
+ c.prog.bump_items()
+ c.check("function removed by undefine",
+ c.prog.function_of(addr) is None, "still a function")
+
+ # navigate there via the real 'g' prompt -> opens the flat LISTING view
+ # (a non-function region), not refused
+ await c.goto_ui(hex(addr))
+ await c.wait(lambda: app._cur is not None and app._cur.ea == addr, 25)
+ c.check("goto to a non-function address opens the listing view (not refused)",
+ app._cur is not None and app._cur.is_region
+ and app._active == "listing" and c.lst.display,
+ f"cur={app._cur} active={app._active} status={c.status()!r}")
+ await c.wait(lambda: c.lst.total > 0 and c.lst._cursor_ea() is not None, 25)
+ c.check("listing renders heads and the cursor sits on the target address",
+ c.lst.total > 0 and c.lst._cursor_ea() == addr,
+ f"total={c.lst.total} cur_ea={c.lst._cursor_ea()}")
+ # the flat listing spans the whole segment, not just this function
+ seg = c.prog.segment_bounds(addr)
+ c.check("listing spans the whole segment (more heads than one function)",
+ seg is not None and c.lst.total > 1, f"total={c.lst.total} seg={seg}")
+
+ # 'p' on the entry head (re)creates the function and UPGRADES to DisasmView
+ c.lst.focus()
+ c.lst.cursor, c.lst.cursor_x = c.lst.model.index_of_ea(addr), 0
+ await c.pause(0.05)
+ await c.press("p")
+ await c.wait(lambda: c.prog.function_of(addr) is not None
+ and app._cur is not None and not app._cur.is_region, 25)
+ c.check("'p' creates a function and upgrades the listing to a function view",
+ c.prog.function_of(addr) is not None and not app._cur.is_region
+ and app._cur.ea == addr and app._active in ("listing", "decomp"),
+ f"fn={c.prog.function_of(addr)} cur={app._cur} active={app._active}")
+ finally:
+ # idempotency: guarantee the function is back even if a check failed
+ if c.prog.function_of(addr) is None:
+ try:
+ c.prog.define_func(addr)
+ except Exception: # noqa: BLE001
+ pass
+ c.prog.bump_items()
+
+
+@scenario("listing_view")
+async def s_listing_view(c: Ctx):
+ """The flat listing view over a data segment: renders code AND data heads,
+ navigates, and reaches hex — without any function in play."""
+ app = c.app
+ # find a data segment (has a non-code head somewhere) via the listing itself
+ data_ea = None
+ for start, end, name in c.prog.sections():
+ if start >= end:
+ continue
+ lm = c.prog.listing(start)
+ if lm is None:
+ continue
+ lm.ensure(40)
+ if any(h.kind == "data" for h in lm.window(0, 40)):
+ data_ea = start
+ break
+ if data_ea is None:
+ c.check("found a segment with data items", False)
+ return
+
+ await c.goto_ui(hex(data_ea))
+ await c.wait(lambda: app._cur is not None and app._active == "listing"
+ and c.lst.total > 0, 25)
+ c.check("navigating to a data segment opens the listing view",
+ app._active == "listing" and c.lst.display and c.lst.total > 0,
+ f"active={app._active} total={c.lst.total}")
+ kinds = {h.kind for h in c.lst.model.window(0, 40)}
+ c.check("listing shows data heads (not just code)", "data" in kinds, str(kinds))
+
+ # a rendered data line carries the item text (e.g. db/dd/string)
+ c.lst.focus()
+ first_data = next((i for i in range(min(c.lst.total, 60))
+ if c.lst.model.get(i) and c.lst.model.get(i).kind == "data"), None)
+ c.check("a data head exists in the first screenful", first_data is not None,
+ f"total={c.lst.total}")
+ if first_data is not None:
+ c.lst.cursor = first_data
+ await c.pause(0.05)
+ plain = c.lst._line_plain(first_data)
+ c.check("data line renders its item text", bool(plain and plain.strip()),
+ f"plain={plain!r}")
+ c.check("listing cursor reports the head address",
+ c.lst._cursor_ea() == c.lst.model.get(first_data).ea, str(c.lst._cursor_ea()))
+
+ # backslash from the listing opens hex at the cursor address; and back
+ cur_ea = c.lst._cursor_ea()
+ await c.press("backslash")
+ await c.wait(lambda: app._active == "hex" and c.hex.display, 15)
+ c.check("backslash from the listing opens the hex view", app._active == "hex",
+ f"active={app._active}")
+ await c.press("backslash")
+ await c.wait(lambda: app._active == "listing", 15)
+ c.check("returning from hex lands back on the listing (not a func view)",
+ app._active == "listing" and c.lst.display, f"active={app._active}")
+
+ # 'd' defines typed data over an undefined run. Synthesize the run
+ # deterministically: undefine a data head, then re-type it via the prompt.
+ dhead = next((c.lst.model.get(i) for i in range(min(c.lst.total, 200))
+ if c.lst.model.get(i) and c.lst.model.get(i).kind == "data"
+ and (c.lst.model.get(i).size or 0) >= 4), None)
+ if dhead is None:
+ c.check("found a data head to re-type", False)
+ return
+ dea = dhead.ea
+ try:
+ c.prog.undefine(dea, size=4)
+ c.prog.bump_items()
+ # reopen the listing so the model reflects the new undefined run
+ await c.goto_ui(hex(dea))
+ await c.wait(lambda: app._active == "listing" and c.lst.total > 0
+ and c.lst.model.index_of_ea(dea) >= 0, 25)
+ ui = c.lst.model.index_of_ea(dea)
+ c.check("undefining a data head yields an unknown run in the listing",
+ ui >= 0 and c.lst.model.get(ui).kind == "unknown",
+ f"kind={c.lst.model.get(ui).kind if ui>=0 else None}")
+ c.lst.focus()
+ c.lst.cursor = ui
+ await c.pause(0.05)
+ await c.press("d")
+ await c.pause(0.1)
+ mdi = app.query_one("#makedata", Input)
+ c.check("'d' opens the make-data prompt prefilled with a type",
+ mdi.display and bool(mdi.value), f"display={mdi.display} val={mdi.value!r}")
+ mdi.value = "char[4]"
+ await c.press("enter")
+ await c.wait(lambda: app._active == "listing"
+ and c.lst.model.index_of_ea(dea) >= 0
+ and c.lst.model.get(c.lst.model.index_of_ea(dea)) is not None
+ and c.lst.model.get(c.lst.model.index_of_ea(dea)).kind == "data", 25)
+ di = c.lst.model.index_of_ea(dea)
+ c.check("'d' turns the undefined run into a typed data item",
+ di >= 0 and c.lst.model.get(di).kind == "data",
+ f"kind={c.lst.model.get(di).kind if di>=0 else None}")
+ finally:
+ c.prog.bump_items()
+
+
+@scenario("listing_name_addr")
+async def s_listing_name_addr(c: Ctx):
+ """In the listing, 'n' names the ADDRESS under the cursor — so you can name a
+ bare/undefined byte (e.g. the free byte at addr+1 after shrinking a u16 to a
+ u8), which the symbol-by-name rename path can't do."""
+ app = c.app
+ # pick a data segment and a >=2-byte data head to shrink
+ data_ea = None
+ for start, end, name in c.prog.sections():
+ if start >= end:
+ continue
+ lm = c.prog.listing(start)
+ if lm is None:
+ continue
+ lm.ensure(60)
+ if any(h.kind == "data" and (h.size or 0) >= 2 for h in lm.window(0, 60)):
+ data_ea = start
+ break
+ if data_ea is None:
+ c.check("found a data segment with a >=2-byte item", False)
+ return
+ dh = next(h for h in c.prog.listing(data_ea).window(0, 60)
+ if h.kind == "data" and (h.size or 0) >= 2)
+ A = dh.ea
+ newname = f"after_{os.getpid()}"
+ try:
+ # shrink the >=2-byte item to a single byte -> A+1 becomes undefined
+ c.prog.make_data(A, "unsigned __int8")
+ c.prog.bump_items()
+ await c.goto_ui(hex(A + 1))
+ await c.wait(lambda: app._active == "listing" and app._cur is not None
+ and app._cur.ea == A + 1, 25)
+ head = c.lst.cur_head()
+ c.check("cursor lands on the now-undefined byte at addr+1",
+ head is not None and head.ea == A + 1 and head.kind == "unknown",
+ f"head={head}")
+ # 'n' opens the address-name prompt (even though there's no symbol)
+ c.lst.focus()
+ await c.press("n")
+ await c.pause(0.1)
+ ri = app.query_one("#rename", Input)
+ c.check("'n' opens the name prompt on an unnamed byte",
+ ri.display, f"display={ri.display}")
+ ri.value = newname
+ await c.press("enter")
+ await c.wait(lambda: app._active == "listing"
+ and c.lst.model.index_of_ea(A + 1) >= 0
+ and c.lst.model.get(c.lst.model.index_of_ea(A + 1)) is not None
+ and c.lst.model.get(c.lst.model.index_of_ea(A + 1)).name == newname, 25)
+ hi = c.lst.model.index_of_ea(A + 1)
+ c.check("naming a bare byte at addr+1 sticks",
+ hi >= 0 and c.lst.model.get(hi).name == newname,
+ f"name={c.lst.model.get(hi).name if hi>=0 else None}")
+ finally:
+ # revert: drop the label and restore raw bytes at A
+ try:
+ c.prog.client.call("rename", batch={"data": {"addr": hex(A + 1), "new": ""}})
+ except Exception: # noqa: BLE001
+ pass
+ c.prog.undefine(A, size=8)
+ c.prog.bump_items()
+
+
+@scenario("listing_make_string")
+async def s_listing_make_string(c: Ctx):
+ """'a' in the listing makes a string literal at the cursor (IDA's 'A')."""
+ app = c.app
+ target = None
+ for start, end, name in c.prog.sections():
+ if start >= end:
+ continue
+ lm = c.prog.listing(start)
+ if lm is None:
+ continue
+ lm.ensure(80)
+ h = next((h for h in lm.window(0, 80)
+ if h.kind == "data" and "'" in h.text), None)
+ if h is not None:
+ target = h.ea
+ break
+ if target is None:
+ c.check("found a string data item to remake", False)
+ return
+ A = target
+ try:
+ c.prog.undefine(A, size=8)
+ c.prog.bump_items()
+ await c.goto_ui(hex(A))
+ await c.wait(lambda: app._active == "listing" and app._cur is not None
+ and app._cur.ea == A, 25)
+ c.check("target is undefined before 'a'",
+ c.lst.cur_head() is not None and c.lst.cur_head().kind == "unknown",
+ f"head={c.lst.cur_head()}")
+ c.lst.focus()
+ await c.press("a")
+ await c.wait(lambda: c.lst.model.index_of_ea(A) >= 0
+ and c.lst.model.get(c.lst.model.index_of_ea(A)) is not None
+ and c.lst.model.get(c.lst.model.index_of_ea(A)).kind == "data"
+ and "'" in c.lst.model.get(c.lst.model.index_of_ea(A)).text, 25)
+ hi = c.lst.model.index_of_ea(A)
+ c.check("'a' creates a string literal at the cursor",
+ hi >= 0 and c.lst.model.get(hi).kind == "data"
+ and "'" in c.lst.model.get(hi).text,
+ f"head={c.lst.model.get(hi) if hi >= 0 else None}")
+ finally:
+ try:
+ c.prog.make_string(A) # restore the original string
+ except Exception: # noqa: BLE001
+ pass
+ c.prog.bump_items()
+
+
+@scenario("listing_struct_expand")
+async def s_listing_struct_expand(c: Ctx):
+ """A struct-typed global expands into indented member rows in the listing."""
+ app = c.app
+ # a writable data address: reuse a data segment's first data head
+ A = None
+ for start, end, name in c.prog.sections():
+ if start >= end:
+ continue
+ lm = c.prog.listing(start)
+ if lm is None:
+ continue
+ lm.ensure(60)
+ h = next((h for h in lm.window(0, 60) if h.kind == "data"), None)
+ if h is not None:
+ A = h.ea
+ break
+ if A is None:
+ c.check("found a data address for the struct test", False)
+ return
+ try:
+ c.prog.client.call(
+ "declare_type",
+ decls=["struct TuiExpandS { int a; char b[4]; short c; };"])
+ c.prog.make_data(A, "TuiExpandS")
+ c.prog.bump_items()
+ await c.goto_ui(hex(A))
+ await c.wait(lambda: app._active == "listing" and app._cur is not None
+ and app._cur.ea == A and c.lst.total > 0, 25)
+ # the summary head, then member rows for a/b/c
+ si = c.lst.model.index_of_ea(A)
+ members = [c.lst.model.get(si + 1 + k) for k in range(3)]
+ names = [m.text for m in members if m is not None]
+ c.check("struct global expands into member rows",
+ all(m is not None and m.kind == "member" for m in members)
+ and any("a" in t for t in names) and any("b" in t for t in names),
+ f"members={names}")
+ c.check("member rows carry field addresses",
+ members[1] is not None and members[1].ea == A + 4,
+ f"ea={members[1].ea if members[1] else None:#x} want={A+4:#x}")
+ finally:
+ try:
+ c.prog.undefine(A, size=16)
+ except Exception: # noqa: BLE001
+ pass
+ c.prog.bump_items()
+
+
+@scenario("continuous_view")
+async def s_continuous_view(c: Ctx):
+ """The default code view is ONE continuous listing: opening a function shows
+ the whole segment (functions + data interleaved, with opcodes); F5/Tab
+ decompiles the function at the cursor and back."""
+ app = c.app
+ fn = await c.open_biggest("listing")
+ fn_ea = fn.addr
+ await c.wait(lambda: app._active == "listing" and c.lst.display, 10)
+ c.check("a function opens in the continuous listing by default",
+ app._active == "listing" and c.lst.display
+ and c.lst._cursor_ea() == fn_ea,
+ f"active={app._active} disp={c.lst.display} cur_ea={c.lst._cursor_ea()}")
+ # the listing spans the whole segment, not just the function
+ c.lst.model.load_all()
+ seg = c.prog.segment_bounds(fn_ea)
+ seg_rows = len(c.lst.model)
+ # a function's own instruction count is far smaller than the segment
+ fdis = c.prog.disasm(fn_ea, fn.name)
+ c.check("the continuous listing extends past the function's bounds",
+ seg_rows > fdis.total(), f"listing={seg_rows} func={fdis.total()}")
+ kinds = {c.lst.model.get(i).kind for i in range(seg_rows)}
+ c.check("continuous listing interleaves code with data/undefined",
+ "code" in kinds and ("data" in kinds or "unknown" in kinds), str(kinds))
+ # rendering parity with disasm: code lines carry opcode bytes
+ cidx = next((i for i in range(seg_rows)
+ if c.lst.model.get(i).kind == "code"), None)
+ c.check("continuous listing renders opcode bytes (parity with disasm)",
+ cidx is not None and c.lst.model.get(cidx).raw
+ and c.lst._op_field(c.lst.model.get(cidx)).strip() != "",
+ f"raw={c.lst.model.get(cidx).raw if cidx is not None else None!r}")
+ # F5/Tab at the function -> decompiler, and back to the same spot
+ c.lst.focus()
+ await c.press("tab")
+ await c.wait(lambda: (app._active == "decomp" and c.dec.loaded_ea == fn_ea)
+ or (app._active == "listing" and "fail" in c.status().lower()), 25)
+ if app._active == "decomp":
+ c.check("F5/Tab decompiles the function under the cursor",
+ c.dec.loaded_ea == fn_ea, f"loaded={c.dec.loaded_ea}")
+ await c.press("tab")
+ await c.wait(lambda: app._active == "listing", 25)
+ c.check("F5/Tab in the decompiler returns to the listing at the same ea",
+ app._active == "listing" and c.lst._cursor_ea() == fn_ea,
+ f"active={app._active} cur_ea={c.lst._cursor_ea()}")
+ else:
+ c.check("undecompilable function falls back to the listing",
+ app._active == "listing", f"active={app._active}")
+
+
+@scenario("func_banners")
+async def s_func_banners(c: Ctx):
+ """The unified listing shows IDA-style function boundary banners: a
+ SUBROUTINE separator + 'name proc near' header and a 'name endp' footer,
+ and those banner rows are display-only (navigation lands on real code)."""
+ app = c.app
+ fn = await c.open_biggest("listing")
+ c.lst.model.load_all()
+ heads = [c.lst.model.get(i) for i in range(len(c.lst.model))]
+ ci = c.lst.model.index_of_ea(fn.addr)
+ c.check("navigation to a function lands on its code head, not a banner",
+ ci >= 0 and c.lst.model.get(ci).kind == "code",
+ f"kind={c.lst.model.get(ci).kind if ci >= 0 else None}")
+ c.check("a SUBROUTINE separator banner is present",
+ any(h.kind == "sep" and "S U B R O U T I N E" in h.text for h in heads))
+ c.check("a 'name proc' header is present",
+ any(h.kind == "funchdr" and h.text.endswith(" proc") for h in heads))
+ c.check("a 'name endp' footer is present",
+ any(h.kind == "funchdr" and h.text.endswith("endp") for h in heads))
+ # the proc header for the origin function carries its name
+ hdr = next((h for h in heads if h.kind == "funchdr"
+ and h.text == f"{fn.name} proc"), None)
+ c.check("the proc header names the function", hdr is not None, f"fn={fn.name}")
+
+
# --------------------------------------------------------------------------- #
# Runner
# --------------------------------------------------------------------------- #
-async def run(db, only=None):
- url = os.environ.get("IDA_MCP_URL", "http://127.0.0.1:8745/mcp")
- app = IdaTui(url=url, db=db, keepalive=False)
+async def run(binary, only=None):
+ # Own idalib worker: opens the binary in-process over a unix socket.
+ app = IdaTui(open_path=binary, keepalive=False)
async with app.run_test(size=(140, 44)) as pilot:
c = Ctx(app, pilot)
await c.boot()
@@ -1282,21 +2241,28 @@ async def run(db, only=None):
def main(argv):
global STOP_AFTER
- db = only = None
+ only = None
+ binary = None
it = iter(argv)
for a in it:
- if a == "--db":
- db = next(it)
- elif a == "--only":
+ if a == "--only":
only = next(it).split(",")
elif a == "--stop-after":
STOP_AFTER = next(it)
+ elif a in ("--worker", "--binary"):
+ binary = os.path.abspath(os.path.expanduser(next(it)))
elif a == "--list":
for name, _ in SCENARIOS:
print(name)
return 0
+ elif not a.startswith("-"):
+ binary = os.path.abspath(os.path.expanduser(a))
+ if binary is None: # default target for the pilot
+ binary = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "targets", "echo")
try:
- asyncio.run(run(db, only))
+ asyncio.run(run(binary, only))
except _StopSuite:
print(f" … stopped after '{STOP_AFTER}'")
print(f"\n{PASS} passed, {FAIL} failed")