From 4d98bf31a5fc99350c47a7ec84d00da618a15ca6 Mon Sep 17 00:00:00 2001 From: blasty Date: Wed, 22 Jul 2026 23:55:51 +0200 Subject: app: 'd' — define typed data in the listing (make_data, M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Press 'd' on a listing head to define typed data at that address via a prompt (the ".data type definitions" backlog item). Mirrors the retype prompt flow: MakeDataRequested -> #makedata Input (prefilled with a size-appropriate default type: unsigned __int8/16/32/64 or char[N]) -> _do_make_data worker -> Program.make_data -> bump_items -> reopen the listing in place. Accepts any C type IDA's SetType understands: int, char[16], my_struct, T *arr[4], ... Pilot: listing_view gains a deterministic sub-test — undefine a data head to synthesize an unknown run, assert it renders as `unknown`, then 'd' char[4] over it and assert it becomes a `data` head. 118 pass / 1 pre-existing flaky (filter); rpc_smoke 29/0. --- TODO | 9 ++++-- idatui/app.py | 86 +++++++++++++++++++++++++++++++++++++++++++++++++ idatui/domain.py | 11 +++++++ tests/test_scenarios.py | 41 +++++++++++++++++++++++ 4 files changed, 144 insertions(+), 3 deletions(-) diff --git a/TODO b/TODO index 92ecfa1..935fc24 100644 --- a/TODO +++ b/TODO @@ -25,9 +25,12 @@ hard: -> M2 done: ListingView — virtualized flat listing (code+data+undefined, kind-styled), wired into nav/follow/xrefs/hex/edit-verbs. region views now use it (not the DisasmModel stopgap). - -> next: M3 data typing in .data (`d`/make_data with a type prompt, struct- - typed data, string/dup coalescing for big undefined runs); M4 optionally - unify the function disasm view as a filtered listing. + -> M3 done: `d` = typed make_data prompt in the listing (define data in + .data with any C type: int, char[16], my_struct, ...); undefined byte + runs coalesce into `db N dup(?)` rows so big .bss doesn't explode. + -> next (optional): M4 unify the function disasm view as a filtered listing; + struct-typed data expansion / string auto-detection; back-paging in the + ListingModel (currently loads a segment forward via load_all). crazy: [ ] multi/split view ala ghidra? diff --git a/idatui/app.py b/idatui/app.py index ffef9b4..7bca4e0 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -145,6 +145,14 @@ class EditItemRequested(Message): self.kind = kind +class MakeDataRequested(Message): + """The listing view asks to define typed data (IDA 'd') at the current head.""" + + def __init__(self, view) -> None: # type: ignore[no-untyped-def] + super().__init__() + self.view = view + + class NavMixin: """Follow / xrefs actions shared by the code views (bindings live on each view since Textual only merges BINDINGS from DOMNode subclasses).""" @@ -891,6 +899,7 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru Binding("home", "goto_top", "Top", show=False), Binding("G,end", "goto_bottom", "Bottom", show=False), Binding("c", "define_code", "Code", show=False), + Binding("d", "make_data", "Data", show=False), Binding("p", "define_func", "Func", show=False), Binding("u", "undefine", "Undef", show=False), *SearchMixin.SEARCH_BINDINGS, @@ -1059,6 +1068,12 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru def action_undefine(self) -> None: self.post_message(EditItemRequested(self, "undef")) + def action_make_data(self) -> None: + self.post_message(MakeDataRequested(self)) + + def cur_head(self) -> Head | None: + return self._head(self.cursor) + def action_cursor_down(self) -> None: self._move(1) @@ -1962,6 +1977,10 @@ class IdaTui(App): height: 1; border: none; padding: 0 1; background: $accent-darken-2; color: $text; } + #makedata { + height: 1; border: none; padding: 0 1; + background: $secondary-darken-2; color: $text; + } #goto { height: 1; border: none; padding: 0 1; background: $primary-darken-3; color: $text; @@ -2037,6 +2056,7 @@ class IdaTui(App): self._rename_ctx: tuple[object | None, str] = (None, "") self._comment_ctx: tuple[object | None, int, str] = (None, 0, "") self._retype_ctx: tuple[object | None, str, int, str] = (None, "", 0, "") + self._makedata_ctx: tuple[object | None, int] = (None, 0) self._xref_focus_name: str | None = None self._dirty = False @@ -2072,6 +2092,10 @@ class IdaTui(App): ti.display = False ti.can_focus = False yield ti + mdi = Input(id="makedata") + mdi.display = False + mdi.can_focus = False + yield mdi gi = Input(id="goto") gi.display = False gi.can_focus = False @@ -2823,6 +2847,57 @@ class IdaTui(App): what = "prototype" if kind == "func" else f"'{word}'" self._status(f"retyped {what} (Ctrl+S to save)") + # -- typed data definition (make_data, IDA 'd') ----------------------- # + @staticmethod + def _default_data_type(head) -> str: # type: ignore[no-untyped-def] + """A sensible prefill C type for defining data over ``head``.""" + sz = getattr(head, "size", 0) or 0 + return {1: "unsigned __int8", 2: "unsigned __int16", + 4: "unsigned __int32", 8: "unsigned __int64"}.get( + sz, f"char[{sz}]" if sz > 0 else "unsigned __int8") + + def on_make_data_requested(self, msg: MakeDataRequested) -> None: + view = msg.view + ea = view._cursor_ea() if isinstance(view, ListingView) else None + if ea is None: + self._status("no address on this line to define data") + return + self._makedata_ctx = (view, ea) + self.query_one("#status", Static).display = False + inp = self.query_one("#makedata", Input) + inp.placeholder = (f"data type @ {ea:#x} (e.g. int, char[16], my_struct)" + " — Enter=apply Esc=cancel") + inp.can_focus = True + inp.display = True + head = view.cur_head() if isinstance(view, ListingView) else None + inp.value = self._default_data_type(head) if head is not None else "int" + inp.focus() + + def _end_makedata(self) -> None: + inp = self.query_one("#makedata", Input) + inp.display = False + inp.can_focus = False + self.query_one("#status", Static).display = True + view = self._makedata_ctx[0] + if view is not None: + view.focus() + + @work(thread=True, exclusive=True, group="makedata") + def _do_make_data(self, ea: int, type_decl: str) -> None: # worker context + assert self.program is not None + try: + self.program.make_data(ea, type_decl) + except Exception as e: # noqa: BLE001 + self.app.call_from_thread(self._status, f"make data: {e}") + return + self.program.bump_items() + name = self.program.region_label(ea) + lm = self.program.listing(ea) + idx = max(lm.ensure_ea(ea), 0) if lm is not None else 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, f"data ({type_decl})", ea) + @work(thread=True, exclusive=True, group="rename") def _do_rename(self, view, old: str, new: str) -> None: # type: ignore[no-untyped-def] assert self.program is not None @@ -3094,6 +3169,11 @@ class IdaTui(App): event.prevent_default() self._end_retype() return + if self.query_one("#makedata", Input).display: + event.stop() + event.prevent_default() + self._end_makedata() + return if self.query_one("#goto", Input).display: event.stop() event.prevent_default() @@ -3142,6 +3222,12 @@ class IdaTui(App): if view is not None and value: self._do_retype(kind, subject, word, value) return + if inp.id == "makedata": + view, ea = self._makedata_ctx + self._end_makedata() + if view is not None and value: + self._do_make_data(ea, value) + return if inp.id == "goto": self._end_goto() (self.query_one(HexView) if self._active == "hex" diff --git a/idatui/domain.py b/idatui/domain.py index fd673ca..4f33211 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -1071,6 +1071,17 @@ class Program: if res.get("error"): raise IDAToolError(f"undefine @ {ea:#x}: {res['error']}") + def make_data(self, ea: int, type_decl: str, name: str | None = None) -> None: + """Create a typed data item at ``ea`` (IDA's 'd', but typed). ``type_decl`` + is a C type, e.g. 'int', 'unsigned __int32', 'char[5]', 'my_struct'.""" + item: dict = {"addr": hex(ea), "type": type_decl} + if name: + item["name"] = name + res = self._first_result(self.client.call("make_data", items=[item])) + if res.get("ok") is False or res.get("error"): + raise IDAToolError( + f"make data @ {ea:#x}: {res.get('error') or 'rejected'}") + def region_label(self, ea: int) -> str: """Display name for a non-function address (segment-qualified).""" try: diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 2b9a9b8..f947888 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -1369,6 +1369,47 @@ async def s_listing_view(c: Ctx): c.check("returning from hex lands back on the listing (not a func view)", app._active == "listing" and c.lst.display, f"active={app._active}") + # 'd' defines typed data over an undefined run. Synthesize the run + # deterministically: undefine a data head, then re-type it via the prompt. + dhead = next((c.lst.model.get(i) for i in range(min(c.lst.total, 200)) + if c.lst.model.get(i) and c.lst.model.get(i).kind == "data" + and (c.lst.model.get(i).size or 0) >= 4), None) + if dhead is None: + c.check("found a data head to re-type", False) + return + dea = dhead.ea + try: + c.prog.undefine(dea, size=4) + c.prog.bump_items() + # reopen the listing so the model reflects the new undefined run + await c.goto_ui(hex(dea)) + await c.wait(lambda: app._active == "listing" and c.lst.total > 0 + and c.lst.model.index_of_ea(dea) >= 0, 25) + ui = c.lst.model.index_of_ea(dea) + c.check("undefining a data head yields an unknown run in the listing", + ui >= 0 and c.lst.model.get(ui).kind == "unknown", + f"kind={c.lst.model.get(ui).kind if ui>=0 else None}") + c.lst.focus() + c.lst.cursor = ui + await c.pause(0.05) + await c.press("d") + await c.pause(0.1) + mdi = app.query_one("#makedata", Input) + c.check("'d' opens the make-data prompt prefilled with a type", + mdi.display and bool(mdi.value), f"display={mdi.display} val={mdi.value!r}") + mdi.value = "char[4]" + await c.press("enter") + await c.wait(lambda: app._active == "listing" + and c.lst.model.index_of_ea(dea) >= 0 + and c.lst.model.get(c.lst.model.index_of_ea(dea)) is not None + and c.lst.model.get(c.lst.model.index_of_ea(dea)).kind == "data", 25) + di = c.lst.model.index_of_ea(dea) + c.check("'d' turns the undefined run into a typed data item", + di >= 0 and c.lst.model.get(di).kind == "data", + f"kind={c.lst.model.get(di).kind if di>=0 else None}") + finally: + c.prog.bump_items() + # --------------------------------------------------------------------------- # # Runner -- cgit v1.3.1-sl0p