aboutsummaryrefslogtreecommitdiffstats
path: root/idatui/domain.py
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 /idatui/domain.py
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.
Diffstat (limited to 'idatui/domain.py')
-rw-r--r--idatui/domain.py56
1 files changed, 56 insertions, 0 deletions
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")