diff options
| author | blasty <blasty@local> | 2026-07-10 09:53:56 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-10 09:53:56 +0200 |
| commit | 29f8a238d0e316c4a0fe4081c5412a18f5bb3c64 (patch) | |
| tree | f380c21b8c59e78962bf0d6bfe1d2be365ae93c6 | |
| parent | decompiler: fall back to disassembly when decompilation fails (diff) | |
| download | ida-tui-29f8a238d0e316c4a0fe4081c5412a18f5bb3c64.tar.gz ida-tui-29f8a238d0e316c4a0fe4081c5412a18f5bb3c64.tar.xz ida-tui-29f8a238d0e316c4a0fe4081c5412a18f5bb3c64.zip | |
struct editor: C-style CRUD for local types (Ctrl+T)
New StructEditor overlay: left is the list of local structs/unions, right is an
editable C definition. Enter loads a struct (reconstructed as C from its member
layout via type_inspect), Ctrl+S declares it (create or update via declare_type),
Ctrl+N starts a new one, Del deletes, Esc returns to the list then closes.
Domain: Struct model + Program.list_structs / struct_source / declare_type /
delete_type (anonymous $-types filtered).
Delete needs a 'del_type' tool the ida-pro-mcp server doesn't currently expose;
delete_type detects its absence and reports a clear message instead of failing.
Create/read/update work fully.
Pilot checks: open, list, view C, create, update-in-place, delete(-or-report),
close. full suite 84/84.
| -rw-r--r-- | idatui/__init__.py | 2 | ||||
| -rw-r--r-- | idatui/app.py | 186 | ||||
| -rw-r--r-- | idatui/domain.py | 77 | ||||
| -rw-r--r-- | tests/test_tui.py | 63 |
4 files changed, 324 insertions, 4 deletions
diff --git a/idatui/__init__.py b/idatui/__init__.py index 24f3c0f..a52ba8b 100644 --- a/idatui/__init__.py +++ b/idatui/__init__.py @@ -19,6 +19,7 @@ from .domain import ( Func, Line, Ref, + Struct, Decompilation, LIST_PAGE, DISASM_BLOCK, @@ -31,6 +32,7 @@ __all__ = [ "Func", "Line", "Ref", + "Struct", "Decompilation", "LIST_PAGE", "DISASM_BLOCK", diff --git a/idatui/app.py b/idatui/app.py index c87c27d..9cb0db5 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -32,13 +32,15 @@ from textual.reactive import reactive from textual.screen import ModalScreen from textual.scroll_view import ScrollView from textual.strip import Strip -from textual.widgets import DataTable, Footer, Header, Input, OptionList, Static +from textual.widgets import ( + DataTable, Footer, Header, Input, OptionList, Static, TextArea, +) from textual.widgets.option_list import Option from .highlight import highlight_c from .client import IDAClient, IDAToolError -from .domain import DisasmModel, Func, Program +from .domain import DisasmModel, Func, Program, Struct # Styles for the disassembly listing. _S_ADDR = Style(color="grey58") @@ -1164,6 +1166,169 @@ class SymbolPalette(ModalScreen): # --------------------------------------------------------------------------- # +# Struct editor (C-style local type editor) +# --------------------------------------------------------------------------- # +class StructEditor(ModalScreen): + """CRUD editor for local structs/unions, written as plain C. + + Left: the list of structs. Right: an editable C definition. Enter loads the + selected struct; Ctrl+S declares (creates or updates) it; Ctrl+N starts a + new one; Delete removes the highlighted struct; Esc returns to the list then + closes. + """ + + BINDINGS = [ + Binding("ctrl+s", "save", "Save", priority=True), + Binding("ctrl+n", "new", "New", priority=True), + Binding("delete", "delete", "Delete", show=False), + Binding("escape", "close", "Close"), + ] + + NEW_TEMPLATE = "struct NewStruct\n{\n int field;\n};\n" + + def __init__(self, program: Program) -> None: + super().__init__() + self._program = program + self._structs: list[Struct] = [] + self._loaded: str | None = None # name currently in the editor + + def compose(self) -> ComposeResult: + with Vertical(id="se-box"): + with Horizontal(id="se-panes"): + with Vertical(id="se-left"): + yield Static(" structs", id="se-title") + yield OptionList(id="se-list") + with Vertical(id="se-right"): + yield Static(" C definition", id="se-hint") + yield TextArea("", id="se-edit") + yield Static( + "Enter edit · Ctrl+S save · Ctrl+N new · Del delete · Esc back/close", + id="se-status") + + def on_mount(self) -> None: + self._refresh() + self.query_one("#se-list", OptionList).focus() + + # -- data --------------------------------------------------------------- # + @work(thread=True, exclusive=True, group="se-refresh") + def _refresh(self, select: str | None = None) -> None: + try: + structs = self._program.list_structs() + except Exception as e: # noqa: BLE001 + self.app.call_from_thread(self._set_status, f"list failed: {e}") + return + self.app.call_from_thread(self._populate, structs, select) + + def _populate(self, structs: list[Struct], select: str | None) -> None: + self._structs = structs + ol = self.query_one("#se-list", OptionList) + ol.clear_options() + for s in structs: + kw = "union" if s.is_union else "struct" + label = Text() + label.append(s.name, _S_LABEL) + label.append(f" {s.size:#x} {s.members}f {kw}", _S_DIM) + ol.add_option(Option(label)) + if structs: + idx = 0 + if select is not None: + idx = next((i for i, s in enumerate(structs) if s.name == select), 0) + ol.highlighted = idx + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + i = event.option_index + if 0 <= i < len(self._structs): + self._load(self._structs[i].name) + + @work(thread=True, exclusive=True, group="se-load") + def _load(self, name: str) -> None: + try: + src = self._program.struct_source(name) + except Exception as e: # noqa: BLE001 + self.app.call_from_thread(self._set_status, f"load failed: {e}") + return + self.app.call_from_thread(self._set_editor, name, src) + + def _set_editor(self, name: str, src: str) -> None: + self._loaded = name + ta = self.query_one("#se-edit", TextArea) + ta.text = src + ta.focus() + self._set_status(f"editing {name} — Ctrl+S to apply changes") + + # -- actions ------------------------------------------------------------ # + def action_save(self) -> None: + text = self.query_one("#se-edit", TextArea).text.strip() + if not text: + self._set_status("nothing to declare") + return + self._set_status("declaring…") + self._save(text) + + @work(thread=True, exclusive=True, group="se-save") + def _save(self, text: str) -> None: + try: + err = self._program.declare_type(text) + except Exception as e: # noqa: BLE001 + self.app.call_from_thread(self._set_status, f"declare failed: {e}") + return + m = re.search(r"\b(?:struct|union)\s+([A-Za-z_]\w*)", text) + name = m.group(1) if m else None + self.app.call_from_thread(self._after_save, name, err) + + def _after_save(self, name: str | None, err: str | None) -> None: + if err: + self._set_status(f"declare failed: {(err.strip() or 'parse error')[:80]}") + return + self._loaded = name + if getattr(self.app, "_dirty", None) is not None: + self.app._dirty = True # struct change is unsaved until Ctrl+S in the app + self._refresh(select=name) + self._set_status(f"saved {name}" if name else "saved") + + def action_new(self) -> None: + ta = self.query_one("#se-edit", TextArea) + ta.text = self.NEW_TEMPLATE + self._loaded = None + ta.focus() + self._set_status("new struct — edit and Ctrl+S") + + def action_delete(self) -> None: + ol = self.query_one("#se-list", OptionList) + i = ol.highlighted + if i is None or not (0 <= i < len(self._structs)): + return + self._delete(self._structs[i].name) + + @work(thread=True, exclusive=True, group="se-del") + def _delete(self, name: str) -> None: + try: + err = self._program.delete_type(name) + except Exception as e: # noqa: BLE001 + err = str(e) + self.app.call_from_thread(self._after_delete, name, err) + + def _after_delete(self, name: str, err: str | None) -> None: + if err: + self._set_status(err) + return + if getattr(self.app, "_dirty", None) is not None: + self.app._dirty = True + self._refresh() + self._set_status(f"deleted {name}") + + def action_close(self) -> None: + # A stray Esc while editing returns to the list instead of discarding. + if self.focused is self.query_one("#se-edit", TextArea): + self.query_one("#se-list", OptionList).focus() + return + self.dismiss(None) + + def _set_status(self, text: str) -> None: + self.query_one("#se-status", Static).update(text) + + +# --------------------------------------------------------------------------- # # The app # --------------------------------------------------------------------------- # class IdaTui(App): @@ -1202,11 +1367,21 @@ class IdaTui(App): #pal-title { dock: top; height: 1; background: $accent; color: $text; padding: 0 1; } #pal-input { border: none; height: 1; margin: 0 1; background: $panel; color: $text; } #pal-list { height: auto; max-height: 24; } + StructEditor { align: center middle; } + #se-box { width: 90%; height: 84%; border: thick $accent; background: $panel; } + #se-panes { height: 1fr; } + #se-left { width: 38; border-right: solid $accent; } + #se-right { width: 1fr; } + #se-title, #se-hint { height: 1; background: $accent; color: $text; padding: 0 1; } + #se-list { height: 1fr; } + #se-edit { height: 1fr; border: none; } + #se-status { height: 1; background: $panel-darken-2; color: $text-muted; padding: 0 1; } """ BINDINGS = [ Binding("q", "quit", "Quit"), Binding("ctrl+n", "symbols", "Symbols"), + Binding("ctrl+t", "structs", "Structs"), Binding("g", "goto", "Goto"), Binding("slash", "filter", "Filter", show=False), Binding("ctrl+b", "toggle_functions", "Names", show=False), @@ -1444,6 +1619,13 @@ class IdaTui(App): else: self.query_one("#func-table", DataTable).focus() + def action_structs(self) -> None: + """Ctrl+T: open the C-style struct editor overlay.""" + if self.program is None: + self._status("not connected yet") + return + self.push_screen(StructEditor(self.program)) + def action_symbols(self) -> None: """Ctrl+N: fuzzy-find a symbol in a command-palette overlay.""" idx = self._func_index diff --git a/idatui/domain.py b/idatui/domain.py index cdbdd67..f52535c 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -92,6 +92,25 @@ class Xref: fn_addr: int | None +@dataclass(frozen=True) +class Struct: + name: str + size: int + is_union: bool + members: int # field count + ordinal: int + + @classmethod + def from_raw(cls, d: dict) -> "Struct": + return cls( + name=d.get("name", ""), + size=int(d.get("size", 0) or 0), + is_union=bool(d.get("is_union", False)), + members=int(d.get("cardinality", 0) or 0), + ordinal=int(d.get("ordinal", 0) or 0), + ) + + @dataclass class Decompilation: ea: int @@ -431,6 +450,64 @@ class Program: return secs[i][2] return None + # -- structs / local types -------------------------------------------- # + 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) + res = payload.get("result", []) if isinstance(payload, dict) else [] + out = [Struct.from_raw(d) for d in res + if isinstance(d, dict) and d.get("name") + and not str(d["name"]).startswith("$")] # skip anonymous UDTs + out.sort(key=lambda s: s.name.lower()) + return out + + 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 + field names/types; array dims are moved after the field name.""" + payload = self.client.call( + "type_inspect", queries=[{"name": name, "include_members": True}]) + res = payload.get("result", []) if isinstance(payload, dict) else [] + info = res[0] if res and isinstance(res[0], dict) else {} + kw = "union" if info.get("is_union") else "struct" + lines = [f"{kw} {name}", "{"] + for m in info.get("members", []) or []: + if not isinstance(m, dict): + continue + t = str(m.get("type", "")).strip() + fn = m.get("name", "") + base, arr = t, "" + am = re.match(r"^(.*?)((?:\s*\[\d+\])+)\s*$", t) + if am: + base, arr = am.group(1).rstrip(), am.group(2).replace(" ", "") + sep = "" if base.endswith("*") else " " + lines.append(f" {base}{sep}{fn}{arr};") + lines.append("};") + return "\n".join(lines) + + 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) + res = payload.get("result", []) if isinstance(payload, dict) else [] + if res and isinstance(res[0], dict): + return res[0].get("error") + return None + + 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.""" + try: + self.client.call("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 msg + # -- disassembly ------------------------------------------------------- # def disasm(self, ea: int, name: str | None = None) -> DisasmModel: with self._lock: diff --git a/tests/test_tui.py b/tests/test_tui.py index 47a0e3e..f2e38ff 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -19,9 +19,10 @@ import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from idatui.app import ( # noqa: E402 - DecompView, DisasmView, FunctionsPanel, IdaTui, SymbolPalette, XrefsScreen, + DecompView, DisasmView, FunctionsPanel, IdaTui, StructEditor, SymbolPalette, + XrefsScreen, ) -from textual.widgets import DataTable, Input, OptionList, Static # noqa: E402 +from textual.widgets import DataTable, Input, OptionList, Static, TextArea # noqa: E402 from rich.text import Text # noqa: E402 PASS = FAIL = 0 @@ -143,6 +144,64 @@ async def run(db): check("found a decompile-failing function", failing is not None, f"pref={app._pref}") + # Struct editor (Ctrl+T): list / view / create / update / delete. + await pilot.press("ctrl+t") + se_open = await wait_until(pilot, lambda: isinstance(app.screen, StructEditor), 10) + check("Ctrl+T opens the struct editor", se_open, + f"screen={type(app.screen).__name__}") + if se_open: + se = app.screen + await wait_until(pilot, lambda: bool(se._structs), 15) + check("struct editor lists existing structs", len(se._structs) > 0, + f"n={len(se._structs)}") + ta = se.query_one(TextArea) + tname = next((s.name for s in se._structs if s.name == "timespec"), + se._structs[0].name) + idx = next(i for i, s in enumerate(se._structs) if s.name == tname) + se.query_one(OptionList).highlighted = idx + se.on_option_list_option_selected(type("E", (), {"option_index": idx})()) + await wait_until(pilot, lambda: tname in ta.text and "{" in ta.text, 15) + check("selecting a struct shows its C definition", + tname in ta.text and "{" in ta.text, f"text={ta.text[:40]!r}") + # create (fixed name so re-runs update in place -> no churn) + sname = "TuiEdTest" + await pilot.press("ctrl+n") + await pilot.pause(0.05) + ta.text = f"struct {sname} {{ int a; char b[8]; }};" + await pilot.press("ctrl+s") + await wait_until(pilot, lambda: any(s.name == sname for s in se._structs), 15) + check("Ctrl+S declares a new struct", + any(s.name == sname for s in se._structs), "not created") + # update it (add a field -> member count grows) + idx = next(i for i, s in enumerate(se._structs) if s.name == sname) + se.on_option_list_option_selected(type("E", (), {"option_index": idx})()) + await wait_until(pilot, lambda: sname in ta.text, 10) + ta.text = f"struct {sname} {{ int a; char b[8]; long c; }};" + await pilot.press("ctrl+s") + await wait_until( + pilot, + lambda: next((s.members for s in se._structs if s.name == sname), 0) == 3, + 15) + check("Ctrl+S updates an existing struct in place", + next((s.members for s in se._structs if s.name == sname), 0) == 3, + "member count not 3") + # delete: removes it if the server supports del_type, else reports it + idx = next(i for i, s in enumerate(se._structs) if s.name == sname) + se.query_one(OptionList).highlighted = idx + se.action_delete() + await wait_until( + pilot, + lambda: not any(s.name == sname for s in se._structs) + or "del_type" in str(se.query_one("#se-status").render()), 15) + st = str(se.query_one("#se-status").render()) + gone = not any(s.name == sname for s in se._structs) + check("delete removes the struct (or reports the missing server tool)", + gone or "del_type" in st, f"gone={gone} status={st!r}") + se.query_one(OptionList).focus() + await pilot.press("escape") + await wait_until(pilot, lambda: not isinstance(app.screen, StructEditor), 10) + check("Esc closes the struct editor", not isinstance(app.screen, StructEditor)) + # Open the biggest function we can find (scan a sample of rows for size). # Simpler: select the row whose Size column is largest among first N. biggest_i, biggest_sz = 0, -1 |
