From 0db18f870a45c0355ca1b28db1a2e763b60f6226 Mon Sep 17 00:00:00 2001 From: Duncan Ogilvie Date: Fri, 31 Jul 2026 01:40:21 +0200 Subject: [PATCH] WIP: vibeslop ida-codemode port --- README.md | 100 +-- TODO | 17 +- docs/CODEMODE_PORT.md | 175 +++++ docs/PAGING_FINDINGS.md | 54 +- docs/PROJECTS.md | 41 +- docs/SPLIT_VIEW.md | 12 +- experiments/worker_smoke.py | 104 ++- ida-tui | 7 +- idatui/__init__.py | 5 +- idatui/app.py | 206 +++--- idatui/codemode_client.py | 1107 +++++++++++++++++++++++++++++ idatui/domain.py | 194 +++-- idatui/drive.py | 3 +- idatui/errors.py | 10 +- idatui/launch.py | 92 +-- idatui/pane.py | 92 +-- idatui/pool.py | 100 +-- idatui/project.py | 35 +- idatui/worker.py | 233 ------ idatui/worker_client.py | 234 ------- pyproject.toml | 17 +- server/patch_server.py | 1248 --------------------------------- tests/test_codemode_client.py | 137 ++++ tests/test_pool.py | 61 +- tests/test_project.py | 2 +- tests/test_scenarios.py | 18 +- uv.lock | 65 +- 27 files changed, 2055 insertions(+), 2314 deletions(-) create mode 100644 docs/CODEMODE_PORT.md create mode 100644 idatui/codemode_client.py delete mode 100644 idatui/worker.py delete mode 100644 idatui/worker_client.py delete mode 100644 server/patch_server.py create mode 100644 tests/test_codemode_client.py diff --git a/README.md b/README.md index 7d7d8d7..47d5366 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # ida-tui A minimal, keyboard-first (mouse-capable) **TUI frontend for IDA Pro**, built with -[Textual](https://textual.textualize.io/) and driving **idalib** (IDA headless). +[Textual](https://textual.textualize.io/) and using +[ida-codemode-mcp](../ida-codemode-mcp) as a Python library. -Opening a binary spawns our own **idalib worker** — a private subprocess talking a -unix socket (`idatui/worker.py` + `WorkerClient`), ~50–100× cheaper per call than -an HTTP transport. It reuses [ida-pro-mcp](https://github.com/mrexodia/ida-pro-mcp)'s -tool implementations in-process; the old ida-pro-mcp HTTP server/supervisor path -has been **removed**. +ida-tui attaches to databases through `ida_codemode.client.DatabaseHandle`. A +matching database already open in the IDA GUI is reused; otherwise Code Mode +reuses or starts a shared managed idalib worker. The TUI owns only a client lease, +never the GUI or worker process. ## ⚠️ Status: not ready for public consumption @@ -31,7 +31,7 @@ don't file expectations. **Use at your own risk.** - A unified **IDA-style listing** (continuous disassembly interleaved with data / undefined heads) as the default code view; `F5`/`Tab` drops into the **decompiler (pseudocode)** for the function under the cursor. Both are - line-virtualized and page lazily over the worker. + line-virtualized and page lazily over the Code Mode database. - A **Ghidra-style split view** (`s`): listing and pseudocode side by side, kept in cursor sync — the focused pane drives and the other highlights the linked region (every instruction a C line owns), following you across functions. @@ -49,46 +49,58 @@ don't file expectations. **Use at your own risk.** ## Architecture (three layers, kept separate) -- **`idatui/worker.py` + `idatui/worker_client.py`** — the backend. `worker.py` - opens one DB with idalib (on its main thread) and serves ida-pro-mcp's tool - functions over a unix socket; `WorkerClient` spawns it and is a stdlib-only - drop-in client (length-prefixed pickle, calls serialized under a lock). Shared - error types + the `Session` model live in `idatui/errors.py`. -- **`idatui/domain.py`** — paging/caching over the worker client (`FunctionIndex`, - `DisasmModel`, `ListingModel`, `decompile`, xrefs, resolve). Synchronous, - thread-safe. Tools ida-pro-mcp lacks (`heads`, `read_raw`, `resolve_names`, - `xref_types`, …) are injected by `server/patch_server.py`, which the worker - runs itself on startup. +- **`idatui/codemode_client.py`** — lifecycle and execution adapter. It leases a + registered GUI/idalib instance with `DatabaseHandle`, waits for autoanalysis, + normalizes errors, saves, and releases the lease. Address-centric operations + are sent through Code Mode's `execute_python` surface and use its preloaded + `ida-domain` `db` object. +- **`idatui/domain.py`** — synchronous, thread-safe paging/caching + (`FunctionIndex`, `DisasmModel`, `ListingModel`, decompile, xrefs, resolve). + It has no process/database ownership logic. - **`idatui/app.py`** — the Textual app (virtualized `ScrollView`s, shared cursor/ search/nav mixins, modals). -The domain + worker-client layers are intentionally **stdlib-only** (the worker -process links idalib); only the TUI layer pulls in Textual + Pygments. +`idatui/pool.py` retains LRU project leases. Releasing an entry never kills a GUI +or another client's worker. See `docs/CODEMODE_PORT.md` for what maps to public +ida-domain APIs and which remaining features require IDAPython inside the Code +Mode execution sandbox. ## Requirements - Python ≥ 3.11 -- A working **IDA Pro** with **idalib** and **ida-pro-mcp** installed (the worker - reuses ida-pro-mcp's tool implementations in-process — no server runs). -- Textual ≥ 8 and Pygments ≥ 2 for the TUI (`pip install -e '.[tui]'`). +- IDA Pro 9.4+ with idalib configured +- `ida-codemode-mcp` installed in the TUI environment (this checkout uses the + editable sibling path `../ida-codemode-mcp`) +- The ida-codemode IDA plugin installed so GUI databases register themselves +- Textual ≥ 8 and Pygments ≥ 2 (`uv sync` installs both) -Two python environments are expected: one with **textual + idapro** for the TUI -(`~/ida-venv`, override `$IDATUI_PYTHON`) and one with **idapro + ida_pro_mcp** -for the worker (auto-detected, override `$IDATUI_WORKER_PYTHON`). +Code Mode's own worker launcher carries the correct Python environment; ida-tui +no longer searches for a second Python or imports `ida_pro_mcp`. ## Running -One command — it spawns a private idalib worker for the binary (which opens + -auto-analyzes it in its own process over a unix socket) and drops you into the -TUI behind a loading overlay: +Install the project and its TUI dependencies: ```sh -./ida-tui /path/to/binary # open a binary and drive it — that's it +uv sync ``` -It uses `~/ida-venv/bin/python` for the TUI (override with `$IDATUI_PYTHON`) and -resolves binary paths against your real cwd. The binary's directory must be -writable (idalib writes a `.i64` there). +Pass an executable/IDB path. If the plugin has registered a matching GUI session, +ida-tui attaches to it; otherwise Code Mode opens a managed idalib database: + +```sh +./ida-tui /path/to/binary +``` + +With exactly one registered database, the path may be omitted: + +```sh +./ida-tui +``` + +When several databases are registered, the launcher lists their paths and asks +for one explicitly. A newly managed single-binary database still needs a writable +output location; projects stage binaries and IDBs in their sidecar directory. Headerless blobs need to be told what they are — a raw firmware dump has no format to detect, and IDA falls back to x86 at address 0, which analyses to @@ -102,15 +114,16 @@ ARM images that use Thumb need one more thing: press `t` on the listing to switc ARM/Thumb decoding at the cursor (it sets IDA's `T` register, and the segment to 32-bit, since Thumb doesn't exist in AArch64). -`--base` is a real address (IDA's own `-b` is in paragraphs; the conversion is -done for you). In a project the options are recorded per binary, which is what a -multi-image firmware wants. They apply to the first open only — after that the -`.i64` records how the image was loaded. See `docs/PROJECTS.md`. +`--base` is a real address (Code Mode's typed loading address is also natural, +so no paragraph conversion crosses the dependency boundary). In a project the +options are recorded per binary. They apply only when Code Mode must create the +first database; a registered or existing IDB already records them. Arbitrary +`--ida-args` are rejected because `DatabaseHandle.open()` has no equivalent; +processor, base, and loader/file type are the supported import surface. -> Recovering a wedged database: if a worker was hard-killed it leaves unpacked -> `foo.id0/.id1/.id2/.nam/.til` next to `foo.i64`, and the `.i64` then refuses to -> reopen. Delete those stale files (never the `.i64`) and retry — `ida-tui` does -> this automatically. +ida-tui never deletes unpacked IDA scratch files during discovery: those files +may belong to a registered GUI or another Code Mode client. Registry locks and +health probes are the ownership authority. ## Execution traces @@ -166,8 +179,8 @@ See `docs/RPC.md` for the full protocol. ## Tests -A headless Textual `Pilot` suite lives in `tests/`; it spawns a worker on the -given binary (default `targets/echo`): +A headless Textual `Pilot` suite lives in `tests/`; it attaches through Code Mode +(or starts a managed worker) for the given binary: ```sh python tests/test_scenarios.py targets/echo # full UI suite @@ -177,6 +190,7 @@ python tests/test_scenarios.py --only hex,rename ## Docs - `docs/RPC.md` — the RPC protocol -- `docs/PAGING_FINDINGS.md` — idalib tool paging/scale quirks +- `docs/CODEMODE_PORT.md` — port coverage, API gaps, and lifecycle semantics +- `docs/PAGING_FINDINGS.md` — historical paging/scale findings - `docs/TEXTUAL_NOTES.md` — Textual pitfalls encountered - `docs/TUI_DRIVING_BLUEPRINT.md` — generalizing the driving layer diff --git a/TODO b/TODO index 58b4693..c578cff 100644 --- a/TODO +++ b/TODO @@ -4,14 +4,15 @@ bugs: current: -[~] DITCH ida-pro-mcp -> our own idalib worker (idatui/worker.py + WorkerClient) - [x] worker + WorkerClient (drop-in for IDAClient, same tool shapes) - [x] --backend {worker,mcp}; worker is now the DEFAULT for opening a binary - [x] worker spawns under the IDA python; {"result":...} wrapping to match MCP - [ ] run the pilot suite against --backend worker (blocked: idalib reaping here) - [ ] progress reporting during analysis (worker streams notes to the overlay) - [ ] once solid: delete client.py, server/patch_server.py, spawn.sh, and - launch.py's whole supervisor/ensure_server/lock-sweep dance +[x] PORT TO ida-codemode-mcp as a library dependency + [x] DatabaseHandle discovery prefers registered GUI sessions + [x] shared managed idalib workers + SSE lease lifecycle + [x] domain operations execute against ida-domain through Code Mode + [x] delete the private pickle worker and ida-pro-mcp patch injection + [x] stop sweeping/reaping resources that may belong to another client + [ ] run the full live Pilot suite against both GUI and managed backends + [ ] add database revision/change notifications for cross-client cache invalidation + [ ] decide how "discard changes" should work (Code Mode final workers save) [x] RPC endpoint for robot-spectator-ida -> progressssss diff --git a/docs/CODEMODE_PORT.md b/docs/CODEMODE_PORT.md new file mode 100644 index 0000000..4b4bc45 --- /dev/null +++ b/docs/CODEMODE_PORT.md @@ -0,0 +1,175 @@ +# ida-tui → IDA Code Mode port + +This port is an experiment: can ida-tui be implemented as an ordinary client of +`ida_codemode`, sharing GUI databases and managed idalib workers instead of +owning a private worker and depending on ida-pro-mcp tool functions? + +## Result + +Yes for the database lifecycle and the complete current TUI feature set, with a +small number of operations implemented using IDAPython inside Code Mode's +`execute_python` sandbox because ida-domain does not yet expose the required +behavior. + +The old components are gone: + +- `idatui/worker.py` (private pickle/socket idalib process) +- `idatui/worker_client.py` +- `server/patch_server.py` (ida-pro-mcp tool injection) + +The replacement is `idatui/codemode_client.py`. + +## Lifecycle mapping + +`CodeModeClient.connect()` calls `ida_codemode.client.DatabaseHandle.open()`. +Resolution is therefore Code Mode's resolution, not ida-tui's: + +1. Match a registered GUI by executable path. +2. Otherwise match the owner of the expected IDB. +3. Otherwise serialize creation and start a managed `ida-codemode-worker`. +4. Establish an authenticated SSE lease. +5. Wait through the public autoanalysis route. + +The handle's registry entry supplies the backend, PID, executable path, IDB path, +and record ID used by the status/pool layers. + +Closing ida-tui closes only its lease. It never closes a GUI or kills an idalib +process. A managed worker saves and exits under Code Mode's own policy after its +last lease disappears. A second agent or TUI can keep using the same instance. + +This also changes project pooling semantics. `DatabasePool` is an LRU pool of +leases, not process ownership. Managed-IDB save-on-evict remains; budget eviction +does not implicitly save a GUI. Eviction cannot force a shared worker to exit, +and GUI process memory is only advisory. + +## ida-domain coverage + +The remote snippets receive Code Mode's preloaded `db` (`ida_domain.Database`). +The following TUI needs map to public ida-domain entities: + +| TUI need | ida-domain surface | +|---|---| +| Function paging, lookup, names, sizes | `db.functions` | +| Segments and names | `db.segments` | +| Instructions and plain disassembly | `db.instructions`, `db.functions.get_instructions()` | +| Heads and item classification | `db.heads`, `db.bytes` | +| Bytes and strings | `db.bytes`, `db.strings` | +| Symbol resolution and rename | `db.names`, `db.functions` | +| Comments | `db.comments` | +| Imports and exports | `db.imports`, `db.entries` | +| Xrefs and fine type predicates | `db.xrefs` / `XrefInfo` | +| Named types, members, parse/apply | `db.types` | +| Function prototypes and local variables | `db.pseudocode`, `PseudocodeFunction.local_variables` | +| Decompilation text and object references | `db.pseudocode` | + +All values are reduced to JSON primitives inside the database process. No SWIG +or ida-domain object crosses the Code Mode boundary. + +## Remaining IDAPython gaps + +Code Mode intentionally allows regular Python imports, so these features still +work, but they identify useful additions to ida-domain: + +1. **Rich continuous listing** + - ida-domain enumerates defined heads and renders plain disassembly. + - ida-tui also needs coalesced undefined runs, IDA colour-tag spans, function + banners, code-label rows, file-region offsets, and expanded struct members. + - The `heads` operation uses `ida_bytes`, `ida_lines`, and related modules for + this presentation model. + +2. **Instruction/function carving** + - Creating an instruction and walking a speculative decode run requires + `ida_ua.create_insn` and processor flow/return checks. + - Function creation exists in ida-domain; the explicit-end fallback still + needs lower-level item boundaries. + +3. **ARM/Thumb state** + - T-register ranges and segment addressing use `ida_segregs`, `ida_idp`, and + `ida_segment`. There is no equivalent ida-domain operation. + +4. **Detailed decompiler diagnostics and line maps** + - Pseudocode text, ctree objects, and the address map are available through + ida-domain. + - Reproducing IDA's per-rendered-line coverage uses + `cfunc.get_line_item`; obtaining the exact Hex-Rays failure description + uses `hexrays_failure_t`. + +5. **A few type/item primitives** + - Deleting a named local type and some exact item-undefinition/data-creation + behavior still use `ida_typeinf`/`ida_bytes` directly. + +These uses are isolated in `idatui/codemode_client.py`; the paging and Textual +layers do not import IDAPython. + +## API limitations exposed by the port + +### No rollback or close-without-save + +A Code Mode lease has no rollback operation. Closing a GUI handle leaves the GUI +state as-is. A managed idalib worker currently saves when its final lease closes. +Consequently ida-tui's old “discard & quit” guarantee cannot be implemented. +The UI now labels this choice “leave as-is & quit” and does not explicitly save, +but managed-worker policy may still persist the changes. + +A true discard action would need a Code Mode/database API for transaction-like +rollback, a close policy on a newly-owned worker, or a TUI-managed disposable DB +copy. + +### Typed loader options only + +`DatabaseHandle.open()` supports processor, natural loading address, file type, +output database, and fresh-database selection. It does not support ida-tui's +arbitrary `ida_args` escape hatch. The adapter rejects unsupported switches +rather than silently loading at the wrong architecture/base. + +### No database-change notification stream + +The lease reports liveness, not mutations. If a GUI user or another Code Mode +client renames/retypes content while ida-tui is open, already-materialized TUI +caches are not invalidated automatically. TUI-originated edits invalidate their +own caches correctly. A database revision counter or change feed would make +shared interactive editing robust. + +### Discovery requires a path for ambiguity + +`ida-tui` with no path attaches automatically when exactly one database is +registered. With several registrations it lists them and requires an explicit +executable/IDB path. There is not yet a pre-connection database picker in the +Textual UI. + +### `DatabaseHandle` import stability + +The usable library primitive currently lives at +`ida_codemode.client.DatabaseHandle`; `ida_codemode.__init__` exports nothing. +The port therefore depends on a submodule path. Exporting the handle and public +client exceptions from the package root would make the supported library API +clearer. + +## Safety differences + +ida-tui no longer removes `.id0/.id1/.id2/.nam/.til` files before opening. That +was only defensible when the TUI exclusively owned a private process; it is +unsafe when a GUI or another client may own the database. Code Mode registry +locks, health probes, and IDA itself now arbitrate ownership. + +The old pane “reap private workers” behavior is obsolete. A TUI crash closes its +lease at the socket/kernel boundary; Code Mode decides whether a managed worker +still has clients and when it should stop. + +## Verification surfaces + +The non-IDA suite verifies project staging, LRU lease behavior, load-option +translation, and adapter response/error normalization. The existing live suites +remain the end-to-end contract: + +```sh +uv run python tests/test_codemode_client.py +uv run python tests/test_pool.py +uv run python tests/test_project.py +uv run python tests/test_scenarios.py /path/to/binary +``` + +For GUI reuse, open the same binary in an IDA with the Code Mode plugin, confirm +it appears in `ida_codemode.registry.discover_instances()`, then launch +`ida-tui /path/to/binary`. The TUI status/`CodeModeClient.backend` should report +`gui`, and closing the TUI must leave IDA open. diff --git a/docs/PAGING_FINDINGS.md b/docs/PAGING_FINDINGS.md index bcde583..bd6c38f 100644 --- a/docs/PAGING_FINDINGS.md +++ b/docs/PAGING_FINDINGS.md @@ -2,10 +2,10 @@ Measured against a real target: `libcrypto.so.3` (5.7 MB, **10,092 functions**, biggest function **52,120 instructions**). These constraints drive the domain / -paging layer. They describe the ida-pro-mcp *tool functions* (`list_funcs`, -`disasm`, `decompile`, `xref_query`, …) which the idalib worker now calls -in-process (`idatui/worker.py`) — the shapes and caps below are the tools' -behaviour and are unchanged by dropping the HTTP transport. +paging layer. The measurements below came from the former ida-pro-mcp tool +backend. The Code Mode port preserves the adapter response shapes and conservative +page sizes, but executes enumeration through ida-domain; old server caps and RTT +numbers are historical rather than Code Mode constraints. ## Response shape (list_* / *_query tools) @@ -93,32 +93,26 @@ disasm totals are **top-level** fields, not under `asm`: (correct). The pseudocode view must handle "decompilation failed" gracefully — fall back to the disassembly view or show an error panel. -Normal decompile bodies are server-truncated with a `[N chars total]` marker -(still to be solved for full-body display — see Phase 2). - -## Worker lifecycle (idatui's own idalib worker) - -idatui no longer uses ida-pro-mcp's shared HTTP supervisor. `idatui/worker.py` -opens exactly **one** database with `idapro.open_database(...)` in its own process -and serves tool calls over a unix socket (`WorkerClient`). Consequences vs the -old supervisor model, which several design choices here were built around: - -* **No `max_workers` cap, no cross-session contention.** Each TUI owns its - worker; there is no "Maximum idalib worker count reached" and no shared license - slot to free. -* **No idle self-exit / keepalive dance.** The old per-worker `WorkerLifecycle` - watchdog (`idle_ttl_sec`, default 600s) and the `KeepAlive` heartbeat that - fought it are gone with the supervisor. The worker lives as long as the TUI - holds the socket and dies with it. `WorkerClient.keepalive()` is a no-op kept - for API parity, and `--ttl` is passed through but the single owned worker does - not self-reap. -* **A crashed worker drops the socket**, surfacing as `IDAConnectionError`; the - app's `_reconnect` respawns a fresh worker (re-opening + re-analyzing the - binary). The hard-kill lock recovery below still applies. +Code Mode returns the complete execution result directly; ida-tui no longer +needs MCP structured-content/download-URL recovery for large pseudocode bodies. + +## Code Mode lifecycle + +`CodeModeClient` owns an authenticated SSE lease on a registered database: + +* A matching GUI is preferred and remains open when the TUI exits. +* Otherwise Code Mode reuses or starts a shared managed idalib worker. +* Releasing one lease never terminates another client's session. A managed + worker saves and exits after its final lease under Code Mode's grace policy. +* Lease loss surfaces as `IDAConnectionError`; reconnect performs discovery + again and may bind a newly-created instance. It does not silently swap the + handle underneath an operation. +* `--ttl` and the old keepalive flag are compatibility no-ops; the lease itself + carries heartbeats. ## Writable path requirement (operational) -`idb_open` writes the `.i64` next to the input binary, so the path must be -**writable**. Opening from read-only dirs (e.g. `/usr/lib`) fails with -`"Failed to open database"`. Copy targets into a writable dir first -(`targets/` in this repo). +Attaching to a registered GUI does not require ida-tui to write beside the input. +Creating a managed database does require a writable output path. Multi-binary +projects provide one in their sidecar. ida-tui does not sweep IDA scratch files, +because another registered session may own them. diff --git a/docs/PROJECTS.md b/docs/PROJECTS.md index fe6c2a8..0efee31 100644 --- a/docs/PROJECTS.md +++ b/docs/PROJECTS.md @@ -7,9 +7,11 @@ search across all of them, and (later) follow calls from one into another. ## The constraint that shapes everything -`idatui/worker.py` is `serve(sock, binpath)` — **one worker process holds exactly -one database** (idalib is main-thread-only and single-DB). So N binaries = N -worker processes, each with the analyzed DB resident. +IDA still exposes one active database per GUI/idalib process. Code Mode makes +those instances discoverable and shareable: each project entry retains one +`DatabaseHandle` lease, which may target a registered GUI or a managed idalib +worker. N resident project databases can therefore mean up to N processes, but +ida-tui no longer owns or terminates them. Measured cost (this box, `targets/`): @@ -30,13 +32,13 @@ crypto library. Two capabilities that feel like one, but aren't: -1. **Switching** to a binary needs a *live worker*. +1. **Switching** to a binary needs a *live Code Mode lease*. 2. **Searching across** binaries does *not* — if a per-binary index (functions, strings, imports/exports) is cached on disk. That split is the unlock: project-wide search stays instant across every binary, including ones never opened this session, and only *jumping* to a hit costs a -worker spawn. +Code Mode attach/open. ## Layout @@ -83,17 +85,16 @@ basename and must be unique (it names the staged file). ## Runtime -- **`WorkerPool`** — one `WorkerClient` per binary, spawned lazily on first - switch, kept resident until the memory budget is exceeded, then LRU-evicted. - Eviction **saves the DB first**, so returning to a binary is a DB load, not a - re-analysis. Binaries can be pinned to stay resident. +- **`DatabasePool`** — one `CodeModeClient` lease per resident binary, attached + lazily on first switch and LRU-released when the advisory memory budget is + exceeded. Eviction explicitly saves managed IDBs but never implicitly saves a + GUI. Closing a lease never kills a GUI or another client's managed worker; + Code Mode owns final worker shutdown. - **`BinaryState`** — per binary: `client, program, nav, cur, func_index, pref/active/split, filter`. Switching snapshots the current state and restores - the target's. `_after_reconnect` already does exactly this swap (client + - program, reload the index, re-open the entry) — switching reuses that seam. -- **Clean shutdown** — the worker currently does `close_database(save=False)` and - is hard-killed on exit, which is why wedge files accumulate. Projects need - save-on-evict and an orderly close anyway, so that gets fixed here. + the target's. `_after_reconnect` provides the client/program swap seam. +- **Clean shutdown** — release all leases. Managed idalib workers save/close on + their own main thread after the final lease; GUI sessions remain open. ## UI @@ -109,8 +110,8 @@ basename and must be unique (it names the staged file). ## Phases **Phase 1 — project model + switching. DONE.** Project file + staging -(`idatui/project.py`), `WorkerPool` with budget eviction / save-on-evict / -clean shutdown (`idatui/pool.py`), `BinaryState` snapshot+restore and the switch +(`idatui/project.py`), `DatabasePool` with budgeted lease release and +save-on-evict (`idatui/pool.py`), `BinaryState` snapshot+restore and the switch itself, the `Ctrl+O` switcher palette, the active binary in the status line, and `--project` (which creates the project when given binaries). One active binary; no cross-binary search yet. @@ -118,9 +119,9 @@ no cross-binary search yet. Project mode is **additive**: with no `--project` the app is byte-for-byte the single-binary tool it was, which is what keeps the 167-check pilot honest. Switching reuses the `_after_reconnect` shape — swap client+program, rebuild the -index, reopen the entry. A binary whose worker is still resident restores -instantly (its `Program` and index are still in memory); an evicted one comes -back with a fresh worker but keeps its nav history, since that is just addresses. +index, reopen the entry. A binary whose lease is still resident restores +instantly (its `Program` and index are still in memory); an evicted one attaches +again but keeps its nav history, since that is just addresses. **Phase 2 — index cache + project-wide search. (symbols done)** `idatui/index.py` keeps one **SQLite FTS5 trigram** index at @@ -222,7 +223,7 @@ records nothing — that's not navigation. *Pre-warm follows the linkage graph, not list order.* When a binary finishes indexing, `_prewarm_provider` warms the binary that provides the most of its imports — where a follow is most likely to take you, so its startup is paid -before you ask. `WorkerPool.prewarm()` refuses rather than evicting: spending a +before you ask. `DatabasePool.prewarm()` refuses rather than evicting: spending a binary you visited on one you haven't is a straight downgrade, and it would throw away that binary's caches too. At a tight budget pre-warm simply does nothing. It estimates the cost of a not-yet-spawned worker from the largest resident one, diff --git a/docs/SPLIT_VIEW.md b/docs/SPLIT_VIEW.md index de37dc1..c421656 100644 --- a/docs/SPLIT_VIEW.md +++ b/docs/SPLIT_VIEW.md @@ -29,11 +29,11 @@ Ghidra highlights **all** instructions a C line owns. We have one ea per line (the marker), not the set. Getting the set is the only real work, and it's a known technique: -ida-pro-mcp derives the per-line marker via +The old ida-pro-mcp backend derived the per-line marker via `cfunc.get_line_item(line, col=0, …).get_ea()`. To get the **full set**, sweep every column of the line (`get_line_item(line, x, …).get_ea()` for `x` in -`0..len`) and collect distinct non-`BADADDR` EAs. Same proven API, swept across -the line. A custom `decomp_map(ea)` tool in `server/patch_server.py` returns +`0..len`) and collect distinct non-`BADADDR` EAs. The Code Mode adapter's +`decomp_map(ea)` operation returns `[{line, primary_ea, eas:[…]}, …]`; invert for `ea → line`. ## State model @@ -78,14 +78,14 @@ decomp→listing uses `ListingModel.ensure_ea`. Tab re-links from the new driver Still single-ea per line (one instruction highlighted); the region comes in phase 3. -**Phase 3 — rich highlight. DONE.** `decomp_map` custom tool -(`server/patch_server.py`) sweeps `cfunc.get_line_item` across every column of +**Phase 3 — rich highlight. DONE.** The Code Mode `decomp_map` operation +(`idatui/codemode_client.py`) sweeps `cfunc.get_line_item` across every column of each pseudocode line and collects the EAs from each item's `dstr()` (`'EA: desc'` — the same source as the `/*ea*/` marker, so it aligns). `Program.decomp_map(ea)` returns the per-line ea lists (cached by name-gen); the app loads it async into `_split_eamap` / `_split_ea2line` and `_sync_split` bands the **whole** instruction region of a C line (and uses the exact ea→line inverse for the reverse). Falls -back to the single marker until the map lands. Verified on the pilot's real worker +back to the single marker until the map lands. Verified on a real Code Mode database (alignment + multi-instruction region band). **Phase 4 — polish. DONE.** diff --git a/experiments/worker_smoke.py b/experiments/worker_smoke.py index b215a57..9b55138 100644 --- a/experiments/worker_smoke.py +++ b/experiments/worker_smoke.py @@ -1,58 +1,56 @@ -"""Runnable read-path smoke: drives the REAL domain.Program through WorkerClient -(our idalib worker over a unix socket). Run when idalib can spawn: - ~/ida-venv/bin/python experiments/worker_smoke.py -""" -import os, sys, shutil, time -REPO=os.path.expanduser("~/dev/ida-tui-maybe"); sys.path.insert(0, REPO); os.chdir(REPO) -# fresh copy so the worker's idalib doesn't fight any running server -src=f"{REPO}/targets/echo"; tmp="/tmp/echo_worker" -shutil.copy(src, tmp) -for e in ".i64 .id0 .id1 .id2 .nam .til".split(): - try: os.remove(tmp+e) - except OSError: pass - -from idatui.worker_client import WorkerClient -from idatui.domain import Program - -print("spawning worker + opening echo…", flush=True) -t=time.time() -cl=WorkerClient(tmp) -cl.connect(progress=lambda m: None) -print(f" worker ready in {time.time()-t:.2f}s session={cl.resolve_db()}", flush=True) -prog=Program(cl) - -# --- drive the REAL domain layer through the worker (read path) --- -main=prog.resolve("main") -print("resolve('main') =", hex(main), flush=True) +"""Exercise the real domain.Program through an IDA Code Mode lease. -idx=prog.functions(); idx.load_all() -print("functions() ->", len(idx), "funcs", flush=True) - -fn=prog.function_of(main) -print("function_of(main) ->", fn.name, hex(fn.addr), "size", fn.size, flush=True) - -b=prog.read_bytes(main, 16) -print("read_bytes(main,16) ->", b.hex(), flush=True) - -lm=prog.listing(main) -for _ in range(3): lm.load_next_page() -rows=[lm.get(i) for i in range(min(6,len(lm)))] -print("listing() first rows:", flush=True) -for h in rows: - if h: print(" ", hex(h.ea), h.kind, repr(h.text[:44]), flush=True) +A matching registered GUI is reused; otherwise Code Mode starts a managed +idalib worker. Usage: ``uv run python experiments/worker_smoke.py FILE``. +""" +from __future__ import annotations -d=prog.decompile(main) -print("decompile(main) -> failed?", d.failed, "lines:", len((d.code or '').splitlines()), flush=True) +import os +import sys +import time -regs=prog.file_regions() -print("file_regions ->", len(regs), "segments", flush=True) +from idatui.codemode_client import CodeModeClient +from idatui.domain import Program -# xrefs to a called function -callee=next((f.addr for f in idx.all_loaded() if f.name.startswith("sub_")), None) -if callee: - xr=prog.xrefs_to(callee) - print("xrefs_to(", hex(callee), ") ->", len(xr), "refs", flush=True) -ok = (fn.name=="main" and len(idx)>100 and b and not d.failed and len(regs)>0) -print("VERDICT:", "OK — domain.Program runs unchanged on the worker" if ok else "FAIL", flush=True) -cl.close() +def main() -> int: + target = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "experiments/fibonacci.elf") + print(f"attaching Code Mode to {target}…", flush=True) + started = time.time() + client = CodeModeClient(target) + client.connect(progress=lambda message: print(f" {message}", flush=True)) + print( + f" ready in {time.time() - started:.2f}s; backend={client.backend}; " + f"session={client.resolve_db()}", + flush=True, + ) + program = Program(client) + try: + index = program.functions() + index.load_all() + print(f"functions() -> {len(index)}", flush=True) + first = index.get(0) + if first is None: + print("VERDICT: FAIL — no functions", flush=True) + return 1 + fn = program.function_of(first.addr) + data = program.read_bytes(first.addr, 16) + decompilation = program.decompile(first.addr) + print(f"function_of() -> {fn}", flush=True) + print(f"read_bytes() -> {data.hex()}", flush=True) + print( + f"decompile() -> failed={decompilation.failed}; " + f"lines={len((decompilation.code or '').splitlines())}", + flush=True, + ) + print(f"file_regions() -> {len(program.file_regions())}", flush=True) + ok = fn is not None and bool(data) and bool(program.file_regions()) + print(f"VERDICT: {'OK' if ok else 'FAIL'}", flush=True) + return 0 if ok else 1 + finally: + program.close() + client.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ida-tui b/ida-tui index 9ceb949..a488cac 100755 --- a/ida-tui +++ b/ida-tui @@ -3,10 +3,9 @@ # # ./ida-tui foo.elf # open a binary and drive it — that's it # -# Opening a binary spins up our own idalib worker (a unix-socket subprocess; -# no HTTP, no supervisor). Uses the venv python that has textual (override with -# $IDATUI_PYTHON); the worker auto-picks the python that has ida_pro_mcp -# (override with $IDATUI_WORKER_PYTHON). +# The launcher leases a registered IDA GUI or shared managed idalib worker +# through ida_codemode. The selected Python must have ida-tui's dependencies; +# override it with $IDATUI_PYTHON. set -eu SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) diff --git a/idatui/__init__.py b/idatui/__init__.py index 2cbdde8..7da28b3 100644 --- a/idatui/__init__.py +++ b/idatui/__init__.py @@ -1,5 +1,4 @@ -"""idatui — a minimal keyboard-first TUI for IDA Pro, driving idalib via a -private unix-socket worker (idatui.worker / WorkerClient).""" +"""idatui — a keyboard-first TUI using shared IDA Code Mode databases.""" from .errors import ( IDAError, @@ -11,6 +10,7 @@ from .errors import ( IDASessionError, Session, ) +from .codemode_client import CodeModeClient from .domain import ( Program, FunctionIndex, @@ -25,6 +25,7 @@ from .domain import ( ) __all__ = [ + "CodeModeClient", "Program", "FunctionIndex", "DisasmModel", diff --git a/idatui/app.py b/idatui/app.py index 1edc65e..984140f 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -10,8 +10,8 @@ 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. -* On startup we bump the worker idle-TTL and run a keepalive heartbeat so the - session never gets reaped while we chill. +* Database lifecycle is lease-based through ida_codemode: matching GUI sessions + are reused, otherwise a shared managed idalib worker is opened on demand. """ from __future__ import annotations @@ -30,7 +30,7 @@ from textual import work from textual.app import App, ComposeResult from textual.binding import Binding from textual.command import DiscoveryHit, Hit, Provider -from textual.containers import Grid, Horizontal, Vertical, VerticalScroll +from textual.containers import Horizontal, Vertical, VerticalScroll from textual.geometry import Region, Size from textual.message import Message from textual.reactive import reactive @@ -46,8 +46,8 @@ from textual.widgets.option_list import Option from .highlight import highlight_c from .errors import IDAToolError, IDAConnectionError -from .worker_client import WorkerClient -from .domain import DisasmModel, Func, Head, ListingModel, Program, Struct +from .codemode_client import CodeModeClient, registered_database +from .domain import Func, Head, ListingModel, Program, Struct # Styles for the disassembly listing. _S_ADDR = Style(color="#6b7684") @@ -126,8 +126,8 @@ _ADDR_MARK_STRIP_RE = re.compile(r"\s*/\*\s*0x[0-9A-Fa-f]+\s*\*/") @dataclass class BinaryState: """Everything that makes one project binary's session resumable across a - switch. Addresses outlive the worker, so nav history survives eviction; the - Program/index only survive while that worker is still resident.""" + switch. Addresses outlive a database lease, so nav history survives eviction; + the Program/index only survive while that lease remains resident.""" label: str program: object | None = None @@ -812,9 +812,9 @@ 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 the worker supplied it; falls - back to the old mnemonic/rest split so an older worker (or a row whose - spans didn't match the text) still renders. + Uses IDA's own token classification when Code Mode supplies it; falls + back to the mnemonic/rest split when spans are absent or disagree with + the plain text. """ if h.spans: return [Segment(t, _S_SPAN.get(k, fallback)) for k, t in h.spans] @@ -2228,12 +2228,16 @@ _HELP = ( class QuitScreen(ModalScreen): - """Asked before exiting with unsaved database changes. Dismisses with - "save", "discard" or None (stay).""" + """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. + """ BINDINGS = [ Binding("s", "save", "Save & quit"), - Binding("d", "discard", "Discard & quit"), + Binding("d", "discard", "Leave & quit"), Binding("escape,c", "cancel", "Cancel"), ] @@ -2250,7 +2254,7 @@ class QuitScreen(ModalScreen): for label in self._labels: body.append(f" \u2022 {label}\n", _S_LABEL) yield Static(body, id="quit-list") - yield Static("s save & quit d discard & quit Esc cancel", + yield Static("s save & quit d leave as-is & quit Esc cancel", id="quit-help") def action_save(self) -> None: @@ -3392,15 +3396,16 @@ class IdaTui(App): self._index = None # project-wide symbol/string index if project is not None: from .index import ProjectIndex - from .pool import WorkerPool - self._pool = WorkerPool(project, ttl=ttl) + from .pool import DatabasePool + self._pool = DatabasePool(project, ttl=ttl) self._index = ProjectIndex( os.path.join(project.index_dir, "project.db")) self._binary = project.refs[0].label open_path = project.refs[0].staged self._open_path = open_path self._ttl = ttl - self._load_args = load_args or "" # IDA switches for a headerless blob + 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._title = (os.path.basename(open_path) if open_path else "") self._trace_path = trace_path or "" # Tenet execution trace to explore self._trace = None # the loaded Trace, once analysed @@ -3414,7 +3419,7 @@ class IdaTui(App): self._do_keepalive = keepalive self._rpc_path = rpc_path self._rpc = None - self.client: WorkerClient | None = None + self.client: CodeModeClient | None = None self.program: Program | None = None self._loading_screen: LoadingScreen | None = None self._ka = None @@ -3512,8 +3517,8 @@ 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 the worker starts — once IDA has made a database - # the answer is baked in and changing it means deleting the .i64. + # opened, so ask BEFORE Code Mode 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() if ref is not None: @@ -3544,6 +3549,11 @@ class IdaTui(App): if os.path.exists(self._open_path + ".i64") or os.path.exists( os.path.splitext(self._open_path)[0] + ".i64"): return False + try: + if registered_database(self._open_path): + return False + except Exception: + pass # connect() will surface registry failures with full diagnostics return needs_load_options(self._open_path) def action_load_options(self) -> None: @@ -3557,7 +3567,11 @@ class IdaTui(App): forward. """ if not self._can_reload(): - self._status("nothing to reload") + if self.client is not None and self.client.backend == "gui": + self._status( + "reload unavailable for a GUI-owned database — reopen it in IDA") + else: + self._status("nothing to reload") return n = len(self._func_index) if self._func_index else 0 note = ("this image has no functions, so nothing is lost" @@ -3576,10 +3590,13 @@ class IdaTui(App): ref = self._project.by_label(self._binary) if ref is not None: path, label = ref.source, ref.label - # Drop the worker first: it holds the database open, and the .i64 can't - # be removed (or rebuilt) underneath a live one. - self._release_worker() - self._drop_database() + # Release our lease first. Code Mode 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() + self._new_database = True + if label is not None and self._pool is not None: + self._pool.recreate_on_next_open(label) self._reset_for_reload() self._load_args = "" if label is not None and self._project is not None: @@ -3591,7 +3608,9 @@ class IdaTui(App): self._pending_switch = None self._ask_load_options(path, label=label) - def _release_worker(self) -> None: + def _release_database(self) -> None: + if self.program is not None: + self.program.close() if self._pool is not None and self._binary is not None: try: self._pool.evict(self._binary, save=False) @@ -3605,23 +3624,6 @@ class IdaTui(App): self.client = None self.program = None - def _drop_database(self) -> None: - """Remove the .i64 (and any unpacked scratch) so the next open re-reads - the raw image with new options.""" - base = self._open_path - if self._project is not None and self._binary is not None: - ref = self._project.by_label(self._binary) - if ref is not None: - base = ref.staged - if not base: - return - for suffix in (".i64", ".id0", ".id1", ".id2", ".nam", ".til"): - for cand in (base + suffix, os.path.splitext(base)[0] + suffix): - try: - os.remove(cand) - except OSError: - pass - def _reset_for_reload(self) -> None: self._no_functions = False self._func_index = None @@ -3665,6 +3667,11 @@ class IdaTui(App): if os.path.exists(ref.db) or os.path.exists( os.path.splitext(ref.staged)[0] + ".i64"): return None # already analysed: the .i64 records how + try: + if registered_database(ref.staged, output_database=ref.db): + return None + except Exception: + pass from .formats import needs_load_options return ref if needs_load_options(ref.source) else None @@ -3718,10 +3725,6 @@ class IdaTui(App): asyncio.get_running_loop().create_task(_serve()) - async def on_unmount(self) -> None: - if self._rpc is not None: - await self._rpc.stop() - # -- status helper ----------------------------------------------------- # def _status(self, text: str, priority: bool = False) -> None: """Write the status bar. ``priority`` marks the RESULT of something the @@ -3786,9 +3789,10 @@ class IdaTui(App): # -- connection loss / recovery --------------------------------------- # def _handle_exception(self, error: BaseException) -> None: - """Intercept a lost-connection error from any worker so the whole app - doesn't die when the analysis server goes away (it can idle out, be - killed, or the box can sleep). Everything else crashes as usual.""" + """Intercept a lost Code Mode lease so the app can rediscover the DB. + + Everything unrelated to database connectivity crashes as usual. + """ from textual.worker import WorkerFailed orig = error.error if isinstance(error, WorkerFailed) else error if isinstance(orig, IDAConnectionError): @@ -3820,15 +3824,15 @@ class IdaTui(App): @work(thread=True, exclusive=True, group="reconnect") def _reconnect(self) -> None: - # The worker died (segfault -> dropped socket). Respawn it: it re-opens - # and re-analyzes the binary in a fresh process, then we rebuild. + # The registered instance disappeared. Rediscover it; Code Mode may find + # a GUI/replacement worker, then we rebuild caches against the new handle. try: if self._open_path is None: self.app.call_from_thread(self._reconnect_failed, "no binary to reopen") return - client = WorkerClient(self._open_path, ttl=self._ttl, - load_args=self._load_args) + client = CodeModeClient(self._open_path, ttl=self._ttl, + load_args=self._load_args) client.connect(progress=lambda m: self.app.call_from_thread( self._conn_note, m)) except Exception as e: # noqa: BLE001 @@ -3836,7 +3840,7 @@ class IdaTui(App): return self.app.call_from_thread(self._after_reconnect, client, Program(client)) - def _after_reconnect(self, client: "WorkerClient", program: "Program") -> None: + def _after_reconnect(self, client: "CodeModeClient", program: "Program") -> None: self.client = client self.program = program self._reconnecting = False @@ -3856,13 +3860,13 @@ class IdaTui(App): @work(thread=True, exclusive=True, group="connect") def _connect(self) -> None: try: - client = self._open_worker_client() + client = self._open_database_client() if client is None: return # the opener already reported + dismissed the overlay module = client.health().get("module", "?") if self._do_keepalive: - # Keep the session warm while we run; don't make it immortal, so - # it's reclaimed after the TUI closes. (No-op for the worker.) + # Compatibility shim: DatabaseHandle's SSE lease already owns + # liveness and heartbeat behavior. self._ka = client.keepalive(interval=120.0).start() program = Program(client) except Exception as e: # noqa: BLE001 @@ -3877,14 +3881,14 @@ class IdaTui(App): return self.client = client self.program = program - self.app.call_from_thread(self._status, f"{module} — loading functions…") + self._new_database = False + self.app.call_from_thread( + self._status, f"{module} [{client.backend}] — loading functions…") self._load_functions() - def _open_worker_client(self): # type: ignore[no-untyped-def] - """Our idalib-worker path: spawn the worker (it opens + analyzes the - binary in its own process) and connect. Returns the client, or None.""" - from .worker_client import WorkerClient - if self._pool is not None: # project mode: the pool owns the workers + def _open_database_client(self): # type: ignore[no-untyped-def] + """Attach through Code Mode, 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: self.app.call_from_thread(self._status, m)) @@ -3895,14 +3899,15 @@ class IdaTui(App): return client if not self._open_path: self.app.call_from_thread( - self._status, "the worker backend needs a binary path") + self._status, "Code Mode 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"starting worker — initial auto-analysis of {base}…") - client = WorkerClient(self._open_path, ttl=self._ttl, - load_args=self._load_args) + self._status, f"discovering Code Mode database for {base}…") + client = CodeModeClient(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( self._status, m)) return client @@ -3974,7 +3979,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 worker is gone.""" + be searched later even when its Code Mode 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) @@ -3992,7 +3997,7 @@ class IdaTui(App): imps, exps = self.program.linkage() entries += [(KIND_IMPORT, i.addr, i.name) for i in imps] entries += [(KIND_EXPORT, e.addr, e.name) for e in exps] - except Exception: # noqa: BLE001 -- an old worker has no list_linkage + except Exception: # noqa: BLE001 -- indexing is best-effort pass try: n = self._index.reindex(self._binary, entries, source=ref.source) @@ -4077,7 +4082,13 @@ class IdaTui(App): cursor=0, push=True, is_region=True) def _can_reload(self) -> bool: - """Whether we're able to re-open this binary with different options.""" + """Whether Code Mode 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. + """ + if self.client is not None and self.client.backend == "gui": + return False if self._project is not None and self._binary is not None: return True return bool(self._open_path) @@ -4267,6 +4278,9 @@ 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() elif choice == "save": @@ -4283,7 +4297,7 @@ class IdaTui(App): if self._pool is not None: self._pool.close_all(save=True) # saves each resident worker elif self.program is not None: - self.program.client.call("idb_save", timeout=600.0) + self.program.client.save_database() except Exception as e: # noqa: BLE001 -- still exit, but say so self.app.call_from_thread(self._status, f"save failed: {e}") self.app.call_from_thread(self._finish_exit) @@ -4323,7 +4337,7 @@ class IdaTui(App): self._ask_load_options(ref.source, label=label) return # Snapshot what we're leaving so coming back restores the view, then let - # the pool hand us a worker (spawning + evicting as the budget dictates). + # the pool hand us a lease (attaching + evicting as the budget dictates). if self._binary is not None: self._states[self._binary] = BinaryState( label=self._binary, program=self.program, @@ -4344,8 +4358,8 @@ class IdaTui(App): self.app.call_from_thread(self._switch_failed, label, str(e)) return st = self._states.get(label) - # The Program (and its caches) only survive while that worker does; a - # binary that was evicted comes back with a fresh one. Either way the nav + # The Program (and its caches) only survive while that lease does; an + # evicted binary reattaches. Either way the nav # history is just addresses, so it always survives. reuse = (st is not None and st.program is not None and getattr(st.program, "client", None) is client) @@ -4382,7 +4396,7 @@ class IdaTui(App): self._did_auto_land = False self._auto_land() return - # Cold (first visit, or the worker was evicted): rebuild the index, then + # Cold (first visit, or the lease was evicted): rebuild the index, then # land back where we were via _pending_restore. self._cur = None self._func_index = None @@ -4439,28 +4453,6 @@ class IdaTui(App): return self._goto_ea(addr, push=True) # land on the literal in the listing - def on_descendant_focus(self, event) -> None: # type: ignore[no-untyped-def] - """Keep ``_active`` in step with focus while split. - - Tab moves both together, but focus also moves on its own — a click, or a - pane focusing itself after a load — and then ``_active`` still names the - pane you're NOT in. Everything downstream trusts ``_active``: follow - resolves the word under that pane's cursor and pushes history for it, so - Enter in the pseudocode would follow something from the listing and the - next Esc got spent undoing it. - """ - if not self._split: - return - w = self.focused - mode = ("decomp" if isinstance(w, DecompView) - else "listing" if isinstance(w, ListingView) else None) - if mode is None or mode == self._active: - return - self._active = mode - self._sync_split(mode) # re-link the band from the new driver - if not self.query_one(DecompView).loading: - self._status_for_cur("split") # never clobber "decompiling…" - 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).""" @@ -4807,7 +4799,7 @@ class IdaTui(App): def _cross_binary_impl(self, name: str) -> tuple[str, int] | None: """``(binary, addr)`` of a project binary that EXPORTS ``name``. - Reads the on-disk index, so a provider resolves even when its worker was + Reads the on-disk index, so a provider resolves even when its lease was evicted — the whole reason the index exists. """ if self._index is None or self._project is None or not name: @@ -4980,7 +4972,7 @@ class IdaTui(App): def _foreign_importers(self, subj: int, subj_name, fn): # type: ignore[no-untyped-def] """Project binaries that IMPORT the symbol at ``subj`` — the other half of the phase-3 join, read from the on-disk index so a caller shows up - whether or not its worker is resident. + whether or not its database lease is resident. Only for a symbol this binary actually exports: a local name that happens to collide with another binary's import isn't a caller of ours. @@ -5422,7 +5414,7 @@ class IdaTui(App): kind = "stack" batch = {"stack": {"func_addr": hex(cur.ea), "old": old, "new": new}} try: - res = prog.client.call("rename", batch=batch) + res = prog.client.invoke("rename", batch=batch) except IDAToolError as e: self.app.call_from_thread(self._status, f"rename failed: {e.message}") return @@ -5438,7 +5430,6 @@ class IdaTui(App): self.app.call_from_thread(self._after_rename, kind, addr, old, new) def _after_rename(self, kind: str, addr: int | None, old: str, new: str) -> None: - cur = self._cur # A renamed symbol can appear in many functions, so invalidate globally; # each function refreshes its names the next time it's viewed. self.program.bump_names() @@ -5465,7 +5456,7 @@ class IdaTui(App): — unlike the symbol-by-name path, this names the address directly.""" assert self.program is not None try: - res = self.program.client.call( + res = self.program.client.invoke( "rename", batch={"data": {"addr": hex(addr), "new": name}}) except IDAToolError as e: self.app.call_from_thread(self._status, f"name failed: {e.message}") @@ -5729,7 +5720,6 @@ class IdaTui(App): screen row, so the eye tracks straight across. """ lst = self.query_one(ListingView) - dec = self.query_one(DecompView) if lst.model is None: return False row = lst.model.ensure_ea(pc) @@ -6022,7 +6012,7 @@ class IdaTui(App): def _save(self) -> None: assert self.program is not None try: - self.program.client.call("idb_save", timeout=300.0) + self.program.client.save_database() except Exception as e: # noqa: BLE001 self.app.call_from_thread(self._status, f"save failed: {e}") return @@ -6951,7 +6941,9 @@ class IdaTui(App): "(c code · p func · u undefine · Enter follow)") # -- teardown ---------------------------------------------------------- # - def on_unmount(self) -> None: + async def on_unmount(self) -> None: + if self._rpc is not None: + await self._rpc.stop() if self._ka is not None: self._ka.stop() if self.program is not None: @@ -6963,7 +6955,7 @@ class IdaTui(App): elif self.client is not None: if self._save_on_exit is None and self._dirty: try: # unexpected teardown with edits: don't drop them - self.client.call("idb_save", timeout=600.0) + self.client.save_database() except Exception: # noqa: BLE001 pass self.client.close() diff --git a/idatui/codemode_client.py b/idatui/codemode_client.py new file mode 100644 index 0000000..ab4e698 --- /dev/null +++ b/idatui/codemode_client.py @@ -0,0 +1,1107 @@ +"""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 json +import os +import shlex +import threading +import time +from textwrap import dedent +from typing import Any + +from ida_codemode.client import ( + ClientError, + DatabaseHandle, + InstanceDisconnectedError, + RemoteError, +) +from ida_codemode.registry import ( + REGISTRY_DIR, + FileLock, + RegistryEntry, + canonical_path, + idb_key, + scan_instances, +) +from ida_codemode.resolver import IdbBusy, expected_idb_path + +from .errors import IDAConnectionError, IDATimeoutError, IDAToolError, Session + + +def registered_database(path: str, output_database: str | None = None) -> bool: + """Whether a live/lock-held Code Mode instance owns this target.""" + source = canonical_path(path) + expected = canonical_path(output_database) if output_database else expected_idb_path(source) + expected_key = idb_key(expected) + for instance in scan_instances(timeout=0.5): + entry = instance.entry + if entry.idb_key == expected_key: + return True + if not output_database and entry.backend == "gui" and entry.exe_path: + if canonical_path(entry.exe_path) == source: + return True + return False + + +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 + + +def _script(args: dict[str, Any], body: str) -> str: + """Bind JSON arguments without interpolating user text into Python code.""" + encoded = json.dumps(args, ensure_ascii=False, separators=(",", ":")) + return f"import json\na = json.loads({encoded!r})\n{dedent(body).strip()}\n" + + +# Rich flat-listing generation is the largest ida-domain gap in this port. +# ida-domain can enumerate heads and render plain disassembly, but it does not +# expose undefined runs, IDA colour spans, function banners, or expanded UDT +# members. Keep that IDAPython-only logic isolated in this one operation. +_HEADS = r''' +import ida_bytes, ida_funcs, ida_idaapi, ida_lines, ida_name, ida_nalt, ida_segment, ida_typeinf +start = int(str(a["addr"]), 16) +count = max(1, min(int(a.get("count", 200)), 2000)) +offset = max(0, int(a.get("offset", 0))) +annotate = bool(a.get("annotate", False)) +seg = db.segments.get_at(start) +if seg is None: + result = {"addr": a["addr"], "error": "no segment", "heads": [], "cursor": {"done": True}} +else: + lo, hi = int(seg.start_ea), int(seg.end_ea) + if a.get("end"): + hi = min(hi, int(str(a["end"]), 16)) + + span_names = { + "insn": ("SCOLOR_INSN", "SCOLOR_KEYWORD", "SCOLOR_ASMDIR", "SCOLOR_MACRO"), + "reg": ("SCOLOR_REG",), + "num": ("SCOLOR_NUMBER", "SCOLOR_CHAR", "SCOLOR_BINPREF"), + "str": ("SCOLOR_STRING",), + "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"), + "err": ("SCOLOR_ERROR",), + } + tag_kinds = {} + for kind, names in span_names.items(): + for name in names: + value = getattr(ida_lines, name, None) + if isinstance(value, str) and value: + tag_kinds[value[0]] = kind + elif isinstance(value, int): + tag_kinds[chr(value)] = kind + + def spans(tagged): + 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)) + out, stack, buf = [], [], [] + def flush(): + if buf: + out.append([stack[-1] if stack else "text", "".join(buf)]) + buf.clear() + i = 0 + while i < len(tagged): + ch = tagged[i] + if ch == on and i + 1 < len(tagged): + tag = tagged[i + 1] + if tag == addr_tag: + i += 2 + addr_len + continue + flush(); stack.append(tag_kinds.get(tag, "text")); i += 2; continue + if ch == off and i + 1 < len(tagged): + flush() + if stack: stack.pop() + i += 2; continue + if ch == esc and i + 1 < len(tagged): + buf.append(tagged[i + 1]); i += 2; continue + buf.append(ch); i += 1 + flush() + collapsed, previous_space = [], False + for kind, text in out: + acc = [] + for ch in text: + if ch.isspace(): + if previous_space: continue + acc.append(" "); previous_space = True + else: + acc.append(ch); previous_space = False + if acc: collapsed.append([kind, "".join(acc)]) + if collapsed: + collapsed[0][1] = collapsed[0][1].lstrip() + collapsed[-1][1] = collapsed[-1][1].rstrip() + return [[kind, text] for kind, text in collapsed if text] + + def row(ea): + flags = ida_bytes.get_flags(ea) + kind = "code" if ida_bytes.is_code(flags) else ("data" if ida_bytes.is_data(flags) else "unknown") + tagged = ida_lines.generate_disasm_line(ea, 0) or "" + text = " ".join(ida_lines.tag_remove(tagged).split()) if tagged else "" + item = {"ea": hex(ea), "kind": kind, "size": int(ida_bytes.get_item_size(ea)), "text": text} + if tagged: + rich = spans(tagged) + if " ".join("".join(x[1] for x in rich).split()) == text: + item["spans"] = rich + name = ida_name.get_ea_name(ea) + if name: item["name"] = name + return item + + def unknown_row(ea, size): + if size <= 1: return row(ea) + item = {"ea": hex(ea), "kind": "unknown", "size": int(size), "text": f"db {size} dup(?)"} + name = ida_name.get_ea_name(ea) + if name: item["name"] = name + return item + + def members(ea): + tif = db.types.get_at(ea) + if tif is None or not tif.is_udt(): return [] + answer = [] + for member in db.types.get_udt_members(tif): + type_text = member.type.dstr() or "" + text = f"+{member.offset:X} {member.name}" + (f" {type_text}" if type_text else "") + answer.append({"ea": hex(ea + member.offset), "kind": "member", + "size": int(member.size), "text": text}) + return answer + + def is_unknown(ea): + flags = ida_bytes.get_flags(ea) + return not (ida_bytes.is_code(flags) or ida_bytes.is_data(flags)) + def run_end(ea): + nxt = ida_bytes.next_head(ea, hi) + return nxt if nxt != ida_idaapi.BADADDR and ea < nxt <= hi else hi + def advance(ea): + if is_unknown(ea): return run_end(ea) + nxt = ida_bytes.get_item_end(ea) + return nxt if nxt > ea else ea + 1 + def rows_for(ea): + if is_unknown(ea): return [unknown_row(ea, run_end(ea) - ea)] + fn = db.functions.get_at(ea) if annotate else None + at_start = fn is not None and int(fn.start_ea) == ea + answer = [] + if at_start: + name = db.functions.get_name(fn) or f"sub_{ea:X}" + answer += [ + {"ea": hex(ea), "kind": "sep", "size": 0, "text": ""}, + {"ea": hex(ea), "kind": "sep", "size": 0, + "text": "; " + "=" * 15 + " S U B R O U T I N E " + "=" * 15}, + {"ea": hex(ea), "kind": "funchdr", "size": 0, + "text": name + " proc", "name": name}, + ] + item = row(ea) + if at_start: + item["name"] = None + elif annotate and item["kind"] == "code" and item.get("name"): + name = item["name"] + answer.append({"ea": hex(ea), "kind": "label", "size": 0, + "text": name + ":", "name": name}) + item["name"] = None + answer.append(item) + if item["kind"] == "data": answer += members(ea) + if fn is not None and ida_bytes.get_item_end(ea) >= int(fn.end_ea): + name = db.functions.get_name(fn) or f"sub_{int(fn.start_ea):X}" + answer += [ + {"ea": hex(ea), "kind": "funchdr", "size": 0, + "text": name + " endp", "name": name}, + {"ea": hex(ea), "kind": "sep", "size": 0, "text": "; " + "-" * 60}, + ] + return answer + + ea = ida_bytes.get_item_head(start) + if ea == ida_idaapi.BADADDR: ea = start + for _ in range(offset): + if ea >= hi: break + ea = advance(ea) + rows = [] + more = False + while ea != ida_idaapi.BADADDR and ea < hi: + if len(rows) >= count: + more = True; break + rows += rows_for(ea) + ea = advance(ea) + result = {"addr": a["addr"], "heads": rows, + "cursor": {"next": hex(ea)} if more else {"done": True}} +result +''' + + +_DECOMP_MAP_HELPER = r''' +def line_map(cfunc): + import ida_hexrays + answer = [] + for sl in cfunc.get_pseudocode(): + tagged, eas, seen = sl.line, [], set() + for x in range(len(tagged) + 1): + head = ida_hexrays.ctree_item_t(); item = ida_hexrays.ctree_item_t(); tail = ida_hexrays.ctree_item_t() + if not cfunc.get_line_item(tagged, x, False, head, item, tail): continue + text = item.dstr() or "" + try: ea = int(text.split(": ", 1)[0], 16) + except (ValueError, IndexError): continue + if ea not in seen: seen.add(ea); eas.append(ea) + answer.append(eas) + return answer +''' + + +_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 +''', + "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 +''', + "xref_types": r''' +queries = a.get("queries") or [] +all_results = [] +for query in queries: + ea, direction = int(str(query["addr"]), 16), str(query.get("direction", "both")) + refs = [] + if direction in ("to", "both"): refs += list(db.xrefs.to_ea(ea)) + if direction in ("from", "both"): refs += list(db.xrefs.from_ea(ea)) + rows, seen = [], set() + for ref in refs: + key = (int(ref.from_ea), int(ref.to_ea), int(ref.type)) + if query.get("dedup") and key in seen: continue + seen.add(key) + fn = db.functions.get_at(int(ref.from_ea)) + kind = ("call" if ref.is_call else "jump" if ref.is_jump else "flow" if ref.is_flow + else "read" if ref.is_read else "write" if ref.is_write else ref.type.name.lower()) + row = {"from": hex(int(ref.from_ea)), "to": hex(int(ref.to_ea)), + "type": "code" if ref.is_code else "data", "kind": kind} + if query.get("include_fn") and fn is not None: + row["fn"] = {"addr": hex(int(fn.start_ea)), "name": db.functions.get_name(fn) or ""} + rows.append(row) + if len(rows) >= int(query.get("count", 2000)): break + all_results.append({"data": rows}) +result = {"result": all_results} +result +''', + "xref_query": r''' +queries = a.get("queries") or [] +all_results = [] +for query in queries: + ea, direction = int(str(query["addr"]), 16), str(query.get("direction", "both")) + refs = [] + if direction in ("to", "both"): refs += list(db.xrefs.to_ea(ea)) + if direction in ("from", "both"): refs += list(db.xrefs.from_ea(ea)) + rows = [] + for ref in refs[:int(query.get("count", 2000))]: + fn = db.functions.get_at(int(ref.from_ea)) + row = {"from": hex(int(ref.from_ea)), "to": hex(int(ref.to_ea)), + "type": "code" if ref.is_code else "data"} + if query.get("include_fn") and fn is not None: + row["fn"] = {"addr": hex(int(fn.start_ea)), "name": db.functions.get_name(fn) or ""} + rows.append(row) + all_results.append({"data": rows}) +result = {"result": all_results} +result +''', + "set_comments": r''' +rows = [] +for item in a.get("items", []): + ea, text = int(str(item["addr"]), 16), str(item.get("comment") or "") + try: + if text: ok = bool(db.comments.set_at(ea, text)) + else: db.comments.delete_at(ea); ok = True + rows.append({"addr": hex(ea), "ok": ok}) + except Exception as exc: + rows.append({"addr": hex(ea), "ok": False, "error": str(exc)}) +result = {"result": rows} +result +''', + "rename": r''' +import ida_idaapi, ida_name, ida_typeinf +batch = a.get("batch") or {} +out = {}; ok_count = failed = 0 +for category, edit in batch.items(): + try: + if category == "func": + ea, new = int(str(edit["addr"]), 16), str(edit["name"]) + fn = db.functions.get_at(ea); ok = bool(fn and db.functions.set_name(fn, new)) + elif category == "data": + new = str(edit.get("new") or "") + if edit.get("addr") is not None: ea = int(str(edit["addr"]), 16) + else: ea = int(ida_name.get_name_ea(ida_idaapi.BADADDR, str(edit.get("old") or ""))) + ok = bool(db.names.set_name(ea, new)) + elif category in ("local", "stack"): + ea, old, new = int(str(edit["func_addr"]), 16), str(edit["old"]), str(edit["new"]) + pseudo = db.pseudocode.decompile(ea); var = pseudo.find_local_variable(old) + if var is None: ok = False + else: + var.set_user_name(new) + ok = bool(pseudo.save_local_variable_info(var, save_name=True)) + else: + raise ValueError(f"unsupported rename category: {category}") + row = {"ok": ok, **({} if ok else {"error": "IDA rejected the name"})} + except Exception as exc: + row = {"ok": False, "error": str(exc)} + out[category] = [row] + if row["ok"]: ok_count += 1 + else: failed += 1 +out["summary"] = {"ok": ok_count, "failed": failed} +result = out +result +''', +} + + +_OPERATIONS["decompile"] = _DECOMP_MAP_HELPER + r''' +ea = int(str(a["addr"]), 16) +fn = db.functions.get_at(ea) +if fn is None: + result = {"error": f"no function at {ea:#x}"} +else: + pseudo = db.pseudocode.decompile(fn) + mapping = line_map(pseudo.raw_cfunc) + plain = pseudo.to_text() + marked = [line + (f" /*0x{eas[0]:X}*/" if eas else "") + for line, eas in zip(plain, mapping)] + import ida_name + refs, seen = [], set() + for expr in pseudo.find_objects(): + target = int(expr.obj_ea) + if target in seen or not (db.is_valid_ea(target) or db.is_private_ea(target)): continue + seen.add(target) + name = expr.obj_name or ida_name.get_name(target) or "" + try: string = db.bytes.get_string_at(target) if db.is_valid_ea(target) else None + except Exception: string = None + refs.append({"addr": hex(target), "name": name, "string": string}) + result = {"addr": hex(int(fn.start_ea)), "code": "\n".join(marked), "refs": refs} +result +''' + +_OPERATIONS["decomp_map"] = _DECOMP_MAP_HELPER + r''' +ea = int(str(a["addr"]), 16) +fn = db.functions.get_at(ea) +if fn is None: + result = {"error": f"no function at {ea:#x}"} +else: + pseudo = db.pseudocode.decompile(fn) + mapping = line_map(pseudo.raw_cfunc) + result = {"addr": hex(int(fn.start_ea)), + "lines": [{"ea": hex(eas[0]) if eas else None, + "eas": [hex(item) for item in eas]} for eas in mapping]} +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 +''' + + +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_entry: RegistryEntry | None = None + self._connect_lock = threading.Lock() + + def connect(self, timeout: float = 1800.0, progress=None) -> "CodeModeClient": + 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, + spawn=self._spawn, + timeout=max(0.1, timeout), + output_database=self._output_database, + processor=self._processor, + loading_address=self._loading_address, + file_type=self._file_type, + new_database=self._new_database, + ) + break + except IdbBusy: + if not self._new_database or time.monotonic() >= deadline: + raise + if progress: + progress("waiting for the previous Code Mode lease to close…") + # Remember the record before managed shutdown withdraws + # its JSON. The lifetime lock remains held until IDA has + # actually closed the IDB; waiting on it avoids racing a + # replacement worker into the old process's file lock. + expected = canonical_path( + self._output_database or expected_idb_path(self._path) + ) + owners = [item.entry for item in scan_instances(timeout=0.5) + if item.entry.idb_key == idb_key(expected)] + if owners: + self._wait_for_entry_release( + owners[0], max(0.0, deadline - time.monotonic()) + ) + else: + time.sleep(0.2) + if progress: + backend = handle.entry.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_entry = handle.entry + 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.entry.pid if self._handle is not None else None + + @property + def backend(self) -> str | None: + return self._handle.entry.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 (InstanceDisconnectedError, ClientError) 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"] + + 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: + return self.execute_python(_script(args, body), timeout=timeout) + 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 (InstanceDisconnectedError, ClientError) 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.entry + 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.entry.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.entry + 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_entry = handle.entry + handle.close() # release our lease; never close a GUI/other client's DB + + @staticmethod + def _wait_for_entry_release(entry: RegistryEntry, timeout: float) -> bool: + path = REGISTRY_DIR / f"{entry.record_id}.lock" + deadline = time.monotonic() + max(0.0, timeout) + while True: + lock = FileLock(path) + try: + if lock.try_acquire(): + return True + except OSError: + pass + finally: + lock.close() + if time.monotonic() >= deadline: + return False + time.sleep(min(0.1, deadline - time.monotonic())) + + 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. + """ + entry = self._last_entry + if entry is None or entry.backend != "idalib": + return False + return self._wait_for_entry_release(entry, 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 97b042d..2d6500e 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -1,19 +1,15 @@ -"""Domain / paging layer: address-centric models over the raw MCP client. +"""Domain / paging layer: address-centric models over IDA Code Mode. 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 ``docs/PAGING_FINDINGS.md`` is encoded here: -* Per-call caps are silent (over the cap the server returns 10, not a clamp), so - we clamp page sizes ourselves: ``LIST_PAGE`` / ``DISASM_BLOCK`` <= the caps. -* ``next_offset`` is unreliable; we paginate by advancing ``len(data)``. -* ``disasm offset=N`` is O(N) with no resumable cursor, so windowed disassembly - is **block-cached** (revisits are free) and **prefetches** the next block on a - background thread (the client is concurrency-safe). -* ``include_total`` scans the whole function (~200ms on monsters); totals are - fetched once and cached. -* ``decompile`` can hard-fail on huge functions as a *soft* error (``code`` is - null); that is surfaced as data, not an exception. +* 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. +* Expensive function totals are fetched once and cached. +* Decompilation failures are surfaced as data, not application crashes. Everything here is synchronous and thread-safe. The TUI runs these calls from Textual worker threads; the internal prefetch pool is separate and small. @@ -22,10 +18,8 @@ Textual worker threads; the internal prefetch pool is separate and small. from __future__ import annotations import bisect -import json import re import threading -import urllib.request from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field, replace from typing import Callable, TYPE_CHECKING @@ -33,7 +27,7 @@ from typing import Callable, TYPE_CHECKING from .errors import IDAToolError if TYPE_CHECKING: # type hint only - from .worker_client import WorkerClient # noqa: F401 + from .codemode_client import CodeModeClient # Clamps derived from measured caps (list ~700, disasm ~500). Margin included. LIST_PAGE = 500 @@ -63,7 +57,7 @@ class Func: def from_raw(cls, d: dict) -> "Func": addr = _as_int(d["addr"]) name = d.get("name") - # An unnamed function (server returns null/empty) must still have a + # An unnamed function must still have a # usable string name — synthesize IDA's sub_ADDR so every consumer # (palette, sort, rename prefill) can treat name as a str. if not name: @@ -91,7 +85,7 @@ class Line: @dataclass(frozen=True) class Head: - """One flat-listing item (from the ``heads`` server tool): a code + """One flat-listing item from the Code Mode ``heads`` operation: a code instruction, a data item, or an undefined byte run.""" ea: int @@ -101,7 +95,7 @@ class Head: 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 the worker didn't provide them (older worker, or the spans + #: None when Code Mode didn't provide them (or the spans #: disagreed with the plain text, in which case the text wins). spans: tuple[tuple[str, str], ...] | None = None @@ -240,7 +234,7 @@ class FunctionIndex: """A lazily-paginated, cached view of the function list. Loads pages of ``LIST_PAGE`` on demand, advancing by ``len(data)`` (never by - ``next_offset``). A single index instance corresponds to one server-side + ``next_offset``). A single index instance corresponds to one remote ``filter`` glob (``None`` = all functions). """ @@ -260,7 +254,7 @@ class FunctionIndex: query: dict = {"offset": offset, "count": LIST_PAGE} if self.filter: query["filter"] = self.filter - data = _query_data(self._prog.client.call("list_funcs", queries=[query])) + data = _query_data(self._prog.client.invoke("list_funcs", queries=[query])) added = 0 with self._lock: for d in data: @@ -370,7 +364,7 @@ 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.call( + payload = self._prog.client.invoke( "disasm", addr=hex(self.ea), max_instructions=1, include_total=True ) total = payload.get("total_instructions") @@ -431,7 +425,7 @@ class DisasmModel: # 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.call( + payload = self._prog.client.invoke( "heads", addr=hex(self.ea), offset=b * self.BLOCK, count=self.BLOCK + 1, **self._end_kw(), ) @@ -572,15 +566,15 @@ 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 injected ``heads`` server tool, which walks item heads and - renders each via ``generate_disasm_line``. The segment is walked lazily in + Backed by the Code Mode 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 first time (then cached) — the same tradeoff as ``disasm offset=N``. Grows on demand as the viewport scrolls. Synchronous + thread-safe. """ - PAGE = 500 # heads per server call (well under the tool's 2000 cap) + PAGE = 500 # viewport-scale heads per Code Mode execution def __init__(self, program: "Program", seg_start: int, seg_end: int, name: str | None = None): @@ -654,7 +648,7 @@ class ListingModel: if self._done or self._next is None: return 0 frm = self._next - payload = self._prog.client.call( + payload = self._prog.client.invoke( "heads", addr=hex(frm), count=self.PAGE, annotate=True) rows = payload.get("heads", []) if isinstance(payload, dict) else [] cur = payload.get("cursor", {}) if isinstance(payload, dict) else {} @@ -956,7 +950,7 @@ class HexModel: class Program: """The bound analysis session: models, caches, and a small prefetch pool.""" - def __init__(self, client: "WorkerClient", prefetch_workers: int = 2): + def __init__(self, client: "CodeModeClient", prefetch_workers: int = 2): self.client = client self._pool = ThreadPoolExecutor( max_workers=prefetch_workers, thread_name_prefix="idatui-prefetch" @@ -973,7 +967,7 @@ class Program: self._sections: list[tuple[int, int, str]] | None = None self._fileregions: list[tuple[int, int, int]] | None = None self._hexmodel: "HexModel | None" = None - self._no_read_raw = False # set if the server lacks the read_raw tool + self._no_read_raw = False # compatibility fallback for alternate clients self._lock = threading.Lock() # -- prefetch plumbing ------------------------------------------------- # @@ -1000,17 +994,14 @@ class Program: """Sorted raw segment map [(start, end, file_off, name)] — the single source for sections()/file_regions()/image_range. Cached. - Uses the injected ``file_regions`` tool (a plain segment walk, ~ms). - This deliberately AVOIDS ``survey_binary``, which also computes function - counts / strings / stats and takes *seconds* on a large IDB (it was the - cause of the multi-second hex-pane open). Falls back to survey_binary - only if the injected tool is missing. + Uses the Code Mode 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.call("file_regions") + r = self.client.invoke("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"]), @@ -1019,7 +1010,7 @@ class Program: segs = [] if not segs: # older server without file_regions -> survey_binary (slow) try: - sb = self.client.call("survey_binary") + sb = self.client.invoke("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, @@ -1061,8 +1052,7 @@ class Program: def file_regions(self) -> list[tuple[int, int, int]]: """Sorted [(start, end, file_off)] mapping loaded segments to raw file - offsets (file_off == -1 for non-file-backed, e.g. .bss). Cached; needs - the injected ``file_regions`` server tool.""" + offsets (file_off == -1 for non-file-backed, e.g. .bss). Cached.""" if self._fileregions is not None: return self._fileregions regions = [(s, e, fo) for s, e, fo, _nm in self._segments()] @@ -1080,15 +1070,14 @@ class Program: def read_bytes(self, ea: int, n: int) -> bytes: """Raw bytes [ea, ea+n) from IDA (gaps read as zero). - Fast path: the injected ``read_raw`` tool returns one contiguous hex - string (C-speed both ends). Falls back to the stock ``get_bytes`` (a - per-byte '0x..'-with-spaces string) on an older server without it. + The Code Mode 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.call("read_raw", addr=hex(ea), size=int(n)) + r = self.client.invoke("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) @@ -1102,7 +1091,7 @@ class Program: except (ValueError, KeyError): pass # malformed hex -> fall through to the legacy decoder try: - r = self.client.call("get_bytes", regions=[{"addr": hex(ea), "size": int(n)}]) + r = self.client.invoke("get_bytes", regions=[{"addr": hex(ea), "size": int(n)}]) except IDAToolError: return b"\x00" * n res = r.get("result", []) if isinstance(r, dict) else [] @@ -1151,7 +1140,7 @@ class Program: def list_structs(self, filter: str = "") -> list[Struct]: """All local structs/unions (optionally name-substring filtered), sorted by name.""" - payload = self.client.call("search_structs", filter=filter) + payload = self.client.invoke("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") @@ -1161,9 +1150,9 @@ class Program: def struct_source(self, name: str) -> str: """A C definition for ``name`` reconstructed from its member layout - (the server exposes members, not printable source). Faithful to IDA's + (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.call( + payload = self.client.invoke( "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 {} @@ -1186,7 +1175,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.call("declare_type", decls=decl) + payload = self.client.invoke("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") @@ -1195,10 +1184,9 @@ class Program: # -- function / variable types ---------------------------------------- # def func_types(self, ea: int) -> FuncTypes | None: """Structured decompiler types for the function at ``ea`` (prototype + - local variables). None if ``ea`` isn't a decompilable function. Requires - the injected ``func_types`` server tool.""" + local variables). None if ``ea`` isn't a decompilable function.""" try: - r = self.client.call("func_types", addr=hex(ea)) + r = self.client.invoke("func_types", addr=hex(ea)) except IDAToolError: return None if not isinstance(r, dict) or r.get("error"): @@ -1211,7 +1199,7 @@ class Program: 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.call("set_type", edits=[{"addr": hex(ea), "signature": signature}]) + r = self.client.invoke("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"): @@ -1220,9 +1208,9 @@ class Program: def data_type(self, ea: int) -> dict | None: """Current type info for a data item/global: {addr,name,type,size,is_func}. - None if the tool is unavailable or the address isn't mapped.""" + None if the operation fails or the address isn't mapped.""" try: - r = self.client.call("data_type", addr=hex(ea)) + r = self.client.invoke("data_type", addr=hex(ea)) except IDAToolError: return None if not isinstance(r, dict) or r.get("error"): @@ -1231,7 +1219,7 @@ 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.call( + r = self.client.invoke( "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 {} @@ -1240,9 +1228,9 @@ class Program: return row.get("error") or "failed to set the type" def set_lvar_type(self, fn_ea: int, var: str, ty: str) -> str | None: - """Set a decompiler local variable's type (via the injected server tool). + """Set a decompiler local variable's type through ida-domain pseudocode. None on success, else an error string.""" - r = self.client.call("set_lvar_type", addr=hex(fn_ea), variable=var, type=ty) + r = self.client.invoke("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"): @@ -1251,15 +1239,14 @@ class Program: def delete_type(self, name: str) -> str | None: """Delete a named type. Returns None on success, else an error string. - Requires a server-side ``del_type`` tool; if absent, a clear message is - returned instead of raising.""" + Returns a clear error instead of raising when the runtime cannot do it.""" try: - self.client.call("del_type", name=name) + self.client.invoke("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 "delete needs a 'del_type' tool on the ida-pro-mcp server" + return "the connected Code Mode runtime cannot delete local types" return msg # -- disassembly ------------------------------------------------------- # @@ -1273,13 +1260,7 @@ class Program: # -- decompilation ----------------------------------------------------- # def decompile(self, ea: int, refresh: bool = False) -> Decompilation: - """Full pseudocode for a function. - - The server truncates responses over 50KB (strings clipped to 1000 - chars) but caches the full output and exposes it at - ``_meta.ida_mcp.download_url``. We transparently fetch that so the view - always gets the complete body, not a 1KB stub. - """ + """Full pseudocode for a function, returned directly by Code Mode.""" if not refresh: with self._lock: hit = self._decomp.get(ea) @@ -1288,10 +1269,10 @@ class Program: dec, hit_gen = hit if hit_gen == gen: return dec - # Cached before a rename: names may be stale. Drop the server's - # Hex-Rays cache so the refetch reflects the new names. + # Cached before a rename: names may be stale. Drop Hex-Rays' + # cache so the refetch reflects the new names. try: - self.client.call("force_recompile", items=[{"addr": hex(ea)}]) + self.client.invoke("force_recompile", items=[{"addr": hex(ea)}]) except Exception: # noqa: BLE001 pass # Bound the decompile: a function Hex-Rays can't handle tends to stall @@ -1301,7 +1282,10 @@ class Program: # rpcclient socket timeout, and cache the failure below so a re-request # returns instantly instead of re-grinding. try: - envelope = self.client.call_envelope( + # 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 ) except Exception as e: # noqa: BLE001 -- surface as a failed decompile @@ -1309,15 +1293,6 @@ class Program: with self._lock: self._decomp[ea] = (dec, self._name_gen) return dec - result = envelope.get("result", {}) - payload = result.get("structuredContent") - if payload is None: # fall back to text content - payload = self.client._extract_payload("decompile", result) - meta = (result.get("_meta") or {}).get("ida_mcp") - if isinstance(meta, dict) and meta.get("download_url"): - full = self._fetch_output(meta["download_url"]) - if isinstance(full, dict) and full.get("code"): - payload = full dec = _parse_decompilation(ea, payload) with self._lock: self._decomp[ea] = (dec, self._name_gen) @@ -1365,18 +1340,18 @@ 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.call("undefine", items=[{"addr": hex(ea)}]) + self.client.invoke("undefine", items=[{"addr": hex(ea)}]) except IDAToolError: pass # nothing defined here yet -> just try to create the insn res = self._first_result( - self.client.call("define_code", items=[{"addr": hex(ea)}])) + self.client.invoke("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.call("decomp_error", addr=hex(ea)) + r = self.client.invoke("decomp_error", addr=hex(ea)) except IDAToolError: return "" if not isinstance(r, dict): @@ -1395,7 +1370,7 @@ 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.call("thumb_scan", start=hex(start), end=hex(end), + r = self.client.invoke("thumb_scan", start=hex(start), end=hex(end), apply=bool(apply)) if not isinstance(r, dict) or r.get("error"): raise IDAToolError("thumb_scan", @@ -1404,7 +1379,7 @@ class Program: def set_thumb(self, ea: int, mode: str = "toggle") -> dict: """Switch ARM/Thumb decoding at ``ea``. Returns the resulting state.""" - r = self.client.call("set_thumb", addr=hex(ea), mode=mode) + r = self.client.invoke("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')}") @@ -1413,11 +1388,11 @@ class Program: def define_code_run(self, ea: int, limit: int = 20000) -> dict: """Disassemble consecutively from ``ea`` until something stops it. - Falls back to a single instruction when the worker predates the tool, so - an old worker degrades to the previous behaviour instead of failing. + Falls back to a single instruction for alternate clients that do not + provide the run operation. """ try: - r = self.client.call("define_code_run", addr=hex(ea), limit=int(limit)) + r = self.client.invoke("define_code_run", addr=hex(ea), limit=int(limit)) except IDAToolError: self.define_code(ea) return {"count": 1, "stopped": "single", "end": hex(ea)} @@ -1429,14 +1404,14 @@ class Program: def define_func(self, ea: int) -> dict: """Create a function starting at ``ea`` (IDA's 'p'). - Prefers the injected tool, which works out the end when IDA can't; - falls back to the plain one for an older worker. + Prefers the Code Mode operation, which works out the end when IDA can't; + falls back to a plain create for alternate clients. """ try: - r = self.client.call("define_func_run", addr=hex(ea)) + r = self.client.invoke("define_func_run", addr=hex(ea)) except IDAToolError: res = self._first_result( - self.client.call("define_func", items=[{"addr": hex(ea)}])) + self.client.invoke("define_func", items=[{"addr": hex(ea)}])) if res.get("error"): raise IDAToolError("define_func", f"@ {ea:#x}: {res['error']}") return {"ok": True, "how": "legacy"} @@ -1450,7 +1425,7 @@ class Program: item: dict = {"addr": hex(ea)} if size: item["size"] = int(size) - res = self._first_result(self.client.call("undefine", items=[item])) + res = self._first_result(self.client.invoke("undefine", items=[item])) if res.get("error"): raise IDAToolError("undefine", f"@ {ea:#x}: {res['error']}") @@ -1460,7 +1435,7 @@ class Program: item: dict = {"addr": hex(ea), "type": type_decl} if name: item["name"] = name - res = self._first_result(self.client.call("make_data", items=[item])) + res = self._first_result(self.client.invoke("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'}") @@ -1468,7 +1443,7 @@ class Program: 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.call("make_string", addr=hex(ea), length=int(length), kind=kind) + r = self.client.invoke("make_string", addr=hex(ea), length=int(length), kind=kind) res = r if isinstance(r, dict) else {} if not res.get("ok"): raise IDAToolError( @@ -1483,15 +1458,6 @@ class Program: sec = None return f"{sec} @ {ea:#x}" if sec else f" @ {ea:#x}" - @staticmethod - def _fetch_output(url: str, timeout: float = 15.0): - """GET the server's cached full-output blob (plain HTTP, not MCP).""" - try: - with urllib.request.urlopen(url, timeout=timeout) as r: - return json.loads(r.read().decode("utf-8", "replace")) - except Exception: # noqa: BLE001 -- fall back to the truncated preview - return None - def strings(self, min_len: int = 4, refresh: bool = False) -> list[StrLit]: """Every string literal in the binary (IDA's Shift+F12 list), paged in full and cached. ``[]`` if the tool is unavailable.""" @@ -1504,7 +1470,7 @@ class Program: offset, page = 0, 2000 while True: try: - payload = self.client.call( + payload = self.client.invoke( "list_strings", offset=offset, count=page, min_len=min_len, refresh=(refresh and offset == 0)) except IDAToolError: @@ -1529,13 +1495,13 @@ class Program: def linkage(self) -> tuple[list[Linkage], list[Linkage]]: """``(imports, exports)`` for this binary, cached. ``([], [])`` if the - tool is unavailable — an old worker must not break the caller.""" + operation is unavailable — an alternate client must not break the caller.""" with self._lock: hit = self._linkage if hit is not None: return hit try: - payload = self.client.call("list_linkage", kind="both") + payload = self.client.invoke("list_linkage", kind="both") except IDAToolError: return ([], []) if not isinstance(payload, dict): @@ -1566,7 +1532,7 @@ class Program: if hit is not None and hit[1] == gen: return hit[0] try: - payload = self.client.call("decomp_map", addr=hex(ea)) + payload = self.client.invoke("decomp_map", addr=hex(ea)) except IDAToolError: return [] lines = payload.get("lines", []) if isinstance(payload, dict) else [] @@ -1579,13 +1545,13 @@ 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.call("lookup_funcs", queries=[hex(ea)]) + payload = self.client.invoke("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.call( + payload = self.client.invoke( "xref_query", queries=[{"addr": hex(ea), "direction": "from", "include_fn": True}], ) @@ -1597,9 +1563,9 @@ class Program: 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.call("xref_types", queries=q) + payload = self.client.invoke("xref_types", queries=q) except IDAToolError: - payload = self.client.call("xref_query", queries=q) + payload = self.client.invoke("xref_query", queries=q) return _parse_xrefs(payload) # -- address resolution ------------------------------------------------ # @@ -1617,17 +1583,17 @@ 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.call("resolve_names", queries=[s]) + payload = self.client.invoke("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: return _as_int(ea) except IDAToolError: - pass # older server without resolve_names -> fall back below + pass # alternate client without resolve_names -> fall back below # Fall back to function-name resolution (also drives the 'did you mean' # suggestion when the name is unknown). try: - payload = self.client.call("lookup_funcs", queries=[s]) + payload = self.client.invoke("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 [] @@ -1665,7 +1631,7 @@ 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.call("set_comments", items=[{"addr": hex(ea), "comment": text}]) + return self.client.invoke("set_comments", items=[{"addr": hex(ea), "comment": text}]) # -- invalidation (after edits) --------------------------------------- # def invalidate(self, ea: int) -> None: diff --git a/idatui/drive.py b/idatui/drive.py index b6fa641..0d865b5 100644 --- a/idatui/drive.py +++ b/idatui/drive.py @@ -121,7 +121,8 @@ def cmd_pc(c, args): lines = d["code"].splitlines() if needle: nlow = needle.lower() - lines = [f"{i:4} {l}" for i, l in enumerate(lines) if nlow in l.lower()] + lines = [f"{i:4} {line}" for i, line in enumerate(lines) + if nlow in line.lower()] return "\n".join(lines) or f"(no line matches {needle!r})" return d["code"] diff --git a/idatui/errors.py b/idatui/errors.py index 29b09ae..aaf2dc5 100644 --- a/idatui/errors.py +++ b/idatui/errors.py @@ -1,10 +1,8 @@ -"""Transport-agnostic error hierarchy and the Session model. +"""TUI-facing error hierarchy and lightweight database session model. -These were originally defined in client.py (the ida-pro-mcp HTTP client), but the -idalib worker path (worker_client / domain / app) needs the same exception types -and Session dataclass without dragging in the HTTP transport. They live here so -both backends share one definition; client.py re-exports them for backwards -compatibility with the (deprecated) mcp tooling and the stress tests. +The Code Mode adapter normalizes ``ida_codemode.client`` transport and execution +errors into these types so the domain and Textual layers do not depend on HTTP or +registry implementation details. """ from __future__ import annotations diff --git a/idatui/launch.py b/idatui/launch.py index 64e1546..f78213c 100644 --- a/idatui/launch.py +++ b/idatui/launch.py @@ -1,15 +1,13 @@ -"""One-shot launcher: ``ida-tui foo.elf`` and you're in the TUI. +"""One-shot launcher for the IDA Code Mode-backed TUI. -Spawns a private idalib worker (``idatui.worker``) that opens + auto-analyzes -THIS binary in its own process, talking to the TUI over a unix socket. No shared -supervisor, no HTTP: everything slow (open + analysis) happens behind the TUI's -loading overlay. +A path first resolves to a registered GUI database; when none matches, Code Mode +reuses or starts a managed idalib worker. With no path, a single registered +database is selected automatically. -Usage: +Usage:: - ida-tui /path/to/binary # open a binary and drive it - -Extras: --ttl, --no-keepalive, --rpc (all forwarded to the TUI). + ida-tui /path/to/binary + ida-tui # attach when exactly one database is registered """ from __future__ import annotations @@ -17,12 +15,6 @@ import argparse import os import sys -# The unpacked working-copy files IDA writes next to a `.i64` while a database is -# open. A hard-killed worker leaves them behind and the `.i64` then refuses to -# reopen ("Failed to open database"). Safe to delete when nothing holds the DB. -_LOCK_SUFFIXES = (".id0", ".id1", ".id2", ".nam", ".til") - - def _load_args(load: dict) -> str: """``load`` as IDA switches, for the single-binary path (no project ref).""" from .formats import load_args @@ -34,24 +26,19 @@ def _log(msg: str) -> None: print(f"ida-tui: {msg}", file=sys.stderr) -def _sweep_locks(binary: str) -> int: - """Remove stale unpacked DB files next to ``binary``. Returns how many.""" - stem = os.path.splitext(binary)[0] - n = 0 - for base in (binary, stem): # IDA may key on the full name or the stem - for suf in _LOCK_SUFFIXES: - try: - os.remove(base + suf) - n += 1 - except OSError: - pass - return n +def _registered_databases() -> tuple[list[dict], list[dict]]: + """Ready and blocked Code Mode registrations, with normalized errors.""" + try: + from ida_codemode.registry import discover_instances + return discover_instances() + except Exception as exc: # discovery diagnostics belong at the CLI boundary + return [], [{"error": str(exc)}] def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser( prog="ida-tui", - description="Open a binary in the IDA TUI (private idalib worker).") + description="Open a registered GUI or managed idalib database in the IDA TUI.") p.add_argument("binary", nargs="*", help="binary to open and analyze (several with --project " "creates/extends that project)") @@ -59,9 +46,9 @@ 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="worker idle-TTL seconds (default 1800)") + help="deprecated compatibility option (Code Mode uses leases)") p.add_argument("--no-keepalive", action="store_true", - help="do not run the keepalive heartbeat") + help="deprecated compatibility option (the lease is the heartbeat)") p.add_argument("--rpc", metavar="PATH", help="listen for RPC on this unix socket (puppeteer the TUI)") p.add_argument("--trace", metavar="FILE", @@ -76,7 +63,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="extra IDA command-line switches, passed through as-is") + help="legacy switches; only Code Mode-representable -p/-b/-T are accepted") args = p.parse_args(argv) load: dict = {} @@ -134,27 +121,42 @@ def main(argv: list[str] | None = None) -> int: _log(str(e)) return 2 else: - if len(args.binary) != 1: - _log("give exactly one binary, or use --project for several") + ready, blocked = _registered_databases() + if len(args.binary) > 1: + _log("give at most one binary, or use --project for several") return 2 - binary = os.path.abspath(os.path.expanduser(args.binary[0])) - if not os.path.isfile(binary): - _log(f"no such file: {binary}") + if args.binary: + binary = os.path.abspath(os.path.expanduser(args.binary[0])) + key = os.path.normcase(os.path.realpath(binary)) + registered = any( + key == os.path.normcase(os.path.realpath(str(item.get(field) or ""))) + for item in ready for field in ("exe_path", "idb_path") + if item.get(field) + ) + if not os.path.isfile(binary) and not registered: + _log(f"no such file or registered database: {binary}") + return 2 + elif len(ready) == 1: + item = ready[0] + binary = str(item.get("exe_path") or item.get("idb_path") or "") + _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}") return 2 - if not os.access(os.path.dirname(binary), os.W_OK): - _log(f"directory not writable (IDA writes a .i64 there): " - f"{os.path.dirname(binary)}") + else: + _log("several Code Mode 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')}]") return 2 - swept = _sweep_locks(binary) # a crashed worker can leave the DB wedged - if swept: - _log(f"cleared {swept} stale lock file(s) from a crashed worker") - # Hand off to the TUI (imported late so --help works without textual). It - # spawns the worker behind its loading overlay while auto-analysis runs. + # Hand off to the TUI (imported late so --help works without Textual). Code + # Mode discovery/opening happens behind its loading overlay. try: from .app import IdaTui except ImportError as e: - _log(f"the TUI needs textual; run with ~/ida-venv/bin/python ({e})") + _log(f"TUI dependencies are missing; run `uv sync` ({e})") return 1 rpc_path = os.path.abspath(os.path.expanduser(args.rpc)) if args.rpc else None IdaTui(open_path=binary, keepalive=not args.no_keepalive, diff --git a/idatui/pane.py b/idatui/pane.py index 37a5f0c..0f8fa57 100644 --- a/idatui/pane.py +++ b/idatui/pane.py @@ -15,9 +15,9 @@ then close it — all without a human touching the keyboard. python -m idatui.pane list python -m idatui.pane stop --sock # graceful quit + kill pane -Requires: running inside tmux. Each pane spawns its own private idalib worker -(no shared supervisor). Uses ~/ida-venv/bin/python for the TUI (needs textual) -unless --python / IDATUI_PYTHON says otherwise. +Requires: running inside tmux. Each pane leases a registered GUI or shared +managed idalib database through Code Mode. Uses ~/ida-venv/bin/python for the +TUI unless --python / IDATUI_PYTHON says otherwise. """ from __future__ import annotations @@ -25,7 +25,6 @@ import argparse import json import os import secrets -import signal import subprocess import sys import time @@ -72,56 +71,14 @@ def _tmux(*args: str) -> str: check=True).stdout.strip() -# --------------------------------------------------------------------------- # -# idalib worker reaping -# -# ``pane stop`` kills the TUI pane, but a hard-killed pane can leave its private -# idalib worker (idatui/worker.py) running. A worker is only *safe* to reap when -# no idatui pane is live (then every worker is orphaned), which avoids killing an -# in-use analyser. -# --------------------------------------------------------------------------- # -_WORKER_PATTERN = r"idatui/worker\.py" - - -def _worker_pids() -> list[int]: - """PIDs of our private per-pane idalib worker processes (idatui/worker.py), - never our own PID.""" - try: - out = subprocess.run(["pgrep", "-f", _WORKER_PATTERN], - capture_output=True, text=True) - except OSError: - return [] - me = os.getpid() - pids: list[int] = [] - for tok in out.stdout.split(): - try: - pid = int(tok) - except ValueError: - continue - if pid != me: - pids.append(pid) - return pids - - def _count_live_panes() -> int: return sum(1 for r in _load_registry() if _pane_alive(r.get("pane", ""))) def _reap_orphan_workers(force: bool = False) -> int: - """Kill leaked idalib workers when it is safe (no live pane) or ``force``. - - Returns the number of workers signalled. Best-effort; never raises. - """ - if not force and _count_live_panes() > 0: - return 0 - reaped = 0 - for pid in _worker_pids(): - try: - os.kill(pid, signal.SIGKILL) - reaped += 1 - except OSError: - pass - return reaped + """Compatibility no-op: Code Mode workers are shared and lease-managed.""" + del force + return 0 # --------------------------------------------------------------------------- # @@ -146,16 +103,8 @@ def spawn(args) -> int: print(f"error: no such project: {project}", file=sys.stderr) return 2 - # Reap workers leaked by previously-stopped/crashed panes so we don't spawn - # into a full IDA_MCP_MAX_WORKERS (which makes the new TUI hang forever, - # never reaching ready). No-op while any pane is live. - reaped = _reap_orphan_workers() - if reaped: - print(f"reaped {reaped} orphaned idalib worker(s) before spawn", - file=sys.stderr) - - # the command the pane runs: the launcher spawns a private idalib worker for - # this binary and becomes the TUI, so kill-pane tears the whole thing down. + # The pane owns only the TUI. Code Mode'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 # the project (and a missing project file is created from them). @@ -202,9 +151,8 @@ def _wait_ready(sock: str, timeout: float, pane: str, stuck_after: float = 45.0) -> dict[str, Any]: """Poll the socket + ping until the TUI reports ready (or timeout). - Emits a one-time hint to stderr if it's still not ready after ``stuck_after`` - seconds, so a wedged idalib worker / full worker pool surfaces a diagnostic - instead of an unexplained silent hang. + Emits a one-time hint if Code Mode discovery/opening is still not ready after + ``stuck_after`` seconds. """ start = time.time() deadline = start + timeout @@ -225,9 +173,8 @@ def _wait_ready(sock: str, timeout: float, pane: str, warned = True 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}. If this " - f"hangs, the idalib worker may be stuck — try " - f"`python -m idatui.pane reap`.", file=sys.stderr) + print(f"still waiting ({int(time.time() - start)}s): {why}. " + f"Check Code Mode registrations and worker logs.", file=sys.stderr) time.sleep(0.4) last = dict(last) last["ready"] = False @@ -317,13 +264,9 @@ def list_panes(args) -> int: def reap(args) -> int: - """Kill leaked idalib workers (safe when no pane is live; --force overrides).""" - live = _count_live_panes() - n = _reap_orphan_workers(force=args.force) - print(json.dumps({"reaped_workers": n, "live_panes": live, "forced": args.force})) - if n == 0 and not args.force and live > 0: - print(f"note: {live} live pane(s) — not reaping in-use workers; pass " - f"--force to reap anyway", file=sys.stderr) + """Deprecated no-op; shared Code Mode workers are managed by leases.""" + print(json.dumps({"reaped_workers": 0, "live_panes": _count_live_panes(), + "forced": args.force, "deprecated": True})) return 0 @@ -361,9 +304,8 @@ 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="kill leaked idalib workers (frees worker slots)") - rp.add_argument("--force", action="store_true", - help="reap even while panes are live (may kill an in-use analyser)") + rp = sub.add_parser("reap", help="deprecated no-op (Code Mode uses shared leases)") + rp.add_argument("--force", action="store_true", help=argparse.SUPPRESS) rp.set_defaults(fn=reap) args = p.parse_args(argv) diff --git a/idatui/pool.py b/idatui/pool.py index ae37c25..465dff2 100644 --- a/idatui/pool.py +++ b/idatui/pool.py @@ -1,23 +1,16 @@ -"""WorkerPool — keeps a live idalib worker per project binary, within a budget. +"""DatabasePool — LRU leases on Code Mode databases for a project. -One worker process holds exactly one database (idalib is single-DB and -main-thread-only), so a project with N binaries means up to N processes. They are -not cheap and they do not share: a worker on ``bash`` measures ~126 MB RSS / -117 MB PSS, and the database working set dominates for anything larger -(``libcrypto.so.3``'s ``.i64`` alone is 72 MB). +Code Mode 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. -Residency is therefore bounded by a **memory budget**, not a worker count — a -count is the wrong knob when one project holds both a 50 KB helper and a 6 MB -crypto library. Workers are spawned lazily on first use, kept resident while they -fit, and least-recently-used ones evicted when they don't. Eviction **saves the -database first**, so coming back is a load rather than a re-analysis. - -The pool never evicts the active binary, nor anything pinned. +The historical memory budget remains useful for managed idalib instances, while +GUI process memory is only advisory. The active and pinned databases are never +released to satisfy it. """ from __future__ import annotations -import os - from .project import BinaryRef, Project #: Fallback budget if /proc/meminfo can't be read (MB). @@ -36,11 +29,11 @@ def _total_ram_mb() -> int: def _pss_mb(pid: int | None) -> int: - """Proportional set size of a worker, in MB. + """Proportional set size of the leased instance process, in MB. - PSS (not RSS) is the honest per-worker cost: it splits shared pages between - the processes mapping them. In practice workers share very little, so the two - are close, but PSS is what makes summing across workers meaningful. + PSS is useful for managed idalib workers. For GUI/shared processes it is only + advisory because the TUI neither owns all that memory nor controls process + exit. """ if not pid: return 0 @@ -54,13 +47,19 @@ def _pss_mb(pid: int | None) -> int: return 0 -def _default_spawn(ref: BinaryRef, ttl: int): # pragma: no cover - needs idalib - from .worker_client import WorkerClient - return WorkerClient(ref.staged, ttl=ttl, load_args=ref.load_args) +def _default_spawn(ref: BinaryRef, ttl: int, *, new_database: bool = False): # pragma: no cover - needs IDA + from .codemode_client import CodeModeClient + return CodeModeClient( + ref.staged, + ttl=ttl, + load_args=ref.load_args, + output_database=ref.db, + new_database=new_database, + ) -class WorkerPool: - """Live workers for a project's binaries, keyed by label.""" +class DatabasePool: + """Live Code Mode 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: @@ -71,6 +70,7 @@ class WorkerPool: self._clients: dict[str, object] = {} self._lru: list[str] = [] # least-recently-used first self._pinned: set[str] = set() + self._recreate: set[str] = set() # Ctrl+L: next attachment creates a fresh IDB self.active: str | None = None # never evicted if budget_mb is None: ram = _total_ram_mb() @@ -98,11 +98,11 @@ class WorkerPool: # -- acquire ----------------------------------------------------------- # def get(self, label: str, progress=None): - """A live client for ``label``, spawning it (and making room) if needed. + """A live client for ``label``, attaching or spawning as needed. - Staging and the scratch sweep happen here: a worker killed hard last time - leaves unpacked ``.id0/.id1/...`` behind, and the database then refuses to - reopen. Nothing else holds this DB (one worker per label), so it is safe. + 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 + probes are the authority for safe discovery and stale-record cleanup. """ client = self._clients.get(label) if client is not None: @@ -118,28 +118,29 @@ class WorkerPool: note(f"staging {ref.label}\u2026") self.project.stage(ref) - self.project.sweep_scratch(ref) note(f"opening {ref.label}\u2026") - client = self._spawn(ref, self._ttl) + fresh = label in self._recreate + client = (_default_spawn(ref, self._ttl, new_database=fresh) + if self._spawn is _default_spawn else self._spawn(ref, self._ttl)) connect = getattr(client, "connect", None) if connect is not None: connect(progress=progress) if progress is not None else connect() self._clients[label] = client + self._recreate.discard(label) self._lru.append(label) self._enforce_budget(protect=label) return client def prewarm(self, label: str, progress=None) -> bool: - """Spawn a worker for ``label`` only if it fits the budget AS IT STANDS. + """Attach a database for ``label`` only if it fits the current budget. Pre-warming must never cost residency: evicting a binary the user actually visited to speculatively load one they haven't is a straight downgrade, and the eviction would also throw away that binary's caches. So this refuses rather than making room, and returns False. - The cost of a worker that doesn't exist yet can only be estimated; the - largest resident one is the best evidence available (they are all the - same program with a different database). With nothing resident we have + The cost of a database not attached yet can only be estimated; the + largest resident instance is the best evidence available. With nothing resident we have no evidence at all, so we allow one — that is the case where the budget is certainly free. """ @@ -153,13 +154,19 @@ class WorkerPool: return False self.get(label, progress=progress) # get() enforces the budget protecting the NEW label; if that had to - # evict, our estimate was wrong and the speculative worker is the one + # evict, our estimate was wrong and the speculative lease is the one # that should go — never a binary the user chose. if self.memory_mb() > self.budget_mb and label != self.active: self.evict(label) return False return True + def recreate_on_next_open(self, label: str) -> None: + """Request a fresh IDB after the current lease has been released.""" + if self.project.by_label(label) is None: + raise KeyError(f"no such binary in the project: {label}") + self._recreate.add(label) + def _touch(self, label: str) -> None: if label in self._lru: self._lru.remove(label) @@ -171,16 +178,21 @@ class WorkerPool: self._touch(label) # -- release ----------------------------------------------------------- # - def evict(self, label: str, save: bool = True) -> bool: - """Drop a resident worker, persisting its database first.""" + def evict(self, label: str, save: bool = True, + save_gui: bool = False) -> bool: + """Release a resident lease, persisting a managed database first. + + A budget-driven eviction must not save somebody's GUI implicitly. GUI + saves are reserved for an explicit/defensive ``close_all(save=True)``. + """ client = self._clients.pop(label, None) if client is None: return False if label in self._lru: self._lru.remove(label) - if save: + if save and (save_gui or getattr(client, "backend", None) != "gui"): try: # persist analysis + edits so the next open is a load - client.call("idb_save") + client.save_database() except Exception: # noqa: BLE001 -- evict regardless pass try: @@ -198,7 +210,7 @@ class WorkerPool: return None def _enforce_budget(self, protect: str | None = None) -> int: - """Evict LRU workers until the pool fits its budget. Returns how many.""" + """Release LRU leases until the pool fits its budget. Returns how many.""" n = 0 while self.memory_mb() > self.budget_mb: victim = self._evictable(protect) @@ -210,7 +222,7 @@ class WorkerPool: def close_all(self, save: bool = True) -> None: for label in list(self._clients): - self.evict(label, save=save) + self.evict(label, save=save, save_gui=save) self.active = None # -- introspection ------------------------------------------------------ # @@ -231,5 +243,9 @@ class WorkerPool: return out def __repr__(self) -> str: # pragma: no cover - debug aid - return (f"") + + +# Source compatibility for callers that imported the pre-Code-Mode name. +WorkerPool = DatabasePool diff --git a/idatui/project.py b/idatui/project.py index e2fc542..6afd8a1 100644 --- a/idatui/project.py +++ b/idatui/project.py @@ -23,7 +23,8 @@ 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). -stdlib-only, like the domain/worker layers — the TUI is the only Textual consumer. +The model has no IDA imports. Staging consults ida_codemode's registry before +replacing files so it never mutates a database owned by a GUI/shared worker. """ from __future__ import annotations @@ -56,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 = "" # escape hatch: extra IDA command-line switches + ida_args: str = "" # legacy -p/-b/-T switches accepted by Code Mode adapter @property def db(self) -> str: @@ -310,13 +311,32 @@ 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, so keeping it would silently mismatch the disassembly (any renames - in it are lost, which is why callers should say so out loud). + bytes. Refuse while Code Mode 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): raise ProjectError(f"no such binary: {ref.source}") if not self.is_stale(ref): return ref.staged + try: + from ida_codemode.registry import canonical_path, idb_key, scan_instances + expected_key = idb_key(ref.db) + staged_path = canonical_path(ref.staged) + owner = next( + (item.entry for item in scan_instances(timeout=0.5) + if item.entry.idb_key == expected_key + or (item.entry.exe_path and canonical_path(item.entry.exe_path) == staged_path)), + None, + ) + except Exception as exc: + raise ProjectError( + f"cannot verify Code Mode 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"still owns {owner.idb_path}; close/release it first" + ) os.makedirs(self.bin_dir, exist_ok=True) tmp = ref.staged + ".staging" _unlink(tmp) @@ -338,10 +358,11 @@ class Project: return out def sweep_scratch(self, ref: BinaryRef) -> int: - """Delete IDA's unpacked working files (never the ``.i64``) for ``ref``. + """Delete unpacked working files (never the ``.i64``) for maintenance. - A hard-killed worker leaves them behind and the database then refuses to - reopen. Only safe when no worker holds it. + Runtime paths no longer call this: Code Mode 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. """ return sum(1 for suf in SCRATCH_SUFFIXES if _unlink(ref.staged + suf)) diff --git a/idatui/worker.py b/idatui/worker.py deleted file mode 100644 index a4e3509..0000000 --- a/idatui/worker.py +++ /dev/null @@ -1,233 +0,0 @@ -"""idatui's own idalib worker — the replacement for the ida-pro-mcp supervisor. - -Opens ONE database in-process (on the main thread, as idalib requires) and -serves ida-pro-mcp's *tool functions* over a unix socket with length-prefixed -pickle. Same tool implementations as the MCP path (we call -``MCP_SERVER.tools.methods[name](**args)`` directly), so return shapes are -byte-identical — but with ~50us/call instead of the HTTP path's ~5ms, and no -supervisor / HTTP / JSON / 50KB-truncation machinery. - - python -m idatui.worker - -The socket only appears once the database is open + analyzed, so a client can -poll ``connect()`` to know when the worker is ready. Requests are served -serially on the main thread (idalib is single-threaded; every tool runs inline -through its own execute_sync, which is a no-op on the main thread). - -Protocol (both directions length-prefixed: 4-byte big-endian len + pickle): - request = (tool_name: str, kwargs: dict) - response = (ok: bool, result_or_error) - tool_name == "__shutdown__" ends the worker. -""" -from __future__ import annotations - -import os -import pickle -import socket -import struct -import sys -import uuid - - -# --------------------------------------------------------------------------- # -# framing -# --------------------------------------------------------------------------- # -def _recvn(sock: socket.socket, n: int) -> bytes | None: - buf = bytearray() - while len(buf) < n: - chunk = sock.recv(n - len(buf)) - if not chunk: - return None - buf += chunk - return bytes(buf) - - -def send(sock: socket.socket, obj) -> None: - data = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL) - sock.sendall(struct.pack(">I", len(data)) + data) - - -def recv(sock: socket.socket): - hdr = _recvn(sock, 4) - if hdr is None: - return None - (n,) = struct.unpack(">I", hdr) - body = _recvn(sock, n) - return None if body is None else pickle.loads(body) - - -# --------------------------------------------------------------------------- # -# worker -# --------------------------------------------------------------------------- # -def _ensure_tools_injected() -> None: - """Inject idatui's custom tools (heads/read_raw/resolve_names/func_types/...) - into the installed ida_pro_mcp, idempotently, so the worker is self-sufficient - (nothing else has to inject these tools first). Must run BEFORE - ida_pro_mcp.ida_mcp is imported (the injected code lives in api_types.py).""" - import importlib.util - repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - patch = os.path.join(repo, "server", "patch_server.py") - if not os.path.exists(patch): - return - try: - spec = importlib.util.spec_from_file_location("_idatui_patch", patch) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) # IDA-free; just defines + patches api_types - mod.main() - except Exception as e: # noqa: BLE001 -- tools may already be present - sys.stderr.write(f"idatui: tool injection skipped: {e}\n") - - -def _has_database(binpath: str) -> bool: - """Whether IDA already has a database for ``binpath``. - - IDA names it ``.i64`` (keeping the extension), but a database made - from ``foo.bin`` can also appear as ``foo.i64`` depending on how it was - created — check both, because guessing wrong here means re-passing load - switches to an existing database, which fails the open. - """ - return (os.path.exists(binpath + ".i64") - or os.path.exists(os.path.splitext(binpath)[0] + ".i64")) - - -def _open_and_register(binpath: str, load_args: str = ""): - """Open the DB (main thread) then import ida-pro-mcp so every @tool registers - against this live database. Returns (tools_dict, module_name, save_fn). - - ``load_args`` is passed to IDA as command-line switches, which is the only - way to tell it how to read a headerless blob: a raw firmware image has no - format to detect, so without ``-p`` it loads as metapc at 0 and - finds nothing. Ignored once a database exists — the .i64 already records how - it was loaded, and re-passing conflicting switches is how you corrupt one. - """ - _ensure_tools_injected() # before any ida_pro_mcp import - import idapro - idapro.enable_console_messages(False) - args = load_args or None - if args and _has_database(binpath): - # The .i64 already records how this image was loaded. Passing the - # switches again on reopen makes IDA fail outright (rc != 0) — the load - # options belong to the FIRST open only. - args = None - if idapro.open_database(binpath, run_auto_analysis=True, - args=args): # nonzero == failure - if args: - # With load switches in play they are the likeliest culprit by far: - # IDA refuses an unknown -p name with no diagnostic of its own, so - # saying "the database is locked" here sends people hunting a - # problem they don't have. - raise RuntimeError( - f"failed to open {binpath} with load options {args!r}: IDA " - f"rejected them \u2014 an unknown processor name is the usual " - f"cause (see tools/verify_procs.py for the valid ones)") - raise RuntimeError( - f"failed to open {binpath}: the .i64 is likely held by a running " - f"ida-mcp worker (try: pkill -f idalib) or wedged from a crash " - f"(delete its .id0/.id1/.id2/.nam/.til next to the binary)") - import ida_auto - ida_auto.auto_wait() # block until auto-analysis settles (match ida-mcp) - - # importing the package registers all api_*/patched tools against MCP_SERVER - from ida_pro_mcp.ida_mcp import MCP_SERVER # noqa: WPS433 - - import ida_nalt - module = os.path.basename(ida_nalt.get_root_filename() or binpath) - - def save(): - import idc - try: - idc.save_database(idc.get_idb_path(), 0) - except Exception: # noqa: BLE001 - import ida_loader, ida_pro # noqa: WPS433 - ida_loader.save_database(idc.get_idb_path(), 0) - - return MCP_SERVER.tools.methods, module, save - - -def serve(sockpath: str, binpath: str, load_args: str = "") -> None: - tools, module, save = _open_and_register(binpath, load_args) - sid = uuid.uuid4().hex[:8] - - def dispatch(name: str, args: dict): - args = dict(args) - args.pop("database", None) # single-DB worker: no session routing - # session-management shims (were the supervisor's job): - if name in ("idb_open",): - return {"success": True, - "session": {"session_id": sid, "module": module, - "input_path": binpath}} - if name in ("idb_save", "save"): - save() - return {"success": True} - if name in ("server_health", "ping", "health", "state"): - return {"module": module, "ok": True, "session_id": sid} - if name in ("idb_list",): - return {"sessions": [{"session_id": sid, "module": module, - "input_path": binpath}]} - fn = tools.get(name) - if fn is None: - raise KeyError(f"unknown tool: {name!r}") - result = fn(**args) - # Match the MCP server's structuredContent: a dict passes through, any - # other return (list/scalar) is wrapped as {"result": ...}. domain.py - # parses that exact shape (e.g. lookup_funcs -> payload["result"]). - return result if isinstance(result, dict) else {"result": result} - - try: - os.unlink(sockpath) - except OSError: - pass - srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - srv.bind(sockpath) - srv.listen(8) - try: - while True: - conn, _ = srv.accept() - try: - while True: - req = recv(conn) - if req is None: - break - name, args = req - if name == "__shutdown__": - return - try: - send(conn, (True, dispatch(name, args))) - except Exception as e: # noqa: BLE001 -- report, keep serving - send(conn, (False, f"{type(e).__name__}: {e}")) - except (ConnectionError, OSError): - pass - finally: - conn.close() - finally: - try: - import idapro - idapro.close_database(save=False) - except Exception: # noqa: BLE001 - pass - try: - os.unlink(sockpath) - except OSError: - pass - - -def main(argv=None) -> None: - argv = argv if argv is not None else sys.argv[1:] - if len(argv) < 2: - sys.stderr.write( - "usage: python -m idatui.worker [ida-load-args]\n") - raise SystemExit(2) - try: - serve(argv[0], argv[1], argv[2] if len(argv) > 2 else "") - except SystemExit: - raise - except BaseException as e: # noqa: BLE001 -- surface a clean cause + code 1 - import traceback - sys.stderr.write(f"\nWORKER-FATAL: {type(e).__name__}: {e}\n") - traceback.print_exc() - sys.stderr.flush() - raise SystemExit(1) - - -if __name__ == "__main__": - main() diff --git a/idatui/worker_client.py b/idatui/worker_client.py deleted file mode 100644 index 79a6db9..0000000 --- a/idatui/worker_client.py +++ /dev/null @@ -1,234 +0,0 @@ -"""WorkerClient — a drop-in replacement for ``IDAClient`` backed by our own -idalib worker (``idatui.worker``) over a unix socket instead of ida-pro-mcp's -HTTP/JSON transport. - -It exposes exactly the surface the app/domain use on the client -(``call``/``call_envelope``/``connect``/``set_db``/``resolve_db``/ -``list_sessions``/``health``/``keepalive``/``close``) and returns byte-identical -payloads (the worker calls the same tool functions), so ``domain.py`` and the -app are unchanged — you just construct a WorkerClient instead of an IDAClient. - -Concurrency: the app fires calls from several worker threads over one client; -the worker is single-threaded, so calls are serialized under a lock (the worker -processes one tool at a time anyway — and at ~50us/call that's free). -""" -from __future__ import annotations - -import os -import socket -import subprocess -import sys -import threading -import time -import uuid -from typing import Any - -from .errors import IDAToolError, IDAConnectionError, Session -from .worker import recv as _recv -from .worker import send as _send - -_WORKER_PY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "worker.py") -_worker_python_cache: str | None = None - - -def _find_worker_python() -> str: - """A python that can import ``ida_pro_mcp`` (and thus idalib) — NOT necessarily - the TUI's python. On a typical box the TUI runs under a venv that has textual - + idalib but not ida_pro_mcp, while the system python has idalib + - ida_pro_mcp. Override with IDATUI_WORKER_PYTHON.""" - global _worker_python_cache - if _worker_python_cache: - return _worker_python_cache - override = os.environ.get("IDATUI_WORKER_PYTHON") - candidates = [override] if override else [] - candidates += ["/usr/bin/python", "/usr/bin/python3", sys.executable] - for py in candidates: - if not py or not os.path.exists(py): - continue - try: - r = subprocess.run([py, "-c", "import ida_pro_mcp"], - capture_output=True, timeout=30) - if r.returncode == 0: - _worker_python_cache = py - return py - except Exception: # noqa: BLE001 - continue - return sys.executable # last resort; the worker will report the real error - - -class _NoopKeepAlive: - """The worker is ours and never idles out, so keepalive is a no-op.""" - - def __init__(self) -> None: - self.beats = self.failures = 0 - - def start(self): - return self - - def stop(self) -> None: - pass - - -class WorkerClient: - def __init__(self, binary_path: str, *, ttl: int = 0, - python: str | None = None, load_args: str = "") -> None: - self._bin = os.path.abspath(os.path.expanduser(binary_path)) - self._load_args = load_args or "" # IDA switches for a headerless blob - self._python = python or _find_worker_python() - tag = f"{os.getpid()}-{uuid.uuid4().hex[:8]}" - self._sock_path = f"/tmp/idatui-worker-{tag}.sock" - self._log_path = f"/tmp/idatui-worker-{tag}.log" - self._proc: subprocess.Popen | None = None - self._sock: socket.socket | None = None - self._sid = uuid.uuid4().hex[:8] - self._lock = threading.Lock() # serialize socket use - self._spawn_lock = threading.Lock() - - # -- lifecycle --------------------------------------------------------- # - def connect(self, timeout: float = 1800.0, progress=None) -> "WorkerClient": - """Spawn the worker (opens + analyzes the DB) and connect once ready.""" - with self._spawn_lock: - if self._sock is not None: - return self - if self._proc is None or self._proc.poll() is not None: - # run worker.py as a SCRIPT (not -m idatui.worker) so we don't - # import the textual-dependent idatui package __init__ under the - # IDA python, which usually has no textual. - argv = [self._python, _WORKER_PY, self._sock_path, self._bin] - if self._load_args: - argv.append(self._load_args) - self._proc = subprocess.Popen( - argv, - stdout=open(self._log_path, "wb"), - stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, - ) - deadline = time.time() + timeout - t0 = time.time() - while time.time() < deadline: - try: - s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - s.connect(self._sock_path) - self._sock = s - return self - except OSError: - if self._proc.poll() is not None: - raise IDAConnectionError( - f"worker exited (code {self._proc.returncode}): " - f"{self._log_tail()} [full log: {self._log_path}]") - if progress: - progress(f"auto-analyzing {os.path.basename(self._bin)}… " - f"({int(time.time() - t0)}s)") - time.sleep(0.2) - raise IDAConnectionError("worker did not become ready in time") - - @property - def pid(self) -> int | None: - """The worker process id (for memory accounting), or None if not spawned.""" - return self._proc.pid if self._proc is not None else None - - def close(self, grace: float = 20.0) -> None: - """Shut the worker down cleanly. - - After ``__shutdown__`` the worker still has to ``close_database()``, which - re-packs the ``.i64`` and removes the unpacked ``.id0/.id1/...`` scratch. - Signalling it before that finishes is what leaves databases wedged, so - wait out the grace period first and only escalate if it really is stuck. - """ - with self._lock: - s = self._sock - self._sock = None - if s is not None: - try: - _send(s, ("__shutdown__", {})) - except Exception: # noqa: BLE001 - pass - try: - s.close() - except Exception: # noqa: BLE001 - pass - if self._proc is not None: - try: - self._proc.wait(timeout=grace) # let it close the DB properly - except Exception: # noqa: BLE001 -- TimeoutExpired: it's stuck - try: - self._proc.terminate() - self._proc.wait(timeout=5) - except Exception: # noqa: BLE001 - try: - self._proc.kill() - except Exception: # noqa: BLE001 - pass - - # -- the call surface -------------------------------------------------- # - def call(self, tool: str, *, timeout: float | None = None, **args) -> Any: - if self._sock is None: - self.connect() - with self._lock: - s = self._sock - if s is None: - raise IDAConnectionError("worker connection is closed") - try: - _send(s, (tool, args)) - reply = _recv(s) - except (OSError, ConnectionError) as e: - self._sock = None - raise IDAConnectionError(f"worker transport failed: {e}") from e - if reply is None: - self._sock = None - raise IDAConnectionError("worker closed the connection") - ok, payload = reply - if not ok: - raise IDAToolError(tool, str(payload)) - return payload - - def call_envelope(self, tool: str, *, timeout: float | None = None, - **args) -> dict: - # domain.decompile() reads result.structuredContent — mirror that shape. - return {"result": {"structuredContent": self.call(tool, timeout=timeout, - **args)}} - - # -- session shims (single-DB worker) --------------------------------- # - def set_db(self, db: str | None) -> None: - if db: - self._sid = db - - def resolve_db(self) -> str: - return self._sid - - def list_sessions(self) -> list[Session]: - return [Session(session_id=self._sid, - filename=os.path.basename(self._bin), - input_path=self._bin, is_active=True)] - - def health(self) -> dict: - try: - return self.call("server_health") - except IDAToolError: - return {"module": os.path.basename(self._bin), "ok": True} - - def keepalive(self, interval: float = 120.0) -> _NoopKeepAlive: - return _NoopKeepAlive() - - def _log_tail(self, n: int = 400) -> str: - """Last meaningful line(s) of the worker log (skip IDA's licence banner), - so a startup crash surfaces the real cause instead of just 'code 1'.""" - try: - with open(self._log_path, encoding="utf-8", errors="replace") as f: - lines = [ln.strip() for ln in f if ln.strip()] - except OSError: - return "(no worker log)" - # the worker prints a clean 'WORKER-FATAL: ...' line on a startup crash - for ln in reversed(lines): - if ln.startswith("WORKER-FATAL:"): - return ln[len("WORKER-FATAL:"):].strip()[-n:] - skip = ("thank you", "licensed to", "[mcp]", "ida ", "hex-rays") - meaningful = [ln for ln in lines - if not any(s in ln.lower() for s in skip)] - return " | ".join((meaningful or lines)[-3:])[-n:] - - # context manager parity with IDAClient - def __enter__(self) -> "WorkerClient": - return self.connect() - - def __exit__(self, *exc) -> None: - self.close() diff --git a/pyproject.toml b/pyproject.toml index 30f8cc2..72f1ff3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,17 @@ [project] name = "idatui" version = "0.0.1" -description = "A minimal keyboard-first TUI frontend for IDA Pro over the ida-pro-mcp (idalib) server." +description = "A keyboard-first TUI frontend for shared IDA Code Mode databases." requires-python = ">=3.11" -# The client layer is intentionally stdlib-only (urllib/http.client), matching the -# ida-mcp skill philosophy: no install needed to talk to the server. -dependencies = [] +# ida-codemode supplies GUI discovery, shared idalib workers, leases, and the +# execute_python/ida-domain database surface. +dependencies = [ + "ida-codemode-mcp", + "textual>=8", + "pygments>=2", # Used directly for pseudocode highlighting. +] [project.optional-dependencies] -# The TUI layer pulls in Textual; the client/domain layers are stdlib-only. -tui = ["textual>=8", "pygments>=2"] # pygments ships with rich; explicit for the C lexer dev = ["pytest>=8"] [project.scripts] @@ -22,3 +24,6 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["idatui"] + +[tool.uv.sources] +ida-codemode-mcp = { path = "../ida-codemode-mcp", editable = true } diff --git a/server/patch_server.py b/server/patch_server.py deleted file mode 100644 index 160b15b..0000000 --- a/server/patch_server.py +++ /dev/null @@ -1,1248 +0,0 @@ -#!/usr/bin/env python3 -"""Inject idatui's extra ida-pro-mcp tools into the installed server package. - -DEPRECATED along with the ida-pro-mcp transport: the default backend is now the -idalib worker (idatui/worker.py), which registers these same tools in-process and -needs no patching. Kept only for `--backend mcp`; slated for removal. - -ida-pro-mcp lacks a few tools idatui needs. Rather than vendor/fork the server, -we keep the tool source here and inject it (idempotently) into the installed -``api_types.py``. That module is imported by every worker -(``python -m ida_pro_mcp.idalib_server``), so the tools register themselves via -``@tool`` on the shared ``MCP_SERVER`` — no server code is forked, and re-running -this (spawn.sh does, on every start) re-applies it after a reinstall/upgrade. - -Injected tools: - * ``del_type`` — delete a named local type (struct editor CRUD). - * ``func_types`` — structured decompiler types for a function (prototype + - local variables), so clients don't parse pseudocode text. - * ``set_lvar_type`` — set a decompiler local variable's type; works on auto/ - register vars too (the stock set_type only updates lvars - that already have user-saved info). - -The block between the BEGIN/END markers is *replaced* on each run, so editing -BODY here and restarting the supervisor updates the tools. - -Run with the *same* interpreter the server uses (the idalib-mcp entry point's -``/usr/bin/python``), so it patches the file the workers actually import. -Changing a tool needs a supervisor restart so workers respawn. -""" -from __future__ import annotations - -import importlib.util -import pathlib -import sys - -BEGIN = "# >>> idatui-ext: begin (auto-injected by server/patch_server.py) >>>" -END = "# <<< idatui-ext: end <<<" - -# Appended to ida_pro_mcp/ida_mcp/api_types.py, which already imports -# ``Annotated``, ``tool``, ``idasync``, ``ida_typeinf``, ``parse_address`` and -# ``_parse_type_tinfo``. -BODY = ''' -def _idatui_lv_get(x): - return x() if callable(x) else x - - -@tool -@idasync -def resolve_names( - queries: Annotated[list, "Symbol name(s) to resolve to their OWN address"], -) -> list: - """Resolve named locations (functions, labels like loc_/locret_, data) to the - exact address the NAME denotes, via get_name_ea. Unlike lookup_funcs, a - mid-function label resolves to the label's address, not the containing - function's entry.""" - import idaapi - qs = queries if isinstance(queries, list) else [queries] - out = [] - for q in qs: - q = str(q).strip() - ea = idaapi.get_name_ea(idaapi.BADADDR, q) - out.append({"query": q, "ea": (hex(ea) if ea != idaapi.BADADDR else None)}) - return out - - -@tool -@idasync -def del_type( - name: Annotated[str, "Local type name to delete (struct/union/enum/typedef)"], -) -> dict: - """Delete a named local type from the local type library.""" - til = ida_typeinf.get_idati() - ok = ida_typeinf.del_named_type(til, name, ida_typeinf.NTF_TYPE) - if not ok: - return {"name": name, "error": f"Type '{name}' not found or could not be deleted"} - return {"name": name, "deleted": True} - - -@tool -@idasync -def func_types( - addr: Annotated[str, "Function address or name"], -) -> dict: - """Structured decompiler types for a function: its prototype plus each local - variable (name/type/is_arg). Lets clients read/edit types without parsing - pseudocode text.""" - import ida_hexrays - import idaapi - - def _tstr(tif): - try: - s = tif.dstr() - if s: - return s - except Exception: - pass - return str(tif) - - ea = parse_address(addr) - f = idaapi.get_func(ea) - if not f: - return {"addr": str(addr), "error": "no function at address"} - try: - cf = ida_hexrays.decompile(f.start_ea) - except Exception as e: - return {"addr": hex(f.start_ea), "error": f"decompile failed: {e}"} - if cf is None: - return {"addr": hex(f.start_ea), "error": "decompilation failed"} - name = idaapi.get_func_name(f.start_ea) or "" - try: - proto = ida_typeinf.print_tinfo( - "", 0, 0, ida_typeinf.PRTYPE_1LINE, cf.type, name, "") - except Exception: - proto = "" - lvars = [] - for lv in cf.get_lvars(): - try: - ty = _tstr(_idatui_lv_get(lv.type)) - except Exception: - ty = "" - lvars.append({ - "name": _idatui_lv_get(lv.name), - "type": ty, - "is_arg": bool(_idatui_lv_get(lv.is_arg_var)), - }) - return { - "addr": hex(f.start_ea), - "name": name, - "prototype": (proto or "").strip(), - "lvars": lvars, - } - - -@tool -@idasync -def set_lvar_type( - addr: Annotated[str, "Function address or name"], - variable: Annotated[str, "Local variable name"], - type: Annotated[str, "New C type for the variable"], -) -> dict: - """Set a decompiler local variable's type. Handles auto/register vars (unlike - set_type, which only updates lvars that already have user-saved info).""" - import ida_hexrays - import idaapi - - ea = parse_address(addr) - f = idaapi.get_func(ea) - if not f: - return {"error": "no function at address"} - try: - cf = ida_hexrays.decompile(f.start_ea) - except Exception as e: - return {"error": f"decompile failed: {e}"} - if cf is None: - return {"error": "decompilation failed"} - target = None - for lv in cf.get_lvars(): - if _idatui_lv_get(lv.name) == variable: - target = lv - break - if target is None: - return {"error": f"local variable {variable!r} not found"} - try: - tif = _parse_type_tinfo(type) - except Exception as e: - return {"error": f"bad type {type!r}: {e}"} - lsi = ida_hexrays.lvar_saved_info_t() - try: - lsi.ll = target - except Exception: - try: - lsi.ll.location = _idatui_lv_get(target.location) - lsi.ll.defea = target.defea - except Exception as e: - return {"error": f"could not locate variable: {e}"} - lsi.type = tif - ok = bool(ida_hexrays.modify_user_lvar_info( - f.start_ea, ida_hexrays.MLI_TYPE, lsi)) - return {"addr": hex(f.start_ea), "variable": variable, "type": type, "ok": ok} - - -@tool -@idasync -def file_regions() -> dict: - """Loaded segments mapped to their raw file offsets (get_fileregion_offset), - so clients can convert a virtual address to an on-disk file offset without a - format-specific header parser. file_off is -1 for non-file-backed segments - (e.g. .bss).""" - import ida_segment - import idaapi - - out = [] - seg = ida_segment.get_first_seg() - while seg is not None: - try: - fo = int(idaapi.get_fileregion_offset(seg.start_ea)) - except Exception: - fo = -1 - if fo < 0 or fo >= (1 << 48): - fo = -1 - try: - nm = ida_segment.get_segm_name(seg) or "" - except Exception: - nm = "" - out.append({"start": hex(seg.start_ea), "end": hex(seg.end_ea), - "file_off": fo, "name": nm}) - seg = ida_segment.get_next_seg(seg.start_ea) - return {"regions": out} - - -@tool -@idasync -def make_string( - addr: Annotated[str, "Address of the string start"], - length: Annotated[int, "Length in bytes (0 = auto-detect to the terminator)"] = 0, - kind: Annotated[str, "String kind: c | c16 | c32 | pascal"] = "c", -) -> dict: - """Create a string literal at ``addr`` (IDA's 'A'). ``length`` 0 auto-detects - to the terminator. Undefines any items in the way first, like the UI does. - Returns the created byte size and the decoded contents.""" - import ida_bytes - import ida_nalt - - ea = parse_address(addr) - strtype = { - "c": ida_nalt.STRTYPE_C, - "c16": ida_nalt.STRTYPE_C_16, - "c32": ida_nalt.STRTYPE_C_32, - "pascal": ida_nalt.STRTYPE_PASCAL, - }.get(str(kind).lower(), ida_nalt.STRTYPE_C) - n = max(int(length), 0) - # Free any existing item(s) so create_strlit can carve the literal. - ida_bytes.del_items(ea, ida_bytes.DELIT_SIMPLE, n if n > 0 else 1) - ok = bool(ida_bytes.create_strlit(ea, n, strtype)) - if not ok: - return {"addr": addr, "ok": False, "error": "create_strlit failed"} - size = int(ida_bytes.get_item_size(ea)) - try: - raw = ida_bytes.get_strlit_contents(ea, -1, strtype) - text = raw.decode("utf-8", "replace") if raw else "" - except Exception: - text = "" - return {"addr": addr, "ok": True, "size": size, "text": text} - - -@tool -@idasync -def read_raw( - addr: Annotated[str, "Start address (hex or name)"], - size: Annotated[int, "Number of bytes to read"], -) -> dict: - """Read ``size`` bytes at ``addr`` as ONE contiguous lowercase hex string - (no per-byte '0x'/spaces). The hot path for the hex view and disasm opcode - bytes. - - Fast: does a single bulk ``ida_bytes.get_bytes`` (C-speed) instead of the - per-byte read_bytes_bss_safe loop (2 IDA calls/byte). Unloaded bytes come - back from IDA as the 0xFF sentinel, so we only re-check is_loaded for the - (usually sparse) 0xFF bytes and zero the genuinely-unloaded ones — matching - get_bytes' bss semantics without paying per-byte for the whole range. - - Encoding is compact hex (~2.5x smaller than get_bytes' '0x..'-with-spaces) - and, unlike get_bytes, does not truncate on large reads.""" - import ida_bytes - - ea = parse_address(addr) - n = max(int(size), 0) - if n == 0: - return {"addr": addr, "hex": "", "n": 0} - raw = ida_bytes.get_bytes(ea, n) - if raw is None or len(raw) < n: # nothing (or not all) mapped - base = bytearray(raw or b"") - base.extend(b"\\xff" * (n - len(base))) - raw = bytes(base) - ba = bytearray(raw) - # Only unloaded bytes read as 0xFF; correct just those to 0 (bss => zero). - i = ba.find(0xFF) - while i != -1: - if not ida_bytes.is_loaded(ea + i): - ba[i] = 0 - i = ba.find(0xFF, i + 1) - return {"addr": addr, "hex": bytes(ba).hex(), "n": len(ba)} - - -def _idatui_head_row(ea): - """One flat-listing row for the head at ``ea``: kind (code/data/unknown), - byte size, rendered text, and any symbol name.""" - import ida_bytes - import ida_lines - import ida_name - - f = ida_bytes.get_flags(ea) - if ida_bytes.is_code(f): - kind = "code" - elif ida_bytes.is_data(f): - kind = "data" - else: - kind = "unknown" - line = ida_lines.generate_disasm_line(ea, 0) - text = ida_lines.tag_remove(line) if line else "" - text = " ".join(text.split()) # collapse IDA's column padding - row = { - "ea": hex(ea), - "kind": kind, - "size": int(ida_bytes.get_item_size(ea)), - "text": text, - } - if line: - # Keep IDA's own token classification for syntax highlighting. Built from - # the SAME line as `text`, then whitespace-collapsed identically so the - # two never disagree about what the row says. - spans = _idatui_spans(line) - joined = "".join(t for _k, t in spans) - if " ".join(joined.split()) == text: - row["spans"] = spans - nm = ida_name.get_ea_name(ea) - if nm: - row["name"] = nm - return row - - -#: IDA colour tag -> the semantic kind the TUI styles. IDA already classifies -#: every token in a disassembly line, for every processor it supports, so there -#: is nothing to lex: generate_disasm_line emits \x01text\x02 and the -#: tag says what the text IS. A pygments assembly lexer would be a worse guess at -#: this and would need one dialect per architecture. -_IDATUI_SPAN_KINDS = { - "insn": ("SCOLOR_INSN", "SCOLOR_KEYWORD", "SCOLOR_ASMDIR", "SCOLOR_MACRO"), - "reg": ("SCOLOR_REG",), - "num": ("SCOLOR_NUMBER", "SCOLOR_CHAR", "SCOLOR_BINPREF"), - "str": ("SCOLOR_STRING",), - # 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"), - "seg": ("SCOLOR_SEGNAME",), - "cmt": ("SCOLOR_AUTOCMT", "SCOLOR_REGCMT", "SCOLOR_RPTCMT", "SCOLOR_VOIDOP"), - "punct": ("SCOLOR_SYMBOL", "SCOLOR_ALTOP", "SCOLOR_HIDNAME"), - "err": ("SCOLOR_ERROR",), -} - - -def _idatui_tag_map(): - """{tag character: kind}, built once from whatever this IDA actually has.""" - import ida_lines - out = {} - for kind, names in _IDATUI_SPAN_KINDS.items(): - for n in names: - v = getattr(ida_lines, n, None) - if isinstance(v, str) and v: - out[v[0]] = kind - elif isinstance(v, int): - out[chr(v)] = kind - return out - - -_IDATUI_TAGS = None - - -def _idatui_spans(line): - """A tagged disasm line as [[kind, text], ...], colour tags resolved. - - Unknown tags become 'text' rather than being dropped: a processor module can - emit a colour we don't classify, and losing the characters would corrupt the - line.""" - global _IDATUI_TAGS - import ida_lines - if _IDATUI_TAGS is None: - _IDATUI_TAGS = _idatui_tag_map() - 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)) - spans, stack, buf = [], [], [] - i, n = 0, len(line) - - def flush(): - if buf: - spans.append([stack[-1] if stack else "text", "".join(buf)]) - del buf[:] - - while i < n: - ch = line[i] - if ch == on and i + 1 < n: - tag = line[i + 1] - if tag == addr_tag: - # An embedded target address, not display text: 16 hex digits - # that must not reach the screen. - i += 2 + addr_len - continue - flush() - stack.append(_IDATUI_TAGS.get(tag, "text")) - i += 2 - continue - if ch == off and i + 1 < n: - flush() - if stack: - stack.pop() - i += 2 - continue - if ch == esc and i + 1 < n: # escaped literal - buf.append(line[i + 1]) - i += 2 - continue - buf.append(ch) - i += 1 - flush() - # Collapse IDA's column padding EXACTLY as the plain text does. A run of - # spaces can straddle two spans, so this walks characters rather than - # collapsing each span on its own — otherwise the spans and `text` disagree - # about the line and the row silently loses its highlighting. - out, prev_space = [], False - for kind, txt in spans: - acc = [] - for ch in txt: - if ch.isspace(): - if prev_space: - continue - acc.append(" ") - prev_space = True - else: - acc.append(ch) - prev_space = False - if acc: - out.append([kind, "".join(acc)]) - while out and out[0][1] == " ": - out.pop(0) - while out and out[-1][1] == " ": - out.pop() - if out and out[0][1].startswith(" "): - out[0][1] = out[0][1].lstrip() - if out and out[-1][1].endswith(" "): - out[-1][1] = out[-1][1].rstrip() - return [[k, t] for k, t in out if t] - - -def _idatui_unknown_row(ea, size): - """One collapsed row for a run of ``size`` undefined bytes starting at - ``ea``. A single byte is rendered normally (shows its value); a longer run - collapses to ``db N dup(?)`` so a big .bss/gap doesn't explode into millions - of one-byte rows.""" - import ida_name - - if size <= 1: - return _idatui_head_row(ea) - 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 - return row - - -def _idatui_struct_member_rows(ea): - """Indented member rows for a struct-typed data item at ``ea`` (expansion), - or [] if it isn't a struct. Top-level fields only.""" - import ida_nalt - import ida_typeinf - import idaapi - - tif = ida_typeinf.tinfo_t() - if not (ida_nalt.get_tinfo(tif, ea) and tif.is_udt()): - return [] - udt = ida_typeinf.udt_type_data_t() - if not tif.get_udt_details(udt): - return [] - rows = [] - for m in udt: - off = m.begin() // 8 - try: - mtype = m.type._print() or "" - except Exception: - mtype = "" - try: - sz = int(m.type.get_size()) - if sz == idaapi.BADSIZE: - sz = 0 - except Exception: - 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}) - return rows - - -def _idatui_func_header_rows(ea): - """IDA-style subroutine banner rows shown just before a function's entry.""" - import ida_funcs - - name = ida_funcs.get_func_name(ea) or "sub_%X" % ea - bar = "=" * 15 + " S U B R O U T I N E " + "=" * 15 - 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}, - ] - - -def _idatui_func_footer_rows(ea, func): - """End-of-function marker shown just after a function's last item.""" - import ida_funcs - - 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": "sep", "size": 0, "text": "; " + "-" * 60}, - ] - - -@tool -@idasync -def heads( - addr: Annotated[str, "Start address or name to walk from"], - 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, -) -> 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 - next_head/prev_head. Unlike ``disasm`` (code-only, bails at the first data - byte) this shows db/dw/dd/... lines for data and undefined regions — IDA's - real disassembly view. Address-paged: page forward by re-calling with - ``addr`` = the returned cursor.next; page up with ``back=true``.""" - import ida_bytes - import ida_segment - import idaapi - - count = 2000 if count > 2000 else (1 if count < 1 else count) - offset = max(int(offset), 0) - try: - start = parse_address(addr) - except Exception as e: - 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}} - lo, hi = seg.start_ea, seg.end_ea - if end: - try: - hi = min(hi, parse_address(end)) - except Exception: - pass - - rows = [] - if back: - # Collect up to (count+offset) heads strictly before `start`, then take - # the window closest to `start`, returned in forward order. - walk = [] - cur = ida_bytes.prev_head(start, lo) - while cur != idaapi.BADADDR and cur >= lo and len(walk) < count + offset: - walk.append(cur) - cur = ida_bytes.prev_head(cur, lo) - walk.reverse() - chosen = walk[: len(walk) - offset] if offset else walk - chosen = chosen[-count:] - 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)} - return {"addr": str(addr), "heads": rows, "cursor": cursor} - - # Walk by item END (not next_head): next_head SKIPS undefined bytes, but a - # flat listing must show them (IDA renders undefined as `db ?` lines, and - # navigating to an unmarked address must land ON it). Defined items advance - # by get_item_end; a run of undefined bytes is COLLAPSED into one row (its - # end found in O(1) via next_head, which skips undefined) so a large .bss or - # gap doesn't explode into millions of one-byte rows. - def _is_unknown(e): - f = ida_bytes.get_flags(e) - return not (ida_bytes.is_code(f) or ida_bytes.is_data(f)) - - def _run_end(e): - """End (exclusive) of the undefined run starting at ``e``.""" - nh = ida_bytes.next_head(e, hi) - return nh if (nh != idaapi.BADADDR and e < nh <= hi) else hi - - def _advance(e): - if _is_unknown(e): - return _run_end(e) - nxt = ida_bytes.get_item_end(e) - return nxt if nxt > e else e + 1 - - def _rows_for(e): - if _is_unknown(e): - return [_idatui_unknown_row(e, _run_end(e) - e)] - func = idaapi.get_func(e) if annotate else None - at_start = func is not None and func.start_ea == e - out = [] - if at_start: - out.extend(_idatui_func_header_rows(e)) - row = _idatui_head_row(e) - if at_start: - row = dict(row) - row["name"] = None # the name is shown on the proc header line - elif annotate and row.get("kind") == "code" and row.get("name"): - # 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}) - row = dict(row) - row["name"] = None - out.append(row) - if row.get("kind") == "data": - out.extend(_idatui_struct_member_rows(e)) # expand struct fields - if func is not None and ida_bytes.get_item_end(e) >= func.end_ea: - out.extend(_idatui_func_footer_rows(e, func)) - return out - - ea = ida_bytes.get_item_head(start) - for _ in range(offset): - if ea >= hi or ea == idaapi.BADADDR: - break - ea = _advance(ea) - more = False - while ea != idaapi.BADADDR and ea < hi: - if len(rows) >= count: - more = True - break - rows.extend(_rows_for(ea)) # a struct head expands into member rows - ea = _advance(ea) - cursor = {"next": hex(ea)} if more else {"done": True} - return {"addr": str(addr), "heads": rows, "cursor": cursor} - - -@tool -@idasync -def xref_types( - queries: Annotated[list, "[{addr, direction:'to'|'from'|'both', include_fn, dedup, count}]"], -) -> dict: - """Like xref_query, but every row carries a fine-grained ``kind`` derived from - the IDA xref type \u2014 call/jump/flow for code, read/write/offset/text/info for - data \u2014 alongside the coarse ``type`` (code/data). Feeds the xref dialog's - r/w/call badges. Same query/envelope shape as xref_query.""" - import idaapi, idautils, ida_funcs, ida_bytes, 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): - table = code_kind if xr.iscode else data_kind - return table.get(xr.type, "code" if xr.iscode else "data") - - def _fn(ea): - f = ida_funcs.get_func(ea) - if not f: - return None - return {"addr": hex(f.start_ea), "name": ida_funcs.get_func_name(f.start_ea)} - - def _resolve(raw): - raw = str(raw).strip() - try: - return int(raw, 16) # handles '0x2490' and '2490' - except ValueError: - return idaapi.get_name_ea(idaapi.BADADDR, raw) - - qs = queries if isinstance(queries, list) else [queries] - result = [] - for q in qs: - q = q if isinstance(q, dict) else {"addr": q} - raw = str(q.get("addr", "")).strip() - direction = str(q.get("direction", "to") or "to").lower() - include_fn = bool(q.get("include_fn", True)) - dedup = bool(q.get("dedup", True)) - try: - count = int(q.get("count", 2000) or 2000) - except (TypeError, ValueError): - count = 2000 - target = _resolve(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(xr.frm), "from": hex(xr.frm), - "to": hex(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(xr.to), "from": hex(target), - "to": hex(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 = set() - deduped = [] - 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] - result.append({"query": raw, "data": rows, "next_offset": None}) - return {"result": result} - - -@tool -@idasync -def data_type( - addr: Annotated[str, "Address or name of a data item / global"], -) -> dict: - """The current C type of a data item, for prefilling a retype prompt: - {addr, name, type, size, is_func}. ``type`` is empty when the item is - untyped; ``is_func`` distinguishes a global from a function so the caller - knows which flavour of set_type to use.""" - import idaapi - import ida_bytes - import ida_name - import idc - raw = str(addr).strip() - try: - ea = int(raw, 16) - except ValueError: - ea = idaapi.get_name_ea(idaapi.BADADDR, raw) - if ea == idaapi.BADADDR or not ida_bytes.is_mapped(ea): - return {"addr": raw, "error": f"not a mapped address: {raw}"} - return { - "addr": hex(ea), - "name": ida_name.get_name(ea) or "", - "type": idc.get_type(ea) or "", - "size": int(ida_bytes.get_item_size(ea) or 0), - "is_func": bool(idaapi.get_func(ea)), - } - - -@tool -@idasync -def decomp_map( - addr: Annotated[str, "Function address or name"], -) -> dict: - """Per-pseudocode-line instruction coverage for the split view's region - highlight: for each line, the set of EAs the decompiler attributes to it, - swept across the line's columns via get_line_item. Shape: - {addr, lines:[{ea: primary|None, eas:[hex,...]}, ...]}.""" - import ida_hexrays - import idaapi - try: - ea = int(str(addr), 16) - except ValueError: - ea = idaapi.get_name_ea(idaapi.BADADDR, str(addr).strip()) - func = idaapi.get_func(ea) - if not func: - return {"error": f"no function at {addr}"} - try: - cfunc = ida_hexrays.decompile(func.start_ea) - except Exception as e: # noqa: BLE001 - return {"error": f"decompile failed: {e}"} - if cfunc is None: - return {"error": "decompile failed"} - lines = [] - for sl in cfunc.get_pseudocode(): - line = sl.line - eas, seen = [], set() - for x in range(len(line) + 1): - head = ida_hexrays.ctree_item_t() - item = ida_hexrays.ctree_item_t() - tail = ida_hexrays.ctree_item_t() - if not cfunc.get_line_item(line, x, False, head, item, tail): - continue - # Match the /*ea*/ marker's source (decompile_function_safe): the - # item's dstr() is 'EA: description'; get_ea() reports a different ea. - dstr = item.dstr() - if not dstr: - continue - parts = dstr.split(": ", 1) - if len(parts) != 2: - continue - try: - e = int(parts[0], 16) - except ValueError: - continue - if e not in seen: - seen.add(e) - eas.append(hex(e)) - lines.append({"ea": eas[0] if eas else None, "eas": eas}) - return {"addr": hex(func.start_ea), "lines": lines} - - -_idatui_strings_cache = {} - - -def _idatui_build_strings(min_len): - """[(ea, text, length, typename)] for every string IDA found, cached by - min_len (rebuilding the list is O(n) and the browser pages through it).""" - import idautils - import ida_nalt - hit = _idatui_strings_cache.get(min_len) - if hit is not None: - return hit - tnames = {} - for nm, lbl in (("STRTYPE_C", "C"), ("STRTYPE_C_16", "utf16"), - ("STRTYPE_C_32", "utf32"), ("STRTYPE_PASCAL", "pascal")): - v = getattr(ida_nalt, nm, None) - if v is not None: - tnames[v & 0xFF] = lbl - items = [] - for s in idautils.Strings(): - if s is None: - continue - try: - text = str(s) - except Exception: # noqa: BLE001 -- undecodable literal - continue - if len(text) < min_len: - continue - st = getattr(s, "strtype", 0) & 0xFF - items.append((s.ea, text, getattr(s, "length", len(text)), - tnames.get(st, "t%d" % st))) - _idatui_strings_cache[min_len] = items - return items - - -@tool -@idasync -def list_strings( - offset: Annotated[int, "Start index into the strings list"] = 0, - count: Annotated[int, "Max strings to return (page size)"] = 2000, - min_len: Annotated[int, "Minimum string length to include"] = 4, - refresh: Annotated[bool, "Rebuild the cached strings list"] = False, -) -> dict: - """Every string literal IDA found in the binary (IDA's Shift+F12 window), - paginated: {strings:[{addr,text,len,type}], total, next_offset}. Feeds the - TUI's strings browser.""" - try: - min_len = max(int(min_len), 1) - except (TypeError, ValueError): - min_len = 4 - try: - offset = max(int(offset), 0) - except (TypeError, ValueError): - offset = 0 - try: - count = max(int(count), 1) - except (TypeError, ValueError): - count = 2000 - if refresh: - _idatui_strings_cache.pop(min_len, None) - items = _idatui_build_strings(min_len) - page = items[offset:offset + count] - return { - "strings": [{"addr": hex(ea), "text": text, "len": ln, "type": ty} - for (ea, text, ln, ty) in page], - "total": len(items), - "next_offset": offset + len(page), - } - -@tool -@idasync -def list_linkage( - kind: Annotated[str, "'import', 'export' or 'both'"] = "both", -) -> dict: - """What this binary imports from, and exports to, other modules: - {imports:[{addr,name,module}], exports:[{addr,name,ordinal}]}. Feeds the - project-wide import/export join, which resolves a PLT stub in one binary to - the real implementation in another.""" - import idaapi - import idautils - import ida_nalt - want = str(kind or "both").lower() - imports = [] - exports = [] - if want in ("import", "both"): - n = ida_nalt.get_import_module_qty() - for i in range(n): - mod = ida_nalt.get_import_module_name(i) or "" - - def _cb(ea, name, ordinal, _mod=mod): - # An ordinal-only import has no name; skip rather than invent one. - if name: - imports.append({"addr": hex(ea), "name": name, "module": _mod}) - return True - - ida_nalt.enum_import_names(i, _cb) - if want in ("export", "both"): - for index, ordinal, ea, name in idautils.Entries(): - if name: - exports.append({"addr": hex(ea), "name": name, - "ordinal": int(ordinal)}) - return {"imports": imports, "exports": exports, - "n_imports": len(imports), "n_exports": len(exports)} - -@tool -@idasync -def define_code_run( - addr: Annotated[str, "Address to start disassembling from"], - limit: Annotated[int, "Max instructions to create (safety stop)"] = 20000, -) -> dict: - """Disassemble CONSECUTIVELY from ``addr`` until something stops it, the way - IDA's 'c' does — one instruction is rarely what you want when carving a raw - image. Returns {start,end,count,stopped} where ``stopped`` says why: - 'undecodable' (bytes aren't an instruction), 'flow' (the last instruction - doesn't fall through, e.g. RET/B), 'defined' (ran into existing code/data), - 'segment' (hit the end) or 'limit'. - - Runs in-process: doing this from the client would be one round trip per - instruction, which is minutes on a real firmware image.""" - import ida_bytes - import ida_idp - import ida_segment - import ida_ua - import idaapi - - try: - ea = parse_address(addr) - except Exception as e: - return {"addr": str(addr), "error": str(e), "count": 0} - - seg = ida_segment.getseg(ea) - if not seg: - return {"addr": str(addr), "error": "no segment", "count": 0} - hi = seg.end_ea - try: - limit = max(1, min(int(limit), 200000)) - except (TypeError, ValueError): - limit = 20000 - - start, count, stopped = ea, 0, "limit" - 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): - # Already defined: stop rather than clobber. Undefining someone's - # existing work to keep a speculative run going is not a trade the - # user asked for. - stopped = "defined" - break - n = ida_ua.create_insn(ea) - if n <= 0: - stopped = "undecodable" - break - count += 1 - # Stop where control flow stops. Past a RET the next bytes are usually - # padding or a new function's data, and running on turns a clean carve - # into a mess that has to be undone by hand. - # - # Ask ida_idp.is_ret_insn, NOT the canonical feature bits: on AArch64 - # get_canon_feature() returns 0 for RET, so a CF_STOP test silently never - # fires and the run walks straight through the end of the routine. - insn = ida_ua.insn_t() - if ida_ua.decode_insn(insn, ea) > 0: - try: - is_ret = ida_idp.is_ret_insn(insn) - except Exception: - is_ret = False - if is_ret or (insn.get_canon_feature() & idaapi.CF_STOP): - ea += n - stopped = "flow" - break - ea += n - - return {"start": hex(start), "end": hex(ea), "count": count, - "stopped": stopped} - -@tool -@idasync -def set_thumb( - addr: Annotated[str, "Address to change the ARM decoding mode at"], - mode: Annotated[str, "'toggle', 'on' (Thumb) or 'off' (ARM)"] = "toggle", - end: Annotated[str, "Optional exclusive end address (default: this item)"] = "", -) -> dict: - """Switch ARM/Thumb decoding at ``addr`` (IDA's T segment register). - - Thumb is not a property of the bytes, it's a mode the CPU is in, so a raw - image gives IDA no way to know: at a Thumb entry point it decodes 16-bit - instructions as 32-bit ARM and produces confident nonsense - (``push {r3,lr}`` reads as ``SVCLT 0xBF00``). - - Also forces the segment to 32-bit when turning Thumb ON. Thumb does not - exist in AArch64, and a headerless blob loaded with -parm defaults to - 64-bit — so setting T alone changes nothing and looks broken. Asking for - Thumb IS asking for ARM32.""" - import ida_bytes - import ida_idp - import ida_segment - import ida_segregs - - try: - ea = parse_address(addr) - except Exception as e: - return {"addr": str(addr), "error": str(e)} - treg = ida_idp.str2reg("T") - if treg is None or treg < 0: - return {"addr": hex(ea), "error": "no T register (not an ARM database)"} - seg = ida_segment.getseg(ea) - if not seg: - return {"addr": hex(ea), "error": "no segment"} - - import ida_ida - db64 = ida_ida.inf_get_app_bitness() == 64 - cur = ida_segregs.get_sreg(ea, treg) - cur = 0 if cur in (None, 0xFFFFFFFF, -1) else int(cur) - want = {"on": 1, "off": 0}.get(str(mode).lower(), 0 if cur else 1) - - changed_bits = False - if want and seg.bitness != 1: - ida_segment.set_segm_addressing(seg, 1) - changed_bits = True - - try: - stop = parse_address(end) if end else 0 - except Exception: - stop = 0 - size = max(int(stop) - ea, 0) or max(ida_bytes.get_item_size(ea), 2) - # The bytes are currently decoded in the OLD mode; leaving that item defined - # pins the wrong instruction length and the new mode has nothing to apply to. - 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) - return {"addr": hex(ea), "thumb": bool(now), "was": bool(cur), "ok": ok, - "bitness": ida_segment.getseg(ea).bitness, - "forced_32bit": changed_bits, - # The DATABASE's bitness is fixed at load and can't be corrected - # here (setting it post-hoc makes the decompiler INTERR). In a - # 64-bit database a 32-bit function disassembles but Hex-Rays - # refuses it outright, so say so instead of leaving the user to - # discover that F5 does nothing. - "db_64bit": bool(db64 and want)} - -def _idatui_add_func(ea): - """add_func at ``ea``, falling back to an explicit end. - - ida_funcs.add_func(ea) asks IDA to find the end and on carved or - freshly-marked code it often can't, failing with no reason given.""" - import ida_bytes - import ida_funcs - import ida_segment - import idaapi - - if idaapi.get_func(ea) is not None: - return True - if ida_funcs.add_func(ea): - return True - seg = ida_segment.getseg(ea) - hi = seg.end_ea if seg else ea - end = ea - while end < hi and ida_bytes.is_code(ida_bytes.get_flags(end)): - nxt = ida_bytes.get_item_end(end) - if nxt <= end: - break - end = nxt - return bool(end > ea and ida_funcs.add_func(ea, end)) - - -@tool -@idasync -def define_func_run( - addr: Annotated[str, "Entry point of the function to create"], -) -> dict: - """Create a function at ``addr``, working out its end if IDA can't. - - ida_funcs.add_func(ea) asks IDA to find the end itself, and on hand-carved - code it often can't — a run that ends in a tail call, or whose last - instruction isn't recognised as a return, simply fails with no reason given. - You then have a disassembled routine that refuses to become a function, and - F5 has nothing to work with. - - So: try IDA's way, and if that fails, use the end of the contiguous - instruction run starting at ``addr``.""" - import ida_bytes - import ida_funcs - import ida_segment - import idaapi - - try: - ea = parse_address(addr) - except Exception as e: - return {"addr": str(addr), "error": str(e), "ok": False} - fn = idaapi.get_func(ea) - if fn is not None and fn.start_ea == ea: - return {"addr": hex(ea), "ok": True, "start": hex(fn.start_ea), - "end": hex(fn.end_ea), "how": "existed"} - auto = ida_funcs.add_func(ea) - if not auto and not _idatui_add_func(ea): - return {"addr": hex(ea), "ok": False, - "error": f"IDA refused a function at {ea:#x}"} - f = idaapi.get_func(ea) - if f is None: - return {"addr": hex(ea), "ok": False, "error": "function did not stick"} - return {"addr": hex(ea), "ok": True, "start": hex(f.start_ea), - "end": hex(f.end_ea), "how": "auto" if auto else "explicit-end"} - -@tool -@idasync -def decomp_error( - addr: Annotated[str, "Address of the function that failed to decompile"], -) -> dict: - """Why Hex-Rays refused this function, in its own words. - - The plain decompile tool reports "Decompilation failed at 0x0" and drops the - reason, which is the only useful part. Hex-Rays fills in a hexrays_failure_t - saying things like "only 64-bit functions can be decompiled in the current - database" — that one is unfixable in place (the database's bitness is set at - load), so a user who can't see it has no way to know they must reload.""" - import ida_funcs - import ida_hexrays - import ida_ida - - try: - ea = parse_address(addr) - except Exception as e: - return {"addr": str(addr), "error": str(e)} - out = {"addr": hex(ea), "bitness": ida_ida.inf_get_app_bitness()} - fn = ida_funcs.get_func(ea) - if fn is None: - out["reason"] = "no function here" - return out - try: - if not ida_hexrays.init_hexrays_plugin(): - out["reason"] = "the decompiler is not available for this processor" - return out - hf = ida_hexrays.hexrays_failure_t() - cf = ida_hexrays.decompile_func(fn, hf) - if cf is not None: - out["reason"] = "" # it decompiles now - return out - out["reason"] = hf.desc() or f"error {hf.code}" - out["code"] = int(hf.code) - out["errea"] = hex(hf.errea) - except Exception as e: # noqa: BLE001 - out["reason"] = f"{type(e).__name__}: {e}" - return out - -@tool -@idasync -def thumb_scan( - start: Annotated[str, "Start of the range to scan for entry pointers"] = "", - end: Annotated[str, "Exclusive end of the range (default: 1KB from start)"] = "", - apply: Annotated[bool, "Mark the targets as Thumb and disassemble them"] = True, - limit: Annotated[int, "Max entries to act on"] = 512, -) -> dict: - """Find Thumb entry points from ODD pointers, e.g. a Cortex-M vector table. - - An ARM function pointer carries the mode in bit 0: odd means Thumb. A vector - table is therefore a list of Thumb entry points that IDA won't follow on a - headerless image, because nothing tells it those words are pointers at all. - - Being wrong here is expensive — marking a data word as code corrupts the - listing — so a word only counts when it is odd, lands inside a loaded - segment, and its target is EXECUTABLE and not already defined as data. The - even words in a vector table (the initial stack pointer) fail the first test, - which is the point.""" - import ida_bytes - import ida_funcs - import ida_idp - import ida_segment - import ida_segregs - import ida_ua - - seg0 = ida_segment.getseg(parse_address(start)) if start else None - if seg0 is None: - seg0 = ida_segment.getnseg(0) - if seg0 is None: - return {"error": "no segments", "found": [], "applied": 0} - try: - lo = parse_address(start) if start else seg0.start_ea - hi = parse_address(end) if end else min(lo + 0x400, seg0.end_ea) - except Exception as e: - return {"error": str(e), "found": [], "applied": 0} - - treg = ida_idp.str2reg("T") - found, applied = [], 0 - ea = lo - while ea + 4 <= hi and len(found) < limit: - w = ida_bytes.get_dword(ea) - ea += 4 - if not (w & 1): - continue # even: not a Thumb pointer - tgt = w & ~1 - seg = ida_segment.getseg(tgt) - if seg is None or not (seg.perm & ida_segment.SEGPERM_EXEC or seg.perm == 0): - continue # points outside the image, or at data - f = ida_bytes.get_flags(tgt) - if ida_bytes.is_data(f): - continue # already something else; don't fight it - rec = {"at": hex(ea - 4), "value": hex(w), "target": hex(tgt), - "was_code": bool(ida_bytes.is_code(f))} - found.append(rec) - if not apply: - continue - if treg is not None and treg >= 0: - ida_segregs.split_sreg_range(tgt, treg, 1, ida_segregs.SR_user) - if not ida_bytes.is_code(ida_bytes.get_flags(tgt)): - ida_bytes.del_items(tgt, 0, 2) - if ida_ua.create_insn(tgt) <= 0: - rec["decoded"] = False - continue - rec["decoded"] = True - rec["function"] = _idatui_add_func(tgt) - applied += 1 - return {"start": hex(lo), "end": hex(hi), "found": found, - "applied": applied, "n": len(found)} -''' - -SNIPPET = f"{BEGIN}\n{BODY.strip()}\n{END}\n" - - -def api_types_path() -> pathlib.Path | None: - """Locate ida_pro_mcp/ida_mcp/api_types.py without importing it (importing the - submodule would pull in IDA, which isn't available outside a worker).""" - spec = importlib.util.find_spec("ida_pro_mcp") # top-level pkg is IDA-free - if spec is None or not spec.submodule_search_locations: - return None - p = pathlib.Path(spec.submodule_search_locations[0]) / "ida_mcp" / "api_types.py" - return p if p.exists() else None - - -def main() -> int: - path = api_types_path() - if path is None: - print("idatui: ida_pro_mcp not found; skipping tool injection", file=sys.stderr) - return 0 - text = path.read_text() - if BEGIN in text and END in text: # replace the existing block in place - pre = text[: text.index(BEGIN)].rstrip() - post = text[text.index(END) + len(END):].lstrip("\n") - new = pre + "\n\n" + SNIPPET + ("\n" + post if post else "") - else: - new = text.rstrip() + "\n\n" + SNIPPET - if new == text: - return 0 - try: - path.write_text(new) - except OSError as e: - print(f"idatui: could not patch {path}: {e}", file=sys.stderr) - return 1 - print(f"idatui: injected/updated idatui-ext tools in {path}", file=sys.stderr) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_codemode_client.py b/tests/test_codemode_client.py new file mode 100644 index 0000000..6303eb9 --- /dev/null +++ b/tests/test_codemode_client.py @@ -0,0 +1,137 @@ +"""IDA-free contract tests for the Code Mode client adapter.""" +from __future__ import annotations + +import os +import sys +import tempfile +from dataclasses import dataclass + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import idatui.codemode_client as module # noqa: E402 +from idatui.codemode_client import CodeModeClient, _parse_load_args # noqa: E402 +from idatui.errors import IDAToolError # noqa: E402 + +PASS = FAIL = 0 + + +def check(name: str, condition: bool, detail="") -> None: + global PASS, FAIL + if condition: + PASS += 1 + print(f" ok {name}") + else: + FAIL += 1 + print(f" FAIL {name} {detail}") + + +@dataclass(frozen=True) +class FakeEntry: + pid: int = 123 + backend: str = "gui" + record_id: str = "123-abcdef" + exe_path: str = "" + idb_path: str = "" + + +class FakeHandle: + def __init__(self, path: str) -> None: + self.connected = True + self.entry = FakeEntry(exe_path=path, idb_path=path + ".i64") + self.waited = None + self.saved = 0 + self.closed = False + self.code = "" + self.code_timeout = None + + def wait_autoanalysis(self, timeout=None): + self.waited = timeout + return {"complete": True, "status": "complete"} + + def execute_python(self, code, timeout=None): + self.code = code + self.code_timeout = timeout + return {"result": {"sentinel": 7}, "stdout": "", "stderr": ""} + + def save_database(self): + self.saved += 1 + return {"saved": True, "idb_path": self.entry.idb_path} + + def close(self): + self.connected = False + self.closed = True + + +class FakeDatabaseHandle: + opened = None + kwargs = None + + @classmethod + def open(cls, path, **kwargs): + cls.opened = path + cls.kwargs = kwargs + return FakeHandle(path) + + +def main() -> int: + proc, base, file_type = _parse_load_args("-parm:ARMv7-M -b800000 -TRaw") + check("legacy switches map to typed Code Mode options", + (proc, base, file_type) == ("arm:ARMv7-M", 0x8000000, "Raw"), + (proc, base, file_type)) + try: + _parse_load_args("-parm -zcustom") + except ValueError as exc: + check("arbitrary IDA switches fail loudly", "cannot represent" in str(exc), exc) + else: + check("arbitrary IDA switches fail loudly", False) + + original = module.DatabaseHandle + module.DatabaseHandle = FakeDatabaseHandle + try: + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "sample.bin") + with open(path, "wb") as file: + file.write(b"sample") + client = CodeModeClient(path, load_args="-parm:ARMv7-A -b100") + notes = [] + client.connect(timeout=42, progress=notes.append) + handle = client._handle + check("connect delegates database discovery to DatabaseHandle.open", + FakeDatabaseHandle.opened == path and handle is not None) + check("typed loader options cross the dependency boundary", + FakeDatabaseHandle.kwargs["processor"] == "arm:ARMv7-A" + and FakeDatabaseHandle.kwargs["loading_address"] == 0x1000, + FakeDatabaseHandle.kwargs) + check("connect waits for Code Mode autoanalysis", + handle.waited == 42, getattr(handle, "waited", None)) + check("progress distinguishes discovery and backend attachment", + len(notes) == 2 and "gui" in notes[-1], notes) + result = client.invoke("list_funcs", queries=[{"offset": 0, "count": 2}]) + check("invoke returns execute_python's result", result == {"sentinel": 7}, result) + check("operation scripts use the preloaded ida-domain database", + "db.functions.get_all()" in handle.code, handle.code[:200]) + check("health exposes registry identity", + client.health()["record_id"] == "123-abcdef") + client.save_database() + check("save uses the public Code Mode save route", handle.saved == 1) + client.close() + check("close releases only the handle lease", handle.closed) + check("GUI lifetime is never claimed by the client", + client.wait_released(0) is False) + finally: + module.DatabaseHandle = original + + client = CodeModeClient(__file__) + try: + client.invoke("not-an-operation") + except IDAToolError as exc: + check("unknown adapter operations are explicit", exc.tool == "not-an-operation") + else: + check("unknown adapter operations are explicit", False) + + print(f"\n{PASS} passed, {FAIL} failed") + return 1 if FAIL else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_pool.py b/tests/test_pool.py index 5c6e2c4..ff0c016 100644 --- a/tests/test_pool.py +++ b/tests/test_pool.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 -"""Unit tests for idatui.pool (worker residency: LRU + memory budget). +"""Unit tests for idatui.pool (Code Mode lease residency and LRU budget). -Pure stdlib with a fake client injected, so the eviction policy is testable -without spawning real idalib workers. +A fake client keeps the policy testable without IDA or Textual. python tests/test_pool.py """ @@ -11,7 +10,7 @@ import sys import tempfile sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from idatui.pool import WorkerPool # noqa: E402 +from idatui.pool import DatabasePool # noqa: E402 from idatui.project import Project # noqa: E402 PASS = FAIL = 0 @@ -28,11 +27,12 @@ def check(name, cond, detail=""): class FakeClient: - """Stands in for a WorkerClient: records saves/closes, reports fixed memory.""" + """Stands in for a CodeModeClient lease and records saves/closes.""" - def __init__(self, ref, mem=100): + def __init__(self, ref, mem=100, backend="idalib"): self.ref = ref self.mem = mem + self.backend = backend self.saved = 0 self.closed = False self.connected = False @@ -41,10 +41,9 @@ class FakeClient: self.connected = True return self - def call(self, tool, **kw): - if tool == "idb_save": - self.saved += 1 - return {} + def save_database(self): + self.saved += 1 + return {"saved": True} def close(self, grace=None): self.closed = True @@ -72,15 +71,15 @@ def main() -> int: made[ref.label] = c return c - pool = WorkerPool(proj, budget_mb=350, spawn=spawn, + pool = DatabasePool(proj, budget_mb=350, spawn=spawn, mem_fn=lambda c: c.mem) # -- lazy spawn + reuse -------------------------------------------- # a = pool.get("bin0") - check("get() spawns a worker on first use", a is made["bin0"] and a.connected) + check("get() spawns a database lease on first use", a is made["bin0"] and a.connected) check("get() stages the binary first", os.path.isfile(proj.by_label("bin0").staged)) - check("get() reuses the resident worker", pool.get("bin0") is a) + check("get() reuses the resident lease", pool.get("bin0") is a) check("resident() reports it", pool.resident() == ["bin0"], pool.resident()) # -- LRU ordering ---------------------------------------------------- # @@ -96,9 +95,9 @@ def main() -> int: check("exceeding the budget evicts the least-recently-used", pool.evicted == ["bin1"] and not pool.is_resident("bin1"), f"evicted={pool.evicted} resident={pool.resident()}") - check("the just-spawned worker is never the victim", pool.is_resident("bin3")) + check("the just-attached lease is never the victim", pool.is_resident("bin3")) check("eviction saves the database first", made["bin1"].saved == 1) - check("eviction closes the worker", made["bin1"].closed) + check("eviction closes the lease", made["bin1"].closed) check("pool is back within budget", pool.memory_mb() <= pool.budget_mb, f"{pool.memory_mb()}/{pool.budget_mb}") @@ -112,7 +111,7 @@ def main() -> int: # -- pinning ---------------------------------------------------------- # pool.close_all() - pool2 = WorkerPool(proj, budget_mb=250, spawn=spawn, mem_fn=lambda c: c.mem) + pool2 = DatabasePool(proj, budget_mb=250, spawn=spawn, mem_fn=lambda c: c.mem) pool2.get("bin0") pool2.pin("bin0") pool2.get("bin1") @@ -139,7 +138,7 @@ def main() -> int: # -- teardown ----------------------------------------------------------- # pool2.close_all() - check("close_all() closes every worker", + check("close_all() closes every lease", not pool2.resident() and all(c.closed for c in made.values())) check("close_all() clears the active binary", pool2.active is None) @@ -151,8 +150,8 @@ def main() -> int: check("an unknown label raises KeyError", True) # -- default budget comes from the project's memory_pct ------------------- # - pool3 = WorkerPool(proj, spawn=spawn, mem_fn=lambda c: c.mem) - check("default budget is derived, not a fixed worker count", + pool3 = DatabasePool(proj, spawn=spawn, mem_fn=lambda c: c.mem) + check("default budget is derived, not a fixed lease count", pool3.budget_mb >= 256, pool3.budget_mb) # -- prewarm: speculative, and never at the cost of a real binary ------ # @@ -165,7 +164,7 @@ def main() -> int: made2[ref.label] = c return c - pool = WorkerPool(proj, budget_mb=250, spawn=spawn2, + pool = DatabasePool(proj, budget_mb=250, spawn=spawn2, mem_fn=lambda c: c.mem) labels = [r.label for r in proj.refs] a, b, c_ = labels[0], labels[1], labels[2] @@ -184,6 +183,28 @@ def main() -> int: check("prewarm ignores a label outside the project", pool.prewarm("nope") is False) + # Budget eviction releases GUI leases but must not save somebody's open IDA + # implicitly. An explicit save-and-close remains authoritative. + with tempfile.TemporaryDirectory() as tmp: + proj = _mkproject(tmp, n=1) + made_gui = [] + + def spawn_gui(ref, ttl): + client = FakeClient(ref, backend="gui") + made_gui.append(client) + return client + + pool = DatabasePool(proj, spawn=spawn_gui, mem_fn=lambda c: c.mem) + label = proj.refs[0].label + pool.get(label) + pool.evict(label) + check("LRU release does not implicitly save a GUI database", + made_gui[-1].saved == 0) + pool.get(label) + pool.close_all(save=True) + check("explicit close_all(save=True) does save a GUI database", + made_gui[-1].saved == 1) + print(f"\n{PASS} passed, {FAIL} failed") return 1 if FAIL else 0 diff --git a/tests/test_project.py b/tests/test_project.py index 91fd250..7c0dcba 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Unit tests for idatui.project (the multi-binary project model + staging). -Pure stdlib: no IDA, no textual, no worker — runs anywhere in under a second. +IDA-free: exercises staging plus Code Mode ownership checks without opening a database. python tests/test_project.py """ diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 9d77680..ab510f4 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -2316,7 +2316,13 @@ async def _build_pristine(binary, cache): await pilot.pause(0.05) if app._func_index is not None and app._func_index.complete: break - app.program.client.call("idb_save", timeout=600.0) + app.program.client.save_database() + # Textual's headless run_test context does not reliably emit App.Unmount on + # every platform/version; release the Code Mode lease explicitly. + if app.program is not None: + app.program.close() + if app.client is not None: + app.client.close() db = binary + ".i64" if os.path.exists(db): shutil.copy2(db, cache) @@ -2346,7 +2352,7 @@ async def run(binary, only=None): async def _run_on(binary, only=None): - # Own idalib worker: opens the binary in-process over a unix socket. + # Code Mode attaches a registered GUI or starts/reuses a managed worker. app = IdaTui(open_path=binary, keepalive=False) async with app.run_test(size=(140, 44)) as pilot: c = Ctx(app, pilot) @@ -2366,6 +2372,14 @@ async def _run_on(binary, only=None): print(f"── {name} ({asyncio.get_event_loop().time() - _t0:.1f}s) CRASHED") c.check("scenario did not crash", False, f"{type(e).__name__}: {e}") traceback.print_exc() + # Headless run_test does not reliably emit App.Unmount; explicitly release + # the lease. Then wait through the managed worker's final-lease grace and + # IDB close so Windows can remove this suite's TemporaryDirectory safely. + if app.program is not None: + app.program.close() + if app.client is not None: + app.client.close() + await asyncio.to_thread(app.client.wait_released, 45.0) def main(argv): diff --git a/uv.lock b/uv.lock index 91414b5..392ebf0 100644 --- a/uv.lock +++ b/uv.lock @@ -11,27 +11,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "ida-codemode-mcp" +version = "0.2.0" +source = { editable = "../ida-codemode-mcp" } +dependencies = [ + { name = "ida-domain" }, + { name = "zeromcp" }, +] + +[package.metadata] +requires-dist = [ + { name = "ida-domain", git = "https://github.com/HexRaysSA/ida-domain?branch=main" }, + { name = "zeromcp", specifier = ">=1.5.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.3" }, + { name = "ruff", specifier = ">=0.12.0" }, +] + +[[package]] +name = "ida-domain" +version = "0.5.1.dev1" +source = { git = "https://github.com/HexRaysSA/ida-domain?branch=main#8f36bbce94f0dd55e4ad5f7c8b5f0ef59b9c557a" } +dependencies = [ + { name = "idapro" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] + +[[package]] +name = "idapro" +version = "0.0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/75/249c605cc144a6b3778c48381d31ff9242f3e0b7ae23a9ca9c27224e641a/idapro-0.0.10.tar.gz", hash = "sha256:417c03c4605d18417e470f6a748e397b39d6d5829ebd3bbdedd92ff5b9092d11", size = 1060989, upload-time = "2026-07-15T12:55:22.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/83/7b02832cc8b057f686cccdb771fe80282f801a9b798961f8070bb468c73c/idapro-0.0.10-py3-none-any.whl", hash = "sha256:43f227953a0e348ced21c050d277b7ce34103e2ce05fc739b7d8c186ef0e1542", size = 2194897, upload-time = "2026-07-15T12:55:20.88Z" }, +] + [[package]] name = "idatui" version = "0.0.1" source = { editable = "." } +dependencies = [ + { name = "ida-codemode-mcp" }, + { name = "pygments" }, + { name = "textual" }, +] [package.optional-dependencies] dev = [ { name = "pytest" }, ] -tui = [ - { name = "pygments" }, - { name = "textual" }, -] [package.metadata] requires-dist = [ - { name = "pygments", marker = "extra == 'tui'", specifier = ">=2" }, + { name = "ida-codemode-mcp", editable = "../ida-codemode-mcp" }, + { name = "pygments", specifier = ">=2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, - { name = "textual", marker = "extra == 'tui'", specifier = ">=8" }, + { name = "textual", specifier = ">=8" }, ] -provides-extras = ["tui", "dev"] +provides-extras = ["dev"] [[package]] name = "iniconfig" @@ -191,3 +233,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d wheels = [ { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, ] + +[[package]] +name = "zeromcp" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/10/0c5018221766413c808b62f229a3b6b2cd0e4b10bc9ac25fee6152c22938/zeromcp-1.5.0.tar.gz", hash = "sha256:ef4e590ddb20a30a2ceaee86dbf893c9edb5d3e583c22a0ea7025e94763e59d2", size = 95257, upload-time = "2026-07-22T13:39:29.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/46/aa0e0941b511969a3eb70ea19add43b22c7336a4bf65a4fe4ea2176faf25/zeromcp-1.5.0-py3-none-any.whl", hash = "sha256:ca3b67687850ed463a255c180a286901ea69343612dc84ef279ec75b460f77ee", size = 21875, upload-time = "2026-07-22T13:39:28.44Z" }, +] -- 2.53.0.windows.2