aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--idatui/app.py60
-rw-r--r--tests/test_scenarios.py63
2 files changed, 123 insertions, 0 deletions
diff --git a/idatui/app.py b/idatui/app.py
index 409e35a..570ed0a 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -2057,6 +2057,7 @@ class IdaTui(App):
self._cur: NavEntry | None = None
self._search_ctx: tuple[object | None, int] = (None, 1)
self._rename_ctx: tuple[object | None, str] = (None, "")
+ self._rename_addr: int | None = None # set for address-based (listing) naming
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)
@@ -2689,6 +2690,27 @@ class IdaTui(App):
return re.search(rf"\bgoto\s+{re.escape(name)}\b", body) is not None
def on_rename_requested(self, msg: RenameRequested) -> None:
+ # In the flat listing, 'n' names the ADDRESS under the cursor (create a
+ # label), not a symbol-by-name. This is what lets you name a bare/
+ # undefined byte — e.g. the free byte at addr+1 after shrinking a u16 to
+ # a u8 — which the word-under-cursor path can't do (no symbol to rename).
+ if isinstance(msg.view, ListingView):
+ ea = msg.view._cursor_ea()
+ if ea is None:
+ self._status("no address on this line to name")
+ return
+ head = msg.view.cur_head()
+ cur = head.name if (head is not None and head.name) else ""
+ self._rename_ctx = (msg.view, cur)
+ self._rename_addr = ea
+ self.query_one("#status", Static).display = False
+ inp = self.query_one("#rename", Input)
+ inp.placeholder = f"name @ {ea:#x} — Enter=apply Esc=cancel"
+ inp.can_focus = True
+ inp.display = True
+ inp.value = cur
+ inp.focus()
+ return
if not msg.name:
self._status("nothing to rename under the cursor")
return
@@ -2698,6 +2720,7 @@ class IdaTui(App):
"(Hex-Rays goto labels aren't renamable via the API)")
return
self._rename_ctx = (msg.view, msg.name)
+ self._rename_addr = None
self.query_one("#status", Static).display = False
inp = self.query_one("#rename", Input)
inp.placeholder = f"rename '{msg.name}' — Enter=apply Esc=cancel"
@@ -2710,6 +2733,7 @@ class IdaTui(App):
inp = self.query_one("#rename", Input)
inp.display = False
inp.can_focus = False
+ self._rename_addr = None
self.query_one("#status", Static).display = True
view, _ = self._rename_ctx
if view is not None:
@@ -3011,6 +3035,37 @@ class IdaTui(App):
self._dirty = True
self._status(f"renamed {old} → {new} (Ctrl+S to save)")
+ @work(thread=True, exclusive=True, group="rename")
+ def _do_name_addr(self, addr: int, name: str) -> None: # worker context
+ """Set a label at ``addr`` (listing 'n'). Works on a bare/undefined byte
+ — unlike the symbol-by-name path, this names the address directly."""
+ assert self.program is not None
+ try:
+ res = self.program.client.call(
+ "rename", batch={"data": {"addr": hex(addr), "new": name}})
+ except IDAToolError as e:
+ self.app.call_from_thread(self._status, f"name failed: {e.message}")
+ return
+ summary = res.get("summary", {}) if isinstance(res, dict) else {}
+ if not (summary.get("ok", 0) > 0 and summary.get("failed", 0) == 0):
+ err = "name failed"
+ items = res.get("data") if isinstance(res, dict) else None
+ if isinstance(items, list) and items and items[0].get("error"):
+ err = f"name failed: {items[0]['error']}"
+ self.app.call_from_thread(self._status, err)
+ return
+ # The label shows in the listing's head rows -> invalidate + reopen.
+ self.program.bump_items()
+ lm = self.program.listing(addr)
+ label = self.program.region_label(addr)
+ idx = max(lm.ensure_ea(addr), 0) if lm is not None else 0
+ self.app.call_from_thread(self._open_at_named, label, addr, idx, name)
+
+ def _open_at_named(self, label: str, addr: int, idx: int, name: str) -> None:
+ self._open_at(addr, label, idx, False, -1, 0, True)
+ self._dirty = True
+ self._status(f"named {addr:#x} → {name} (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()
@@ -3242,7 +3297,12 @@ class IdaTui(App):
return
if inp.id == "rename":
view, old = self._rename_ctx
+ addr = self._rename_addr # capture before _end_rename clears it
self._end_rename()
+ if addr is not None: # listing: name this address (create a label)
+ if value and value != old:
+ self._do_name_addr(addr, value)
+ return
if view is not None and value and value != old:
self._do_rename(view, old, value)
return
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 3d23ef0..26e916f 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -1444,6 +1444,69 @@ async def s_listing_view(c: Ctx):
c.prog.bump_items()
+@scenario("listing_name_addr")
+async def s_listing_name_addr(c: Ctx):
+ """In the listing, 'n' names the ADDRESS under the cursor — so you can name a
+ bare/undefined byte (e.g. the free byte at addr+1 after shrinking a u16 to a
+ u8), which the symbol-by-name rename path can't do."""
+ app = c.app
+ # pick a data segment and a >=2-byte data head to shrink
+ data_ea = None
+ for start, end, name in c.prog.sections():
+ if start >= end:
+ continue
+ lm = c.prog.listing(start)
+ if lm is None:
+ continue
+ lm.ensure(60)
+ if any(h.kind == "data" and (h.size or 0) >= 2 for h in lm.window(0, 60)):
+ data_ea = start
+ break
+ if data_ea is None:
+ c.check("found a data segment with a >=2-byte item", False)
+ return
+ dh = next(h for h in c.prog.listing(data_ea).window(0, 60)
+ if h.kind == "data" and (h.size or 0) >= 2)
+ A = dh.ea
+ newname = f"after_{os.getpid()}"
+ try:
+ # shrink the >=2-byte item to a single byte -> A+1 becomes undefined
+ c.prog.make_data(A, "unsigned __int8")
+ c.prog.bump_items()
+ await c.goto_ui(hex(A + 1))
+ await c.wait(lambda: app._active == "listing" and app._cur is not None
+ and app._cur.ea == A + 1, 25)
+ head = c.lst.cur_head()
+ c.check("cursor lands on the now-undefined byte at addr+1",
+ head is not None and head.ea == A + 1 and head.kind == "unknown",
+ f"head={head}")
+ # 'n' opens the address-name prompt (even though there's no symbol)
+ c.lst.focus()
+ await c.press("n")
+ await c.pause(0.1)
+ ri = app.query_one("#rename", Input)
+ c.check("'n' opens the name prompt on an unnamed byte",
+ ri.display, f"display={ri.display}")
+ ri.value = newname
+ await c.press("enter")
+ await c.wait(lambda: app._active == "listing"
+ and c.lst.model.index_of_ea(A + 1) >= 0
+ and c.lst.model.get(c.lst.model.index_of_ea(A + 1)) is not None
+ and c.lst.model.get(c.lst.model.index_of_ea(A + 1)).name == newname, 25)
+ hi = c.lst.model.index_of_ea(A + 1)
+ c.check("naming a bare byte at addr+1 sticks",
+ hi >= 0 and c.lst.model.get(hi).name == newname,
+ f"name={c.lst.model.get(hi).name if hi>=0 else None}")
+ finally:
+ # revert: drop the label and restore raw bytes at A
+ try:
+ c.prog.client.call("rename", batch={"data": {"addr": hex(A + 1), "new": ""}})
+ except Exception: # noqa: BLE001
+ pass
+ c.prog.undefine(A, size=8)
+ c.prog.bump_items()
+
+
# --------------------------------------------------------------------------- #
# Runner
# --------------------------------------------------------------------------- #