aboutsummaryrefslogtreecommitdiffstats
path: root/idatui
diff options
context:
space:
mode:
Diffstat (limited to 'idatui')
-rw-r--r--idatui/__init__.py6
-rw-r--r--idatui/app.py391
-rw-r--r--idatui/codemode_client.py1494
-rw-r--r--idatui/domain.py695
-rw-r--r--idatui/edit_ctl.py207
-rw-r--r--idatui/errors.py2
-rw-r--r--idatui/kittygfx.py18
-rw-r--r--idatui/launch.py16
-rw-r--r--idatui/nexus_client.py551
-rw-r--r--idatui/pane.py18
-rw-r--r--idatui/pool.py36
-rw-r--r--idatui/project.py14
-rw-r--r--idatui/remote_ops.py1683
-rw-r--r--idatui/remote_tools.py622
14 files changed, 3675 insertions, 2078 deletions
diff --git a/idatui/__init__.py b/idatui/__init__.py
index 7da28b3..e89d8f6 100644
--- a/idatui/__init__.py
+++ b/idatui/__init__.py
@@ -1,4 +1,4 @@
-"""idatui — a keyboard-first TUI using shared IDA Code Mode databases."""
+"""idatui — a keyboard-first TUI using shared IDA Nexus databases."""
from .errors import (
IDAError,
@@ -10,7 +10,7 @@ from .errors import (
IDASessionError,
Session,
)
-from .codemode_client import CodeModeClient
+from .nexus_client import NexusClient
from .domain import (
Program,
FunctionIndex,
@@ -25,7 +25,7 @@ from .domain import (
)
__all__ = [
- "CodeModeClient",
+ "NexusClient",
"Program",
"FunctionIndex",
"DisasmModel",
diff --git a/idatui/app.py b/idatui/app.py
index d0bf403..1ee7459 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -10,7 +10,7 @@ Design notes:
without ever materializing 52k lines in a widget.
* All network/domain work runs in Textual worker threads; the UI never blocks.
* An address-history stack backs Enter (follow) / Esc (back), IDA-style.
-* Database lifecycle is lease-based through ida_codemode: matching GUI sessions
+* Database lifecycle is lease-based through ida_nexus: matching GUI sessions
are reused, otherwise a shared managed idalib worker is opened on demand.
"""
@@ -46,8 +46,7 @@ from textual.widgets import (
)
from textual.widgets.option_list import Option
-from . import graph
-from . import kittygfx
+from . import diag, graph, kittygfx
from .edit_ctl import EditController
from .prompt import PromptBar
from .trace_ctl import TraceController
@@ -55,8 +54,8 @@ from . import findings, search
from .highlight import CTextArea, highlight_c
from .journal import Journal
-from .errors import IDAToolError, IDAConnectionError
-from .codemode_client import CodeModeClient, registered_database
+from .errors import IDAConnectionError
+from .nexus_client import NexusClient, registered_database
from .domain import Func, Head, ListingModel, Program, Struct
# Styles for the disassembly listing.
@@ -1088,7 +1087,7 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
def _span_segments(h: Head, fallback: Style):
"""Segments for a row's disassembly text.
- Uses IDA's own token classification when Code Mode supplies it; falls
+ Uses IDA's own token classification when IDA Nexus supplies it; falls
back to the mnemonic/rest split when spans are absent or disagree with
the plain text.
"""
@@ -3695,6 +3694,7 @@ _HELP = (
("Ctrl+B", "show/hide the names pane"),
("Ctrl+T", "structs / types editor"),
("Ctrl+F", "search the database: text or bytes"),
+ ("Ctrl+R", "refresh the current view in place"),
("Ctrl+E", "export findings as markdown"),
("Ctrl+P", "command palette"),
)),
@@ -3748,14 +3748,14 @@ _HELP = (
class QuitScreen(ModalScreen):
"""Asked before exiting with unsaved database changes.
- Code Mode clients cannot roll a shared database back. The ``d`` choice means
- "do not explicitly save": a GUI keeps the changes dirty, while a managed
- idalib worker may persist them when its final lease closes.
+ A final managed-worker lease can discard the whole session. Shared workers
+ and GUI databases keep their state: releasing this lease transfers the final
+ save/discard decision to the remaining client or GUI owner.
"""
BINDINGS = [
Binding("s", "save", "Save & quit"),
- Binding("d", "discard", "Leave & quit"),
+ Binding("d", "discard", "Discard / leave"),
Binding("escape,c", "cancel", "Cancel"),
]
@@ -3771,8 +3771,11 @@ class QuitScreen(ModalScreen):
body = Text()
for label in self._labels:
body.append(f" \u2022 {label}\n", _S_LABEL)
+ body.append(
+ "\nFinal managed leases discard; shared/GUI sessions stay open.",
+ _S_DIM)
yield Static(body, id="quit-list")
- yield Static("s save & quit d leave as-is & quit Esc cancel",
+ yield Static("s save & quit d discard / leave & quit Esc cancel",
id="quit-help")
def action_save(self) -> None:
@@ -5174,6 +5177,7 @@ class IdaTui(App):
Binding("ctrl+n", "symbols", "Symbols"),
Binding("ctrl+t", "structs", "Structs"),
Binding("ctrl+f", "find", "Find"),
+ Binding("ctrl+r", "refresh_view", "Refresh", show=False),
Binding("ctrl+e", "export", "Export", show=False),
Binding("backslash", "hex", "Hex"),
Binding("s", "toggle_split", "Split", show=False),
@@ -5244,7 +5248,7 @@ class IdaTui(App):
self._open_path = open_path
self._ttl = ttl
self._load_args = load_args or "" # first-open options for a headerless blob
- self._new_database = False # Ctrl+L asks Code Mode for a fresh IDB
+ self._new_database = False # Ctrl+L asks IDA Nexus for a fresh IDB
self._title = (os.path.basename(open_path) if open_path else "")
#: Where we are in the execution trace, and everything that moves us.
#: Owns the trace state; the _trace/_t/_trail_* properties below
@@ -5253,7 +5257,7 @@ class IdaTui(App):
self._do_keepalive = keepalive
self._rpc_path = rpc_path
self._rpc = None
- self.client: CodeModeClient | None = None
+ self.client: NexusClient | None = None
self.program: Program | None = None
self._loading_screen: LoadingScreen | None = None
self._ka = None
@@ -5294,6 +5298,11 @@ class IdaTui(App):
self.journal = Journal()
self._xref_focus_name: str | None = None
self._dirty = False
+ # One subscription for the active database. NexusClient debounces
+ # bursts off the Textual worker pool; the callback re-enters here on the
+ # UI thread to invalidate and reload the visible models.
+ self._idb_event_watch = None
+ self._idb_refresh_seq = 0
# -- layout ------------------------------------------------------------ #
def compose(self) -> ComposeResult:
@@ -5363,7 +5372,7 @@ class IdaTui(App):
if self._rpc_path:
self._start_rpc()
# A file no loader recognises has to be described before it can be
- # opened, so ask BEFORE Code Mode creates it — once IDA has made a database
+ # opened, so ask BEFORE IDA Nexus creates it — once IDA has made a database
# the answer is baked in and changing it requires a fresh-IDB reopen.
if self._project is not None:
ref = self._pending_load_ref()
@@ -5436,7 +5445,7 @@ class IdaTui(App):
ref = self._project.by_label(self._binary)
if ref is not None:
path, label = ref.source, ref.label
- # Release our lease first. Code Mode waits for a managed worker's final
+ # Release our lease first. IDA Nexus waits for a managed worker's final
# lease grace, then creates the replacement IDB atomically. A GUI-backed
# database is rejected by _can_reload(): the TUI must never close it.
self._release_database()
@@ -5455,6 +5464,7 @@ class IdaTui(App):
self._ask_load_options(path, label=label)
def _release_database(self) -> None:
+ self._stop_idb_event_watch()
if self.program is not None:
self.program.close()
if self._pool is not None and self._binary is not None:
@@ -5633,9 +5643,142 @@ class IdaTui(App):
pass
return len(text)
+ # -- live refresh from shared IDB changes ----------------------------- #
+ def _start_idb_event_watch(self, client: NexusClient) -> None:
+ self._stop_idb_event_watch()
+ watch = getattr(client, "watch_idb_events", None)
+ if watch is None: # IDA-free test doubles and pre-event adapters
+ return
+
+ def changed(events) -> None: # listener thread
+ try:
+ self.call_from_thread(self._refresh_idb_events, client, events)
+ except Exception: # noqa: BLE001 -- app teardown can win this race
+ pass
+
+ def failed(error: BaseException) -> None: # listener thread
+ try:
+ self.call_from_thread(self._idb_event_watch_failed, client, error)
+ except Exception: # noqa: BLE001 -- app teardown can win this race
+ pass
+
+ self._idb_event_watch = watch(
+ changed, on_error=failed, debounce=0.2)
+
+ def _stop_idb_event_watch(self) -> None:
+ watcher, self._idb_event_watch = self._idb_event_watch, None
+ if watcher is not None:
+ watcher.close()
+
+ def _idb_event_watch_failed(
+ self, client: NexusClient, error: BaseException
+ ) -> None:
+ if client is not self.client:
+ return
+ if isinstance(error, IDAConnectionError):
+ self._on_connection_lost()
+ else:
+ self._status(f"live database refresh stopped: {error}")
+
+ def _listing_event_anchor(self) -> ViewAnchor:
+ """Capture the listing position even when the split's decompiler has focus."""
+ anchor = ViewAnchor(view=self._active)
+ listing = self.query_one(ListingView)
+ model = listing.model
+ if model is None:
+ return anchor
+ anchor.cursor_x = listing.cursor_x
+ anchor.ea = listing._cursor_ea()
+ top = round(listing.scroll_offset.y)
+ head = model.cached_line(top) or model.get(top)
+ anchor.top_ea = getattr(head, "ea", None)
+ return anchor
+
+ def _refresh_idb_events(
+ self, client: NexusClient, events: tuple[dict, ...]
+ ) -> None:
+ """Invalidate once per external edit burst and reload the active surface."""
+ program = self.program
+ if not events or client is not self.client or program is None \
+ or program.client is not client:
+ return
+ self._idb_refresh_seq += 1
+ seq = self._idb_refresh_seq
+ entry = self._cur
+ anchor = self._listing_event_anchor()
+ hex_ea = self.query_one(HexView).cursor_va() if self.is_hex else None
+ graph_ea = self.query_one(GraphView)._cursor_ea() if self.is_graph else None
+ decomp = self.query_one(DecompView)
+ if entry is not None and decomp.loaded_ea == entry.ea:
+ entry.dec_cursor = decomp.cursor
+ entry.dec_cursor_x = decomp.cursor_x
+ entry.dec_scroll_y = round(decomp.scroll_offset.y)
+ entry.dec_scroll_x = round(decomp.scroll_offset.x)
+
+ program.invalidate_external()
+ self._status(
+ f"{len(events)} external database "
+ f"change{'s' if len(events) != 1 else ''} — refreshing…")
+ self._reindex_functions()
+
+ if self.is_hex:
+ self.query_one(HexView).model = None
+ self._load_hex_model(hex_ea)
+ return
+ if self.is_graph and entry is not None:
+ self._load_graph(entry.ea, graph_ea or entry.ea)
+ return
+ if entry is None:
+ return
+
+ # A split needs both halves rebuilt; a decompiler-only view still keeps
+ # the hidden listing fresh so Tab does not reveal pre-event rows.
+ decomp.loaded_ea = None
+ self._reload_idb_listing(program, seq, entry, anchor)
+ if self.is_decomp or self._split:
+ self._show_active()
+
+ @work(thread=True, exclusive=True, group="idb-refresh")
+ def _reload_idb_listing(
+ self, program: Program, seq: int, entry: NavEntry, anchor: ViewAnchor
+ ) -> None:
+ target = anchor.ea if anchor.ea is not None else entry.ea
+ model = program.listing(target)
+ cursor = top = -1
+ if model is not None:
+ model.ensure_ea(target)
+ cursor, top = self._anchor_rows(anchor, model, target)
+ fn = program.function_of(entry.ea)
+ name = fn.name if fn is not None else program.region_label(entry.ea)
+ self.app.call_from_thread(
+ self._apply_idb_listing, program, seq, entry, model,
+ cursor, top, anchor.cursor_x, name, fn is None)
+
+ def _apply_idb_listing(
+ self, program: Program, seq: int, entry: NavEntry, model,
+ cursor: int, top: int, cursor_x: int, name: str, is_region: bool,
+ ) -> None:
+ if program is not self.program or seq != self._idb_refresh_seq \
+ or entry is not self._cur:
+ return
+ if model is None:
+ self._status(f"{entry.ea:#x} is no longer in a loaded segment")
+ return
+ entry.name = name
+ entry.is_region = is_region
+ entry.cursor = max(cursor, 0)
+ entry.cursor_x = cursor_x
+ if top >= 0:
+ entry.scroll_y = top
+ self.query_one(ListingView).load(
+ model, name, cursor=entry.cursor, cursor_x=entry.cursor_x,
+ scroll_y=top if top >= 0 else None)
+ if self.is_listing:
+ self._show_active()
+
# -- connection loss / recovery --------------------------------------- #
def _handle_exception(self, error: BaseException) -> None:
- """Intercept a lost Code Mode lease so the app can rediscover the DB.
+ """Intercept a lost IDA Nexus lease so the app can rediscover the DB.
Everything unrelated to database connectivity crashes as usual.
"""
@@ -5670,15 +5813,22 @@ class IdaTui(App):
@work(thread=True, exclusive=True, group="reconnect")
def _reconnect(self) -> None:
- # The registered instance disappeared. Rediscover it; Code Mode may find
- # a GUI/replacement worker, then we rebuild caches against the new handle.
+ # Rediscovery is attach-only. If a GUI owner closes its database, a TUI
+ # must not silently reopen it by spawning a headless worker.
try:
if self._open_path is None:
self.app.call_from_thread(self._reconnect_failed,
"no binary to reopen")
return
- client = CodeModeClient(self._open_path, ttl=self._ttl,
- load_args=self._load_args)
+ if self._project is not None and self._binary is not None:
+ ref = self._project.by_label(self._binary)
+ client = NexusClient(
+ ref.staged, ttl=self._ttl, load_args=ref.load_args,
+ output_database=ref.db, spawn=False)
+ else:
+ client = NexusClient(
+ self._open_path, ttl=self._ttl,
+ load_args=self._load_args, spawn=False)
client.connect(progress=lambda m: self.app.call_from_thread(
self._conn_note, m))
except Exception as e: # noqa: BLE001
@@ -5686,21 +5836,33 @@ class IdaTui(App):
return
self.app.call_from_thread(self._after_reconnect, client, Program(client))
- def _after_reconnect(self, client: "CodeModeClient", program: "Program") -> None:
+ def _after_reconnect(self, client: "NexusClient", program: "Program") -> None:
+ old_client, old_program = self.client, self.program
+ self._stop_idb_event_watch()
+ if old_program is not None:
+ old_program.close()
+ if self._pool is not None and self._binary is not None:
+ self._pool.replace_client(self._binary, old_client, client)
+ if old_client is not None and old_client is not client:
+ old_client.close()
self.client = client
self.program = program
+ self._start_idb_event_watch(client)
self._reconnecting = False
+ self._dirty = False
self._dismiss_conn()
- self._status("reconnected \u2014 reloading\u2026")
- self._load_functions() # rebuild the function index against the new client
+ self._status("reattached — reloading persisted state…")
+ self._load_functions()
cur = self._cur
- if cur is not None: # refresh the current view with the new program
+ if cur is not None:
self._open_entry(cur, push=False)
def _reconnect_failed(self, why: str) -> None:
self._reconnecting = False
- self._conn_note(f"reconnect failed: {why} \u2014 retry on next action, or 'q'")
- self._status(f"reconnect failed: {why}")
+ note = (f"database owner closed: {why} — reopen it in IDA, then "
+ "Esc and retry an action; or q to quit")
+ self._conn_note(note)
+ self._status(note)
# -- connection + initial load ---------------------------------------- #
@work(thread=True, exclusive=True, group="connect")
@@ -5727,13 +5889,14 @@ class IdaTui(App):
return
self.client = client
self.program = program
+ self._start_idb_event_watch(client)
self._new_database = False
self.app.call_from_thread(
self._status, f"{module} [{client.backend}] — loading functions…")
self._load_functions()
def _open_database_client(self): # type: ignore[no-untyped-def]
- """Attach through Code Mode, reusing a GUI or managed idalib database."""
+ """Attach through IDA Nexus, reusing a GUI or managed idalib database."""
if self._pool is not None: # project mode: the pool owns the leases
label = self._binary or self._project.refs[0].label
client = self._pool.get(label, progress=lambda m:
@@ -5745,13 +5908,13 @@ class IdaTui(App):
return client
if not self._open_path:
self.app.call_from_thread(
- self._status, "Code Mode needs a database or executable path")
+ self._status, "IDA Nexus needs a database or executable path")
self.app.call_from_thread(self._dismiss_loading)
return None
base = os.path.basename(self._open_path)
self.app.call_from_thread(
- self._status, f"discovering Code Mode database for {base}…")
- client = CodeModeClient(self._open_path, ttl=self._ttl,
+ self._status, f"discovering IDA Nexus database for {base}…")
+ client = NexusClient(self._open_path, ttl=self._ttl,
load_args=self._load_args,
new_database=self._new_database)
client.connect(progress=lambda m: self.app.call_from_thread(
@@ -5825,7 +5988,7 @@ class IdaTui(App):
@work(thread=True, exclusive=True, group="index")
def _index_binary(self) -> None:
"""Fold this binary's symbols + strings into the project index, so it can
- be searched later even when its Code Mode lease is gone."""
+ be searched later even when its IDA Nexus lease is gone."""
if self._index is None or self._project is None or self._binary is None:
return
ref = self._project.by_label(self._binary)
@@ -5928,7 +6091,7 @@ class IdaTui(App):
cursor=0, push=True, is_region=True)
def _can_reload(self) -> bool:
- """Whether Code Mode can replace this IDB with different options.
+ """Whether IDA Nexus can replace this IDB with different options.
A GUI database is owned by the user and has no remote close/rollback
route. Managed idalib databases can be released and reopened fresh.
@@ -6123,11 +6286,13 @@ class IdaTui(App):
def _on_quit_choice(self, choice: str | None) -> None:
if choice == "discard":
- # Code Mode has no rollback/close-without-save operation. For GUI
- # sessions this leaves changes dirty in IDA; a managed worker owns
- # its final save policy and may persist them on final lease release.
- self._save_on_exit = False
- self.exit()
+ # Whole-session discard is legal only for the final managed lease.
+ # GUI/shared sessions retain state and inherit finalization.
+ dirty = self._dirty_labels()
+ self._loading_screen = LoadingScreen(
+ "discarding", note="finalizing database leases…")
+ self.push_screen(self._loading_screen)
+ self._discard_then_exit(dirty)
elif choice == "save":
# Save with the overlay up: writing a big .i64 takes seconds, and
# doing it during teardown would look like a hang with no UI left.
@@ -6137,6 +6302,31 @@ class IdaTui(App):
# None: cancel, stay put
@work(thread=True, exclusive=True, group="save-exit")
+ def _discard_then_exit(self, dirty: list[str]) -> None:
+ try:
+ if self._pool is not None:
+ transferred = self._pool.discard_changes(dirty)
+ elif self.client is not None:
+ transferred = [] if self.client.discard_database() else dirty
+ else:
+ transferred = dirty
+ except Exception as exc: # noqa: BLE001 -- keep the app open on failure
+ self.app.call_from_thread(self._discard_failed, str(exc))
+ return
+ self.app.call_from_thread(self._finish_discard, transferred)
+
+ def _discard_failed(self, why: str) -> None:
+ self._dismiss_loading()
+ self._status(f"discard failed: {why}", priority=True)
+
+ def _finish_discard(self, transferred: list[str]) -> None:
+ if transferred and self._loading_screen is not None:
+ labels = ", ".join(transferred)
+ self._loading_screen.update_note(
+ f"finalization transferred: {labels}")
+ self._finish_exit()
+
+ @work(thread=True, exclusive=True, group="save-exit")
def _save_then_exit(self) -> None:
try:
if self._pool is not None:
@@ -6148,7 +6338,9 @@ class IdaTui(App):
self.app.call_from_thread(self._finish_exit)
def _finish_exit(self) -> None:
- self._save_on_exit = False # already written above
+ # Teardown must not save again: save, discard, or ownership transfer was
+ # already decided by the quit path.
+ self._save_on_exit = False
self._dirty = False
self.exit()
@@ -6215,6 +6407,7 @@ class IdaTui(App):
def _after_switch(self, label, client, program, st, reuse) -> None: # type: ignore[no-untyped-def]
self.client = client
self.program = program
+ self._start_idb_event_watch(client)
self._binary = label
self._pool.set_active(label)
self._open_path = self._project.by_label(label).staged
@@ -6327,6 +6520,127 @@ class IdaTui(App):
return
self._goto_ea(addr, push=True) # land on the literal in the listing
+ def action_refresh_view(self) -> None:
+ """Ctrl+R: discard cached data and reload the visible view in place."""
+ if self.program is None or self._cur is None:
+ self._status("nothing to refresh")
+ return
+ if self._prompt_active() or self.screen is not self.screen_stack[0]:
+ return
+
+ if self.is_hex:
+ hx = self.query_one(HexView)
+ if hx.model is None:
+ self._status("hex: nothing to refresh")
+ return
+ self._status("hex — refreshing…")
+ hx.model.invalidate()
+ # Re-read the visible blocks off the UI thread. ``center=False``
+ # preserves both the byte cursor and the viewport.
+ hx._prime(center=False)
+ return
+
+ cur = self._cur
+ mode = self._active
+ split = self._split
+ listing_anchor = None
+ if self.is_listing or split:
+ # _anchor() follows the active pane. In split mode pseudocode may be
+ # active, but the listing must still round-trip through addresses:
+ # row indices do not survive an external structure change.
+ lst = self.query_one(ListingView)
+ listing_anchor = ViewAnchor(view=ViewMode.LISTING,
+ cursor_x=lst.cursor_x)
+ model = lst.model
+ if model is not None:
+ listing_anchor.ea = lst._cursor_ea()
+ top = round(lst.scroll_offset.y)
+ h = model.cached_line(top) or model.get(top)
+ listing_anchor.top_ea = getattr(h, "ea", None)
+
+ refresh_decomp = self.is_decomp or split
+ if refresh_decomp:
+ # Keep the live pseudocode position; NavEntry is only updated when
+ # navigating away and may lag behind the widget.
+ dec = self.query_one(DecompView)
+ if dec.loaded_ea == cur.ea:
+ cur.dec_cursor = dec.cursor
+ cur.dec_cursor_x = dec.cursor_x
+ cur.dec_scroll_y = round(dec.scroll_offset.y)
+ cur.dec_scroll_x = round(dec.scroll_offset.x)
+ dec.loading = True
+
+ want_ea = (self.query_one(GraphView)._cursor_ea()
+ if self.is_graph else None)
+ if self.is_graph:
+ self._graph_sticky = True
+ self._status(f"{cur.name} — refreshing graph…")
+ else:
+ self._status(f"{cur.name} — refreshing…")
+ self._refresh_view(cur, mode, split, listing_anchor,
+ refresh_decomp, want_ea, self.program)
+
+ @work(thread=True, exclusive=True, group="refresh-view")
+ def _refresh_view(self, cur: NavEntry, mode: ViewMode, split: bool,
+ anchor: ViewAnchor | None, refresh_decomp: bool,
+ want_ea: int | None, program) -> None: # type: ignore[no-untyped-def]
+ """Invalidate and rebuild without blocking Textual's event loop."""
+ try:
+ program.bump_items()
+ if refresh_decomp:
+ program.force_recompile(cur.ea)
+
+ model = None
+ cursor = top = -1
+ if anchor is not None:
+ target = anchor.ea if anchor.ea is not None else cur.ea
+ model = program.listing(target)
+ if model is not None:
+ model.ensure_ea(target)
+ cursor, top = self._anchor_rows(anchor, model, target)
+ except Exception as exc: # noqa: BLE001 -- a refresh is recoverable
+ diag.note("refresh_view", exc)
+ self.app.call_from_thread(
+ self._view_refresh_failed, cur, program, str(exc))
+ return
+ self.app.call_from_thread(
+ self._apply_view_refresh, cur, mode, split, anchor,
+ refresh_decomp, want_ea, program, model, cursor, top)
+
+ def _view_refresh_failed(self, cur: NavEntry, program, error: str) -> None: # type: ignore[no-untyped-def]
+ if self.program is program and self._cur is cur:
+ self.query_one(DecompView).loading = False
+ self._status(f"refresh failed: {error}", priority=True)
+
+ def _apply_view_refresh(self, cur: NavEntry, mode: ViewMode, split: bool,
+ anchor: ViewAnchor | None, refresh_decomp: bool,
+ want_ea: int | None, program, model, cursor: int,
+ top: int) -> None: # type: ignore[no-untyped-def]
+ # A binary switch or navigation completed while the refresh was in
+ # flight. Its newer view wins; never drag the user back.
+ if self.program is not program or self._cur is not cur:
+ return
+ self._active = mode
+ self._split = split
+
+ if self.is_graph:
+ self._load_graph(cur.ea, want_ea)
+ return
+
+ if anchor is not None and model is not None:
+ lst = self.query_one(ListingView)
+ cur.cursor = max(cursor, 0)
+ cur.cursor_x = anchor.cursor_x
+ cur.scroll_y = top
+ lst.load(model, cur.name, cursor=cur.cursor,
+ cursor_x=cur.cursor_x,
+ scroll_y=top if top >= 0 else None)
+ if refresh_decomp:
+ self.query_one(DecompView).loaded_ea = None
+ self._show_active()
+ if not refresh_decomp:
+ self._status(f"{cur.name} — refreshed", priority=True)
+
def action_toggle_view(self) -> None:
"""Tab: switch the code pane between disassembly and pseudocode (or leave
the hex view back to the preferred code view)."""
@@ -8261,6 +8575,7 @@ class IdaTui(App):
# -- teardown ---------------------------------------------------------- #
async def on_unmount(self) -> None:
+ self._stop_idb_event_watch()
if self._rpc is not None:
await self._rpc.stop()
if self._ka is not None:
diff --git a/idatui/codemode_client.py b/idatui/codemode_client.py
deleted file mode 100644
index 8eb66b5..0000000
--- a/idatui/codemode_client.py
+++ /dev/null
@@ -1,1494 +0,0 @@
-"""Client adapter from ida-tui's domain operations to IDA Code Mode.
-
-``DatabaseHandle`` is the lifecycle boundary: it discovers an already-registered
-GUI database, reuses a shared managed idalib worker, or starts one when needed.
-The TUI never owns or terminates an IDA process. Closing this client releases
-only its lease.
-
-The Code Mode transport intentionally exposes one broad operation,
-``execute_python``. ``CodeModeClient.invoke`` turns the small, address-centric
-operations needed by the paging layer into self-contained snippets. The
-snippets prefer the public ``ida-domain`` ``db`` object. A handful of features
-that ida-domain does not currently expose (IDA-coloured listing rows, creating
-instructions, ARM T-state, and detailed Hex-Rays line maps/failures) use the
-IDAPython modules that Code Mode deliberately makes importable.
-"""
-from __future__ import annotations
-
-import hashlib
-import json
-import os
-import shlex
-import threading
-import time
-from pathlib import Path
-from textwrap import dedent
-from typing import Any
-
-from .errors import IDAConnectionError, IDATimeoutError, IDAToolError, Session
-
-# ida_codemode is imported EAGERLY-IF-PRESENT but never at hard import cost.
-#
-# The paging/graph/trace layers and their offline test suites must keep importing
-# `idatui` on a machine with no IDA and no Code Mode installed -- that is the
-# house rule the stdlib-only worker client used to satisfy for free, and
-# `tests/run.py --fast` (380 checks, any python3) depends on it. A hard top-level
-# import here makes the whole package unimportable, so the failure is deferred to
-# the first operation that genuinely needs the library.
-_CODEMODE_ERROR: Exception | None = None
-try:
- from ida_codemode import (
- CodeModeConnectionError,
- DatabaseBusyError,
- DatabaseDisconnectedError,
- DatabaseHandle,
- DatabaseInstance,
- DatabaseOpenOptions,
- RemoteError,
- find_database_owner,
- wait_database_released,
- )
-except ImportError as _exc: # library absent: usable only for offline layers
- _CODEMODE_ERROR = _exc
- # Bound to None rather than left undefined so the names stay patchable: the
- # offline contract tests inject a fake DatabaseHandle here.
- CodeModeConnectionError = DatabaseDisconnectedError = RemoteError = None # type: ignore[assignment,misc]
- DatabaseBusyError = DatabaseHandle = DatabaseInstance = None # type: ignore[assignment,misc]
- DatabaseOpenOptions = find_database_owner = wait_database_released = None # type: ignore[assignment]
-
-
-def _require_codemode() -> None:
- """Raise an actionable error when the Code Mode library is missing.
-
- Gated on the binding, not on the original import result, so a test that
- injects a fake ``DatabaseHandle`` exercises the real adapter logic.
- """
- if DatabaseHandle is None:
- raise IDAConnectionError(
- "ida-codemode is not installed in this environment "
- f"({_CODEMODE_ERROR}). Install it (e.g. `uv sync`, or "
- "`pip install ida-codemode`) so ida-tui can lease a "
- "database.") from _CODEMODE_ERROR
-
-
-def database_owner(idb_path: str, staged_path: str | None = None):
- """The Code Mode instance that owns ``idb_path``/``staged_path``, else None.
-
- Returns None when the Code Mode library is absent: with no library there is
- no client in this environment that could be holding the database, and the
- IDA-free layers (project staging) must keep working. Discovery errors with
- the library installed still propagate because unknown ownership is unsafe.
- """
- if DatabaseHandle is None:
- return None
- if staged_path:
- owner = find_database_owner(
- staged_path,
- output_database=idb_path,
- timeout=0.5,
- )
- return owner or find_database_owner(staged_path, timeout=0.5)
- return find_database_owner(idb_path, timeout=0.5)
-
-
-def registered_database(path: str, output_database: str | None = None) -> bool:
- """Whether a live/lock-held Code Mode instance owns this target."""
- _require_codemode()
- return (
- find_database_owner(
- path,
- output_database=output_database,
- timeout=0.5,
- )
- is not None
- )
-
-
-class _NoopKeepAlive:
- """Compatibility shim: the DatabaseHandle's SSE lease is the heartbeat."""
-
- def __init__(self) -> None:
- self.beats = self.failures = 0
-
- def start(self) -> "_NoopKeepAlive":
- return self
-
- def stop(self) -> None:
- pass
-
-
-def _parse_load_args(value: str) -> tuple[str | None, int | None, str | None]:
- """Translate ida-tui's legacy first-open switches to Code Mode options.
-
- Code Mode has typed options for processor, natural loading address and file
- type. It deliberately has no arbitrary command-line escape hatch; reject
- switches we cannot represent instead of silently loading a blob wrongly.
- """
- processor: str | None = None
- loading_address: int | None = None
- file_type: str | None = None
- unsupported: list[str] = []
- try:
- words = shlex.split(value or "", posix=os.name != "nt")
- except ValueError as exc:
- raise ValueError(f"invalid IDA load options: {exc}") from exc
- for word in words:
- if word.startswith("-p") and len(word) > 2:
- processor = word[2:]
- elif word.startswith("-b") and len(word) > 2:
- try:
- # IDA's -b is in 16-byte paragraphs. DatabaseHandle expects the
- # natural address, which is the safer public API.
- loading_address = int(word[2:], 16) << 4
- except ValueError as exc:
- raise ValueError(f"invalid IDA loading address: {word!r}") from exc
- elif word.startswith("-T") and len(word) > 2:
- file_type = word[2:]
- else:
- unsupported.append(word)
- if unsupported:
- joined = " ".join(unsupported)
- raise ValueError(
- "ida-codemode cannot represent arbitrary IDA load options: "
- f"{joined!r}; use processor/base/file type options instead"
- )
- return processor, loading_address, file_type
-
-
-#: Key of the pre-serialised payload envelope. See _script().
-_PACKED = "__idatui_json__"
-
-#: Serialise the answer INSIDE the database process and hand back one string.
-#:
-#: Written when Code Mode ran to_jsonable() over every snippet result, walking
-#: the whole structure in Python to make it JSON-safe: a 200-row listing page is
-#: ~10k small objects, which cost 66ms to walk -- 72% of the page's total cost,
-#: and 114x what json.dumps of the same data cost (0.58ms).
-#:
-#: ida-codemode 0.3.2 removed that reason: serialization.dumps_json now hands
-#: the structure straight to the C encoder and only falls back to the walker for
-#: values json.dumps rejects. Re-measured against 0.3.2, packing buys 0.97x on
-#: that same page (experiments/bench_pack_trace.py) -- i.e. nothing, because the
-#: dodged walk is replaced by a double encode.
-#:
-#: It is kept anyway, on correctness rather than speed: packing pins OUR encoder
-#: settings (compact separators, default=str) inside the database process, so an
-#: un-encodable IDA object degrades to repr() at a point we control instead of
-#: depending on the runtime's fallback. Delete it if that stops being worth a
-#: protocol step -- it is no longer load-bearing for performance.
-_PACK_EPILOGUE = (
- '\n{"' + _PACKED + '": json.dumps(result, separators=(",", ":"), default=str)}\n'
-)
-
-
-def _script(args: dict[str, Any], body: str) -> str:
- """Bind JSON arguments without interpolating user text into Python code.
-
- This used to also run the body with Code Mode's trace hook detached
- (sys.settrace(None) + restore), because the runtime wrapped every
- execute_python in a trace function that returned ITSELF -- enabling line
- tracing in every frame it saw, so every line of every function we called
- paid a Python-level callback (ida_bytes.get_flags: 0.106us -> 5.49us, 52x).
-
- ida-codemode 0.3.2 deleted that hook; cancellation is now a C-level thread
- interrupt (runtime._interrupt_thread) that costs nothing while idle. The
- workaround measured 0.99x on a 200-row listing page against 0.3.2 -- pure
- noise -- so it is gone, and with it the caveat that a pure-Python loop in a
- snippet escaped its deadline. See experiments/bench_pack_trace.py.
- """
- encoded = json.dumps(args, ensure_ascii=False, separators=(",", ":"))
- head = f"import json\na = json.loads({encoded!r})\n"
- return f"{head}{dedent(body).strip()}\n{_PACK_EPILOGUE}"
-
-
-_OPERATIONS: dict[str, str] = {
- "list_funcs": r'''
-import fnmatch
-queries = a.get("queries") or [{}]
-q = queries[0]
-offset, count = max(0, int(q.get("offset", 0))), max(1, int(q.get("count", 500)))
-pattern = str(q.get("filter") or "").lower()
-if pattern and not any(ch in pattern for ch in "*?["): pattern = "*" + pattern + "*"
-rows = []
-for fn in db.functions.get_all():
- name = db.functions.get_name(fn) or f"sub_{int(fn.start_ea):X}"
- if pattern and not fnmatch.fnmatchcase(name.lower(), pattern): continue
- rows.append({"addr": hex(int(fn.start_ea)), "name": name,
- "size": int(fn.end_ea) - int(fn.start_ea)})
-page = rows[offset:offset + count]
-result = {"result": [{"data": page, "next_offset": offset + len(page), "total": len(rows)}]}
-result
-''',
- "disasm": r'''
-ea = int(str(a["addr"]), 16)
-fn = db.functions.get_at(ea)
-if fn is None:
- result = {"instructions": [], "total_instructions": 0, "instruction_count": 0}
-else:
- instructions = list(db.functions.get_instructions(fn))
- limit = max(1, int(a.get("max_instructions", len(instructions) or 1)))
- rows = [{"addr": hex(int(insn.ea)), "instruction": db.instructions.get_disassembly(insn)}
- for insn in instructions[:limit]]
- result = {"instructions": rows, "total_instructions": len(instructions),
- "instruction_count": len(instructions)}
-result
-''',
- "file_regions": r'''
-import idaapi
-rows = []
-for seg in db.segments.get_all():
- try: file_off = int(idaapi.get_fileregion_offset(seg.start_ea))
- except Exception: file_off = -1
- if file_off < 0 or file_off >= (1 << 48): file_off = -1
- rows.append({"start": hex(int(seg.start_ea)), "end": hex(int(seg.end_ea)),
- "file_off": file_off, "name": db.segments.get_name(seg) or ""})
-result = {"regions": rows}
-result
-''',
- "read_raw": r'''
-import ida_bytes
-ea, size = int(str(a["addr"]), 16), max(0, int(a["size"]))
-raw = ida_bytes.get_bytes(ea, size) or b""
-raw = raw[:size] + b"\xff" * max(0, size - len(raw))
-data = bytearray(raw)
-for index, value in enumerate(data):
- if value == 0xFF and not ida_bytes.is_loaded(ea + index): data[index] = 0
-result = {"addr": a["addr"], "hex": bytes(data).hex(), "n": len(data)}
-result
-''',
- "get_bytes": r'''
-rows = []
-for region in a.get("regions", []):
- ea, size = int(str(region["addr"]), 16), int(region["size"])
- raw = db.bytes.get_bytes_at(ea, size) or b""
- rows.append({"addr": region["addr"], "data": " ".join(f"{b:02x}" for b in raw)})
-result = {"result": rows}
-result
-''',
- "search_structs": r'''
-needle = str(a.get("filter") or "").lower()
-rows = []
-for tif in db.types.get_all():
- name = tif.get_type_name() or ""
- if not name or needle not in name.lower() or not tif.is_udt(): continue
- members = list(db.types.get_udt_members(tif))
- rows.append({"name": name, "size": int(tif.get_size()), "is_union": bool(tif.is_union()),
- "cardinality": len(members), "ordinal": int(tif.get_ordinal())})
-result = {"result": rows}
-result
-''',
- "type_inspect": r'''
-rows = []
-for query in a.get("queries", []):
- name = str(query.get("name") or "")
- tif = db.types.get_by_name(name)
- if tif is None:
- rows.append({"name": name, "error": "type not found"}); continue
- members = [{"name": m.name, "type": m.type.dstr() or str(m.type),
- "offset": int(m.offset), "size": int(m.size)}
- for m in db.types.get_udt_members(tif)] if tif.is_udt() else []
- rows.append({"name": name, "size": int(tif.get_size()), "is_union": bool(tif.is_union()),
- "members": members})
-result = {"result": rows}
-result
-''',
- "declare_type": r'''
-import ida_typeinf
-decls = a.get("decls", "")
-if isinstance(decls, str): decls = [decls]
-rows = []
-for declaration in decls:
- try:
- errors = int(db.types.parse_declarations(ida_typeinf.get_idati(), declaration))
- rows.append({"ok": errors == 0, **({} if errors == 0 else {"error": f"{errors} parse error(s)"})})
- except Exception as exc:
- rows.append({"ok": False, "error": str(exc)})
-result = {"result": rows}
-result
-''',
- "del_type": r'''
-import ida_typeinf
-name = str(a["name"])
-ok = bool(ida_typeinf.del_named_type(ida_typeinf.get_idati(), name, ida_typeinf.NTF_TYPE))
-result = {"name": name, "deleted": ok, **({} if ok else {"error": f"Type {name!r} not found or could not be deleted"})}
-result
-''',
- "func_types": r'''
-import ida_typeinf
-ea = int(str(a["addr"]), 16)
-fn = db.functions.get_at(ea)
-if fn is None:
- result = {"addr": a["addr"], "error": "no function at address"}
-else:
- pseudo = db.pseudocode.decompile(fn)
- name = db.functions.get_name(fn) or ""
- tif = pseudo.get_func_type()
- try: prototype = ida_typeinf.print_tinfo("", 0, 0, ida_typeinf.PRTYPE_1LINE, tif, name, "") if tif else ""
- except Exception: prototype = tif.dstr() if tif else ""
- lvars = [{"name": var.name, "type": var.type_info.dstr() if var.type_info else "",
- "is_arg": bool(var.is_arg)} for var in pseudo.local_variables]
- result = {"addr": hex(int(fn.start_ea)), "name": name,
- "prototype": (prototype or "").strip(), "lvars": lvars}
-result
-''',
- "set_lvar_type": r'''
-import ida_typeinf
-ea, variable, declaration = int(str(a["addr"]), 16), str(a["variable"]), str(a["type"])
-fn = db.functions.get_at(ea)
-if fn is None:
- result = {"error": "no function at address"}
-else:
- pseudo = db.pseudocode.decompile(fn)
- var = pseudo.find_local_variable(variable)
- if var is None:
- result = {"error": f"local variable {variable!r} not found"}
- else:
- try:
- tif = db.types.parse_one_declaration(ida_typeinf.get_idati(), declaration)
- accepted = bool(var.set_type(tif))
- saved = bool(pseudo.save_local_variable_info(var, save_type=True)) if accepted else False
- result = {"addr": hex(int(fn.start_ea)), "variable": variable,
- "type": declaration, "ok": accepted and saved}
- except Exception as exc:
- result = {"error": f"bad type {declaration!r}: {exc}"}
-result
-''',
- "set_type": r'''
-from ida_domain.types import TypeApplyFlags
-rows = []
-for edit in a.get("edits", []):
- ea = int(str(edit["addr"]), 16)
- declaration = str(edit.get("signature") or edit.get("type") or "")
- try:
- ok = bool(db.types.apply_declaration_at(ea, declaration, TypeApplyFlags.DEFINITE))
- rows.append({"addr": hex(ea), "ok": ok, **({} if ok else {"error": "IDA rejected the type"})})
- except Exception as exc:
- rows.append({"addr": hex(ea), "ok": False, "error": str(exc)})
-result = {"result": rows}
-result
-''',
- "data_type": r'''
-ea = int(str(a["addr"]), 16)
-try:
- tif = db.types.get_at(ea)
- fn = db.functions.get_at(ea)
- result = {"addr": hex(ea), "name": db.names.get_at(ea) or "",
- "type": tif.dstr() if tif else "", "size": int(db.heads.size(ea)) if db.heads.is_head(ea) else 0,
- "is_func": bool(fn)}
-except Exception as exc:
- result = {"addr": hex(ea), "error": str(exc)}
-result
-''',
- "force_recompile": r'''
-import ida_hexrays
-rows = []
-for item in a.get("items", []):
- ea = int(str(item["addr"]), 16)
- ida_hexrays.mark_cfunc_dirty(ea, False)
- rows.append({"addr": hex(ea), "ok": True})
-result = {"result": rows}
-result
-''',
- "undefine": r'''
-import ida_bytes
-rows = []
-for item in a.get("items", []):
- ea = int(str(item["addr"]), 16)
- size = max(1, int(item.get("size") or ida_bytes.get_item_size(ea) or 1))
- ok = bool(ida_bytes.del_items(ea, ida_bytes.DELIT_SIMPLE, size))
- rows.append({"addr": hex(ea), "ok": ok, **({} if ok else {"error": "delete items failed"})})
-result = {"result": rows}
-result
-''',
- "define_code": r'''
-import ida_ua
-rows = []
-for item in a.get("items", []):
- ea = int(str(item["addr"]), 16); size = int(ida_ua.create_insn(ea))
- rows.append({"addr": hex(ea), "ok": size > 0, "size": size,
- **({} if size > 0 else {"error": "instruction did not decode"})})
-result = {"result": rows}
-result
-''',
- "define_func": r'''
-rows = []
-for item in a.get("items", []):
- ea = int(str(item["addr"]), 16); ok = bool(db.functions.create(ea))
- rows.append({"addr": hex(ea), "ok": ok, **({} if ok else {"error": "IDA refused the function"})})
-result = {"result": rows}
-result
-''',
- "make_data": r'''
-import ida_bytes, ida_idaapi, ida_typeinf
-from ida_domain.types import TypeApplyFlags
-rows = []
-for item in a.get("items", []):
- ea, declaration = int(str(item["addr"]), 16), str(item["type"])
- try:
- tif = db.types.parse_one_declaration(ida_typeinf.get_idati(), declaration)
- size = max(1, int(tif.get_size()))
- saved_names = [(addr, name) for addr, name in db.names.get_all()
- if ea <= int(addr) < ea + size]
- ida_bytes.del_items(ea, ida_bytes.DELIT_EXPAND | ida_bytes.DELIT_DELNAMES,
- max(size, int(ida_bytes.get_item_size(ea) or 1)))
- created = bool(ida_bytes.create_data(ea, ida_bytes.FF_BYTE, size, ida_idaapi.BADADDR))
- ok = created and bool(db.types.apply_at(tif, ea, TypeApplyFlags.DEFINITE))
- for address, name in saved_names:
- db.names.set_name(int(address), name)
- if ok and item.get("name"): ok = bool(db.names.set_name(ea, str(item["name"])))
- rows.append({"addr": hex(ea), "ok": ok, "size": size,
- **({} if ok else {"error": "IDA rejected the data type"})})
- except Exception as exc:
- rows.append({"addr": hex(ea), "ok": False, "error": str(exc)})
-result = {"result": rows}
-result
-''',
- "make_string": r'''
-from ida_domain.strings import StringType
-ea, length = int(str(a["addr"]), 16), max(0, int(a.get("length", 0)))
-kind = {"c": StringType.C, "c16": StringType.C_16, "c32": StringType.C_32,
- "pascal": StringType.PASCAL}.get(str(a.get("kind", "c")).lower(), StringType.C)
-import ida_bytes
-try:
- ida_bytes.del_items(ea, ida_bytes.DELIT_SIMPLE, length if length > 0 else 1)
-except Exception:
- pass
-try:
- ok = bool(db.bytes.create_string_at(ea, length or None, kind))
- text = db.bytes.get_string_at(ea) or "" if ok else ""
- result = {"addr": hex(ea), "ok": ok, "size": int(db.heads.size(ea)) if ok else 0, "text": text}
-except Exception as exc:
- result = {"addr": hex(ea), "ok": False, "error": str(exc)}
-result
-''',
- "list_strings": r'''
-from ida_domain.strings import StringListConfig
-offset, count, min_len = max(0, int(a.get("offset", 0))), max(1, int(a.get("count", 2000))), max(1, int(a.get("min_len", 4)))
-if offset == 0 or a.get("refresh"):
- from ida_domain.strings import StringType
- db.strings.rebuild(StringListConfig(string_types=list(StringType), min_len=min_len,
- only_ascii_7bit=False))
-items = list(db.strings.get_all())
-page = items[offset:offset + count]
-rows = []
-for item in page:
- try: text = str(item)
- except Exception: text = item.contents.decode("utf-8", "replace") if item.contents else ""
- rows.append({"addr": hex(int(item.address)), "text": text, "len": int(item.length), "type": item.type.name})
-result = {"strings": rows, "total": len(items), "next_offset": offset + len(rows)}
-result
-''',
- # Everything a person ADDED to the database: comments, non-dummy names, and
- # the prototypes they set.
- #
- # Names come from IDA's name list, which is already an index -- no scan at
- # all. Comments have no index, so they need a walk, and the walk is over
- # HEADS: `next_that`'s predicate is a *Python* callback (SWIG calls it with
- # one argument, so `f_has_cmt` does not even fit), which would be one call
- # per BYTE -- 400 million of them on a big image. `max_scan` bounds it and
- # reports `truncated` rather than sitting there.
- "list_annotations": r'''
-import ida_bytes, ida_funcs, ida_lines, ida_nalt, ida_name
-import ida_segment, ida_typeinf, idautils
-limit = max(1, int(a.get("limit", 4000)))
-max_scan = max(1000, int(a.get("max_scan", 2000000)))
-comments, names = [], []
-scanned = 0
-
-def _line(ea):
- try:
- txt = ida_lines.generate_disasm_line(ea, ida_lines.GENDSM_REMOVE_TAGS)
- except Exception:
- txt = ""
- return " ".join((txt or "").split())
-
-for ea, nm in idautils.Names():
- if len(names) >= limit:
- break
- if not nm or not ida_bytes.has_user_name(ida_bytes.get_flags(ea)):
- continue
- fn = ida_funcs.get_func(ea)
- is_fn = fn is not None and int(fn.start_ea) == int(ea)
- proto = None
- if is_fn:
- try:
- ti = ida_typeinf.tinfo_t()
- if ida_nalt.get_tinfo(ti, ea):
- proto = str(ti)
- except Exception:
- proto = None
- seg = ida_segment.getseg(ea)
- names.append({"addr": hex(int(ea)), "name": nm, "func": is_fn,
- "size": (int(fn.end_ea - fn.start_ea) if is_fn else 0),
- "proto": proto,
- "seg": (ida_segment.get_segm_name(seg) if seg else "")})
-
-for i in range(ida_segment.get_segm_qty()):
- seg = ida_segment.getnseg(i)
- if seg is None or len(comments) >= limit or scanned >= max_scan:
- continue
- for ea in idautils.Heads(seg.start_ea, seg.end_ea):
- scanned += 1
- if len(comments) >= limit or scanned >= max_scan:
- break
- if not ida_bytes.has_cmt(ida_bytes.get_flags(ea)):
- continue
- for rep in (False, True):
- text = ida_bytes.get_cmt(ea, rep)
- if text:
- fn = ida_funcs.get_func(ea)
- comments.append({
- "addr": hex(int(ea)), "text": text, "repeatable": rep,
- "line": _line(ea), "seg": ida_segment.get_segm_name(seg),
- "func": (ida_funcs.get_func_name(fn.start_ea) if fn else None),
- "func_addr": (hex(int(fn.start_ea)) if fn else None)})
-
-# Whole-function comments are not on the byte flags, so the scan cannot see them.
-for fn_ea in idautils.Functions():
- fn = ida_funcs.get_func(fn_ea)
- if fn is None or len(comments) >= limit:
- continue
- for rep in (False, True):
- text = ida_funcs.get_func_cmt(fn, rep)
- if text:
- seg = ida_segment.getseg(fn_ea)
- comments.append({"addr": hex(int(fn_ea)), "text": text,
- "repeatable": rep, "line": "", "whole_func": True,
- "seg": (ida_segment.get_segm_name(seg) if seg else ""),
- "func": ida_funcs.get_func_name(fn_ea),
- "func_addr": hex(int(fn_ea))})
-result = {"comments": comments, "names": names, "scanned": scanned,
- "truncated": (len(comments) >= limit or len(names) >= limit
- or scanned >= max_scan)}
-result
-''',
- # The findings journal (idatui/journal.py). A netnode blob rides along in
- # the .i64, so "what did I work out here" survives closing the database.
- "journal_get": r'''
-import ida_netnode
-n = ida_netnode.netnode(a.get("node", "$ idatui.journal"))
-blob = n.getblob(0, "I") if ida_netnode.exist(n) else None
-result = {"data": blob.decode("utf-8", "replace") if blob else ""}
-result
-''',
- "journal_put": r'''
-import ida_netnode
-n = ida_netnode.netnode(a.get("node", "$ idatui.journal"), 0, True)
-payload = (a.get("data") or "").encode("utf-8")
-n.setblob(payload, 0, "I")
-result = {"ok": True, "bytes": len(payload)}
-result
-''',
- # Database-wide search (Ctrl+F), two kinds.
- #
- # BYTES uses IDA's own `find_bytes`, which already understands the pattern
- # language people expect -- "B8 ? ? ? ? 90", nibble wildcards ("48 8? ??")
- # and quoted literals -- so we neither parse nor match anything ourselves.
- # Iterating is match+1, per its documented contract.
- "search_bytes": r'''
-import ida_bytes, ida_funcs, ida_idaapi, ida_lines, ida_segment
-pat = str(a.get("pattern", "")).strip()
-limit = max(1, int(a.get("limit", 500)))
-lo = int(a.get("start", 0))
-hi = int(a.get("end", 0)) or ida_idaapi.BADADDR
-flags = ida_bytes.BIN_SEARCH_FORWARD | ida_bytes.BIN_SEARCH_NOSHOW
-if a.get("case"):
- flags |= ida_bytes.BIN_SEARCH_CASE
-rows, err, ea = [], None, lo
-while len(rows) < limit:
- try:
- hit = ida_bytes.find_bytes(pat, range_start=ea, range_end=hi, flags=flags)
- except Exception as exc:
- err = str(exc) or exc.__class__.__name__
- break
- if hit is None or hit == ida_idaapi.BADADDR:
- break
- head = ida_bytes.get_item_head(hit)
- fn = ida_funcs.get_func(hit)
- seg = ida_segment.getseg(hit)
- try:
- line = ida_lines.generate_disasm_line(head, ida_lines.GENDSM_REMOVE_TAGS) or ""
- except Exception:
- line = ""
- rows.append({"addr": hex(int(hit)), "head": hex(int(head)),
- "line": " ".join(line.split()),
- "func": (ida_funcs.get_func_name(fn.start_ea) if fn else None),
- "func_addr": (hex(int(fn.start_ea)) if fn else None),
- "seg": (ida_segment.get_segm_name(seg) if seg else "")})
- ea = int(hit) + 1
-result = {"hits": rows, "error": err, "truncated": len(rows) >= limit}
-result
-''',
- # TEXT walks the listing the way a person reads it: every head's rendered
- # disassembly line, which is why it finds "call cs:__isoc99_scanf" and
- # "0deadbeefh" alike. Bounded by max_scan, so a 400MB image reports partial
- # results instead of stalling.
- "search_text": r'''
-import ida_lines, ida_funcs, ida_segment, idautils
-import re as _re
-q = str(a.get("query", ""))
-limit = max(1, int(a.get("limit", 500)))
-max_scan = max(1000, int(a.get("max_scan", 3000000)))
-ci = (not a.get("case")) and q.islower() # smartcase, like the in-view search
-rx, err = None, None
-if a.get("regex"):
- try:
- rx = _re.compile(q, _re.I if ci else 0)
- except Exception as exc:
- err = "bad regex: " + str(exc)
-needle = q.lower() if ci else q
-rows, scanned = [], 0
-if err is None and q:
- for i in range(ida_segment.get_segm_qty()):
- seg = ida_segment.getnseg(i)
- if seg is None or len(rows) >= limit or scanned >= max_scan:
- continue
- for ea in idautils.Heads(seg.start_ea, seg.end_ea):
- scanned += 1
- if len(rows) >= limit or scanned >= max_scan:
- break
- try:
- line = ida_lines.generate_disasm_line(ea, ida_lines.GENDSM_REMOVE_TAGS) or ""
- except Exception:
- continue
- # Match what the user SEES, not IDA's column padding: nobody types
- # "call" + four spaces + "cs:getenv_ptr".
- line = " ".join(line.split())
- hay = line.lower() if ci else line
- if (rx.search(line) if rx is not None else (needle in hay)):
- fn = ida_funcs.get_func(ea)
- rows.append({"addr": hex(int(ea)), "head": hex(int(ea)),
- "line": line,
- "func": (ida_funcs.get_func_name(fn.start_ea) if fn else None),
- "func_addr": (hex(int(fn.start_ea)) if fn else None),
- "seg": ida_segment.get_segm_name(seg)})
-result = {"hits": rows, "error": err, "scanned": scanned,
- "truncated": len(rows) >= limit or scanned >= max_scan}
-result
-''',
- "list_linkage": r'''
-imports = [{"addr": hex(int(item.address)), "name": item.name, "module": item.module_name}
- for item in db.imports.get_all_imports() if item.name]
-exports = [{"addr": hex(int(item.address)), "name": item.name, "ordinal": int(item.ordinal)}
- for item in db.entries.get_all() if item.name]
-result = {"imports": imports, "exports": exports,
- "n_imports": len(imports), "n_exports": len(exports)}
-result
-''',
- "lookup_funcs": r'''
-rows = []
-for query in a.get("queries", []):
- raw = str(query)
- try: ea = int(raw, 16)
- except ValueError:
- fn = db.functions.get_by_name(raw); ea = int(fn.start_ea) if fn else None
- else: fn = db.functions.get_at(ea)
- if fn is None:
- rows.append({"query": raw, "fn": None})
- else:
- rows.append({"query": raw, "fn": {"addr": hex(int(fn.start_ea)),
- "name": db.functions.get_name(fn) or f"sub_{int(fn.start_ea):X}",
- "size": int(fn.end_ea) - int(fn.start_ea)}})
-result = {"result": rows}
-result
-''',
- "resolve_names": r'''
-import ida_idaapi, ida_name
-rows = []
-for query in a.get("queries", []):
- name = str(query).strip(); ea = ida_name.get_name_ea(ida_idaapi.BADADDR, name)
- rows.append({"query": name, "ea": hex(int(ea)) if ea != ida_idaapi.BADADDR else None})
-result = {"result": rows}
-result
-''',
- # Ours: the coarse code/data type plus a fine `kind` (call/jump/flow,
- # read/write/offset/text/info) that the xref dialog draws its badges from.
- # Deliberately NOT sorted -- the dialog lists xrefs in IDA's own order.
- "xref_types": r'''
-import idaapi, idautils, ida_bytes, ida_funcs, ida_xref
-code_kind = {ida_xref.fl_CF: "call", ida_xref.fl_CN: "call", ida_xref.fl_JF: "jump",
- ida_xref.fl_JN: "jump", ida_xref.fl_F: "flow"}
-data_kind = {ida_xref.dr_O: "offset", ida_xref.dr_W: "write", ida_xref.dr_R: "read",
- ida_xref.dr_T: "text", ida_xref.dr_I: "info"}
-def _kind(xr):
- return (code_kind if xr.iscode else data_kind).get(xr.type, "code" if xr.iscode else "data")
-def _fn(ea):
- f = ida_funcs.get_func(ea)
- return {"addr": hex(int(f.start_ea)), "name": ida_funcs.get_func_name(f.start_ea) or ""} if f else None
-queries = a.get("queries") or []
-all_results = []
-for query in queries:
- query = query if isinstance(query, dict) else {"addr": query}
- raw = str(query.get("addr", "")).strip()
- direction = str(query.get("direction", "to") or "to").lower()
- include_fn = bool(query.get("include_fn", True))
- dedup = bool(query.get("dedup", True))
- try: count = int(query.get("count", 2000) or 2000)
- except (TypeError, ValueError): count = 2000
- try: target = int(raw, 16)
- except ValueError: target = idaapi.get_name_ea(idaapi.BADADDR, raw)
- rows = []
- if target is not None and target != idaapi.BADADDR and ida_bytes.is_mapped(target):
- if direction in ("to", "both"):
- for xr in idautils.XrefsTo(target, 0):
- row = {"direction": "to", "addr": hex(int(xr.frm)), "from": hex(int(xr.frm)),
- "to": hex(int(target)), "type": "code" if xr.iscode else "data", "kind": _kind(xr)}
- if include_fn: row["fn"] = _fn(xr.frm)
- rows.append(row)
- if direction in ("from", "both"):
- for xr in idautils.XrefsFrom(target, 0):
- row = {"direction": "from", "addr": hex(int(xr.to)), "from": hex(int(target)),
- "to": hex(int(xr.to)), "type": "code" if xr.iscode else "data", "kind": _kind(xr)}
- if include_fn: row["fn"] = _fn(xr.to)
- rows.append(row)
- if dedup:
- seen, deduped = set(), []
- for r in rows:
- k = (r["direction"], r["from"], r["to"], r["kind"])
- if k in seen: continue
- seen.add(k); deduped.append(r)
- rows = deduped
- rows = rows[:count]
- all_results.append({"query": raw, "data": rows, "next_offset": None})
-result = {"result": all_results}
-result
-''',
- # Mirrors the tool ida-tui was written against, ORDER INCLUDED. The rows are
- # sorted by the far-end address and deduped by default, and the pseudocode
- # follow's address fallback silently depends on it: at a call site the raw
- # IDA order yields the ordinary-flow xref (the next instruction) first, so an
- # unsorted result makes "follow the call" land on the following line instead.
- "xref_query": r'''
-import idaapi, idautils, ida_bytes, ida_funcs
-def _fn(ea):
- f = ida_funcs.get_func(ea)
- return {"addr": hex(int(f.start_ea)), "name": ida_funcs.get_func_name(f.start_ea) or ""} if f else None
-queries = a.get("queries") or []
-all_results = []
-for query in queries:
- raw = str(query.get("addr", "")).strip()
- direction = str(query.get("direction", "both") or "both").lower()
- if direction not in ("to", "from", "both"): direction = "both"
- xref_type = str(query.get("xref_type", "any") or "any").lower()
- if xref_type not in ("any", "code", "data"): xref_type = "any"
- include_fn = bool(query.get("include_fn", True))
- dedup = bool(query.get("dedup", True))
- sort_by = str(query.get("sort_by", "addr") or "addr")
- descending = bool(query.get("descending", False))
- try: offset = max(0, int(query.get("offset", 0) or 0))
- except (TypeError, ValueError): offset = 0
- try: count = max(0, min(int(query.get("count", 200) or 200), 5000))
- except (TypeError, ValueError): count = 200
- try:
- try: target = int(raw, 16)
- except ValueError:
- target = idaapi.get_name_ea(idaapi.BADADDR, raw)
- if target == idaapi.BADADDR: raise ValueError(f"Failed to resolve address/name: {raw}")
- if not ida_bytes.is_mapped(target): raise ValueError(f"Address not mapped: {raw}")
- rows = []
- if direction in ("to", "both"):
- for xr in idautils.XrefsTo(target, 0):
- kind = "code" if xr.iscode else "data"
- if xref_type != "any" and kind != xref_type: continue
- row = {"direction": "to", "addr": hex(int(xr.frm)), "from": hex(int(xr.frm)),
- "to": hex(int(target)), "type": kind}
- if include_fn: row["fn"] = _fn(xr.frm)
- rows.append(row)
- if direction in ("from", "both"):
- for xr in idautils.XrefsFrom(target, 0):
- kind = "code" if xr.iscode else "data"
- if xref_type != "any" and kind != xref_type: continue
- row = {"direction": "from", "addr": hex(int(xr.to)), "from": hex(int(target)),
- "to": hex(int(xr.to)), "type": kind}
- if include_fn: row["fn"] = _fn(xr.to)
- rows.append(row)
- if dedup:
- seen, deduped = set(), []
- for row in rows:
- key = (row["direction"], row["from"], row["to"], row["type"])
- if key in seen: continue
- seen.add(key); deduped.append(row)
- rows = deduped
- if sort_by == "type":
- rows.sort(key=lambda r: (str(r.get("type", "")), int(str(r["addr"]), 16)), reverse=descending)
- else:
- rows.sort(key=lambda r: int(str(r["addr"]), 16), reverse=descending)
- page = rows[offset:offset + count] if count else rows[offset:]
- nxt = offset + len(page)
- all_results.append({"target": raw, "resolved_addr": hex(int(target)), "direction": direction,
- "xref_type": xref_type, "data": page,
- "next_offset": nxt if nxt < len(rows) else None,
- "total": len(rows), "error": None})
- except Exception as exc:
- all_results.append({"target": raw, "resolved_addr": None, "direction": direction,
- "xref_type": xref_type, "data": [], "next_offset": None,
- "total": 0, "error": str(exc)})
-result = {"result": all_results}
-result
-''',
- # A comment must land in BOTH views, and the pseudocode half is not a
- # simple set: db.comments.set_at() alone leaves the pseudocode unchanged.
- # Hex-Rays comments are anchored to a ctree location (treeloc_t), and an
- # anchor the ctree does not actually own is dropped as an "orphan" -- so the
- # itp slot has to be searched until one sticks, exactly as IDA's own UI does.
- # Without it a comment silently never appears in the decompilation.
- "set_comments": r'''
-import idaapi, idc, ida_hexrays
-rows = []
-for item in a.get("items", []):
- addr_s = str(item.get("addr", ""))
- text = str(item.get("comment") or "")
- try:
- ea = int(addr_s, 16)
- if not idaapi.set_cmt(ea, text, False):
- rows.append({"addr": addr_s,
- "error": f"Failed to set disassembly comment at {hex(ea)}"})
- continue
- if not ida_hexrays.init_hexrays_plugin():
- rows.append({"addr": addr_s}); continue
- try:
- cfunc = ida_hexrays.decompile(ea)
- except Exception:
- cfunc = None
- if cfunc is None:
- rows.append({"addr": addr_s}); continue
- if ea == cfunc.entry_ea:
- # The signature line carries no ctree item: it is a function comment.
- idc.set_func_cmt(ea, text, True)
- cfunc.refresh_func_ctext()
- rows.append({"addr": addr_s}); continue
- eamap = cfunc.get_eamap()
- if ea not in eamap:
- rows.append({"addr": addr_s,
- "error": f"Failed to set decompiler comment at {hex(ea)}"})
- continue
- nearest_ea = eamap[ea][0].ea
- if cfunc.has_orphan_cmts():
- cfunc.del_orphan_cmts(); cfunc.save_user_cmts()
- tl = idaapi.treeloc_t(); tl.ea = nearest_ea
- placed = False
- for itp in range(idaapi.ITP_SEMI, idaapi.ITP_COLON):
- tl.itp = itp
- cfunc.set_user_cmt(tl, text)
- cfunc.save_user_cmts()
- cfunc.refresh_func_ctext()
- if not cfunc.has_orphan_cmts():
- placed = True; break
- cfunc.del_orphan_cmts(); cfunc.save_user_cmts()
- rows.append({"addr": addr_s} if placed else
- {"addr": addr_s,
- "error": f"Failed to set decompiler comment at {hex(ea)}"})
- except Exception as exc:
- rows.append({"addr": addr_s, "error": str(exc)})
-result = {"result": rows}
-result
-''',
- # Every category takes EITHER one edit or a LIST of them, and the answer is
- # one row per edit. The port accepted only a single dict, so any batch path
- # (rpc rename_many applying a whole symbol file, which is the entire point of
- # that verb) died with "list indices must be integers or slices, not str" and
- # reported the failure against addr=null. Mirrors the real tool: conflict
- # detection before the write, dry_run/allow_overwrite/stop_on_error, per-row
- # addr/old/name, and a summary counting EDITS rather than categories.
- "rename": r'''
-import idaapi, ida_hexrays, ida_name
-batch = a.get("batch") or {}
-dry_run = bool(batch.get("dry_run", False))
-allow_overwrite = bool(batch.get("allow_overwrite", False))
-stop_on_error = bool(batch.get("stop_on_error", False))
-
-def _items(value):
- if value is None: return []
- if isinstance(value, dict): return [value]
- if isinstance(value, list): return [i for i in value if isinstance(i, dict)]
- return []
-
-def _set_name_checked(ea, new):
- conflict = idaapi.get_name_ea(idaapi.BADADDR, new)
- if conflict != idaapi.BADADDR and conflict != ea and not allow_overwrite:
- return False, f"can't rename at {hex(ea)} as {new!r}: name already used at {hex(conflict)}"
- if dry_run:
- return True, None
- flags = idaapi.SN_CHECK
- if allow_overwrite: flags |= int(getattr(idaapi, "SN_FORCE", 0))
- if not idaapi.set_name(ea, new, flags):
- return False, (f"Rename failed at {hex(ea)}: IDA rejected name {new!r} "
- "(invalid identifier or internal conflict)")
- return True, None
-
-def _refresh_ctext(fn_addr):
- # A renamed function must invalidate Hex-Rays' cache, which is per function
- # and persisted in the .i64: without this the pseudocode keeps calling the
- # old name forever while every other readback reports the new one.
- if not ida_hexrays.init_hexrays_plugin(): return
- failure = ida_hexrays.hexrays_failure_t()
- cfunc = ida_hexrays.decompile_func(fn_addr, failure, ida_hexrays.DECOMP_WARNINGS)
- if cfunc: cfunc.refresh_func_ctext()
-
-out = {}; ok_count = failed = 0; halted = False
-for category in ("func", "data", "local", "stack"):
- if category not in batch: continue
- rows = []
- for edit in _items(batch.get(category)):
- try:
- if category == "func":
- addr_text = edit.get("addr") or edit.get("func_addr") or edit.get("func")
- new = edit.get("name") or edit.get("new") or edit.get("new_name")
- if not addr_text or not new:
- row = {"addr": addr_text, "name": new,
- "error": "Function rename requires addr + name"}
- else:
- ea = int(str(addr_text), 16)
- fn = idaapi.get_func(ea)
- if fn is None:
- row = {"addr": addr_text, "name": new, "error": "Function not found"}
- else:
- old = idaapi.get_name(fn.start_ea) or None
- ok, err = _set_name_checked(fn.start_ea, str(new))
- row = {"addr": addr_text, "old": old, "name": str(new)}
- if err: row["error"] = err
- if dry_run: row["dry_run"] = True
- if ok and not dry_run: _refresh_ctext(fn.start_ea)
- elif category == "data":
- addr_text = edit.get("addr")
- old = edit.get("old") or edit.get("old_name")
- new = edit.get("new") or edit.get("new_name") or edit.get("name")
- if not new and new != "":
- row = {"old": old, "new": None,
- "error": "Global rename requires target and new name"}
- else:
- if addr_text is not None:
- ea = int(str(addr_text), 16)
- old = old or (idaapi.get_name(ea) or None)
- else:
- ea = idaapi.get_name_ea(idaapi.BADADDR, str(old or ""))
- if ea == idaapi.BADADDR:
- row = {"old": old, "new": str(new), "error": f"Global {old!r} not found"}
- else:
- # An empty new name CLEARS the label; that is a real
- # request (tests revert with it), not a missing argument.
- if str(new) == "":
- ok = bool(ida_name.set_name(ea, "", idaapi.SN_CHECK))
- err = None if ok else f"Failed to clear the name at {hex(ea)}"
- else:
- ok, err = _set_name_checked(ea, str(new))
- row = {"addr": hex(ea), "old": old, "new": str(new)}
- if err: row["error"] = err
- if dry_run: row["dry_run"] = True
- else:
- fa, old, new = edit.get("func_addr"), edit.get("old"), edit.get("new")
- if not fa or not old or not new:
- row = {"old": old, "new": new,
- "error": f"{category} rename requires func_addr + old + new"}
- else:
- ea = int(str(fa), 16)
- pseudo = db.pseudocode.decompile(ea)
- var = pseudo.find_local_variable(str(old))
- if var is None:
- row = {"func_addr": fa, "old": old, "new": new,
- "error": f"no local {old!r} in that function"}
- elif dry_run:
- row = {"func_addr": fa, "old": old, "new": new, "dry_run": True}
- else:
- var.set_user_name(str(new))
- ok = bool(pseudo.save_local_variable_info(var, save_name=True))
- row = {"func_addr": fa, "old": old, "new": new}
- if not ok: row["error"] = "IDA rejected the local variable name"
- except Exception as exc:
- row = {"addr": edit.get("addr"), "error": str(exc)}
- rows.append(row)
- if row.get("error"): failed += 1
- else: ok_count += 1
- if row.get("error") and stop_on_error:
- halted = True; break
- out[category] = rows
- if halted: break
-out["summary"] = {"ok": ok_count, "failed": failed}
-if dry_run: out["summary"]["dry_run"] = True
-if halted: out["summary"]["halted"] = True
-result = out
-result
-''',
-}
-
-
-_OPERATIONS["define_code_run"] = r'''
-import ida_bytes, ida_idp, ida_segment, ida_ua, idaapi
-ea, limit = int(str(a["addr"]), 16), max(1, min(int(a.get("limit", 20000)), 200000))
-seg = ida_segment.getseg(ea)
-if seg is None:
- result = {"addr": a["addr"], "error": "no segment", "count": 0}
-else:
- start, count, stopped, hi = ea, 0, "limit", int(seg.end_ea)
- while count < limit:
- if ea >= hi: stopped = "segment"; break
- flags = ida_bytes.get_flags(ea)
- if ida_bytes.is_code(flags) or ida_bytes.is_data(flags): stopped = "defined"; break
- size = int(ida_ua.create_insn(ea))
- if size <= 0: stopped = "undecodable"; break
- count += 1
- insn = ida_ua.insn_t()
- if ida_ua.decode_insn(insn, ea) > 0:
- try: is_ret = bool(ida_idp.is_ret_insn(insn))
- except Exception: is_ret = False
- if is_ret or (insn.get_canon_feature() & idaapi.CF_STOP):
- ea += size; stopped = "flow"; break
- ea += size
- result = {"start": hex(start), "end": hex(ea), "count": count, "stopped": stopped}
-result
-'''
-
-
-_OPERATIONS["define_func_run"] = r'''
-import ida_bytes, ida_funcs, ida_segment
-ea = int(str(a["addr"]), 16)
-fn = db.functions.get_at(ea)
-if fn is not None and int(fn.start_ea) == ea:
- result = {"addr": hex(ea), "ok": True, "start": hex(ea), "end": hex(int(fn.end_ea)), "how": "existed"}
-else:
- automatic = bool(db.functions.create(ea))
- if not automatic:
- seg = db.segments.get_at(ea); end = ea; hi = int(seg.end_ea) if seg else ea
- while end < hi and ida_bytes.is_code(ida_bytes.get_flags(end)):
- nxt = int(ida_bytes.get_item_end(end))
- if nxt <= end: break
- end = nxt
- ok = bool(end > ea and ida_funcs.add_func(ea, end))
- else: ok = True
- fn = db.functions.get_at(ea)
- result = ({"addr": hex(ea), "ok": True, "start": hex(int(fn.start_ea)),
- "end": hex(int(fn.end_ea)), "how": "auto" if automatic else "explicit-end"}
- if ok and fn is not None else
- {"addr": hex(ea), "ok": False, "error": f"IDA refused a function at {ea:#x}"})
-result
-'''
-
-
-_OPERATIONS["set_thumb"] = r'''
-import ida_bytes, ida_ida, ida_idp, ida_segment, ida_segregs
-ea = int(str(a["addr"]), 16); treg = ida_idp.str2reg("T")
-seg = ida_segment.getseg(ea)
-if treg is None or treg < 0:
- result = {"addr": hex(ea), "error": "no T register (not an ARM database)"}
-elif seg is None:
- result = {"addr": hex(ea), "error": "no segment"}
-else:
- current = ida_segregs.get_sreg(ea, treg)
- current = 0 if current in (None, 0xFFFFFFFF, -1) else int(current)
- want = {"on": 1, "off": 0}.get(str(a.get("mode", "toggle")).lower(), 0 if current else 1)
- changed = False
- if want and seg.bitness != 1:
- ida_segment.set_segm_addressing(seg, 1); changed = True
- size = max(int(ida_bytes.get_item_size(ea)), 2)
- ida_bytes.del_items(ea, 0, size)
- ok = bool(ida_segregs.split_sreg_range(ea, treg, want, ida_segregs.SR_user))
- now = ida_segregs.get_sreg(ea, treg)
- result = {"addr": hex(ea), "thumb": bool(now), "was": bool(current), "ok": ok,
- "bitness": ida_segment.getseg(ea).bitness, "forced_32bit": changed,
- "db_64bit": bool(ida_ida.inf_get_app_bitness() == 64 and want)}
-result
-'''
-
-
-_OPERATIONS["thumb_scan"] = r'''
-import ida_bytes, ida_funcs, ida_idp, ida_segment, ida_segregs, ida_ua
-lo, hi = int(str(a["start"]), 16), int(str(a["end"]), 16)
-apply, limit = bool(a.get("apply", True)), int(a.get("limit", 512))
-treg = ida_idp.str2reg("T"); found = []; applied = 0; cursor = lo
-while cursor + 4 <= hi and len(found) < limit:
- at = cursor; value = int(ida_bytes.get_dword(cursor)); cursor += 4
- if not value & 1: continue
- target = value & ~1; seg = ida_segment.getseg(target)
- if seg is None or not (seg.perm & ida_segment.SEGPERM_EXEC or seg.perm == 0): continue
- flags = ida_bytes.get_flags(target)
- if ida_bytes.is_data(flags): continue
- item = {"at": hex(at), "value": hex(value), "target": hex(target),
- "was_code": bool(ida_bytes.is_code(flags))}; found.append(item)
- if not apply: continue
- if treg is not None and treg >= 0: ida_segregs.split_sreg_range(target, treg, 1, ida_segregs.SR_user)
- if not ida_bytes.is_code(ida_bytes.get_flags(target)):
- ida_bytes.del_items(target, 0, 2)
- if ida_ua.create_insn(target) <= 0: item["decoded"] = False; continue
- item["decoded"] = True; item["function"] = bool(db.functions.get_at(target) or db.functions.create(target)); applied += 1
-result = {"start": hex(lo), "end": hex(hi), "found": found, "applied": applied, "n": len(found)}
-result
-'''
-
-
-_OPERATIONS["decomp_error"] = r'''
-import ida_hexrays, ida_ida
-ea = int(str(a["addr"]), 16); fn = db.functions.get_at(ea)
-result = {"addr": hex(ea), "bitness": ida_ida.inf_get_app_bitness()}
-if fn is None:
- result["reason"] = "no function here"
-else:
- try:
- failure = ida_hexrays.hexrays_failure_t(); cfunc = ida_hexrays.decompile_func(fn, failure)
- if cfunc is not None: result["reason"] = ""
- else:
- result.update({"reason": failure.desc() or f"error {failure.code}",
- "code": int(failure.code), "errea": hex(int(failure.errea))})
- except Exception as exc: result["reason"] = f"{type(exc).__name__}: {exc}"
-result
-'''
-
-# `heads` and the operand-format tools are the port's IDAPython island: the
-# continuous listing's presentation model (undefined runs, colour spans, operand
-# extents, banners, struct members, the digest protocol) and IDA/Hex-Rays number
-# formats have no ida-domain surface. Rather than paraphrase ~1100 lines of
-# performance-tuned, behaviour-sensitive code into string literals, they stay
-# real, diffable source in idatui/remote_tools.py and are shipped to the database
-# process as text. Read once at import; the file ships beside this module.
-_REMOTE_LIB = (Path(__file__).with_name("remote_tools.py")).read_text(encoding="utf-8")
-
-#: Versioned by content, so editing remote_tools.py re-installs it instead of
-#: silently running the copy a long-lived worker already has.
-_REMOTE_MODULE = "_idatui_remote_" + hashlib.sha1(
- _REMOTE_LIB.encode("utf-8")).hexdigest()[:12]
-
-#: Sent back when the database process has not got the library yet; the client
-#: installs it and retries once. Amortised, a worker receives it exactly once.
-_NEED_LIB = "__idatui_needs_remote_lib__"
-
-#: Installs the library as a real module in the database process. Persisting it
-#: in sys.modules is what makes the module-level caches (the tag maps, and the
-#: line-render lru_cache the listing's throughput depends on) survive between
-#: calls -- execute_python builds a fresh namespace every time, so a library
-#: exec'd inline is rebuilt, and its caches thrown away, on every single call.
-_INSTALL_LIB = f'''
-import sys, types
-_m = types.ModuleType({_REMOTE_MODULE!r})
-exec(compile(a["source"], {_REMOTE_MODULE!r}, "exec"), _m.__dict__)
-sys.modules[{_REMOTE_MODULE!r}] = _m
-result = True
-result
-'''
-
-
-def _remote_op(call: str) -> str:
- """A snippet that calls one of the carried-over tools by its real signature.
-
- Costs one short request: the library is imported from the database process's
- own sys.modules, not shipped again.
- """
- return (f"import sys\n"
- f"_m = sys.modules.get({_REMOTE_MODULE!r})\n"
- f"result = {{{_NEED_LIB!r}: True}} if _m is None else _m.{call}\n"
- f"result\n")
-
-
-_OPERATIONS["op_format"] = _remote_op(
- 'op_format(addr=a["addr"], mode=a.get("mode", "cycle"),'
- ' col=int(a.get("col", -1)), n=int(a.get("n", -1)))')
-_OPERATIONS["pc_nums"] = _remote_op('pc_nums(addr=a["addr"])')
-_OPERATIONS["decompile"] = _remote_op(
- 'decompile(addr=a["addr"],'
- ' include_addresses=bool(a.get("include_addresses", True)))')
-_OPERATIONS["decomp_map"] = _remote_op('decomp_map(addr=a["addr"])')
-_OPERATIONS["pc_num_format"] = _remote_op(
- 'pc_num_format(addr=a["addr"], mode=a.get("mode", "cycle"),'
- ' line=int(a.get("line", -1)), col=int(a.get("col", -1)),'
- ' ea=a.get("ea", ""), opnum=int(a.get("opnum", -1)))')
-
-# The listing walker itself. Replaces the port's re-implementation, which
-# rendered no per-operand extents (so no keypress could say which literal it
-# would reformat) and had no digest/expect support (so every page was re-sent
-# after any edit), and whose span walk was the per-character loop our own
-# version had already been rewritten to avoid.
-#: Row count + seek anchors for a whole segment, in ONE call. See
-#: remote_tools.segment_index: the alternative is fetching every row.
-_OPERATIONS["segment_index"] = _remote_op(
- 'segment_index(addr=a["addr"], end=a.get("end", ""),'
- ' page_rows=int(a.get("page_rows", 500)), detail=bool(a.get("detail", False)))')
-
-_HEADS = _remote_op(
- 'heads(addr=a["addr"], count=int(a.get("count", 200)),'
- ' offset=int(a.get("offset", 0)), end=a.get("end", ""),'
- ' back=bool(a.get("back", False)), annotate=bool(a.get("annotate", False)),'
- ' expect=a.get("expect", ""), text=bool(a.get("text", True)))')
-
-
-# The graph view's only backend call. Blocks are address RANGES, never text:
-# the client re-renders them with `heads`, so boxes reuse the exact listing rows
-# (colours, operand marks, trail painting) instead of growing a second renderer.
-#
-# ida-domain exposes no basic-block/edge-kind surface, so this stays on ida_gdl.
-_OPERATIONS["flowchart"] = r'''
-import ida_funcs, ida_gdl
-ea = int(str(a["addr"]), 16)
-fn = ida_funcs.get_func(ea)
-if fn is None:
- result = {"addr": hex(ea), "error": "no function at that address", "blocks": []}
-else:
- fc = ida_gdl.FlowChart(fn, flags=ida_gdl.FC_PREDS)
- index, order = {}, []
- for bb in fc:
- index[bb.start_ea] = len(order)
- order.append(bb)
- blocks = []
- for bb in order:
- sl = [s for s in bb.succs() if s.start_ea in index]
- succs = []
- for s in sl:
- # Edge kind is what the graph view colours by: an n-way dispatch is
- # "switch", a successor that is literally the next address falls
- # through, anything else is a taken branch.
- if len(sl) > 2: kind = "switch"
- elif s.start_ea == bb.end_ea: kind = "fall"
- else: kind = "jump"
- succs.append([index[s.start_ea], kind])
- blocks.append({"id": index[bb.start_ea], "start": hex(int(bb.start_ea)),
- "end": hex(int(bb.end_ea)), "succs": succs})
- result = {"addr": hex(ea),
- "func": {"addr": hex(int(fn.start_ea)), "end": hex(int(fn.end_ea)),
- "name": ida_funcs.get_func_name(fn.start_ea) or ""},
- "entry": index.get(fn.start_ea, 0), "blocks": blocks}
-result
-'''
-
-# Only ever reached as domain.py's fallback when file_regions yields nothing.
-_OPERATIONS["survey_binary"] = r'''
-segments = []
-for seg in db.segments.get_all():
- segments.append({"start": hex(int(seg.start_ea)), "end": hex(int(seg.end_ea)),
- "name": db.segments.get_name(seg) or ""})
-result = {"segments": segments}
-result
-'''
-
-
-class CodeModeClient:
- """A leased GUI/idalib database accessed through ``ida_codemode``."""
-
- def __init__(
- self,
- binary_path: str,
- *,
- ttl: int = 0,
- load_args: str = "",
- processor: str | None = None,
- loading_address: int | None = None,
- file_type: str | None = None,
- output_database: str | None = None,
- spawn: bool = True,
- new_database: bool = False,
- ) -> None:
- del ttl # managed-worker lifetime is lease-based, not idle-TTL based
- self._path = os.path.abspath(os.path.expanduser(binary_path))
- parsed_processor, parsed_address, parsed_file_type = _parse_load_args(load_args)
- self._processor = processor or parsed_processor
- self._loading_address = loading_address if loading_address is not None else parsed_address
- self._file_type = file_type or parsed_file_type
- self._output_database = output_database
- self._spawn = spawn
- self._new_database = new_database
- self._handle: DatabaseHandle | None = None
- self._last_instance: DatabaseInstance | None = None
- self._connect_lock = threading.Lock()
-
- def connect(self, timeout: float = 1800.0, progress=None) -> "CodeModeClient":
- _require_codemode()
- with self._connect_lock:
- if self._handle is not None and self._handle.connected:
- return self
- if progress:
- progress(f"discovering Code Mode database for {os.path.basename(self._path)}…")
- try:
- # A Ctrl+L reload releases its current managed-worker lease, but
- # that worker remains registered during Code Mode's final-lease
- # grace period. Retry only that known handoff window. A GUI or
- # another long-lived client remains busy and yields a clear
- # failure rather than being modified underneath its owner.
- deadline = time.monotonic() + min(timeout, 60.0)
- while True:
- try:
- handle = DatabaseHandle.open(
- self._path,
- options=DatabaseOpenOptions(
- spawn=self._spawn,
- startup_timeout=max(0.1, timeout),
- output_database=self._output_database,
- processor=self._processor,
- # The natural byte address is converted to IDA's
- # paragraph-based -b value by Code Mode.
- image_base=self._loading_address,
- file_type=self._file_type,
- new_database=self._new_database,
- ),
- )
- break
- except DatabaseBusyError:
- if not self._new_database or time.monotonic() >= deadline:
- raise
- if progress:
- progress("waiting for the previous Code Mode lease to close…")
- owner = find_database_owner(
- self._path,
- output_database=self._output_database,
- timeout=0.5,
- )
- if owner is not None:
- wait_database_released(
- owner,
- max(0.0, deadline - time.monotonic()),
- )
- else:
- time.sleep(0.2)
- if progress:
- backend = handle.instance.backend
- progress(f"attached to {backend} database; waiting for auto-analysis…")
- handle.wait_autoanalysis(timeout=timeout)
- except Exception as exc: # normalize the dependency's transport errors
- raise self._connection_error(exc) from exc
- self._handle = handle
- self._last_instance = handle.instance
- return self
-
- @staticmethod
- def _connection_error(exc: BaseException) -> IDAConnectionError:
- return IDAConnectionError(str(exc) or type(exc).__name__)
-
- @property
- def connected(self) -> bool:
- return self._handle is not None and self._handle.connected
-
- @property
- def pid(self) -> int | None:
- return self._handle.instance.pid if self._handle is not None else None
-
- @property
- def backend(self) -> str | None:
- return self._handle.instance.backend if self._handle is not None else None
-
- def execute_python(self, code: str, *, timeout: float | None = None) -> Any:
- if not self.connected:
- self.connect()
- handle = self._handle
- if handle is None:
- raise IDAConnectionError("Code Mode database is not connected")
- try:
- response = handle.execute_python(code, timeout=timeout)
- except RemoteError as exc:
- details = exc.details or {}
- message = str(exc)
- if details.get("traceback"):
- message += f"\n{details['traceback']}"
- if exc.code == "operation_timeout":
- raise IDATimeoutError(message) from exc
- raise IDAToolError("execute_python", message) from exc
- except (DatabaseDisconnectedError, CodeModeConnectionError) as exc:
- raise self._connection_error(exc) from exc
- if not isinstance(response, dict) or "result" not in response:
- raise IDAToolError("execute_python", "Code Mode returned an invalid execution result")
- return response["result"]
-
- @staticmethod
- def _unpack(answer: Any) -> Any:
- """Undo _PACK_EPILOGUE. Anything else passes through untouched."""
- if isinstance(answer, dict) and _PACKED in answer:
- return json.loads(answer[_PACKED])
- return answer
-
- def invoke(self, operation: str, *, timeout: float | None = None, **args) -> Any:
- """Execute one TUI domain operation through Code Mode."""
- if operation in ("idb_save", "save"):
- return self.save_database()
- if operation in ("server_health", "ping", "health", "state"):
- return self.health()
- body = _HEADS if operation == "heads" else _OPERATIONS.get(operation)
- if body is None:
- raise IDAToolError(operation, f"unknown ida-tui Code Mode operation: {operation}")
- try:
- answer = self._unpack(self.execute_python(_script(args, body), timeout=timeout))
- if isinstance(answer, dict) and answer.get(_NEED_LIB):
- # First call against this database process (or a restarted one).
- self.execute_python(_script({"source": _REMOTE_LIB}, _INSTALL_LIB),
- timeout=timeout)
- answer = self._unpack(
- self.execute_python(_script(args, body), timeout=timeout))
- return answer
- except IDAToolError as exc:
- if exc.tool == "execute_python":
- raise IDAToolError(operation, exc.message) from exc
- raise
-
- # Temporary source compatibility for external drivers/tests that used the
- # old WorkerClient. Application code uses the accurately named invoke().
- call = invoke
-
- def save_database(self) -> dict[str, Any]:
- if not self.connected:
- self.connect()
- handle = self._handle
- if handle is None:
- raise IDAConnectionError("Code Mode database is not connected")
- try:
- return handle.save_database()
- except RemoteError as exc:
- raise IDAToolError("save_database", str(exc)) from exc
- except (DatabaseDisconnectedError, CodeModeConnectionError) as exc:
- raise self._connection_error(exc) from exc
-
- def health(self) -> dict[str, Any]:
- if not self.connected:
- self.connect()
- assert self._handle is not None
- entry = self._handle.instance
- module = os.path.basename(entry.exe_path or entry.idb_path or self._path)
- return {
- "ok": self._handle.connected,
- "module": module,
- "backend": entry.backend,
- "record_id": entry.record_id,
- "input_path": entry.exe_path,
- "idb_path": entry.idb_path,
- }
-
- def keepalive(self, interval: float = 120.0) -> _NoopKeepAlive:
- del interval
- return _NoopKeepAlive()
-
- def resolve_db(self) -> str:
- if not self.connected:
- self.connect()
- assert self._handle is not None
- return self._handle.instance.record_id
-
- def set_db(self, db: str | None) -> None:
- del db # one handle is permanently bound to one registered database
-
- def list_sessions(self) -> list[Session]:
- if not self.connected:
- self.connect()
- assert self._handle is not None
- entry = self._handle.instance
- path = entry.exe_path or entry.idb_path or self._path
- return [Session(session_id=entry.record_id, filename=os.path.basename(path),
- input_path=path, is_active=True)]
-
- def close(self, grace: float = 0.0) -> None:
- del grace
- with self._connect_lock:
- handle, self._handle = self._handle, None
- if handle is not None:
- self._last_instance = handle.instance
- handle.close() # release our lease; never close a GUI/other client's DB
-
- def wait_released(self, timeout: float = 45.0) -> bool:
- """Wait until a managed instance releases its lifetime lock.
-
- Normal application shutdown must not wait: another client may retain the
- worker. This is an explicit test/maintenance helper for deleting a
- temporary IDB safely after this client closes. GUI instances return
- ``False`` immediately because clients never own their lifetime.
- """
- instance = self._last_instance
- if instance is None or instance.backend != "idalib":
- return False
- return wait_database_released(instance, timeout)
-
- def __enter__(self) -> "CodeModeClient":
- return self.connect()
-
- def __exit__(self, *exc) -> None:
- self.close()
diff --git a/idatui/domain.py b/idatui/domain.py
index 9690a65..1e5a863 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -1,4 +1,4 @@
-"""Domain / paging layer: address-centric models over IDA Code Mode.
+"""Domain / paging layer: address-centric models over IDA Nexus.
This is where the "millions of lines" problem is solved, so the TUI widgets only
ever see a viewport-sized slice. Every hard-won constraint from
@@ -7,7 +7,7 @@ ever see a viewport-sized slice. Every hard-won constraint from
* Page sizes remain bounded so remote execution returns viewport-scale JSON.
* Pagination advances by the number of rows actually returned.
* Deep head walks are block-cached (revisits are free) and neighboring blocks
- prefetch through the thread-safe Code Mode client.
+ prefetch through the thread-safe IDA Nexus client.
* Expensive function totals are fetched once and cached.
* Decompilation failures are surfaced as data, not application crashes.
@@ -22,23 +22,21 @@ import bisect
import re
import threading
from base64 import b64decode
+from collections.abc import Callable, Sequence
from concurrent.futures import ThreadPoolExecutor
-from collections.abc import Sequence
from dataclasses import dataclass, field, replace
-from typing import NamedTuple
-from typing import Callable, TYPE_CHECKING
+from typing import TYPE_CHECKING, NamedTuple
-from . import diag
+from . import remote_ops
from .errors import IDAToolError
if TYPE_CHECKING: # type hint only
- from .codemode_client import CodeModeClient
+ from .nexus_client import NexusClient
# Clamps derived from measured caps (list ~700, disasm ~500). Margin included.
LIST_PAGE = 500
DISASM_BLOCK = 256 # instructions per cached/fetched block (<= disasm cap)
-HEX_BLOCK = 16384 # bytes per cached/fetched hex block (compact read_raw -> cheap)
-DECOMPILE_TIMEOUT = 15.0 # s; cap per decompile so a failing one can't hang the CLI
+HEX_BLOCK = 16384 # bytes per cached/fetched hex block (compact read_raw -> cheap)
_TRUNC_RE = re.compile(r"\[(\d+) chars total\]\s*$")
@@ -92,7 +90,7 @@ class Line:
class Head(NamedTuple):
- """One flat-listing item (from the Code Mode ``heads`` operation): a code
+ """One flat-listing item (from the IDA Nexus ``heads`` operation): a code
instruction, a data item, or an undefined byte run.
A ``NamedTuple`` rather than a dataclass because this is by far the
@@ -107,13 +105,13 @@ class Head(NamedTuple):
"""
ea: int
- kind: str # 'code' | 'data' | 'unknown' | 'member'
+ kind: str # 'code' | 'data' | 'unknown' | 'member'
size: int
text: str
name: str | None = None
raw: bytes | None = None # opcode/item bytes (filled in for code by the model)
#: [(kind, text)] from IDA's own colour tags — mnem/reg/num/name/str/punct/…
- #: None when Code Mode didn't provide them (or the spans
+ #: None when IDA Nexus didn't provide them (or the spans
#: disagreed with the plain text, in which case the text wins).
#:
#: Held exactly as it came off the wire, and **read-only**. The worker
@@ -199,10 +197,10 @@ class Ref:
@dataclass
class Xref:
- frm: int # the referencing address
- to: int | None # the referenced address
- type: str # coarse: "code" | "data"
- fn_name: str | None # function containing `frm`
+ frm: int # the referencing address
+ to: int | None # the referenced address
+ type: str # coarse: "code" | "data"
+ fn_name: str | None # function containing `frm`
fn_addr: int | None
kind: str | None = None # fine: call/jump/flow/read/write/offset/text/info
@@ -218,7 +216,7 @@ class LVar:
class FuncTypes:
addr: int
name: str
- prototype: str # e.g. 'int __fastcall foo(int a, char *b)'
+ prototype: str # e.g. 'int __fastcall foo(int a, char *b)'
lvars: list[LVar]
@@ -227,7 +225,7 @@ class Struct:
name: str
size: int
is_union: bool
- members: int # field count
+ members: int # field count
ordinal: int
@classmethod
@@ -244,6 +242,7 @@ class Struct:
@dataclass(frozen=True)
class StrLit:
"""A string literal IDA found in the binary (the Shift+F12 list)."""
+
addr: int
text: str
length: int
@@ -271,6 +270,7 @@ class SearchHit:
an instruction, so ``head`` is the item to navigate to and ``line`` is what
that item renders as.
"""
+
addr: int
head: int
line: str = ""
@@ -287,6 +287,7 @@ class Comment:
report can show what was being commented ON without a second round trip.
``whole_func`` marks a function comment rather than an instruction one.
"""
+
addr: int
text: str
repeatable: bool = False
@@ -302,6 +303,7 @@ class NamedItem:
"""An address carrying a real name -- one you typed, or one the file's own
symbols supplied. IDA records both as "user" names and does not remember
which was which, so a report must say so rather than claim authorship."""
+
addr: int
name: str
is_func: bool = False
@@ -319,6 +321,7 @@ class Linkage:
``name`` is the joinable name; ``raw`` keeps the spelling IDA reported, which
is what the user sees in the listing.
"""
+
addr: int
name: str
module: str = ""
@@ -374,7 +377,9 @@ class FunctionIndex:
query: dict = {"offset": offset, "count": LIST_PAGE}
if self.filter:
query["filter"] = self.filter
- data = _query_data(self._prog.client.invoke("list_funcs", queries=[query]))
+ data = _query_data(
+ self._prog.client.call(remote_ops.list_funcs, queries=[query])
+ )
added = 0
with self._lock:
for d in data:
@@ -468,7 +473,7 @@ class DisasmModel:
self._blocks: dict[int, list[Line]] = {}
self._total: int | None = None
self._ea_list: list[int] | None = None
- self._max_raw = 0 # widest opcode length seen (bytes)
+ self._max_raw = 0 # widest opcode length seen (bytes)
self._func_end: int | None = None
self._func_end_done = False
self._lock = threading.Lock()
@@ -484,8 +489,8 @@ class DisasmModel:
code function this equals the heads row count that backs the lines."""
if self._total is not None:
return self._total
- payload = self._prog.client.invoke(
- "disasm", addr=hex(self.ea), max_instructions=1, include_total=True
+ payload = self._prog.client.call(
+ remote_ops.disasm, addr=hex(self.ea), max_instructions=1, include_total=True
)
total = payload.get("total_instructions")
if total is None:
@@ -527,7 +532,7 @@ class DisasmModel:
nxt = lines[i + 1].ea if i + 1 < len(lines) else last_end
length = max(nxt - ln.ea, 0)
off = ln.ea - start
- b = bytes(data[off:off + length])
+ b = bytes(data[off : off + length])
biggest = max(biggest, len(b))
out.append(replace(ln, raw=b))
with self._lock:
@@ -538,20 +543,22 @@ class DisasmModel:
@staticmethod
def _line_from_head(r: dict) -> Line:
"""Adapt a ``heads`` row to a disasm Line (label = the head's name)."""
- return Line(ea=_as_int(r["ea"]), text=r.get("text", ""),
- label=r.get("name"))
+ return Line(ea=_as_int(r["ea"]), text=r.get("text", ""), label=r.get("name"))
def _fetch_block(self, b: int) -> list[Line]:
# The function disasm view is a listing filtered to the function: fetch a
# block of heads (one per instruction for code). Over-fetch one row so
# the block knows where its last instruction ends (opcode-byte sizing).
- payload = self._prog.client.invoke(
- "heads", addr=hex(self.ea), offset=b * self.BLOCK,
- count=self.BLOCK + 1, **self._end_kw(),
+ payload = self._prog.client.call(
+ remote_ops.heads,
+ addr=hex(self.ea),
+ offset=b * self.BLOCK,
+ count=self.BLOCK + 1,
+ **self._end_kw(),
)
rows = payload.get("heads", []) if isinstance(payload, dict) else []
fetched = [self._line_from_head(r) for r in rows]
- lines = fetched[:self.BLOCK]
+ lines = fetched[: self.BLOCK]
if len(fetched) > self.BLOCK:
end_ea: int | None = fetched[self.BLOCK].ea
else: # this block ends the function
@@ -607,7 +614,7 @@ class DisasmModel:
block = self._get_block(b)
lo = start - b * self.BLOCK if b == b0 else 0
hi = end - b * self.BLOCK if b == b1 else self.BLOCK
- out.extend(block[max(lo, 0):hi])
+ out.extend(block[max(lo, 0) : hi])
if prefetch:
self._prefetch_block(b1 + 1) # forward scroll
self._prefetch_block(b0 - 1) # backward scroll
@@ -686,7 +693,7 @@ class ListingModel:
"""A flat, IDA-style disassembly *listing* over one segment: code, data and
undefined heads interleaved, unlike ``DisasmModel`` (one function, code only).
- Backed by the Code Mode adapter's ``heads`` operation, which walks item heads
+ Backed by the IDA Nexus adapter's ``heads`` operation, which walks item heads
and renders each via ``generate_disasm_line``. The segment is walked lazily in
forward pages (``FunctionIndex`` style); line index == position in the walked
head list. Random access to an address is O(distance-from-seg-start) the
@@ -694,13 +701,14 @@ class ListingModel:
on demand as the viewport scrolls. Synchronous + thread-safe.
"""
- PAGE = 500 # viewport-scale heads per Code Mode execution
+ PAGE = 500 # viewport-scale heads per IDA Nexus execution
#: Generation marker for a skeleton (text-less) page. Never equals a real
#: _text_gen, which counts up from 0, so such a page always reads as stale.
_SKELETON_GEN = -1
- def __init__(self, program: "Program", seg_start: int, seg_end: int,
- name: str | None = None):
+ def __init__(
+ self, program: "Program", seg_start: int, seg_end: int, name: str | None = None
+ ):
self._prog = program
self.seg_start = seg_start
self.seg_end = seg_end
@@ -714,7 +722,7 @@ class ListingModel:
# N bytes PRESENTS as N rows and the text for each is synthesised on
# demand. _row_at[i] is the logical row where physical head i starts.
self._row_at: list[int] = []
- self._head_eas: list[int] = [] # parallel to _heads, for bisect
+ self._head_eas: list[int] = [] # parallel to _heads, for bisect
#: Which name generation each head's TEXT was rendered at, parallel to
#: _heads. A rename bumps :attr:`_text_gen`; the rows themselves stay
#: (their addresses and row numbers are unchanged) and are re-rendered a
@@ -740,7 +748,7 @@ class ListingModel:
#: means something DID move the walk. Program.listing() throws the model
#: away when it sees this, so the next read rebuilds from scratch.
self.stale_structure = False
- self._rows = 0 # total logical rows loaded
+ self._rows = 0 # total logical rows loaded
self._ubytes: dict[int, bytes] = {} # lazily-read bytes for those rows
self._next: int | None = seg_start # next address to fetch from
self._done = False
@@ -788,7 +796,7 @@ class ListingModel:
size = int(r.get("size") or 0)
if size > 0:
off = _as_int(r["ea"]) - lo
- raw = bytes(data[off:off + size])
+ raw = bytes(data[off : off + size])
if len(raw) > biggest:
biggest = len(raw)
try:
@@ -827,19 +835,26 @@ class ListingModel:
"""
with self._lock:
if self._done and self._heads:
- return True # already indexed; re-priming is a no-op
+ return True # already indexed; re-priming is a no-op
try:
- idx = self._prog.client.invoke(
- "segment_index", addr=hex(self.seg_start), end=hex(self.seg_end),
- page_rows=self.PAGE, detail=True)
+ idx = self._prog.client.call(
+ remote_ops.segment_index,
+ addr=hex(self.seg_start),
+ end=hex(self.seg_end),
+ page_rows=self.PAGE,
+ detail=True,
+ )
except Exception: # noqa: BLE001 -- fall back to streaming
return False
if not isinstance(idx, dict) or idx.get("error") or "eas" not in idx:
return False
try:
- eas = array.array("Q"); eas.frombytes(b64decode(idx["eas"]))
- kinds = array.array("B"); kinds.frombytes(b64decode(idx["kinds"]))
- sizes = array.array("I"); sizes.frombytes(b64decode(idx["sizes"]))
+ eas = array.array("Q")
+ eas.frombytes(b64decode(idx["eas"]))
+ kinds = array.array("B")
+ kinds.frombytes(b64decode(idx["kinds"]))
+ sizes = array.array("I")
+ sizes.frombytes(b64decode(idx["sizes"]))
except Exception: # noqa: BLE001
return False
names = idx.get("kind_names") or []
@@ -881,7 +896,8 @@ class ListingModel:
self._page_digest = [None] * len(anchors)
self._page_rows = [
(anchors[k + 1][2] if k + 1 < len(anchors) else n) - anchors[k][2]
- for k in range(len(anchors))]
+ for k in range(len(anchors))
+ ]
self._skeleton = True
self._done = True
self._next = None
@@ -912,8 +928,9 @@ class ListingModel:
if self._done or self._next is None:
return 0
frm = self._next
- payload = self._prog.client.invoke(
- "heads", addr=hex(frm), count=self.PAGE, annotate=True, text=text)
+ payload = self._prog.client.call(
+ remote_ops.heads, addr=hex(frm), count=self.PAGE, annotate=True, text=text
+ )
rows = payload.get("heads", []) if isinstance(payload, dict) else []
cur = payload.get("cursor", {}) if isinstance(payload, dict) else {}
page = self._build_page(rows, raw=text)
@@ -925,8 +942,9 @@ class ListingModel:
self._skeleton = True
self._page_head.append(len(self._heads))
self._page_addr.append(frm)
- self._page_digest.append(payload.get("digest")
- if isinstance(payload, dict) else None)
+ self._page_digest.append(
+ payload.get("digest") if isinstance(payload, dict) else None
+ )
self._page_rows.append(len(rows))
for h in page:
# Banner/label rows (function headers, separators, code labels)
@@ -983,7 +1001,7 @@ class ListingModel:
self._ubytes[b0] = blk
off = a - b0
take = min(BLK - off, n - len(out))
- chunk = blk[off:off + take] if blk else b""
+ chunk = blk[off : off + take] if blk else b""
if not chunk:
break
out += chunk
@@ -1004,8 +1022,9 @@ class ListingModel:
ea = h.ea + off
b = self._unknown_bytes(ea, 1)
text = f"db {b[0]:02X}h" if b else "db ?"
- return Head(ea=ea, kind="unknown", size=1, text=text,
- name=h.name if off == 0 else None)
+ return Head(
+ ea=ea, kind="unknown", size=1, text=text, name=h.name if off == 0 else None
+ )
def ensure(self, n: int) -> None:
"""Ensure at least ``n`` logical rows are loaded (or all, if fewer)."""
@@ -1022,8 +1041,11 @@ class ListingModel:
return idx
with self._lock:
have = self._rows
- last_ea = (self._heads[-1].ea + max(self._heads[-1].size, 1) - 1
- if self._heads else -1)
+ last_ea = (
+ self._heads[-1].ea + max(self._heads[-1].size, 1) - 1
+ if self._heads
+ else -1
+ )
done = self._done
if done or (have and last_ea >= ea):
# Loaded past ea without an exact head hit: return the first head
@@ -1086,13 +1108,13 @@ class ListingModel:
"""
with self._lock:
if not (self.seg_start <= ea < self.seg_end):
- return True # another segment; nothing moved here
+ return True # another segment; nothing moved here
if len(self._page_head) < 3:
- return False # barely walked; a rebuild is cheaper
+ return False # barely walked; a rebuild is cheaper
p = bisect.bisect_right(self._page_addr, ea) - 1
p = max(p - 1, 0)
if p <= 0:
- return False # the edit is in the first pages
+ return False # the edit is in the first pages
keep = self._page_head[p]
if keep <= 0:
return False
@@ -1110,7 +1132,7 @@ class ListingModel:
last = self._heads[-1]
self._rows = self._row_at[-1] + self._span(last)
self._done = False
- self._ubytes.clear() # undefined-run bytes behind the drop point
+ self._ubytes.clear() # undefined-run bytes behind the drop point
return True
def invalidate_text(self) -> None:
@@ -1156,8 +1178,9 @@ class ListingModel:
def _page_bounds(self, p: int) -> tuple[int, int]:
"""[first, last) head index of page ``p`` (caller holds the lock)."""
lo = self._page_head[p]
- hi = (self._page_head[p + 1] if p + 1 < len(self._page_head)
- else len(self._heads))
+ hi = (
+ self._page_head[p + 1] if p + 1 < len(self._page_head) else len(self._heads)
+ )
return lo, hi
def _ensure_page(self, p: int) -> int:
@@ -1181,13 +1204,20 @@ class ListingModel:
# the expectation rather than asking first means a page that HAS changed
# still costs one round trip.
try:
- payload = self._prog.client.invoke(
- "heads", addr=hex(addr), count=self.PAGE, annotate=True,
- expect="" if want_digest is None else str(want_digest))
+ payload = self._prog.client.call(
+ remote_ops.heads,
+ addr=hex(addr),
+ count=self.PAGE,
+ annotate=True,
+ expect="" if want_digest is None else str(want_digest),
+ )
except Exception: # noqa: BLE001 -- keep the old text rather than blank
return p + 1
- if (isinstance(payload, dict) and "heads" not in payload
- and payload.get("count") == want_rows):
+ if (
+ isinstance(payload, dict)
+ and "heads" not in payload
+ and payload.get("count") == want_rows
+ ):
with self._lock:
if self._text_gen == gen and len(self._heads) >= hi:
for k in range(lo, hi):
@@ -1212,8 +1242,9 @@ class ListingModel:
# what it once loaded. Leaving it stale is how a literal cycling
# hex -> dec -> hex ends up declared "unchanged" while the row still
# shows the decimal it was refetched with in between.
- self._page_digest[p] = (payload.get("digest")
- if isinstance(payload, dict) else None)
+ self._page_digest[p] = (
+ payload.get("digest") if isinstance(payload, dict) else None
+ )
for k in range(lo, hi):
self._head_gen[k] = gen
return p + 1
@@ -1225,8 +1256,9 @@ class ListingModel:
j, off = self._phys(i)
if j < 0:
return None
- stale = ((self._renamed or self._skeleton)
- and self._head_gen[j] != self._text_gen)
+ stale = (self._renamed or self._skeleton) and self._head_gen[
+ j
+ ] != self._text_gen
if not stale:
span = self._span(self._heads[j])
h = self._heads[j]
@@ -1265,8 +1297,9 @@ class ListingModel:
spans = [self._phys(i) for i in range(max(start, 0), max(rows, 0))]
heads = self._heads
plain = [(j, off, heads[j]) for j, off in spans if j >= 0]
- return [self._row_head(j, off) if self._span(h) > 1 else h
- for j, off, h in plain]
+ return [
+ self._row_head(j, off) if self._span(h) > 1 else h for j, off, h in plain
+ ]
def index_of_ea(self, ea: int) -> int:
with self._lock:
@@ -1361,7 +1394,7 @@ class HexModel:
if block is None:
return (va, None)
bo = off - b * self.BLOCK
- return (va, block[bo:bo + 16])
+ return (va, block[bo : bo + 16])
def ensure(self, r0: int, count: int) -> None:
"""Blocking: fetch the blocks covering rows [r0, r0+count) if missing."""
@@ -1389,6 +1422,11 @@ class HexModel:
for b in range(b0 - 1, b1 + 2):
self._prefetch(b)
+ def invalidate(self) -> None:
+ """Drop cached bytes so the next viewport read reaches the database."""
+ with self._lock:
+ self._blocks.clear()
+
# --------------------------------------------------------------------------- #
# Program: top-level handle, model registry, prefetch pool
@@ -1396,7 +1434,7 @@ class HexModel:
class Program:
"""The bound analysis session: models, caches, and a small prefetch pool."""
- def __init__(self, client: "CodeModeClient", prefetch_workers: int = 2):
+ def __init__(self, client: "NexusClient", prefetch_workers: int = 2):
self.client = client
self._pool = ThreadPoolExecutor(
max_workers=prefetch_workers, thread_name_prefix="idatui-prefetch"
@@ -1447,27 +1485,39 @@ class Program:
"""Sorted raw segment map [(start, end, file_off, name)] — the single
source for sections()/file_regions()/image_range. Cached.
- Uses the Code Mode adapter's ``file_regions`` operation (a plain segment
+ Uses the IDA Nexus adapter's ``file_regions`` operation (a plain segment
walk, ~ms), avoiding broad binary surveys on the hex-pane open path.
"""
if self._segments_cache is not None:
return self._segments_cache
segs: list[tuple[int, int, int, str]] = []
try:
- r = self.client.invoke("file_regions")
- for d in (r.get("regions", []) if isinstance(r, dict) else []):
+ r = self.client.call(remote_ops.file_regions)
+ for d in r.get("regions", []) if isinstance(r, dict) else []:
if isinstance(d, dict) and "start" in d:
- segs.append((_as_int(d["start"]), _as_int(d["end"]),
- int(d.get("file_off", -1)), d.get("name", "") or ""))
+ segs.append(
+ (
+ _as_int(d["start"]),
+ _as_int(d["end"]),
+ int(d.get("file_off", -1)),
+ d.get("name", "") or "",
+ )
+ )
except IDAToolError:
segs = []
if not segs: # older server without file_regions -> survey_binary (slow)
try:
- sb = self.client.invoke("survey_binary")
- for s in (sb.get("segments", []) if isinstance(sb, dict) else []):
+ sb = self.client.call(remote_ops.survey_binary)
+ for s in sb.get("segments", []) if isinstance(sb, dict) else []:
try:
- segs.append((_as_int(s["start"]), _as_int(s["end"]), -1,
- s.get("name", "") or ""))
+ segs.append(
+ (
+ _as_int(s["start"]),
+ _as_int(s["end"]),
+ -1,
+ s.get("name", "") or "",
+ )
+ )
except (KeyError, ValueError, TypeError):
continue
except Exception: # noqa: BLE001 -- best-effort; callers handle empty
@@ -1523,28 +1573,34 @@ class Program:
def read_bytes(self, ea: int, n: int) -> bytes:
"""Raw bytes [ea, ea+n) from IDA (gaps read as zero).
- The Code Mode adapter returns one contiguous hex string (C-speed in IDA).
+ The IDA Nexus adapter returns one contiguous hex string (C-speed in IDA).
A legacy ``get_bytes`` decoding fallback remains for alternate clients.
"""
if n <= 0:
return b""
if not self._no_read_raw:
try:
- r = self.client.invoke("read_raw", addr=hex(ea), size=int(n))
+ r = self.client.call(remote_ops.read_raw, addr=hex(ea), size=int(n))
h = r.get("hex") if isinstance(r, dict) else None
if isinstance(h, str):
out = bytes.fromhex(h)
return out[:n] if len(out) >= n else out + b"\x00" * (n - len(out))
except IDAToolError as e:
# Tool missing on this server: stop trying it, use get_bytes.
- if "read_raw" in str(e) or "Unknown tool" in str(e) or "not found" in str(e):
+ if (
+ "read_raw" in str(e)
+ or "Unknown tool" in str(e)
+ or "not found" in str(e)
+ ):
self._no_read_raw = True
else:
return b"\x00" * n
except (ValueError, KeyError):
pass # malformed hex -> fall through to the legacy decoder
try:
- r = self.client.invoke("get_bytes", regions=[{"addr": hex(ea), "size": int(n)}])
+ r = self.client.call(
+ remote_ops.get_bytes, regions=[{"addr": hex(ea), "size": int(n)}]
+ )
except IDAToolError:
return b"\x00" * n
res = r.get("result", []) if isinstance(r, dict) else []
@@ -1585,7 +1641,7 @@ class Program:
with self._lock:
m = self._listings.get(start)
if m is not None and m.stale_structure:
- m = None # a refresh found the walk had moved; start over
+ m = None # a refresh found the walk had moved; start over
if m is None:
m = ListingModel(self, start, end, name)
self._listings[start] = m
@@ -1595,11 +1651,15 @@ class Program:
def list_structs(self, filter: str = "") -> list[Struct]:
"""All local structs/unions (optionally name-substring filtered), sorted
by name."""
- payload = self.client.invoke("search_structs", filter=filter)
+ payload = self.client.call(remote_ops.search_structs, filter=filter)
res = payload.get("result", []) if isinstance(payload, dict) else []
- out = [Struct.from_raw(d) for d in res
- if isinstance(d, dict) and d.get("name")
- and not str(d["name"]).startswith("$")] # skip anonymous UDTs
+ out = [
+ Struct.from_raw(d)
+ for d in res
+ if isinstance(d, dict)
+ and d.get("name")
+ and not str(d["name"]).startswith("$")
+ ] # skip anonymous UDTs
out.sort(key=lambda s: s.name.lower())
return out
@@ -1607,8 +1667,9 @@ class Program:
"""A C definition for ``name`` reconstructed from its member layout
(the remote operation exposes members, not printable source). Faithful to IDA's
field names/types; array dims are moved after the field name."""
- payload = self.client.invoke(
- "type_inspect", queries=[{"name": name, "include_members": True}])
+ payload = self.client.call(
+ remote_ops.type_inspect, queries=[{"name": name, "include_members": True}]
+ )
res = payload.get("result", []) if isinstance(payload, dict) else []
info = res[0] if res and isinstance(res[0], dict) else {}
kw = "union" if info.get("is_union") else "struct"
@@ -1630,7 +1691,7 @@ class Program:
def declare_type(self, decl: str) -> str | None:
"""Create or update a C type. Returns None on success, else the parse
error. (Re-declaring a name updates it in place.)"""
- payload = self.client.invoke("declare_type", decls=decl)
+ payload = self.client.call(remote_ops.declare_type, decls=decl)
res = payload.get("result", []) if isinstance(payload, dict) else []
if res and isinstance(res[0], dict):
return res[0].get("error")
@@ -1641,20 +1702,32 @@ class Program:
"""Structured decompiler types for the function at ``ea`` (prototype +
local variables). None if ``ea`` isn't a decompilable function."""
try:
- r = self.client.invoke("func_types", addr=hex(ea))
+ r = self.client.call(remote_ops.func_types, addr=hex(ea))
except IDAToolError:
return None
if not isinstance(r, dict) or r.get("error"):
return None
- lvars = [LVar(name=lv.get("name", ""), type=lv.get("type", ""),
- is_arg=bool(lv.get("is_arg")))
- for lv in r.get("lvars", []) if isinstance(lv, dict)]
- return FuncTypes(addr=_as_int(r.get("addr", hex(ea))), name=r.get("name", ""),
- prototype=r.get("prototype", ""), lvars=lvars)
+ lvars = [
+ LVar(
+ name=lv.get("name", ""),
+ type=lv.get("type", ""),
+ is_arg=bool(lv.get("is_arg")),
+ )
+ for lv in r.get("lvars", [])
+ if isinstance(lv, dict)
+ ]
+ return FuncTypes(
+ addr=_as_int(r.get("addr", hex(ea))),
+ name=r.get("name", ""),
+ prototype=r.get("prototype", ""),
+ lvars=lvars,
+ )
def set_function_type(self, ea: int, signature: str) -> str | None:
"""Set a function's prototype. None on success, else an error string."""
- r = self.client.invoke("set_type", edits=[{"addr": hex(ea), "signature": signature}])
+ r = self.client.call(
+ remote_ops.set_type, edits=[{"addr": hex(ea), "signature": signature}]
+ )
res = r.get("result", []) if isinstance(r, dict) else []
row = res[0] if res and isinstance(res[0], dict) else {}
if row.get("ok"):
@@ -1665,7 +1738,7 @@ class Program:
"""Current type info for a data item/global: {addr,name,type,size,is_func}.
None if the operation fails or the address isn't mapped."""
try:
- r = self.client.invoke("data_type", addr=hex(ea))
+ r = self.client.call(remote_ops.data_type, addr=hex(ea))
except IDAToolError:
return None
if not isinstance(r, dict) or r.get("error"):
@@ -1674,8 +1747,10 @@ class Program:
def set_data_type(self, ea: int, decl: str) -> str | None:
"""Set a global/data item's type. None on success, else an error string."""
- r = self.client.invoke(
- "set_type", edits=[{"kind": "global", "addr": hex(ea), "type": decl}])
+ r = self.client.call(
+ remote_ops.set_type,
+ edits=[{"kind": "global", "addr": hex(ea), "type": decl}],
+ )
res = r.get("result", []) if isinstance(r, dict) else []
row = res[0] if res and isinstance(res[0], dict) else {}
if row.get("ok"):
@@ -1685,7 +1760,9 @@ class Program:
def set_lvar_type(self, fn_ea: int, var: str, ty: str) -> str | None:
"""Set a decompiler local variable's type through ida-domain pseudocode.
None on success, else an error string."""
- r = self.client.invoke("set_lvar_type", addr=hex(fn_ea), variable=var, type=ty)
+ r = self.client.call(
+ remote_ops.set_lvar_type, addr=hex(fn_ea), variable=var, type=ty
+ )
if isinstance(r, dict) and r.get("error"):
return r["error"]
if isinstance(r, dict) and not r.get("ok"):
@@ -1696,12 +1773,12 @@ class Program:
"""Delete a named type. Returns None on success, else an error string.
Returns a clear error instead of raising when the runtime cannot do it."""
try:
- self.client.invoke("del_type", name=name)
+ self.client.call(remote_ops.del_type, name=name)
return None
except IDAToolError as e:
msg = e.message
if "not found" in msg.lower() and "del_type" in msg:
- return "the connected Code Mode runtime cannot delete local types"
+ return "the connected IDA Nexus runtime cannot delete local types"
return msg
# -- disassembly ------------------------------------------------------- #
@@ -1714,8 +1791,25 @@ class Program:
return m
# -- decompilation ----------------------------------------------------- #
+ def force_recompile(self, ea: int) -> None:
+ """Drop local and Hex-Rays caches before an explicit view refresh.
+
+ Normal edit paths use generation-based invalidation. Ctrl+R is also for
+ changes made by another IDA Nexus/IDA client, for which this Program has
+ seen no generation bump, so it must explicitly ask Hex-Rays to discard
+ its cached cfunc.
+ """
+ with self._lock:
+ self._decomp.pop(ea, None)
+ self._pc_nums.pop(ea, None)
+ self._decomp_maps.pop(ea, None)
+ try:
+ self.client.call(remote_ops.force_recompile, items=[{"addr": hex(ea)}])
+ except Exception: # noqa: BLE001 -- refresh still refetches best-effort
+ pass
+
def decompile(self, ea: int, refresh: bool = False) -> Decompilation:
- """Full pseudocode for a function, returned directly by Code Mode."""
+ """Full pseudocode for a function, returned directly by IDA Nexus."""
if not refresh:
with self._lock:
hit = self._decomp.get(ea)
@@ -1727,22 +1821,15 @@ class Program:
# Cached before a rename: names may be stale. Drop Hex-Rays'
# cache so the refetch reflects the new names.
try:
- self.client.invoke("force_recompile", items=[{"addr": hex(ea)}])
+ self.client.call(
+ remote_ops.force_recompile, items=[{"addr": hex(ea)}]
+ )
except Exception: # noqa: BLE001
pass
- # Bound the decompile: a function Hex-Rays can't handle tends to stall
- # near the client's default 30s timeout, and the transport retries a
- # dropped connection up to max_retries+1 times, re-running the failing
- # decompile each time. Cap it so the worst case stays well under the
- # rpcclient socket timeout, and cache the failure below so a re-request
- # returns instantly instead of re-grinding.
+ # The typed remote declaration carries a 15-second transport timeout,
+ # so a function Hex-Rays cannot handle does not stall the UI.
try:
- # Code Mode returns the complete JSON result directly; unlike the
- # old MCP tool transport there is no structured-content envelope or
- # out-of-band download URL to unwrap.
- payload = self.client.invoke(
- "decompile", addr=hex(ea), timeout=DECOMPILE_TIMEOUT
- )
+ payload = self.client.call(remote_ops.decompile, addr=hex(ea))
except Exception as e: # noqa: BLE001 -- surface as a failed decompile
dec = Decompilation(ea, None, True, f"decompile error: {e}", False, None)
with self._lock:
@@ -1768,7 +1855,7 @@ class Program:
self._name_gen += 1
models = list(self._disasm.values())
listings = list(self._listings.values())
- self._pc_nums.clear() # a reformat moves every literal on its line
+ self._pc_nums.clear() # a reformat moves every literal on its line
for m in models:
m.invalidate()
for lm in listings:
@@ -1810,6 +1897,34 @@ class Program:
if self._listings.get(start) is lm:
del self._listings[start]
+ def invalidate_external(self) -> None:
+ """Drop every cached view of an IDB changed by another client.
+
+ An event may describe a rename, a byte patch, a new function, or a
+ segment move. Treating an unknown event as text-only risks displaying a
+ structurally impossible mix of old rows and new metadata, so the
+ external boundary deliberately invalidates all derived state. The app
+ debounces event bursts before reaching this method.
+ """
+ with self._lock:
+ self._name_gen += 1
+ models = list(self._disasm.values())
+ self._indices.clear()
+ self._disasm.clear()
+ self._listings.clear()
+ self._decomp.clear()
+ self._pc_nums.clear()
+ self._decomp_maps.clear()
+ self._flowcharts.clear()
+ self._strings = None
+ self._linkage = None
+ self._segments_cache = None
+ self._sections = None
+ self._fileregions = None
+ self._hexmodel = None
+ for model in models:
+ model.invalidate()
+
# -- item / function structure edits (IDA c/d/u/p) --------------------- #
@staticmethod
def _first_result(payload) -> dict:
@@ -1825,18 +1940,19 @@ class Program:
Undefine first so it works even when the bytes are currently part of a
data/align item — ``create_insn`` refuses to carve into a live item."""
try:
- self.client.invoke("undefine", items=[{"addr": hex(ea)}])
+ self.client.call(remote_ops.undefine, items=[{"addr": hex(ea)}])
except IDAToolError:
pass # nothing defined here yet -> just try to create the insn
res = self._first_result(
- self.client.invoke("define_code", items=[{"addr": hex(ea)}]))
+ self.client.call(remote_ops.define_code, items=[{"addr": hex(ea)}])
+ )
if res.get("error"):
raise IDAToolError("define_code", f"@ {ea:#x}: {res['error']}")
def decomp_error(self, ea: int) -> str:
"""Hex-Rays' own reason for refusing ``ea``, or "" if it won't say."""
try:
- r = self.client.invoke("decomp_error", addr=hex(ea))
+ r = self.client.call(remote_ops.decomp_error, addr=hex(ea))
except IDAToolError:
return ""
if not isinstance(r, dict):
@@ -1855,19 +1971,22 @@ class Program:
def thumb_scan(self, start: int, end: int, apply: bool = True) -> dict:
"""Find Thumb entry points from odd pointers in ``[start, end)``."""
- r = self.client.invoke("thumb_scan", start=hex(start), end=hex(end),
- apply=bool(apply))
+ r = self.client.call(
+ remote_ops.thumb_scan, start=hex(start), end=hex(end), apply=bool(apply)
+ )
if not isinstance(r, dict) or r.get("error"):
- raise IDAToolError("thumb_scan",
- f"@ {start:#x}: {(r or {}).get('error', 'failed')}")
+ raise IDAToolError(
+ "thumb_scan", f"@ {start:#x}: {(r or {}).get('error', 'failed')}"
+ )
return r
def set_thumb(self, ea: int, mode: str = "toggle") -> dict:
"""Switch ARM/Thumb decoding at ``ea``. Returns the resulting state."""
- r = self.client.invoke("set_thumb", addr=hex(ea), mode=mode)
+ r = self.client.call(remote_ops.set_thumb, addr=hex(ea), mode=mode)
if not isinstance(r, dict) or r.get("error"):
- raise IDAToolError("set_thumb",
- f"@ {ea:#x}: {(r or {}).get('error', 'failed')}")
+ raise IDAToolError(
+ "set_thumb", f"@ {ea:#x}: {(r or {}).get('error', 'failed')}"
+ )
return r
def define_code_run(self, ea: int, limit: int = 20000) -> dict:
@@ -1877,32 +1996,37 @@ class Program:
provide the run operation.
"""
try:
- r = self.client.invoke("define_code_run", addr=hex(ea), limit=int(limit))
+ r = self.client.call(
+ remote_ops.define_code_run, addr=hex(ea), limit=int(limit)
+ )
except IDAToolError:
self.define_code(ea)
return {"count": 1, "stopped": "single", "end": hex(ea)}
if not isinstance(r, dict) or r.get("error"):
- raise IDAToolError("define_code_run",
- f"@ {ea:#x}: {(r or {}).get('error', 'failed')}")
+ raise IDAToolError(
+ "define_code_run", f"@ {ea:#x}: {(r or {}).get('error', 'failed')}"
+ )
return r
def define_func(self, ea: int) -> dict:
"""Create a function starting at ``ea`` (IDA's 'p').
- Prefers the Code Mode operation, which works out the end when IDA can't;
+ Prefers the IDA Nexus operation, which works out the end when IDA can't;
falls back to a plain create for alternate clients.
"""
try:
- r = self.client.invoke("define_func_run", addr=hex(ea))
+ r = self.client.call(remote_ops.define_func_run, addr=hex(ea))
except IDAToolError:
res = self._first_result(
- self.client.invoke("define_func", items=[{"addr": hex(ea)}]))
+ self.client.call(remote_ops.define_func, items=[{"addr": hex(ea)}])
+ )
if res.get("error"):
raise IDAToolError("define_func", f"@ {ea:#x}: {res['error']}")
return {"ok": True, "how": "legacy"}
if not isinstance(r, dict) or not r.get("ok"):
- raise IDAToolError("define_func",
- f"@ {ea:#x}: {(r or {}).get('error', 'failed')}")
+ raise IDAToolError(
+ "define_func", f"@ {ea:#x}: {(r or {}).get('error', 'failed')}"
+ )
return r
def undefine(self, ea: int, size: int | None = None) -> None:
@@ -1910,7 +2034,7 @@ class Program:
item: dict = {"addr": hex(ea)}
if size:
item["size"] = int(size)
- res = self._first_result(self.client.invoke("undefine", items=[item]))
+ res = self._first_result(self.client.call(remote_ops.undefine, items=[item]))
if res.get("error"):
raise IDAToolError("undefine", f"@ {ea:#x}: {res['error']}")
@@ -1920,24 +2044,29 @@ class Program:
item: dict = {"addr": hex(ea), "type": type_decl}
if name:
item["name"] = name
- res = self._first_result(self.client.invoke("make_data", items=[item]))
+ res = self._first_result(self.client.call(remote_ops.make_data, items=[item]))
if res.get("ok") is False or res.get("error"):
raise IDAToolError(
- "make_data", f"@ {ea:#x}: {res.get('error') or 'rejected'}")
+ "make_data", f"@ {ea:#x}: {res.get('error') or 'rejected'}"
+ )
def make_string(self, ea: int, length: int = 0, kind: str = "c") -> str:
"""Create a string literal at ``ea`` (IDA's 'A'); auto-length when 0.
Returns the decoded contents."""
- r = self.client.invoke("make_string", addr=hex(ea), length=int(length), kind=kind)
+ r = self.client.call(
+ remote_ops.make_string, addr=hex(ea), length=int(length), kind=kind
+ )
res = r if isinstance(r, dict) else {}
if not res.get("ok"):
raise IDAToolError(
- "make_string", f"@ {ea:#x}: {res.get('error') or 'rejected'}")
+ "make_string", f"@ {ea:#x}: {res.get('error') or 'rejected'}"
+ )
return res.get("text", "")
# -- literal display formats (IDA's 'o': hex / dec / char / offset) ---- #
- def op_format(self, ea: int, mode: str = "cycle", col: int = -1,
- n: int = -1) -> dict:
+ def op_format(
+ self, ea: int, mode: str = "cycle", col: int = -1, n: int = -1
+ ) -> dict:
"""Change how the literal at ``ea`` is DISPLAYED in the listing.
``col`` is a column inside the rendered line, which is how the cursor
@@ -1945,8 +2074,9 @@ class Program:
``cycle``/``back`` (step the stops that make sense for this value) or a
format by name. ``show`` reports without changing anything.
"""
- r = self.client.invoke("op_format", addr=hex(ea), mode=str(mode),
- col=int(col), n=int(n))
+ r = self.client.call(
+ remote_ops.op_format, addr=hex(ea), mode=str(mode), col=int(col), n=int(n)
+ )
res = r if isinstance(r, dict) else {}
if res.get("error"):
raise IDAToolError("op_format", f"@ {ea:#x}: {res['error']}")
@@ -1969,31 +2099,43 @@ class Program:
if hit is not None and hit[1] == gen:
return hit[0]
try:
- r = self.client.invoke("pc_nums", addr=hex(fn_ea))
+ r = self.client.call(remote_ops.pc_nums, addr=hex(fn_ea))
except Exception: # noqa: BLE001 -- an older worker hasn't got the tool
r = {}
out: dict[int, list[tuple[int, int, str, int, int]]] = {}
for rec in (r or {}).get("nums", []):
try:
out.setdefault(int(rec["line"]), []).append(
- (int(rec["x0"]), int(rec["x1"]), str(rec.get("value", "")),
- _as_int(rec["ea"]), int(rec.get("opnum", 0))))
+ (
+ int(rec["x0"]),
+ int(rec["x1"]),
+ str(rec.get("value", "")),
+ _as_int(rec["ea"]),
+ int(rec.get("opnum", 0)),
+ )
+ )
except Exception: # noqa: BLE001 -- skip a malformed row
continue
with self._lock:
self._pc_nums[fn_ea] = (out, gen)
return out
- def pc_num_format(self, fn_ea: int, mode: str = "cycle", line: int = -1,
- col: int = -1) -> dict:
+ def pc_num_format(
+ self, fn_ea: int, mode: str = "cycle", line: int = -1, col: int = -1
+ ) -> dict:
"""The same, for a number in the DECOMPILATION of ``fn_ea``.
Hex-Rays keeps number formats of its own, per (address, operand) — the
listing's format doesn't reach the pseudocode and vice versa, so this is
a separate call rather than a flag on ``op_format``.
"""
- r = self.client.invoke("pc_num_format", addr=hex(fn_ea), mode=str(mode),
- line=int(line), col=int(col))
+ r = self.client.call(
+ remote_ops.pc_num_format,
+ addr=hex(fn_ea),
+ mode=str(mode),
+ line=int(line),
+ col=int(col),
+ )
res = r if isinstance(r, dict) else {}
if res.get("error"):
raise IDAToolError("pc_num_format", f"@ {fn_ea:#x}: {res['error']}")
@@ -2021,22 +2163,30 @@ class Program:
offset, page = 0, 2000
while True:
try:
- payload = self.client.invoke(
- "list_strings", offset=offset, count=page, min_len=min_len,
- refresh=(refresh and offset == 0))
+ payload = self.client.call(
+ remote_ops.list_strings,
+ offset=offset,
+ count=page,
+ min_len=min_len,
+ refresh=(refresh and offset == 0),
+ )
except IDAToolError:
return []
rows = payload.get("strings", []) if isinstance(payload, dict) else []
for r in rows:
if not isinstance(r, dict):
continue
- out.append(StrLit(
- addr=_as_int(r.get("addr", 0)),
- text=r.get("text", ""),
- length=int(r.get("len", 0) or 0),
- type=r.get("type", "") or "",
- ))
- total = int(payload.get("total", 0) or 0) if isinstance(payload, dict) else 0
+ out.append(
+ StrLit(
+ addr=_as_int(r.get("addr", 0)),
+ text=r.get("text", ""),
+ length=int(r.get("len", 0) or 0),
+ type=r.get("type", "") or "",
+ )
+ )
+ total = (
+ int(payload.get("total", 0) or 0) if isinstance(payload, dict) else 0
+ )
if len(rows) < page or len(out) >= total:
break
offset += len(rows)
@@ -2052,27 +2202,39 @@ class Program:
if hit is not None:
return hit
try:
- payload = self.client.invoke("list_linkage", kind="both")
+ payload = self.client.call(remote_ops.list_linkage, kind="both")
except IDAToolError:
return ([], [])
if not isinstance(payload, dict):
return ([], [])
- imps = [Linkage(addr=_as_int(r.get("addr", 0)),
- name=link_name(r.get("name", "")),
- module=r.get("module", "") or "",
- raw=r.get("name", "") or "")
- for r in payload.get("imports", []) if isinstance(r, dict)]
- exps = [Linkage(addr=_as_int(r.get("addr", 0)),
- name=link_name(r.get("name", "")),
- ordinal=int(r.get("ordinal", 0) or 0),
- raw=r.get("name", "") or "")
- for r in payload.get("exports", []) if isinstance(r, dict)]
+ imps = [
+ Linkage(
+ addr=_as_int(r.get("addr", 0)),
+ name=link_name(r.get("name", "")),
+ module=r.get("module", "") or "",
+ raw=r.get("name", "") or "",
+ )
+ for r in payload.get("imports", [])
+ if isinstance(r, dict)
+ ]
+ exps = [
+ Linkage(
+ addr=_as_int(r.get("addr", 0)),
+ name=link_name(r.get("name", "")),
+ ordinal=int(r.get("ordinal", 0) or 0),
+ raw=r.get("name", "") or "",
+ )
+ for r in payload.get("exports", [])
+ if isinstance(r, dict)
+ ]
out = ([i for i in imps if i.name], [e for e in exps if e.name])
with self._lock:
self._linkage = out
return out
- def annotations(self, limit: int = 4000) -> tuple[list["Comment"], list["NamedItem"]]:
+ def annotations(
+ self, limit: int = 4000
+ ) -> tuple[list["Comment"], list["NamedItem"]]:
"""``(comments, names)`` -- everything a person added to this database.
Not cached: it is the *current* state of your work, and the one caller
@@ -2080,44 +2242,64 @@ class Program:
no such operation, so an alternate client degrades instead of breaking.
"""
try:
- payload = self.client.invoke("list_annotations", limit=int(limit))
+ payload = self.client.call(remote_ops.list_annotations, limit=int(limit))
except IDAToolError:
return ([], [])
if not isinstance(payload, dict):
return ([], [])
comments = [
- Comment(addr=_as_int(r.get("addr", 0)), text=str(r.get("text", "")),
- repeatable=bool(r.get("repeatable")),
- whole_func=bool(r.get("whole_func")),
- line=str(r.get("line", "") or ""),
- seg=str(r.get("seg", "") or ""),
- func=(r.get("func") or None),
- func_addr=(_as_int(r["func_addr"]) if r.get("func_addr") else None))
- for r in payload.get("comments", []) if isinstance(r, dict) and r.get("text")]
+ Comment(
+ addr=_as_int(r.get("addr", 0)),
+ text=str(r.get("text", "")),
+ repeatable=bool(r.get("repeatable")),
+ whole_func=bool(r.get("whole_func")),
+ line=str(r.get("line", "") or ""),
+ seg=str(r.get("seg", "") or ""),
+ func=(r.get("func") or None),
+ func_addr=(_as_int(r["func_addr"]) if r.get("func_addr") else None),
+ )
+ for r in payload.get("comments", [])
+ if isinstance(r, dict) and r.get("text")
+ ]
names = [
- NamedItem(addr=_as_int(r.get("addr", 0)), name=str(r.get("name", "")),
- is_func=bool(r.get("func")), size=int(r.get("size", 0) or 0),
- proto=(r.get("proto") or None), seg=str(r.get("seg", "") or ""))
- for r in payload.get("names", []) if isinstance(r, dict) and r.get("name")]
+ NamedItem(
+ addr=_as_int(r.get("addr", 0)),
+ name=str(r.get("name", "")),
+ is_func=bool(r.get("func")),
+ size=int(r.get("size", 0) or 0),
+ proto=(r.get("proto") or None),
+ seg=str(r.get("seg", "") or ""),
+ )
+ for r in payload.get("names", [])
+ if isinstance(r, dict) and r.get("name")
+ ]
return (comments, names)
-
- def search(self, query: str, mode: str = "text", *, limit: int = 500,
- regex: bool = False, case: bool = False,
- ) -> tuple[list["SearchHit"], str | None, bool]:
+ def search(
+ self,
+ query: str,
+ mode: str = "text",
+ *,
+ limit: int = 500,
+ regex: bool = False,
+ case: bool = False,
+ ) -> tuple[list["SearchHit"], str | None, bool]:
"""Search the whole database. Returns ``(hits, error, truncated)``.
A failed search is DATA (a message to show), not an exception: a bad
regex or an unparsable byte pattern is something the user typed, and
the palette wants to say so without unwinding.
"""
- op = "search_bytes" if mode == "bytes" else "search_text"
+ operation = (
+ remote_ops.search_bytes if mode == "bytes" else remote_ops.search_text
+ )
args: dict = {"limit": int(limit), "case": bool(case)}
if mode == "bytes":
# Validate HERE, not just in the UI: IDA's find_bytes answers a
# malformed pattern with zero hits and no error, which reads as
# "not present" -- the most misleading answer a search can give.
from .search import normalise_pattern, pattern_problem
+
problem = pattern_problem(query)
if problem:
return ([], problem, False)
@@ -2126,29 +2308,32 @@ class Program:
args["query"] = query
args["regex"] = bool(regex)
try:
- payload = self.client.invoke(op, **args)
+ payload = self.client.call(operation, **args)
except IDAToolError as e:
return ([], str(e), False)
if not isinstance(payload, dict):
return ([], "the backend returned nothing searchable", False)
hits = [
- SearchHit(addr=_as_int(r.get("addr", 0)),
- head=_as_int(r.get("head", r.get("addr", 0))),
- line=str(r.get("line", "") or ""),
- func=(r.get("func") or None),
- func_addr=(_as_int(r["func_addr"]) if r.get("func_addr")
- else None),
- seg=str(r.get("seg", "") or ""))
- for r in payload.get("hits", []) if isinstance(r, dict)]
+ SearchHit(
+ addr=_as_int(r.get("addr", 0)),
+ head=_as_int(r.get("head", r.get("addr", 0))),
+ line=str(r.get("line", "") or ""),
+ func=(r.get("func") or None),
+ func_addr=(_as_int(r["func_addr"]) if r.get("func_addr") else None),
+ seg=str(r.get("seg", "") or ""),
+ )
+ for r in payload.get("hits", [])
+ if isinstance(r, dict)
+ ]
return (hits, payload.get("error") or None, bool(payload.get("truncated")))
def journal_get(self) -> str:
"""The findings journal blob stored in this database ('' if none)."""
- payload = self.client.invoke("journal_get")
+ payload = self.client.call(remote_ops.journal_get)
return str(payload.get("data", "")) if isinstance(payload, dict) else ""
def journal_put(self, data: str) -> None:
- self.client.invoke("journal_put", data=str(data))
+ self.client.call(remote_ops.journal_put, data=str(data))
def decomp_map(self, ea: int) -> list[list[int]]:
"""Per-pseudocode-line instruction coverage for the split-view region
@@ -2161,12 +2346,15 @@ class Program:
if hit is not None and hit[1] == gen:
return hit[0]
try:
- payload = self.client.invoke("decomp_map", addr=hex(ea))
+ payload = self.client.call(remote_ops.decomp_map, addr=hex(ea))
except IDAToolError:
return []
lines = payload.get("lines", []) if isinstance(payload, dict) else []
- out = [[_as_int(e) for e in (ln.get("eas") or [])]
- for ln in lines if isinstance(ln, dict)]
+ out = [
+ [_as_int(e) for e in (ln.get("eas") or [])]
+ for ln in lines
+ if isinstance(ln, dict)
+ ]
with self._lock:
self._decomp_maps[ea] = (out, gen)
return out
@@ -2191,7 +2379,7 @@ class Program:
if hit is not None and hit[1] == gen:
return hit[0]
try:
- payload = self.client.invoke("flowchart", addr=hex(ea))
+ payload = self.client.call(remote_ops.flowchart, addr=hex(ea))
except IDAToolError:
return None
if not isinstance(payload, dict) or payload.get("error"):
@@ -2202,10 +2390,14 @@ class Program:
blocks = []
for b in raw:
try:
- blocks.append(BasicBlock(
- id=int(b["id"]), start=_as_int(b["start"]),
- end=_as_int(b["end"]),
- succs=[(int(d), str(k)) for d, k in (b.get("succs") or [])]))
+ blocks.append(
+ BasicBlock(
+ id=int(b["id"]),
+ start=_as_int(b["start"]),
+ end=_as_int(b["end"]),
+ succs=[(int(d), str(k)) for d, k in (b.get("succs") or [])],
+ )
+ )
except (KeyError, ValueError, TypeError):
continue
if not blocks:
@@ -2217,8 +2409,9 @@ class Program:
for b in blocks:
# bisect, not a scan per block: a 400-block function against a few
# thousand rows is a million comparisons done for nothing.
- b.rows = rows[bisect.bisect_left(eas, b.start):
- bisect.bisect_left(eas, b.end)]
+ b.rows = rows[
+ bisect.bisect_left(eas, b.start) : bisect.bisect_left(eas, b.end)
+ ]
fcv = Flowchart(
func_ea=_as_int(f.get("addr", lo)),
name=str(f.get("name") or f"sub_{lo:X}"),
@@ -2261,11 +2454,12 @@ class Program:
operand marks for free."""
out: list[Head] = []
addr = lo
- for _ in range(64): # bounded: ~128k heads
+ for _ in range(64): # bounded: ~128k heads
if addr >= hi:
break
- payload = self.client.invoke("heads", addr=hex(addr), end=hex(hi),
- count=2000)
+ payload = self.client.call(
+ remote_ops.heads, addr=hex(addr), end=hex(hi), count=2000
+ )
rows = payload.get("heads", []) if isinstance(payload, dict) else []
if not rows:
break
@@ -2293,27 +2487,34 @@ class Program:
# -- cross-references & containing function --------------------------- #
def function_of(self, ea: int) -> Func | None:
"""Return the function containing ``ea`` (resolves mid-function addrs)."""
- payload = self.client.invoke("lookup_funcs", queries=[hex(ea)])
+ payload = self.client.call(remote_ops.lookup_funcs, queries=[hex(ea)])
res = payload.get("result", []) if isinstance(payload, dict) else []
fn = res[0].get("fn") if res and isinstance(res[0], dict) else None
return Func.from_raw(fn) if fn else None
def xrefs_from(self, ea: int) -> list[Xref]:
- payload = self.client.invoke(
- "xref_query",
+ payload = self.client.call(
+ remote_ops.xref_query,
queries=[{"addr": hex(ea), "direction": "from", "include_fn": True}],
)
return _parse_xrefs(payload)
def xrefs_to(self, ea: int, limit: int = 2000) -> list[Xref]:
- q = [{"addr": hex(ea), "direction": "to", "include_fn": True,
- "dedup": True, "count": limit}]
+ q = [
+ {
+ "addr": hex(ea),
+ "direction": "to",
+ "include_fn": True,
+ "dedup": True,
+ "count": limit,
+ }
+ ]
try:
# xref_types adds a fine-grained `kind` (call/read/write/...) for the
# xref dialog; fall back to xref_query (code/data only) if absent.
- payload = self.client.invoke("xref_types", queries=q)
+ payload = self.client.call(remote_ops.xref_types, queries=q)
except IDAToolError:
- payload = self.client.invoke("xref_query", queries=q)
+ payload = self.client.call(remote_ops.xref_query, queries=q)
return _parse_xrefs(payload)
# -- address resolution ------------------------------------------------ #
@@ -2331,7 +2532,7 @@ class Program:
# (loc_/locret_): lookup_funcs would map a label to its *containing*
# function's entry, so double-clicking a label jumped to the wrong place.
try:
- payload = self.client.invoke("resolve_names", queries=[s])
+ payload = self.client.call(remote_ops.resolve_names, queries=[s])
res = payload.get("result", []) if isinstance(payload, dict) else []
ea = res[0].get("ea") if res and isinstance(res[0], dict) else None
if ea:
@@ -2341,7 +2542,7 @@ class Program:
# Fall back to function-name resolution (also drives the 'did you mean'
# suggestion when the name is unknown).
try:
- payload = self.client.invoke("lookup_funcs", queries=[s])
+ payload = self.client.call(remote_ops.lookup_funcs, queries=[s])
except IDAToolError as e:
raise KeyError(f"cannot resolve {target!r}: {e}") from e
res = payload.get("result", []) if isinstance(payload, dict) else []
@@ -2367,8 +2568,10 @@ class Program:
except Exception: # noqa: BLE001 -- suggestions are strictly optional
return ""
if not cands:
- return (" (no function name contains it; it may be a data symbol or "
- "not a function — pass an address like 0x1234)")
+ return (
+ " (no function name contains it; it may be a data symbol or "
+ "not a function — pass an address like 0x1234)"
+ )
shown = cands[:5]
names = ", ".join(f"{c.name} @ {c.addr:#x}" for c in shown)
more = " …" if len(cands) > len(shown) else ""
@@ -2379,7 +2582,9 @@ class Program:
"""Set (empty text clears) the comment at ``ea``; affects both the disasm
and decompiler views. Returns the raw payload so the caller can surface a
soft per-item error. The caller must invalidate/recompile to see it."""
- return self.client.invoke("set_comments", items=[{"addr": hex(ea), "comment": text}])
+ return self.client.call(
+ remote_ops.set_comments, items=[{"addr": hex(ea), "comment": text}]
+ )
# -- invalidation (after edits) --------------------------------------- #
def invalidate(self, ea: int) -> None:
@@ -2408,14 +2613,16 @@ def _parse_xrefs(payload) -> list[Xref]:
fn = d.get("fn") or {}
frm = d.get("from", d.get("addr"))
to = d.get("to")
- out.append(Xref(
- frm=_as_int(frm) if frm is not None else 0,
- to=_as_int(to) if to is not None else None,
- type=d.get("type", "?"),
- fn_name=fn.get("name"),
- fn_addr=_as_int(fn["addr"]) if fn.get("addr") else None,
- kind=d.get("kind"),
- ))
+ out.append(
+ Xref(
+ frm=_as_int(frm) if frm is not None else 0,
+ to=_as_int(to) if to is not None else None,
+ type=d.get("type", "?"),
+ fn_name=fn.get("name"),
+ fn_addr=_as_int(fn["addr"]) if fn.get("addr") else None,
+ kind=d.get("kind"),
+ )
+ )
return out
@@ -2425,13 +2632,15 @@ def _parse_decompilation(ea: int, payload) -> Decompilation:
code = payload.get("code")
error = payload.get("error")
if not code:
- return Decompilation(ea, None, True, error or "decompilation failed",
- False, None)
+ return Decompilation(
+ ea, None, True, error or "decompilation failed", False, None
+ )
m = _TRUNC_RE.search(code)
truncated = m is not None
total_chars = int(m.group(1)) if m else len(code)
refs = [
Ref(addr=_as_int(r["addr"]), name=r.get("name", ""), string=r.get("string"))
- for r in payload.get("refs", []) if isinstance(r, dict) and "addr" in r
+ for r in payload.get("refs", [])
+ if isinstance(r, dict) and "addr" in r
]
return Decompilation(ea, code, False, error, truncated, total_chars, refs)
diff --git a/idatui/edit_ctl.py b/idatui/edit_ctl.py
index 52566ca..89dfc69 100644
--- a/idatui/edit_ctl.py
+++ b/idatui/edit_ctl.py
@@ -19,6 +19,7 @@ The message handlers and the ``@work`` entry points stay on ``IdaTui``: Textual
dispatches ``on_<message>`` by name on the DOMNode, and its worker machinery
wants a DOMNode host. They are one-line delegates into here.
"""
+
from __future__ import annotations
import re
@@ -26,10 +27,10 @@ from typing import TYPE_CHECKING
from textual.widgets import DataTable
-from . import diag
+from . import diag, remote_ops
from .errors import IDAToolError
-if TYPE_CHECKING: # pragma: no cover
+if TYPE_CHECKING: # pragma: no cover
from .app import IdaTui
_app_mod = None
@@ -40,13 +41,18 @@ def _M():
global _app_mod
if _app_mod is None:
from . import app as _m
+
_app_mod = _m
return _app_mod
#: A C type wide enough for N bytes, for prefilling a retype/define prompt.
-_BY_SIZE = {1: "unsigned __int8", 2: "unsigned __int16",
- 4: "unsigned __int32", 8: "unsigned __int64"}
+_BY_SIZE = {
+ 1: "unsigned __int8",
+ 2: "unsigned __int16",
+ 4: "unsigned __int32",
+ 8: "unsigned __int64",
+}
class EditController:
@@ -169,16 +175,24 @@ class EditController:
# If the cursor is on a symbol token (a call/branch target, a data
# reference, or this head's own label) rename THAT symbol; otherwise
# create/rename a label at the head's address (bare/undefined bytes).
- if (word and app._looks_like_symbol(word) and word != mnem
- and word.lower() not in M._ASM_KEYWORDS):
+ if (
+ word
+ and app._looks_like_symbol(word)
+ and word != mnem
+ and word.lower() not in M._ASM_KEYWORDS
+ ):
app.prompts.rename.show(
f"rename '{word}' — Enter=apply Esc=cancel",
- word, ctx=(msg.view, word, None))
+ word,
+ ctx=(msg.view, word, None),
+ )
else:
cur = head.name if (head is not None and head.name) else ""
app.prompts.rename.show(
f"name @ {ea:#x} — Enter=apply Esc=cancel",
- cur, ctx=(msg.view, cur, ea))
+ cur,
+ ctx=(msg.view, cur, ea),
+ )
return
if not msg.name:
app._status("nothing to rename under the cursor")
@@ -186,11 +200,14 @@ class EditController:
if self.is_pseudocode_label(msg.view, msg.name):
app._status(
f"can't rename pseudocode label '{msg.name}' "
- "(Hex-Rays goto labels aren't renamable via the API)")
+ "(Hex-Rays goto labels aren't renamable via the API)"
+ )
return
app.prompts.rename.show(
f"rename '{msg.name}' — Enter=apply Esc=cancel",
- msg.name, ctx=(msg.view, msg.name, None))
+ msg.name,
+ ctx=(msg.view, msg.name, None),
+ )
def submit_rename(self, ctx, value: str) -> None: # type: ignore[no-untyped-def]
view, old, addr = ctx
@@ -242,7 +259,7 @@ class EditController:
kind = "stack"
batch = {"stack": {"func_addr": hex(cur.ea), "old": old, "new": new}}
try:
- res = prog.client.invoke("rename", batch=batch)
+ res = prog.client.call(remote_ops.rename, batch=batch)
except IDAToolError as e:
app.call_from_thread(app._status, f"rename failed: {e.message}")
return
@@ -275,8 +292,9 @@ class EditController:
app = self.app
assert app.program is not None
try:
- res = app.program.client.invoke(
- "rename", batch={"data": {"addr": hex(addr), "new": name}})
+ res = app.program.client.call(
+ remote_ops.rename, batch={"data": {"addr": hex(addr), "new": name}}
+ )
except IDAToolError as e:
app.call_from_thread(app._status, f"name failed: {e.message}")
return
@@ -307,11 +325,11 @@ class EditController:
lm = app.program.listing(addr)
label = name if is_func_start else app.program.region_label(addr)
idx = max(lm.ensure_ea(addr), 0) if lm is not None else 0
- app.call_from_thread(self.open_at_named, label, addr, idx, name,
- is_func_start)
+ app.call_from_thread(self.open_at_named, label, addr, idx, name, is_func_start)
- def open_at_named(self, label: str, addr: int, idx: int, name: str,
- is_func_start: bool = False) -> None:
+ def open_at_named(
+ self, label: str, addr: int, idx: int, name: str, is_func_start: bool = False
+ ) -> None:
app = self.app
if is_func_start:
app.program.bump_names()
@@ -328,10 +346,11 @@ class EditController:
pseudocode a comment is `// text` before the trailing /*0xEA*/ markers;
C has no `//` operator, so the last `//` is unambiguously the comment."""
if isinstance(view, _M().DecompView) and 0 <= view.cursor < len(view._texts):
- s = re.sub(r"(?:/\*\s*0x[0-9A-Fa-f]+\s*\*/\s*)+$", "",
- view._texts[view.cursor])
+ s = re.sub(
+ r"(?:/\*\s*0x[0-9A-Fa-f]+\s*\*/\s*)+$", "", view._texts[view.cursor]
+ )
i = s.rfind("//")
- return s[i + 2:].strip() if i >= 0 else ""
+ return s[i + 2 :].strip() if i >= 0 else ""
return ""
def request_comment(self, msg) -> None: # type: ignore[no-untyped-def]
@@ -349,7 +368,9 @@ class EditController:
what = "function comment" if func_level else "comment"
app.prompts.comment.show(
f"{what} @ {ea:#x} — Enter=apply (empty=clear) Esc=cancel",
- existing, ctx=(msg.view, ea, existing))
+ existing,
+ ctx=(msg.view, ea, existing),
+ )
def submit_comment(self, ctx, value: str) -> None: # type: ignore[no-untyped-def]
view, ea, existing = ctx
@@ -369,10 +390,13 @@ class EditController:
app.call_from_thread(app._status, f"comment failed: {e.message}")
return
data = res.get("result") if isinstance(res, dict) else None
- if (isinstance(data, list) and data and isinstance(data[0], dict)
- and data[0].get("error")):
- app.call_from_thread(app._status,
- f"comment failed: {data[0]['error']}")
+ if (
+ isinstance(data, list)
+ and data
+ and isinstance(data[0], dict)
+ and data[0].get("error")
+ ):
+ app.call_from_thread(app._status, f"comment failed: {data[0]['error']}")
return
app.call_from_thread(self.after_comment, ea, text)
@@ -429,30 +453,34 @@ class EditController:
if dt is not None and not dt.get("is_func"):
kind, subject = "data", tgt
prefill = dt.get("type") or self.guess_data_type(
- dt.get("size") or 0)
+ dt.get("size") or 0
+ )
# 3) fall back to the current function itself
if kind is None and ft is not None:
kind, subject, prefill = "func", app._cur.ea, ft.prototype
if kind is None:
- app.call_from_thread(app._status,
- "nothing to retype under the cursor")
+ app.call_from_thread(app._status, "nothing to retype under the cursor")
return
- app.call_from_thread(self.open_retype, view, kind, subject,
- word or "", prefill)
+ app.call_from_thread(self.open_retype, view, kind, subject, word or "", prefill)
- def open_retype(self, view, kind: str, subject: int, word: str,
- prefill: str) -> None: # type: ignore[no-untyped-def]
+ def open_retype(
+ self, view, kind: str, subject: int, word: str, prefill: str
+ ) -> None: # type: ignore[no-untyped-def]
label = "prototype" if kind == "func" else f"type for '{word}'"
- self.app.prompts.retype.show(f"{label} — Enter=apply Esc=cancel",
- prefill, ctx=(view, kind, subject, word))
+ self.app.prompts.retype.show(
+ f"{label} — Enter=apply Esc=cancel",
+ prefill,
+ ctx=(view, kind, subject, word),
+ )
def submit_retype(self, ctx, value: str) -> None: # type: ignore[no-untyped-def]
view, kind, subject, word = ctx
if view is not None and value:
self.app._do_retype(kind, subject, word, value)
- def do_retype(self, kind: str, subject: int, word: str,
- new: str) -> None: # worker context
+ def do_retype(
+ self, kind: str, subject: int, word: str, new: str
+ ) -> None: # worker context
app = self.app
assert app.program is not None
if kind == "func":
@@ -473,8 +501,9 @@ class EditController:
app.program.bump_names()
self.reload_active_code()
app._dirty = True
- app.journal.record("retype", getattr(app._cur, "ea", None), word,
- {"kind": kind})
+ app.journal.record(
+ "retype", getattr(app._cur, "ea", None), word, {"kind": kind}
+ )
what = "prototype" if kind == "func" else f"'{word}'"
app._status(f"retyped {what} (Ctrl+S to save)")
@@ -498,15 +527,17 @@ class EditController:
f"data type @ {ea:#x} (e.g. int, char[16], my_struct)"
" — Enter=apply Esc=cancel",
self.default_data_type(head) if head is not None else "int",
- ctx=(view, ea))
+ ctx=(view, ea),
+ )
def submit_make_data(self, ctx, value: str) -> None: # type: ignore[no-untyped-def]
view, ea = ctx
if view is not None and value:
self.app._do_make_data(ea, value, self.app._anchor())
- def do_make_data(self, ea: int, type_decl: str,
- anchor=None) -> None: # worker context
+ def do_make_data(
+ self, ea: int, type_decl: str, anchor=None
+ ) -> None: # worker context
app = self.app
assert app.program is not None
try:
@@ -522,8 +553,7 @@ class EditController:
lm = app.program.listing(ea)
idx = max(lm.ensure_ea(ea), 0) if lm is not None else 0
_cur, top = app._anchor_rows(anchor, lm, ea)
- app.call_from_thread(
- app._open_at, ea, name, idx, False, -1, 0, True, None, top)
+ app.call_from_thread(app._open_at, ea, name, idx, False, -1, 0, True, None, top)
app.call_from_thread(self.edit_done, anchor)
# -- literal display formats (IDA 'o') --------------------------------- #
@@ -554,8 +584,9 @@ class EditController:
fn = view.loaded_ea if view.loaded_ea is not None else app._cur.ea
app._do_op_format(msg.mode, "decomp", fn, view.cursor_x, view.cursor)
- def do_op_format(self, mode: str, where: str, ea: int, col: int,
- line: int = -1) -> None: # worker context
+ def do_op_format(
+ self, mode: str, where: str, ea: int, col: int, line: int = -1
+ ) -> None: # worker context
app = self.app
assert app.program is not None
try:
@@ -584,7 +615,9 @@ class EditController:
app.call_from_thread(
app._status,
f"{what}{fmt} {r.get('value') or ''}"
- f" [{', '.join(r.get('choices', []))}]", True)
+ f" [{', '.join(r.get('choices', []))}]",
+ True,
+ )
return
step = f"{prev} \u2192 {fmt}" if prev and prev != fmt else fmt
desc = f"{what}{step}: {text[:96]}"
@@ -629,13 +662,17 @@ class EditController:
return
app._do_edit_item(msg.kind, ea, app._anchor())
- def do_edit_item(self, kind: str, ea: int,
- anchor=None) -> None: # worker context
+ def do_edit_item(self, kind: str, ea: int, anchor=None) -> None: # worker context
app = self.app
assert app.program is not None
- verb = {"code": "defined code", "func": "created function",
- "undef": "undefined", "string": "made string",
- "thumb": "switched decoding", "thumbscan": "scanned"}[kind]
+ verb = {
+ "code": "defined code",
+ "func": "created function",
+ "undef": "undefined",
+ "string": "made string",
+ "thumb": "switched decoding",
+ "thumbscan": "scanned",
+ }[kind]
try:
if kind == "code":
# Keep going until something stops it: one instruction is rarely
@@ -646,20 +683,24 @@ class EditController:
if n == 0 and why == "defined":
# Already code/data here — a no-op, not a failure. Saying
# "failed to create instruction" for it would be a lie.
- app.call_from_thread(
- app._status, f"already defined @ {ea:#x}")
+ app.call_from_thread(app._status, f"already defined @ {ea:#x}")
return
if n == 0:
- raise IDAToolError("define_code",
- f"@ {ea:#x}: Failed to create instruction")
+ raise IDAToolError(
+ "define_code", f"@ {ea:#x}: Failed to create instruction"
+ )
end = int(str(r.get("end", hex(ea))), 0)
- reason = {"undecodable": "hit bytes that don't decode",
- "flow": "control flow ends here",
- "defined": "ran into existing code/data",
- "segment": "end of segment",
- "limit": "instruction limit"}.get(why, why)
- verb = (f"defined {n} instruction{'s' if n != 1 else ''} "
- f"({ea:#x}\u2013{end:#x}) \u2014 {reason}")
+ reason = {
+ "undecodable": "hit bytes that don't decode",
+ "flow": "control flow ends here",
+ "defined": "ran into existing code/data",
+ "segment": "end of segment",
+ "limit": "instruction limit",
+ }.get(why, why)
+ verb = (
+ f"defined {n} instruction{'s' if n != 1 else ''} "
+ f"({ea:#x}\u2013{end:#x}) \u2014 {reason}"
+ )
elif kind == "thumbscan":
# A vector table is a list of Thumb entry points that IDA won't
# follow on a headerless image, because nothing tells it those
@@ -668,11 +709,15 @@ class EditController:
r = app.program.thumb_scan(ea, ea + 0x400)
n, applied = int(r.get("n", 0)), int(r.get("applied", 0))
if not n:
- verb = (f"no Thumb entry pointers in {ea:#x}\u2013{ea+0x400:#x}"
- " (odd words pointing into the image)")
+ verb = (
+ f"no Thumb entry pointers in {ea:#x}\u2013{ea + 0x400:#x}"
+ " (odd words pointing into the image)"
+ )
else:
- verb = (f"{n} Thumb entr{'y' if n == 1 else 'ies'} found, "
- f"{applied} disassembled")
+ verb = (
+ f"{n} Thumb entr{'y' if n == 1 else 'ies'} found, "
+ f"{applied} disassembled"
+ )
elif kind == "thumb":
# Switch the mode, then disassemble in it: flipping T and
# leaving the bytes undefined shows nothing, and the reason you
@@ -686,11 +731,16 @@ class EditController:
verb += " (segment set to 32-bit; Thumb needs ARM32)"
if r.get("db_64bit"):
# Disassembly will look right and F5 will never work.
- verb += (" \u26a0 this database is 64-bit, so Hex-Rays "
- "won't decompile it \u2014 Ctrl+L and pick "
- "arm:ARMv7-A")
- verb += (f" \u2014 {n} instruction{'s' if n != 1 else ''}"
- if n else " \u2014 still doesn't decode")
+ verb += (
+ " \u26a0 this database is 64-bit, so Hex-Rays "
+ "won't decompile it \u2014 Ctrl+L and pick "
+ "arm:ARMv7-A"
+ )
+ verb += (
+ f" \u2014 {n} instruction{'s' if n != 1 else ''}"
+ if n
+ else " \u2014 still doesn't decode"
+ )
# falls through to the shared reload: same cache bump, same
# anchor restore, same flash. That is the whole point of having
# one path.
@@ -698,9 +748,11 @@ class EditController:
anchor.refresh_functions = True
r = app.program.define_func(ea)
if r.get("start") and r.get("end"):
- verb = (f"created function {r['start']}\u2013{r['end']}"
- + (" (end worked out from the code)"
- if r.get("how") == "explicit-end" else ""))
+ verb = f"created function {r['start']}\u2013{r['end']}" + (
+ " (end worked out from the code)"
+ if r.get("how") == "explicit-end"
+ else ""
+ )
elif kind == "string":
s = app.program.make_string(ea)
verb = f"made string ({s[:24]!r})" if s else verb
@@ -726,13 +778,14 @@ class EditController:
idx = 0 if ea == fn.addr else model.index_of_ea(ea)
_cur, top = app._anchor_rows(anchor, model, ea)
app.call_from_thread(
- app._open_at, fn.addr, fn.name, idx, False, -1, 0, False,
- None, top)
+ app._open_at, fn.addr, fn.name, idx, False, -1, 0, False, None, top
+ )
else:
name = app.program.region_label(ea)
lm = app.program.listing(ea)
idx = max(lm.ensure_ea(ea), 0) if lm is not None else 0
_cur, top = app._anchor_rows(anchor, lm, ea)
app.call_from_thread(
- app._open_at, ea, name, idx, False, -1, 0, True, None, top)
+ app._open_at, ea, name, idx, False, -1, 0, True, None, top
+ )
app.call_from_thread(self.edit_done, anchor)
diff --git a/idatui/errors.py b/idatui/errors.py
index 7632ade..ab1365a 100644
--- a/idatui/errors.py
+++ b/idatui/errors.py
@@ -1,6 +1,6 @@
"""TUI-facing error hierarchy and lightweight database session model.
-The Code Mode adapter normalizes ``ida_codemode`` transport and execution
+The IDA Nexus adapter normalizes ``ida_nexus`` transport and execution
errors into these types so the domain and Textual layers do not depend on HTTP or
registry implementation details.
"""
diff --git a/idatui/kittygfx.py b/idatui/kittygfx.py
index 2f6766d..62fd6a6 100644
--- a/idatui/kittygfx.py
+++ b/idatui/kittygfx.py
@@ -79,8 +79,16 @@ def log(msg: str) -> None:
# Detection
# --------------------------------------------------------------------------- #
def _query_tty(timeout: float = 2.0) -> bool:
- import termios
- import tty as ttymod
+ # ``termios`` and ``/dev/tty`` are POSIX-only. Native Windows terminals
+ # generally don't expose the synchronous reply channel this probe needs;
+ # use the ANSI-art splash there instead of making graphics fatal to the
+ # whole application. IDATUI_KITTY=1 still permits an explicit override.
+ try:
+ import termios
+ import tty as ttymod
+ except ImportError:
+ log("supported: tty queries are unavailable on this platform")
+ return False
try:
fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY)
@@ -144,7 +152,11 @@ def supported() -> bool:
_supported = False # pilot tests, pipes, redirected output
log("supported: stdout is not a tty")
else:
- _supported = _query_tty()
+ try:
+ _supported = _query_tty()
+ except Exception as exc: # graphics are optional on every platform
+ log(f"supported: terminal query failed ({type(exc).__name__}: {exc})")
+ _supported = False
log(f"supported() -> {_supported}")
return _supported
diff --git a/idatui/launch.py b/idatui/launch.py
index e3cb091..a0201a7 100644
--- a/idatui/launch.py
+++ b/idatui/launch.py
@@ -1,6 +1,6 @@
-"""One-shot launcher for the IDA Code Mode-backed TUI.
+"""One-shot launcher for the IDA Nexus-backed TUI.
-A path first resolves to a registered GUI database; when none matches, Code Mode
+A path first resolves to a registered GUI database; when none matches, IDA Nexus
reuses or starts a managed idalib worker. With no path, a single registered
database is selected automatically.
@@ -34,9 +34,9 @@ def _log(msg: str) -> None:
def _registered_databases() -> tuple[list[dict], list[dict]]:
- """Ready and blocked Code Mode registrations, with normalized errors."""
+ """Ready and blocked IDA Nexus registrations, with normalized errors."""
try:
- from ida_codemode import InstanceState, discover_databases
+ from ida_nexus import InstanceState, discover_databases
ready: list[dict] = []
blocked: list[dict] = []
@@ -70,7 +70,7 @@ def main(argv: list[str] | None = None) -> int:
help="open a multi-binary project (created from the given "
"binaries if FILE doesn't exist)")
p.add_argument("--ttl", type=int, default=1800,
- help="deprecated compatibility option (Code Mode uses leases)")
+ help="deprecated compatibility option (IDA Nexus uses leases)")
p.add_argument("--no-keepalive", action="store_true",
help="deprecated compatibility option (the lease is the heartbeat)")
p.add_argument("--rpc", metavar="PATH",
@@ -87,7 +87,7 @@ def main(argv: list[str] | None = None) -> int:
g.add_argument("--base", metavar="ADDR",
help="load address, e.g. 0x8000000 (any base; NOT paragraphs)")
g.add_argument("--ida-args", metavar="STR", dest="ida_args",
- help="legacy switches; only Code Mode-representable -p/-b/-T are accepted")
+ help="legacy switches; only IDA Nexus-representable -p/-b/-T are accepted")
args = p.parse_args(argv)
load: dict = {}
@@ -166,10 +166,10 @@ def main(argv: list[str] | None = None) -> int:
_log(f"attaching to registered {item.get('backend')} database: {binary}")
elif not ready:
detail = f" ({blocked[0].get('error')})" if blocked else ""
- _log(f"no registered Code Mode database; pass a binary path{detail}")
+ _log(f"no registered IDA Nexus database; pass a binary path{detail}")
return 2
else:
- _log("several Code Mode databases are registered; pass one of these paths:")
+ _log("several IDA Nexus databases are registered; pass one of these paths:")
for item in ready:
_log(f" {item.get('exe_path') or item.get('idb_path')} "
f"[{item.get('backend')}, {item.get('record_id')}]")
diff --git a/idatui/nexus_client.py b/idatui/nexus_client.py
new file mode 100644
index 0000000..44eb1b1
--- /dev/null
+++ b/idatui/nexus_client.py
@@ -0,0 +1,551 @@
+"""Client adapter from ida-tui's domain operations to IDA Nexus.
+
+``DatabaseHandle`` is the lifecycle boundary: it discovers an already-registered
+GUI database, reuses a shared managed idalib worker, or starts one when needed.
+The TUI never owns or terminates an IDA process. Closing this client releases
+only its lease.
+
+Remote operations are ordinary typed Python functions declared in
+``idatui.remote_ops``. IDA Nexus installs their content-addressed modules once
+per IDA Python interpreter; subsequent calls send only encoded arguments. The
+optimized IDAPython listing/decompiler implementation remains real source in
+``idatui.remote_tools`` and is installed through the same module interface.
+"""
+
+from __future__ import annotations
+
+import os
+import shlex
+import threading
+import time
+from collections.abc import Callable
+from typing import Any
+
+from .errors import IDAConnectionError, IDATimeoutError, IDAToolError, Session
+
+# ida_nexus is imported EAGERLY-IF-PRESENT but never at hard import cost.
+#
+# The paging/graph/trace layers and their offline test suites must keep importing
+# `idatui` on a machine with no IDA and no IDA Nexus installed -- that is the
+# house rule the stdlib-only worker client used to satisfy for free, and
+# `tests/run.py --fast` (380 checks, any python3) depends on it. A hard top-level
+# import here makes the whole package unimportable, so the failure is deferred to
+# the first operation that genuinely needs the library.
+_NEXUS_ERROR: Exception | None = None
+try:
+ from ida_nexus import (
+ DatabaseBusyError,
+ DatabaseDisconnectedError,
+ DatabaseHandle,
+ DatabaseInstance,
+ DatabaseOpenOptions,
+ NexusConnectionError,
+ RemoteError,
+ find_database_owner,
+ wait_database_released,
+ )
+except ImportError as _exc: # library absent: usable only for offline layers
+ _NEXUS_ERROR = _exc
+ # Bound to None rather than left undefined so the names stay patchable: the
+ # offline contract tests inject a fake DatabaseHandle here.
+ NexusConnectionError = DatabaseDisconnectedError = RemoteError = None # type: ignore[assignment,misc]
+ DatabaseBusyError = DatabaseHandle = DatabaseInstance = None # type: ignore[assignment,misc]
+ DatabaseOpenOptions = find_database_owner = wait_database_released = None # type: ignore[assignment]
+
+
+def _require_nexus() -> None:
+ """Raise an actionable error when the IDA Nexus library is missing.
+
+ Gated on the binding, not on the original import result, so a test that
+ injects a fake ``DatabaseHandle`` exercises the real adapter logic.
+ """
+ if DatabaseHandle is None:
+ raise IDAConnectionError(
+ "ida-nexus is not installed in this environment "
+ f"({_NEXUS_ERROR}). Install it (e.g. `uv sync`, or "
+ "`pip install ida-nexus`) so ida-tui can lease a "
+ "database."
+ ) from _NEXUS_ERROR
+
+
+def database_owner(idb_path: str, staged_path: str | None = None):
+ """The IDA Nexus instance that owns ``idb_path``/``staged_path``, else None.
+
+ Returns None when the IDA Nexus library is absent: with no library there is
+ no client in this environment that could be holding the database, and the
+ IDA-free layers (project staging) must keep working. Discovery errors with
+ the library installed still propagate because unknown ownership is unsafe.
+ """
+ if DatabaseHandle is None:
+ return None
+ if staged_path:
+ owner = find_database_owner(
+ staged_path,
+ output_database=idb_path,
+ timeout=0.5,
+ )
+ return owner or find_database_owner(staged_path, timeout=0.5)
+ return find_database_owner(idb_path, timeout=0.5)
+
+
+def registered_database(path: str, output_database: str | None = None) -> bool:
+ """Whether a live/lock-held IDA Nexus instance owns this target."""
+ _require_nexus()
+ return (
+ find_database_owner(
+ path,
+ output_database=output_database,
+ timeout=0.5,
+ )
+ is not None
+ )
+
+
+class _NoopKeepAlive:
+ """Compatibility shim: the DatabaseHandle's SSE lease is the heartbeat."""
+
+ def __init__(self) -> None:
+ self.beats = self.failures = 0
+
+ def start(self) -> "_NoopKeepAlive":
+ return self
+
+ def stop(self) -> None:
+ pass
+
+
+def _parse_load_args(value: str) -> tuple[str | None, int | None, str | None]:
+ """Translate ida-tui's legacy first-open switches to IDA Nexus options.
+
+ IDA Nexus has typed options for processor, natural loading address and file
+ type. It deliberately has no arbitrary command-line escape hatch; reject
+ switches we cannot represent instead of silently loading a blob wrongly.
+ """
+ processor: str | None = None
+ loading_address: int | None = None
+ file_type: str | None = None
+ unsupported: list[str] = []
+ try:
+ words = shlex.split(value or "", posix=os.name != "nt")
+ except ValueError as exc:
+ raise ValueError(f"invalid IDA load options: {exc}") from exc
+ for word in words:
+ if word.startswith("-p") and len(word) > 2:
+ processor = word[2:]
+ elif word.startswith("-b") and len(word) > 2:
+ try:
+ # IDA's -b is in 16-byte paragraphs. DatabaseHandle expects the
+ # natural address, which is the safer public API.
+ loading_address = int(word[2:], 16) << 4
+ except ValueError as exc:
+ raise ValueError(f"invalid IDA loading address: {word!r}") from exc
+ elif word.startswith("-T") and len(word) > 2:
+ file_type = word[2:]
+ else:
+ unsupported.append(word)
+ if unsupported:
+ joined = " ".join(unsupported)
+ raise ValueError(
+ "ida-nexus cannot represent arbitrary IDA load options: "
+ f"{joined!r}; use processor/base/file type options instead"
+ )
+ return processor, loading_address, file_type
+
+
+class IDBEventListener:
+ """Debounced, closeable delivery of another client's IDB changes.
+
+ IDA Nexus's subscription is a blocking iterator, so one daemon thread reads
+ it and a second waits for a quiet period before handing a batch to the UI.
+ Keeping the debounce here avoids a permanent Textual worker (which would
+ make the app's worker-idle contract impossible) and bounds refresh work to
+ one pass per edit burst.
+ """
+
+ def __init__(
+ self,
+ client: "NexusClient",
+ callback: Callable[[tuple[dict[str, Any], ...]], None],
+ *,
+ on_error: Callable[[BaseException], None] | None = None,
+ debounce: float = 0.2,
+ ) -> None:
+ self._client = client
+ self._callback = callback
+ self._on_error = on_error
+ self._debounce = max(float(debounce), 0.0)
+ self._condition = threading.Condition()
+ self._closed = False
+ self._subscription = None
+ self._pending: list[dict[str, Any]] = []
+ self._deadline = 0.0
+ self._reader = threading.Thread(
+ target=self._read, name="idatui-idb-events", daemon=True
+ )
+ self._deliverer = threading.Thread(
+ target=self._deliver, name="idatui-idb-refresh", daemon=True
+ )
+ self._deliverer.start()
+ self._reader.start()
+
+ def _report(self, error: BaseException) -> None:
+ disconnected = DatabaseDisconnectedError
+ if isinstance(disconnected, type) and isinstance(error, disconnected):
+ error = self._client._connection_error(error)
+ with self._condition:
+ closed = self._closed
+ if not closed and self._on_error is not None:
+ self._on_error(error)
+
+ def _read(self) -> None:
+ try:
+ subscription = self._client.subscribe_idb_events()
+ except Exception as exc: # noqa: BLE001 -- surfaced through on_error
+ self._report(exc)
+ with self._condition:
+ self._closed = True
+ self._pending.clear()
+ self._condition.notify_all()
+ return
+ with self._condition:
+ if self._closed:
+ subscription.close()
+ return
+ self._subscription = subscription
+ try:
+ for event in subscription:
+ with self._condition:
+ if self._closed:
+ break
+ if self._client.owns_event(event):
+ continue
+ with self._condition:
+ if self._closed:
+ break
+ self._pending.append(event)
+ self._deadline = time.monotonic() + self._debounce
+ self._condition.notify_all()
+ except Exception as exc: # noqa: BLE001 -- stream failures are recoverable
+ self._report(exc)
+ finally:
+ subscription.close()
+ with self._condition:
+ if self._subscription is subscription:
+ self._subscription = None
+ self._closed = True
+ self._pending.clear()
+ self._condition.notify_all()
+
+ def _deliver(self) -> None:
+ while True:
+ with self._condition:
+ while not self._closed and not self._pending:
+ self._condition.wait()
+ if self._closed:
+ return
+ remaining = self._deadline - time.monotonic()
+ if remaining > 0:
+ self._condition.wait(remaining)
+ continue
+ batch = tuple(self._pending)
+ self._pending.clear()
+ try:
+ self._callback(batch)
+ except Exception as exc: # noqa: BLE001 -- keep the stream alive
+ self._report(exc)
+
+ def close(self) -> None:
+ """Stop delivery and unblock the subscription reader."""
+ with self._condition:
+ if self._closed:
+ return
+ self._closed = True
+ self._pending.clear()
+ subscription = self._subscription
+ self._condition.notify_all()
+ if subscription is not None:
+ subscription.close()
+
+
+class NexusClient:
+ """A leased GUI/idalib database accessed through ``ida_nexus``."""
+
+ def __init__(
+ self,
+ binary_path: str,
+ *,
+ ttl: int = 0,
+ load_args: str = "",
+ processor: str | None = None,
+ loading_address: int | None = None,
+ file_type: str | None = None,
+ output_database: str | None = None,
+ spawn: bool = True,
+ new_database: bool = False,
+ ) -> None:
+ del ttl # managed-worker lifetime is lease-based, not idle-TTL based
+ self._path = os.path.abspath(os.path.expanduser(binary_path))
+ parsed_processor, parsed_address, parsed_file_type = _parse_load_args(load_args)
+ self._processor = processor or parsed_processor
+ self._loading_address = (
+ loading_address if loading_address is not None else parsed_address
+ )
+ self._file_type = file_type or parsed_file_type
+ self._output_database = output_database
+ self._spawn = spawn
+ self._new_database = new_database
+ self._handle: DatabaseHandle | None = None
+ self._last_instance: DatabaseInstance | None = None
+ self._connect_lock = threading.Lock()
+
+ def connect(self, timeout: float = 1800.0, progress=None) -> "NexusClient":
+ _require_nexus()
+ with self._connect_lock:
+ handle = self._handle
+ if handle is not None:
+ if handle.connected:
+ return self
+ raise IDAConnectionError(
+ "IDA Nexus database disconnected; explicit rediscovery required"
+ )
+ if progress:
+ progress(
+ f"discovering IDA Nexus database for {os.path.basename(self._path)}…"
+ )
+ try:
+ # A Ctrl+L reload releases its current managed-worker lease, but
+ # that worker remains registered during IDA Nexus's final-lease
+ # grace period. Retry only that known handoff window. A GUI or
+ # another long-lived client remains busy and yields a clear
+ # failure rather than being modified underneath its owner.
+ deadline = time.monotonic() + min(timeout, 60.0)
+ while True:
+ try:
+ handle = DatabaseHandle.open(
+ self._path,
+ options=DatabaseOpenOptions(
+ spawn=self._spawn,
+ startup_timeout=max(0.1, timeout),
+ output_database=self._output_database,
+ processor=self._processor,
+ # The natural byte address is converted to IDA's
+ # paragraph-based -b value by IDA Nexus.
+ image_base=self._loading_address,
+ file_type=self._file_type,
+ new_database=self._new_database,
+ ),
+ )
+ break
+ except DatabaseBusyError:
+ if not self._new_database or time.monotonic() >= deadline:
+ raise
+ if progress:
+ progress(
+ "waiting for the previous IDA Nexus lease to close…"
+ )
+ owner = find_database_owner(
+ self._path,
+ output_database=self._output_database,
+ timeout=0.5,
+ )
+ if owner is not None:
+ wait_database_released(
+ owner,
+ max(0.0, deadline - time.monotonic()),
+ )
+ else:
+ time.sleep(0.2)
+ if progress:
+ backend = handle.instance.backend
+ progress(
+ f"attached to {backend} database; waiting for auto-analysis…"
+ )
+ handle.wait_autoanalysis(timeout=timeout)
+ except Exception as exc: # normalize the dependency's transport errors
+ raise self._connection_error(exc) from exc
+ self._handle = handle
+ self._last_instance = handle.instance
+ return self
+
+ @staticmethod
+ def _connection_error(exc: BaseException) -> IDAConnectionError:
+ return IDAConnectionError(str(exc) or type(exc).__name__)
+
+ @property
+ def connected(self) -> bool:
+ return self._handle is not None and self._handle.connected
+
+ @property
+ def pid(self) -> int | None:
+ return self._handle.instance.pid if self._handle is not None else None
+
+ @property
+ def backend(self) -> str | None:
+ return self._handle.instance.backend if self._handle is not None else None
+
+ def owns_event(self, event: dict[str, Any]) -> bool:
+ """Whether ``event`` was produced through this client's handle."""
+ handle = self._handle
+ return handle is not None and handle.owns_event(event)
+
+ def subscribe_idb_events(self):
+ """Open IDA Nexus's closeable IDB-change iterator."""
+ if not self.connected:
+ self.connect()
+ handle = self._handle
+ if handle is None:
+ raise IDAConnectionError("IDA Nexus database is not connected")
+ try:
+ return handle.subscribe_idb_events()
+ except (DatabaseDisconnectedError, NexusConnectionError) as exc:
+ raise self._connection_error(exc) from exc
+
+ def watch_idb_events(
+ self,
+ callback: Callable[[tuple[dict[str, Any], ...]], None],
+ *,
+ on_error: Callable[[BaseException], None] | None = None,
+ debounce: float = 0.2,
+ ) -> IDBEventListener:
+ """Deliver external IDB changes in debounced batches."""
+ return IDBEventListener(self, callback, on_error=on_error, debounce=debounce)
+
+ def call(self, operation: Callable[..., Any], /, **args) -> Any:
+ """Execute one source-backed remote declaration through this client."""
+ name = getattr(operation, "__name__", "remote operation")
+ try:
+ from .remote_ops import bind
+
+ remote = bind(operation)
+ except KeyError as exc:
+ raise IDAToolError(
+ name, f"remote operation {name!r} is not registered"
+ ) from exc
+ if not self.connected:
+ self.connect()
+ handle = self._handle
+ if handle is None:
+ raise IDAConnectionError("IDA Nexus database is not connected")
+ try:
+ return remote(handle, **args)
+ except RemoteError as exc:
+ message = str(exc)
+ if exc.details.get("traceback"):
+ message += f"\n{exc.details['traceback']}"
+ if exc.code == "operation_timeout":
+ raise IDATimeoutError(message) from exc
+ raise IDAToolError(name, message) from exc
+ except (DatabaseDisconnectedError, NexusConnectionError) as exc:
+ raise self._connection_error(exc) from exc
+
+ def save_database(self) -> dict[str, Any]:
+ if not self.connected:
+ self.connect()
+ handle = self._handle
+ if handle is None:
+ raise IDAConnectionError("IDA Nexus database is not connected")
+ try:
+ return handle.save_database()
+ except RemoteError as exc:
+ raise IDAToolError("save_database", str(exc)) from exc
+ except (DatabaseDisconnectedError, NexusConnectionError) as exc:
+ raise self._connection_error(exc) from exc
+
+ def discard_database(self, timeout: float = 5.0) -> bool:
+ """Discard a final managed-worker lease; otherwise transfer finalization.
+
+ ``False`` is an expected ownership result: a GUI owns its session, or
+ another lease still shares the managed worker. A busy final worker is
+ retried briefly so background reads finishing during quit do not turn a
+ real discard into an implicit save.
+ """
+ handle = self._handle
+ if handle is None or not handle.connected:
+ return False
+ entry = handle.instance
+ if entry.backend != "idalib" or not getattr(entry, "managed", False):
+ return False
+ deadline = time.monotonic() + max(float(timeout), 0.0)
+ while True:
+ try:
+ handle.shutdown_database(save=False)
+ return True
+ except RemoteError as exc:
+ if exc.code in ("instance_shared", "shutdown_not_supported"):
+ return False
+ if exc.code == "instance_busy" and time.monotonic() < deadline:
+ time.sleep(0.05)
+ continue
+ raise IDAToolError("shutdown_database", str(exc)) from exc
+ except (DatabaseDisconnectedError, NexusConnectionError) as exc:
+ raise self._connection_error(exc) from exc
+
+ def health(self) -> dict[str, Any]:
+ if not self.connected:
+ self.connect()
+ assert self._handle is not None
+ entry = self._handle.instance
+ module = os.path.basename(entry.exe_path or entry.idb_path or self._path)
+ return {
+ "ok": self._handle.connected,
+ "module": module,
+ "backend": entry.backend,
+ "record_id": entry.record_id,
+ "input_path": entry.exe_path,
+ "idb_path": entry.idb_path,
+ }
+
+ def keepalive(self, interval: float = 120.0) -> _NoopKeepAlive:
+ del interval
+ return _NoopKeepAlive()
+
+ def resolve_db(self) -> str:
+ if not self.connected:
+ self.connect()
+ assert self._handle is not None
+ return self._handle.instance.record_id
+
+ def set_db(self, db: str | None) -> None:
+ del db # one handle is permanently bound to one registered database
+
+ def list_sessions(self) -> list[Session]:
+ if not self.connected:
+ self.connect()
+ assert self._handle is not None
+ entry = self._handle.instance
+ path = entry.exe_path or entry.idb_path or self._path
+ return [
+ Session(
+ session_id=entry.record_id,
+ filename=os.path.basename(path),
+ input_path=path,
+ is_active=True,
+ )
+ ]
+
+ def close(self, grace: float = 0.0) -> None:
+ del grace
+ with self._connect_lock:
+ handle, self._handle = self._handle, None
+ if handle is not None:
+ self._last_instance = handle.instance
+ handle.close() # release our lease; never close a GUI/other client's DB
+
+ def wait_released(self, timeout: float = 45.0) -> bool:
+ """Wait until a managed instance releases its lifetime lock.
+
+ Normal application shutdown must not wait: another client may retain the
+ worker. This is an explicit test/maintenance helper for deleting a
+ temporary IDB safely after this client closes. GUI instances return
+ ``False`` immediately because clients never own their lifetime.
+ """
+ instance = self._last_instance
+ if instance is None or instance.backend != "idalib":
+ return False
+ return wait_database_released(instance, timeout)
+
+ def __enter__(self) -> "NexusClient":
+ return self.connect()
+
+ def __exit__(self, *exc) -> None:
+ self.close()
diff --git a/idatui/pane.py b/idatui/pane.py
index c31a93c..1eee847 100644
--- a/idatui/pane.py
+++ b/idatui/pane.py
@@ -24,7 +24,7 @@ per pane in the registry, so stop/list/capture/keys keep working across both
python -m idatui.pane keys --pane <pane> Escape
Requires: running inside tmux or zellij. Each pane leases a registered GUI or
-shared managed idalib database through Code Mode. Uses ~/ida-venv/bin/python for
+shared managed idalib database through IDA Nexus. Uses ~/ida-venv/bin/python for
the TUI (needs textual) unless --python / IDATUI_PYTHON says otherwise.
"""
from __future__ import annotations
@@ -250,8 +250,8 @@ def _pane_keys(pane: str, keys: list[str], mux: str | None = None) -> None:
subprocess.run(["tmux", "send-keys", "-t", pane, *keys], check=True)
-# Code Mode owns database process lifetime: a closed pane drops its lease at the
-# socket/kernel boundary and Code Mode decides whether a managed worker still
+# IDA Nexus owns database process lifetime: a closed pane drops its lease at the
+# socket/kernel boundary and IDA Nexus decides whether a managed worker still
# has clients. There is nothing for the pane layer to reap.
@@ -261,7 +261,7 @@ def _count_live_panes() -> int:
def _reap_orphan_workers(force: bool = False) -> int:
- """Compatibility no-op: Code Mode workers are shared and lease-managed."""
+ """Compatibility no-op: IDA Nexus workers are shared and lease-managed."""
del force
return 0
@@ -294,7 +294,7 @@ def spawn(args) -> int:
print(f"error: no such project: {project}", file=sys.stderr)
return 2
- # The pane owns only the TUI. Code Mode's lease cleanup handles crashes;
+ # The pane owns only the TUI. IDA Nexus's lease cleanup handles crashes;
# kill-pane must never reap a shared GUI/idalib database.
if project is not None:
# launch takes: --project FILE [binaries...]; extra binaries are added to
@@ -347,7 +347,7 @@ def _wait_ready(sock: str, timeout: float, pane: str,
stuck_after: float = 45.0, mux: str | None = None) -> dict[str, Any]:
"""Poll the socket + ping until the TUI reports ready (or timeout).
- Emits a one-time hint if Code Mode discovery/opening is still not ready after
+ Emits a one-time hint if IDA Nexus discovery/opening is still not ready after
``stuck_after`` seconds.
"""
start = time.time()
@@ -370,7 +370,7 @@ def _wait_ready(sock: str, timeout: float, pane: str,
why = ("RPC socket not created yet" if not os.path.exists(sock)
else "TUI up but analysis not ready")
print(f"still waiting ({int(time.time() - start)}s): {why}. "
- f"Check Code Mode registrations and worker logs.", file=sys.stderr)
+ f"Check IDA Nexus registrations and worker logs.", file=sys.stderr)
time.sleep(0.4)
last = dict(last)
last["ready"] = False
@@ -467,7 +467,7 @@ def list_panes(args) -> int:
def reap(args) -> int:
- """Deprecated no-op; shared Code Mode workers are managed by leases."""
+ """Deprecated no-op; shared IDA Nexus workers are managed by leases."""
print(json.dumps({"reaped_workers": 0, "live_panes": _count_live_panes(),
"forced": args.force, "deprecated": True}))
return 0
@@ -572,7 +572,7 @@ def main(argv: list[str]) -> int:
ls.add_argument("--prune", action="store_true", help="drop dead panes (and their sockets)")
ls.set_defaults(fn=list_panes)
- rp = sub.add_parser("reap", help="deprecated no-op (Code Mode uses shared leases)")
+ rp = sub.add_parser("reap", help="deprecated no-op (IDA Nexus uses shared leases)")
rp.add_argument("--force", action="store_true", help=argparse.SUPPRESS)
rp.set_defaults(fn=reap)
diff --git a/idatui/pool.py b/idatui/pool.py
index 465dff2..2407b44 100644
--- a/idatui/pool.py
+++ b/idatui/pool.py
@@ -1,6 +1,6 @@
-"""DatabasePool — LRU leases on Code Mode databases for a project.
+"""DatabasePool — LRU leases on IDA Nexus databases for a project.
-Code Mode may bind a lease to an existing IDA GUI or to a shared managed idalib
+IDA Nexus may bind a lease to an existing IDA GUI or to a shared managed idalib
worker. The pool therefore owns *client interest*, never an IDA process. Releasing
an LRU entry persists managed IDBs but does not implicitly save a GUI, then closes
only this TUI's lease; other clients and GUI sessions remain alive. Managed workers exit themselves after their final lease.
@@ -48,8 +48,8 @@ def _pss_mb(pid: int | None) -> int:
def _default_spawn(ref: BinaryRef, ttl: int, *, new_database: bool = False): # pragma: no cover - needs IDA
- from .codemode_client import CodeModeClient
- return CodeModeClient(
+ from .nexus_client import NexusClient
+ return NexusClient(
ref.staged,
ttl=ttl,
load_args=ref.load_args,
@@ -59,7 +59,7 @@ def _default_spawn(ref: BinaryRef, ttl: int, *, new_database: bool = False): #
class DatabasePool:
- """Live Code Mode database leases, keyed by project label."""
+ """Live IDA Nexus database leases, keyed by project label."""
def __init__(self, project: Project, *, budget_mb: int | None = None,
ttl: int = 1800, spawn=None, mem_fn=None) -> None:
@@ -101,7 +101,7 @@ class DatabasePool:
"""A live client for ``label``, attaching or spawning as needed.
Do not sweep IDA scratch files here: a registered GUI or another Code
- Mode client may own the database. Code Mode's registry locks and health
+ Mode client may own the database. IDA Nexus's registry locks and health
probes are the authority for safe discovery and stale-record cleanup.
"""
client = self._clients.get(label)
@@ -225,6 +225,28 @@ class DatabasePool:
self.evict(label, save=save, save_gui=save)
self.active = None
+ def discard_changes(self, labels: list[str]) -> list[str]:
+ """Discard final managed sessions; return labels whose owner remains.
+
+ A returned label is not an error: its client is attached to a GUI or a
+ still-shared worker, so releasing our lease transfers finalization to
+ that session's owner or remaining clients.
+ """
+ transferred: list[str] = []
+ for label in labels:
+ client = self._clients.get(label)
+ if client is not None and not client.discard_database():
+ transferred.append(label)
+ return transferred
+
+ def replace_client(self, label: str, old, new) -> bool:
+ """Replace one disconnected lease without changing residency policy."""
+ if self._clients.get(label) is not old:
+ return False
+ self._clients[label] = new
+ self._touch(label)
+ return True
+
# -- introspection ------------------------------------------------------ #
def status(self) -> list[dict]:
"""Per-binary residency for the switcher UI."""
@@ -247,5 +269,3 @@ class DatabasePool:
f"{self.memory_mb()}/{self.budget_mb}MB active={self.active}>")
-# Source compatibility for callers that imported the pre-Code-Mode name.
-WorkerPool = DatabasePool
diff --git a/idatui/project.py b/idatui/project.py
index 53f3b0a..e681dfc 100644
--- a/idatui/project.py
+++ b/idatui/project.py
@@ -23,7 +23,7 @@ firmware image, a cleaned build tree).
A source whose size/mtime no longer matches the staged copy is re-staged, and its
now-stale database is dropped (the DB describes the old bytes).
-The model has no IDA imports. Staging consults ida_codemode's registry before
+The model has no IDA imports. Staging consults ida_nexus's registry before
replacing files so it never mutates a database owned by a GUI/shared worker.
"""
from __future__ import annotations
@@ -57,7 +57,7 @@ class BinaryRef:
#: it is, a raw firmware image doesn't, and IDA defaults to metapc at 0.
processor: str = "" # IDA processor name: arm, armb, mipsb, metapc, …
base: int = 0 # load address (natural, e.g. 0x8000000)
- ida_args: str = "" # legacy -p/-b/-T switches accepted by Code Mode adapter
+ ida_args: str = "" # legacy -p/-b/-T switches accepted by IDA Nexus adapter
@property
def db(self) -> str:
@@ -311,7 +311,7 @@ class Project:
"""Ensure ``ref`` is staged in the sidecar; returns the staged path.
Re-staging a changed source drops its database: the DB describes the old
- bytes. Refuse while Code Mode reports a GUI/idalib owner; replacing a
+ bytes. Refuse while IDA Nexus reports a GUI/idalib owner; replacing a
staged executable or IDB underneath a shared live instance is corruption.
"""
if not os.path.isfile(ref.source):
@@ -319,15 +319,15 @@ class Project:
if not self.is_stale(ref):
return ref.staged
try:
- from .codemode_client import database_owner
+ from .nexus_client import database_owner
owner = database_owner(ref.db, ref.staged)
except Exception as exc:
raise ProjectError(
- f"cannot verify Code Mode ownership before staging {ref.label}: {exc}"
+ f"cannot verify IDA Nexus ownership before staging {ref.label}: {exc}"
) from exc
if owner is not None:
raise ProjectError(
- f"cannot restage {ref.label}: Code Mode instance {owner.record_id} "
+ f"cannot restage {ref.label}: IDA Nexus instance {owner.record_id} "
f"still owns {owner.idb_path}; close/release it first"
)
os.makedirs(self.bin_dir, exist_ok=True)
@@ -353,7 +353,7 @@ class Project:
def sweep_scratch(self, ref: BinaryRef) -> int:
"""Delete unpacked working files (never the ``.i64``) for maintenance.
- Runtime paths no longer call this: Code Mode instances are shared, so a
+ Runtime paths no longer call this: IDA Nexus instances are shared, so a
registry owner may still be using these files. Callers must independently
prove that no GUI/idalib instance owns the database.
"""
diff --git a/idatui/remote_ops.py b/idatui/remote_ops.py
new file mode 100644
index 0000000..a67b2b7
--- /dev/null
+++ b/idatui/remote_ops.py
@@ -0,0 +1,1683 @@
+"""Typed remote operations executed through ida-nexus."""
+
+from __future__ import annotations
+# ruff: noqa
+
+import threading
+from collections.abc import Callable
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from ida_domain import Database
+
+
+def operation_label() -> str:
+ """Display attribution for the current call; ready for per-user context."""
+ return "IDA TUI"
+
+
+def data_type(db: Database, **a: Any) -> Any:
+ ea = int(str(a["addr"]), 16)
+ try:
+ tif = db.types.get_at(ea)
+ fn = db.functions.get_at(ea)
+ result = {
+ "addr": hex(ea),
+ "name": db.names.get_at(ea) or "",
+ "type": tif.dstr() if tif else "",
+ "size": int(db.heads.size(ea)) if db.heads.is_head(ea) else 0,
+ "is_func": bool(fn),
+ }
+ except Exception as exc:
+ result = {"addr": hex(ea), "error": str(exc)}
+ return result
+
+
+def declare_type(db: Database, **a: Any) -> Any:
+ import ida_typeinf
+
+ decls = a.get("decls", "")
+ if isinstance(decls, str):
+ decls = [decls]
+ rows = []
+ for declaration in decls:
+ try:
+ errors = int(
+ db.types.parse_declarations(ida_typeinf.get_idati(), declaration)
+ )
+ rows.append(
+ {
+ "ok": errors == 0,
+ **({} if errors == 0 else {"error": f"{errors} parse error(s)"}),
+ }
+ )
+ except Exception as exc:
+ rows.append({"ok": False, "error": str(exc)})
+ result = {"result": rows}
+ return result
+
+
+def decomp_error(db: Database, **a: Any) -> Any:
+ import ida_hexrays, ida_ida
+
+ ea = int(str(a["addr"]), 16)
+ fn = db.functions.get_at(ea)
+ result = {"addr": hex(ea), "bitness": ida_ida.inf_get_app_bitness()}
+ if fn is None:
+ result["reason"] = "no function here"
+ else:
+ try:
+ failure = ida_hexrays.hexrays_failure_t()
+ cfunc = ida_hexrays.decompile_func(fn, failure)
+ if cfunc is not None:
+ result["reason"] = ""
+ else:
+ result.update(
+ {
+ "reason": failure.desc() or f"error {failure.code}",
+ "code": int(failure.code),
+ "errea": hex(int(failure.errea)),
+ }
+ )
+ except Exception as exc:
+ result["reason"] = f"{type(exc).__name__}: {exc}"
+ return result
+
+
+def define_code(db: Database, **a: Any) -> Any:
+ import ida_ua
+
+ rows = []
+ for item in a.get("items", []):
+ ea = int(str(item["addr"]), 16)
+ size = int(ida_ua.create_insn(ea))
+ rows.append(
+ {
+ "addr": hex(ea),
+ "ok": size > 0,
+ "size": size,
+ **({} if size > 0 else {"error": "instruction did not decode"}),
+ }
+ )
+ result = {"result": rows}
+ return result
+
+
+def define_code_run(db: Database, **a: Any) -> Any:
+ import ida_bytes, ida_idp, ida_segment, ida_ua, idaapi
+
+ ea, limit = int(str(a["addr"]), 16), max(1, min(int(a.get("limit", 20000)), 200000))
+ seg = ida_segment.getseg(ea)
+ if seg is None:
+ result = {"addr": a["addr"], "error": "no segment", "count": 0}
+ else:
+ start, count, stopped, hi = ea, 0, "limit", int(seg.end_ea)
+ while count < limit:
+ if ea >= hi:
+ stopped = "segment"
+ break
+ flags = ida_bytes.get_flags(ea)
+ if ida_bytes.is_code(flags) or ida_bytes.is_data(flags):
+ stopped = "defined"
+ break
+ size = int(ida_ua.create_insn(ea))
+ if size <= 0:
+ stopped = "undecodable"
+ break
+ count += 1
+ insn = ida_ua.insn_t()
+ if ida_ua.decode_insn(insn, ea) > 0:
+ try:
+ is_ret = bool(ida_idp.is_ret_insn(insn))
+ except Exception:
+ is_ret = False
+ if is_ret or (insn.get_canon_feature() & idaapi.CF_STOP):
+ ea += size
+ stopped = "flow"
+ break
+ ea += size
+ result = {
+ "start": hex(start),
+ "end": hex(ea),
+ "count": count,
+ "stopped": stopped,
+ }
+ return result
+
+
+def define_func(db: Database, **a: Any) -> Any:
+ rows = []
+ for item in a.get("items", []):
+ ea = int(str(item["addr"]), 16)
+ ok = bool(db.functions.create(ea))
+ rows.append(
+ {
+ "addr": hex(ea),
+ "ok": ok,
+ **({} if ok else {"error": "IDA refused the function"}),
+ }
+ )
+ result = {"result": rows}
+ return result
+
+
+def define_func_run(db: Database, **a: Any) -> Any:
+ import ida_bytes, ida_funcs, ida_segment
+
+ ea = int(str(a["addr"]), 16)
+ fn = db.functions.get_at(ea)
+ if fn is not None and int(fn.start_ea) == ea:
+ result = {
+ "addr": hex(ea),
+ "ok": True,
+ "start": hex(ea),
+ "end": hex(int(fn.end_ea)),
+ "how": "existed",
+ }
+ else:
+ automatic = bool(db.functions.create(ea))
+ if not automatic:
+ seg = db.segments.get_at(ea)
+ end = ea
+ hi = int(seg.end_ea) if seg else ea
+ while end < hi and ida_bytes.is_code(ida_bytes.get_flags(end)):
+ nxt = int(ida_bytes.get_item_end(end))
+ if nxt <= end:
+ break
+ end = nxt
+ ok = bool(end > ea and ida_funcs.add_func(ea, end))
+ else:
+ ok = True
+ fn = db.functions.get_at(ea)
+ result = (
+ {
+ "addr": hex(ea),
+ "ok": True,
+ "start": hex(int(fn.start_ea)),
+ "end": hex(int(fn.end_ea)),
+ "how": "auto" if automatic else "explicit-end",
+ }
+ if ok and fn is not None
+ else {
+ "addr": hex(ea),
+ "ok": False,
+ "error": f"IDA refused a function at {ea:#x}",
+ }
+ )
+ return result
+
+
+def del_type(db: Database, **a: Any) -> Any:
+ import ida_typeinf
+
+ name = str(a["name"])
+ ok = bool(
+ ida_typeinf.del_named_type(ida_typeinf.get_idati(), name, ida_typeinf.NTF_TYPE)
+ )
+ result = {
+ "name": name,
+ "deleted": ok,
+ **({} if ok else {"error": f"Type {name!r} not found or could not be deleted"}),
+ }
+ return result
+
+
+def disasm(db: Database, **a: Any) -> Any:
+ ea = int(str(a["addr"]), 16)
+ fn = db.functions.get_at(ea)
+ if fn is None:
+ result = {"instructions": [], "total_instructions": 0, "instruction_count": 0}
+ else:
+ instructions = list(db.functions.get_instructions(fn))
+ limit = max(1, int(a.get("max_instructions", len(instructions) or 1)))
+ rows = [
+ {
+ "addr": hex(int(insn.ea)),
+ "instruction": db.instructions.get_disassembly(insn),
+ }
+ for insn in instructions[:limit]
+ ]
+ result = {
+ "instructions": rows,
+ "total_instructions": len(instructions),
+ "instruction_count": len(instructions),
+ }
+ return result
+
+
+def file_regions(db: Database, **a: Any) -> Any:
+ import idaapi
+
+ rows = []
+ for seg in db.segments.get_all():
+ try:
+ file_off = int(idaapi.get_fileregion_offset(seg.start_ea))
+ except Exception:
+ file_off = -1
+ if file_off < 0 or file_off >= (1 << 48):
+ file_off = -1
+ rows.append(
+ {
+ "start": hex(int(seg.start_ea)),
+ "end": hex(int(seg.end_ea)),
+ "file_off": file_off,
+ "name": db.segments.get_name(seg) or "",
+ }
+ )
+ result = {"regions": rows}
+ return result
+
+
+def flowchart(db: Database, **a: Any) -> Any:
+ import ida_funcs, ida_gdl
+
+ ea = int(str(a["addr"]), 16)
+ fn = ida_funcs.get_func(ea)
+ if fn is None:
+ result = {"addr": hex(ea), "error": "no function at that address", "blocks": []}
+ else:
+ fc = ida_gdl.FlowChart(fn, flags=ida_gdl.FC_PREDS)
+ index, order = {}, []
+ for bb in fc:
+ index[bb.start_ea] = len(order)
+ order.append(bb)
+ blocks = []
+ for bb in order:
+ sl = [s for s in bb.succs() if s.start_ea in index]
+ succs = []
+ for s in sl:
+ # Edge kind is what the graph view colours by: an n-way dispatch is
+ # "switch", a successor that is literally the next address falls
+ # through, anything else is a taken branch.
+ if len(sl) > 2:
+ kind = "switch"
+ elif s.start_ea == bb.end_ea:
+ kind = "fall"
+ else:
+ kind = "jump"
+ succs.append([index[s.start_ea], kind])
+ blocks.append(
+ {
+ "id": index[bb.start_ea],
+ "start": hex(int(bb.start_ea)),
+ "end": hex(int(bb.end_ea)),
+ "succs": succs,
+ }
+ )
+ result = {
+ "addr": hex(ea),
+ "func": {
+ "addr": hex(int(fn.start_ea)),
+ "end": hex(int(fn.end_ea)),
+ "name": ida_funcs.get_func_name(fn.start_ea) or "",
+ },
+ "entry": index.get(fn.start_ea, 0),
+ "blocks": blocks,
+ }
+ return result
+
+
+def force_recompile(db: Database, **a: Any) -> Any:
+ import ida_hexrays
+
+ rows = []
+ for item in a.get("items", []):
+ ea = int(str(item["addr"]), 16)
+ ida_hexrays.mark_cfunc_dirty(ea, False)
+ rows.append({"addr": hex(ea), "ok": True})
+ result = {"result": rows}
+ return result
+
+
+def func_types(db: Database, **a: Any) -> Any:
+ import ida_typeinf
+
+ ea = int(str(a["addr"]), 16)
+ fn = db.functions.get_at(ea)
+ if fn is None:
+ result = {"addr": a["addr"], "error": "no function at address"}
+ else:
+ pseudo = db.pseudocode.decompile(fn)
+ name = db.functions.get_name(fn) or ""
+ tif = pseudo.get_func_type()
+ try:
+ prototype = (
+ ida_typeinf.print_tinfo(
+ "", 0, 0, ida_typeinf.PRTYPE_1LINE, tif, name, ""
+ )
+ if tif
+ else ""
+ )
+ except Exception:
+ prototype = tif.dstr() if tif else ""
+ lvars = [
+ {
+ "name": var.name,
+ "type": var.type_info.dstr() if var.type_info else "",
+ "is_arg": bool(var.is_arg),
+ }
+ for var in pseudo.local_variables
+ ]
+ result = {
+ "addr": hex(int(fn.start_ea)),
+ "name": name,
+ "prototype": (prototype or "").strip(),
+ "lvars": lvars,
+ }
+ return result
+
+
+def get_bytes(db: Database, **a: Any) -> Any:
+ rows = []
+ for region in a.get("regions", []):
+ ea, size = int(str(region["addr"]), 16), int(region["size"])
+ raw = db.bytes.get_bytes_at(ea, size) or b""
+ rows.append({"addr": region["addr"], "data": " ".join(f"{b:02x}" for b in raw)})
+ result = {"result": rows}
+ return result
+
+
+def journal_get(db: Database, **a: Any) -> Any:
+ import ida_netnode
+
+ n = ida_netnode.netnode(a.get("node", "$ idatui.journal"))
+ blob = n.getblob(0, "I") if ida_netnode.exist(n) else None
+ result = {"data": blob.decode("utf-8", "replace") if blob else ""}
+ return result
+
+
+def journal_put(db: Database, **a: Any) -> Any:
+ import ida_netnode
+
+ n = ida_netnode.netnode(a.get("node", "$ idatui.journal"), 0, True)
+ payload = (a.get("data") or "").encode("utf-8")
+ n.setblob(payload, 0, "I")
+ result = {"ok": True, "bytes": len(payload)}
+ return result
+
+
+def list_annotations(db: Database, **a: Any) -> Any:
+ import ida_bytes, ida_funcs, ida_lines, ida_nalt, ida_name
+ import ida_segment, ida_typeinf, idautils
+
+ limit = max(1, int(a.get("limit", 4000)))
+ max_scan = max(1000, int(a.get("max_scan", 2000000)))
+ comments, names = [], []
+ scanned = 0
+
+ def _line(ea):
+ try:
+ txt = ida_lines.generate_disasm_line(ea, ida_lines.GENDSM_REMOVE_TAGS)
+ except Exception:
+ txt = ""
+ return " ".join((txt or "").split())
+
+ for ea, nm in idautils.Names():
+ if len(names) >= limit:
+ break
+ if not nm or not ida_bytes.has_user_name(ida_bytes.get_flags(ea)):
+ continue
+ fn = ida_funcs.get_func(ea)
+ is_fn = fn is not None and int(fn.start_ea) == int(ea)
+ proto = None
+ if is_fn:
+ try:
+ ti = ida_typeinf.tinfo_t()
+ if ida_nalt.get_tinfo(ti, ea):
+ proto = str(ti)
+ except Exception:
+ proto = None
+ seg = ida_segment.getseg(ea)
+ names.append(
+ {
+ "addr": hex(int(ea)),
+ "name": nm,
+ "func": is_fn,
+ "size": (int(fn.end_ea - fn.start_ea) if is_fn else 0),
+ "proto": proto,
+ "seg": (ida_segment.get_segm_name(seg) if seg else ""),
+ }
+ )
+
+ for i in range(ida_segment.get_segm_qty()):
+ seg = ida_segment.getnseg(i)
+ if seg is None or len(comments) >= limit or scanned >= max_scan:
+ continue
+ for ea in idautils.Heads(seg.start_ea, seg.end_ea):
+ scanned += 1
+ if len(comments) >= limit or scanned >= max_scan:
+ break
+ if not ida_bytes.has_cmt(ida_bytes.get_flags(ea)):
+ continue
+ for rep in (False, True):
+ text = ida_bytes.get_cmt(ea, rep)
+ if text:
+ fn = ida_funcs.get_func(ea)
+ comments.append(
+ {
+ "addr": hex(int(ea)),
+ "text": text,
+ "repeatable": rep,
+ "line": _line(ea),
+ "seg": ida_segment.get_segm_name(seg),
+ "func": (
+ ida_funcs.get_func_name(fn.start_ea) if fn else None
+ ),
+ "func_addr": (hex(int(fn.start_ea)) if fn else None),
+ }
+ )
+
+ # Whole-function comments are not on the byte flags, so the scan cannot see them.
+ for fn_ea in idautils.Functions():
+ fn = ida_funcs.get_func(fn_ea)
+ if fn is None or len(comments) >= limit:
+ continue
+ for rep in (False, True):
+ text = ida_funcs.get_func_cmt(fn, rep)
+ if text:
+ seg = ida_segment.getseg(fn_ea)
+ comments.append(
+ {
+ "addr": hex(int(fn_ea)),
+ "text": text,
+ "repeatable": rep,
+ "line": "",
+ "whole_func": True,
+ "seg": (ida_segment.get_segm_name(seg) if seg else ""),
+ "func": ida_funcs.get_func_name(fn_ea),
+ "func_addr": hex(int(fn_ea)),
+ }
+ )
+ result = {
+ "comments": comments,
+ "names": names,
+ "scanned": scanned,
+ "truncated": (
+ len(comments) >= limit or len(names) >= limit or scanned >= max_scan
+ ),
+ }
+ return result
+
+
+def list_funcs(db: Database, **a: Any) -> Any:
+ import fnmatch
+
+ queries = a.get("queries") or [{}]
+ q = queries[0]
+ offset, count = max(0, int(q.get("offset", 0))), max(1, int(q.get("count", 500)))
+ pattern = str(q.get("filter") or "").lower()
+ if pattern and not any(ch in pattern for ch in "*?["):
+ pattern = "*" + pattern + "*"
+ rows = []
+ for fn in db.functions.get_all():
+ name = db.functions.get_name(fn) or f"sub_{int(fn.start_ea):X}"
+ if pattern and not fnmatch.fnmatchcase(name.lower(), pattern):
+ continue
+ rows.append(
+ {
+ "addr": hex(int(fn.start_ea)),
+ "name": name,
+ "size": int(fn.end_ea) - int(fn.start_ea),
+ }
+ )
+ page = rows[offset : offset + count]
+ result = {
+ "result": [
+ {"data": page, "next_offset": offset + len(page), "total": len(rows)}
+ ]
+ }
+ return result
+
+
+def list_linkage(db: Database, **a: Any) -> Any:
+ imports = [
+ {"addr": hex(int(item.address)), "name": item.name, "module": item.module_name}
+ for item in db.imports.get_all_imports()
+ if item.name
+ ]
+ exports = [
+ {
+ "addr": hex(int(item.address)),
+ "name": item.name,
+ "ordinal": int(item.ordinal),
+ }
+ for item in db.entries.get_all()
+ if item.name
+ ]
+ result = {
+ "imports": imports,
+ "exports": exports,
+ "n_imports": len(imports),
+ "n_exports": len(exports),
+ }
+ return result
+
+
+def list_strings(db: Database, **a: Any) -> Any:
+ from ida_domain.strings import StringListConfig
+
+ offset, count, min_len = (
+ max(0, int(a.get("offset", 0))),
+ max(1, int(a.get("count", 2000))),
+ max(1, int(a.get("min_len", 4))),
+ )
+ if offset == 0 or a.get("refresh"):
+ from ida_domain.strings import StringType
+
+ db.strings.rebuild(
+ StringListConfig(
+ string_types=list(StringType), min_len=min_len, only_ascii_7bit=False
+ )
+ )
+ items = list(db.strings.get_all())
+ page = items[offset : offset + count]
+ rows = []
+ for item in page:
+ try:
+ text = str(item)
+ except Exception:
+ text = item.contents.decode("utf-8", "replace") if item.contents else ""
+ rows.append(
+ {
+ "addr": hex(int(item.address)),
+ "text": text,
+ "len": int(item.length),
+ "type": item.type.name,
+ }
+ )
+ result = {"strings": rows, "total": len(items), "next_offset": offset + len(rows)}
+ return result
+
+
+def lookup_funcs(db: Database, **a: Any) -> Any:
+ rows = []
+ for query in a.get("queries", []):
+ raw = str(query)
+ try:
+ ea = int(raw, 16)
+ except ValueError:
+ fn = db.functions.get_by_name(raw)
+ ea = int(fn.start_ea) if fn else None
+ else:
+ fn = db.functions.get_at(ea)
+ if fn is None:
+ rows.append({"query": raw, "fn": None})
+ else:
+ rows.append(
+ {
+ "query": raw,
+ "fn": {
+ "addr": hex(int(fn.start_ea)),
+ "name": db.functions.get_name(fn)
+ or f"sub_{int(fn.start_ea):X}",
+ "size": int(fn.end_ea) - int(fn.start_ea),
+ },
+ }
+ )
+ result = {"result": rows}
+ return result
+
+
+def make_data(db: Database, **a: Any) -> Any:
+ import ida_bytes, ida_idaapi, ida_typeinf
+ from ida_domain.types import TypeApplyFlags
+
+ rows = []
+ for item in a.get("items", []):
+ ea, declaration = int(str(item["addr"]), 16), str(item["type"])
+ try:
+ tif = db.types.parse_one_declaration(ida_typeinf.get_idati(), declaration)
+ size = max(1, int(tif.get_size()))
+ saved_names = [
+ (addr, name)
+ for addr, name in db.names.get_all()
+ if ea <= int(addr) < ea + size
+ ]
+ ida_bytes.del_items(
+ ea,
+ ida_bytes.DELIT_EXPAND | ida_bytes.DELIT_DELNAMES,
+ max(size, int(ida_bytes.get_item_size(ea) or 1)),
+ )
+ created = bool(
+ ida_bytes.create_data(ea, ida_bytes.FF_BYTE, size, ida_idaapi.BADADDR)
+ )
+ ok = created and bool(db.types.apply_at(tif, ea, TypeApplyFlags.DEFINITE))
+ for address, name in saved_names:
+ db.names.set_name(int(address), name)
+ if ok and item.get("name"):
+ ok = bool(db.names.set_name(ea, str(item["name"])))
+ rows.append(
+ {
+ "addr": hex(ea),
+ "ok": ok,
+ "size": size,
+ **({} if ok else {"error": "IDA rejected the data type"}),
+ }
+ )
+ except Exception as exc:
+ rows.append({"addr": hex(ea), "ok": False, "error": str(exc)})
+ result = {"result": rows}
+ return result
+
+
+def make_string(db: Database, **a: Any) -> Any:
+ from ida_domain.strings import StringType
+
+ ea, length = int(str(a["addr"]), 16), max(0, int(a.get("length", 0)))
+ kind = {
+ "c": StringType.C,
+ "c16": StringType.C_16,
+ "c32": StringType.C_32,
+ "pascal": StringType.PASCAL,
+ }.get(str(a.get("kind", "c")).lower(), StringType.C)
+ import ida_bytes
+
+ try:
+ ida_bytes.del_items(ea, ida_bytes.DELIT_SIMPLE, length if length > 0 else 1)
+ except Exception:
+ pass
+ try:
+ ok = bool(db.bytes.create_string_at(ea, length or None, kind))
+ text = db.bytes.get_string_at(ea) or "" if ok else ""
+ result = {
+ "addr": hex(ea),
+ "ok": ok,
+ "size": int(db.heads.size(ea)) if ok else 0,
+ "text": text,
+ }
+ except Exception as exc:
+ result = {"addr": hex(ea), "ok": False, "error": str(exc)}
+ return result
+
+
+def read_raw(db: Database, **a: Any) -> Any:
+ import ida_bytes
+
+ ea, size = int(str(a["addr"]), 16), max(0, int(a["size"]))
+ raw = ida_bytes.get_bytes(ea, size) or b""
+ raw = raw[:size] + b"\xff" * max(0, size - len(raw))
+ data = bytearray(raw)
+ for index, value in enumerate(data):
+ if value == 0xFF and not ida_bytes.is_loaded(ea + index):
+ data[index] = 0
+ result = {"addr": a["addr"], "hex": bytes(data).hex(), "n": len(data)}
+ return result
+
+
+def rename(db: Database, **a: Any) -> Any:
+ import idaapi, ida_hexrays, ida_name
+
+ batch = a.get("batch") or {}
+ dry_run = bool(batch.get("dry_run", False))
+ allow_overwrite = bool(batch.get("allow_overwrite", False))
+ stop_on_error = bool(batch.get("stop_on_error", False))
+
+ def _items(value):
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return [value]
+ if isinstance(value, list):
+ return [i for i in value if isinstance(i, dict)]
+ return []
+
+ def _set_name_checked(ea, new):
+ conflict = idaapi.get_name_ea(idaapi.BADADDR, new)
+ if conflict != idaapi.BADADDR and conflict != ea and not allow_overwrite:
+ return (
+ False,
+ f"can't rename at {hex(ea)} as {new!r}: name already used at {hex(conflict)}",
+ )
+ if dry_run:
+ return True, None
+ flags = idaapi.SN_CHECK
+ if allow_overwrite:
+ flags |= int(getattr(idaapi, "SN_FORCE", 0))
+ if not idaapi.set_name(ea, new, flags):
+ return False, (
+ f"Rename failed at {hex(ea)}: IDA rejected name {new!r} "
+ "(invalid identifier or internal conflict)"
+ )
+ return True, None
+
+ def _refresh_ctext(fn_addr):
+ # A renamed function must invalidate Hex-Rays' cache, which is per function
+ # and persisted in the .i64: without this the pseudocode keeps calling the
+ # old name forever while every other readback reports the new one.
+ if not ida_hexrays.init_hexrays_plugin():
+ return
+ failure = ida_hexrays.hexrays_failure_t()
+ cfunc = ida_hexrays.decompile_func(
+ fn_addr, failure, ida_hexrays.DECOMP_WARNINGS
+ )
+ if cfunc:
+ cfunc.refresh_func_ctext()
+
+ out = {}
+ ok_count = failed = 0
+ halted = False
+ for category in ("func", "data", "local", "stack"):
+ if category not in batch:
+ continue
+ rows = []
+ for edit in _items(batch.get(category)):
+ try:
+ if category == "func":
+ addr_text = (
+ edit.get("addr") or edit.get("func_addr") or edit.get("func")
+ )
+ new = edit.get("name") or edit.get("new") or edit.get("new_name")
+ if not addr_text or not new:
+ row = {
+ "addr": addr_text,
+ "name": new,
+ "error": "Function rename requires addr + name",
+ }
+ else:
+ ea = int(str(addr_text), 16)
+ fn = idaapi.get_func(ea)
+ if fn is None:
+ row = {
+ "addr": addr_text,
+ "name": new,
+ "error": "Function not found",
+ }
+ else:
+ old = idaapi.get_name(fn.start_ea) or None
+ ok, err = _set_name_checked(fn.start_ea, str(new))
+ row = {"addr": addr_text, "old": old, "name": str(new)}
+ if err:
+ row["error"] = err
+ if dry_run:
+ row["dry_run"] = True
+ if ok and not dry_run:
+ _refresh_ctext(fn.start_ea)
+ elif category == "data":
+ addr_text = edit.get("addr")
+ old = edit.get("old") or edit.get("old_name")
+ new = edit.get("new") or edit.get("new_name") or edit.get("name")
+ if not new and new != "":
+ row = {
+ "old": old,
+ "new": None,
+ "error": "Global rename requires target and new name",
+ }
+ else:
+ if addr_text is not None:
+ ea = int(str(addr_text), 16)
+ old = old or (idaapi.get_name(ea) or None)
+ else:
+ ea = idaapi.get_name_ea(idaapi.BADADDR, str(old or ""))
+ if ea == idaapi.BADADDR:
+ row = {
+ "old": old,
+ "new": str(new),
+ "error": f"Global {old!r} not found",
+ }
+ else:
+ # An empty new name CLEARS the label; that is a real
+ # request (tests revert with it), not a missing argument.
+ if str(new) == "":
+ ok = bool(ida_name.set_name(ea, "", idaapi.SN_CHECK))
+ err = (
+ None
+ if ok
+ else f"Failed to clear the name at {hex(ea)}"
+ )
+ else:
+ ok, err = _set_name_checked(ea, str(new))
+ row = {"addr": hex(ea), "old": old, "new": str(new)}
+ if err:
+ row["error"] = err
+ if dry_run:
+ row["dry_run"] = True
+ else:
+ fa, old, new = (
+ edit.get("func_addr"),
+ edit.get("old"),
+ edit.get("new"),
+ )
+ if not fa or not old or not new:
+ row = {
+ "old": old,
+ "new": new,
+ "error": f"{category} rename requires func_addr + old + new",
+ }
+ else:
+ ea = int(str(fa), 16)
+ pseudo = db.pseudocode.decompile(ea)
+ var = pseudo.find_local_variable(str(old))
+ if var is None:
+ row = {
+ "func_addr": fa,
+ "old": old,
+ "new": new,
+ "error": f"no local {old!r} in that function",
+ }
+ elif dry_run:
+ row = {
+ "func_addr": fa,
+ "old": old,
+ "new": new,
+ "dry_run": True,
+ }
+ else:
+ var.set_user_name(str(new))
+ ok = bool(
+ pseudo.save_local_variable_info(var, save_name=True)
+ )
+ row = {"func_addr": fa, "old": old, "new": new}
+ if not ok:
+ row["error"] = "IDA rejected the local variable name"
+ except Exception as exc:
+ row = {"addr": edit.get("addr"), "error": str(exc)}
+ rows.append(row)
+ if row.get("error"):
+ failed += 1
+ else:
+ ok_count += 1
+ if row.get("error") and stop_on_error:
+ halted = True
+ break
+ out[category] = rows
+ if halted:
+ break
+ out["summary"] = {"ok": ok_count, "failed": failed}
+ if dry_run:
+ out["summary"]["dry_run"] = True
+ if halted:
+ out["summary"]["halted"] = True
+ result = out
+ return result
+
+
+def resolve_names(db: Database, **a: Any) -> Any:
+ import ida_idaapi, ida_name
+
+ rows = []
+ for query in a.get("queries", []):
+ name = str(query).strip()
+ ea = ida_name.get_name_ea(ida_idaapi.BADADDR, name)
+ rows.append(
+ {"query": name, "ea": hex(int(ea)) if ea != ida_idaapi.BADADDR else None}
+ )
+ result = {"result": rows}
+ return result
+
+
+def search_bytes(db: Database, **a: Any) -> Any:
+ import ida_bytes, ida_funcs, ida_idaapi, ida_lines, ida_segment
+
+ pat = str(a.get("pattern", "")).strip()
+ limit = max(1, int(a.get("limit", 500)))
+ lo = int(a.get("start", 0))
+ hi = int(a.get("end", 0)) or ida_idaapi.BADADDR
+ flags = ida_bytes.BIN_SEARCH_FORWARD | ida_bytes.BIN_SEARCH_NOSHOW
+ if a.get("case"):
+ flags |= ida_bytes.BIN_SEARCH_CASE
+ rows, err, ea = [], None, lo
+ while len(rows) < limit:
+ try:
+ hit = ida_bytes.find_bytes(pat, range_start=ea, range_end=hi, flags=flags)
+ except Exception as exc:
+ err = str(exc) or exc.__class__.__name__
+ break
+ if hit is None or hit == ida_idaapi.BADADDR:
+ break
+ head = ida_bytes.get_item_head(hit)
+ fn = ida_funcs.get_func(hit)
+ seg = ida_segment.getseg(hit)
+ try:
+ line = (
+ ida_lines.generate_disasm_line(head, ida_lines.GENDSM_REMOVE_TAGS) or ""
+ )
+ except Exception:
+ line = ""
+ rows.append(
+ {
+ "addr": hex(int(hit)),
+ "head": hex(int(head)),
+ "line": " ".join(line.split()),
+ "func": (ida_funcs.get_func_name(fn.start_ea) if fn else None),
+ "func_addr": (hex(int(fn.start_ea)) if fn else None),
+ "seg": (ida_segment.get_segm_name(seg) if seg else ""),
+ }
+ )
+ ea = int(hit) + 1
+ result = {"hits": rows, "error": err, "truncated": len(rows) >= limit}
+ return result
+
+
+def search_structs(db: Database, **a: Any) -> Any:
+ needle = str(a.get("filter") or "").lower()
+ rows = []
+ for tif in db.types.get_all():
+ name = tif.get_type_name() or ""
+ if not name or needle not in name.lower() or not tif.is_udt():
+ continue
+ members = list(db.types.get_udt_members(tif))
+ rows.append(
+ {
+ "name": name,
+ "size": int(tif.get_size()),
+ "is_union": bool(tif.is_union()),
+ "cardinality": len(members),
+ "ordinal": int(tif.get_ordinal()),
+ }
+ )
+ result = {"result": rows}
+ return result
+
+
+def search_text(db: Database, **a: Any) -> Any:
+ import ida_lines, ida_funcs, ida_segment, idautils
+ import re as _re
+
+ q = str(a.get("query", ""))
+ limit = max(1, int(a.get("limit", 500)))
+ max_scan = max(1000, int(a.get("max_scan", 3000000)))
+ ci = (not a.get("case")) and q.islower() # smartcase, like the in-view search
+ rx, err = None, None
+ if a.get("regex"):
+ try:
+ rx = _re.compile(q, _re.I if ci else 0)
+ except Exception as exc:
+ err = "bad regex: " + str(exc)
+ needle = q.lower() if ci else q
+ rows, scanned = [], 0
+ if err is None and q:
+ for i in range(ida_segment.get_segm_qty()):
+ seg = ida_segment.getnseg(i)
+ if seg is None or len(rows) >= limit or scanned >= max_scan:
+ continue
+ for ea in idautils.Heads(seg.start_ea, seg.end_ea):
+ scanned += 1
+ if len(rows) >= limit or scanned >= max_scan:
+ break
+ try:
+ line = (
+ ida_lines.generate_disasm_line(ea, ida_lines.GENDSM_REMOVE_TAGS)
+ or ""
+ )
+ except Exception:
+ continue
+ # Match what the user SEES, not IDA's column padding: nobody types
+ # "call" + four spaces + "cs:getenv_ptr".
+ line = " ".join(line.split())
+ hay = line.lower() if ci else line
+ if rx.search(line) if rx is not None else (needle in hay):
+ fn = ida_funcs.get_func(ea)
+ rows.append(
+ {
+ "addr": hex(int(ea)),
+ "head": hex(int(ea)),
+ "line": line,
+ "func": (
+ ida_funcs.get_func_name(fn.start_ea) if fn else None
+ ),
+ "func_addr": (hex(int(fn.start_ea)) if fn else None),
+ "seg": ida_segment.get_segm_name(seg),
+ }
+ )
+ result = {
+ "hits": rows,
+ "error": err,
+ "scanned": scanned,
+ "truncated": len(rows) >= limit or scanned >= max_scan,
+ }
+ return result
+
+
+def set_comments(db: Database, **a: Any) -> Any:
+ import idaapi, idc, ida_hexrays
+
+ rows = []
+ for item in a.get("items", []):
+ addr_s = str(item.get("addr", ""))
+ text = str(item.get("comment") or "")
+ try:
+ ea = int(addr_s, 16)
+ if not idaapi.set_cmt(ea, text, False):
+ rows.append(
+ {
+ "addr": addr_s,
+ "error": f"Failed to set disassembly comment at {hex(ea)}",
+ }
+ )
+ continue
+ if not ida_hexrays.init_hexrays_plugin():
+ rows.append({"addr": addr_s})
+ continue
+ try:
+ cfunc = ida_hexrays.decompile(ea)
+ except Exception:
+ cfunc = None
+ if cfunc is None:
+ rows.append({"addr": addr_s})
+ continue
+ if ea == cfunc.entry_ea:
+ # The signature line carries no ctree item: it is a function comment.
+ idc.set_func_cmt(ea, text, True)
+ cfunc.refresh_func_ctext()
+ rows.append({"addr": addr_s})
+ continue
+ eamap = cfunc.get_eamap()
+ if ea not in eamap:
+ rows.append(
+ {
+ "addr": addr_s,
+ "error": f"Failed to set decompiler comment at {hex(ea)}",
+ }
+ )
+ continue
+ nearest_ea = eamap[ea][0].ea
+ if cfunc.has_orphan_cmts():
+ cfunc.del_orphan_cmts()
+ cfunc.save_user_cmts()
+ tl = idaapi.treeloc_t()
+ tl.ea = nearest_ea
+ placed = False
+ for itp in range(idaapi.ITP_SEMI, idaapi.ITP_COLON):
+ tl.itp = itp
+ cfunc.set_user_cmt(tl, text)
+ cfunc.save_user_cmts()
+ cfunc.refresh_func_ctext()
+ if not cfunc.has_orphan_cmts():
+ placed = True
+ break
+ cfunc.del_orphan_cmts()
+ cfunc.save_user_cmts()
+ rows.append(
+ {"addr": addr_s}
+ if placed
+ else {
+ "addr": addr_s,
+ "error": f"Failed to set decompiler comment at {hex(ea)}",
+ }
+ )
+ except Exception as exc:
+ rows.append({"addr": addr_s, "error": str(exc)})
+ result = {"result": rows}
+ return result
+
+
+def set_lvar_type(db: Database, **a: Any) -> Any:
+ import ida_typeinf
+
+ ea, variable, declaration = (
+ int(str(a["addr"]), 16),
+ str(a["variable"]),
+ str(a["type"]),
+ )
+ fn = db.functions.get_at(ea)
+ if fn is None:
+ result = {"error": "no function at address"}
+ else:
+ pseudo = db.pseudocode.decompile(fn)
+ var = pseudo.find_local_variable(variable)
+ if var is None:
+ result = {"error": f"local variable {variable!r} not found"}
+ else:
+ try:
+ tif = db.types.parse_one_declaration(
+ ida_typeinf.get_idati(), declaration
+ )
+ accepted = bool(var.set_type(tif))
+ saved = (
+ bool(pseudo.save_local_variable_info(var, save_type=True))
+ if accepted
+ else False
+ )
+ result = {
+ "addr": hex(int(fn.start_ea)),
+ "variable": variable,
+ "type": declaration,
+ "ok": accepted and saved,
+ }
+ except Exception as exc:
+ result = {"error": f"bad type {declaration!r}: {exc}"}
+ return result
+
+
+def set_thumb(db: Database, **a: Any) -> Any:
+ import ida_bytes, ida_ida, ida_idp, ida_segment, ida_segregs
+
+ ea = int(str(a["addr"]), 16)
+ treg = ida_idp.str2reg("T")
+ seg = ida_segment.getseg(ea)
+ if treg is None or treg < 0:
+ result = {"addr": hex(ea), "error": "no T register (not an ARM database)"}
+ elif seg is None:
+ result = {"addr": hex(ea), "error": "no segment"}
+ else:
+ current = ida_segregs.get_sreg(ea, treg)
+ current = 0 if current in (None, 0xFFFFFFFF, -1) else int(current)
+ want = {"on": 1, "off": 0}.get(
+ str(a.get("mode", "toggle")).lower(), 0 if current else 1
+ )
+ changed = False
+ if want and seg.bitness != 1:
+ ida_segment.set_segm_addressing(seg, 1)
+ changed = True
+ size = max(int(ida_bytes.get_item_size(ea)), 2)
+ ida_bytes.del_items(ea, 0, size)
+ ok = bool(ida_segregs.split_sreg_range(ea, treg, want, ida_segregs.SR_user))
+ now = ida_segregs.get_sreg(ea, treg)
+ result = {
+ "addr": hex(ea),
+ "thumb": bool(now),
+ "was": bool(current),
+ "ok": ok,
+ "bitness": ida_segment.getseg(ea).bitness,
+ "forced_32bit": changed,
+ "db_64bit": bool(ida_ida.inf_get_app_bitness() == 64 and want),
+ }
+ return result
+
+
+def set_type(db: Database, **a: Any) -> Any:
+ from ida_domain.types import TypeApplyFlags
+
+ rows = []
+ for edit in a.get("edits", []):
+ ea = int(str(edit["addr"]), 16)
+ declaration = str(edit.get("signature") or edit.get("type") or "")
+ try:
+ ok = bool(
+ db.types.apply_declaration_at(ea, declaration, TypeApplyFlags.DEFINITE)
+ )
+ rows.append(
+ {
+ "addr": hex(ea),
+ "ok": ok,
+ **({} if ok else {"error": "IDA rejected the type"}),
+ }
+ )
+ except Exception as exc:
+ rows.append({"addr": hex(ea), "ok": False, "error": str(exc)})
+ result = {"result": rows}
+ return result
+
+
+def survey_binary(db: Database, **a: Any) -> Any:
+ segments = []
+ for seg in db.segments.get_all():
+ segments.append(
+ {
+ "start": hex(int(seg.start_ea)),
+ "end": hex(int(seg.end_ea)),
+ "name": db.segments.get_name(seg) or "",
+ }
+ )
+ result = {"segments": segments}
+ return result
+
+
+def thumb_scan(db: Database, **a: Any) -> Any:
+ import ida_bytes, ida_funcs, ida_idp, ida_segment, ida_segregs, ida_ua
+
+ lo, hi = int(str(a["start"]), 16), int(str(a["end"]), 16)
+ apply, limit = bool(a.get("apply", True)), int(a.get("limit", 512))
+ treg = ida_idp.str2reg("T")
+ found = []
+ applied = 0
+ cursor = lo
+ while cursor + 4 <= hi and len(found) < limit:
+ at = cursor
+ value = int(ida_bytes.get_dword(cursor))
+ cursor += 4
+ if not value & 1:
+ continue
+ target = value & ~1
+ seg = ida_segment.getseg(target)
+ if seg is None or not (seg.perm & ida_segment.SEGPERM_EXEC or seg.perm == 0):
+ continue
+ flags = ida_bytes.get_flags(target)
+ if ida_bytes.is_data(flags):
+ continue
+ item = {
+ "at": hex(at),
+ "value": hex(value),
+ "target": hex(target),
+ "was_code": bool(ida_bytes.is_code(flags)),
+ }
+ found.append(item)
+ if not apply:
+ continue
+ if treg is not None and treg >= 0:
+ ida_segregs.split_sreg_range(target, treg, 1, ida_segregs.SR_user)
+ if not ida_bytes.is_code(ida_bytes.get_flags(target)):
+ ida_bytes.del_items(target, 0, 2)
+ if ida_ua.create_insn(target) <= 0:
+ item["decoded"] = False
+ continue
+ item["decoded"] = True
+ item["function"] = bool(
+ db.functions.get_at(target) or db.functions.create(target)
+ )
+ applied += 1
+ result = {
+ "start": hex(lo),
+ "end": hex(hi),
+ "found": found,
+ "applied": applied,
+ "n": len(found),
+ }
+ return result
+
+
+def type_inspect(db: Database, **a: Any) -> Any:
+ rows = []
+ for query in a.get("queries", []):
+ name = str(query.get("name") or "")
+ tif = db.types.get_by_name(name)
+ if tif is None:
+ rows.append({"name": name, "error": "type not found"})
+ continue
+ members = (
+ [
+ {
+ "name": m.name,
+ "type": m.type.dstr() or str(m.type),
+ "offset": int(m.offset),
+ "size": int(m.size),
+ }
+ for m in db.types.get_udt_members(tif)
+ ]
+ if tif.is_udt()
+ else []
+ )
+ rows.append(
+ {
+ "name": name,
+ "size": int(tif.get_size()),
+ "is_union": bool(tif.is_union()),
+ "members": members,
+ }
+ )
+ result = {"result": rows}
+ return result
+
+
+def undefine(db: Database, **a: Any) -> Any:
+ import ida_bytes
+
+ rows = []
+ for item in a.get("items", []):
+ ea = int(str(item["addr"]), 16)
+ size = max(1, int(item.get("size") or ida_bytes.get_item_size(ea) or 1))
+ ok = bool(ida_bytes.del_items(ea, ida_bytes.DELIT_SIMPLE, size))
+ rows.append(
+ {
+ "addr": hex(ea),
+ "ok": ok,
+ **({} if ok else {"error": "delete items failed"}),
+ }
+ )
+ result = {"result": rows}
+ return result
+
+
+def xref_query(db: Database, **a: Any) -> Any:
+ import idaapi, idautils, ida_bytes, ida_funcs
+
+ def _fn(ea):
+ f = ida_funcs.get_func(ea)
+ return (
+ {
+ "addr": hex(int(f.start_ea)),
+ "name": ida_funcs.get_func_name(f.start_ea) or "",
+ }
+ if f
+ else None
+ )
+
+ queries = a.get("queries") or []
+ all_results = []
+ for query in queries:
+ raw = str(query.get("addr", "")).strip()
+ direction = str(query.get("direction", "both") or "both").lower()
+ if direction not in ("to", "from", "both"):
+ direction = "both"
+ xref_type = str(query.get("xref_type", "any") or "any").lower()
+ if xref_type not in ("any", "code", "data"):
+ xref_type = "any"
+ include_fn = bool(query.get("include_fn", True))
+ dedup = bool(query.get("dedup", True))
+ sort_by = str(query.get("sort_by", "addr") or "addr")
+ descending = bool(query.get("descending", False))
+ try:
+ offset = max(0, int(query.get("offset", 0) or 0))
+ except (TypeError, ValueError):
+ offset = 0
+ try:
+ count = max(0, min(int(query.get("count", 200) or 200), 5000))
+ except (TypeError, ValueError):
+ count = 200
+ try:
+ try:
+ target = int(raw, 16)
+ except ValueError:
+ target = idaapi.get_name_ea(idaapi.BADADDR, raw)
+ if target == idaapi.BADADDR:
+ raise ValueError(f"Failed to resolve address/name: {raw}")
+ if not ida_bytes.is_mapped(target):
+ raise ValueError(f"Address not mapped: {raw}")
+ rows = []
+ if direction in ("to", "both"):
+ for xr in idautils.XrefsTo(target, 0):
+ kind = "code" if xr.iscode else "data"
+ if xref_type != "any" and kind != xref_type:
+ continue
+ row = {
+ "direction": "to",
+ "addr": hex(int(xr.frm)),
+ "from": hex(int(xr.frm)),
+ "to": hex(int(target)),
+ "type": kind,
+ }
+ if include_fn:
+ row["fn"] = _fn(xr.frm)
+ rows.append(row)
+ if direction in ("from", "both"):
+ for xr in idautils.XrefsFrom(target, 0):
+ kind = "code" if xr.iscode else "data"
+ if xref_type != "any" and kind != xref_type:
+ continue
+ row = {
+ "direction": "from",
+ "addr": hex(int(xr.to)),
+ "from": hex(int(target)),
+ "to": hex(int(xr.to)),
+ "type": kind,
+ }
+ if include_fn:
+ row["fn"] = _fn(xr.to)
+ rows.append(row)
+ if dedup:
+ seen, deduped = set(), []
+ for row in rows:
+ key = (row["direction"], row["from"], row["to"], row["type"])
+ if key in seen:
+ continue
+ seen.add(key)
+ deduped.append(row)
+ rows = deduped
+ if sort_by == "type":
+ rows.sort(
+ key=lambda r: (str(r.get("type", "")), int(str(r["addr"]), 16)),
+ reverse=descending,
+ )
+ else:
+ rows.sort(key=lambda r: int(str(r["addr"]), 16), reverse=descending)
+ page = rows[offset : offset + count] if count else rows[offset:]
+ nxt = offset + len(page)
+ all_results.append(
+ {
+ "target": raw,
+ "resolved_addr": hex(int(target)),
+ "direction": direction,
+ "xref_type": xref_type,
+ "data": page,
+ "next_offset": nxt if nxt < len(rows) else None,
+ "total": len(rows),
+ "error": None,
+ }
+ )
+ except Exception as exc:
+ all_results.append(
+ {
+ "target": raw,
+ "resolved_addr": None,
+ "direction": direction,
+ "xref_type": xref_type,
+ "data": [],
+ "next_offset": None,
+ "total": 0,
+ "error": str(exc),
+ }
+ )
+ result = {"result": all_results}
+ return result
+
+
+def xref_types(db: Database, **a: Any) -> Any:
+ import idaapi, idautils, ida_bytes, ida_funcs, ida_xref
+
+ code_kind = {
+ ida_xref.fl_CF: "call",
+ ida_xref.fl_CN: "call",
+ ida_xref.fl_JF: "jump",
+ ida_xref.fl_JN: "jump",
+ ida_xref.fl_F: "flow",
+ }
+ data_kind = {
+ ida_xref.dr_O: "offset",
+ ida_xref.dr_W: "write",
+ ida_xref.dr_R: "read",
+ ida_xref.dr_T: "text",
+ ida_xref.dr_I: "info",
+ }
+
+ def _kind(xr):
+ return (code_kind if xr.iscode else data_kind).get(
+ xr.type, "code" if xr.iscode else "data"
+ )
+
+ def _fn(ea):
+ f = ida_funcs.get_func(ea)
+ return (
+ {
+ "addr": hex(int(f.start_ea)),
+ "name": ida_funcs.get_func_name(f.start_ea) or "",
+ }
+ if f
+ else None
+ )
+
+ queries = a.get("queries") or []
+ all_results = []
+ for query in queries:
+ query = query if isinstance(query, dict) else {"addr": query}
+ raw = str(query.get("addr", "")).strip()
+ direction = str(query.get("direction", "to") or "to").lower()
+ include_fn = bool(query.get("include_fn", True))
+ dedup = bool(query.get("dedup", True))
+ try:
+ count = int(query.get("count", 2000) or 2000)
+ except (TypeError, ValueError):
+ count = 2000
+ try:
+ target = int(raw, 16)
+ except ValueError:
+ target = idaapi.get_name_ea(idaapi.BADADDR, raw)
+ rows = []
+ if (
+ target is not None
+ and target != idaapi.BADADDR
+ and ida_bytes.is_mapped(target)
+ ):
+ if direction in ("to", "both"):
+ for xr in idautils.XrefsTo(target, 0):
+ row = {
+ "direction": "to",
+ "addr": hex(int(xr.frm)),
+ "from": hex(int(xr.frm)),
+ "to": hex(int(target)),
+ "type": "code" if xr.iscode else "data",
+ "kind": _kind(xr),
+ }
+ if include_fn:
+ row["fn"] = _fn(xr.frm)
+ rows.append(row)
+ if direction in ("from", "both"):
+ for xr in idautils.XrefsFrom(target, 0):
+ row = {
+ "direction": "from",
+ "addr": hex(int(xr.to)),
+ "from": hex(int(target)),
+ "to": hex(int(xr.to)),
+ "type": "code" if xr.iscode else "data",
+ "kind": _kind(xr),
+ }
+ if include_fn:
+ row["fn"] = _fn(xr.to)
+ rows.append(row)
+ if dedup:
+ seen, deduped = set(), []
+ for r in rows:
+ k = (r["direction"], r["from"], r["to"], r["kind"])
+ if k in seen:
+ continue
+ seen.add(k)
+ deduped.append(r)
+ rows = deduped
+ rows = rows[:count]
+ all_results.append({"query": raw, "data": rows, "next_offset": None})
+ result = {"result": all_results}
+ return result
+
+
+def op_format(addr: str, mode: str = "cycle", col: int = -1, n: int = -1) -> dict: ...
+
+
+def pc_nums(addr: str) -> dict: ...
+
+
+def decompile(addr, include_addresses=True) -> dict: ...
+
+
+def decomp_map(addr: str) -> dict: ...
+
+
+def pc_num_format(
+ addr: str,
+ mode: str = "cycle",
+ line: int = -1,
+ col: int = -1,
+ ea: str = "",
+ opnum: int = -1,
+) -> dict: ...
+
+
+def segment_index(
+ addr: str,
+ end: str = "",
+ page_rows: int = 500,
+ detail: bool = False,
+) -> dict: ...
+
+
+def heads(
+ addr: str,
+ count: int = 200,
+ offset: int = 0,
+ end: str = "",
+ back: bool = False,
+ annotate: bool = False,
+ expect: str = "",
+ text: bool = True,
+) -> dict: ...
+
+
+def profile_remote(operation: str, args: dict[str, Any], reps: int = 5) -> dict: ...
+
+
+OPERATIONS: dict[str, Callable[..., Any]] = {
+ "data_type": data_type,
+ "declare_type": declare_type,
+ "decomp_error": decomp_error,
+ "define_code": define_code,
+ "define_code_run": define_code_run,
+ "define_func": define_func,
+ "define_func_run": define_func_run,
+ "del_type": del_type,
+ "disasm": disasm,
+ "file_regions": file_regions,
+ "flowchart": flowchart,
+ "force_recompile": force_recompile,
+ "func_types": func_types,
+ "get_bytes": get_bytes,
+ "heads": heads,
+ "journal_get": journal_get,
+ "journal_put": journal_put,
+ "list_annotations": list_annotations,
+ "list_funcs": list_funcs,
+ "list_linkage": list_linkage,
+ "list_strings": list_strings,
+ "lookup_funcs": lookup_funcs,
+ "make_data": make_data,
+ "make_string": make_string,
+ "op_format": op_format,
+ "pc_nums": pc_nums,
+ "decompile": decompile,
+ "decomp_map": decomp_map,
+ "pc_num_format": pc_num_format,
+ "profile_remote": profile_remote,
+ "read_raw": read_raw,
+ "rename": rename,
+ "resolve_names": resolve_names,
+ "search_bytes": search_bytes,
+ "search_structs": search_structs,
+ "search_text": search_text,
+ "segment_index": segment_index,
+ "set_comments": set_comments,
+ "set_lvar_type": set_lvar_type,
+ "set_thumb": set_thumb,
+ "set_type": set_type,
+ "survey_binary": survey_binary,
+ "thumb_scan": thumb_scan,
+ "type_inspect": type_inspect,
+ "undefine": undefine,
+ "xref_query": xref_query,
+ "xref_types": xref_types,
+}
+
+_MODULE_DECLARATIONS = frozenset(
+ (
+ heads,
+ segment_index,
+ op_format,
+ pc_nums,
+ decompile,
+ decomp_map,
+ pc_num_format,
+ profile_remote,
+ )
+)
+_BOUND: dict[Callable[..., Any], Any] | None = None
+_BIND_LOCK = threading.Lock()
+
+
+def _bindings() -> dict[Callable[..., Any], Any]:
+ global _BOUND
+ with _BIND_LOCK:
+ if _BOUND is not None:
+ return _BOUND
+ from ida_nexus import RemoteModule
+
+ operations_module = RemoteModule(
+ Path(__file__), operation_label=operation_label, codec="json"
+ )
+ tools_module = RemoteModule(
+ Path(__file__).with_name("remote_tools.py"),
+ operation_label=operation_label,
+ codec="json",
+ )
+ bound: dict[Callable[..., Any], Any] = {}
+ for declaration in OPERATIONS.values():
+ if declaration in _MODULE_DECLARATIONS:
+ bound[declaration] = tools_module.function(
+ declaration,
+ timeout=15.0 if declaration is decompile else None,
+ )
+ else:
+ bound[declaration] = operations_module.function(declaration)
+ _BOUND = bound
+ return bound
+
+
+def bind(function: Callable[..., Any]) -> Any:
+ """Return the lazily constructed remote callable for one declaration."""
+ return _bindings()[function]
diff --git a/idatui/remote_tools.py b/idatui/remote_tools.py
index 0e26965..6681379 100644
--- a/idatui/remote_tools.py
+++ b/idatui/remote_tools.py
@@ -1,8 +1,8 @@
-"""The IDAPython ida-tui runs inside the Code Mode sandbox.
+"""The IDAPython ida-tui runs inside the IDA Nexus sandbox.
Two features have no ida-domain surface at all and are carried over VERBATIM
from the tools ida-tui was developed against (`server/patch_server.py`'s
-injected BODY, which the Code Mode port deletes):
+injected BODY, which the IDA Nexus port deletes):
* `heads` -- the continuous listing. ida-domain enumerates defined heads and
renders plain disassembly; the listing also needs coalesced undefined runs,
@@ -20,15 +20,16 @@ what you see). A re-implementation drifts from it silently.
This file is SOURCE SHIPPED AS TEXT to the database process; it is never
imported here, because the ida_* modules do not exist in the TUI's interpreter.
-`codemode_client` reads it and prepends it to the relevant snippets. Keep it
-self-contained: no relative imports, nothing beyond what Code Mode provides.
+`nexus_client` reads it and prepends it to the relevant snippets. Keep it
+self-contained: no relative imports, nothing beyond what IDA Nexus provides.
"""
+
# ruff: noqa
import re as _re
# IDAPython, imported ONCE at module scope.
#
-# This file is never imported by the client -- codemode_client reads it as
+# This file is never imported by the client -- nexus_client reads it as
# TEXT and installs it as a module inside the database process -- so the
# no-IDA house rule that keeps idatui importable without IDA does not apply
# here, and these need not be function-local.
@@ -134,7 +135,7 @@ def _idatui_head_row(ea, flags=None, text=True):
"""
f = ida_bytes.get_flags(ea) if flags is None else flags
- cls = f & _MS_CLS # == is_code(f) / is_data(f), without the calls
+ cls = f & _MS_CLS # == is_code(f) / is_data(f), without the calls
if cls == _FF_CODE:
kind = "code"
elif cls == _FF_DATA:
@@ -211,10 +212,20 @@ _IDATUI_SPAN_KINDS = {
# NB the real constant names: DATNAME/CODNAME, not "DNAME". Guessing here
# fails silently — an unmapped tag renders as plain body text, so symbols
# just quietly aren't blue and nothing tells you why.
- "name": ("SCOLOR_DATNAME", "SCOLOR_CODNAME", "SCOLOR_LOCNAME",
- "SCOLOR_IMPNAME", "SCOLOR_DEMNAME", "SCOLOR_LIBNAME",
- "SCOLOR_CNAME", "SCOLOR_DNAME",
- "SCOLOR_CREF", "SCOLOR_DREF", "SCOLOR_CREFTAIL", "SCOLOR_DREFTAIL"),
+ "name": (
+ "SCOLOR_DATNAME",
+ "SCOLOR_CODNAME",
+ "SCOLOR_LOCNAME",
+ "SCOLOR_IMPNAME",
+ "SCOLOR_DEMNAME",
+ "SCOLOR_LIBNAME",
+ "SCOLOR_CNAME",
+ "SCOLOR_DNAME",
+ "SCOLOR_CREF",
+ "SCOLOR_DREF",
+ "SCOLOR_CREFTAIL",
+ "SCOLOR_DREFTAIL",
+ ),
"seg": ("SCOLOR_SEGNAME",),
"cmt": ("SCOLOR_AUTOCMT", "SCOLOR_REGCMT", "SCOLOR_RPTCMT", "SCOLOR_VOIDOP"),
"punct": ("SCOLOR_SYMBOL", "SCOLOR_ALTOP", "SCOLOR_HIDNAME"),
@@ -241,7 +252,7 @@ _IDATUI_TAGS = None
_IDATUI_OPND_TAGS = None
-_IDATUI_CTL = None # re: a tag = one of three control chars plus its argument
+_IDATUI_CTL = None # re: a tag = one of three control chars plus its argument
_IDATUI_TAGINFO = None
@@ -280,6 +291,7 @@ def _idatui_spans(line):
_IDATUI_OPND_TAGS = _idatui_opnd_tag_map()
if _IDATUI_CTL is None:
import re as _re
+
# One capturing split gives [text, tag, text, tag, ..., text] in a
# single C pass. A per-character python loop over the line used to be
# the most expensive thing the `heads` tool did, and a line is ~54
@@ -289,17 +301,18 @@ def _idatui_spans(line):
if _IDATUI_TAGINFO is None:
_IDATUI_TAGINFO = {
tag: (_IDATUI_TAGS.get(tag, "text"), _IDATUI_OPND_TAGS.get(tag))
- for tag in set(_IDATUI_TAGS) | set(_IDATUI_OPND_TAGS)}
+ for tag in set(_IDATUI_TAGS) | set(_IDATUI_OPND_TAGS)
+ }
taginfo = _IDATUI_TAGINFO
plain_tag = ("text", None)
on, off, esc = "\x01", "\x02", "\x03"
addr_tag = chr(getattr(ida_lines, "COLOR_ADDR", 0x28))
addr_len = int(getattr(ida_lines, "COLOR_ADDR_SIZE", 16))
parts = _IDATUI_CTL.split(line)
- spans, stack = [], [] # stack entries: (kind, operand index|None)
- kind, opnd = "text", None # state the current run of text belongs to
+ spans, stack = [], [] # stack entries: (kind, operand index|None)
+ kind, opnd = "text", None # state the current run of text belongs to
pend = ""
- skip = 0 # characters of an address payload still due
+ skip = 0 # characters of an address payload still due
i, n = 0, len(parts)
while i < n:
txt = parts[i]
@@ -317,11 +330,11 @@ def _idatui_spans(line):
break
pair = parts[i]
i += 1
- if skip: # a tag INSIDE an address payload: 2 chars
+ if skip: # a tag INSIDE an address payload: 2 chars
skip = skip - 2 if skip > 2 else 0
continue
ch = pair[0]
- if ch == esc: # escaped literal: keep the char it guards
+ if ch == esc: # escaped literal: keep the char it guards
pend += pair[1]
continue
tag = pair[1]
@@ -337,7 +350,7 @@ def _idatui_spans(line):
stack.append((kind, opnd))
kind, o = taginfo.get(tag, plain_tag)
if o is not None:
- opnd = o # operands nest: an inner colour keeps the operand
+ opnd = o # operands nest: an inner colour keeps the operand
elif stack:
kind, opnd = stack.pop()
else:
@@ -359,7 +372,7 @@ def _idatui_spans(line):
prev_space = False
out.append([kind, txt, opnd])
continue
- if not core: # the span is nothing but padding
+ if not core: # the span is nothing but padding
if not prev_space:
prev_space = True
out.append([kind, " ", opnd])
@@ -396,7 +409,7 @@ def _idatui_spans(line):
ops.append([start, pos, cur])
text = "".join(t for _k, t, _o in out)
trimmed = []
- for lo, hi, k in ops: # don't let a range own trailing space
+ for lo, hi, k in ops: # don't let a range own trailing space
while hi > lo and text[hi - 1].isspace():
hi -= 1
while lo < hi and text[lo].isspace():
@@ -435,8 +448,17 @@ def _idatui_rows_digest(rows):
sh = seen.get(key)
if sh is None:
sh = seen[key] = hash(tuple(map(tuple, sp)))
- acc = hash((acc, r.get("ea"), r.get("kind"), r.get("size"),
- r.get("text"), r.get("name"), sh))
+ acc = hash(
+ (
+ acc,
+ r.get("ea"),
+ r.get("kind"),
+ r.get("size"),
+ r.get("text"),
+ r.get("name"),
+ sh,
+ )
+ )
return acc
@@ -448,8 +470,12 @@ def _idatui_unknown_row(ea, size):
if size <= 1:
return _idatui_head_row(ea)
- row = {"ea": hex(ea), "kind": "unknown", "size": int(size),
- "text": f"db {size} dup(?)"}
+ row = {
+ "ea": hex(ea),
+ "kind": "unknown",
+ "size": int(size),
+ "text": f"db {size} dup(?)",
+ }
nm = ida_name.get_ea_name(ea)
if nm:
row["name"] = nm
@@ -481,8 +507,7 @@ def _idatui_struct_member_rows(ea):
sz = 0
name = m.name or ""
text = f"+{off:X} {name}" + (f" {mtype}" if mtype else "")
- rows.append({"ea": hex(ea + off), "kind": "member", "size": sz,
- "text": text})
+ rows.append({"ea": hex(ea + off), "kind": "member", "size": sz, "text": text})
return rows
@@ -494,8 +519,13 @@ def _idatui_func_header_rows(ea):
return [
{"ea": hex(ea), "kind": "sep", "size": 0, "text": ""},
{"ea": hex(ea), "kind": "sep", "size": 0, "text": "; " + bar},
- {"ea": hex(ea), "kind": "funchdr", "size": 0,
- "text": name + " proc", "name": name},
+ {
+ "ea": hex(ea),
+ "kind": "funchdr",
+ "size": 0,
+ "text": name + " proc",
+ "name": name,
+ },
]
@@ -504,8 +534,13 @@ def _idatui_func_footer_rows(ea, func):
name = ida_funcs.get_func_name(func.start_ea) or "sub_%X" % func.start_ea
return [
- {"ea": hex(ea), "kind": "funchdr", "size": 0,
- "text": name + " endp", "name": name},
+ {
+ "ea": hex(ea),
+ "kind": "funchdr",
+ "size": 0,
+ "text": name + " endp",
+ "name": name,
+ },
{"ea": hex(ea), "kind": "sep", "size": 0, "text": "; " + "-" * 60},
]
@@ -543,9 +578,12 @@ def _idatui_segment_detail(addr, end, page_rows):
except Exception:
pass
- K_CODE = _IDATUI_KIND_ID["code"]; K_DATA = _IDATUI_KIND_ID["data"]
- K_UNK = _IDATUI_KIND_ID["unknown"]; K_SEP = _IDATUI_KIND_ID["sep"]
- K_FUNC = _IDATUI_KIND_ID["funchdr"]; K_LABEL = _IDATUI_KIND_ID["label"]
+ K_CODE = _IDATUI_KIND_ID["code"]
+ K_DATA = _IDATUI_KIND_ID["data"]
+ K_UNK = _IDATUI_KIND_ID["unknown"]
+ K_SEP = _IDATUI_KIND_ID["sep"]
+ K_FUNC = _IDATUI_KIND_ID["funchdr"]
+ K_LABEL = _IDATUI_KIND_ID["label"]
K_MEMBER = _IDATUI_KIND_ID["member"]
eas = array.array("Q")
@@ -569,8 +607,8 @@ def _idatui_segment_detail(addr, end, page_rows):
# are the same number until a segment contains an undefined run) and then
# silently yields pages that do not line up with a refetch.
anchors = []
- page_phys = 0 # physical rows emitted into the page being filled
- rows = 0 # logical rows so far (what the scrollbar counts)
+ page_phys = 0 # physical rows emitted into the page being filled
+ rows = 0 # logical rows so far (what the scrollbar counts)
fn = None
ea = ida_bytes.get_item_head(lo)
while ea != BAD and ea < hi:
@@ -584,7 +622,9 @@ def _idatui_segment_detail(addr, end, page_rows):
nh = next_head(ea, hi)
stop = nh if (nh != BAD and ea < nh <= hi) else hi
run = stop - ea
- ea_ap(ea); kind_ap(K_UNK); size_ap(run)
+ ea_ap(ea)
+ kind_ap(K_UNK)
+ size_ap(run)
rows += run if run > 1 else 1
page_phys += len(eas) - before
ea = stop
@@ -593,23 +633,32 @@ def _idatui_segment_detail(addr, end, page_rows):
fn = get_func(ea)
at_start = fn is not None and fn.start_ea == ea
if at_start:
- for k in (K_SEP, K_SEP, K_FUNC): # blank, banner, `name proc`
- ea_ap(ea); kind_ap(k); size_ap(0)
+ for k in (K_SEP, K_SEP, K_FUNC): # blank, banner, `name proc`
+ ea_ap(ea)
+ kind_ap(k)
+ size_ap(0)
rows += 3
elif cls == _FF_CODE and get_ea_name(ea):
- ea_ap(ea); kind_ap(K_LABEL); size_ap(0)
+ ea_ap(ea)
+ kind_ap(K_LABEL)
+ size_ap(0)
rows += 1
- ea_ap(ea); kind_ap(K_CODE if cls == _FF_CODE else K_DATA)
- size_ap(int(get_item_size(ea))); rows += 1
+ ea_ap(ea)
+ kind_ap(K_CODE if cls == _FF_CODE else K_DATA)
+ size_ap(int(get_item_size(ea)))
+ rows += 1
if cls == _FF_DATA:
for m in _idatui_struct_member_rows(ea):
ea_ap(int(m["ea"], 16) if isinstance(m["ea"], str) else m["ea"])
kind_ap(_IDATUI_KIND_ID.get(m.get("kind", "member"), K_MEMBER))
- size_ap(int(m.get("size", 0) or 0)); rows += 1
+ size_ap(int(m.get("size", 0) or 0))
+ rows += 1
item_end = get_item_end(ea)
if fn is not None and item_end >= fn.end_ea:
- for k in (K_FUNC, K_SEP): # `name endp`, separator
- ea_ap(ea); kind_ap(k); size_ap(0)
+ for k in (K_FUNC, K_SEP): # `name endp`, separator
+ ea_ap(ea)
+ kind_ap(k)
+ size_ap(0)
rows += 2
page_phys += len(eas) - before
ea = item_end if item_end > ea else ea + 1
@@ -618,19 +667,28 @@ def _idatui_segment_detail(addr, end, page_rows):
# which turns a bytes object into its repr -- 4 characters per byte and
# unparseable at the other end. Learned by watching 2.97MB arrive as 11.26MB.
import base64
+
b64 = base64.b64encode
- return {"addr": hex(lo), "end": hex(hi), "rows": rows, "heads": len(eas),
- "anchors": anchors, "kind_names": list(_IDATUI_KINDS),
- "eas": b64(eas.tobytes()).decode(),
- "kinds": b64(kinds.tobytes()).decode(),
- "sizes": b64(sizes.tobytes()).decode()}
+ return {
+ "addr": hex(lo),
+ "end": hex(hi),
+ "rows": rows,
+ "heads": len(eas),
+ "anchors": anchors,
+ "kind_names": list(_IDATUI_KINDS),
+ "eas": b64(eas.tobytes()).decode(),
+ "kinds": b64(kinds.tobytes()).decode(),
+ "sizes": b64(sizes.tobytes()).decode(),
+ }
def segment_index(
addr: Annotated[str, "Any address in the segment to index"],
end: Annotated[str, "Optional exclusive end address; default = segment end"] = "",
page_rows: Annotated[int, "Rows between anchors (default 500)"] = 500,
- detail: Annotated[bool, "Also return every row's ea/kind/size as packed arrays"] = False,
+ detail: Annotated[
+ bool, "Also return every row's ea/kind/size as packed arrays"
+ ] = False,
) -> dict:
"""How many listing rows a segment has, and where to seek into it.
@@ -662,6 +720,7 @@ def segment_index(
if detail:
return _idatui_segment_detail(addr, end, count)
import ida_segment
+
seg = ida_segment.getseg(start)
if not seg:
return {"addr": str(addr), "error": "no segment", "rows": 0, "anchors": []}
@@ -703,19 +762,24 @@ def segment_index(
fn = get_func(ea)
n = 1
if fn is not None and fn.start_ea == ea:
- n += 3 # blank, banner, `proc`
+ n += 3 # blank, banner, `proc`
elif cls == _FF_CODE and get_ea_name(ea):
- n += 1 # loc_XXX label on its own row
+ n += 1 # loc_XXX label on its own row
if cls == _FF_DATA:
n += len(_idatui_struct_member_rows(ea))
item_end = get_item_end(ea)
if fn is not None and item_end >= fn.end_ea:
- n += 2 # `endp` + separator
+ n += 2 # `endp` + separator
rows += n
n_heads += 1
ea = item_end if item_end > ea else ea + 1
- return {"addr": hex(lo), "end": hex(hi), "rows": rows,
- "heads": n_heads, "anchors": anchors}
+ return {
+ "addr": hex(lo),
+ "end": hex(hi),
+ "rows": rows,
+ "heads": n_heads,
+ "anchors": anchors,
+ }
def heads(
@@ -723,10 +787,21 @@ def heads(
count: Annotated[int, "Max heads to return (default 200, max 2000)"] = 200,
offset: Annotated[int, "Skip first N heads from addr (default 0)"] = 0,
end: Annotated[str, "Optional exclusive end address; default = segment end"] = "",
- back: Annotated[bool, "Walk backwards: return the count heads ENDING just before addr, in forward order"] = False,
- annotate: Annotated[bool, "Emit IDA-style function boundary banner rows (kind sep/funchdr)"] = False,
- expect: Annotated[str, "Digest a caller already holds: the rows are omitted when they still hash to it"] = "",
- text: Annotated[bool, "Render each row's disassembly text (default true). False = a skeleton page: same rows, same addresses, no text"] = True,
+ back: Annotated[
+ bool,
+ "Walk backwards: return the count heads ENDING just before addr, in forward order",
+ ] = False,
+ annotate: Annotated[
+ bool, "Emit IDA-style function boundary banner rows (kind sep/funchdr)"
+ ] = False,
+ expect: Annotated[
+ str,
+ "Digest a caller already holds: the rows are omitted when they still hash to it",
+ ] = "",
+ text: Annotated[
+ bool,
+ "Render each row's disassembly text (default true). False = a skeleton page: same rows, same addresses, no text",
+ ] = True,
) -> dict:
"""Walk item heads from ``addr`` as a flat listing: every head is rendered
(code OR data OR undefined) via generate_disasm_line and stepped with
@@ -740,10 +815,20 @@ def heads(
try:
start = parse_address(addr)
except Exception as e:
- return {"addr": str(addr), "error": str(e), "heads": [], "cursor": {"done": True}}
+ return {
+ "addr": str(addr),
+ "error": str(e),
+ "heads": [],
+ "cursor": {"done": True},
+ }
seg = ida_segment.getseg(start)
if not seg:
- return {"addr": str(addr), "error": "no segment", "heads": [], "cursor": {"done": True}}
+ return {
+ "addr": str(addr),
+ "error": "no segment",
+ "heads": [],
+ "cursor": {"done": True},
+ }
lo, hi = seg.start_ea, seg.end_ea
if end:
try:
@@ -766,7 +851,9 @@ def heads(
rows = [_idatui_head_row(e) for e in chosen]
first = chosen[0] if chosen else start
pea = ida_bytes.prev_head(first, lo)
- cursor = {"done": True} if pea == idaapi.BADADDR or pea < lo else {"prev": hex(pea)}
+ cursor = (
+ {"done": True} if pea == idaapi.BADADDR or pea < lo else {"prev": hex(pea)}
+ )
return {"addr": str(addr), "heads": rows, "cursor": cursor}
# Walk by item END (not next_head): next_head SKIPS undefined bytes, but a
@@ -823,8 +910,9 @@ def heads(
# A code label (loc_XXX/jump target) gets its OWN line at depth 0,
# like IDA; strip it from the instruction row below.
nm = row["name"]
- out.append({"ea": hex(e), "kind": "label", "size": 0,
- "text": nm + ":", "name": nm})
+ out.append(
+ {"ea": hex(e), "kind": "label", "size": 0, "text": nm + ":", "name": nm}
+ )
row = dict(row)
row["name"] = None
out.append(row)
@@ -845,7 +933,7 @@ def heads(
if len(rows) >= count:
more = True
break
- f = get_flags(ea) # once per head, not once per consumer
+ f = get_flags(ea) # once per head, not once per consumer
rows.extend(_rows_for(ea, f)) # a struct head expands into member rows
ea = _advance(ea, f)
cursor = {"next": hex(ea)} if more else {"done": True}
@@ -871,8 +959,18 @@ def heads(
_IDATUI_FMT_CYCLE = ("hex", "dec", "bin", "char", "offset", "default")
-_IDATUI_FMT_SETTABLE = ("hex", "dec", "oct", "bin", "char", "offset", "seg",
- "float", "stack", "default")
+_IDATUI_FMT_SETTABLE = (
+ "hex",
+ "dec",
+ "oct",
+ "bin",
+ "char",
+ "offset",
+ "seg",
+ "float",
+ "stack",
+ "default",
+)
def _idatui_fmt_nibbles():
@@ -880,13 +978,20 @@ def _idatui_fmt_nibbles():
this module is injected into a file that is imported before a database is
open."""
return {
- "default": ida_bytes.FF_N_VOID, "hex": ida_bytes.FF_N_NUMH,
- "dec": ida_bytes.FF_N_NUMD, "char": ida_bytes.FF_N_CHAR,
- "seg": ida_bytes.FF_N_SEG, "offset": ida_bytes.FF_N_OFF,
- "bin": ida_bytes.FF_N_NUMB, "oct": ida_bytes.FF_N_NUMO,
- "enum": ida_bytes.FF_N_ENUM, "forced": ida_bytes.FF_N_FOP,
- "stroff": ida_bytes.FF_N_STRO, "stack": ida_bytes.FF_N_STK,
- "float": ida_bytes.FF_N_FLT, "custom": ida_bytes.FF_N_CUST,
+ "default": ida_bytes.FF_N_VOID,
+ "hex": ida_bytes.FF_N_NUMH,
+ "dec": ida_bytes.FF_N_NUMD,
+ "char": ida_bytes.FF_N_CHAR,
+ "seg": ida_bytes.FF_N_SEG,
+ "offset": ida_bytes.FF_N_OFF,
+ "bin": ida_bytes.FF_N_NUMB,
+ "oct": ida_bytes.FF_N_NUMO,
+ "enum": ida_bytes.FF_N_ENUM,
+ "forced": ida_bytes.FF_N_FOP,
+ "stroff": ida_bytes.FF_N_STRO,
+ "stack": ida_bytes.FF_N_STK,
+ "float": ida_bytes.FF_N_FLT,
+ "custom": ida_bytes.FF_N_CUST,
}
@@ -932,8 +1037,12 @@ def _idatui_op_value(ea, n):
size = 0
return int(v), size
size = int(ida_bytes.get_item_size(ea))
- read = {1: ida_bytes.get_byte, 2: ida_bytes.get_word,
- 4: ida_bytes.get_dword, 8: ida_bytes.get_qword}.get(size)
+ read = {
+ 1: ida_bytes.get_byte,
+ 2: ida_bytes.get_word,
+ 4: ida_bytes.get_dword,
+ 8: ida_bytes.get_qword,
+ }.get(size)
if read is None:
return None, size
try:
@@ -991,9 +1100,9 @@ def _idatui_op_candidates(ea):
F = ida_bytes.get_flags(ea)
if ida_bytes.is_data(F):
- return [0] # a data item's value is operand 0
+ return [0] # a data item's value is operand 0
if not ida_bytes.is_code(F):
- return [] # undefined bytes: IDA refuses a format outright
+ return [] # undefined bytes: IDA refuses a format outright
insn = ida_ua.insn_t()
if ida_ua.decode_insn(insn, ea) <= 0:
return []
@@ -1037,7 +1146,7 @@ def _idatui_op_spans(ea, text):
if not op:
continue
i = text.find(op, pos)
- if i < 0: # duplicated operand text (mov eax, eax)
+ if i < 0: # duplicated operand text (mov eax, eax)
i = text.find(op)
if i < 0:
continue
@@ -1070,21 +1179,34 @@ def _idatui_apply_fmt(ea, n, fmt):
if base in (idaapi.BADADDR, None) or base < 0:
base = 0
return bool(ida_offset.op_plain_offset(ea, n, base)), ""
- fn = {"hex": ida_bytes.op_hex, "dec": ida_bytes.op_dec,
- "oct": ida_bytes.op_oct, "bin": ida_bytes.op_bin,
- "char": ida_bytes.op_chr, "seg": ida_bytes.op_seg,
- "float": ida_bytes.op_flt, "stack": ida_bytes.op_stkvar}.get(fmt)
+ fn = {
+ "hex": ida_bytes.op_hex,
+ "dec": ida_bytes.op_dec,
+ "oct": ida_bytes.op_oct,
+ "bin": ida_bytes.op_bin,
+ "char": ida_bytes.op_chr,
+ "seg": ida_bytes.op_seg,
+ "float": ida_bytes.op_flt,
+ "stack": ida_bytes.op_stkvar,
+ }.get(fmt)
if fn is None:
- return False, (f"can't set {fmt!r} from a name alone"
- if fmt in _idatui_fmt_nibbles() else
- f"unknown format {fmt!r}")
+ return False, (
+ f"can't set {fmt!r} from a name alone"
+ if fmt in _idatui_fmt_nibbles()
+ else f"unknown format {fmt!r}"
+ )
return bool(fn(ea, n)), ""
def op_format(
addr: Annotated[str, "Address of the instruction or data item"],
- mode: Annotated[str, "cycle | back | show | hex | dec | oct | bin | char | offset | stack | default"] = "cycle",
- col: Annotated[int, "Cursor column inside the rendered line (-1: first literal)"] = -1,
+ mode: Annotated[
+ str,
+ "cycle | back | show | hex | dec | oct | bin | char | offset | stack | default",
+ ] = "cycle",
+ col: Annotated[
+ int, "Cursor column inside the rendered line (-1: first literal)"
+ ] = -1,
n: Annotated[int, "Operand index; -1 derives it from ``col``"] = -1,
) -> dict:
"""Change how a literal is DISPLAYED (IDA's 'o' family): hex, decimal,
@@ -1126,19 +1248,27 @@ def op_format(
# a different operand would make that highlight a lie -- say
# which one can be changed instead.
where = before[lo:hi].strip()
- alt = (f"; the literal on this line is operand {cands[0]} "
- f"({_idatui_op_text(ea, before, cands[0])})"
- if cands else "")
- return {"addr": hex(ea), "n": i, "text": before,
- "error": f"operand {i} ({where}) has no format to "
- f"change{alt}"}
+ alt = (
+ f"; the literal on this line is operand {cands[0]} "
+ f"({_idatui_op_text(ea, before, cands[0])})"
+ if cands
+ else ""
+ )
+ return {
+ "addr": hex(ea),
+ "n": i,
+ "text": before,
+ "error": f"operand {i} ({where}) has no format to change{alt}",
+ }
if n < 0:
if not cands:
F = ida_bytes.get_flags(ea)
- why = ("no literal on this line to reformat"
- if ida_bytes.is_code(F) or ida_bytes.is_data(F) else
- "undefined bytes have no format to change -- define "
- "them first ('d' makes data, 'c' makes code)")
+ why = (
+ "no literal on this line to reformat"
+ if ida_bytes.is_code(F) or ida_bytes.is_data(F)
+ else "undefined bytes have no format to change -- define "
+ "them first ('d' makes data, 'c' makes code)"
+ )
return {"addr": hex(ea), "text": before, "error": why}
n = cands[0]
@@ -1148,9 +1278,12 @@ def op_format(
# The ring is a property of the OPERAND, not of what you last pressed: every
# stop is one that changes what you see for this value, and it is the same
# ring at every step, so a lap always comes home.
- choices = [f for f in _IDATUI_FMT_CYCLE
- if (f != "char" or _idatui_printable(value))
- and (f != "offset" or _idatui_offset_worth(value))]
+ choices = [
+ f
+ for f in _IDATUI_FMT_CYCLE
+ if (f != "char" or _idatui_printable(value))
+ and (f != "offset" or _idatui_offset_worth(value))
+ ]
# A stack variable is deliberately NOT a stop: ``[rbp+var_40]`` is a frame
# member, not a way of writing a number, and IDA's own "is this a stack
# variable" test isn't exposed to Python here (calc_stkvar_struc_offset
@@ -1160,10 +1293,18 @@ def op_format(
mode = str(mode or "cycle").lower()
if mode == "show":
- return {"addr": hex(ea), "n": n, "format": cur, "prev": cur,
- "choices": choices, "text": before, "before": before,
- "value": None if value is None else hex(value),
- "width": width, "applied": False}
+ return {
+ "addr": hex(ea),
+ "n": n,
+ "format": cur,
+ "prev": cur,
+ "choices": choices,
+ "text": before,
+ "before": before,
+ "value": None if value is None else hex(value),
+ "width": width,
+ "applied": False,
+ }
if mode in ("cycle", "back"):
step = 1 if mode == "cycle" else -1
if cur in choices:
@@ -1176,31 +1317,51 @@ def op_format(
else:
want = mode
if want not in _idatui_fmt_nibbles():
- return {"addr": hex(ea), "n": n, "text": before,
- "error": f"unknown format {mode!r}; one of "
- + ", ".join(_IDATUI_FMT_SETTABLE)}
+ return {
+ "addr": hex(ea),
+ "n": n,
+ "text": before,
+ "error": f"unknown format {mode!r}; one of "
+ + ", ".join(_IDATUI_FMT_SETTABLE),
+ }
if want == "offset" and not mapped:
- return {"addr": hex(ea), "n": n, "text": before, "format": cur,
- "error": (f"{'0x%x' % value if value is not None else 'this operand'}"
- " isn't a mapped address -- an offset to it would"
- " invent a name for nothing")}
+ return {
+ "addr": hex(ea),
+ "n": n,
+ "text": before,
+ "format": cur,
+ "error": (
+ f"{'0x%x' % value if value is not None else 'this operand'}"
+ " isn't a mapped address -- an offset to it would"
+ " invent a name for nothing"
+ ),
+ }
ok, err = _idatui_apply_fmt(ea, n, want)
if err:
- return {"addr": hex(ea), "n": n, "text": before, "format": cur,
- "error": err}
+ return {"addr": hex(ea), "n": n, "text": before, "format": cur, "error": err}
got = _idatui_op_fmt(ea, n)
- out = {"addr": hex(ea), "n": n, "prev": cur, "format": got,
- "requested": want, "applied": bool(ok), "choices": choices,
- "before": before, "text": _idatui_line_text(ea),
- "value": None if value is None else hex(value), "width": width}
+ out = {
+ "addr": hex(ea),
+ "n": n,
+ "prev": cur,
+ "format": got,
+ "requested": want,
+ "applied": bool(ok),
+ "choices": choices,
+ "before": before,
+ "text": _idatui_line_text(ea),
+ "value": None if value is None else hex(value),
+ "width": width,
+ }
if not ok:
out["error"] = f"IDA refused {want} on operand {n}"
elif lossy:
- out["warn"] = (
- f"operand {n} was {cur} and the ring has no stop there -- "
- + (f"'{cur}' sets it again" if cur in _IDATUI_FMT_SETTABLE else
- f"{cur} names a type this can't put back, reassign it by hand"))
+ out["warn"] = f"operand {n} was {cur} and the ring has no stop there -- " + (
+ f"'{cur}' sets it again"
+ if cur in _IDATUI_FMT_SETTABLE
+ else f"{cur} names a type this can't put back, reassign it by hand"
+ )
return out
@@ -1267,13 +1428,18 @@ def _idatui_lit_extent(plain, x):
around the column, which cannot reach a ``)`` or a space."""
if x >= len(plain):
return None
- if plain[x] == "'": # a character constant: '-'
+ if plain[x] == "'": # a character constant: '-'
end = plain.find("'", x + 1)
return (x, end + 1) if end > x else None
lo = plain.rfind("'", 0, x)
- if lo >= 0 and plain.find("'", x) > x and "'" in plain[lo:x] and \
- plain[lo:x].count("'") == 1 and " " not in plain[lo:x]:
- return (lo, plain.find("'", x) + 1) # inside 'c'
+ if (
+ lo >= 0
+ and plain.find("'", x) > x
+ and "'" in plain[lo:x]
+ and plain[lo:x].count("'") == 1
+ and " " not in plain[lo:x]
+ ):
+ return (lo, plain.find("'", x) + 1) # inside 'c'
if plain[x] not in _IDATUI_LIT_CHARS:
return None
lo = x
@@ -1282,7 +1448,7 @@ def _idatui_lit_extent(plain, x):
hi = x
while hi < len(plain) and plain[hi] in _IDATUI_LIT_CHARS:
hi += 1
- if lo > 0 and plain[lo - 1] == "-": # a unary minus is part of it
+ if lo > 0 and plain[lo - 1] == "-": # a unary minus is part of it
lo -= 1
return (lo, hi)
@@ -1321,26 +1487,36 @@ def _idatui_pc_nums(cf, sl):
continue
nf = e.n.nf
opnum = ord(nf.opnum) if isinstance(nf.opnum, str) else int(nf.opnum)
- nbytes = (ord(nf.org_nbytes) if isinstance(nf.org_nbytes, str)
- else int(nf.org_nbytes))
+ nbytes = (
+ ord(nf.org_nbytes) if isinstance(nf.org_nbytes, str) else int(nf.org_nbytes)
+ )
ea = int(e.ea)
if ea == idaapi.BADADDR:
x = extent[1]
- continue # synthesised: nothing to key on
+ continue # synthesised: nothing to key on
nib = (nf.flags >> ida_bytes.get_operand_type_shift(opnum)) & 0xF
# Whether this format is the USER's or Hex-Rays' own guess. The nibble
# can't say: an untouched number reads back as whatever it happens to
# be printed as, and cycling from there would skip that stop forever
# (default already looks like it) and never come back to it.
loc = ida_hexrays.operand_locator_t(ea, opnum)
- user = (ida_hexrays.user_numforms_find(cf.numforms, loc)
- != ida_hexrays.user_numforms_end(cf.numforms))
- out.append({"x0": extent[0], "x1": extent[1], "ea": ea,
- "opnum": opnum, "value": int(e.n._value),
- "nbytes": nbytes, "user": user,
- "fmt": _idatui_fmt_name(nib) if user else "default",
- "shown": _idatui_fmt_name(nib)})
- x = extent[1] # past this literal, not into it
+ user = ida_hexrays.user_numforms_find(
+ cf.numforms, loc
+ ) != ida_hexrays.user_numforms_end(cf.numforms)
+ out.append(
+ {
+ "x0": extent[0],
+ "x1": extent[1],
+ "ea": ea,
+ "opnum": opnum,
+ "value": int(e.n._value),
+ "nbytes": nbytes,
+ "user": user,
+ "fmt": _idatui_fmt_name(nib) if user else "default",
+ "shown": _idatui_fmt_name(nib),
+ }
+ )
+ x = extent[1] # past this literal, not into it
return out
@@ -1367,31 +1543,36 @@ def pc_nums(
try:
cf = ida_hexrays.decompile(f.start_ea)
except Exception as e:
- return {"addr": hex(f.start_ea), "error": f"decompile failed: {e}",
- "nums": []}
+ return {"addr": hex(f.start_ea), "error": f"decompile failed: {e}", "nums": []}
if cf is None:
- return {"addr": hex(f.start_ea), "error": "decompilation failed",
- "nums": []}
+ return {"addr": hex(f.start_ea), "error": "decompilation failed", "nums": []}
sv = cf.get_pseudocode()
out = []
for i in range(len(sv)):
plain = ida_lines.tag_remove(sv[i].line)
compact = _idatui_compact(plain)
for rec in _idatui_pc_nums(cf, sv[i]):
- out.append({
- "line": i,
- "x0": _idatui_compact_col(plain, compact, rec["x0"]),
- "x1": _idatui_compact_col(plain, compact, rec["x1"]),
- "ea": hex(rec["ea"]), "opnum": rec["opnum"],
- "value": hex(rec["value"]), "fmt": rec["fmt"],
- "shown": rec["shown"], "user": bool(rec["user"]),
- })
+ out.append(
+ {
+ "line": i,
+ "x0": _idatui_compact_col(plain, compact, rec["x0"]),
+ "x1": _idatui_compact_col(plain, compact, rec["x1"]),
+ "ea": hex(rec["ea"]),
+ "opnum": rec["opnum"],
+ "value": hex(rec["value"]),
+ "fmt": rec["fmt"],
+ "shown": rec["shown"],
+ "user": bool(rec["user"]),
+ }
+ )
return {"addr": hex(f.start_ea), "nums": out, "lines": len(sv)}
def pc_num_format(
addr: Annotated[str, "Function address (or any address inside it)"],
- mode: Annotated[str, "cycle | back | show | hex | dec | oct | char | default"] = "cycle",
+ mode: Annotated[
+ str, "cycle | back | show | hex | dec | oct | char | default"
+ ] = "cycle",
line: Annotated[int, "0-based pseudocode line index"] = -1,
col: Annotated[int, "Cursor column in the DISPLAYED line (-1: first literal)"] = -1,
ea: Annotated[str, "Address of the number instead of line/col"] = "",
@@ -1431,8 +1612,9 @@ def pc_num_format(
return {"addr": hex(f.start_ea), "error": str(e)}
for i in range(len(sv)):
for rec in _idatui_pc_nums(cf, sv[i]):
- if rec["ea"] == want_ea and (int(opnum) < 0
- or rec["opnum"] == int(opnum)):
+ if rec["ea"] == want_ea and (
+ int(opnum) < 0 or rec["opnum"] == int(opnum)
+ ):
target, line = rec, i
break
if target:
@@ -1446,26 +1628,42 @@ def pc_num_format(
target = next((r for r in nums if r["x0"] <= x < r["x1"]), None)
target = target or nums[0]
else:
- return {"addr": hex(f.start_ea),
- "error": f"line {line} is outside the {len(sv)}-line decompilation"}
+ return {
+ "addr": hex(f.start_ea),
+ "error": f"line {line} is outside the {len(sv)}-line decompilation",
+ }
if target is None:
- return {"addr": hex(f.start_ea), "line": line,
- "text": (ida_lines.tag_remove(sv[line].line).strip()
- if 0 <= line < len(sv) else ""),
- "error": "no number literal on this line"}
+ return {
+ "addr": hex(f.start_ea),
+ "line": line,
+ "text": (
+ ida_lines.tag_remove(sv[line].line).strip()
+ if 0 <= line < len(sv)
+ else ""
+ ),
+ "error": "no number literal on this line",
+ }
cur, value = target["fmt"], target["value"]
- choices = [c for c in _IDATUI_PC_FMT_CYCLE
- if c != "char" or _idatui_printable(value)]
+ choices = [
+ c for c in _IDATUI_PC_FMT_CYCLE if c != "char" or _idatui_printable(value)
+ ]
# Same rule as the listing: one ring per literal, every step. A format the
# ring can't hold (an enum set in the GUI) is reported on the way out
# instead of being kept for one lap and then lost.
lossy = cur not in choices and cur != "default"
- out = {"addr": hex(f.start_ea), "ea": hex(target["ea"]),
- "opnum": target["opnum"], "line": line, "prev": cur,
- "format": cur, "shown": target["shown"], "choices": choices,
- "value": hex(value),
- "before": ida_lines.tag_remove(sv[line].line).strip()}
+ out = {
+ "addr": hex(f.start_ea),
+ "ea": hex(target["ea"]),
+ "opnum": target["opnum"],
+ "line": line,
+ "prev": cur,
+ "format": cur,
+ "shown": target["shown"],
+ "choices": choices,
+ "value": hex(value),
+ "before": ida_lines.tag_remove(sv[line].line).strip(),
+ }
mode = str(mode or "cycle").lower()
if mode == "show":
@@ -1481,13 +1679,16 @@ def pc_num_format(
else:
want = mode
if want in ("bin", "offset", "stack", "seg", "float"):
- out["error"] = (f"Hex-Rays has no {want} format for a number "
- f"-- set it on the listing instead")
+ out["error"] = (
+ f"Hex-Rays has no {want} format for a number "
+ f"-- set it on the listing instead"
+ )
out["text"] = out["before"]
return out
if want not in ("hex", "dec", "oct", "char", "default"):
- out["error"] = (f"unknown format {mode!r}; one of hex, dec, oct, "
- f"char, default")
+ out["error"] = (
+ f"unknown format {mode!r}; one of hex, dec, oct, char, default"
+ )
out["text"] = out["before"]
return out
@@ -1499,8 +1700,9 @@ def pc_num_format(
ida_hexrays.user_numforms_erase(cf.numforms, it)
if want != "default":
nf = ida_hexrays.number_format_t(target["opnum"])
- nf.flags = ida_bytes.get_operand_flag(_idatui_fmt_nibbles()[want],
- target["opnum"])
+ nf.flags = ida_bytes.get_operand_flag(
+ _idatui_fmt_nibbles()[want], target["opnum"]
+ )
try:
nf.org_nbytes = target["nbytes"]
except Exception:
@@ -1515,14 +1717,18 @@ def pc_num_format(
out["format"] = want
out["applied"] = True
if lossy:
- out["warn"] = (f"this number was {cur}, which names a type a radix "
- f"can't put back -- reassign it in IDA")
+ out["warn"] = (
+ f"this number was {cur}, which names a type a radix "
+ f"can't put back -- reassign it in IDA"
+ )
try:
- cf2 = ida_hexrays.decompile(f.start_ea,
- flags=ida_hexrays.DECOMP_NO_CACHE)
+ cf2 = ida_hexrays.decompile(f.start_ea, flags=ida_hexrays.DECOMP_NO_CACHE)
sv2 = cf2.get_pseudocode() if cf2 is not None else None
- out["text"] = (ida_lines.tag_remove(sv2[line].line).strip()
- if sv2 is not None and line < len(sv2) else out["before"])
+ out["text"] = (
+ ida_lines.tag_remove(sv2[line].line).strip()
+ if sv2 is not None and line < len(sv2)
+ else out["before"]
+ )
except Exception as e:
out["text"] = out["before"]
out["warn"] = f"re-render failed: {e}"
@@ -1534,7 +1740,7 @@ def decompile(addr, include_addresses=True):
Faithful to the tool ida-tui was written against, and in particular to its
COST: the per-line address anchor comes from ONE ``get_line_item`` at column
- 0 per line. The Code Mode port asked for the full per-column line map (what
+ 0 per line. The IDA Nexus port asked for the full per-column line map (what
``decomp_map`` is for) purely to fill in that anchor, which is thousands of
``get_line_item``+``dstr()`` calls per function instead of one per line, and
made every pseudocode open cost the same as opening the split view.
@@ -1557,11 +1763,17 @@ def decompile(addr, include_addresses=True):
try:
cfunc = ida_hexrays.decompile_func(fn, failure)
except Exception as e:
- return {"addr": hex(int(fn.start_ea)), "code": None,
- "error": f"Decompilation failed at {ea:#x}: {e}"}
+ return {
+ "addr": hex(int(fn.start_ea)),
+ "code": None,
+ "error": f"Decompilation failed at {ea:#x}: {e}",
+ }
if cfunc is None:
- return {"addr": hex(int(fn.start_ea)), "code": None,
- "error": failure.desc() or f"Decompilation failed at {ea:#x}"}
+ return {
+ "addr": hex(int(fn.start_ea)),
+ "code": None,
+ "error": failure.desc() or f"Decompilation failed at {ea:#x}",
+ }
lines = []
for sl in cfunc.get_pseudocode():
@@ -1569,7 +1781,9 @@ def decompile(addr, include_addresses=True):
item = ida_hexrays.ctree_item_t()
tail = ida_hexrays.ctree_item_t()
line_ea = None
- if include_addresses and cfunc.get_line_item(sl.line, 0, False, head, item, tail):
+ if include_addresses and cfunc.get_line_item(
+ sl.line, 0, False, head, item, tail
+ ):
parts = (item.dstr() or "").split(": ")
if len(parts) == 2:
try:
@@ -1595,9 +1809,13 @@ def decompile(addr, include_addresses=True):
text = raw.decode("utf-8", "replace") if raw else None
except Exception:
text = None
- refs.append({"addr": hex(target),
- "name": ida_name.get_name(target) or "",
- "string": text})
+ refs.append(
+ {
+ "addr": hex(target),
+ "name": ida_name.get_name(target) or "",
+ "string": text,
+ }
+ )
return 0
try:
@@ -1615,6 +1833,7 @@ def decomp_map(
swept across the line's columns via get_line_item. Shape:
{addr, lines:[{ea: primary|None, eas:[hex,...]}, ...]}."""
import ida_hexrays
+
try:
ea = int(str(addr), 16)
except ValueError:
@@ -1691,3 +1910,32 @@ def decomp_map(
eas.append(hex(e))
lines.append({"ea": eas[0] if eas else None, "eas": eas})
return {"addr": hex(func.start_ea), "lines": lines}
+
+
+def profile_remote(operation, args, reps=5):
+ """Profile one persistent remote tool entirely inside IDA."""
+ import cProfile
+ import io
+ import pstats
+
+ functions = {
+ "heads": heads,
+ "segment_index": segment_index,
+ "op_format": op_format,
+ "pc_nums": pc_nums,
+ "decompile": decompile,
+ "decomp_map": decomp_map,
+ "pc_num_format": pc_num_format,
+ }
+ function = functions.get(str(operation))
+ if function is None:
+ raise ValueError(f"unknown profile operation: {operation!r}")
+ profiler = cProfile.Profile()
+ for _ in range(max(1, int(reps))):
+ profiler.runcall(function, **dict(args))
+ stats = pstats.Stats(profiler)
+ output = io.StringIO()
+ stats.stream = output
+ stats.sort_stats("tottime")
+ stats.print_stats(80)
+ return {"stats": output.getvalue(), "total": stats.total_tt, "reps": reps}