aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test_scenarios.py88
-rw-r--r--tests/test_search.py101
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())