diff options
| -rw-r--r-- | TODO | 9 | ||||
| -rw-r--r-- | idatui/app.py | 85 | ||||
| -rw-r--r-- | idatui/domain.py | 62 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 50 |
4 files changed, 200 insertions, 6 deletions
@@ -15,8 +15,13 @@ doable: hard: [ ] deal with PLT stubs and such (oh god here we go) -[ ] how to deal with non-function-body regions? - -> we want to be able to do data/type definitions in .data etc. +[~] how to deal with non-function-body regions? + -> we want to be able to do data/type definitions in .data etc. + -> M0 done: navigating to a non-function EA opens a flat listing (region) + instead of refusing; c/p/u edit verbs on the disasm view (define code / + function / undefine) with bump_items() cache invalidation + region->func + upgrade. next: M1 server `heads` walker + ListingModel (address paging), + M2 ListingView (code+data render), M3 data typing in .data. crazy: [ ] multi/split view ala ghidra? diff --git a/idatui/app.py b/idatui/app.py index 1db32ff..e1b26b8 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -79,6 +79,7 @@ class NavEntry: dec_cursor_x: int = 0 # pseudocode column dec_scroll_y: int = -1 # pseudocode viewport top (-1 = derive) dec_scroll_x: int = 0 # pseudocode horizontal scroll + is_region: bool = False # not inside a function (flat listing view) class SearchRequested(Message): @@ -132,6 +133,16 @@ class RetypeRequested(Message): self.name = name +class EditItemRequested(Message): + """The disasm view asks to change item structure (IDA c/d/u/p) at the line + under the cursor. ``kind`` is 'code' | 'func' | 'undef'.""" + + def __init__(self, view, kind: str) -> None: # type: ignore[no-untyped-def] + super().__init__() + self.view = view + self.kind = kind + + class NavMixin: """Follow / xrefs actions shared by the code views (bindings live on each view since Textual only merges BINDINGS from DOMNode subclasses).""" @@ -581,6 +592,9 @@ class DisasmView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True Binding("home", "goto_top", "Top", show=False), Binding("G,end", "goto_bottom", "Bottom", show=False), Binding("o", "toggle_opcodes", "Opcodes", show=False), + Binding("c", "define_code", "Code", show=False), + Binding("p", "define_func", "Func", show=False), + Binding("u", "undefine", "Undef", show=False), Binding("tab,shift+tab", "app.toggle_view", "Pseudocode", priority=True), *SearchMixin.SEARCH_BINDINGS, *NavMixin.NAV_BINDINGS, @@ -832,6 +846,16 @@ class DisasmView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True line = self.model.cached_line(self.cursor) return line.ea if line else None + # -- item structure edits (IDA c/p/u) --------------------------------- # + def action_define_code(self) -> None: + self.post_message(EditItemRequested(self, "code")) + + def action_define_func(self) -> None: + self.post_message(EditItemRequested(self, "func")) + + def action_undefine(self) -> None: + self.post_message(EditItemRequested(self, "undef")) + def action_cursor_down(self) -> None: self._move(1) @@ -2654,6 +2678,51 @@ class IdaTui(App): self._dirty = True self._status(f"renamed {old} → {new} (Ctrl+S to save)") + # -- item structure edits (IDA c/p/u) --------------------------------- # + def on_edit_item_requested(self, msg: EditItemRequested) -> None: + ea = msg.view._cursor_ea() if isinstance(msg.view, DisasmView) else None + if ea is None: + self._status("no address on this line to (re)define") + return + self._do_edit_item(msg.kind, ea) + + @work(thread=True, exclusive=True, group="edititem") + def _do_edit_item(self, kind: str, ea: int) -> None: # worker context + assert self.program is not None + verb = {"code": "defined code", "func": "created function", + "undef": "undefined"}[kind] + try: + if kind == "code": + self.program.define_code(ea) + elif kind == "func": + self.program.define_func(ea) + else: + self.program.undefine(ea) + except Exception as e: # noqa: BLE001 -- surface soft/hard tool errors + self.app.call_from_thread(self._status, f"{kind}: {e}") + return + # Structure changed everywhere: drop all item/function/decomp caches. + self.program.bump_items() + # Re-resolve: a define_func upgrades the region to a real function view; + # anything else re-reads the (still function-less) listing in place. + fn = self.program.function_of(ea) + if fn is not None: + model = self.program.disasm(fn.addr, fn.name) + idx = 0 if ea == fn.addr else model.index_of_ea(ea) + self.app.call_from_thread( + self._open_at, fn.addr, fn.name, idx, False, -1, 0, False) + else: + name = self.program.region_label(ea) + model = self.program.disasm(ea, name) + idx = max(model.index_of_ea(ea), 0) + self.app.call_from_thread( + self._open_at, ea, name, idx, False, -1, 0, True) + self.app.call_from_thread(self._edit_item_done, verb, ea) + + def _edit_item_done(self, verb: str, ea: int) -> None: + self._dirty = True + self._status(f"{verb} @ {ea:#x} (Ctrl+S to save)") + @work(thread=True, exclusive=True, group="save") def _save(self) -> None: assert self.program is not None @@ -2682,7 +2751,13 @@ class IdaTui(App): assert self.program is not None fn = self.program.function_of(ea) if fn is None: - self.app.call_from_thread(self._status, f"no function contains {ea:#x}") + # Not inside a function: open a flat listing (region) anchored here + # rather than refusing. This is where you land on code IDA didn't + # mark, or on data — use c/p to define it (see _do_edit_item). + name = self.program.region_label(ea) + self.program.disasm(ea, name) # warm the block cache off the UI loop + self.app.call_from_thread( + self._open_at, ea, name, 0, push, -1, 0, True) return model = self.program.disasm(fn.addr, fn.name) idx = 0 if ea == fn.addr else model.index_of_ea(ea) @@ -2737,10 +2812,11 @@ class IdaTui(App): return best_idx def _open_at(self, ea: int, name: str, cursor: int, push: bool, - dec_cursor: int = -1, dec_cursor_x: int = 0) -> None: + dec_cursor: int = -1, dec_cursor_x: int = 0, + is_region: bool = False) -> None: if push: self._save_current_pos() - entry = NavEntry(ea=ea, name=name, cursor=cursor) + entry = NavEntry(ea=ea, name=name, cursor=cursor, is_region=is_region) if dec_cursor >= 0: entry.dec_cursor = dec_cursor entry.dec_cursor_x = dec_cursor_x @@ -2919,7 +2995,8 @@ class IdaTui(App): self._cur = entry # Each open honours the preferred view; a decomp failure last time fell # back to disasm without changing the preference, so retry decomp here. - self._active = self._pref + # A region (not inside a function) has no pseudocode -> force disasm. + self._active = "disasm" if entry.is_region else self._pref model = self.program.disasm(entry.ea, entry.name) sy = entry.scroll_y if entry.scroll_y >= 0 else None self.query_one(DisasmView).load( diff --git a/idatui/domain.py b/idatui/domain.py index c374b71..53fd907 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -853,6 +853,68 @@ class Program: for m in models: m.invalidate() + def bump_items(self) -> None: + """Signal that item/function STRUCTURE changed (define code/data/func, + undefine). Unlike a rename this can move instruction boundaries and + change function membership anywhere, so drop the disasm block caches, + the decompilation cache and the cached function indices outright, and + bump the name generation too (labels/names may appear or vanish).""" + with self._lock: + self._name_gen += 1 + self._indices.clear() + self._decomp.clear() + models = list(self._disasm.values()) + self._disasm.clear() + for m in models: + m.invalidate() + + # -- item / function structure edits (IDA c/d/u/p) --------------------- # + @staticmethod + def _first_result(payload) -> dict: + """Unwrap the first row of a batch tool response ({result:[...]} or a + bare list); soft per-item ``error`` fields ride along in the dict.""" + data = payload.get("result", payload) if isinstance(payload, dict) else payload + if isinstance(data, list): + return data[0] if data and isinstance(data[0], dict) else {} + return data if isinstance(data, dict) else {} + + def define_code(self, ea: int) -> None: + """Convert the bytes at ``ea`` into a code instruction (IDA's 'c'). + 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)}]) + 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)}])) + if res.get("error"): + raise IDAToolError(f"define code @ {ea:#x}: {res['error']}") + + def define_func(self, ea: int) -> None: + """Create a function starting at ``ea`` (IDA's 'p').""" + res = self._first_result( + self.client.call("define_func", items=[{"addr": hex(ea)}])) + if res.get("error"): + raise IDAToolError(f"create function @ {ea:#x}: {res['error']}") + + def undefine(self, ea: int, size: int | None = None) -> None: + """Undefine the item at ``ea`` back to raw bytes (IDA's 'u').""" + item: dict = {"addr": hex(ea)} + if size: + item["size"] = int(size) + res = self._first_result(self.client.call("undefine", items=[item])) + if res.get("error"): + raise IDAToolError(f"undefine @ {ea:#x}: {res['error']}") + + def region_label(self, ea: int) -> str: + """Display name for a non-function address (segment-qualified).""" + try: + sec = self.section_of(ea) + except Exception: # noqa: BLE001 + sec = None + return f"{sec} @ {ea:#x}" if sec else f"<no function> @ {ea:#x}" + @staticmethod def _fetch_output(url: str, timeout: float = 15.0): """GET the server's cached full-output blob (plain HTTP, not MCP).""" diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index b16cd98..97ee74b 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -1254,6 +1254,56 @@ async def s_rename_history(c: Ctx): app.program.client.call("rename", batch={"func": {"addr": hex(htarget), "name": hsym}}) +@scenario("region_define") +async def s_region_define(c: Ctx): + """A non-function address opens a flat listing (region) instead of being + refused, and 'p' there creates a function and upgrades the view.""" + app = c.app + fn = c.find_func(lambda f: 0x10 <= f.size <= 0x60 and f.name.startswith("sub_")) + if fn is None: + c.check("found a small sub_ function for the region test", False) + return + addr, size = fn.addr, fn.size + try: + # setup: undefine the whole function so [addr, addr+size) is a bare region + c.prog.undefine(addr, size=size) + c.prog.bump_items() + c.check("function removed by undefine", + c.prog.function_of(addr) is None, "still a function") + + # navigate there via the real 'g' prompt -> opens a REGION, not refused + await c.goto_ui(hex(addr)) + await c.wait(lambda: app._cur is not None and app._cur.ea == addr, 25) + c.check("goto to a non-function address opens a region (not refused)", + app._cur is not None and app._cur.is_region + and app._active == "disasm", + f"cur={app._cur} active={app._active} status={c.status()!r}") + await c.wait(lambda: c.dis.total > 0 and c.dis._cursor_ea() is not None, 25) + c.check("region listing renders lines with a resolvable cursor address", + c.dis.total > 0 and c.dis._cursor_ea() == addr, + f"total={c.dis.total} cur_ea={c.dis._cursor_ea()}") + + # cursor on the entry line; 'p' (re)creates the function + c.dis.focus() + c.dis.cursor, c.dis.cursor_x = 0, 0 + await c.pause(0.05) + await c.press("p") + await c.wait(lambda: c.prog.function_of(addr) is not None + and app._cur is not None and not app._cur.is_region, 25) + c.check("'p' creates a function and upgrades the region to a function view", + c.prog.function_of(addr) is not None + and not app._cur.is_region and app._cur.ea == addr, + f"fn={c.prog.function_of(addr)} cur={app._cur}") + finally: + # idempotency: guarantee the function is back even if a check failed + if c.prog.function_of(addr) is None: + try: + c.prog.define_func(addr) + except Exception: # noqa: BLE001 + pass + c.prog.bump_items() + + # --------------------------------------------------------------------------- # # Runner # --------------------------------------------------------------------------- # |
