aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_scenarios.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_scenarios.py')
-rw-r--r--tests/test_scenarios.py222
1 files changed, 219 insertions, 3 deletions
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 091ddc8..9d77680 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -20,12 +20,14 @@ import asyncio
import fnmatch
import os
import re
+import shutil
import sys
+import tempfile
import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from idatui.app import ( # noqa: E402
- ConfirmScreen, DecompView, DisasmView, FunctionsPanel, HexView, IdaTui,
+ ConfirmScreen, DecompView, FunctionsPanel, HexView, IdaTui,
HelpScreen, ListingView, QuitScreen, StringsPalette, StructEditor,
SymbolPalette, XrefsScreen, _str_display, _word_occurrences,
)
@@ -309,6 +311,15 @@ async def s_palette(c: Ctx):
c.check("palette matches a fuzzy subsequence",
any(n == "error" for _, _, n in pal._results),
f"results={[n for _, _, n in pal._results[:4]]}")
+ # Every other query here is lowercase, which is how a case bug hid for so
+ # long: the name was lowered but the query wasn't, so ONE capital matched
+ # nothing. Invisible on lowercase C symbols, fatal on a library that
+ # capitalises (PEM_read_bio found 0 of 10093 functions in libcrypto).
+ pinp.value = "MAIN"
+ await c.wait(lambda: any(n == "main" for _, _, n in pal._results), 10)
+ c.check("palette matching is case-insensitive in BOTH directions",
+ any(n == "main" for _, _, n in pal._results),
+ f"results={[n for _, _, n in pal._results[:4]]}")
pinp.value = "main"
await c.wait(lambda: pal._results and pal._results[0][2] == "main", 10)
want = pal._results[0][1]
@@ -318,6 +329,21 @@ async def s_palette(c: Ctx):
c.check("selecting a palette entry opens that function",
bool(app._cur) and app._cur.ea == want,
f"cur={app._cur.ea if app._cur else None}")
+ # Re-open the function we are ALREADY standing on. That used to append an
+ # identical nav entry, and the extra Esc it bought popped the stack without
+ # changing anything on screen — a dead keypress, which is precisely what
+ # "back is broken" feels like from the keyboard.
+ depth = len(app._nav)
+ await c.press("ctrl+n")
+ await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10)
+ pal2 = app.screen
+ pal2.query_one(Input).value = "main"
+ await c.wait(lambda: pal2._results and pal2._results[0][2] == "main", 10)
+ await c.press("enter")
+ await c.wait(lambda: not isinstance(app.screen, SymbolPalette), 10)
+ await c.pause(0.4)
+ c.check("re-opening the current function doesn't stack a duplicate",
+ len(app._nav) == depth, f"nav {depth} -> {len(app._nav)}")
await c.press("ctrl+n")
await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10)
await c.press("escape")
@@ -325,6 +351,130 @@ async def s_palette(c: Ctx):
c.check("Esc closes the palette", not isinstance(app.screen, SymbolPalette))
+@scenario("load_options")
+async def s_load_options(c: Ctx):
+ """The dialog must never appear for a file IDA can load itself — the whole
+ suite runs on an ELF, so a false positive here would block every run."""
+ from idatui.app import LoadOptionsScreen
+ app = c.app
+ c.check("no load dialog for a recognised binary",
+ not isinstance(app.screen, LoadOptionsScreen),
+ f"screen={type(app.screen).__name__}")
+ c.check("and the app agrees it shouldn't ask",
+ not app._should_ask_load_options())
+ # The dialog itself, driven directly: it has to come back with switches the
+ # worker can use, and -b has to be paragraphs.
+ from idatui.formats import load_args, needs_load_options, sniff
+ c.check("the running target sniffs as a real format",
+ sniff(app._open_path) is not None and not needs_load_options(app._open_path),
+ f"{sniff(app._open_path)}")
+ c.check("dialog output converts a base to paragraphs",
+ load_args("arm", 0x8000000) == "-parm -b800000")
+
+ # Tab is a PRIORITY app binding (disasm<->pseudocode), so it fired even with
+ # a modal up and nothing in a dialog could be tabbed to. That is why the load
+ # dialog's address field was unreachable — and it was broken in every other
+ # modal too.
+ from idatui.app import LoadOptionsScreen
+ app.push_screen(LoadOptionsScreen("/tmp/probe.bin", 1234))
+ await c.wait(lambda: isinstance(app.screen, LoadOptionsScreen), 10)
+ sc = app.screen
+ first = app.focused
+ await c.press("tab")
+ await c.pause(0.2)
+ c.check("Tab moves focus inside a modal instead of toggling the view",
+ app.focused is not first and isinstance(app.screen, LoadOptionsScreen),
+ f"focus={getattr(app.focused, 'id', None)}")
+ c.check("Tab in the load dialog lands on the address field",
+ getattr(app.focused, "id", None) == "load-base",
+ f"focus={getattr(app.focused, 'id', None)}")
+ await c.press("tab")
+ await c.pause(0.2)
+ c.check("Tab again returns to the processor filter",
+ getattr(app.focused, "id", None) == "pal-input",
+ f"focus={getattr(app.focused, 'id', None)}")
+ await c.press("escape")
+ await c.wait(lambda: not isinstance(app.screen, LoadOptionsScreen), 10)
+
+
+@scenario("asm_highlight")
+async def s_asm_highlight(c: Ctx):
+ """Assembly is highlighted from IDA's OWN token tags.
+
+ generate_disasm_line() emits \x01<tag>text\x02<tag>, and the tag says what
+ the text is — for every processor IDA supports. Nothing is lexed here, so
+ what this pins is that the tags survive the trip: parsed server-side, agreed
+ with the plain text, carried on the Head, and mapped to a style.
+ """
+ from idatui.app import _S_SPAN
+ app = c.app
+ await c.open_biggest("listing")
+ lst = c.lst
+ ok = await c.wait(lambda: lst.model is not None and lst.total > 20, 25)
+ if not ok:
+ c.check("a listing to highlight", False, f"total={lst.total}")
+ return
+ lst.model.ensure(400)
+ rows = [lst.model.get(i) for i in range(min(lst.model.loaded(), 400))]
+ rows = [h for h in rows if h is not None]
+ code = [h for h in rows if h.kind == "code"]
+ c.check("code rows carry IDA's token spans",
+ code and sum(1 for h in code if h.spans) > len(code) * 0.9,
+ f"{sum(1 for h in code if h.spans)}/{len(code)} have spans")
+
+ kinds = {k for h in rows for k, _ in (h.spans or ())}
+ # If a tag isn't mapped it renders as body text and nothing says why, so the
+ # ones that carry real meaning are worth asserting explicitly.
+ for want in ("insn", "reg", "punct"):
+ c.check(f"the palette sees {want} tokens", want in kinds, f"{sorted(kinds)}")
+ c.check("every span kind has a style",
+ all(k in _S_SPAN for k in kinds), f"unstyled: {sorted(kinds - set(_S_SPAN))}")
+
+ # Spans must describe the SAME text the row shows, or the row renders
+ # different characters than search/width calculations think it has.
+ bad = [h for h in rows if h.spans
+ and "".join(t for _k, t in h.spans) != h.text]
+ c.check("spans reconstruct the row text exactly", not bad,
+ f"{[(hex(h.ea), h.text) for h in bad[:2]]}")
+
+ # And the mnemonic must be the loudest thing on the line (the column you
+ # scan), not just any styled token.
+ mn = next((h for h in code if h.spans and h.spans[0][0] == "insn"), None)
+ c.check("the mnemonic is the first span",
+ mn is not None, f"{code[0].spans if code else None}")
+
+
+@scenario("status_names_the_file")
+async def s_status_names_the_file(c: Ctx):
+ """The status bar always says which file you're looking at.
+
+ Obvious once several panes and a project switcher exist: with two idatui
+ windows open, or after switching binaries, "0x2490" alone doesn't say what
+ it belongs to.
+ """
+ from textual.widgets import Static
+ app = c.app
+ await c.open_biggest("listing")
+ await c.pause(0.3)
+ name = os.path.basename(app._open_path)
+ status = str(app.query_one("#status", Static).render())
+ c.check("the status bar names the open file",
+ status.startswith(f"[{name}]"), f"{status[:60]!r} (want [{name}])")
+
+ # It must survive the messages that WRITE the status, not just the idle one.
+ c.lst.focus()
+ await c.press("down")
+ await c.pause(0.3)
+ status = str(app.query_one("#status", Static).render())
+ c.check("and keeps naming it as you move",
+ status.startswith(f"[{name}]"), status[:60])
+
+ # The function-count message used to include the module name itself, which
+ # would now read "[echo] echo — 128 functions".
+ c.check("without saying the name twice",
+ status.count(name) == 1, status[:70])
+
+
@scenario("command_palette")
async def s_command_palette(c: Ctx):
app = c.app
@@ -887,6 +1037,23 @@ async def s_view_toggle(c: Ctx):
styled = any(seg.style is not None and seg.style.color is not None
for strip in dec._strips[:min(dec.total, 200)] for seg in strip)
c.check("pseudocode is syntax-highlighted", styled)
+ # Cancelling a prompt must hand focus back to the pane you were READING.
+ # _code_view() used to choose on _pref, which was only ever "listing", so it
+ # focused the hidden listing and the pseudocode stopped answering the
+ # keyboard — arrows did nothing at all until you clicked.
+ dec.focus()
+ await c.pause(0.05)
+ line0 = dec.cursor
+ await c.press("g")
+ await c.wait(lambda: app.query_one("#goto", Input).display, 10)
+ await c.press("escape")
+ await c.wait(lambda: not app.query_one("#goto", Input).display, 10)
+ await c.press("down")
+ await c.press("down")
+ await c.pause(0.2)
+ c.check("cancelling goto leaves focus in the pseudocode (arrows still work)",
+ dec.cursor > line0,
+ f"cursor {line0} -> {dec.cursor} focus={type(app.focused).__name__}")
dec.focus() # Tab only toggles the view from a code pane; elsewhere it's
await c.pause(0.05) # focus-next, which would silently leave us in decomp
await c.press("tab")
@@ -1773,7 +1940,7 @@ async def s_region_define(c: Ctx):
c.check("listing spans the whole segment (more heads than one function)",
seg is not None and c.lst.total > 1, f"total={c.lst.total} seg={seg}")
- # 'p' on the entry head (re)creates the function and UPGRADES to DisasmView
+ # 'p' on the entry head (re)creates the function
c.lst.focus()
c.lst.cursor, c.lst.cursor_x = c.lst.model.index_of_ea(addr), 0
await c.pause(0.05)
@@ -1862,9 +2029,16 @@ async def s_listing_view(c: Ctx):
try:
c.prog.undefine(dea, size=4)
c.prog.bump_items()
- # reopen the listing so the model reflects the new undefined run
+ # Reopen the listing so the model reflects the new undefined run, and
+ # wait for the model to be REPLACED. Waiting on index_of_ea(dea) >= 0 is
+ # no test at all: dea is in the OLD model too (it's the head we just
+ # undefined), so the predicate passes instantly and we then assert
+ # against pre-edit rows. It only ever passed because the swap happened to
+ # win the race, and it started failing the moment page loads got bigger.
+ stale = c.lst.model
await c.goto_ui(hex(dea))
await c.wait(lambda: app._active == "listing" and c.lst.total > 0
+ and c.lst.model is not stale
and c.lst.model.index_of_ea(dea) >= 0, 25)
ui = c.lst.model.index_of_ea(dea)
c.check("undefining a data head yields an unknown run in the listing",
@@ -2129,7 +2303,49 @@ async def s_func_banners(c: Ctx):
# --------------------------------------------------------------------------- #
# Runner
# --------------------------------------------------------------------------- #
+async def _build_pristine(binary, cache):
+ """Analyse ``binary`` once and keep the resulting database as a golden copy.
+
+ Costs one full analysis, then every later run starts from it instead of
+ re-analysing.
+ """
+ print(f" (building pristine database for {os.path.basename(binary)}\u2026)")
+ app = IdaTui(open_path=binary, keepalive=False)
+ async with app.run_test(size=(140, 44)) as pilot:
+ for _ in range(6000):
+ await pilot.pause(0.05)
+ if app._func_index is not None and app._func_index.complete:
+ break
+ app.program.client.call("idb_save", timeout=600.0)
+ db = binary + ".i64"
+ if os.path.exists(db):
+ shutil.copy2(db, cache)
+
+
async def run(binary, only=None):
+ # The suite EDITS the database — it defines code, undefines items, renames
+ # and comments — and IDA saves those edits. Run that against the tracked
+ # target and every run inherits the last one's damage: decomp_follow_self
+ # started failing with no code change because an earlier scenario had
+ # undefined an instruction, and a position check looked flaky one run in
+ # three for the same reason. A suite whose result depends on its own history
+ # can't be trusted to accuse the code.
+ #
+ # So: work on a scratch copy, seeded from a golden database that nothing
+ # ever writes back to.
+ with tempfile.TemporaryDirectory(prefix="idatui-pilot-") as scratch:
+ target = os.path.join(scratch, os.path.basename(binary))
+ shutil.copy2(binary, target)
+ cache = binary + ".pristine.i64"
+ if not (os.path.exists(cache)
+ and os.path.getmtime(cache) >= os.path.getmtime(binary)):
+ await _build_pristine(target, cache)
+ if os.path.exists(cache):
+ shutil.copy2(cache, target + ".i64")
+ await _run_on(target, only)
+
+
+async def _run_on(binary, only=None):
# Own idalib worker: opens the binary in-process over a unix socket.
app = IdaTui(open_path=binary, keepalive=False)
async with app.run_test(size=(140, 44)) as pilot: