aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-08-07 23:16:30 +0200
committerblasty <blasty@local>2026-08-07 23:16:30 +0200
commit41b3710d5b6f9be24430fd55c46c83f9ae3b8d83 (patch)
treeed3517997a3248310c904c3a57ea053257162ac2
parentExport findings as markdown (Ctrl+E), and the journal that makes it true (diff)
downloadida-tui-41b3710d5b6f9be24430fd55c46c83f9ae3b8d83.tar.gz
ida-tui-41b3710d5b6f9be24430fd55c46c83f9ae3b8d83.tar.xz
ida-tui-41b3710d5b6f9be24430fd55c46c83f9ae3b8d83.zip
Ctrl+F: search the whole database, by text or by bytes
`/` only ever searched the lines of the view you were in. This adds the search you actually need on a binary: over the entire database, either through the rendered disassembly or through the image. * **text** matches the line as displayed, whitespace-normalised, so `call cs:` finds `call cs:getenv_ptr` (IDA's column padding is not something anyone types). Smartcase; `regex` available over RPC. * **bytes** is IDA's own `find_bytes`, so the pattern language people already know works unchanged: hex pairs, `?` wildcards for a whole byte or one nibble (`48 8? ?? 24`), quoted literals (`"Hello", 0`). Commas, no separators (`488B05C3`) and ragged spacing all normalise. **Which mode you meant is guessed, and the guess is biased on purpose.** `dead`, `add`, `cafe` and `ff` are valid hex AND ordinary things to search for, so a bare hex-looking word stays TEXT; nobody types `48 8b ?? c3` meaning prose. `hex:`/`text:` prefixes and F2 override it. The subtle case is a *typo* in a byte pattern. `48 zz c3` first fell through to a text search and reported "no match" — indistinguishable from "those bytes are not in this binary", which is the most misleading answer a search can give. Now any query whose tokens are all byte-sized is treated as bytes, and a bad token is refused BY NAME. IDA does the same thing quietly (find_bytes answers a malformed pattern with zero hits and no error), so the validation lives in Program.search, not just in the UI. Enter searches, then Enter opens the highlighted hit; the title says which it will do, because a database-wide scan is far too slow to run on every keystroke like the other palettes. Navigation goes to the item head — a byte match can start mid-instruction — and the status names the exact address. Also: the `find` RPC verb and `drive find`, which is the one an agent wants (`drive find '48 8b ?? c3'`). idatui/search.py holds the classification and is pure, so the whole question of "what did they mean" is tested offline: tests/test_search.py, 35 checks, 0.1s. Pilot scenario db_search covers the UI end to end. Full suite: 890 passed, 0 failed, 51.3s.
-rw-r--r--README.md7
-rw-r--r--docs/RPC.md28
-rw-r--r--idatui/app.py211
-rw-r--r--idatui/codemode_client.py87
-rw-r--r--idatui/domain.py56
-rw-r--r--idatui/drive.py16
-rw-r--r--idatui/rpc.py26
-rw-r--r--idatui/search.py112
-rw-r--r--tests/test_scenarios.py88
-rw-r--r--tests/test_search.py101
10 files changed, 728 insertions, 4 deletions
diff --git a/README.md b/README.md
index 1092219..bee852d 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,13 @@ a 400 MB binary scrolls like a text file.
**Decompiler** — Hex-Rays pseudocode with syntax highlighting, per-line address
anchors, and rename/retype/comment that write back.
+**Search** (`ctrl+f`) — the whole database, two ways: **text** through the
+rendered disassembly (`call cs:`, `xor eax, eax`) and **bytes** with IDA's
+pattern language, wildcards included (`48 8b ?? c3`, nibbles like `8?`, quoted
+literals). Which one you meant is guessed from the query — a hex-looking *word*
+like `dead` stays a text search — and `hex:`/`text:` or F2 override the guess.
+Enter searches, then Enter opens the hit.
+
**Findings export** (`ctrl+e`) — the session as a markdown writeup: your
comments grouped by function, the names and prototypes you set, the types you
declared. A `.i64` does not record *who* wrote a comment — IDA's own analyzer
diff --git a/docs/RPC.md b/docs/RPC.md
index 4e736f6..e8dc76b 100644
--- a/docs/RPC.md
+++ b/docs/RPC.md
@@ -102,6 +102,7 @@ predicate so the returned state is final.
| `xrefs` | — | `x`: open the xref picker. |
| `symbols` | `query?` | Ctrl+N palette, optionally pre-typed. |
| `structs` | — | Ctrl+T struct editor. |
+| `find` | `query`, `mode?=auto\|text\|bytes`, `limit?=500`, `regex?`, `case?` | search the **whole database** and return `{mode, query, truncated, hits:[{addr, head, line, func, seg}]}`. `mode=auto` (the default) guesses from the query. A byte pattern that does not parse is an error naming the bad token, never an empty result. |
| `export` | `path?`, `types?=true` | write the session's findings as **markdown** and return `{path, comments, names, types, functions, bytes}`. Not typed through a prompt: the point of this verb is the file it leaves behind, so a driver gets the path back rather than a screenshot. Defaults to `<binary>.findings.md`. See *Findings export* below. |
| `search` | `term`, `direction?=1` | `/` (or `?`) incremental search in the active code view. |
| `select` | `index?` | in an open modal list (xrefs/symbols) choose the highlighted (or nth) item and activate it. |
@@ -132,6 +133,33 @@ symbol file: each one costs a navigation (listing page + decompile) plus two
prompt round-trips, i.e. tens of minutes for a few hundred symbols, where
`rename_many` is one call and a few seconds.
+### Database-wide search
+
+`find` is Ctrl+F: two searches over the whole binary, not the current view.
+
+* **text** matches the rendered disassembly line, whitespace-normalised — so
+ `call cs:` matches `call cs:getenv_ptr`. `regex=true` switches to a Python
+ regex. Smartcase: an all-lowercase query is case-insensitive.
+* **bytes** is IDA's own pattern language via `find_bytes`: hex pairs, `?`
+ wildcards (whole byte *or* one nibble, `48 8? ?? 24`), and quoted literals
+ (`"Hello", 0`). Commas, no separators at all (`488B05C3`) and mixed spacing
+ all normalise to the same pattern.
+
+Mode is guessed unless you say otherwise, and the guess is deliberately biased:
+a hex-looking word (`dead`, `add`, `cafe`) is a *text* search, because those are
+words. A query whose tokens are all byte-sized but one is malformed (`48 zz c3`)
+is treated as bytes and **refused by name** — answering "no match" there would
+be indistinguishable from "not present".
+
+`head` is the item to navigate to (a byte match can land mid-instruction);
+`addr` is the exact match.
+
+```sh
+python -m idatui.drive find 'call cs:'
+python -m idatui.drive find '48 8b ?? c3'
+python -m idatui.drive raw find query='mov e?x' regex=true limit=20
+```
+
### Findings export
`export` writes what the session **worked out** -- comments, names, prototypes
diff --git a/idatui/app.py b/idatui/app.py
index 4a603f3..9082d99 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -50,7 +50,7 @@ from . import kittygfx
from .edit_ctl import EditController
from .prompt import PromptBar
from .trace_ctl import TraceController
-from . import findings
+from . import findings, search
from .highlight import CTextArea, highlight_c
from .journal import Journal
@@ -3293,6 +3293,182 @@ def _str_display(text: str, limit: int = 200) -> str:
return out[:limit] + ("\u2026" if len(out) > limit else "")
+class SearchPalette(ModalScreen):
+ """Ctrl+F: search the whole database, by text or by bytes.
+
+ Unlike every other palette here this does NOT filter as you type: each
+ search walks the image or the listing in the database process, so it runs
+ on Enter. That gives Enter two jobs, which is fine as long as it is never
+ ambiguous: while the query differs from what was last searched, Enter
+ searches; once the results on screen belong to the query in the box, Enter
+ opens the highlighted one. The title says which it will do.
+
+ Mode is guessed from the query (idatui/search.py) because asking first is a
+ tax on every search; F2 overrides the guess and a `hex:`/`text:` prefix
+ settles it outright.
+ """
+
+ BINDINGS = [
+ Binding("escape", "close", "Close"),
+ Binding("down,ctrl+n", "cursor_down", show=False),
+ Binding("up,ctrl+p", "cursor_up", show=False),
+ Binding("f2", "mode", "Text / bytes", show=False),
+ ]
+ LIMIT = 500
+
+ def __init__(self, program, initial: str = "") -> None:
+ super().__init__()
+ self._program = program
+ self._initial = initial
+ self._forced: str | None = None # F2: pin the mode
+ self._hits: list = []
+ self._searched: tuple[str, str] | None = None # (mode, query) on screen
+ self._busy = False
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="pal-box") as box:
+ box.border_title = Text("search")
+ yield Input(placeholder="text, or bytes like 48 8b ?? c3 \u00b7 "
+ "Enter search \u00b7 F2 mode \u00b7 Esc close",
+ id="pal-input")
+ yield OptionList(id="pal-list")
+
+ def on_mount(self) -> None:
+ inp = self.query_one("#pal-input", Input)
+ inp.value = self._initial
+ inp.focus()
+ self._retitle()
+ if self._initial:
+ self._run()
+
+ # -- mode / title ------------------------------------------------------- #
+ def _query(self) -> str:
+ return self.query_one("#pal-input", Input).value.strip()
+
+ def _mode_query(self) -> tuple[str, str]:
+ return search.classify(self._query(), self._forced)
+
+ def _retitle(self, note: str = "") -> None:
+ mode, q = self._mode_query()
+ pinned = "" if self._forced is None else "*"
+ state = note
+ if not state:
+ if self._busy:
+ state = "searching\u2026"
+ elif self._searched == (mode, q) and q:
+ n = len(self._hits)
+ state = f"{n} hit{'' if n == 1 else 's'} \u2014 Enter opens"
+ elif q:
+ state = "Enter searches"
+ self.query_one("#pal-box").border_title = Text(
+ f"search [{mode}{pinned}]" + (f": {state}" if state else ""))
+
+ def action_mode(self) -> None:
+ mode, _ = self._mode_query()
+ self._forced = search.TEXT if mode == search.BYTES else search.BYTES
+ self._searched = None # the results on screen are for the old mode
+ self._retitle()
+
+ def on_input_changed(self, event: Input.Changed) -> None:
+ event.stop() # modal inputs bubble to the app's own #search handler
+ self._retitle()
+
+ def on_input_submitted(self, event: Input.Submitted) -> None:
+ event.stop()
+ mode, q = self._mode_query()
+ if self._searched == (mode, q) and self._hits:
+ self.action_choose()
+ else:
+ self._run()
+
+ # -- searching ---------------------------------------------------------- #
+ def _run(self) -> None:
+ mode, q = self._mode_query()
+ if not q:
+ return
+ if mode == search.BYTES:
+ problem = search.pattern_problem(q)
+ if problem:
+ # Refuse here rather than round-tripping: IDA's own message for
+ # a bad pattern is empty about half the time.
+ self._hits, self._searched = [], None
+ self.query_one(OptionList).clear_options()
+ self._retitle(problem)
+ return
+ q = search.normalise_pattern(q)
+ self._busy = True
+ self._retitle()
+ self._search(mode, q)
+
+ @work(thread=True, exclusive=True, group="dbsearch")
+ def _search(self, mode: str, query: str) -> None:
+ try:
+ hits, err, truncated = self._program.search(query, mode,
+ limit=self.LIMIT)
+ except Exception as e: # noqa: BLE001 -- a search must not kill the app
+ hits, err, truncated = [], str(e), False
+ self.app.call_from_thread(self._present, mode, query, hits, err, truncated)
+
+ def _present(self, mode: str, query: str, hits: list, err: str | None,
+ truncated: bool) -> None:
+ self._busy = False
+ self._hits = hits
+ # Remember what these results ARE, not what the box says now: the user
+ # may have typed on while the search ran, and then Enter must search
+ # again rather than open a hit from the previous query.
+ self._searched = (mode, query) if err is None else None
+ ol = self.query_one(OptionList)
+ ol.clear_options()
+ opts = []
+ for h in hits:
+ label = Text()
+ label.append(f"{h.addr:08X} ", _S_ADDR)
+ label.append(f"{(h.func or h.seg or ''):<22.22} ", _S_DIM)
+ body = Text(h.line or "")
+ if mode == search.TEXT and query:
+ low, ql = (h.line or "").lower(), query.lower()
+ at = low.find(ql)
+ if at >= 0:
+ body.stylize(_S_NAME_MATCH, at, at + len(query))
+ label.append_text(body)
+ opts.append(Option(label))
+ ol.add_options(opts)
+ if hits:
+ ol.highlighted = 0
+ if err:
+ self._retitle(err)
+ elif not hits:
+ self._retitle("no match")
+ else:
+ n = len(hits)
+ self._retitle(f"{n}{'+' if truncated else ''} "
+ f"hit{'' if n == 1 else 's'} \u2014 Enter opens")
+
+ # -- moving / choosing --------------------------------------------------- #
+ def action_cursor_down(self) -> None:
+ ol = self.query_one(OptionList)
+ if ol.option_count:
+ ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
+
+ def action_cursor_up(self) -> None:
+ ol = self.query_one(OptionList)
+ if ol.option_count:
+ ol.highlighted = max((ol.highlighted or 0) - 1, 0)
+
+ def action_choose(self) -> None:
+ ol = self.query_one(OptionList)
+ i = ol.highlighted
+ if i is not None and 0 <= i < len(self._hits):
+ self.dismiss(self._hits[i])
+
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+ if 0 <= event.option_index < len(self._hits):
+ self.dismiss(self._hits[event.option_index])
+
+ def action_close(self) -> None:
+ self.dismiss(None)
+
+
class StringsPalette(ModalScreen):
"""Every string in the binary (IDA's Shift+F12), filterable; Enter jumps to
it in the unified listing."""
@@ -3446,6 +3622,7 @@ _HELP = (
("B", "cycle the opcode-bytes column"),
("Ctrl+B", "show/hide the names pane"),
("Ctrl+T", "structs / types editor"),
+ ("Ctrl+F", "search the database: text or bytes"),
("Ctrl+E", "export findings as markdown"),
("Ctrl+P", "command palette"),
)),
@@ -4670,6 +4847,8 @@ class IdaCommands(Provider):
app.action_strings),
("Switch binary…", "another binary in the project (Ctrl+O)",
app.action_switch_binary),
+ ("Search database…", "disassembly text or a byte pattern with "
+ "wildcards (Ctrl+F)", app.action_find),
("Export findings…", "your comments, names and types as markdown "
"(Ctrl+E)", app.action_export),
("Keyboard shortcuts", "the key cheatsheet (F1 or H)",
@@ -4894,6 +5073,7 @@ class IdaTui(App):
Binding("q", "quit", "Quit"),
Binding("ctrl+n", "symbols", "Symbols"),
Binding("ctrl+t", "structs", "Structs"),
+ Binding("ctrl+f", "find", "Find"),
Binding("ctrl+e", "export", "Export", show=False),
Binding("backslash", "hex", "Hex"),
Binding("s", "toggle_split", "Split", show=False),
@@ -6009,6 +6189,35 @@ class IdaTui(App):
binary=self._binary),
self._on_string_chosen)
+ def action_find(self) -> None:
+ """Ctrl+F: search the whole database — disassembly text, or bytes."""
+ if self.program is None:
+ self._status("not connected yet")
+ return
+ if self._prompt_active():
+ return
+ # Seed it with the word under the cursor: the search you want is
+ # usually about the thing you are looking at.
+ seed = ""
+ view = self._active_code_view()
+ if view is not None:
+ try:
+ seed = view.word_under_cursor() or ""
+ except Exception: # noqa: BLE001 -- a seed is a nicety, never a
+ seed = "" # reason not to open the search
+ self.push_screen(SearchPalette(self.program, seed), self._on_hit_chosen)
+
+ def _on_hit_chosen(self, hit) -> None: # type: ignore[no-untyped-def]
+ if hit is None:
+ return
+ # Navigate to the ITEM, not the matched byte: a pattern can start in
+ # the middle of an instruction, and there is nothing to put a cursor on
+ # there. The status names the exact address so it isn't lost.
+ self._goto_ea(hit.head, push=True)
+ if hit.addr != hit.head:
+ self._status(f"match at {hit.addr:#x} (inside {hit.head:#x})",
+ priority=True)
+
def _on_string_chosen(self, choice) -> None: # type: ignore[no-untyped-def]
if choice is None:
return
diff --git a/idatui/codemode_client.py b/idatui/codemode_client.py
index 4c3ec99..1ac995e 100644
--- a/idatui/codemode_client.py
+++ b/idatui/codemode_client.py
@@ -602,6 +602,93 @@ n.setblob(payload, 0, "I")
result = {"ok": True, "bytes": len(payload)}
result
''',
+ # Database-wide search (Ctrl+F), two kinds.
+ #
+ # BYTES uses IDA's own `find_bytes`, which already understands the pattern
+ # language people expect -- "B8 ? ? ? ? 90", nibble wildcards ("48 8? ??")
+ # and quoted literals -- so we neither parse nor match anything ourselves.
+ # Iterating is match+1, per its documented contract.
+ "search_bytes": r'''
+import ida_bytes, ida_funcs, ida_idaapi, ida_lines, ida_segment
+pat = str(a.get("pattern", "")).strip()
+limit = max(1, int(a.get("limit", 500)))
+lo = int(a.get("start", 0))
+hi = int(a.get("end", 0)) or ida_idaapi.BADADDR
+flags = ida_bytes.BIN_SEARCH_FORWARD | ida_bytes.BIN_SEARCH_NOSHOW
+if a.get("case"):
+ flags |= ida_bytes.BIN_SEARCH_CASE
+rows, err, ea = [], None, lo
+while len(rows) < limit:
+ try:
+ hit = ida_bytes.find_bytes(pat, range_start=ea, range_end=hi, flags=flags)
+ except Exception as exc:
+ err = str(exc) or exc.__class__.__name__
+ break
+ if hit is None or hit == ida_idaapi.BADADDR:
+ break
+ head = ida_bytes.get_item_head(hit)
+ fn = ida_funcs.get_func(hit)
+ seg = ida_segment.getseg(hit)
+ try:
+ line = ida_lines.generate_disasm_line(head, ida_lines.GENDSM_REMOVE_TAGS) or ""
+ except Exception:
+ line = ""
+ rows.append({"addr": hex(int(hit)), "head": hex(int(head)),
+ "line": " ".join(line.split()),
+ "func": (ida_funcs.get_func_name(fn.start_ea) if fn else None),
+ "func_addr": (hex(int(fn.start_ea)) if fn else None),
+ "seg": (ida_segment.get_segm_name(seg) if seg else "")})
+ ea = int(hit) + 1
+result = {"hits": rows, "error": err, "truncated": len(rows) >= limit}
+result
+''',
+ # TEXT walks the listing the way a person reads it: every head's rendered
+ # disassembly line, which is why it finds "call cs:__isoc99_scanf" and
+ # "0deadbeefh" alike. Bounded by max_scan, so a 400MB image reports partial
+ # results instead of stalling.
+ "search_text": r'''
+import ida_lines, ida_funcs, ida_segment, idautils
+import re as _re
+q = str(a.get("query", ""))
+limit = max(1, int(a.get("limit", 500)))
+max_scan = max(1000, int(a.get("max_scan", 3000000)))
+ci = (not a.get("case")) and q.islower() # smartcase, like the in-view search
+rx, err = None, None
+if a.get("regex"):
+ try:
+ rx = _re.compile(q, _re.I if ci else 0)
+ except Exception as exc:
+ err = "bad regex: " + str(exc)
+needle = q.lower() if ci else q
+rows, scanned = [], 0
+if err is None and q:
+ for i in range(ida_segment.get_segm_qty()):
+ seg = ida_segment.getnseg(i)
+ if seg is None or len(rows) >= limit or scanned >= max_scan:
+ continue
+ for ea in idautils.Heads(seg.start_ea, seg.end_ea):
+ scanned += 1
+ if len(rows) >= limit or scanned >= max_scan:
+ break
+ try:
+ line = ida_lines.generate_disasm_line(ea, ida_lines.GENDSM_REMOVE_TAGS) or ""
+ except Exception:
+ continue
+ # Match what the user SEES, not IDA's column padding: nobody types
+ # "call" + four spaces + "cs:getenv_ptr".
+ line = " ".join(line.split())
+ hay = line.lower() if ci else line
+ if (rx.search(line) if rx is not None else (needle in hay)):
+ fn = ida_funcs.get_func(ea)
+ rows.append({"addr": hex(int(ea)), "head": hex(int(ea)),
+ "line": line,
+ "func": (ida_funcs.get_func_name(fn.start_ea) if fn else None),
+ "func_addr": (hex(int(fn.start_ea)) if fn else None),
+ "seg": ida_segment.get_segm_name(seg)})
+result = {"hits": rows, "error": err, "scanned": scanned,
+ "truncated": len(rows) >= limit or scanned >= max_scan}
+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]
diff --git a/idatui/domain.py b/idatui/domain.py
index 15f4ef4..d65c473 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -252,6 +252,22 @@ def link_name(raw: str) -> str:
@dataclass(frozen=True)
+class SearchHit:
+ """One database-wide search result (Ctrl+F).
+
+ ``addr`` is where the match starts -- for a byte pattern that can be inside
+ an instruction, so ``head`` is the item to navigate to and ``line`` is what
+ that item renders as.
+ """
+ addr: int
+ head: int
+ line: str = ""
+ func: str | None = None
+ func_addr: int | None = None
+ seg: str = ""
+
+
+@dataclass(frozen=True)
class Comment:
"""One comment somebody wrote into the database.
@@ -1964,6 +1980,46 @@ class Program:
return (comments, names)
+ def search(self, query: str, mode: str = "text", *, limit: int = 500,
+ regex: bool = False, case: bool = False,
+ ) -> tuple[list["SearchHit"], str | None, bool]:
+ """Search the whole database. Returns ``(hits, error, truncated)``.
+
+ A failed search is DATA (a message to show), not an exception: a bad
+ regex or an unparsable byte pattern is something the user typed, and
+ the palette wants to say so without unwinding.
+ """
+ op = "search_bytes" if mode == "bytes" else "search_text"
+ args: dict = {"limit": int(limit), "case": bool(case)}
+ if mode == "bytes":
+ # Validate HERE, not just in the UI: IDA's find_bytes answers a
+ # malformed pattern with zero hits and no error, which reads as
+ # "not present" -- the most misleading answer a search can give.
+ from .search import normalise_pattern, pattern_problem
+ problem = pattern_problem(query)
+ if problem:
+ return ([], problem, False)
+ args["pattern"] = normalise_pattern(query)
+ else:
+ args["query"] = query
+ args["regex"] = bool(regex)
+ try:
+ payload = self.client.invoke(op, **args)
+ except IDAToolError as e:
+ return ([], str(e), False)
+ if not isinstance(payload, dict):
+ return ([], "the backend returned nothing searchable", False)
+ hits = [
+ SearchHit(addr=_as_int(r.get("addr", 0)),
+ head=_as_int(r.get("head", r.get("addr", 0))),
+ line=str(r.get("line", "") or ""),
+ func=(r.get("func") or None),
+ func_addr=(_as_int(r["func_addr"]) if r.get("func_addr")
+ else None),
+ seg=str(r.get("seg", "") or ""))
+ for r in payload.get("hits", []) if isinstance(r, dict)]
+ return (hits, payload.get("error") or None, bool(payload.get("truncated")))
+
def journal_get(self) -> str:
"""The findings journal blob stored in this database ('' if none)."""
payload = self.client.invoke("journal_get")
diff --git a/idatui/drive.py b/idatui/drive.py
index 6ba13c6..6e5c21a 100644
--- a/idatui/drive.py
+++ b/idatui/drive.py
@@ -296,6 +296,21 @@ def cmd_save(c, args):
return " saved"
+def cmd_find(c, args):
+ """find <query...> -- search the database; bytes if it looks like bytes."""
+ if not args:
+ raise SystemExit("usage: find <text | 48 8b ?? c3 | hex:...>")
+ r = c.call("find", query=" ".join(args))
+ hits = r.get("hits", [])
+ out = [f" [{r.get('mode')}] {len(hits)}{'+' if r.get('truncated') else ''} hits"]
+ for h in hits[:40]:
+ out.append(f" {h['addr']} {(h.get('func') or h.get('seg') or ''):<20.20} "
+ f"{h.get('line', '')}")
+ if len(hits) > 40:
+ out.append(f" … {len(hits) - 40} more")
+ return "\n".join(out)
+
+
def cmd_export(c, args):
"""export [path] -- write the session's findings as markdown."""
r = c.call("export", **({"path": args[0]} if args else {}))
@@ -324,6 +339,7 @@ COMMANDS = {
"rename": cmd_rename, "mv": cmd_mv, "note": cmd_note, "retype": cmd_retype,
"save": cmd_save, "screen": cmd_screen, "raw": cmd_raw, "define": cmd_define,
"syms": cmd_syms, "fmt": cmd_fmt, "export": cmd_export,
+ "find": cmd_find,
"binaries": cmd_binaries, "switch": cmd_switch,
}
diff --git a/idatui/rpc.py b/idatui/rpc.py
index 98ff90f..1262ebb 100644
--- a/idatui/rpc.py
+++ b/idatui/rpc.py
@@ -39,7 +39,7 @@ _PROGRAM_METHODS = {
"goto", "open", "rename", "comment", "retype", "follow", "xrefs", "symbols",
"structs", "search", "select", "save", "hex", "toggle_view",
"pseudocode", "disassembly", "xrefs_to", "xrefs_from", "resolve",
- "define", "rename_many", "opfmt", "graph", "export",
+ "define", "rename_many", "opfmt", "graph", "export", "find",
}
# Self-documenting method table (returned by the 'methods' verb).
@@ -77,6 +77,9 @@ METHODS = {
"structs": "open the struct editor",
"export": "{path?,types?=true} write the session's comments/names/types as "
"a markdown report -> {path,comments,names,types}",
+ "find": "{query,mode?=auto|text|bytes,limit?=500,regex?,case?} search the "
+ "WHOLE database: disassembly text, or a byte pattern with "
+ "wildcards (48 8b ?? c3) -> {mode,hits:[{addr,head,line,func}]}",
"search": "{term,direction?=1} incremental search in the code view",
"select": "{index?} choose the highlighted/nth item in the open modal",
"save": "persist the .i64 (Ctrl+S)",
@@ -1175,6 +1178,27 @@ class RpcServer:
return await self._press(
["ctrl+t"], lambda: type(app.screen).__name__ == "StructEditor",
timeout, "structs")
+ if method == "find":
+ from . import search as _search
+ q = str(params.get("query", ""))
+ forced = params.get("mode")
+ forced = None if forced in (None, "auto") else str(forced)
+ mode, cleaned = _search.classify(q, forced)
+ if mode == _search.BYTES:
+ problem = _search.pattern_problem(cleaned)
+ if problem:
+ raise ValueError(f"find: {problem}")
+ cleaned = _search.normalise_pattern(cleaned)
+ hits, err, truncated = await asyncio.to_thread(
+ app.program.search, cleaned, mode,
+ limit=int(params.get("limit", 500)),
+ regex=bool(params.get("regex")), case=bool(params.get("case")))
+ if err:
+ raise ValueError(f"find: {err}")
+ return {"mode": mode, "query": cleaned, "truncated": truncated,
+ "hits": [{"addr": hex(h.addr), "head": hex(h.head),
+ "line": h.line, "func": h.func,
+ "seg": h.seg} for h in hits]}
if method == "export":
# Deliberately NOT driven through the prompt: this is the one verb
# whose whole point is the file it leaves behind, and a driver needs
diff --git a/idatui/search.py b/idatui/search.py
new file mode 100644
index 0000000..feb7e3d
--- /dev/null
+++ b/idatui/search.py
@@ -0,0 +1,112 @@
+"""What did the user mean by that query? (Ctrl+F.)
+
+Database-wide search comes in two kinds -- **text** through the disassembly and
+**bytes** through the image -- and asking people to pick a mode before they type
+is a tax on every search. So the query decides, and the rule has to be
+conservative in one specific direction: a hex-looking word is often real text
+(``add``, ``dec``, ``dead``, ``beef``, ``cafe`` are all valid hex AND things you
+would search for), while nobody types ``48 8b ?? c3`` meaning prose.
+
+Hence: bytes only when the query is unambiguously a byte pattern -- several
+whitespace/comma separated tokens that are all hex pairs or wildcards, or any
+query containing a ``?``. Everything else is text, and the two explicit prefixes
+(``hex:`` / ``text:``) settle any argument, as does F2 in the palette.
+
+Pure: no IDA, no Textual, so ``tests/test_search.py`` runs it offline.
+"""
+
+from __future__ import annotations
+
+import re
+
+TEXT = "text"
+BYTES = "bytes"
+
+#: One token of a byte pattern: a hex pair, a wildcard nibble ("8?"), or a bare
+#: "?" standing for a whole byte. IDA's find_bytes accepts all three.
+_TOKEN = re.compile(r"^(?:[0-9A-Fa-f?]{2}|\?)$")
+
+#: A quoted literal inside a pattern ('"Hello", 0'), which IDA also accepts.
+_QUOTED = re.compile(r'"[^"]*"')
+
+
+def looks_like_bytes(query: str) -> bool:
+ """True when ``query`` can only sensibly be a byte pattern."""
+ q = (query or "").strip()
+ if not q:
+ return False
+ if _QUOTED.search(q):
+ return True
+ tokens = [t for t in re.split(r"[\s,]+", q) if t]
+ if not all(_TOKEN.match(t) for t in tokens):
+ return False
+ # A single token is ambiguous ("ff" is also a word); a wildcard never is.
+ return len(tokens) > 1 or "?" in q
+
+
+def probably_meant_bytes(query: str) -> bool:
+ """True for a query that is *shaped* like bytes but does not parse.
+
+ ``48 zz c3`` is a typo in a byte pattern, and treating it as a text search
+ answers "no match" -- the most misleading thing a search can say, because it
+ is indistinguishable from "those bytes are not in this binary". Every token
+ being byte-sized is the tell; ``add ff`` (a three-letter token) is not, and
+ stays text.
+ """
+ tokens = [t for t in re.split(r"[\s,]+", (query or "").strip()) if t]
+ if len(tokens) < 2 or any(len(t) > 2 for t in tokens):
+ return False
+ return any(_TOKEN.match(t) for t in tokens)
+
+
+def classify(query: str, forced: str | None = None) -> tuple[str, str]:
+ """Return ``(mode, cleaned_query)``.
+
+ An explicit ``hex:``/``bytes:``/``text:`` prefix wins, then ``forced`` (the
+ palette's F2), then the shape of the query.
+ """
+ q = (query or "").strip()
+ low = q.lower()
+ for prefix, mode in (("hex:", BYTES), ("bytes:", BYTES), ("text:", TEXT)):
+ if low.startswith(prefix):
+ return (mode, q[len(prefix):].strip())
+ if forced in (TEXT, BYTES):
+ return (forced, q)
+ if looks_like_bytes(q) or probably_meant_bytes(q):
+ return (BYTES, q)
+ return (TEXT, q)
+
+
+def normalise_pattern(pattern: str) -> str:
+ """Tidy a byte pattern for IDA: single spaces, commas as separators.
+
+ ``48 8B?? C3``, ``48,8b,??,c3`` and ``48 8b ?? c3`` are the same search;
+ people paste all three (the middle one out of a signature file).
+ """
+ q = (pattern or "").strip()
+ if _QUOTED.search(q):
+ return q # a quoted literal owns its own spacing
+ q = q.replace(",", " ")
+ # "488B??C3" -- a bare hex run with no separators at all.
+ if " " not in q and len(q) > 2 and len(q) % 2 == 0:
+ q = " ".join(q[i:i + 2] for i in range(0, len(q), 2))
+ return " ".join(q.split())
+
+
+def pattern_problem(pattern: str) -> str | None:
+ """A human explanation if this cannot be a byte pattern, else ``None``.
+
+ Checked before the round trip, because IDA's own message for a bad pattern
+ is empty about half the time.
+ """
+ q = normalise_pattern(pattern)
+ if not q:
+ return "type some bytes, e.g. 48 8b ?? c3"
+ if _QUOTED.search(q):
+ return None
+ tokens = [t for t in q.split() if t]
+ bad = [t for t in tokens if not _TOKEN.match(t)]
+ if bad:
+ return (f"{bad[0]!r} is not a byte: use hex pairs, ? wildcards "
+ 'or a "quoted string"')
+ return None
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 0b004b3..6d95564 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -34,8 +34,9 @@ from _fixtures import fast_keys, staged # noqa: E402
fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys
from idatui.app import ( # noqa: E402
ConfirmScreen, DecompView, FunctionsPanel, GraphView, HexView, IdaTui,
- HelpScreen, ListingView, QuitScreen, StringsPalette, StructEditor,
- SymbolPalette, XrefsScreen, _HELP, _str_display, _word_occurrences,
+ HelpScreen, ListingView, QuitScreen, SearchPalette, StringsPalette,
+ StructEditor, SymbolPalette, XrefsScreen, _HELP, _str_display,
+ _word_occurrences,
)
from idatui.errors import IDAToolError # noqa: E402
from textual.widgets import ( # noqa: E402
@@ -1093,6 +1094,89 @@ async def s_structs(c: Ctx):
c.check("Esc closes the struct editor", not isinstance(app.screen, StructEditor))
+@scenario("db_search")
+async def s_db_search(c: Ctx):
+ """Ctrl+F: search the whole database, by disassembly text or by bytes."""
+ app = c.app
+ await c.open("main", "listing")
+ await c.press("ctrl+f")
+ opened = await c.wait(lambda: isinstance(app.screen, SearchPalette), 10)
+ c.check("Ctrl+F opens the search palette", opened,
+ f"screen={type(app.screen).__name__}")
+ if not opened:
+ return
+ pal = app.screen
+ inp = pal.query_one("#pal-input", Input)
+
+ # -- text: a mnemonic every x86-64 function starts with ------------------ #
+ inp.value = "endbr64"
+ await c.press("enter")
+ await c.wait(lambda: bool(pal._hits), 30)
+ c.check("a text search finds instructions", len(pal._hits) > 1,
+ f"n={len(pal._hits)}")
+ c.check("and it was classified as text",
+ pal._searched and pal._searched[0] == "text", f"{pal._searched}")
+ c.check("hits carry the line they matched",
+ all("endbr64" in h.line for h in pal._hits[:5]),
+ [h.line for h in pal._hits[:3]])
+
+ # -- text with padding: match what is SEEN, not IDA's column spacing ----- #
+ inp.value = "call cs:"
+ await c.press("enter")
+ found = await c.wait(lambda: pal._searched == ("text", "call cs:"), 30)
+ c.check("a query spanning IDA's column padding still matches",
+ found and len(pal._hits) > 0, f"n={len(pal._hits)}")
+
+ # -- bytes: the same endbr64, as a pattern ------------------------------- #
+ inp.value = "f3 0f 1e fa"
+ await c.press("enter")
+ await c.wait(lambda: pal._searched and pal._searched[0] == "bytes", 30)
+ c.check("a hex query is classified as bytes",
+ pal._searched and pal._searched[0] == "bytes", f"{pal._searched}")
+ c.check("and finds the same instruction", len(pal._hits) > 1,
+ f"n={len(pal._hits)}")
+
+ # -- wildcards ----------------------------------------------------------- #
+ inp.value = "f3 0f ?? fa"
+ await c.press("enter")
+ await c.wait(lambda: pal._searched == ("bytes", "f3 0f ?? fa"), 30)
+ c.check("a wildcard byte matches", len(pal._hits) > 1, f"n={len(pal._hits)}")
+
+ # -- a bad pattern must SAY so, not answer "no matches" ------------------ #
+ inp.value = "48 zz c3"
+ await c.press("enter")
+ await c.pause(0.1)
+ title = str(app.screen.query_one("#pal-box").border_title)
+ c.check("a malformed byte pattern is refused with a reason",
+ "not a byte" in title, f"title={title!r}")
+
+ # -- F2 pins the mode against the guess ---------------------------------- #
+ inp.value = "dead"
+ await c.pause(0.05)
+ c.check("a hex-looking WORD still searches text",
+ pal._mode_query()[0] == "text", f"{pal._mode_query()}")
+ await c.press("f2")
+ c.check("F2 forces it to bytes", pal._mode_query()[0] == "bytes",
+ f"{pal._mode_query()}")
+
+ # -- Enter on a result navigates ----------------------------------------- #
+ inp.value = "endbr64"
+ await c.press("f2") # back to text
+ await c.press("enter")
+ await c.wait(lambda: bool(pal._hits) and pal._searched
+ and pal._searched[0] == "text", 30)
+ target = pal._hits[1] if len(pal._hits) > 1 else pal._hits[0]
+ pal.query_one(OptionList).highlighted = 1 if len(pal._hits) > 1 else 0
+ await c.press("enter")
+ closed = await c.wait(lambda: not isinstance(app.screen, SearchPalette), 10)
+ c.check("Enter on a hit closes the palette", closed,
+ f"screen={type(app.screen).__name__}")
+ landed = await c.wait(
+ lambda: app._cur is not None and c.lst._cursor_ea() == target.head, 30)
+ c.check("and lands the cursor on it", landed,
+ f"cursor={c.lst._cursor_ea()} want={target.head:#x}")
+
+
@scenario("export_findings")
async def s_export_findings(c: Ctx):
"""Ctrl+E writes a markdown report of what this session worked out.
diff --git a/tests/test_search.py b/tests/test_search.py
new file mode 100644
index 0000000..6fd1a25
--- /dev/null
+++ b/tests/test_search.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+"""Query classification for Ctrl+F: is that text, or is it bytes?
+
+The whole risk of a mode-guessing search is guessing wrong in the direction
+that loses work: deciding a word like `dead` or `add` is a byte pattern, and
+silently searching the image instead of the disassembly. These checks pin that
+asymmetry down. Pure -- no IDA, no worker.
+"""
+
+#: pure: stdlib only.
+#: Read by tests/run.py (--fast skips every NEEDS_IDA file).
+NEEDS_IDA = False
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from idatui.search import ( # noqa: E402
+ BYTES, TEXT, classify, looks_like_bytes, normalise_pattern,
+ pattern_problem, probably_meant_bytes,
+)
+
+PASS = FAIL = 0
+
+
+def check(name, cond, detail=""):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print(f" ok {name}")
+ else:
+ FAIL += 1
+ print(f" FAIL {name} {detail}")
+
+
+def main() -> int:
+ # -- the asymmetry: hex-looking WORDS must stay text --------------------- #
+ for word in ("add", "dead", "beef", "cafe", "ff", "0", "abcdef",
+ "decode", "face"):
+ check(f"{word!r} searches text, not bytes",
+ classify(word)[0] == TEXT, classify(word))
+
+ # -- unambiguous byte patterns ------------------------------------------ #
+ for pat in ("48 8b ?? c3", "B8 ? ? ? ? 90", "48,8b,05", "de ad be ef",
+ "48 8? ?? 24", "??"):
+ check(f"{pat!r} searches bytes", classify(pat)[0] == BYTES,
+ classify(pat))
+
+ check("a quoted literal is a byte pattern",
+ classify('"Hello", 0')[0] == BYTES)
+
+ # A TYPO in a byte pattern must stay a byte pattern, so it can be refused
+ # with a reason. Falling back to text answers "no match", which is
+ # indistinguishable from "those bytes are not in this binary".
+ check("a typo'd byte pattern is still a byte pattern",
+ classify("48 zz c3")[0] == BYTES, classify("48 zz c3"))
+ check("and it is refused by name",
+ "'zz'" in (pattern_problem("48 zz c3") or ""))
+ check("but a word among bytes is prose",
+ classify("add ff")[0] == TEXT and classify("mov rdi, rax")[0] == TEXT,
+ classify("add ff"))
+ check("prose stays text", classify("mov rdi, rax")[0] == TEXT)
+ check("a call target stays text", classify("call cs:__isoc99_scanf")[0] == TEXT)
+ check("an empty query is text (nothing to search yet)",
+ classify("")[0] == TEXT and not looks_like_bytes(""))
+
+ # -- explicit wins over any guess --------------------------------------- #
+ check("hex: forces bytes", classify("hex: dead") == (BYTES, "dead"))
+ check("bytes: forces bytes too", classify("bytes:dead") == (BYTES, "dead"))
+ check("text: forces text", classify("text: 48 8b c3") == (TEXT, "48 8b c3"))
+ check("F2's forced mode beats the shape",
+ classify("dead", forced=BYTES) == (BYTES, "dead")
+ and classify("48 8b c3", forced=TEXT) == (TEXT, "48 8b c3"))
+ check("a prefix beats even the forced mode",
+ classify("text:48 8b c3", forced=BYTES)[0] == TEXT)
+
+ # -- the shapes people paste -------------------------------------------- #
+ check("commas become spaces", normalise_pattern("48,8b,05") == "48 8b 05")
+ check("a run with no separators is split into bytes",
+ normalise_pattern("488B05C3") == "48 8B 05 C3")
+ check("whitespace is squeezed", normalise_pattern(" 48 8b\t05 ") == "48 8b 05")
+ check("a quoted literal keeps its own spacing",
+ normalise_pattern('"Hello, world", 0') == '"Hello, world", 0')
+
+ # -- refusing a bad pattern with a reason -------------------------------- #
+ check("an empty pattern says what to type",
+ "48 8b" in (pattern_problem("") or ""))
+ check("a non-hex token is named",
+ "'zz'" in (pattern_problem("48 zz c3") or ""), pattern_problem("48 zz c3"))
+ check("a good pattern has no complaint",
+ pattern_problem("48 8b ?? c3") is None
+ and pattern_problem('"Hi", 0') is None)
+ check("an odd-length run is refused rather than silently split",
+ pattern_problem("488B0") is not None, pattern_problem("488B0"))
+
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())