diff options
| author | blasty <blasty@local> | 2026-08-07 23:16:30 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-08-07 23:16:30 +0200 |
| commit | 41b3710d5b6f9be24430fd55c46c83f9ae3b8d83 (patch) | |
| tree | ed3517997a3248310c904c3a57ea053257162ac2 /tests | |
| parent | Export findings as markdown (Ctrl+E), and the journal that makes it true (diff) | |
| download | ida-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 'tests')
| -rw-r--r-- | tests/test_scenarios.py | 88 | ||||
| -rw-r--r-- | tests/test_search.py | 101 |
2 files changed, 187 insertions, 2 deletions
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()) |
