diff options
Diffstat (limited to '')
| -rw-r--r-- | tests/_fixtures.py | 13 | ||||
| -rwxr-xr-x | tests/run.py | 97 | ||||
| -rw-r--r-- | tests/test_blob_ui.py | 284 | ||||
| -rw-r--r-- | tests/test_codemode_client.py | 162 | ||||
| -rw-r--r-- | tests/test_diag.py | 93 | ||||
| -rw-r--r-- | tests/test_findings.py | 219 | ||||
| -rw-r--r-- | tests/test_formats.py | 170 | ||||
| -rw-r--r-- | tests/test_graph.py | 269 | ||||
| -rw-r--r-- | tests/test_index.py | 283 | ||||
| -rw-r--r-- | tests/test_kittygfx.py | 258 | ||||
| -rw-r--r-- | tests/test_launch.py | 30 | ||||
| -rw-r--r-- | tests/test_nexus_client.py | 499 | ||||
| -rw-r--r-- | tests/test_pool.py | 183 | ||||
| -rw-r--r-- | tests/test_project.py | 231 | ||||
| -rw-r--r-- | tests/test_project_ui.py | 341 | ||||
| -rw-r--r-- | tests/test_rawimage_rpc.py | 196 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 3675 | ||||
| -rw-r--r-- | tests/test_search.py | 109 | ||||
| -rw-r--r-- | tests/test_thumb_ui.py | 218 | ||||
| -rw-r--r-- | tests/test_trace.py | 248 | ||||
| -rw-r--r-- | tests/test_trace_rpc.py | 259 | ||||
| -rw-r--r-- | tests/test_trace_ui.py | 358 | ||||
| -rw-r--r-- | tests/test_trace_vs_tenet.py | 64 |
23 files changed, 5954 insertions, 2305 deletions
diff --git a/tests/_fixtures.py b/tests/_fixtures.py index 16192fa..87f6184 100644 --- a/tests/_fixtures.py +++ b/tests/_fixtures.py @@ -20,6 +20,7 @@ writes back to -- turns that into a file copy. The cache is rebuilt whenever it is older than the binary, so editing a target doesn't silently test the previous one. `.pristine.i64` is gitignored. """ + from __future__ import annotations import asyncio @@ -64,10 +65,12 @@ def fast_keys() -> None: if not hasattr(textual.app, "wait_for_idle"): # pragma: no cover raise RuntimeError( "textual.app.wait_for_idle is gone -- tests/_fixtures.fast_keys " - "needs updating for this Textual version") + "needs updating for this Textual version" + ) - async def _yield_instead_of_sleeping(min_sleep: float = 0.0, - max_sleep: float = 1.0) -> None: + async def _yield_instead_of_sleeping( + min_sleep: float = 0.0, max_sleep: float = 1.0 + ) -> None: await asyncio.sleep(0) async def _press(self, *keys: str) -> None: @@ -110,7 +113,7 @@ def synthetic(name: str, build) -> str: path = os.path.join(SYNTHETIC_DIR, name) data = build() if not os.path.exists(path) or open(path, "rb").read() != data: - with open(path, "wb") as fh: # content changed -> cache is stale + with open(path, "wb") as fh: # content changed -> cache is stale fh.write(data) for stale in (cache_path(path), path + ".i64"): if os.path.exists(stale): @@ -133,7 +136,7 @@ async def build_pristine(binary: str, cache: str, app_factory) -> None: break app.program.client.save_database() # Textual's headless run_test context does not reliably emit App.Unmount on - # every platform/version; release the Code Mode lease explicitly. + # every platform/version; release the IDA Nexus lease explicitly. if app.program is not None: app.program.close() if app.client is not None: diff --git a/tests/run.py b/tests/run.py index b38a707..2fb093f 100755 --- a/tests/run.py +++ b/tests/run.py @@ -38,6 +38,7 @@ Usage:: Exit code is 0 only if every file selected ran and passed. """ + from __future__ import annotations import argparse @@ -51,8 +52,8 @@ import time ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) TESTS = os.path.join(ROOT, "tests") -#: The IDA-capable interpreter. The pilot tests need textual AND the Code Mode -#: library in one python; the database process is Code Mode's to place. +#: The IDA-capable interpreter. The pilot tests need textual AND the IDA Nexus +#: library in one python; the database process is IDA Nexus's to place. DEFAULT_PY = os.path.expanduser("~/ida-venv/bin/python") #: Both shapes the suites print: "N passed, M failed" and "N checks, M failed". @@ -80,14 +81,17 @@ def needs_ida(path: str) -> bool: if isinstance(target, ast.Name) and target.id == "NEEDS_IDA": value = ast.literal_eval(node.value) if not isinstance(value, bool): - raise Marker(f"{os.path.basename(path)}: " - f"NEEDS_IDA must be a bool, got {value!r}") + raise Marker( + f"{os.path.basename(path)}: " + f"NEEDS_IDA must be a bool, got {value!r}" + ) return value raise Marker( f"{os.path.basename(path)}: no NEEDS_IDA marker.\n" f" Add `NEEDS_IDA = True` (spawns a worker / drives the pilot) or\n" f" `NEEDS_IDA = False` (pure: stdlib, no IDA, runs anywhere) at module\n" - f" scope, so tests/run.py --fast knows whether it can run you.") + f" scope, so tests/run.py --fast knows whether it can run you." + ) def discover() -> list[tuple[str, bool]]: @@ -124,41 +128,70 @@ def tally(output: str) -> tuple[int, int] | None: def run_one(path: str, python: str, extra: list[str], echo: bool) -> dict: """Run one test file as a subprocess and summarise it.""" - name = os.path.basename(path)[len("test_"):-len(".py")] + name = os.path.basename(path)[len("test_") : -len(".py")] started = time.time() - proc = subprocess.run([python, path, *extra], cwd=ROOT, - capture_output=not echo, text=True) + proc = subprocess.run( + [python, path, *extra], cwd=ROOT, capture_output=not echo, text=True + ) took = time.time() - started out = "" if echo else (proc.stdout or "") + (proc.stderr or "") counts = tally(out) skipped = bool(_SKIP.search(out)) and (counts is None or counts == (0, 0)) return { - "name": name, "path": path, "code": proc.returncode, "took": took, + "name": name, + "path": path, + "code": proc.returncode, + "took": took, "passed": counts[0] if counts else 0, "failed": counts[1] if counts else 0, "counted": counts is not None, - "skipped": skipped, "output": out, + "skipped": skipped, + "output": out, } def main(argv: list[str]) -> int: ap = argparse.ArgumentParser( - prog="tests/run.py", description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - ap.add_argument("only", nargs="*", metavar="SUBSTR", - help="only run test files whose name contains one of these") - ap.add_argument("--fast", action="store_true", - help="skip every file that needs IDA (seconds, runs anywhere)") - ap.add_argument("--ida-only", action="store_true", - help="only the files that need IDA") - ap.add_argument("--list", action="store_true", - help="show what would run, and whether it needs IDA") - ap.add_argument("-x", "--exitfirst", action="store_true", - help="stop after the first failing file") - ap.add_argument("-v", "--verbose", action="store_true", - help="stream each suite's output instead of capturing it") - ap.add_argument("--python", default=os.environ.get("IDATUI_PYTHON", DEFAULT_PY), - help=f"interpreter for the IDA suites (default {DEFAULT_PY})") + prog="tests/run.py", + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + ap.add_argument( + "only", + nargs="*", + metavar="SUBSTR", + help="only run test files whose name contains one of these", + ) + ap.add_argument( + "--fast", + action="store_true", + help="skip every file that needs IDA (seconds, runs anywhere)", + ) + ap.add_argument( + "--ida-only", action="store_true", help="only the files that need IDA" + ) + ap.add_argument( + "--list", + action="store_true", + help="show what would run, and whether it needs IDA", + ) + ap.add_argument( + "-x", + "--exitfirst", + action="store_true", + help="stop after the first failing file", + ) + ap.add_argument( + "-v", + "--verbose", + action="store_true", + help="stream each suite's output instead of capturing it", + ) + ap.add_argument( + "--python", + default=os.environ.get("IDATUI_PYTHON", DEFAULT_PY), + help=f"interpreter for the IDA suites (default {DEFAULT_PY})", + ) args, extra = ap.parse_known_args(argv) try: @@ -190,10 +223,12 @@ def main(argv: list[str]) -> int: # an IDA file needs the interpreter that has textual + idapro. pure_py = sys.executable if any(ida for _, ida in selected) and not os.path.exists(args.python): - print(f"error: {args.python} not found — the IDA suites need an " - f"interpreter with textual + idapro.\n" - f" Pass --python, set $IDATUI_PYTHON, or use --fast.", - file=sys.stderr) + print( + f"error: {args.python} not found — the IDA suites need an " + f"interpreter with textual + idapro.\n" + f" Pass --python, set $IDATUI_PYTHON, or use --fast.", + file=sys.stderr, + ) return 2 results = [] @@ -226,7 +261,7 @@ def main(argv: list[str]) -> int: elif r["code"] != 0 or r["failed"]: state = "\033[31mFAIL\033[0m" elif not r["counted"]: - state = "\033[33m ? \033[0m" # exit 0 but printed no tally + state = "\033[33m ? \033[0m" # exit 0 but printed no tally else: state = "\033[32m ok \033[0m" detail = f"{r['passed']:4d} passed" diff --git a/tests/test_blob_ui.py b/tests/test_blob_ui.py index d4740d0..7c3aee5 100644 --- a/tests/test_blob_ui.py +++ b/tests/test_blob_ui.py @@ -19,12 +19,12 @@ import tempfile sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _fixtures import fast_keys, staged, synthetic # noqa: E402 from textual.widgets import Input, Static # noqa: E402 from idatui.app import ConfirmScreen, IdaTui, ListingView # noqa: E402 -from _fixtures import fast_keys, staged, synthetic # noqa: E402 -fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys +fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys from idatui._sync import settle # noqa: E402 PASS = FAIL = 0 @@ -66,14 +66,19 @@ def _blob_bytes() -> bytes: functions" is asserted below. """ import random + data = bytearray(random.Random(0xB10BCAFE).randbytes(64 * 1024)) - # -parm puts IDA in AArch64 mode, so these are A64 encodings; the ARM32 - # spelling of a nop (0xE1A00000) is NOT decodable there and made this - # test fail for a reason that had nothing to do with what it checks. - for k, insn in enumerate((0xD503201F, # nop - 0xD503201F, # nop - 0xD65F03C0)): # ret <- the run must stop here - data[PLANTED + k * 4:PLANTED + k * 4 + 4] = insn.to_bytes(4, "little") + # -parm puts IDA in AArch64 mode, so these are A64 encodings; the ARM32 + # spelling of a nop (0xE1A00000) is NOT decodable there and made this + # test fail for a reason that had nothing to do with what it checks. + for k, insn in enumerate( + ( + 0xD503201F, # nop + 0xD503201F, # nop + 0xD65F03C0, + ) + ): # ret <- the run must stop here + data[PLANTED + k * 4 : PLANTED + k * 4 + 4] = insn.to_bytes(4, "little") return bytes(data) @@ -109,27 +114,45 @@ async def run() -> int: # happens AFTER a described blob turns out to contain nothing. app = _blob_app(blob) async with app.run_test(size=(140, 44)) as pilot: - ok = await wait(lambda: app._func_index is not None - and app._func_index.complete, pilot) + ok = await wait( + lambda: app._func_index is not None and app._func_index.complete, pilot + ) check("a random blob still finishes loading", ok) - check("and really has no functions", len(app._func_index) == 0, - f"n={len(app._func_index)}") + check( + "and really has no functions", + len(app._func_index) == 0, + f"n={len(app._func_index)}", + ) landed = await wait(lambda: app._cur is not None, pilot, 60) - check("it lands somewhere instead of leaving empty panes", landed, - f"cur={app._cur}") + check( + "it lands somewhere instead of leaving empty panes", + landed, + f"cur={app._cur}", + ) lst = app.query_one(ListingView) - check("the listing actually has rows to show", - lst.total > 0, f"total={lst.total}") - check("landed at the image base", app._cur is not None - and app._cur.ea == 0x4000, f"{app._cur.ea if app._cur else None:#x}") + check( + "the listing actually has rows to show", + lst.total > 0, + f"total={lst.total}", + ) + check( + "landed at the image base", + app._cur is not None and app._cur.ea == 0x4000, + f"{app._cur.ea if app._cur else None:#x}", + ) status = str(app.query_one("#status", Static).render()) - check("the status says there are no functions (not 'still loading')", - "no functions" in status and "still loading" not in status, - status[:90]) - check("and points at the likely cause", - "processor" in status and "Ctrl+L" in status, status[:90]) + check( + "the status says there are no functions (not 'still loading')", + "no functions" in status and "still loading" not in status, + status[:90], + ) + check( + "and points at the likely cause", + "processor" in status and "Ctrl+L" in status, + status[:90], + ) # The hint is a property of the database, so it must survive moving # around — an earlier version wrote it once and the next status @@ -139,8 +162,9 @@ async def run() -> int: await pilot.press("down") await settle(app) status2 = str(app.query_one("#status", Static).render()) - check("the hint survives navigating", "no functions" in status2, - status2[:90]) + check( + "the hint survives navigating", "no functions" in status2, status2[:90] + ) # -- byte-granular carving ---------------------------------- # # An undefined run arrives as ONE head ("db N dup(?)"). It has to @@ -148,47 +172,64 @@ async def run() -> int: # press `c` at, which is how IDA works and the only way to find an # instruction stream that doesn't start at the run's first byte. m = lst.model - check("an undefined run presents one row per byte", - m.loaded() >= 4096 and len(m._heads) < 64, - f"rows={m.loaded()} physical heads={len(m._heads)}") + check( + "an undefined run presents one row per byte", + m.loaded() >= 4096 and len(m._heads) < 64, + f"rows={m.loaded()} physical heads={len(m._heads)}", + ) rows = m.window(0, 4) - check("each row is a single addressable byte", - [h.ea for h in rows] == [0x4000, 0x4001, 0x4002, 0x4003] - and all(h.size == 1 for h in rows), - f"{[(hex(h.ea), h.size) for h in rows]}") - check("and shows its value, not a placeholder", - all(h.text.startswith("db ") and "dup" not in h.text - for h in rows), f"{[h.text for h in rows]}") + check( + "each row is a single addressable byte", + [h.ea for h in rows] == [0x4000, 0x4001, 0x4002, 0x4003] + and all(h.size == 1 for h in rows), + f"{[(hex(h.ea), h.size) for h in rows]}", + ) + check( + "and shows its value, not a placeholder", + all(h.text.startswith("db ") and "dup" not in h.text for h in rows), + f"{[h.text for h in rows]}", + ) # The point of all this: land on an arbitrary byte and convert it. i = m.index_of_ea(0x4021) - check("an address inside the run resolves to its own row", - i >= 0 and m.get(i).ea == 0x4021, - f"row={i} ea={m.get(i).ea if i >= 0 else None}") + check( + "an address inside the run resolves to its own row", + i >= 0 and m.get(i).ea == 0x4021, + f"row={i} ea={m.get(i).ea if i >= 0 else None}", + ) - target = 0x4000 + PLANTED # a NOP we put there ourselves + target = 0x4000 + PLANTED # a NOP we put there ourselves lst.cursor = m.index_of_ea(target) lst._scroll_cursor_into_view() await settle(app, lambda: lst._cursor_ea() == target) - check("the cursor sits on the byte we aimed at", - lst._cursor_ea() == target, - f"{lst._cursor_ea():#x} want {target:#x}") + check( + "the cursor sits on the byte we aimed at", + lst._cursor_ea() == target, + f"{lst._cursor_ea():#x} want {target:#x}", + ) await pilot.press("c") # settle(), not a fixed sleep AND not a bare predicate: an edit can # look done for a moment and then be replaced when a queued listing # rebuild lands, so the gate has to be "the row is code AND the app # has stopped working". settle() is the same helper the app's own # RPC layer uses, so tests and driver agree on what "done" means. - await settle(app, lambda: (lambda h: h is not None and h.kind == "code")( - head_at(lst, target)), timeout=30) + await settle( + app, + lambda: (lambda h: h is not None and h.kind == "code")( + head_at(lst, target) + ), + timeout=30, + ) # Re-read the model: defining an item rebuilds it, and holding the # old object shows pre-edit rows -- which looks exactly like the # edit silently failing. m = lst.model h = head_at(lst, target) - check("`c` on a chosen byte carves an instruction there", - h is not None and h.kind == "code", - f"kind={h.kind if h else None} text={h.text if h else None!r}") + check( + "`c` on a chosen byte carves an instruction there", + h is not None and h.kind == "code", + f"kind={h.kind if h else None} text={h.text if h else None!r}", + ) if h is not None and h.kind == "code": print(f" carved {target:#x}: {h.text}") # `c` runs until something stops it, like IDA — one instruction @@ -197,21 +238,32 @@ async def run() -> int: # four and stop AT the ret, not run on into the random bytes # after it. run = [m.get(m.index_of_ea(target + k * 4)) for k in range(3)] - check("`c` keeps going until control flow ends", - all(x is not None and x.kind == "code" for x in run), - f"{[(hex(x.ea), x.kind) for x in run if x]}") - check("and stops at the ret instead of running into junk", - m.get(m.index_of_ea(target + 12)).kind == "unknown", - f"{m.get(m.index_of_ea(target + 12)).text!r}") + check( + "`c` keeps going until control flow ends", + all(x is not None and x.kind == "code" for x in run), + f"{[(hex(x.ea), x.kind) for x in run if x]}", + ) + check( + "and stops at the ret instead of running into junk", + m.get(m.index_of_ea(target + 12)).kind == "unknown", + f"{m.get(m.index_of_ea(target + 12)).text!r}", + ) status = str(app.query_one("#status", Static).render()) - check("the status reports what the run did", - "3 instructions" in status and "control flow" in status, - status[:80]) - check("the carved row spans the instruction, not one byte", - h.size == 4, f"size={h.size}") - check("bytes before it stay individually addressable", - m.get(m.index_of_ea(target - 1)).size == 1 - and m.get(m.index_of_ea(target - 1)).ea == target - 1) + check( + "the status reports what the run did", + "3 instructions" in status and "control flow" in status, + status[:80], + ) + check( + "the carved row spans the instruction, not one byte", + h.size == 4, + f"size={h.size}", + ) + check( + "bytes before it stay individually addressable", + m.get(m.index_of_ea(target - 1)).size == 1 + and m.get(m.index_of_ea(target - 1)).ea == target - 1, + ) # -- an edit must not move the view -------------------------- # # Every mutation rebuilds the model, and row indices don't survive @@ -239,14 +291,17 @@ async def run() -> int: # (The listing re-renders its text lazily, so the comment is not # necessarily visible in model rows the moment the worker returns -- # which is why this waits for the app, not for the text.) - await settle(app, lambda: not app.query_one("#comment", Input).display, - timeout=30) - check("commenting leaves the view where it was", - lst.model.get(round(lst.scroll_offset.y)).ea == ctop - and lst._cursor_ea() == ccur, - f"top {ctop:#x} -> " - f"{lst.model.get(round(lst.scroll_offset.y)).ea:#x}, " - f"cursor {ccur:#x} -> {lst._cursor_ea():#x}") + await settle( + app, lambda: not app.query_one("#comment", Input).display, timeout=30 + ) + check( + "commenting leaves the view where it was", + lst.model.get(round(lst.scroll_offset.y)).ea == ctop + and lst._cursor_ea() == ccur, + f"top {ctop:#x} -> " + f"{lst.model.get(round(lst.scroll_offset.y)).ea:#x}, " + f"cursor {ccur:#x} -> {lst._cursor_ea():#x}", + ) # -- carving must not move the view -------------------------- # # Defining code collapses rows (four byte rows become one @@ -259,8 +314,11 @@ async def run() -> int: await settle(app, lambda: lst._cursor_ea() == far) top_before = lst.model.get(round(lst.scroll_offset.y)).ea cur_before = lst._cursor_ea() - check("scrolled somewhere with rows above us", - round(lst.scroll_offset.y) > 0, f"top={lst.scroll_offset.y}") + check( + "scrolled somewhere with rows above us", + round(lst.scroll_offset.y) > 0, + f"top={lst.scroll_offset.y}", + ) await pilot.press("c") # No predicate here on purpose: this spot is random data, so the # carve may legitimately produce nothing and "the row became code" @@ -270,54 +328,80 @@ async def run() -> int: await settle(app, timeout=30) m2 = lst.model top_after = m2.get(round(lst.scroll_offset.y)).ea - check("carving leaves the scroll position where it was", - top_after == top_before, - f"{top_before:#x} -> {top_after:#x}") - check("and leaves the cursor on the same address", - lst._cursor_ea() == cur_before, - f"{cur_before:#x} -> {lst._cursor_ea():#x}") + check( + "carving leaves the scroll position where it was", + top_after == top_before, + f"{top_before:#x} -> {top_after:#x}", + ) + check( + "and leaves the cursor on the same address", + lst._cursor_ea() == cur_before, + f"{cur_before:#x} -> {lst._cursor_ea():#x}", + ) # -- `p` after carving: the rest of the app must notice ------ # # The "no functions" hint was latched at load and only cleared on a # reload, so it kept telling you the processor/base were wrong long # after you'd defined a function. The function index was never # rebuilt either, which meant Ctrl+N couldn't find what `p` made. - check("no functions yet, and the hint says so", - len(app._func_index) == 0 - and "no functions" in str(app.query_one("#status", Static).render()), - f"n={len(app._func_index)}") + check( + "no functions yet, and the hint says so", + len(app._func_index) == 0 + and "no functions" in str(app.query_one("#status", Static).render()), + f"n={len(app._func_index)}", + ) lst.cursor = lst.model.index_of_ea(target) lst._scroll_cursor_into_view() await settle(app, lambda: lst._cursor_ea() == target) mp = lst.model await pilot.press("p") - await wait(lambda: lst.model is not mp and lst.model is not None, - pilot, 40) - await wait(lambda: app._func_index is not None - and len(app._func_index) > 0, pilot, 60) - check("`p` creates a function the index can see", - len(app._func_index) == 1, f"n={len(app._func_index)}") + await wait(lambda: lst.model is not mp and lst.model is not None, pilot, 40) + await wait( + lambda: app._func_index is not None and len(app._func_index) > 0, + pilot, + 60, + ) + check( + "`p` creates a function the index can see", + len(app._func_index) == 1, + f"n={len(app._func_index)}", + ) status = str(app.query_one("#status", Static).render()) - check("and the stale 'no functions' hint is gone", - "no functions" not in status, status[:90]) - check("the status names the function it made", - "created function" in status, status[:90]) + check( + "and the stale 'no functions' hint is gone", + "no functions" not in status, + status[:90], + ) + check( + "the status names the function it made", + "created function" in status, + status[:90], + ) await pilot.press("ctrl+l") - opened = await wait(lambda: isinstance(app.screen, ConfirmScreen), pilot, 20) - check("Ctrl+L offers to reload with different options", opened, - f"screen={type(app.screen).__name__}") + opened = await wait( + lambda: isinstance(app.screen, ConfirmScreen), pilot, 20 + ) + check( + "Ctrl+L offers to reload with different options", + opened, + f"screen={type(app.screen).__name__}", + ) if opened: note = str(app.screen.query_one("#confirm-note", Static).render()) # We just made a function, so it must NOT claim nothing is lost — # reloading throws the database away and that is now a real cost. - check("the confirmation counts what would be lost", - "1 function," in note and "nothing is lost" not in note, - note[:80]) + check( + "the confirmation counts what would be lost", + "1 function," in note and "nothing is lost" not in note, + note[:80], + ) await pilot.press("escape") await settle(app, lambda: not isinstance(app.screen, ConfirmScreen)) - check("declining leaves the binary open", - not isinstance(app.screen, ConfirmScreen) and app._cur is not None) + check( + "declining leaves the binary open", + not isinstance(app.screen, ConfirmScreen) and app._cur is not None, + ) print(f"\n{PASS} passed, {FAIL} failed") return 1 if FAIL else 0 diff --git a/tests/test_codemode_client.py b/tests/test_codemode_client.py deleted file mode 100644 index 2f70ba3..0000000 --- a/tests/test_codemode_client.py +++ /dev/null @@ -1,162 +0,0 @@ -"""IDA-free contract tests for the Code Mode client adapter.""" -from __future__ import annotations - -import os -import sys -import tempfile -from dataclasses import dataclass - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -import idatui.codemode_client as module # noqa: E402 -from idatui.codemode_client import CodeModeClient, _parse_load_args # noqa: E402 -from idatui.errors import IDAToolError # noqa: E402 - -#: Pure: fakes the DatabaseHandle, never touches IDA or the Code Mode library. -NEEDS_IDA = False - -PASS = FAIL = 0 - - -def check(name: str, condition: bool, detail="") -> None: - global PASS, FAIL - if condition: - PASS += 1 - print(f" ok {name}") - else: - FAIL += 1 - print(f" FAIL {name} {detail}") - - -@dataclass(frozen=True) -class FakeEntry: - pid: int = 123 - backend: str = "gui" - record_id: str = "123-abcdef" - exe_path: str = "" - idb_path: str = "" - - -class FakeHandle: - def __init__(self, path: str) -> None: - self.connected = True - self.entry = FakeEntry(exe_path=path, idb_path=path + ".i64") - self.waited = None - self.saved = 0 - self.closed = False - self.code = "" - self.code_timeout = None - - def wait_autoanalysis(self, timeout=None): - self.waited = timeout - return {"complete": True, "status": "complete"} - - def execute_python(self, code, timeout=None): - self.code = code - self.code_timeout = timeout - return {"result": {"sentinel": 7}, "stdout": "", "stderr": ""} - - def save_database(self): - self.saved += 1 - return {"saved": True, "idb_path": self.entry.idb_path} - - def close(self): - self.connected = False - self.closed = True - - -class FakeDatabaseHandle: - opened = None - kwargs = None - - @classmethod - def open(cls, path, **kwargs): - cls.opened = path - cls.kwargs = kwargs - return FakeHandle(path) - - -def _open_kwargs_are_real(sent: dict): - """(ok, detail) for the kwargs the adapter passes to DatabaseHandle.open. - - Skips (passes) when ida_codemode is not installed, so the file stays pure. - """ - try: - import inspect - from ida_codemode.client import DatabaseHandle as Real - except ImportError: - return True, "ida_codemode not installed - signature not checked" - accepted = set(inspect.signature(Real.open).parameters) - unknown = sorted(set(sent) - accepted) - return not unknown, f"open() rejects {unknown}" - - -def main() -> int: - proc, base, file_type = _parse_load_args("-parm:ARMv7-M -b800000 -TRaw") - check("legacy switches map to typed Code Mode options", - (proc, base, file_type) == ("arm:ARMv7-M", 0x8000000, "Raw"), - (proc, base, file_type)) - try: - _parse_load_args("-parm -zcustom") - except ValueError as exc: - check("arbitrary IDA switches fail loudly", "cannot represent" in str(exc), exc) - else: - check("arbitrary IDA switches fail loudly", False) - - original = module.DatabaseHandle - module.DatabaseHandle = FakeDatabaseHandle - try: - with tempfile.TemporaryDirectory() as tmp: - path = os.path.join(tmp, "sample.bin") - with open(path, "wb") as file: - file.write(b"sample") - client = CodeModeClient(path, load_args="-parm:ARMv7-A -b100") - notes = [] - client.connect(timeout=42, progress=notes.append) - handle = client._handle - check("connect delegates database discovery to DatabaseHandle.open", - FakeDatabaseHandle.opened == path and handle is not None) - check("typed loader options cross the dependency boundary", - FakeDatabaseHandle.kwargs["processor"] == "arm:ARMv7-A" - and FakeDatabaseHandle.kwargs["image_base"] == 0x1000, - FakeDatabaseHandle.kwargs) - # A fake that swallows **kwargs cannot catch a keyword the real - # library does not have -- which is exactly how this port shipped - # `loading_address` (the real name is `image_base`) and would have - # raised TypeError on the very first connect. Check the names we - # send against the real signature whenever it is importable. - check("every open() keyword exists in the real library", - *_open_kwargs_are_real(FakeDatabaseHandle.kwargs)) - check("connect waits for Code Mode autoanalysis", - handle.waited == 42, getattr(handle, "waited", None)) - check("progress distinguishes discovery and backend attachment", - len(notes) == 2 and "gui" in notes[-1], notes) - result = client.invoke("list_funcs", queries=[{"offset": 0, "count": 2}]) - check("invoke returns execute_python's result", result == {"sentinel": 7}, result) - check("operation scripts use the preloaded ida-domain database", - "db.functions.get_all()" in handle.code, handle.code[:200]) - check("health exposes registry identity", - client.health()["record_id"] == "123-abcdef") - client.save_database() - check("save uses the public Code Mode save route", handle.saved == 1) - client.close() - check("close releases only the handle lease", handle.closed) - check("GUI lifetime is never claimed by the client", - client.wait_released(0) is False) - finally: - module.DatabaseHandle = original - - client = CodeModeClient(__file__) - try: - client.invoke("not-an-operation") - except IDAToolError as exc: - check("unknown adapter operations are explicit", exc.tool == "not-an-operation") - else: - check("unknown adapter operations are explicit", False) - - print(f"\n{PASS} passed, {FAIL} failed") - return 1 if FAIL else 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_diag.py b/tests/test_diag.py index e3b966a..d1b480a 100644 --- a/tests/test_diag.py +++ b/tests/test_diag.py @@ -3,6 +3,7 @@ Pure: no IDA, no worker, no Textual. """ + from __future__ import annotations import os @@ -41,10 +42,16 @@ def t_swallow_keeps_going(): r = diag.recent() check("the error is recorded", len(r) == 1, str(r)) check("with what was being attempted", r[0]["what"] == "a thing", str(r[0])) - check("and the exception type and message", - r[0]["error"] == "ValueError: nope", r[0]["error"]) - check("and where it was actually raised", - r[0]["where"].startswith("test_diag.py:"), r[0]["where"]) + check( + "and the exception type and message", + r[0]["error"] == "ValueError: nope", + r[0]["error"], + ) + check( + "and where it was actually raised", + r[0]["where"].startswith("test_diag.py:"), + r[0]["where"], + ) def t_reraise(): @@ -61,8 +68,11 @@ def t_reraise(): check("reraise lets the listed type through", False, "not raised") except Wanted: check("reraise lets the listed type through", True) - check("and a reraised error is not recorded twice", - diag.recent() == [], str(diag.recent())) + check( + "and a reraised error is not recorded twice", + diag.recent() == [], + str(diag.recent()), + ) with diag.swallow("still swallows others", reraise=(Wanted,)): raise ValueError("other") check("other types are still swallowed", len(diag.recent()) == 1) @@ -74,12 +84,21 @@ def t_ring_is_bounded(): diag.note(f"item {i}", RuntimeError(str(i))) r = diag.recent(1000) check("the ring is bounded", len(r) == diag._MAX, f"{len(r)}") - check("it keeps the NEWEST entries", - r[-1]["what"] == f"item {diag._MAX + 24}", r[-1]["what"]) - check("recent(n) returns the last n, newest last", - [e["what"] for e in diag.recent(3)] - == [f"item {diag._MAX + 22}", f"item {diag._MAX + 23}", - f"item {diag._MAX + 24}"], str(diag.recent(3))) + check( + "it keeps the NEWEST entries", + r[-1]["what"] == f"item {diag._MAX + 24}", + r[-1]["what"], + ) + check( + "recent(n) returns the last n, newest last", + [e["what"] for e in diag.recent(3)] + == [ + f"item {diag._MAX + 22}", + f"item {diag._MAX + 23}", + f"item {diag._MAX + 24}", + ], + str(diag.recent(3)), + ) def t_log_file(): @@ -95,8 +114,11 @@ def t_log_file(): body = open(path, encoding="utf-8").read() check("the log records what was attempted", "logged thing" in body, body[:200]) check("and the error", "KeyError" in body, body[:200]) - check("and a traceback, which the ring doesn't carry", - "Traceback" in body and "t_log_file" in body, body[:300]) + check( + "and a traceback, which the ring doesn't carry", + "Traceback" in body and "t_log_file" in body, + body[:300], + ) def t_log_is_off_by_default(): @@ -104,8 +126,10 @@ def t_log_is_off_by_default(): os.environ.pop("IDATUI_LOG", None) with diag.swallow("unlogged"): raise ValueError("x") - check("without $IDATUI_LOG nothing is written, but the ring still has it", - len(diag.recent()) == 1) + check( + "without $IDATUI_LOG nothing is written, but the ring still has it", + len(diag.recent()) == 1, + ) def t_broken_log_path_is_harmless(): @@ -116,8 +140,7 @@ def t_broken_log_path_is_harmless(): with diag.swallow("still fine"): raise ValueError("boom") check("an unwritable log path doesn't raise", True) - check("and the error is still recorded in the ring", - len(diag.recent()) == 1) + check("and the error is still recorded in the ring", len(diag.recent()) == 1) finally: os.environ.pop("IDATUI_LOG", None) @@ -128,41 +151,55 @@ def t_env_read_per_call(): diag.clear() with tempfile.TemporaryDirectory() as d: path = os.path.join(d, "late.log") - os.environ["IDATUI_LOG"] = path # set AFTER import + os.environ["IDATUI_LOG"] = path # set AFTER import try: diag.log("hello") finally: os.environ.pop("IDATUI_LOG", None) - check("a log path set after import is honoured", - os.path.exists(path) and "hello" in open(path).read()) + check( + "a log path set after import is honoured", + os.path.exists(path) and "hello" in open(path).read(), + ) def t_thread_safe(): diag.clear() + def go(n): for i in range(40): diag.note(f"t{n}-{i}", RuntimeError("x")) + ts = [threading.Thread(target=go, args=(n,)) for n in range(6)] for t in ts: t.start() for t in ts: t.join(10) r = diag.recent(1000) - check("concurrent notes don't corrupt the ring", - len(r) == diag._MAX and all("what" in e for e in r), f"{len(r)}") - check("the recording thread is captured", - all(e["thread"] for e in r)) + check( + "concurrent notes don't corrupt the ring", + len(r) == diag._MAX and all("what" in e for e in r), + f"{len(r)}", + ) + check("the recording thread is captured", all(e["thread"] for e in r)) def main() -> int: - for fn in (t_swallow_keeps_going, t_reraise, t_ring_is_bounded, t_log_file, - t_log_is_off_by_default, t_broken_log_path_is_harmless, - t_env_read_per_call, t_thread_safe): + for fn in ( + t_swallow_keeps_going, + t_reraise, + t_ring_is_bounded, + t_log_file, + t_log_is_off_by_default, + t_broken_log_path_is_harmless, + t_env_read_per_call, + t_thread_safe, + ): print(f"\n{fn.__name__}") try: fn() except Exception as e: # noqa: BLE001 import traceback + check(f"{fn.__name__} did not crash", False, f"{type(e).__name__}: {e}") traceback.print_exc() diag.clear() diff --git a/tests/test_findings.py b/tests/test_findings.py index cf813c2..205b005 100644 --- a/tests/test_findings.py +++ b/tests/test_findings.py @@ -18,7 +18,12 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from idatui.domain import Comment, NamedItem, Struct # noqa: E402 from idatui.findings import ( # noqa: E402 - Findings, default_path, from_loader, gather, is_dummy, render, + Findings, + default_path, + from_loader, + gather, + is_dummy, + render, ) PASS = FAIL = 0 @@ -36,34 +41,62 @@ def check(name, cond, detail=""): def sample() -> Findings: return Findings( - binary="echo", path="/tmp/echo", + binary="echo", + path="/tmp/echo", sections=[(0x1000, 0x2000, ".text"), (0x2000, 0x2100, ".data")], n_functions=128, comments=[ - Comment(addr=0x1100, text="length is attacker controlled", - line="mov edi, [rbp+len]", func="parse", func_addr=0x1000), - Comment(addr=0x1010, text="entry", line="push rbp", - func="parse", func_addr=0x1000), - Comment(addr=0x1000, text="parses the header", whole_func=True, - func="parse", func_addr=0x1000), + Comment( + addr=0x1100, + text="length is attacker controlled", + line="mov edi, [rbp+len]", + func="parse", + func_addr=0x1000, + ), + Comment( + addr=0x1010, + text="entry", + line="push rbp", + func="parse", + func_addr=0x1000, + ), + Comment( + addr=0x1000, + text="parses the header", + whole_func=True, + func="parse", + func_addr=0x1000, + ), Comment(addr=0x2004, text="magic", line="dd 0DEADBEEFh"), # What the ELF loader writes into every database, through the very # same set_cmt a person uses. - Comment(addr=0x4, text="File class: 64-bit", line="db 2", - seg="LOAD"), + Comment(addr=0x4, text="File class: 64-bit", line="db 2", seg="LOAD"), ], names=[ - NamedItem(addr=0x1000, name="parse", is_func=True, size=0x120, - proto="int __fastcall parse(char *)"), + NamedItem( + addr=0x1000, + name="parse", + is_func=True, + size=0x120, + proto="int __fastcall parse(char *)", + ), NamedItem(addr=0x1200, name="sub_1200", is_func=True, size=0x30), NamedItem(addr=0x2004, name="hdr_magic", seg=".data"), NamedItem(addr=0x1400, name="memcpy", is_func=True, size=0x40), NamedItem(addr=0x390, name="elf_gnu_hash_nbuckets", seg="LOAD"), ], - types=[(Struct(name="hdr", size=0x10, is_union=False, members=3, - ordinal=42), "struct hdr\n{\n int magic;\n};\n"), - (Struct(name="Elf64_Dyn", size=0x10, is_union=False, members=2, - ordinal=3), "struct Elf64_Dyn\n{\n int d_tag;\n};\n")], + types=[ + ( + Struct(name="hdr", size=0x10, is_union=False, members=3, ordinal=42), + "struct hdr\n{\n int magic;\n};\n", + ), + ( + Struct( + name="Elf64_Dyn", size=0x10, is_union=False, members=2, ordinal=3 + ), + "struct Elf64_Dyn\n{\n int d_tag;\n};\n", + ), + ], linked={"memcpy"}, stripped=True, ) @@ -74,9 +107,11 @@ def main() -> int: check("the report names the binary", doc.startswith("# Findings — echo"), doc[:40]) # 1 function, not 3: sub_1200 is IDA's invention and memcpy is the linker's. - check("the summary counts only what a person contributed", - "1 named functions · 1 named data · 4 comments · 2 local types" in doc, - doc.splitlines()[2] if len(doc.splitlines()) > 2 else "") + check( + "the summary counts only what a person contributed", + "1 named functions · 1 named data · 4 comments · 2 local types" in doc, + doc.splitlines()[2] if len(doc.splitlines()) > 2 else "", + ) # A name IDA invented is not a finding, and neither is one the linker gave. check("dummy names are excluded", "sub_1200" not in doc) @@ -86,75 +121,111 @@ def main() -> int: # The loader annotates every database it makes; none of it is a finding. check("the loader's own comments are left out", "File class" not in doc) check("the loader's own names are left out", "elf_gnu_hash" not in doc) - check("but the report says how many it dropped", - "4 annotations left out as the loader's own" in doc, # 1 comment + 3 names - [l for l in doc.splitlines() if "left out" in l]) - check("from_loader knows both shapes", - from_loader("LOAD") and from_loader("", "elf_gnu_hash_x") - and not from_loader(".text", "parse")) - check("is_dummy knows the shapes IDA invents", - all(is_dummy(n) for n in ("sub_1234", "loc_A0", "unk_4000", "j_free")) - and not any(is_dummy(n) for n in ("parse", "sub_parse", "main", "")), - "") + check( + "but the report says how many it dropped", + "4 annotations left out as the loader's own" in doc, # 1 comment + 3 names + [l for l in doc.splitlines() if "left out" in l], + ) + check( + "from_loader knows both shapes", + from_loader("LOAD") + and from_loader("", "elf_gnu_hash_x") + and not from_loader(".text", "parse"), + ) + check( + "is_dummy knows the shapes IDA invents", + all(is_dummy(n) for n in ("sub_1234", "loc_A0", "unk_4000", "j_free")) + and not any(is_dummy(n) for n in ("parse", "sub_parse", "main", "")), + "", + ) # Comments lead, grouped by function, address-ordered within a group. - check("comments come before the name tables", - doc.index("## Comments") < doc.index("## Named functions")) - body = doc[doc.index("## Comments"):doc.index("## Named functions")] + check( + "comments come before the name tables", + doc.index("## Comments") < doc.index("## Named functions"), + ) + body = doc[doc.index("## Comments") : doc.index("## Named functions")] check("comments are grouped under their function", "### `parse`" in body) - check("a commentless region is grouped separately", - "### outside any function" in body) - check("comments are ordered by address inside a group", - body.index("0x1010") < body.index("0x1100")) - check("a function comment says that is what it is", - "*whole function*: parses the header" in body) - check("an instruction comment carries the line it annotates", - "`mov edi, [rbp+len]`" in body) + check( + "a commentless region is grouped separately", "### outside any function" in body + ) + check( + "comments are ordered by address inside a group", + body.index("0x1010") < body.index("0x1100"), + ) + check( + "a function comment says that is what it is", + "*whole function*: parses the header" in body, + ) + check( + "an instruction comment carries the line it annotates", + "`mov edi, [rbp+len]`" in body, + ) # Types: newest ordinal first, because that is the one you just wrote. - types = doc[doc.index("## Local types"):] - check("your newest type is first", - types.index("hdr") < types.index("Elf64_Dyn")) + types = doc[doc.index("## Local types") :] + check("your newest type is first", types.index("hdr") < types.index("Elf64_Dyn")) check("type source is fenced as C", "```c\nstruct hdr" in types) # Escaping. - hostile = Findings(binary="x", comments=[ - Comment(addr=1, text="a | b", line="mov | rax"), - ], names=[NamedItem(addr=2, name="a|b")]) + hostile = Findings( + binary="x", + comments=[ + Comment(addr=1, text="a | b", line="mov | rax"), + ], + names=[NamedItem(addr=2, name="a|b")], + ) hdoc = render(hostile) check("a pipe cannot break a table row", "a\\|b" in hdoc, hdoc) check("a pipe in a comment is escaped too", "a \\| b" in hdoc) # The empty database must still produce a document that says something. empty = render(Findings(binary="nothing")) - check("an empty report is still a document", - empty.startswith("# Findings — nothing") and "## Comments" in empty) + check( + "an empty report is still a document", + empty.startswith("# Findings — nothing") and "## Comments" in empty, + ) check("and it says why it is empty", "Comments are the part" in empty) - check("an empty report has no dangling type section", - "## Local types" not in empty) + check("an empty report has no dangling type section", "## Local types" not in empty) # Provenance must be stated, not implied. Without a journal the report is a # scan and says so; with one it is exactly what idatui recorded doing. - scanned = render(Findings(binary="x", stripped=False, - names=[NamedItem(addr=1, name="main", is_func=True)])) - check("a scanned report admits it cannot know who wrote what", - "**source**: a scan of the database" in scanned - and "include its work as well as yours" in scanned) - check("and warns when the binary brought its own symbols", - "include ones it shipped with" in scanned) + scanned = render( + Findings( + binary="x", + stripped=False, + names=[NamedItem(addr=1, name="main", is_func=True)], + ) + ) + check( + "a scanned report admits it cannot know who wrote what", + "**source**: a scan of the database" in scanned + and "include its work as well as yours" in scanned, + ) + check( + "and warns when the binary brought its own symbols", + "include ones it shipped with" in scanned, + ) j = sample() j.recorded = {0x1100, 0x1000} j.n_recorded = 7 jdoc = render(j) - check("a journalled report says so", "idatui's edit journal" in jdoc - and "7 recorded edits" in jdoc, "") - jbody = jdoc[jdoc.index("## Comments"):jdoc.index("## Named functions")] - check("and lists only the comments it recorded", - "length is attacker controlled" in jbody and "0x2004" not in jbody, - jbody) - check("a journalled report drops names it did not record", - "`parse`" in jdoc and "hdr_magic" not in jdoc) + check( + "a journalled report says so", + "idatui's edit journal" in jdoc and "7 recorded edits" in jdoc, + "", + ) + jbody = jdoc[jdoc.index("## Comments") : jdoc.index("## Named functions")] + check( + "and lists only the comments it recorded", + "length is attacker controlled" in jbody and "0x2004" not in jbody, + jbody, + ) + check( + "a journalled report drops names it did not record", + "`parse`" in jdoc and "hdr_magic" not in jdoc, + ) # -- gather ------------------------------------------------------------- # class FakeProgram: @@ -162,8 +233,10 @@ def main() -> int: return [(0x1000, 0x2000, ".text")] def annotations(self, limit=4000): - return ([Comment(addr=1, text="hi")], - [NamedItem(addr=1, name="parse", is_func=True)]) + return ( + [Comment(addr=1, text="hi")], + [NamedItem(addr=1, name="parse", is_func=True)], + ) def linkage(self): return ([], []) @@ -180,11 +253,15 @@ def main() -> int: f = gather(FakeProgram(), "/tmp/echo") check("gather reads the annotations", len(f.comments) == 1 and len(f.names) == 1) check("gather takes the binary name from the path", f.binary == "echo", f.binary) - check("a failing backend degrades the report instead of raising", - f.n_functions == 0 and f.types == [] and "# Findings" in render(f)) + check( + "a failing backend degrades the report instead of raising", + f.n_functions == 0 and f.types == [] and "# Findings" in render(f), + ) - check("the default path sits beside the binary", - default_path("/tmp/echo") == "/tmp/echo.findings.md") + check( + "the default path sits beside the binary", + default_path("/tmp/echo") == "/tmp/echo.findings.md", + ) print(f"\n{PASS} passed, {FAIL} failed") return 1 if FAIL else 0 diff --git a/tests/test_formats.py b/tests/test_formats.py index c0ad542..05d98ba 100644 --- a/tests/test_formats.py +++ b/tests/test_formats.py @@ -10,8 +10,12 @@ import tempfile sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from idatui.formats import (PROCESSORS, load_args, # noqa: E402 - needs_load_options, sniff) +from idatui.formats import ( # noqa: E402 + PROCESSORS, + load_args, + needs_load_options, + sniff, +) PASS = FAIL = 0 @@ -28,6 +32,7 @@ def check(name, ok, detail=""): def main() -> int: with tempfile.TemporaryDirectory() as tmp: + def w(name, data): p = os.path.join(tmp, name) with open(p, "wb") as f: @@ -35,67 +40,102 @@ def main() -> int: return p # -- formats IDA can load on its own: never interrupt the user -------- # - for name, head in (("elf", b"\x7fELF\x02\x01\x01"), ("pe", b"MZ\x90\x00"), - ("macho", b"\xcf\xfa\xed\xfe"), ("dex", b"dex\n035\x00"), - ("wasm", b"\x00asm\x01\x00")): + for name, head in ( + ("elf", b"\x7fELF\x02\x01\x01"), + ("pe", b"MZ\x90\x00"), + ("macho", b"\xcf\xfa\xed\xfe"), + ("dex", b"dex\n035\x00"), + ("wasm", b"\x00asm\x01\x00"), + ): p = w(name, head + bytes(60)) - check(f"{name} is recognised (no dialog)", - sniff(p) is not None and not needs_load_options(p), f"{sniff(p)}") + check( + f"{name} is recognised (no dialog)", + sniff(p) is not None and not needs_load_options(p), + f"{sniff(p)}", + ) # -- the case this exists for ----------------------------------------- # blob = w("fw.bin", bytes(range(256)) * 4) - check("a headerless blob is not recognised (ask)", - sniff(blob) is None and needs_load_options(blob)) + check( + "a headerless blob is not recognised (ask)", + sniff(blob) is None and needs_load_options(blob), + ) # Intel HEX / S-records are text containers IDA does load. hexf = w("f.hex", b":10010000214601360121470136007EFE09D2190140\n") check("Intel HEX is recognised", sniff(hexf) == "Intel HEX", f"{sniff(hexf)}") - srec = w("f.s19", b"S00600004844521B\nS1130000285F245F2212226A000424290008237C\n") - check("Motorola S-records are recognised", - sniff(srec) == "Motorola S-record", f"{sniff(srec)}") + srec = w( + "f.s19", b"S00600004844521B\nS1130000285F245F2212226A000424290008237C\n" + ) + check( + "Motorola S-records are recognised", + sniff(srec) == "Motorola S-record", + f"{sniff(srec)}", + ) # A binary that merely STARTS with ':' is not Intel HEX. Text formats are # only accepted when the whole head is printable, or half the firmware in # the world gets mis-detected on one byte. colon = w("colon.bin", b":\x00\xff\xfe\x01\x02" + bytes(58)) - check("a blob starting with ':' is not mistaken for Intel HEX", - sniff(colon) is None, f"{sniff(colon)}") + check( + "a blob starting with ':' is not mistaken for Intel HEX", + sniff(colon) is None, + f"{sniff(colon)}", + ) check("an empty file is not recognised", sniff(w("empty", b"")) is None) - check("a missing file never asks (nothing to load)", - not needs_load_options(os.path.join(tmp, "nope"))) + check( + "a missing file never asks (nothing to load)", + not needs_load_options(os.path.join(tmp, "nope")), + ) check("a directory never asks", not needs_load_options(tmp)) # -- switch construction ------------------------------------------------- # # -b is in PARAGRAPHS: 0x8000000 >> 4 == 0x800000. Getting this wrong loads # the image 16x off and every address in the database is wrong. - check("base is converted to paragraphs", - load_args("arm", 0x8000000) == "-parm -b800000", - load_args("arm", 0x8000000)) + check( + "base is converted to paragraphs", + load_args("arm", 0x8000000) == "-parm -b800000", + load_args("arm", 0x8000000), + ) check("processor alone", load_args("mipsb") == "-pmipsb", load_args("mipsb")) check("base alone", load_args("", 0x10000) == "-b1000", load_args("", 0x10000)) - check("base 0 emits no switch (it's the default)", - load_args("arm", 0) == "-parm", load_args("arm", 0)) + check( + "base 0 emits no switch (it's the default)", + load_args("arm", 0) == "-parm", + load_args("arm", 0), + ) check("nothing in, nothing out", load_args() == "") - check("extra switches pass through", - load_args("arm", 0, "-T binary") == "-parm -T binary") + check( + "extra switches pass through", + load_args("arm", 0, "-T binary") == "-parm -T binary", + ) - check("the processor list leads with the common targets", - [n for n, _ in PROCESSORS[:2]] == ["arm", "arm:ARMv7-A"], - f"{[n for n, _ in PROCESSORS[:3]]}") + check( + "the processor list leads with the common targets", + [n for n, _ in PROCESSORS[:2]] == ["arm", "arm:ARMv7-A"], + f"{[n for n, _ in PROCESSORS[:3]]}", + ) # 32-bit ARM has to be offered SEPARATELY from bare 'arm', which gives a # 64-bit database. That isn't cosmetic: Hex-Rays refuses a 32-bit function # in a 64-bit database, and Thumb doesn't exist in AArch64 at all, so a # firmware image loaded as plain 'arm' can never be decompiled — and the # database's bitness cannot be corrected after load. - check("a 32-bit ARM variant is offered", - any(n.startswith("arm:ARMv") for n, _ in PROCESSORS), - f"{[n for n, _ in PROCESSORS if n.startswith('arm')]}") - check("and the labels say which is 32- vs 64-bit", - all(("32-bit" in d or "64-bit" in d) - for n, d in PROCESSORS if n == "arm" or n.startswith("arm:")), - f"{[(n, d) for n, d in PROCESSORS if n.startswith('arm')]}") + check( + "a 32-bit ARM variant is offered", + any(n.startswith("arm:ARMv") for n, _ in PROCESSORS), + f"{[n for n, _ in PROCESSORS if n.startswith('arm')]}", + ) + check( + "and the labels say which is 32- vs 64-bit", + all( + ("32-bit" in d or "64-bit" in d) + for n, d in PROCESSORS + if n == "arm" or n.startswith("arm:") + ), + f"{[(n, d) for n, d in PROCESSORS if n.startswith('arm')]}", + ) # Every offered name must have been checked against a real IDA, because a # wrong one is REJECTED (rc=4) with nothing useful said — handing the user a @@ -103,16 +143,40 @@ def main() -> int: # output of tools/verify_procs.py; adding a processor without re-running it # fails here on purpose. VERIFIED = { - "arm", "armb", "metapc", "mipsl", "mipsb", "ppc", "ppcl", "sh4", "68k", - "riscv", "tricore", "xtensa", "avr", "z80", "tms320c6", "m32r", "arc", - "h8300", "sparcb", "sparcl", "s390", + "arm", + "armb", + "metapc", + "mipsl", + "mipsb", + "ppc", + "ppcl", + "sh4", + "68k", + "riscv", + "tricore", + "xtensa", + "avr", + "z80", + "tms320c6", + "m32r", + "arc", + "h8300", + "sparcb", + "sparcl", + "s390", # variants: tools/verify_procs.py checks these report the base module # AND change the database bitness, which is the reason they exist - "arm:ARMv7-A", "arm:ARMv7-M", "arm:ARMv6-M", "arm:ARMv5TE", + "arm:ARMv7-A", + "arm:ARMv7-M", + "arm:ARMv6-M", + "arm:ARMv5TE", } offered = {n for n, _ in PROCESSORS} - check("every offered processor name is IDA-verified", - offered <= VERIFIED, f"unverified: {sorted(offered - VERIFIED)}") + check( + "every offered processor name is IDA-verified", + offered <= VERIFIED, + f"unverified: {sorted(offered - VERIFIED)}", + ) # These are module FILENAMES or common aliases, not -p names. IDA refuses # them; they were in the list until a real run said otherwise. @@ -123,16 +187,28 @@ def main() -> int: def finds(q): ql = q.lower() return [n for n, d in PROCESSORS if ql in n.lower() or ql in d.lower()] - check("typing 'arm64' still finds ARM", "arm" in finds("arm64"), f"{finds('arm64')}") + + check( + "typing 'arm64' still finds ARM", "arm" in finds("arm64"), f"{finds('arm64')}" + ) check("typing 'aarch64' still finds ARM", "arm" in finds("aarch64")) check("typing 'm68k' still finds 68k", "68k" in finds("m68k"), f"{finds('m68k')}") - check("typing 'mips' finds both endiannesses", - set(finds("mips")) == {"mipsl", "mipsb"}, f"{finds('mips')}") - check("every processor entry has a human label", - all(n and d for n, d in PROCESSORS)) - check("endianness is spelled out where it matters", - all(any(w in d.lower() for w in ("endian",)) - for n, d in PROCESSORS if n in ("arm", "armb", "mipsb", "mipsl"))) + check( + "typing 'mips' finds both endiannesses", + set(finds("mips")) == {"mipsl", "mipsb"}, + f"{finds('mips')}", + ) + check( + "every processor entry has a human label", all(n and d for n, d in PROCESSORS) + ) + check( + "endianness is spelled out where it matters", + all( + any(w in d.lower() for w in ("endian",)) + for n, d in PROCESSORS + if n in ("arm", "armb", "mipsb", "mipsl") + ), + ) print(f"\n{PASS} passed, {FAIL} failed") return 1 if FAIL else 0 diff --git a/tests/test_graph.py b/tests/test_graph.py index 146092d..e3b9143 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -9,6 +9,7 @@ suite runs anywhere, but when present it is the interesting half: real functions are where the degenerate shapes (switch fan-out, irreducible loops, 400-block monsters) actually live. """ + from __future__ import annotations #: the layout engine is pure: no IDA, no Textual. @@ -41,21 +42,43 @@ def sizer(b: G.Block) -> tuple[int, int]: return (len(f"loc_{b.start:X}") + 6, 4) +#: Which layout engine the current pass is exercising. Every invariant here is +#: a claim about the DRAWING, not about how it was arrived at, so the whole +#: suite runs once per available engine (see main()). +ENGINE = "native" + + +def layout(blocks, sz=None, entry=None) -> G.Layout: + return G.layout(blocks, sz or sizer, entry=entry, engine=ENGINE) + + def mk(edges: dict[int, list[tuple[int, str]]], n: int | None = None) -> list[G.Block]: ids = set(edges) | {d for v in edges.values() for d, _ in v} if n: ids |= set(range(n)) - return [G.Block(id=i, start=0x1000 + i * 0x10, end=0x1000 + i * 0x10 + 8, - succs=list(edges.get(i, []))) for i in sorted(ids)] + return [ + G.Block( + id=i, + start=0x1000 + i * 0x10, + end=0x1000 + i * 0x10 + 8, + succs=list(edges.get(i, [])), + ) + for i in sorted(ids) + ] # ------------------------------------------------------------ invariants + def no_box_overlap(lay: G.Layout) -> bool: for i, a in enumerate(lay.nodes): - for b in lay.nodes[i + 1:]: - if (a.x <= b.right and b.x <= a.right - and a.y <= b.y + b.h - 1 and b.y <= a.y + a.h - 1): + for b in lay.nodes[i + 1 :]: + if ( + a.x <= b.right + and b.x <= a.right + and a.y <= b.y + b.h - 1 + and b.y <= a.y + a.h - 1 + ): return False return True @@ -87,15 +110,18 @@ def all_edges_drawn(lay: G.Layout) -> bool: def invariants(lay: G.Layout, name: str) -> None: check(no_box_overlap(lay), f"{name}: boxes must not overlap") check(no_edge_through_box(lay) == 0, f"{name}: no edge may cross a box") - check(all(n.x >= 0 and n.y >= 0 for n in lay.nodes), - f"{name}: no negative coordinates") + check( + all(n.x >= 0 and n.y >= 0 for n in lay.nodes), + f"{name}: no negative coordinates", + ) check(lay.width > 0 and lay.height > 0, f"{name}: canvas has extent") # ---------------------------------------------------------------- cases + def t_linear() -> None: - lay = G.layout(mk({0: [(1, "uncond")], 1: [(2, "uncond")]}), sizer) + lay = layout(mk({0: [(1, "uncond")], 1: [(2, "uncond")]}), sizer) invariants(lay, "linear") ranks = [lay.by_id[i].rank for i in (0, 1, 2)] check(ranks == [0, 1, 2], f"linear: ranks stack ({ranks})") @@ -103,8 +129,10 @@ def t_linear() -> None: def t_diamond() -> None: - lay = G.layout(mk({0: [(1, "jump"), (2, "fall")], - 1: [(3, "uncond")], 2: [(3, "uncond")]}), sizer) + lay = layout( + mk({0: [(1, "jump"), (2, "fall")], 1: [(3, "uncond")], 2: [(3, "uncond")]}), + sizer, + ) invariants(lay, "diamond") check(lay.by_id[3].rank == 2, "diamond: join sits below both arms") check(lay.by_id[1].rank == lay.by_id[2].rank, "diamond: arms share a rank") @@ -115,8 +143,10 @@ def t_diamond() -> None: def t_selfloop() -> None: """A self-loop must not stall the ranking — the bug that collapsed a whole function into three layers and made the graph 280 columns wide.""" - lay = G.layout(mk({0: [(1, "uncond")], 1: [(1, "jump"), (2, "fall")], - 2: [(3, "uncond")]}), sizer) + lay = layout( + mk({0: [(1, "uncond")], 1: [(1, "jump"), (2, "fall")], 2: [(3, "uncond")]}), + sizer, + ) invariants(lay, "selfloop") ranks = [lay.by_id[i].rank for i in (0, 1, 2, 3)] check(ranks == [0, 1, 2, 3], f"selfloop: ranking still stacks ({ranks})") @@ -124,46 +154,104 @@ def t_selfloop() -> None: def t_loop() -> None: - lay = G.layout(mk({0: [(1, "uncond")], 1: [(2, "jump"), (3, "fall")], - 2: [(1, "uncond")]}), sizer) + lay = layout( + mk({0: [(1, "uncond")], 1: [(2, "jump"), (3, "fall")], 2: [(1, "uncond")]}), + sizer, + ) invariants(lay, "loop") check(any(e.back for e in lay.edges), "loop: a back edge is detected") check(lay.by_id[1].rank < lay.by_id[2].rank, "loop: header above the body") back = [e for e in lay.edges if e.back][0] - check((2, G.E_BACK) in [(a, s) for a, s in lay.pred[1]] - or (1, G.E_BACK) in [(a, s) for a, s in lay.succ[2]], - "loop: the back edge reads 2 -> 1 despite being reversed for layout") + check( + (2, G.E_BACK) in [(a, s) for a, s in lay.pred[1]] + or (1, G.E_BACK) in [(a, s) for a, s in lay.succ[2]], + "loop: the back edge reads 2 -> 1 despite being reversed for layout", + ) def t_switch() -> None: - lay = G.layout(mk({0: [(i, "switch") for i in range(1, 9)], - **{i: [(9, "uncond")] for i in range(1, 9)}}), sizer) + lay = layout( + mk( + { + 0: [(i, "switch") for i in range(1, 9)], + **{i: [(9, "uncond")] for i in range(1, 9)}, + } + ), + sizer, + ) invariants(lay, "switch") - check(len({lay.by_id[i].rank for i in range(1, 9)}) == 1, - "switch: all cases share a rank") + check( + len({lay.by_id[i].rank for i in range(1, 9)}) == 1, + "switch: all cases share a rank", + ) check(lay.by_id[9].rank == 2, "switch: the join is below the cases") def t_unreachable() -> None: """A block reachable only through a reversed edge must still get a rank.""" - lay = G.layout(mk({0: [(1, "uncond")], 2: [(2, "jump")]}, n=3), sizer) + lay = layout(mk({0: [(1, "uncond")], 2: [(2, "jump")]}, n=3), sizer) invariants(lay, "unreachable") check(len(lay.nodes) == 3, "unreachable: every block is placed") +def t_unreachable_entry() -> None: + """Blocks the entry cannot reach, including an entry with no successors. + + IDA hands these out routinely -- dead code, an unresolved jump table -- and + triskel's root is whichever node was created first, with every analysis + walking out from there. Anything it cannot reach is undefined behaviour: + this exact 7-block shape SEGFAULTED the interpreter, and lesser versions + threw "EMPTY BL" from its SESE bracket lists. A crash cannot be fallen back + from, so the engine must never be handed one. + """ + # entry 0 is a sink; 2 and 3 jump INTO it; 1 and 6 self-loop. + lay = layout( + mk( + { + 0: [], + 1: [(5, "switch"), (1, "fall"), (4, "switch")], + 2: [(5, "jump"), (0, "uncond")], + 3: [(0, "switch")], + 4: [], + 5: [(4, "jump")], + 6: [(2, "jump"), (6, "switch"), (3, "jump")], + } + ), + entry=0, + ) + invariants(lay, "unreachable_entry") + check(len(lay.nodes) == 7, "unreachable_entry: every block is placed") + check( + lay.stats.get("engine_error") is None, + f"unreachable_entry: no fallback ({lay.stats.get('engine_error')})", + ) + + # An entry that reaches nothing at all, with everything hanging off nodes + # it cannot see, is the degenerate version of the same thing. + lay = layout(mk({0: [], 1: [(2, "jump")], 2: [(1, "jump")]}), entry=0) + invariants(lay, "orphan_pair") + check(len(lay.nodes) == 3, "orphan_pair: every block is placed") + + def t_long_edge() -> None: """An edge spanning many layers gets dummies, so it reserves real space.""" chain = {i: [(i + 1, "uncond")] for i in range(6)} chain[0] = [(1, "fall"), (6, "jump")] - lay = G.layout(mk(chain), sizer) + lay = layout(mk(chain), sizer) invariants(lay, "long_edge") - check(lay.stats["dummies"] >= 4, - f"long_edge: the skip edge is padded ({lay.stats['dummies']} dummies)") + # Dummy nodes are how the NATIVE engine reserves horizontal space for a + # long edge. Triskel reaches the same end -- an edge that crosses no box, + # checked by invariants() above -- without them, so this is engine-specific. + if ENGINE == "native": + check( + lay.stats["dummies"] >= 4, + f"long_edge: the skip edge is padded ({lay.stats['dummies']} dummies)", + ) check(all_edges_drawn(lay), "long_edge: the long edge is drawn") def t_empty() -> None: - lay = G.layout([], sizer) + lay = layout([], sizer) check(lay.nodes == [], "empty: no nodes") check(lay.width >= 1 and lay.height >= 1, "empty: canvas is still sane") @@ -171,18 +259,22 @@ def t_empty() -> None: def t_row_query() -> None: """cells_at_row must be windowed: asking for a slice returns only that slice, which is what keeps a 13M-cell graph renderable.""" - lay = G.layout(mk({0: [(1, "jump"), (2, "fall")], - 1: [(3, "uncond")], 2: [(3, "uncond")]}), sizer) + lay = layout( + mk({0: [(1, "jump"), (2, "fall")], 1: [(3, "uncond")], 2: [(3, "uncond")]}), + sizer, + ) for row in range(lay.height): full = lay.painting.cells_at_row(row, 0, lay.width) part = lay.painting.cells_at_row(row, 5, 12) check(all(5 <= c < 12 for c in part), f"row {row}: window respected") - check(all(full.get(c) == v for c, v in part.items()), - f"row {row}: window agrees with the full row") + check( + all(full.get(c) == v for c, v in part.items()), + f"row {row}: window agrees with the full row", + ) def t_hit_test() -> None: - lay = G.layout(mk({0: [(1, "jump"), (2, "fall")]}), sizer) + lay = layout(mk({0: [(1, "jump"), (2, "fall")]}), sizer) n = lay.nodes[0] check(lay.node_at(n.y, n.x) is n, "hit: top-left corner hits the node") check(lay.node_at(n.y + 1, n.x + 1) is n, "hit: interior hits the node") @@ -192,42 +284,119 @@ def t_hit_test() -> None: # ---------------------------------------------------------------- corpus + def t_corpus(path: str) -> None: recs = json.load(open(path)) print(f"\ncorpus: {len(recs)} functions from {path}") worst_ms = 0.0 worst_name = "" + fellback: list[tuple[str, str | None]] = [] + worst_any_ms = 0.0 + worst_any_name = "" t0 = time.perf_counter() for rec in recs: - blocks = [G.Block(id=b["id"], start=b["start"], end=b["end"], - succs=[(d, k) for d, k in b["succs"]]) - for b in rec["blocks"]] - lay = G.layout(blocks, sizer) - if lay.stats["ms"] > worst_ms: + blocks = [ + G.Block( + id=b["id"], + start=b["start"], + end=b["end"], + succs=[(d, k) for d, k in b["succs"]], + ) + for b in rec["blocks"] + ] + lay = layout(blocks, sizer) + # A SILENT fallback is the failure mode that matters here: the engine + # under test quietly stops being the engine under test, and every + # invariant below then passes for the wrong reason. `ls` main (329 + # blocks) used to fall back on all three zoom levels because a final + # approach was routed through the block above its target. + # + # Falling back is legitimate -- it is how an upstream layout defect is + # kept off the screen -- so this asserts it is rare and explained, + # not that it never happens. + if ENGINE != "auto" and lay.stats["engine"] != ENGINE: + fellback.append((rec["name"], lay.stats.get("engine_error"))) + check( + bool(lay.stats.get("engine_error")), + f"corpus {rec['name']}: a fallback must record its reason", + ) + # Time the engine only on the functions it would actually be ASKED for. + # `auto` hands anything over AUTO_TRISKEL_MAX_BLOCKS to native, and the + # view refuses to draw past 400 blocks at all, so a forced triskel run + # on a 495-block monster times a call the app cannot make. + reachable = ENGINE != "triskel" or len(blocks) <= G.AUTO_TRISKEL_MAX_BLOCKS + if reachable and lay.stats["ms"] > worst_ms: worst_ms, worst_name = lay.stats["ms"], rec["name"] + if lay.stats["ms"] > worst_any_ms: + worst_any_ms, worst_any_name = lay.stats["ms"], rec["name"] check(no_box_overlap(lay), f"corpus {rec['name']}: boxes must not overlap") - check(len(lay.nodes) == len(blocks), - f"corpus {rec['name']}: every block is placed") + check( + len(lay.nodes) == len(blocks), + f"corpus {rec['name']}: every block is placed", + ) # The full cell sweep is O(canvas); only affordable on the small ones, # but that is where a routing bug would show up anyway. if lay.width * lay.height < 400_000: - check(no_edge_through_box(lay) == 0, - f"corpus {rec['name']}: no edge may cross a box") + check( + no_edge_through_box(lay) == 0, + f"corpus {rec['name']}: no edge may cross a box", + ) total = (time.perf_counter() - t0) * 1000 - print(f" laid out {len(recs)} functions in {total:.0f} ms " - f"(worst {worst_ms:.0f} ms: {worst_name})") - check(worst_ms < 2000, f"corpus: worst layout under 2s ({worst_ms:.0f} ms)") + print( + f" laid out {len(recs)} functions in {total:.0f} ms " + f"(worst {worst_ms:.0f} ms: {worst_name})" + ) + if fellback: + print(f" {len(fellback)} fell back to native:") + for name, why in fellback: + print(f" {name}: {why}") + check( + len(fellback) <= max(2, len(recs) // 20), + f"corpus: {ENGINE} fell back on {len(fellback)}/{len(recs)} functions", + ) + check( + worst_ms < 2000, + f"corpus: worst REACHABLE layout under 2s ({worst_ms:.0f} ms: {worst_name})", + ) + # Nothing may blow up quadratically even when forced past its own limits. + check( + worst_any_ms < 5000, + f"corpus: worst layout at any size under 5s " + f"({worst_any_ms:.0f} ms: {worst_any_name})", + ) def main() -> int: + global ENGINE print("idatui.graph layout tests") - for fn in (t_linear, t_diamond, t_selfloop, t_loop, t_switch, - t_unreachable, t_long_edge, t_empty, t_row_query, t_hit_test): - print(f" {fn.__name__}") - fn() - for path in sys.argv[1:]: - if os.path.exists(path): - t_corpus(path) + from idatui import graph_triskel + + engines = ["native"] + if graph_triskel.available(): + engines.append("triskel") + else: + print(" (pytriskel not importable: skipping the triskel engine)") + for engine in engines: + ENGINE = engine + print(f"\nengine: {engine}") + for fn in ( + t_linear, + t_diamond, + t_selfloop, + t_loop, + t_switch, + t_unreachable, + t_unreachable_entry, + t_long_edge, + t_empty, + t_row_query, + t_hit_test, + ): + print(f" {fn.__name__}") + fn() + for path in sys.argv[1:]: + if os.path.exists(path): + t_corpus(path) print(f"\n{CHECKS} checks, {len(FAILED)} failed") for f in FAILED: print(f" - {f}") diff --git a/tests/test_index.py b/tests/test_index.py index f6761b1..5850821 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -15,8 +15,13 @@ import tempfile import time sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from idatui.index import (KIND_EXPORT, KIND_FUNC, KIND_IMPORT, # noqa: E402 - KIND_STRING, ProjectIndex) +from idatui.index import ( # noqa: E402 + KIND_EXPORT, + KIND_FUNC, + KIND_IMPORT, + KIND_STRING, + ProjectIndex, +) PASS = FAIL = 0 @@ -41,70 +46,106 @@ def main() -> int: check("a fresh index is empty", idx.total() == 0 and idx.counts() == {}) check("an unindexed binary is stale", idx.is_stale("libfoo", src)) - n = idx.reindex("libfoo", [ - (KIND_FUNC, 0x1000, "SSL_CTX_new"), - (KIND_FUNC, 0x1100, "SSL_read"), - (KIND_FUNC, 0x1200, "sub_1200"), - (KIND_STRING, 0x8000, "error opening socket"), - (KIND_STRING, 0x8100, "/etc/ssl/certs"), - ], source=src) + n = idx.reindex( + "libfoo", + [ + (KIND_FUNC, 0x1000, "SSL_CTX_new"), + (KIND_FUNC, 0x1100, "SSL_read"), + (KIND_FUNC, 0x1200, "sub_1200"), + (KIND_STRING, 0x8000, "error opening socket"), + (KIND_STRING, 0x8100, "/etc/ssl/certs"), + ], + source=src, + ) check("reindex reports what it stored", n == 5, f"n={n}") check("the entries are there", idx.total() == 5, f"{idx.total()}") check("an indexed binary is fresh", not idx.is_stale("libfoo", src)) # -- substring search (the thing a prefix index can't do) ----------- # hits = idx.search("SSL", kind=KIND_FUNC) - check("finds symbols by substring", {h.text for h in hits} == - {"SSL_CTX_new", "SSL_read"}, f"{[h.text for h in hits]}") - check("matching ignores case across kinds (SSL also hits /etc/ssl)", - {h.text for h in idx.search("SSL")} == - {"SSL_CTX_new", "SSL_read", "/etc/ssl/certs"}, - f"{[h.text for h in idx.search('SSL')]}") + check( + "finds symbols by substring", + {h.text for h in hits} == {"SSL_CTX_new", "SSL_read"}, + f"{[h.text for h in hits]}", + ) + check( + "matching ignores case across kinds (SSL also hits /etc/ssl)", + {h.text for h in idx.search("SSL")} + == {"SSL_CTX_new", "SSL_read", "/etc/ssl/certs"}, + f"{[h.text for h in idx.search('SSL')]}", + ) hits = idx.search("socket") - check("finds strings by substring mid-text", - len(hits) == 1 and hits[0].kind == KIND_STRING - and hits[0].addr == 0x8000, f"{hits}") - check("hits carry the owning binary", - all(h.binary == "libfoo" for h in idx.search("SSL"))) - check("search is case-insensitive", - {h.text for h in idx.search("ssl_read")} == {"SSL_read"}, - f"{[h.text for h in idx.search('ssl_read')]}") - check("kind filter narrows to strings", - [h.text for h in idx.search("ss", kind=KIND_STRING)] == ["/etc/ssl/certs"], - f"{[h.text for h in idx.search('ss', kind=KIND_STRING)]}") + check( + "finds strings by substring mid-text", + len(hits) == 1 and hits[0].kind == KIND_STRING and hits[0].addr == 0x8000, + f"{hits}", + ) + check( + "hits carry the owning binary", + all(h.binary == "libfoo" for h in idx.search("SSL")), + ) + check( + "search is case-insensitive", + {h.text for h in idx.search("ssl_read")} == {"SSL_read"}, + f"{[h.text for h in idx.search('ssl_read')]}", + ) + check( + "kind filter narrows to strings", + [h.text for h in idx.search("ss", kind=KIND_STRING)] == ["/etc/ssl/certs"], + f"{[h.text for h in idx.search('ss', kind=KIND_STRING)]}", + ) # -- the <3 char fallback (trigram silently matches nothing) -------- # - check("2-char query still works (LIKE fallback)", - {h.text for h in idx.search("ss")} == {"SSL_CTX_new", "SSL_read", - "/etc/ssl/certs"}, - f"{[h.text for h in idx.search('ss')]}") - check("1-char query still works", - len(idx.search("/")) == 1, f"{idx.search('/')}") + check( + "2-char query still works (LIKE fallback)", + {h.text for h in idx.search("ss")} + == {"SSL_CTX_new", "SSL_read", "/etc/ssl/certs"}, + f"{[h.text for h in idx.search('ss')]}", + ) + check( + "1-char query still works", len(idx.search("/")) == 1, f"{idx.search('/')}" + ) check("an empty query matches nothing", idx.search(" ") == []) - check("a query with FTS operators is treated literally", - idx.search('SSL OR "') == [] or True) # must not raise + check( + "a query with FTS operators is treated literally", + idx.search('SSL OR "') == [] or True, + ) # must not raise # -- multi-binary: the whole point ---------------------------------- # - idx.reindex("httpd", [ - (KIND_FUNC, 0x2000, "handle_ssl_request"), - (KIND_STRING, 0x9000, "socket bind failed"), - ]) + idx.reindex( + "httpd", + [ + (KIND_FUNC, 0x2000, "handle_ssl_request"), + (KIND_STRING, 0x9000, "socket bind failed"), + ], + ) hits = idx.search("ssl") - check("search spans binaries", - {h.binary for h in hits} == {"libfoo", "httpd"}, - f"{[(h.binary, h.text) for h in hits]}") - check("counts are per binary", - idx.counts() == {"libfoo": 5, "httpd": 2}, f"{idx.counts()}") + check( + "search spans binaries", + {h.binary for h in hits} == {"libfoo", "httpd"}, + f"{[(h.binary, h.text) for h in hits]}", + ) + check( + "counts are per binary", + idx.counts() == {"libfoo": 5, "httpd": 2}, + f"{idx.counts()}", + ) # -- incremental: reindexing one binary leaves the others alone ----- # idx.reindex("libfoo", [(KIND_FUNC, 0x1000, "SSL_CTX_new_v2")], source=src) - check("reindex replaces only that binary's entries", - idx.counts() == {"libfoo": 1, "httpd": 2}, f"{idx.counts()}") - check("the stale entries are gone", - [h.text for h in idx.search("SSL_read")] == [], - f"{idx.search('SSL_read')}") - check("the other binary survived untouched", - len(idx.search("socket bind")) == 1) + check( + "reindex replaces only that binary's entries", + idx.counts() == {"libfoo": 1, "httpd": 2}, + f"{idx.counts()}", + ) + check( + "the stale entries are gone", + [h.text for h in idx.search("SSL_read")] == [], + f"{idx.search('SSL_read')}", + ) + check( + "the other binary survived untouched", len(idx.search("socket bind")) == 1 + ) # -- staleness follows the source ----------------------------------- # time.sleep(0.01) @@ -112,90 +153,128 @@ def main() -> int: f.write(b"\x7fELF binary rebuilt, different size") os.utime(src, (1, 1)) check("a changed source goes stale", idx.is_stale("libfoo", src)) - check("a missing source does NOT wipe the index", - not idx.is_stale("libfoo", os.path.join(tmp, "gone"))) + check( + "a missing source does NOT wipe the index", + not idx.is_stale("libfoo", os.path.join(tmp, "gone")), + ) # -- forget ----------------------------------------------------------- # idx.forget("httpd") - check("forget drops a binary entirely", - idx.counts() == {"libfoo": 1} and idx.search("socket bind") == [], - f"{idx.counts()}") + check( + "forget drops a binary entirely", + idx.counts() == {"libfoo": 1} and idx.search("socket bind") == [], + f"{idx.counts()}", + ) # -- cross-binary linkage join (phase 3) ------------------------------ # - idx.reindex("app", [ - (KIND_FUNC, 0x1000, "main"), - (KIND_IMPORT, 0x2000, "strcmp"), - (KIND_IMPORT, 0x2008, "read"), - (KIND_IMPORT, 0x2010, "SSL_new"), - ]) - idx.reindex("libc", [ - (KIND_EXPORT, 0x8000, "strcmp"), - (KIND_EXPORT, 0x8100, "read"), - (KIND_EXPORT, 0x8200, "pread"), - (KIND_EXPORT, 0x8300, "read_line"), - (KIND_FUNC, 0x8000, "strcmp"), - ]) + idx.reindex( + "app", + [ + (KIND_FUNC, 0x1000, "main"), + (KIND_IMPORT, 0x2000, "strcmp"), + (KIND_IMPORT, 0x2008, "read"), + (KIND_IMPORT, 0x2010, "SSL_new"), + ], + ) + idx.reindex( + "libc", + [ + (KIND_EXPORT, 0x8000, "strcmp"), + (KIND_EXPORT, 0x8100, "read"), + (KIND_EXPORT, 0x8200, "pread"), + (KIND_EXPORT, 0x8300, "read_line"), + (KIND_FUNC, 0x8000, "strcmp"), + ], + ) idx.reindex("libssl", [(KIND_EXPORT, 0x9000, "SSL_new")]) prov = idx.providers("strcmp", exclude="app") - check("an import resolves to the binary that exports it", - [(h.binary, h.addr) for h in prov] == [("libc", 0x8000)], - f"{[(h.binary, hex(h.addr)) for h in prov]}") + check( + "an import resolves to the binary that exports it", + [(h.binary, h.addr) for h in prov] == [("libc", 0x8000)], + f"{[(h.binary, hex(h.addr)) for h in prov]}", + ) # The whole point of exact(): substring search would drag in pread, # read_line and thread_start, and 'read' is also below the trigram floor # for some engines — an import must bind to its exact name or nothing. prov = idx.providers("read", exclude="app") - check("the join is exact, not substring", - [(h.binary, h.addr) for h in prov] == [("libc", 0x8100)], - f"{[(h.binary, h.text) for h in prov]}") + check( + "the join is exact, not substring", + [(h.binary, h.addr) for h in prov] == [("libc", 0x8100)], + f"{[(h.binary, h.text) for h in prov]}", + ) - check("a short name still resolves (below the trigram floor)", - [h.binary for h in idx.providers("SSL_new", exclude="app")] == ["libssl"], - f"{idx.providers('SSL_new')}") + check( + "a short name still resolves (below the trigram floor)", + [h.binary for h in idx.providers("SSL_new", exclude="app")] == ["libssl"], + f"{idx.providers('SSL_new')}", + ) - check("an unprovided import resolves to nothing", - idx.providers("dlopen", exclude="app") == []) + check( + "an unprovided import resolves to nothing", + idx.providers("dlopen", exclude="app") == [], + ) - check("exclude keeps a binary from resolving to itself", - idx.providers("strcmp", exclude="libc") == [], - f"{idx.providers('strcmp', exclude='libc')}") + check( + "exclude keeps a binary from resolving to itself", + idx.providers("strcmp", exclude="libc") == [], + f"{idx.providers('strcmp', exclude='libc')}", + ) imp = idx.importers("strcmp") - check("the reverse join finds who imports an export", - [(h.binary, h.addr) for h in imp] == [("app", 0x2000)], - f"{[(h.binary, hex(h.addr)) for h in imp]}") + check( + "the reverse join finds who imports an export", + [(h.binary, h.addr) for h in imp] == [("app", 0x2000)], + f"{[(h.binary, hex(h.addr)) for h in imp]}", + ) - check("kind keeps functions out of the linkage join", - [h.binary for h in idx.providers("strcmp")] == ["libc"], - "a KIND_FUNC row named strcmp must not answer as an export") + check( + "kind keeps functions out of the linkage join", + [h.binary for h in idx.providers("strcmp")] == ["libc"], + "a KIND_FUNC row named strcmp must not answer as an export", + ) idx.forget("libc") - check("forgetting a provider unresolves its imports", - idx.providers("strcmp", exclude="app") == []) + check( + "forgetting a provider unresolves its imports", + idx.providers("strcmp", exclude="app") == [], + ) # -- ELF symbol versioning -------------------------------------------- # # The importer sees strrchr@@GLIBC_2.2.5 while the provider may export a # different spelling; raw names would resolve almost nothing. link_name # cuts at the first '@' so both sides meet on the bare symbol. from idatui.domain import link_name - check("link_name strips an ELF version suffix", - link_name("strrchr@@GLIBC_2.2.5") == "strrchr", - link_name("strrchr@@GLIBC_2.2.5")) - check("link_name leaves an unversioned name alone", - link_name("strrchr") == "strrchr") - check("link_name handles a single-@ version", - link_name("SSL_new@OPENSSL_3.0.0") == "SSL_new") - check("link_name doesn't eat a leading @", - link_name("@weird") == "@weird", link_name("@weird")) + + check( + "link_name strips an ELF version suffix", + link_name("strrchr@@GLIBC_2.2.5") == "strrchr", + link_name("strrchr@@GLIBC_2.2.5"), + ) + check( + "link_name leaves an unversioned name alone", + link_name("strrchr") == "strrchr", + ) + check( + "link_name handles a single-@ version", + link_name("SSL_new@OPENSSL_3.0.0") == "SSL_new", + ) + check( + "link_name doesn't eat a leading @", + link_name("@weird") == "@weird", + link_name("@weird"), + ) # -- persistence ------------------------------------------------------ # path = idx.path idx.close() idx2 = ProjectIndex(path) - check("the index persists across sessions", - [h.text for h in idx2.search("SSL_CTX")] == ["SSL_CTX_new_v2"], - f"{idx2.search('SSL_CTX')}") + check( + "the index persists across sessions", + [h.text for h in idx2.search("SSL_CTX")] == ["SSL_CTX_new_v2"], + f"{idx2.search('SSL_CTX')}", + ) idx2.close() print(f"\n{PASS} passed, {FAIL} failed") diff --git a/tests/test_kittygfx.py b/tests/test_kittygfx.py new file mode 100644 index 0000000..270b4e7 --- /dev/null +++ b/tests/test_kittygfx.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Kitty graphics escapes: what we actually send to the terminal (no IDA). + +The splash re-anchors itself on every progress note, so the escape it sends has +to be a REPLACEMENT, not another copy. That is one key (``p``) and it is +invisible in every screenshot, which is exactly why it needs a test. + +Also the cross-platform contract: graphics are optional everywhere, so a +missing ``termios`` (native Windows) or a failing terminal probe must disable +the splash, never prevent TUI startup. +""" + +#: pure stdlib escape-construction checks; no IDA, no Textual. +#: Read by tests/run.py (--fast skips every NEEDS_IDA file). +NEEDS_IDA = False +import builtins +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from idatui import kittygfx # noqa: E402 + +PASS = FAIL = 0 + + +def check(name, ok, detail=""): + global PASS, FAIL + if ok: + PASS += 1 + print(f" ok {name}") + else: + FAIL += 1 + print(f" FAIL {name} {detail}") + + +class Tty: + """Capture what kittygfx writes, in place of the real stdout.""" + + def __init__(self): + self.sent = [] + self._real = kittygfx._write + + def __enter__(self): + kittygfx._write = lambda data: (self.sent.append(data), True)[1] + return self + + def __exit__(self, *exc): + kittygfx._write = self._real + + @property + def blob(self): + return "".join(self.sent) + + def cmds(self, action): + """Every graphics command with the given ``a=`` action.""" + return [ + c + for c in re.findall(r"\x1b_G([^;\x1b]*)", self.blob) + if f"a={action}" in c.split(",") + ] + + +def keys(cmd): + return dict(kv.split("=", 1) for kv in cmd.split(",") if "=" in kv) + + +def t_no_termios_falls_back(): + """Native Windows has no termios; the splash must simply use ANSI art.""" + original_import = builtins.__import__ + + def without_termios(name, *args, **kwargs): + if name == "termios": + raise ModuleNotFoundError("No module named 'termios'") + return original_import(name, *args, **kwargs) + + builtins.__import__ = without_termios + try: + check("missing termios disables graphics", kittygfx._query_tty(0) is False) + except Exception as exc: # the original Windows startup crash + check("missing termios does not escape", False, f"{type(exc).__name__}: {exc}") + finally: + builtins.__import__ = original_import + + +def t_probe_failure_is_never_fatal(): + """Even an unexpected platform/probe error cannot prevent TUI startup.""" + original_query = kittygfx._query_tty + original_stdout = sys.__stdout__ + original_supported = kittygfx._supported + old_env = os.environ.pop("IDATUI_KITTY", None) + + class FakeTty: + def isatty(self): + return True + + def broken_query(): + raise RuntimeError("terminal API failed") + + try: + sys.__stdout__ = FakeTty() + kittygfx._query_tty = broken_query + kittygfx._supported = None + check("probe exception disables graphics", kittygfx.supported() is False) + check("failed result is cached", kittygfx.supported() is False) + except Exception as exc: + check("probe exception does not escape", False, f"{type(exc).__name__}: {exc}") + finally: + kittygfx._query_tty = original_query + kittygfx._supported = original_supported + sys.__stdout__ = original_stdout + if old_env is not None: + os.environ["IDATUI_KITTY"] = old_env + + +def main() -> int: + # -- graphics stay optional on every platform ---------------------------- # + t_no_termios_falls_back() + t_probe_failure_is_never_fatal() + + kittygfx._uploaded[kittygfx.LOGO_ID] = (768, 801) # pretend it's uploaded + + # -- the bug: anonymous placements STACK ------------------------------- # + # A placement is identified by (image id, placement id). With no p key + # every place() adds another copy at the same cell: a long load left the + # terminal compositing hundreds of copies of an RGBA image over itself. + with Tty() as tty: + for _ in range(50): + kittygfx.place(4, 10, 60, 26) + placements = tty.cmds("p") + check( + "place() emits one command per call", + len(placements) == 50, + f"{len(placements)}", + ) + check( + "every placement carries a placement id (replaces, not stacks)", + all("p" in keys(c) for c in placements), + f"{placements[0] if placements else '(none)'}", + ) + check( + "the placement id is the same every time (one image on screen)", + len({keys(c)["p"] for c in placements}) == 1, + f"{sorted({keys(c).get('p') for c in placements})}", + ) + check( + "...and it is non-zero (p=0 means anonymous)", + keys(placements[0])["p"] not in ("0", ""), + f"{placements[0]}", + ) + + # -- the rest of the escape still says what it used to ------------------ # + with Tty() as tty: + ok = kittygfx.place(4, 10, 60, 26) + k = keys(tty.cmds("p")[0]) + check("place() reports success", ok) + check( + "image id, source pixels and cell box are unchanged", + (k["i"], k["s"], k["v"], k["c"], k["r"]) + == (str(kittygfx.LOGO_ID), "768", "801", "60", "26"), + f"{k}", + ) + check( + "the terminal is told not to move the cursor (C=1)", + k.get("C") == "1", + f"{k}", + ) + check( + "the cursor is saved and restored around the placement", + tty.blob.startswith("\x1b[s") and tty.blob.endswith("\x1b[u"), + repr(tty.blob[:8] + "..." + tty.blob[-8:]), + ) + check( + "the placement is positioned 1-based (row 4 -> line 5)", + "\x1b[5;11H" in tty.blob, + repr(tty.blob[:24]), + ) + + # -- deleting still removes EVERY placement of the image ---------------- # + # d=i is by image id, so it takes the placement with us regardless of p. + with Tty() as tty: + kittygfx.clear() + k = keys(tty.cmds("d")[0]) + check( + "clear() deletes by image id (d=i), keeping the upload", + k.get("d") == "i" and k.get("i") == str(kittygfx.LOGO_ID), + f"{k}", + ) + check( + "clear() does not free the image data (lowercase d)", + kittygfx.is_uploaded(), + "upload was dropped", + ) + + with Tty() as tty: + kittygfx.delete() + k = keys(tty.cmds("d")[0]) + check("delete() frees the image data too (d=I)", k.get("d") == "I", f"{k}") + check( + "...and forgets the upload, so the next place() refuses", + not kittygfx.is_uploaded() and kittygfx.place(0, 0, 10, 10) is False, + ) + + # -- refusals ----------------------------------------------------------- # + kittygfx._uploaded[kittygfx.LOGO_ID] = (768, 801) + with Tty() as tty: + check( + "a zero-sized box is refused, not sent", + kittygfx.place(0, 0, 0, 10) is False + and kittygfx.place(0, 0, 10, 0) is False + and not tty.sent, + f"{tty.sent}", + ) + kittygfx._uploaded.pop(kittygfx.LOGO_ID, None) + + # -- fit(): aspect ratio against non-square cells ----------------------- # + check( + "fit() keeps the aspect ratio for 9x22 cells", + kittygfx.fit((768, 801), 60, 99, cell=(9, 22)) == (60, 26), + f"{kittygfx.fit((768, 801), 60, 99, cell=(9, 22))}", + ) + check( + "fit() shrinks to the row budget instead of overflowing", + kittygfx.fit((768, 801), 60, 10, cell=(9, 22))[1] == 10, + f"{kittygfx.fit((768, 801), 60, 10, cell=(9, 22))}", + ) + check( + "fit() never returns a zero dimension", + all(v >= 1 for v in kittygfx.fit((768, 801), 1, 1, cell=(9, 22))), + ) + check( + "fit() survives a degenerate image size", + kittygfx.fit((0, 0), 60, 26) == (60, 26), + ) + + # -- png_size() reads the header, not the pixels ------------------------ # + logo = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logo.png" + ) + if os.path.exists(logo): + check( + "png_size() reads logo.png's IHDR", + kittygfx.png_size(logo) == (768, 801), + f"{kittygfx.png_size(logo)}", + ) + check("png_size() returns None for a non-PNG", kittygfx.png_size(__file__) is None) + check( + "png_size() returns None for a missing file", + kittygfx.png_size("/nonexistent/nope.png") is None, + ) + + print(f"\n{PASS} passed, {FAIL} failed") + return 1 if FAIL else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_launch.py b/tests/test_launch.py index d57ee5f..5c05d08 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -3,13 +3,14 @@ The old `_sweep_locks` deleted `.id0/.id1/.id2/.nam/.til` next to the user's binary when a database failed to open. That was only defensible while the TUI -exclusively owned a private worker; under Code Mode a GUI or another client may +exclusively owned a private worker; under IDA Nexus a GUI or another client may own the database, so the sweep is gone. Its tests are replaced by one that keeps it gone -- deleting a shared database's working files is unrecoverable, and this is the cheapest guard against someone reintroducing the "helpful" cleanup. -Pure: no IDA, no Code Mode library, no Textual. +Pure: no IDA, no IDA Nexus library, no Textual. """ + from __future__ import annotations import os @@ -47,15 +48,17 @@ def touch(*paths): def t_no_lock_sweeping(): """The launcher must not delete database working files any more. - Code Mode's registry locks, health probes and IDA itself arbitrate database + IDA Nexus's registry locks, health probes and IDA itself arbitrate database ownership now. A sweep here would delete files out from under a live GUI. """ check("_sweep_locks is gone", not hasattr(launch, "_sweep_locks")) check("the scratch-suffix list is gone", not hasattr(launch, "_LOCK_SUFFIXES")) src = open(launch.__file__, encoding="utf-8").read() - check("the launcher does not remove files at all", - "os.remove" not in src and "shutil.rmtree" not in src, - "launch.py deletes something again") + check( + "the launcher does not remove files at all", + "os.remove" not in src and "shutil.rmtree" not in src, + "launch.py deletes something again", + ) def t_load_args(): @@ -66,10 +69,16 @@ def t_load_args(): # -b is in PARAGRAPHS, not bytes: 0x8000 >> 4 == 0x800. check("a base is converted to paragraphs", "-b800" in a, a) b = _load_args({"base": "0x1000"}) - check("a base given as a hex STRING is accepted (project files write those)", - "-b100" in b, b) - check("no base means no -b switch", "-b" not in _load_args({"processor": "arm"}), - _load_args({"processor": "arm"})) + check( + "a base given as a hex STRING is accepted (project files write those)", + "-b100" in b, + b, + ) + check( + "no base means no -b switch", + "-b" not in _load_args({"processor": "arm"}), + _load_args({"processor": "arm"}), + ) c = _load_args({"ida_args": "-p1"}) check("extra ida_args are passed through", "-p1" in c, c) @@ -81,6 +90,7 @@ def main() -> int: fn() except Exception as e: # noqa: BLE001 import traceback + check(f"{fn.__name__} did not crash", False, f"{type(e).__name__}: {e}") traceback.print_exc() print(f"\n{PASS} passed, {FAIL} failed") diff --git a/tests/test_nexus_client.py b/tests/test_nexus_client.py new file mode 100644 index 0000000..834a8d6 --- /dev/null +++ b/tests/test_nexus_client.py @@ -0,0 +1,499 @@ +"""IDA-free contract tests for the IDA Nexus client adapter.""" + +from __future__ import annotations + +import os +import queue +import sys +import tempfile +import threading +import time +from dataclasses import dataclass + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import idatui.nexus_client as module # noqa: E402 +from idatui import remote_ops # noqa: E402 +from idatui.errors import IDAConnectionError, IDAToolError # noqa: E402 +from idatui.nexus_client import NexusClient, _parse_load_args # noqa: E402 + +#: Pure: fakes the DatabaseHandle, never touches IDA or the IDA Nexus library. +NEEDS_IDA = False + +PASS = FAIL = 0 + + +def check(name: str, condition: bool, detail="") -> None: + global PASS, FAIL + if condition: + PASS += 1 + print(f" ok {name}") + else: + FAIL += 1 + print(f" FAIL {name} {detail}") + + +@dataclass(frozen=True) +class FakeEntry: + pid: int = 123 + backend: str = "gui" + record_id: str = "123-abcdef" + exe_path: str = "" + idb_path: str = "" + managed: bool = False + + +_CLOSED = object() + + +class FakeSubscription: + def __init__(self) -> None: + self._queue: queue.Queue = queue.Queue() + self.closed = False + + def __iter__(self): + return self + + def __next__(self): + item = self._queue.get() + if isinstance(item, BaseException): + raise item + if item is _CLOSED: + raise StopIteration + return item + + def emit(self, event: dict) -> None: + self._queue.put(event) + + def close(self) -> None: + if not self.closed: + self.closed = True + self._queue.put(_CLOSED) + + +class FakeHandle: + def __init__(self, path: str) -> None: + self.connected = True + self.instance = FakeEntry(exe_path=path, idb_path=path + ".i64") + self.waited = None + self.saved = 0 + self.closed = False + self.code = "" + self.codes = [] + self.code_timeout = None + self.operation_label = None + self.event_origin_id = "fake-handle-origin" + self.owns_checks = 0 + self.subscription = FakeSubscription() + self.shutdown_calls = [] + self.shutdown_error = None + + def wait_autoanalysis(self, timeout=None): + self.waited = timeout + return {"complete": True, "status": "complete"} + + def execute_python( + self, + code, + timeout=None, + *, + operation_id=None, + operation_label=None, + persist_globals=False, + filename=None, + ): + self.code = code + self.codes.append(code) + self.code_timeout = timeout + self.operation_label = operation_label + result = ( + { + "__remote_ida_status__": "ok", + "__remote_ida_value__": {"sentinel": 7}, + } + if ".modules.get(" in code + else True + ) + return {"result": result, "stdout": "", "stderr": ""} + + def subscribe_idb_events(self): + return self.subscription + + def owns_event(self, event): + self.owns_checks += 1 + return event.get("origin_id") == self.event_origin_id + + def save_database(self): + self.saved += 1 + return {"saved": True, "idb_path": self.instance.idb_path} + + def shutdown_database(self, *, save=True): + self.shutdown_calls.append(save) + if self.shutdown_error is not None: + raise module.RemoteError(self.shutdown_error, self.shutdown_error, 409) + return {"shutting_down": True, "save": save} + + def close(self): + self.subscription.close() + self.connected = False + self.closed = True + + +class FakeDatabaseHandle: + opened = None + opens = 0 + kwargs = None + + @classmethod + def open(cls, path, **kwargs): + cls.opens += 1 + cls.opened = path + cls.kwargs = kwargs + return FakeHandle(path) + + +@dataclass(frozen=True) +class FakeOpenOptions: + """Stand-in for DatabaseOpenOptions when the library is not installed. + + Deliberately STRICT (no **kwargs): an option the adapter invents would + raise here, and `_option_fields_are_real` checks the surviving names + against the real dataclass wherever it is importable. + """ + + spawn: bool = True + startup_timeout: float = 120.0 + output_database: str | None = None + processor: str | None = None + image_base: int | None = None + file_type: str | None = None + new_database: bool = False + + +class FakeBusy(Exception): + """Stand-in for DatabaseBusyError: `except None` is a TypeError.""" + + +class FakeDisconnected(Exception): + """Stand-in for DatabaseDisconnectedError in stdlib-only runs.""" + + +class FakeRemoteError(Exception): + """Stand-in for RemoteError: (code, message, status, details).""" + + def __init__(self, code, message, status=500, details=None): + super().__init__(message) + self.code = code + self.status = status + self.details = details or {} + + +class FakeRemoteModule: + """Stand-in for ida_nexus.RemoteModule in stdlib-only runs. + + Speaks the same two-step wire contract FakeHandle.execute_python answers: + install the module's real source once, then send each call as a snippet + that looks the function up in the installed module registry (the + ``.modules.get(`` marker the fake keys on), carrying the operation label. + """ + + def __init__(self, path, *, operation_label=None, codec="json"): + with open(path) as file: + self._source = file.read() + self._label = operation_label + self._installed = False + + def function(self, declaration, timeout=None): + name = getattr(declaration, "__name__", str(declaration)) + + def remote(handle, **args): + label = self._label() if callable(self._label) else self._label + if not self._installed: + handle.execute_python(self._source, operation_label=label) + self._installed = True + response = handle.execute_python( + f"__mod = __registry.modules.get(...) # call {name}", + operation_label=label, + ) + result = response["result"] + if isinstance(result, dict) and result.get("__remote_ida_status__") == "ok": + return result.get("__remote_ida_value__") + raise module.RemoteError(name, f"remote call failed: {result!r}", 500) + + return remote + + +def _open_kwargs_are_real(sent: dict): + """(ok, detail) for the kwargs the adapter passes to DatabaseHandle.open. + + Skips (passes) when ida_nexus is not installed, so the file stays pure. + """ + try: + import inspect + + from ida_nexus import DatabaseHandle as Real + except ImportError: + return True, "ida_nexus not installed - signature not checked" + accepted = set(inspect.signature(Real.open).parameters) + unknown = sorted(set(sent) - accepted) + return not unknown, f"open() rejects {unknown}" + + +def _option_fields_are_real(options): + """(ok, detail) for the option names the adapter fills in. + + The open() signature no longer names the loader options -- they moved + inside DatabaseOpenOptions -- so the `loading_address` class of bug now + hides there instead. Check it in the same way. + """ + try: + import dataclasses + + from ida_nexus import DatabaseOpenOptions as Real + except ImportError: + return True, "ida_nexus not installed - fields not checked" + accepted = {field.name for field in dataclasses.fields(Real)} + unknown = sorted({f.name for f in dataclasses.fields(options)} - accepted) + return not unknown, f"DatabaseOpenOptions rejects {unknown}" + + +def main() -> int: + proc, base, file_type = _parse_load_args("-parm:ARMv7-M -b800000 -TRaw") + check( + "legacy switches map to typed IDA Nexus options", + (proc, base, file_type) == ("arm:ARMv7-M", 0x8000000, "Raw"), + (proc, base, file_type), + ) + try: + _parse_load_args("-parm -zcustom") + except ValueError as exc: + check("arbitrary IDA switches fail loudly", "cannot represent" in str(exc), exc) + else: + check("arbitrary IDA switches fail loudly", False) + + original = module.DatabaseHandle + module.DatabaseHandle = FakeDatabaseHandle + # The library's own names when it is installed; strict fakes when it is not + # (this file must keep running under a stdlib-only python3). + original_options = module.DatabaseOpenOptions + original_busy = module.DatabaseBusyError + original_disconnected = module.DatabaseDisconnectedError + original_remote_error = module.RemoteError + module.DatabaseOpenOptions = original_options or FakeOpenOptions + module.DatabaseBusyError = original_busy or FakeBusy + module.DatabaseDisconnectedError = original_disconnected or FakeDisconnected + module.RemoteError = original_remote_error or FakeRemoteError + # The binding seam in remote_ops, same rule: the real RemoteModule when the + # library is installed, this file's fake otherwise -- and the lazy binding + # cache reset around it so this run binds through whichever is active. + original_remote_module = remote_ops.RemoteModule + remote_ops.RemoteModule = original_remote_module or FakeRemoteModule + remote_ops._BOUND = None + try: + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "sample.bin") + with open(path, "wb") as file: + file.write(b"sample") + client = NexusClient(path, load_args="-parm:ARMv7-A -b100") + notes = [] + client.connect(timeout=42, progress=notes.append) + handle = client._handle + check( + "connect delegates database discovery to DatabaseHandle.open", + FakeDatabaseHandle.opened == path and handle is not None, + ) + options = FakeDatabaseHandle.kwargs["options"] + check( + "typed loader options cross the dependency boundary", + options.processor == "arm:ARMv7-A" and options.image_base == 0x1000, + options, + ) + check( + "every open option exists in the real library", + *_option_fields_are_real(options), + ) + # A fake that swallows **kwargs cannot catch a keyword the real + # library does not have -- which is exactly how this port shipped + # `loading_address` (the real name is `image_base`) and would have + # raised TypeError on the very first connect. Check the names we + # send against the real signature whenever it is importable. + check( + "every open() keyword exists in the real library", + *_open_kwargs_are_real(FakeDatabaseHandle.kwargs), + ) + check( + "connect waits for IDA Nexus autoanalysis", + handle.waited == 42, + getattr(handle, "waited", None), + ) + check( + "progress distinguishes discovery and backend attachment", + len(notes) == 2 and "gui" in notes[-1], + notes, + ) + result = client.call( + remote_ops.list_funcs, queries=[{"offset": 0, "count": 2}] + ) + check( + "remote operation returns its JSON result", + result == {"sentinel": 7}, + result, + ) + check( + "operation source is real Python installed through ida-domain", + any("db.functions.get_all()" in code for code in handle.codes), + handle.codes[0][:200], + ) + check( + "remote operations attribute IDB events to IDA TUI", + handle.operation_label == "IDA TUI", + handle.operation_label, + ) + batches = [] + delivered = threading.Event() + + def changed(batch): + batches.append(batch) + delivered.set() + + watcher = client.watch_idb_events(changed, debounce=0.05) + handle.subscription.emit({"event_name": "renamed", "origin_id": "peer-1"}) + handle.subscription.emit( + {"event_name": "cmt_changed", "origin_id": "peer-2"} + ) + check( + "event bursts produce one debounced refresh", + delivered.wait(1) and len(batches) == 1 and len(batches[0]) == 2, + batches, + ) + delivered.clear() + handle.subscription.emit( + {"event_name": "renamed", "origin_id": handle.event_origin_id} + ) + time.sleep(0.1) + check( + "the listener uses handle ownership to ignore its own events", + not delivered.is_set() + and len(batches) == 1 + and handle.owns_checks >= 3, + (batches, handle.owns_checks), + ) + handle.subscription.emit( + {"event_name": "byte_patched", "origin_id": "peer-3"} + ) + watcher.close() + time.sleep(0.1) + check( + "closing drops a pending debounced refresh", + not delivered.is_set() and len(batches) == 1, + batches, + ) + check( + "health exposes registry identity", + client.health()["record_id"] == "123-abcdef", + ) + client.save_database() + check("save uses the public IDA Nexus save route", handle.saved == 1) + check( + "GUI leases transfer rather than claiming discard", + client.discard_database() is False and handle.shutdown_calls == [], + handle.shutdown_calls, + ) + handle.instance = FakeEntry( + backend="idalib", managed=True, exe_path=path, idb_path=path + ".i64" + ) + check( + "a final managed lease discards without saving", + client.discard_database() is True and handle.shutdown_calls == [False], + handle.shutdown_calls, + ) + handle.shutdown_error = "instance_shared" + check( + "a shared managed lease transfers finalization", + client.discard_database() is False, + handle.shutdown_calls, + ) + handle.shutdown_error = "instance_busy" + try: + client.discard_database(timeout=0) + except IDAToolError as exc: + check( + "a busy final lease never silently saves", + exc.tool == "shutdown_database", + exc, + ) + else: + check("a busy final lease never silently saves", False) + handle.shutdown_error = None + handle.instance = FakeEntry(exe_path=path, idb_path=path + ".i64") + opens = FakeDatabaseHandle.opens + handle.connected = False + try: + client.health() + except IDAConnectionError as exc: + check( + "a disconnected handle requires explicit rediscovery", + "explicit rediscovery" in str(exc) + and FakeDatabaseHandle.opens == opens, + (exc, FakeDatabaseHandle.opens, opens), + ) + else: + check("a disconnected handle requires explicit rediscovery", False) + client.close() + check("close releases only the handle lease", handle.closed) + check( + "GUI lifetime is never claimed by the client", + client.wait_released(0) is False, + ) + disconnected = NexusClient(path).connect() + stream_errors = [] + stream_failed = threading.Event() + + def failed(error): + stream_errors.append(error) + stream_failed.set() + + stream_watch = disconnected.watch_idb_events( + lambda _batch: None, on_error=failed, debounce=0 + ) + disconnected._handle.subscription.emit( + module.DatabaseDisconnectedError("GUI database closed") + ) + check( + "stream disconnects become application connection errors", + stream_failed.wait(1) + and isinstance(stream_errors[0], IDAConnectionError), + stream_errors, + ) + stream_watch.close() + disconnected.close() + finally: + module.DatabaseHandle = original + module.DatabaseOpenOptions = original_options + module.DatabaseBusyError = original_busy + module.DatabaseDisconnectedError = original_disconnected + module.RemoteError = original_remote_error + + client = NexusClient(__file__) + + def unknown_operation(): + pass + + try: + client.call(unknown_operation) + except IDAToolError as exc: + check( + "unknown adapter operations are explicit", + exc.tool == "unknown_operation", + ) + else: + check("unknown adapter operations are explicit", False) + finally: + remote_ops.RemoteModule = original_remote_module + remote_ops._BOUND = None + + print(f"\n{PASS} passed, {FAIL} failed") + return 1 if FAIL else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_pool.py b/tests/test_pool.py index 9fc1446..203ce95 100644 --- a/tests/test_pool.py +++ b/tests/test_pool.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Unit tests for idatui.pool (Code Mode lease residency and LRU budget). +"""Unit tests for idatui.pool (IDA Nexus lease residency and LRU budget). A fake client keeps the policy testable without IDA or Textual. @@ -31,13 +31,15 @@ def check(name, cond, detail=""): class FakeClient: - """Stands in for a CodeModeClient lease and records saves/closes.""" + """Stands in for a NexusClient lease and records saves/closes.""" - def __init__(self, ref, mem=100, backend="idalib"): + def __init__(self, ref, mem=100, backend="idalib", discardable=True): self.ref = ref self.mem = mem self.backend = backend self.saved = 0 + self.discarded = 0 + self.discardable = discardable self.closed = False self.connected = False @@ -49,6 +51,10 @@ class FakeClient: self.saved += 1 return {"saved": True} + def discard_database(self): + self.discarded += 1 + return self.discardable + def close(self, grace=None): self.closed = True @@ -75,14 +81,18 @@ def main() -> int: made[ref.label] = c return c - pool = DatabasePool(proj, budget_mb=350, spawn=spawn, - mem_fn=lambda c: c.mem) + pool = DatabasePool(proj, budget_mb=350, spawn=spawn, mem_fn=lambda c: c.mem) # -- lazy spawn + reuse -------------------------------------------- # a = pool.get("bin0") - check("get() spawns a database lease on first use", a is made["bin0"] and a.connected) - check("get() stages the binary first", - os.path.isfile(proj.by_label("bin0").staged)) + check( + "get() spawns a database lease on first use", + a is made["bin0"] and a.connected, + ) + check( + "get() stages the binary first", + os.path.isfile(proj.by_label("bin0").staged), + ) check("get() reuses the resident lease", pool.get("bin0") is a) check("resident() reports it", pool.resident() == ["bin0"], pool.resident()) @@ -90,28 +100,40 @@ def main() -> int: pool.get("bin1") pool.get("bin2") pool.get("bin0") # touch: bin0 becomes most-recent - check("LRU order tracks use", pool.resident() == ["bin1", "bin2", "bin0"], - pool.resident()) + check( + "LRU order tracks use", + pool.resident() == ["bin1", "bin2", "bin0"], + pool.resident(), + ) # -- budget eviction -------------------------------------------------- # check("pool reports its memory", pool.memory_mb() == 300, pool.memory_mb()) pool.get("bin3") # 400MB > 350MB budget -> evict LRU (bin1) - check("exceeding the budget evicts the least-recently-used", - pool.evicted == ["bin1"] and not pool.is_resident("bin1"), - f"evicted={pool.evicted} resident={pool.resident()}") + check( + "exceeding the budget evicts the least-recently-used", + pool.evicted == ["bin1"] and not pool.is_resident("bin1"), + f"evicted={pool.evicted} resident={pool.resident()}", + ) check("the just-attached lease is never the victim", pool.is_resident("bin3")) check("eviction saves the database first", made["bin1"].saved == 1) check("eviction closes the lease", made["bin1"].closed) - check("pool is back within budget", pool.memory_mb() <= pool.budget_mb, - f"{pool.memory_mb()}/{pool.budget_mb}") + check( + "pool is back within budget", + pool.memory_mb() <= pool.budget_mb, + f"{pool.memory_mb()}/{pool.budget_mb}", + ) # -- the active binary is never evicted ------------------------------- # pool.set_active("bin2") - check("set_active touches the LRU", pool.resident()[-1] == "bin2", - pool.resident()) + check( + "set_active touches the LRU", pool.resident()[-1] == "bin2", pool.resident() + ) pool.get("bin1") # over budget again -> must evict, but not bin2 - check("the active binary survives eviction", pool.is_resident("bin2"), - f"resident={pool.resident()}") + check( + "the active binary survives eviction", + pool.is_resident("bin2"), + f"resident={pool.resident()}", + ) # -- pinning ---------------------------------------------------------- # pool.close_all() @@ -120,8 +142,11 @@ def main() -> int: pool2.pin("bin0") pool2.get("bin1") pool2.get("bin2") # 300 > 250 -> evict, but bin0 is pinned - check("pinned binaries are never evicted", pool2.is_resident("bin0"), - f"resident={pool2.resident()} evicted={pool2.evicted}") + check( + "pinned binaries are never evicted", + pool2.is_resident("bin0"), + f"resident={pool2.resident()} evicted={pool2.evicted}", + ) check("an unpinned one went instead", "bin1" in pool2.evicted, pool2.evicted) # -- everything pinned/active: stop evicting rather than thrash -------- # @@ -129,21 +154,30 @@ def main() -> int: pool2.set_active("bin2") n_before = len(pool2.evicted) pool2._enforce_budget() - check("nothing evictable -> gives up instead of thrashing", - len(pool2.evicted) == n_before, pool2.evicted) + check( + "nothing evictable -> gives up instead of thrashing", + len(pool2.evicted) == n_before, + pool2.evicted, + ) # -- status for the switcher UI ---------------------------------------- # st = {s["label"]: s for s in pool2.status()} check("status() covers every project binary", len(st) == 4, list(st)) - check("status() marks resident/pinned/active", - st["bin0"]["resident"] and st["bin0"]["pinned"] - and st["bin2"]["active"] and not st["bin3"]["resident"], - f"{st}") + check( + "status() marks resident/pinned/active", + st["bin0"]["resident"] + and st["bin0"]["pinned"] + and st["bin2"]["active"] + and not st["bin3"]["resident"], + f"{st}", + ) # -- teardown ----------------------------------------------------------- # pool2.close_all() - check("close_all() closes every lease", - not pool2.resident() and all(c.closed for c in made.values())) + check( + "close_all() closes every lease", + not pool2.resident() and all(c.closed for c in made.values()), + ) check("close_all() clears the active binary", pool2.active is None) # -- unknown label -------------------------------------------------------- # @@ -153,10 +187,49 @@ def main() -> int: except KeyError: check("an unknown label raises KeyError", True) + # -- discard delegates shared/GUI finalization ------------------------ # + discard_made = {} + + def spawn_discard(ref, ttl): + client = FakeClient(ref, discardable=ref.label != "bin1") + discard_made[ref.label] = client + return client + + discard_pool = DatabasePool(proj, spawn=spawn_discard, mem_fn=lambda c: c.mem) + discard_pool.get("bin0") + discard_pool.get("bin1") + delegated = discard_pool.discard_changes(["bin0", "bin1"]) + check( + "discard asks every dirty resident database", + discard_made["bin0"].discarded == 1 and discard_made["bin1"].discarded == 1, + {k: c.discarded for k, c in discard_made.items()}, + ) + check( + "discard reports leases whose finalization transferred", + delegated == ["bin1"], + delegated, + ) + old = discard_made["bin0"] + replacement = FakeClient(proj.by_label("bin0")) + check( + "replace_client refuses a stale lease generation", + discard_pool.replace_client("bin0", object(), replacement) is False + and discard_pool.get("bin0") is old, + ) + check( + "replace_client installs the reattached lease", + discard_pool.replace_client("bin0", old, replacement) is True + and discard_pool.get("bin0") is replacement, + ) + discard_pool.close_all(save=False) + # -- default budget comes from the project's memory_pct ------------------- # pool3 = DatabasePool(proj, spawn=spawn, mem_fn=lambda c: c.mem) - check("default budget is derived, not a fixed lease count", - pool3.budget_mb >= 256, pool3.budget_mb) + check( + "default budget is derived, not a fixed lease count", + pool3.budget_mb >= 256, + pool3.budget_mb, + ) # -- prewarm: speculative, and never at the cost of a real binary ------ # with tempfile.TemporaryDirectory() as tmp: @@ -168,24 +241,34 @@ def main() -> int: made2[ref.label] = c return c - pool = DatabasePool(proj, budget_mb=250, spawn=spawn2, - mem_fn=lambda c: c.mem) + pool = DatabasePool(proj, budget_mb=250, spawn=spawn2, mem_fn=lambda c: c.mem) labels = [r.label for r in proj.refs] a, b, c_ = labels[0], labels[1], labels[2] pool.get(a) pool.set_active(a) - check("prewarm warms a binary when the budget has room", - pool.prewarm(b) is True and b in pool.resident(), f"{pool.resident()}") - check("prewarm is a no-op for something already resident", - pool.prewarm(b) is False) + check( + "prewarm warms a binary when the budget has room", + pool.prewarm(b) is True and b in pool.resident(), + f"{pool.resident()}", + ) + check( + "prewarm is a no-op for something already resident", + pool.prewarm(b) is False, + ) # 2 x 100MB resident, estimate 100 more -> 300 > 250: must refuse - check("prewarm refuses rather than making room", - pool.prewarm(c_) is False and c_ not in pool.resident(), - f"resident={pool.resident()} mem={pool.memory_mb()}/{pool.budget_mb}") - check("refusing to prewarm evicts nothing", - set(pool.resident()) == {a, b}, f"{pool.resident()}") - check("prewarm ignores a label outside the project", - pool.prewarm("nope") is False) + check( + "prewarm refuses rather than making room", + pool.prewarm(c_) is False and c_ not in pool.resident(), + f"resident={pool.resident()} mem={pool.memory_mb()}/{pool.budget_mb}", + ) + check( + "refusing to prewarm evicts nothing", + set(pool.resident()) == {a, b}, + f"{pool.resident()}", + ) + check( + "prewarm ignores a label outside the project", pool.prewarm("nope") is False + ) # Budget eviction releases GUI leases but must not save somebody's open IDA # implicitly. An explicit save-and-close remains authoritative. @@ -202,12 +285,16 @@ def main() -> int: label = proj.refs[0].label pool.get(label) pool.evict(label) - check("LRU release does not implicitly save a GUI database", - made_gui[-1].saved == 0) + check( + "LRU release does not implicitly save a GUI database", + made_gui[-1].saved == 0, + ) pool.get(label) pool.close_all(save=True) - check("explicit close_all(save=True) does save a GUI database", - made_gui[-1].saved == 1) + check( + "explicit close_all(save=True) does save a GUI database", + made_gui[-1].saved == 1, + ) print(f"\n{PASS} passed, {FAIL} failed") return 1 if FAIL else 0 diff --git a/tests/test_project.py b/tests/test_project.py index 690f360..63d029e 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Unit tests for idatui.project (the multi-binary project model + staging). -IDA-free: exercises staging plus Code Mode ownership checks without opening a database. +IDA-free: exercises staging plus IDA Nexus ownership checks without opening a database. python tests/test_project.py """ @@ -15,7 +15,7 @@ import sys import tempfile sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from idatui.project import Project, ProjectError, SIDECAR_SUFFIX # noqa: E402 +from idatui.project import SIDECAR_SUFFIX, Project, ProjectError # noqa: E402 PASS = FAIL = 0 @@ -48,36 +48,48 @@ def main() -> int: proj = Project.create(pfile, [httpd, libauth], name="router-fw") check("create() writes the project file", os.path.isfile(pfile)) proj = Project.load(pfile) - check("load() round-trips name + binaries", - proj.name == "router-fw" and len(proj.refs) == 2, - f"name={proj.name} n={len(proj.refs)}") - check("labels default to the basename", - [r.label for r in proj.refs] == ["httpd", "libauth.so"], - f"{[r.label for r in proj.refs]}") + check( + "load() round-trips name + binaries", + proj.name == "router-fw" and len(proj.refs) == 2, + f"name={proj.name} n={len(proj.refs)}", + ) + check( + "labels default to the basename", + [r.label for r in proj.refs] == ["httpd", "libauth.so"], + f"{[r.label for r in proj.refs]}", + ) # -- layout -------------------------------------------------------- # - check("sidecar sits beside the project file", - proj.sidecar == os.path.join(tmp, "router-fw" + SIDECAR_SUFFIX), - proj.sidecar) + check( + "sidecar sits beside the project file", + proj.sidecar == os.path.join(tmp, "router-fw" + SIDECAR_SUFFIX), + proj.sidecar, + ) ref = proj.by_label("httpd") - check("staged path lives in the sidecar, not the source tree", - ref.staged.startswith(proj.bin_dir) and src not in ref.staged, - ref.staged) + check( + "staged path lives in the sidecar, not the source tree", + ref.staged.startswith(proj.bin_dir) and src not in ref.staged, + ref.staged, + ) check("db path hangs off the staged file", ref.db == ref.staged + ".i64") # -- staging: hardlink, freshness ---------------------------------- # check("a binary starts out stale (not yet staged)", proj.is_stale(ref)) proj.stage(ref) - check("stage() materialises the binary in the sidecar", - os.path.isfile(ref.staged)) - check("stage() copies (distinct inode) so the source can't be mutated " - "through it", - os.stat(ref.staged).st_ino != os.stat(ref.source).st_ino - and open(ref.staged, "rb").read() == open(ref.source, "rb").read()) + check( + "stage() materialises the binary in the sidecar", os.path.isfile(ref.staged) + ) + check( + "stage() copies (distinct inode) so the source can't be mutated through it", + os.stat(ref.staged).st_ino != os.stat(ref.source).st_ino + and open(ref.staged, "rb").read() == open(ref.source, "rb").read(), + ) check("a staged binary is no longer stale", not proj.is_stale(ref)) - check("source tree stays clean (no IDA artifacts beside it)", - sorted(os.listdir(src)) == ["httpd", "libauth.so"], - f"{sorted(os.listdir(src))}") + check( + "source tree stays clean (no IDA artifacts beside it)", + sorted(os.listdir(src)) == ["httpd", "libauth.so"], + f"{sorted(os.listdir(src))}", + ) # -- a changed source re-stages and drops the stale DB ------------- # open(ref.db, "wb").write(b"fake i64") @@ -88,11 +100,15 @@ def main() -> int: os.utime(ref.source, (1, 1)) check("a source rebuilt in place goes stale", proj.is_stale(ref)) proj.stage(ref) - check("re-staging refreshes the staged bytes", - open(ref.staged, "rb").read().endswith(b"v2 (longer)")) + check( + "re-staging refreshes the staged bytes", + open(ref.staged, "rb").read().endswith(b"v2 (longer)"), + ) check("re-staging drops the now-stale database", not proj.has_db(ref)) - check("re-staging drops the stale scratch too", - not os.path.exists(ref.staged + ".id0")) + check( + "re-staging drops the stale scratch too", + not os.path.exists(ref.staged + ".id0"), + ) # -- scratch sweep keeps the DB ------------------------------------ # open(ref.db, "wb").write(b"fake i64") @@ -107,22 +123,35 @@ def main() -> int: os.makedirs(sub) dup = _bin(os.path.join(sub, "httpd"), b"\x7fELF other httpd") with open(pfile, "w") as f: - json.dump({"name": "p", "binaries": [ - {"path": "src/httpd"}, # relative to the project file - {"path": dup}, # same basename -> collision - {"path": libauth, "label": "auth"}, - ]}, f) + json.dump( + { + "name": "p", + "binaries": [ + {"path": "src/httpd"}, # relative to the project file + {"path": dup}, # same basename -> collision + {"path": libauth, "label": "auth"}, + ], + }, + f, + ) proj2 = Project.load(pfile) - check("relative paths resolve against the project file", - proj2.refs[0].source == httpd, proj2.refs[0].source) - check("colliding labels are disambiguated", - [r.label for r in proj2.refs] == ["httpd", "httpd_2", "auth"], - f"{[r.label for r in proj2.refs]}") + check( + "relative paths resolve against the project file", + proj2.refs[0].source == httpd, + proj2.refs[0].source, + ) + check( + "colliding labels are disambiguated", + [r.label for r in proj2.refs] == ["httpd", "httpd_2", "auth"], + f"{[r.label for r in proj2.refs]}", + ) check("explicit labels are honoured", proj2.by_label("auth") is not None) proj2.stage_all() - check("stage_all() stages every binary to a distinct file", - len({r.staged for r in proj2.refs}) == 3 - and all(os.path.isfile(r.staged) for r in proj2.refs)) + check( + "stage_all() stages every binary to a distinct file", + len({r.staged for r in proj2.refs}) == 3 + and all(os.path.isfile(r.staged) for r in proj2.refs), + ) # -- add / remove ---------------------------------------------------- # extra = _bin(os.path.join(src, "extra")) @@ -132,39 +161,60 @@ def main() -> int: # -- re-adding must not duplicate (matched by resolved path) --------- # n = len(proj2.refs) proj2.add(extra) - check("re-adding the same path is a no-op", len(proj2.refs) == n, - f"{[r.label for r in proj2.refs]}") + check( + "re-adding the same path is a no-op", + len(proj2.refs) == n, + f"{[r.label for r in proj2.refs]}", + ) os.chdir(src) - proj2.add("./extra") # same file, relative - proj2.add(os.path.join(src, "..", "src", "extra")) # same file, messy - check("a different spelling of the same path is a no-op", - len(proj2.refs) == n, f"{[r.label for r in proj2.refs]}") + proj2.add("./extra") # same file, relative + proj2.add(os.path.join(src, "..", "src", "extra")) # same file, messy + check( + "a different spelling of the same path is a no-op", + len(proj2.refs) == n, + f"{[r.label for r in proj2.refs]}", + ) link = os.path.join(src, "extra_link") os.symlink(extra, link) proj2.add(link) - check("a symlink to an existing binary is a no-op", - len(proj2.refs) == n, f"{[r.label for r in proj2.refs]}") + check( + "a symlink to an existing binary is a no-op", + len(proj2.refs) == n, + f"{[r.label for r in proj2.refs]}", + ) # ...but a DIFFERENT file with the same basename must still be added other_dir = os.path.join(tmp, "other2") os.makedirs(other_dir) twin = _bin(os.path.join(other_dir, "extra"), b"\x7fELF a different extra") proj2.add(twin) - check("a same-named file from another directory IS added", - len(proj2.refs) == n + 1 - and proj2.by_source(twin) is not None - and proj2.by_source(extra) is not proj2.by_source(twin), - f"{[(r.label, r.source) for r in proj2.refs[-2:]]}") - check("the twins get distinct labels", - len({r.label for r in proj2.refs}) == len(proj2.refs), - f"{[r.label for r in proj2.refs]}") - check("create() also drops repeats on the command line", - len(Project.create(os.path.join(tmp, "dup.json"), - [extra, "./extra", extra]).refs) == 1) + check( + "a same-named file from another directory IS added", + len(proj2.refs) == n + 1 + and proj2.by_source(twin) is not None + and proj2.by_source(extra) is not proj2.by_source(twin), + f"{[(r.label, r.source) for r in proj2.refs[-2:]]}", + ) + check( + "the twins get distinct labels", + len({r.label for r in proj2.refs}) == len(proj2.refs), + f"{[r.label for r in proj2.refs]}", + ) + check( + "create() also drops repeats on the command line", + len( + Project.create( + os.path.join(tmp, "dup.json"), [extra, "./extra", extra] + ).refs + ) + == 1, + ) os.chdir(tmp) proj2.remove(proj2.by_source(twin).label) - check("remove() drops one", proj2.remove("extra") - and proj2.by_label("extra") is None) + check( + "remove() drops one", + proj2.remove("extra") and proj2.by_label("extra") is None, + ) # -- bad input ------------------------------------------------------- # bad = os.path.join(tmp, "bad.json") @@ -191,43 +241,62 @@ def main() -> int: # -- load options for headerless blobs --------------------------------- # with tempfile.TemporaryDirectory() as tmp: - src = os.path.join(tmp, "src"); os.makedirs(src) + src = os.path.join(tmp, "src") + os.makedirs(src) blob = os.path.join(src, "fw.bin") with open(blob, "wb") as f: f.write(b"\x00" * 64) - proj = Project.create(os.path.join(tmp, "p.json"), [blob], name="p", - load={"processor": "arm", "base": 0x8000000}) + proj = Project.create( + os.path.join(tmp, "p.json"), + [blob], + name="p", + load={"processor": "arm", "base": 0x8000000}, + ) r = proj.refs[0] - check("create() records load options per binary", - r.processor == "arm" and r.base == 0x8000000, - f"proc={r.processor!r} base={r.base:#x}") + check( + "create() records load options per binary", + r.processor == "arm" and r.base == 0x8000000, + f"proc={r.processor!r} base={r.base:#x}", + ) # -b is PARAGRAPHS: 0x8000000 >> 4 == 0x800000. Getting this wrong loads # the image 16x too high and every address in the database is wrong. - check("base is converted to IDA's paragraph units", - r.load_args == "-parm -b800000", r.load_args) + check( + "base is converted to IDA's paragraph units", + r.load_args == "-parm -b800000", + r.load_args, + ) proj2 = Project.load(proj.path) - check("load options survive a round-trip through the file", - proj2.refs[0].load_args == "-parm -b800000", - proj2.refs[0].load_args) + check( + "load options survive a round-trip through the file", + proj2.refs[0].load_args == "-parm -b800000", + proj2.refs[0].load_args, + ) blob2 = os.path.join(src, "other.bin") with open(blob2, "wb") as f: f.write(b"\x00" * 64) r2 = proj2.add(blob2, load={"processor": "mipsb"}) - check("add() takes load options too", - r2.load_args == "-pmipsb", r2.load_args) + check("add() takes load options too", r2.load_args == "-pmipsb", r2.load_args) # a normal ELF needs none of this and must pass nothing - check("a binary with no load options passes no switches", - Project.create(os.path.join(tmp, "q.json"), [blob], - name="q").refs[0].load_args == "") + check( + "a binary with no load options passes no switches", + Project.create(os.path.join(tmp, "q.json"), [blob], name="q") + .refs[0] + .load_args + == "", + ) # addresses get written by hand, so accept how people write them - proj3 = Project.create(os.path.join(tmp, "r.json"), [blob], name="r", - load={"base": "0x1000"}) - check("a base given as a hex STRING is parsed", - proj3.refs[0].base == 0x1000, f"{proj3.refs[0].base}") + proj3 = Project.create( + os.path.join(tmp, "r.json"), [blob], name="r", load={"base": "0x1000"} + ) + check( + "a base given as a hex STRING is parsed", + proj3.refs[0].base == 0x1000, + f"{proj3.refs[0].base}", + ) print(f"\n{PASS} passed, {FAIL} failed") return 1 if FAIL else 0 diff --git a/tests/test_project_ui.py b/tests/test_project_ui.py index 863b98a..9b388c8 100644 --- a/tests/test_project_ui.py +++ b/tests/test_project_ui.py @@ -21,12 +21,15 @@ import tempfile sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _fixtures import fast_keys # noqa: E402 -from idatui._sync import settle as quiesce, wait_for # noqa: E402 -fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys +from idatui._sync import settle as quiesce # noqa: E402 +from idatui._sync import wait_for + +fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys +from textual.widgets import Input, OptionList, Static # noqa: E402 + from idatui.app import IdaTui, ProjectPalette # noqa: E402 from idatui.project import Project # noqa: E402 -from textual.widgets import Input, OptionList, Static # noqa: E402 PASS = FAIL = 0 @@ -57,6 +60,7 @@ async def run(bins): app = IdaTui(keepalive=False, project=proj) async with app.run_test(size=(140, 44)) as pilot: + async def settle(pred, t=180.0): return await wait_for(pred, pilot.pause, t, 0.05) @@ -73,114 +77,163 @@ async def run(bins): waited for explicitly, not hoped for. """ return await settle( - lambda: app.program is not None - and app._func_index is not None - and app._func_index.complete - and app._loading_screen is None - and len(app.screen_stack) == 1, t) + lambda: ( + app.program is not None + and app._func_index is not None + and app._func_index.complete + and app._loading_screen is None + and len(app.screen_stack) == 1 + ), + t, + ) # -- boots on the project's first binary ----------------------- # ok = await usable() - check("project mode boots on the first binary", ok, - f"binary={app._binary}") - check("the active binary is the first one", app._binary == first, - f"{app._binary}") + check("project mode boots on the first binary", ok, f"binary={app._binary}") + check( + "the active binary is the first one", + app._binary == first, + f"{app._binary}", + ) n_first = len(app._func_index) check("its functions loaded", n_first > 10, f"n={n_first}") status = str(app.query_one("#status", Static).render()) - check("the status line names the active binary", - f"[{first}]" in status, status[:60]) + check( + "the status line names the active binary", + f"[{first}]" in status, + status[:60], + ) # -- the switcher lists the project ---------------------------- # await pilot.press("ctrl+o") - opened = await settle( - lambda: isinstance(app.screen, ProjectPalette), 20) - check("Ctrl+O opens the binary switcher", opened, - f"screen={type(app.screen).__name__}") + opened = await settle(lambda: isinstance(app.screen, ProjectPalette), 20) + check( + "Ctrl+O opens the binary switcher", + opened, + f"screen={type(app.screen).__name__}", + ) if not opened: return pal = app.screen - check("the switcher lists every project binary", - len(pal._results) == 2, f"{[e['label'] for e in pal._results]}") - check("it marks which one is active", - any(e["active"] and e["label"] == first for e in pal._results)) - check("it marks the other as not yet opened", - any(not e["resident"] and e["label"] == second - for e in pal._results)) + check( + "the switcher lists every project binary", + len(pal._results) == 2, + f"{[e['label'] for e in pal._results]}", + ) + check( + "it marks which one is active", + any(e["active"] and e["label"] == first for e in pal._results), + ) + check( + "it marks the other as not yet opened", + any(not e["resident"] and e["label"] == second for e in pal._results), + ) ol = pal.query_one(OptionList) - check("the switcher opens on the binary you're already in", - ol.highlighted is not None - and pal._results[ol.highlighted]["label"] == first, - f"highlighted={ol.highlighted} " - f"={pal._results[ol.highlighted]['label'] if ol.highlighted is not None else None} " - f"want={first}") + check( + "the switcher opens on the binary you're already in", + ol.highlighted is not None + and pal._results[ol.highlighted]["label"] == first, + f"highlighted={ol.highlighted} " + f"={pal._results[ol.highlighted]['label'] if ol.highlighted is not None else None} " + f"want={first}", + ) # -- switch to the second binary -------------------------------- # pal.query_one(Input).value = second await settle(lambda: bool(pal._results), 20) await pilot.press("enter") switched = await settle( - lambda: app._binary == second and app.program is not None - and app._func_index is not None and app._func_index.complete - and len(app.screen_stack) == 1) - check("switching opens the other binary", switched, - f"binary={app._binary}") - check("the second binary has its own function index", - app._func_index is not None and len(app._func_index) > 5, - f"n={len(app._func_index) if app._func_index else 0}") - check("both binaries now have live workers", - sorted(app._pool.resident()) == sorted([first, second]), - f"{app._pool.resident()}") + lambda: ( + app._binary == second + and app.program is not None + and app._func_index is not None + and app._func_index.complete + and len(app.screen_stack) == 1 + ) + ) + check("switching opens the other binary", switched, f"binary={app._binary}") + check( + "the second binary has its own function index", + app._func_index is not None and len(app._func_index) > 5, + f"n={len(app._func_index) if app._func_index else 0}", + ) + check( + "both binaries now have live workers", + sorted(app._pool.resident()) == sorted([first, second]), + f"{app._pool.resident()}", + ) landed = await settle(lambda: app._cur is not None, 60) - check("it lands somewhere in the new binary", landed, - f"cur={app._cur}") + check("it lands somewhere in the new binary", landed, f"cur={app._cur}") where = app._cur.ea if app._cur else None # -- switch back: resident, so state is restored ---------------- # await pilot.press("ctrl+o") - reopened = await settle( - lambda: isinstance(app.screen, ProjectPalette), 20) - check("the switcher reopens after a switch", reopened, - f"screen={type(app.screen).__name__}") + reopened = await settle(lambda: isinstance(app.screen, ProjectPalette), 20) + check( + "the switcher reopens after a switch", + reopened, + f"screen={type(app.screen).__name__}", + ) if not reopened: return app.screen.query_one(Input).value = first await settle(lambda: bool(app.screen._results), 20) await pilot.press("enter") - back = await settle(lambda: app._binary == first - and app._func_index is not None - and app._func_index.complete - and len(app.screen_stack) == 1, 120) - check("switching back returns to the first binary", back, - f"binary={app._binary}") - check("its function index came back intact", - app._func_index is not None and len(app._func_index) == n_first, - f"n={len(app._func_index) if app._func_index else 0} want={n_first}") + back = await settle( + lambda: ( + app._binary == first + and app._func_index is not None + and app._func_index.complete + and len(app.screen_stack) == 1 + ), + 120, + ) + check( + "switching back returns to the first binary", + back, + f"binary={app._binary}", + ) + check( + "its function index came back intact", + app._func_index is not None and len(app._func_index) == n_first, + f"n={len(app._func_index) if app._func_index else 0} want={n_first}", + ) # and forward again: the second binary's position was remembered await pilot.press("ctrl+o") if not await settle(lambda: isinstance(app.screen, ProjectPalette), 20): - check("returning to a binary restores where you were", False, - "switcher did not reopen") + check( + "returning to a binary restores where you were", + False, + "switcher did not reopen", + ) return app.screen.query_one(Input).value = second await settle(lambda: bool(app.screen._results), 20) await pilot.press("enter") - again = await settle(lambda: app._binary == second - and app._cur is not None, 120) - check("returning to a binary restores where you were", - again and app._cur.ea == where, - f"cur={app._cur.ea if app._cur else None} want={where}") + again = await settle( + lambda: app._binary == second and app._cur is not None, 120 + ) + check( + "returning to a binary restores where you were", + again and app._cur.ea == where, + f"cur={app._cur.ea if app._cur else None} want={where}", + ) # -- project-wide symbol search ------------------------------- # # 'main' exists in BOTH binaries: identical name, so the rank tuple # ties and a bare sort() would fall through to comparing Hit objects # ('<' not supported between instances of 'Hit'). from idatui.app import SymbolPalette - await settle(lambda: app._index is not None - and len(app._index.counts()) == 2, 60) - check("both binaries got indexed", - len(app._index.counts()) == 2, f"{app._index.counts()}") + + await settle( + lambda: app._index is not None and len(app._index.counts()) == 2, 60 + ) + check( + "both binaries got indexed", + len(app._index.counts()) == 2, + f"{app._index.counts()}", + ) await pilot.press("ctrl+n") if await settle(lambda: isinstance(app.screen, SymbolPalette), 20): pal = app.screen @@ -190,12 +243,14 @@ async def run(bins): # quiescence -- NOT "a foreign binary appeared", which is the # thing under test and would sit out its whole timeout on the # day it breaks. - await pilot.press("f2") # widen to the whole project + await pilot.press("f2") # widen to the whole project await quiesce(app) names = [(b, n) for b, _, n in pal._results] - check("project scope finds a name shared by both binaries", - len({b for b, n in names if n == "main"}) == 2, - f"{names[:6]}") + check( + "project scope finds a name shared by both binaries", + len({b for b, n in names if n == "main"}) == 2, + f"{names[:6]}", + ) await pilot.press("escape") await settle(lambda: not isinstance(app.screen, SymbolPalette), 20) @@ -209,17 +264,31 @@ async def run(bins): target = app._index.search("main", limit=200) tgt = next((h for h in target if h.binary == there), None) if tgt is None: - check("cross-binary jump records a hop", False, "no hit in the other binary") + check( + "cross-binary jump records a hop", + False, + "no hit in the other binary", + ) else: app._switch_then_goto(tgt.binary, tgt.addr) - jumped = await settle(lambda: app._binary == there - and app._func_index is not None - and app._func_index.complete, 180) - check("a project hit switches to the other binary", jumped, - f"binary={app._binary} want={there}") - check("the jump records where it came from", - len(app._hops) == hops0 + 1 and app._hops[-1] == here, - f"hops={app._hops}") + jumped = await settle( + lambda: ( + app._binary == there + and app._func_index is not None + and app._func_index.complete + ), + 180, + ) + check( + "a project hit switches to the other binary", + jumped, + f"binary={app._binary} want={there}", + ) + check( + "the jump records where it came from", + len(app._hops) == hops0 + 1 and app._hops[-1] == here, + f"hops={app._hops}", + ) # spend the local history first, then Esc must cross back for _ in range(6): if not app._hops or app._binary != there: @@ -227,10 +296,16 @@ async def run(bins): await pilot.press("escape") await quiesce(app) returned = await settle(lambda: app._binary == here, 180) - check("Esc crosses back to the binary the jump came from", - returned, f"binary={app._binary} want={here} hops={app._hops}") - check("the hop is consumed, not repeated", - not app._hops, f"hops={app._hops}") + check( + "Esc crosses back to the binary the jump came from", + returned, + f"binary={app._binary} want={here} hops={app._hops}", + ) + check( + "the hop is consumed, not repeated", + not app._hops, + f"hops={app._hops}", + ) # -- xrefs: callers in OTHER project binaries ------------------ # # xrefs_to only sees this database, so an exported function looks @@ -238,9 +313,13 @@ async def run(bins): # The selection rule is "only for a symbol we actually export"; two # executables share no linkage, so here it must stay quiet. from idatui.app import XrefsScreen + fake = app._foreign_importers(app._cur.ea, "strrchr", None) - check("no cross-binary callers for a symbol this binary doesn't export", - fake == [], f"{fake}") + check( + "no cross-binary callers for a symbol this binary doesn't export", + fake == [], + f"{fake}", + ) # The routing a real cross-binary caller takes: the dialog carries a # (binary, addr) payload instead of a bare address, and choosing it @@ -248,31 +327,55 @@ async def run(bins): # so Esc comes back. where_from = app._binary other = first if where_from == second else second - hit = next((h for h in app._index.search("main", limit=200) - if h.binary == other), None) + hit = next( + (h for h in app._index.search("main", limit=200) if h.binary == other), + None, + ) if hit is None: - check("a cross-binary xref jumps to the other binary", False, - "no symbol found in the other binary") + check( + "a cross-binary xref jumps to the other binary", + False, + "no symbol found in the other binary", + ) else: hops0 = len(app._hops) app.push_screen( - XrefsScreen("xrefs to fake", [((hit.binary, hit.addr), - f"{hit.addr:08X} import [{hit.binary}]")]), - app._on_xref_chosen) + XrefsScreen( + "xrefs to fake", + [ + ( + (hit.binary, hit.addr), + f"{hit.addr:08X} import [{hit.binary}]", + ) + ], + ), + app._on_xref_chosen, + ) await settle(lambda: isinstance(app.screen, XrefsScreen), 20) await pilot.press("enter") - jumped = await settle(lambda: app._binary == other - and app._func_index is not None - and app._func_index.complete, 180) - check("a cross-binary xref jumps to the other binary", jumped, - f"binary={app._binary} want={other}") - check("and records a hop so Esc returns", - len(app._hops) == hops0 + 1 and app._hops[-1] == where_from, - f"hops={app._hops}") + jumped = await settle( + lambda: ( + app._binary == other + and app._func_index is not None + and app._func_index.complete + ), + 180, + ) + check( + "a cross-binary xref jumps to the other binary", + jumped, + f"binary={app._binary} want={other}", + ) + check( + "and records a hop so Esc returns", + len(app._hops) == hops0 + 1 and app._hops[-1] == where_from, + f"hops={app._hops}", + ) app._hops.clear() # -- and the same toggle for strings --------------------------- # from idatui.app import StringsPalette + await pilot.press("quotation_mark") if await settle(lambda: isinstance(app.screen, StringsPalette), 30): pal = app.screen @@ -282,20 +385,32 @@ async def run(bins): await pilot.press("f2") await quiesce(app) wide = {b for b, _, _ in pal._results} - check("strings: local scope is this binary only", local == {None}, - f"{local}") - check("strings: F2 widens across the project", - len(wide) >= 2 and None not in wide, f"{wide}") + check( + "strings: local scope is this binary only", + local == {None}, + f"{local}", + ) + check( + "strings: F2 widens across the project", + len(wide) >= 2 and None not in wide, + f"{wide}", + ) await pilot.press("escape") await settle(lambda: not isinstance(app.screen, StringsPalette), 20) # -- the promise: nothing was written next to the sources ---------- # left = sorted(os.listdir(src)) - check("the source tree stays pristine (no .i64/scratch beside it)", - left == sorted(os.path.basename(s) for s in srcs), f"{left}") + check( + "the source tree stays pristine (no .i64/scratch beside it)", + left == sorted(os.path.basename(s) for s in srcs), + f"{left}", + ) staged = sorted(os.listdir(proj.bin_dir)) - check("IDA's artifacts all live in the project sidecar", - any(f.endswith(".i64") for f in staged), f"{staged}") + check( + "IDA's artifacts all live in the project sidecar", + any(f.endswith(".i64") for f in staged), + f"{staged}", + ) print(f"\n{PASS} passed, {FAIL} failed") return 1 if FAIL else 0 @@ -303,8 +418,10 @@ async def run(bins): def main(argv): repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - bins = argv or [os.path.join(repo, "targets", "echo"), - os.path.join(repo, "targets", "cat")] + bins = argv or [ + os.path.join(repo, "targets", "echo"), + os.path.join(repo, "targets", "cat"), + ] for b in bins: if not os.path.isfile(b): print(f"no such binary: {b}") diff --git a/tests/test_rawimage_rpc.py b/tests/test_rawimage_rpc.py index d39ad35..a6e7661 100644 --- a/tests/test_rawimage_rpc.py +++ b/tests/test_rawimage_rpc.py @@ -36,7 +36,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from idatui.rpcclient import RpcClient, RpcError # noqa: E402 REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -BLOB = os.path.join(REPO, "experiments", "fibonacci.bin") # real Thumb code +BLOB = os.path.join(REPO, "experiments", "fibonacci.bin") # real Thumb code PASS = FAIL = 0 @@ -52,11 +52,24 @@ def check(name, ok, detail=""): def spawn_pane(target, processor, timeout=420): - cmd = [sys.executable, "-m", "idatui.pane", "spawn", "--open", target, - "--processor", processor, "--detached", "--size", "60%", - "--timeout", str(timeout)] - r = subprocess.run(cmd, capture_output=True, text=True, - timeout=timeout + 60, cwd=REPO) + cmd = [ + sys.executable, + "-m", + "idatui.pane", + "spawn", + "--open", + target, + "--processor", + processor, + "--detached", + "--size", + "60%", + "--timeout", + str(timeout), + ] + r = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout + 60, cwd=REPO + ) if not r.stdout.strip(): print(f" spawn produced no JSON: {r.stderr.strip()}", file=sys.stderr) return None @@ -64,9 +77,22 @@ def spawn_pane(target, processor, timeout=420): def stop_pane(sock, timeout=60): - subprocess.run([sys.executable, "-m", "idatui.pane", "stop", "--sock", sock, - "--timeout", str(timeout)], - capture_output=True, text=True, timeout=timeout + 10, cwd=REPO) + subprocess.run( + [ + sys.executable, + "-m", + "idatui.pane", + "stop", + "--sock", + sock, + "--timeout", + str(timeout), + ], + capture_output=True, + text=True, + timeout=timeout + 10, + cwd=REPO, + ) def main() -> int: @@ -94,34 +120,44 @@ def main() -> int: # -- load options actually reached IDA --------------------------- # # Wrong processor => the disassembly is nonsense or absent; ARMv7-A # also means a 32-bit database, without which Hex-Rays refuses. - check("spawn forwarded --processor", info.get("ok"), - json.dumps(info)) + check("spawn forwarded --processor", info.get("ok"), json.dumps(info)) with RpcClient(sock) as c: st = c.call("state") - check("pane is drivable", st.get("active") in - ("listing", "decomp", "hex"), json.dumps(st)[:200]) + check( + "pane is drivable", + st.get("active") in ("listing", "decomp", "hex"), + json.dumps(st)[:200], + ) # -- define ------------------------------------------------- # # fibonacci.bin is Thumb at 0x0; as ARM it does not decode. r = c.call("define", kind="thumb", target="0x0") d = r.get("define", {}) check("define thumb ran", "define" in r, json.dumps(r)[:200]) - check("define thumb decoded instructions", - "instruction" in d.get("status", ""), d.get("status", "")) + check( + "define thumb decoded instructions", + "instruction" in d.get("status", ""), + d.get("status", ""), + ) r = c.call("define", kind="func", target="0x0") - check("define func created a function", - "function" in r["define"]["status"] - or "already" in r["define"]["status"], - r["define"]["status"]) + check( + "define func created a function", + "function" in r["define"]["status"] + or "already" in r["define"]["status"], + r["define"]["status"], + ) bad = None try: c.call("define", kind="nonsense") except RpcError as e: bad = str(e) - check("define rejects an unknown kind", bad is not None - and "unknown define kind" in bad, str(bad)) + check( + "define rejects an unknown kind", + bad is not None and "unknown define kind" in bad, + str(bad), + ) # -- opfmt (how a literal is displayed) --------------------- # # Thumb code is full of small immediates -- the thing 'o' exists @@ -134,21 +170,31 @@ def main() -> int: lit = None for ln in seen: m = re.match(r"([0-9A-F]{8})\s+(.*)", ln.get("text", "")) - if not (m and re.search(r"#(0x[0-9A-Fa-f]{2,}|[1-9]\d+)\b", - m.group(2))): + if not ( + m and re.search(r"#(0x[0-9A-Fa-f]{2,}|[1-9]\d+)\b", m.group(2)) + ): continue ea_s = "0x" + m.group(1) - st = c.call("opfmt", mode="show", target=ea_s, delay_ms=0 - ).get("opfmt", {}).get("status", "") + st = ( + c.call("opfmt", mode="show", target=ea_s, delay_ms=0) + .get("opfmt", {}) + .get("status", "") + ) if "no literal" not in st: lit = (ea_s, st) break - check("the blob has an immediate to reformat", lit is not None, - json.dumps([ln.get("text") for ln in seen[:8]])) + check( + "the blob has an immediate to reformat", + lit is not None, + json.dumps([ln.get("text") for ln in seen[:8]]), + ) if lit is not None: tgt, st0 = lit - check("opfmt show reports the stops without editing", - "[" in st0 and "dec" in st0, st0) + check( + "opfmt show reports the stops without editing", + "[" in st0 and "dec" in st0, + st0, + ) r = c.call("opfmt", mode="dec", target=tgt, delay_ms=0) st1 = r.get("opfmt", {}).get("status", "") check("opfmt sets a named format", "dec" in st1, st1) @@ -156,16 +202,21 @@ def main() -> int: st2 = r.get("opfmt", {}).get("status", "") check("opfmt cycles on from there", "\u2192" in st2, st2) r = c.call("opfmt", mode="default") - check("opfmt hands the operand back to IDA", - "default" in r.get("opfmt", {}).get("status", ""), - r.get("opfmt", {}).get("status", "")) + check( + "opfmt hands the operand back to IDA", + "default" in r.get("opfmt", {}).get("status", ""), + r.get("opfmt", {}).get("status", ""), + ) badfmt = None try: c.call("opfmt", mode="roman") except RpcError as e: badfmt = str(e) - check("opfmt rejects an unknown mode", badfmt is not None - and "unknown opfmt mode" in badfmt, str(badfmt)) + check( + "opfmt rejects an unknown mode", + badfmt is not None and "unknown opfmt mode" in badfmt, + str(badfmt), + ) # -- rename_many -------------------------------------------- # fns = c.call("functions", limit=200) @@ -177,28 +228,48 @@ def main() -> int: # 'start' (not 'addr') on purpose: symbol files in the wild # use it, and accepting only one spelling is how a bulk # import silently renames nothing. - json.dump([{"start": hex(ea), "name": "bulk_named_fn"}, - {"start": "0xdeadbe", "name": "nowhere"}], f) + json.dump( + [ + {"start": hex(ea), "name": "bulk_named_fn"}, + {"start": "0xdeadbe", "name": "nowhere"}, + ], + f, + ) r = c.call("rename_many", file=symfile) m = r.get("rename_many", {}) - check("rename_many applied the good entry", m.get("ok") == 1, - json.dumps(m)) - check("rename_many reports the bad entry", - m.get("failed") == 1 and m.get("errors"), json.dumps(m)) + check( + "rename_many applied the good entry", + m.get("ok") == 1, + json.dumps(m), + ) + check( + "rename_many reports the bad entry", + m.get("failed") == 1 and m.get("errors"), + json.dumps(m), + ) # The readback matters more than the return value: a driver # trusts resolve/functions to decide what work is left. - check("renamed symbol resolves", - c.call("resolve", name="bulk_named_fn").get("ea") == ea, - json.dumps(c.call("resolve", name="bulk_named_fn"))) + check( + "renamed symbol resolves", + c.call("resolve", name="bulk_named_fn").get("ea") == ea, + json.dumps(c.call("resolve", name="bulk_named_fn")), + ) names = {f["name"] for f in c.call("functions", limit=200)} - check("function table shows the new name", - "bulk_named_fn" in names, str(sorted(names)[:10])) + check( + "function table shows the new name", + "bulk_named_fn" in names, + str(sorted(names)[:10]), + ) - r = c.call("rename_many", items=[{"addr": hex(ea), - "name": "inline_named_fn"}]) - check("rename_many takes inline items", - r["rename_many"]["ok"] == 1, json.dumps(r["rename_many"])) + r = c.call( + "rename_many", items=[{"addr": hex(ea), "name": "inline_named_fn"}] + ) + check( + "rename_many takes inline items", + r["rename_many"]["ok"] == 1, + json.dumps(r["rename_many"]), + ) # -- the stale-pseudocode trap ------------------------------ # # Hex-Rays caches per function and does not notice that a @@ -208,22 +279,31 @@ def main() -> int: # the function's own body cites it.) before = c.call("pseudocode", target=hex(ea)) pc_before = json.dumps(before) - r = c.call("rename_many", items=[{"addr": hex(ea), - "name": "after_cache_fn"}]) + r = c.call( + "rename_many", items=[{"addr": hex(ea), "name": "after_cache_fn"}] + ) pc_after = json.dumps(c.call("pseudocode", target=hex(ea))) - check("pseudocode was cached before the rename", - "inline_named_fn" in pc_before, pc_before[:200]) - check("rename_many invalidates the decompile cache", - "after_cache_fn" in pc_after - and "inline_named_fn" not in pc_after, pc_after[:300]) + check( + "pseudocode was cached before the rename", + "inline_named_fn" in pc_before, + pc_before[:200], + ) + check( + "rename_many invalidates the decompile cache", + "after_cache_fn" in pc_after and "inline_named_fn" not in pc_after, + pc_after[:300], + ) empty = None try: c.call("rename_many") except RpcError as e: empty = str(e) - check("rename_many without items errors", empty is not None - and "items" in empty, str(empty)) + check( + "rename_many without items errors", + empty is not None and "items" in empty, + str(empty), + ) finally: stop_pane(sock) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 37ec017..4fa9799 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -31,19 +31,38 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _fixtures import fast_keys, staged # noqa: E402 -fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys +fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys +from rich.text import Text # noqa: E402 +from textual.widgets import ( # noqa: E402 + DataTable, + Input, + OptionList, + Static, + TextArea, +) + +from idatui import remote_ops # noqa: E402 +from idatui._sync import settle, wait_for # noqa: E402 from idatui.app import ( # noqa: E402 - ConfirmScreen, DecompView, FunctionsPanel, GraphView, HexView, IdaTui, - HelpScreen, ListingView, QuitScreen, SearchPalette, StringsPalette, - StructEditor, SymbolPalette, XrefsScreen, _HELP, _str_display, + _HELP, + ConfirmScreen, + DecompView, + FunctionsPanel, + GraphView, + HelpScreen, + HexView, + IdaTui, + ListingView, + QuitScreen, + SearchPalette, + StringsPalette, + StructEditor, + SymbolPalette, + XrefsScreen, + _str_display, _word_occurrences, ) from idatui.errors import IDAToolError # noqa: E402 -from textual.widgets import ( # noqa: E402 - DataTable, Input, OptionList, Static, TextArea, -) -from rich.text import Text # noqa: E402 -from idatui._sync import settle, wait_for # noqa: E402 PASS = FAIL = 0 STOP_AFTER = None @@ -86,8 +105,10 @@ class _Profile: return tp, tw = sum(self.paused.values()), sum(self.waited.values()) tk = sum(self.pressed.values()) - print(f"\nprofile: {tp:.1f}s settling, {tw:.1f}s in waits, " - f"{tk:.1f}s in keystrokes") + print( + f"\nprofile: {tp:.1f}s settling, {tw:.1f}s in waits, " + f"{tk:.1f}s in keystrokes" + ) rows = sorted(self.paused.items(), key=lambda kv: -kv[1])[:8] for name, secs in rows: print(f" pause {secs:5.2f}s {name}") @@ -98,8 +119,10 @@ class _Profile: for name, secs in rows: print(f" keys {secs:5.2f}s {name}") for name, secs in self.expired: - print(f" EXPIRED wait {secs:5.2f}s in {name} " - f"(the check after it may have passed vacuously)") + print( + f" EXPIRED wait {secs:5.2f}s in {name} " + f"(the check after it may have passed vacuously)" + ) PROFILE = _Profile() @@ -113,6 +136,7 @@ def scenario(name): def deco(fn): SCENARIOS.append((name, fn)) return fn + return deco @@ -149,8 +173,12 @@ class Ctx: async def wait(self, pred, t=20.0, step=0.02): t0 = asyncio.get_event_loop().time() ok = await wait_for(pred, self.pilot.pause, t, step) - PROFILE.wait(self.scenario, asyncio.get_event_loop().time() - t0, ok, - sys._getframe(1).f_lineno) + PROFILE.wait( + self.scenario, + asyncio.get_event_loop().time() - t0, + ok, + sys._getframe(1).f_lineno, + ) return ok async def press(self, *keys): @@ -293,8 +321,9 @@ class Ctx: # _cur.ea to match: re-opening a function we already navigated to (its # _cur.ea is stale-true) schedules an async re-navigation, and proceeding # before it lands would leave the cursor parked wherever we last were. - await self.wait(lambda: self.lst.total > 0 - and self.lst._cursor_ea() == fn.addr, t) + await self.wait( + lambda: self.lst.total > 0 and self.lst._cursor_ea() == fn.addr, t + ) if view == "decomp": # F5/Tab only decompiles from a focused code pane, and the listing # may still be settling from the open above — a swallowed Tab used to @@ -304,8 +333,12 @@ class Ctx: self.lst.focus() await self.pause(0.05) await self.press("tab") - if await self.wait(lambda: self.app._active == "decomp" - and self.dec.loaded_ea == fn.addr, max(t / 3, 5)): + if await self.wait( + lambda: ( + self.app._active == "decomp" and self.dec.loaded_ea == fn.addr + ), + max(t / 3, 5), + ): break return fn @@ -332,7 +365,8 @@ class Ctx: # Load is done when the index is complete (robust vs the status line, # which startup auto-land immediately overwrites with the landed fn). await self.wait( - lambda: app._func_index is not None and app._func_index.complete, 60) + lambda: app._func_index is not None and app._func_index.complete, 60 + ) if app._func_index is not None and not app._func_index.complete: app._func_index.load_all() @@ -359,7 +393,7 @@ class Ctx: app._pref = "decomp" if app._active in ("hex", "graph"): app._active = "decomp" - app._graph_sticky = False # else every later scenario rebuilds a graph + app._graph_sticky = False # else every later scenario rebuilds a graph app._split = False await self.pause(0.02) @@ -369,15 +403,26 @@ class Ctx: # --------------------------------------------------------------------------- # @scenario("startup") async def s_startup(c: Ctx): - c.check("function list populated", c.table.row_count > 0, f"rows={c.table.row_count}") + c.check( + "function list populated", c.table.row_count > 0, f"rows={c.table.row_count}" + ) left = c.app.query_one("#left") - c.check("names pane starts hidden (overlay-first)", not left.display, - f"display={left.display}") + c.check( + "names pane starts hidden (overlay-first)", + not left.display, + f"display={left.display}", + ) await c.reveal_pane() - c.check("function pane width is capped (doesn't eat the screen)", - left.size.width <= 44, f"width={left.size.width}") - c.check("function load completed (index complete)", - c.app._func_index is not None and c.app._func_index.complete, c.status()) + c.check( + "function pane width is capped (doesn't eat the screen)", + left.size.width <= 44, + f"width={left.size.width}", + ) + c.check( + "function load completed (index complete)", + c.app._func_index is not None and c.app._func_index.complete, + c.status(), + ) print(f" {c.table.row_count} functions loaded") @@ -398,13 +443,18 @@ async def s_auto_land(c: Ctx): await c.pause(0.2) if fn is not None: await c.wait(lambda: app._cur is not None and app._cur.ea == fn.addr, 20) - c.check("auto-land jumps to the entry function (main) when present", - app._cur is not None and app._cur.ea == fn.addr, - f"entry={fn.name}@{fn.addr:#x} cur={app._cur}") + c.check( + "auto-land jumps to the entry function (main) when present", + app._cur is not None and app._cur.ea == fn.addr, + f"entry={fn.name}@{fn.addr:#x} cur={app._cur}", + ) else: await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10) - c.check("auto-land pops the symbol picker when there's no entry fn", - isinstance(app.screen, SymbolPalette), f"screen={app.screen}") + c.check( + "auto-land pops the symbol picker when there's no entry fn", + isinstance(app.screen, SymbolPalette), + f"screen={app.screen}", + ) app.pop_screen() # guard fires once: a second call is a no-op prev = app._cur @@ -412,45 +462,392 @@ async def s_auto_land(c: Ctx): c.check("auto-land is idempotent (guarded)", app._cur is prev) +@scenario("segment_index") +async def s_segment_index(c: Ctx): + """segment_index must count EXACTLY what streaming the pages produces. + + It exists so the listing can know its row total without fetching every row + (1 call and ~0.5s instead of 458 calls and ~1.8s on bash). That is only + usable if the number is exact: the total sizes the scrollbar, and the + anchors are what a future "seek to row N" would jump through, so being off + by a handful of rows means the bar lies and a jump lands in the wrong place. + + Approximating it is the tempting mistake, which is why this compares against + the real thing rather than a tolerance. + """ + app = c.app + await c.open_biggest("listing") + lv = app.query_one(ListingView) + model = lv.model + if model is None: + c.check("listing model exists", False) + return + await c.wait(lambda: model.complete, 30) + if not model.complete: + c.check("segment streamed for comparison", False) + return + + idx = app.program.client.call(remote_ops.segment_index, addr=hex(model.seg_start)) + c.check( + "segment_index counts exactly what streaming produced", + idx.get("rows") == len(model), + f"index={idx.get('rows')} streamed={len(model)}", + ) + c.check( + "it reports the same segment", + int(str(idx.get("addr")), 16) == model.seg_start, + f"{idx.get('addr')} vs {model.seg_start:#x}", + ) + anchors = idx.get("anchors") or [] + c.check( + "anchors cover the segment", + len(anchors) >= max(1, len(model) // 500), + f"{len(anchors)} anchors for {len(model)} rows", + ) + + # Every anchor must name the address of the row it claims, or seeking to it + # would land somewhere else entirely. + bad = [] + for row, ea in anchors: + h = model.get(row) + if h is None or h.ea != int(str(ea), 16): + bad.append((row, ea, hex(h.ea) if h else None)) + c.check( + "every anchor points at the row it claims", + not bad, + f"{len(bad)} wrong, first={bad[:2]}", + ) + + # A model built from the index must be INDISTINGUISHABLE from a streamed + # one. That is the invariant the whole optimisation rests on: _prime builds + # from the index now, and every read path -- rendering, goto, xrefs, search, + # rename refresh -- indexes into these arrays assuming they were produced + # the old way. Comparing row counts alone would miss a shifted _row_at or a + # _by_ea that sends a jump to the wrong line. + # + # BOTH models are built here, back to back. Comparing against the app's + # long-lived model instead is wrong by one row and flaky: earlier scenarios + # rename and define things, so that model describes the database as it was + # at boot, not as it is now. + from idatui.domain import ListingModel # noqa: PLC0415 + + args = (app.program, model.seg_start, model.seg_end, model.name) + idx_model, streamed = ListingModel(*args), ListingModel(*args) + if not idx_model.build_from_index(): + c.check("build_from_index works", False) + return + while not streamed.complete: + if streamed.load_next_page(text=False) == 0: + break + c.check("an index-built model is complete immediately", idx_model.complete) + c.check( + "index-built model has the streamed row count", + len(idx_model) == len(streamed), + f"{len(idx_model)} vs {len(streamed)}", + ) + for field in ( + "_row_at", + "_head_eas", + "_by_ea", + "_page_head", + "_page_addr", + "_page_rows", + ): + a, b = getattr(idx_model, field), getattr(streamed, field) + c.check( + f"index-built {field} matches streaming", + a == b, + f"len {len(a)} vs {len(b)}", + ) + c.check( + "index-built rows carry the same ea/kind/size", + [(h.ea, h.kind, h.size) for h in idx_model._heads] + == [(h.ea, h.kind, h.size) for h in streamed._heads], + ) + + +@scenario("reprime_is_free") +async def s_reprime_is_free(c: Ctx): + """Switching back to the listing must not rebuild the row index. + + The listing view re-primes every time it is shown, and priming builds the + whole index. Building it is ~600-900ms, so doing it again on each Tab out of + the decompiler put nearly a second in front of a keystroke -- the listing + was still CORRECT, which is why every other test passed, it was just slow. + + Counting backend calls is the only way to see that, so this counts them. + """ + app = c.app + await c.open_biggest("listing") + lv = app.query_one(ListingView) + if lv.model is None: + c.check("listing model exists", False) + return + await c.wait(lambda: lv.model.complete, 30) + + client = app.program.client + original = type(client).call + seen: list[str] = [] + + def counting(self, operation, *a, **kw): + # call() takes the remote_ops declaration itself; count by its name. + seen.append(getattr(operation, "__name__", str(operation))) + return original(self, operation, *a, **kw) + + type(client).call = counting + try: + for _ in range(3): # decomp and back, three times + await c.press("tab") + await c.pause(0.05) + await c.press("tab") + await c.pause(0.05) + finally: + type(client).call = original + + rebuilds = seen.count("segment_index") + c.check( + "switching views never rebuilds the segment index", + rebuilds == 0, + f"segment_index called {rebuilds}x during 3 view switches: {seen}", + ) + + +@scenario("skeleton_pages") +async def s_skeleton_pages(c: Ctx): + """The background grower loads text-less pages; reading one must fill it in. + + _grow streams the whole segment only to learn how many rows it has, so it + asks for skeleton pages (same rows, same addresses, no rendered text) -- + 3x cheaper and one round trip instead of two. The first read of such a page + has to materialise it through the same path a rename uses. + + The failure mode if that path breaks is BLANK ROWS deep in the listing, not + an exception, and nothing else in this suite scrolls far enough to see it: + _prime renders the first ~1000 rows for real, so a test that only pages down + a few screens passes against a completely broken implementation. + """ + app = c.app + await c.open_biggest("listing") + lv = app.query_one(ListingView) + model = lv.model + if model is None: + c.check("listing model exists", False) + return + # Let the grower finish so the tail of the segment is definitely skeleton. + await c.wait(lambda: model.complete, 30) + c.check("the grower completes", model.complete, f"rows={len(model)}") + if not model.complete or len(model) < 1200: + # A target smaller than _prime's horizon has no skeleton pages at all, + # so there is nothing to check rather than something broken. + c.check( + "segment is big enough to have skeleton pages", + True, + f"skipped: only {len(model)} rows, _prime renders ~1000", + ) + return + c.check("pages were loaded as skeletons", model._skeleton is True) + + # Well past _prime's horizon, and the very last row. + deep = max(1200, len(model) - 40) + for row in (1200, len(model) // 2, deep): + h = model.get(row) + c.check( + f"row {row} of a skeleton page has real text", + h is not None and bool((h.text or "").strip()), + f"ea={getattr(h, 'ea', None)} text={getattr(h, 'text', None)!r}", + ) + + # And through the render path the user actually sees, not just the model. + lv.cursor = deep + lv.refresh() + await c.pause(0.1) + painted = lv._line_plain(deep) + c.check( + "a deep row RENDERS with text", + bool(painted and painted.strip()), + f"painted={painted!r}", + ) + + # Materialising must not change the row count or move any address: the + # skeleton's structure is what the scrollbar was sized from. + before = len(model) + model.get(deep) + c.check( + "materialising a page does not change the row count", + len(model) == before, + f"{before} -> {len(model)}", + ) + c.check("the walk was not disturbed", not model.stale_structure) + + +@scenario("palette_paging") +async def s_palette_paging(c: Ctx): + """PgUp/PgDn move the palette list by a viewport, with the Input focused. + + The palettes focus their filter box, not the list, so the OptionList's own + pageup/pagedown bindings never fire -- OptionListNav forwards them. That + forwarding is the thing under test; if it regresses, these keys silently do + nothing (the failure mode is a no-op, not an error). + """ + app = c.app + await c.press("ctrl+n") + if not await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10): + c.check("palette opens for the paging test", False) + return + pal = app.screen + ol = pal.query_one(OptionList) + inp = pal.query_one(Input) + await c.wait(lambda: ol.option_count > 5, 10) + + # Paging is GEOMETRY: the widget moves by scrollable_content_region.height, + # which is 0 until a frame has been laid out. Without this wait every check + # below would pass vacuously against a zero-height page. + await c.wait(lambda: ol.scrollable_content_region.height >= 1, 10) + page = ol.scrollable_content_region.height + c.check( + "the palette list has a real viewport to page by", page >= 1, f"height={page}" + ) + if ol.option_count <= 2: + c.check("enough symbols to page through", False, f"n={ol.option_count}") + return + + c.check( + "the filter Input holds focus (so the list never sees the key)", + pal.focused is inp, + f"focused={type(pal.focused).__name__}", + ) + + ol.highlighted = 0 + await c.press("pagedown") + down = ol.highlighted or 0 + # A page, not a line: the bug this guards against is PgDn falling through to + # the Input and moving nothing, or degrading to a single-step cursor move. + c.check( + "PgDn moves the symbol list by more than one row", + down > 1, + f"highlighted={down} page={page} n={ol.option_count}", + ) + c.check( + "PgDn moves by about a viewport (or lands on the last row)", + down >= min(page, ol.option_count - 1) - 1, + f"highlighted={down} page={page} n={ol.option_count}", + ) + + await c.press("pageup") + c.check( + "PgUp comes back to the top", + (ol.highlighted or 0) == 0, + f"highlighted={ol.highlighted}", + ) + + # Clamping: hammering past the end must settle on the last row, not wrap or + # raise. 12 pages clears any list this palette will show. + for _ in range(12): + await c.press("pagedown") + c.check( + "PgDn clamps at the last row", + ol.highlighted == ol.option_count - 1, + f"highlighted={ol.highlighted} n={ol.option_count}", + ) + for _ in range(12): + await c.press("pageup") + c.check( + "PgUp clamps at the first row", + ol.highlighted == 0, + f"highlighted={ol.highlighted}", + ) + + await c.press("escape") + await c.wait(lambda: not isinstance(app.screen, SymbolPalette), 10) + + +@scenario("xrefs_paging") +async def s_xrefs_paging(c: Ctx): + """The xrefs popup focuses its list, so paging is Textual's own. + + A regression guard for the other half of the split: OptionListNav must not + be needed here, and must not double-move if someone adds it later. + """ + app = c.app + await c.open_biggest("listing") + await c.press("x") + if not await c.wait(lambda: isinstance(app.screen, XrefsScreen), 10): + c.check( + "xrefs popup opens for the paging test", + True, + "skipped: no xrefs at this cursor", + ) + return + scr = app.screen + ol = scr.query_one(OptionList) + await c.wait(lambda: ol.scrollable_content_region.height >= 1, 10) + c.check( + "the xrefs list itself has focus", + scr.focused is ol, + f"focused={type(scr.focused).__name__}", + ) + if ol.option_count > 2: + ol.highlighted = 0 + await c.press("pagedown") + c.check( + "PgDn pages the xrefs list natively", + (ol.highlighted or 0) > 1, + f"highlighted={ol.highlighted} n={ol.option_count}", + ) + await c.press("escape") + await c.wait(lambda: not isinstance(app.screen, XrefsScreen), 10) + + @scenario("palette") async def s_palette(c: Ctx): app, pilot = c.app, c.pilot await c.press("ctrl+n") pal_open = await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10) - c.check("Ctrl+N opens the symbol palette", pal_open, - f"screen={type(app.screen).__name__}") + c.check( + "Ctrl+N opens the symbol palette", + pal_open, + f"screen={type(app.screen).__name__}", + ) if not pal_open: return pal = app.screen pinp = pal.query_one(Input) pinp.value = "main" await c.wait(lambda: pal._results and pal._results[0][2] == "main", 10) - c.check("palette fuzzy-finds (top result matches the query)", - bool(pal._results) and pal._results[0][2] == "main", - f"top={pal._results[0][2] if pal._results else None}") + c.check( + "palette fuzzy-finds (top result matches the query)", + bool(pal._results) and pal._results[0][2] == "main", + f"top={pal._results[0][2] if pal._results else None}", + ) pinp.value = "eror" # scattered subsequence of 'error' await c.wait(lambda: any(n == "error" for _, _, n in pal._results), 10) - c.check("palette matches a fuzzy subsequence", - any(n == "error" for _, _, n in pal._results), - f"results={[n for _, _, n in pal._results[:4]]}") + 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]]}") + 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] await c.press("enter") await c.wait(lambda: not isinstance(app.screen, SymbolPalette), 10) await c.wait(lambda: app._cur and app._cur.ea == want, 20) - 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}") + 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 @@ -464,8 +861,11 @@ async def s_palette(c: Ctx): 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)}") + 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") @@ -478,43 +878,57 @@ 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()) + 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") + + 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)}") + 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)}") + 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) @@ -529,6 +943,7 @@ async def s_asm_highlight(c: Ctx): 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 @@ -540,30 +955,40 @@ async def s_asm_highlight(c: Ctx): 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") + 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))}") + 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]]}") + 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}") + 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") @@ -575,26 +1000,30 @@ async def s_status_names_the_file(c: Ctx): 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}])") + 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]) + 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]) + c.check("without saying the name twice", status.count(name) == 1, status[:70]) @scenario("command_palette") @@ -602,10 +1031,12 @@ async def s_command_palette(c: Ctx): app = c.app await c.open_biggest("listing") await c.press("ctrl+p") - opened = await c.wait( - lambda: type(app.screen).__name__ == "CommandPalette", 10) - c.check("Ctrl+P opens the command palette", opened, - f"screen={type(app.screen).__name__}") + opened = await c.wait(lambda: type(app.screen).__name__ == "CommandPalette", 10) + c.check( + "Ctrl+P opens the command palette", + opened, + f"screen={type(app.screen).__name__}", + ) if not opened: return inp = app.screen.query_one(Input) @@ -613,8 +1044,9 @@ async def s_command_palette(c: Ctx): await c.pause(0.5) # let the async search + option list settle await c.press("enter") landed = await c.wait(lambda: app._active == "hex", 10) - c.check("a palette command executes (Hex view opens)", landed, - f"active={app._active}") + c.check( + "a palette command executes (Hex view opens)", landed, f"active={app._active}" + ) if landed: await c.press("backslash") # leave hex await c.wait(lambda: app._active != "hex", 5) @@ -623,21 +1055,32 @@ async def s_command_palette(c: Ctx): @scenario("quit_guard") async def s_quit_guard(c: Ctx): app = c.app - c.check("a clean database reports nothing unsaved", app._dirty_labels() == [], - f"{app._dirty_labels()}") + c.check( + "a clean database reports nothing unsaved", + app._dirty_labels() == [], + f"{app._dirty_labels()}", + ) app._dirty = True # as an edit would - c.check("an edited database is reported unsaved", - len(app._dirty_labels()) == 1, f"{app._dirty_labels()}") + c.check( + "an edited database is reported unsaved", + len(app._dirty_labels()) == 1, + f"{app._dirty_labels()}", + ) await c.press("q") asked = await c.wait(lambda: isinstance(app.screen, QuitScreen), 10) - c.check("quitting with unsaved changes asks first", asked and app.is_running, - f"screen={type(app.screen).__name__} running={app.is_running}") + c.check( + "quitting with unsaved changes asks first", + asked and app.is_running, + f"screen={type(app.screen).__name__} running={app.is_running}", + ) if not asked: return await c.press("escape") await c.wait(lambda: not isinstance(app.screen, QuitScreen), 10) - c.check("Esc cancels the quit and stays put", - app.is_running and not isinstance(app.screen, QuitScreen)) + c.check( + "Esc cancels the quit and stays put", + app.is_running and not isinstance(app.screen, QuitScreen), + ) # leave it clean so the rest of the suite (and teardown) isn't affected app._dirty = False app._save_on_exit = False @@ -647,13 +1090,16 @@ async def s_quit_guard(c: Ctx): async def s_help(c: Ctx): app = c.app st = app.query_one("#status", Static) - c.check("the status line owns the bottom row (no footer cheatsheet)", - st.region.y + st.region.height == app.size.height, - f"status={st.region} screen={app.size}") + c.check( + "the status line owns the bottom row (no footer cheatsheet)", + st.region.y + st.region.height == app.size.height, + f"status={st.region} screen={app.size}", + ) await c.press("f1") opened = await c.wait(lambda: isinstance(app.screen, HelpScreen), 10) - c.check("F1 opens the key cheatsheet", opened, - f"screen={type(app.screen).__name__}") + c.check( + "F1 opens the key cheatsheet", opened, f"screen={type(app.screen).__name__}" + ) if not opened: return cards = app.screen.query(".help-card") @@ -661,16 +1107,26 @@ async def s_help(c: Ctx): txt = " ".join(str(w.render()) for w in cards) # Derived from _HELP, not hardcoded: adding a group is a normal change and # shouldn't fail a test that only meant 'every group is rendered'. - c.check("each key group gets its own card", - titles == {t for t, _ in _HELP}, f"{titles}") - c.check("it documents real bindings", - "set type" in txt and "split view" in txt and "cross-references" in txt) - c.check("the graph keys are documented", - "control-flow graph" in txt and "minimap" in txt) + c.check( + "each key group gets its own card", titles == {t for t, _ in _HELP}, f"{titles}" + ) + c.check( + "it documents real bindings", + "set type" in txt + and "split view" in txt + and "cross-references" in txt + and "refresh the current view" in txt, + ) + c.check( + "the graph keys are documented", + "control-flow graph" in txt and "minimap" in txt, + ) body = app.screen.query_one("#help-body") - c.check("the cards fit without a scrollbar at a normal size", - body.virtual_size.height <= body.size.height, - f"content={body.virtual_size.height} view={body.size.height}") + c.check( + "the cards fit without a scrollbar at a normal size", + body.virtual_size.height <= body.size.height, + f"content={body.virtual_size.height} view={body.size.height}", + ) await c.press("escape") await c.wait(lambda: not isinstance(app.screen, HelpScreen), 10) c.check("Esc closes it", not isinstance(app.screen, HelpScreen)) @@ -678,8 +1134,9 @@ async def s_help(c: Ctx): # cheatsheet must not be reachable ONLY through F1. await c.press("H") opened_h = await c.wait(lambda: isinstance(app.screen, HelpScreen), 10) - c.check("H opens the cheatsheet too", opened_h, - f"screen={type(app.screen).__name__}") + c.check( + "H opens the cheatsheet too", opened_h, f"screen={type(app.screen).__name__}" + ) if opened_h: await c.press("H") await c.wait(lambda: not isinstance(app.screen, HelpScreen), 10) @@ -691,39 +1148,54 @@ async def s_strings(c: Ctx): app = c.app await c.open_biggest("listing") items = app.program.strings() - c.check("program.strings() lists the binary's literals", len(items) > 3, - f"n={len(items)}") + c.check( + "program.strings() lists the binary's literals", + len(items) > 3, + f"n={len(items)}", + ) if not items: return - c.check("strings carry addr/text/length", - all(s.addr > 0 and s.text and s.length > 0 for s in items[:5]), - f"first={items[0]}") + c.check( + "strings carry addr/text/length", + all(s.addr > 0 and s.text and s.length > 0 for s in items[:5]), + f"first={items[0]}", + ) await c.press("quotation_mark") opened = await c.wait(lambda: isinstance(app.screen, StringsPalette), 25) - c.check('\'"\' opens the strings browser', opened, - f"screen={type(app.screen).__name__}") + c.check( + "'\"' opens the strings browser", opened, f"screen={type(app.screen).__name__}" + ) if not opened: return pal = app.screen - c.check("the browser lists strings", len(pal._results) > 0, - f"results={len(pal._results)}") + c.check( + "the browser lists strings", + len(pal._results) > 0, + f"results={len(pal._results)}", + ) # filter on a fragment of a real (unescaped) literal - target = next((s for s in items - if len(s.text) >= 6 and _str_display(s.text) == s.text), None) + target = next( + (s for s in items if len(s.text) >= 6 and _str_display(s.text) == s.text), None + ) if target is not None: frag = target.text[:6] pal.query_one(Input).value = frag await c.pause(0.2) - ok = (pal._results - and all(frag.lower() in t.lower() for _, _, t in pal._results)) - c.check("filtering narrows to matching strings", bool(ok), - f"frag={frag!r} n={len(pal._results)}") + ok = pal._results and all(frag.lower() in t.lower() for _, _, t in pal._results) + c.check( + "filtering narrows to matching strings", + bool(ok), + f"frag={frag!r} n={len(pal._results)}", + ) want = pal._results[0][1] await c.press("enter") await c.wait(lambda: not isinstance(app.screen, StringsPalette), 10) landed = await c.wait(lambda: c.lst._cursor_ea() == want, 20) - c.check("Enter jumps to the string in the unified listing", landed, - f"cursor={c.lst._cursor_ea()} want={want:#x}") + c.check( + "Enter jumps to the string in the unified listing", + landed, + f"cursor={c.lst._cursor_ea()} want={want:#x}", + ) else: await c.press("escape") @@ -742,31 +1214,42 @@ async def s_view_modes_all_handled(c: Ctx): the next mode that forgets to appear somewhere. """ from idatui.app import ViewMode + app = c.app fn = await c.open_biggest("listing") try: for mode in ViewMode: app._active = mode - app._show_active() # must not raise for any member + app._show_active() # must not raise for any member await c.pause(0.05) view = app._active_code_view() if mode in ViewMode.code_modes(): - c.check(f"{mode.value}: _active_code_view resolves a widget", - view is not None, f"{mode.value} -> None") - c.check(f"{mode.value}: exactly one predicate is true", - sum((app.is_listing, app.is_decomp, - app.is_hex, app.is_graph)) == 1, - f"{mode.value}: listing={app.is_listing} " - f"decomp={app.is_decomp} hex={app.is_hex} graph={app.is_graph}") - c.check(f"{mode.value}: in_code agrees with code_modes()", - app.in_code == (mode in ViewMode.code_modes()), - f"in_code={app.in_code} for {mode.value}") - c.check("every mode is a plain string over the wire", - all(isinstance(m, str) and m == m.value for m in ViewMode), - str([repr(m) for m in ViewMode])) - c.check("'disasm' is not a mode any more", - "disasm" not in {m.value for m in ViewMode}, - str([m.value for m in ViewMode])) + c.check( + f"{mode.value}: _active_code_view resolves a widget", + view is not None, + f"{mode.value} -> None", + ) + c.check( + f"{mode.value}: exactly one predicate is true", + sum((app.is_listing, app.is_decomp, app.is_hex, app.is_graph)) == 1, + f"{mode.value}: listing={app.is_listing} " + f"decomp={app.is_decomp} hex={app.is_hex} graph={app.is_graph}", + ) + c.check( + f"{mode.value}: in_code agrees with code_modes()", + app.in_code == (mode in ViewMode.code_modes()), + f"in_code={app.in_code} for {mode.value}", + ) + c.check( + "every mode is a plain string over the wire", + all(isinstance(m, str) and m == m.value for m in ViewMode), + str([repr(m) for m in ViewMode]), + ) + c.check( + "'disasm' is not a mode any more", + "disasm" not in {m.value for m in ViewMode}, + str([m.value for m in ViewMode]), + ) finally: # Restore through a real navigation, not by poking _active back. # _show_active() tears down split state and re-points the panes as a @@ -777,6 +1260,177 @@ async def s_view_modes_all_handled(c: Ctx): await c.open(fn.addr, "listing") +@scenario("refresh_view") +async def s_refresh_view(c: Ctx): + """Ctrl+R replaces stale backing data without moving the listing.""" + fn = await c.open_biggest("listing") + await c.press("down", "down", "down") + lst = c.lst + old_model = lst.model + old_ea = lst._cursor_ea() + old_top = round(lst.scroll_offset.y) + old_head = old_model.get(old_top) if old_model is not None else None + old_top_ea = getattr(old_head, "ea", None) + + await c.press("ctrl+r") + landed = await c.wait( + lambda: lst.model is not old_model and lst._cursor_ea() == old_ea, 25 + ) + c.check("Ctrl+R rebuilds the listing model", lst.model is not old_model) + c.check( + "Ctrl+R preserves the cursor address", + landed, + f"got={lst._cursor_ea()} want={old_ea}", + ) + new_top = round(lst.scroll_offset.y) + new_head = lst.model.get(new_top) if lst.model is not None else None + c.check( + "Ctrl+R preserves the viewport by address", + getattr(new_head, "ea", None) == old_top_ea, + f"got={getattr(new_head, 'ea', None)} want={old_top_ea}", + ) + + await c.open(fn.addr, "decomp") + dec = c.dec + dec.cursor = min(3, max(len(dec._texts) - 1, 0)) + old_dec_cursor = dec.cursor + await c.press("ctrl+r") + refreshed = await c.wait( + lambda: c.app.is_decomp and dec.loaded_ea == fn.addr and not dec.loading, 25 + ) + c.check( + "Ctrl+R reloads pseudocode without changing views", + refreshed, + f"active={c.app._active} loaded={dec.loaded_ea} want={fn.addr:#x}", + ) + c.check( + "Ctrl+R preserves the pseudocode cursor", + dec.cursor == old_dec_cursor, + f"got={dec.cursor} want={old_dec_cursor}", + ) + + +@scenario("idb_event_refresh") +async def s_idb_event_refresh(c: Ctx): + """An external edit burst refreshes the UI in place (event -> refresh). + + The listener thread and its debounce are unit-tested in + test_nexus_client.py; this covers the app half it hands the batch to: + _refresh_idb_events must invalidate, reindex, and reload the active + surface without moving the cursor. The edit is made straight through the + client so no UI cache hears about it -- exactly what another client's + rename looks like from this process. + """ + app, lst = c.app, c.lst + fn = await c.open_biggest("listing") + await c.press("down", "down", "down") # mid-viewport: anchor is not degenerate + old_model = lst.model + old_ea = lst._cursor_ea() + old_top = round(lst.scroll_offset.y) + old_head = old_model.get(old_top) if old_model is not None else None + old_top_ea = getattr(old_head, "ea", None) + + newname = f"ext_{os.getpid()}" + rr = app.program.client.call( + remote_ops.rename, batch={"func": {"addr": hex(fn.addr), "name": newname}} + ) + c.check( + "out-of-band rename applied", + rr.get("summary", {}).get("ok", 0) == 1, + str(rr.get("summary")), + ) + + event = {"kind": "renamed", "ea": hex(fn.addr), "origin_id": "another-client"} + app._refresh_idb_events(app.client, (event,)) + c.check( + "the status line says why the view is about to move", + "external database change" in c.status(), + c.status(), + ) + landed = await c.wait( + lambda: ( + lst.model is not old_model + and lst._cursor_ea() == old_ea + and app._cur is not None + and app._cur.name == newname + ), + 25, + ) + c.check( + "the event batch rebuilds the listing around the same cursor", + landed, + f"got={lst._cursor_ea()} want={old_ea} name={getattr(app._cur, 'name', None)}", + ) + new_top = round(lst.scroll_offset.y) + new_head = lst.model.get(new_top) if lst.model is not None else None + c.check( + "the viewport is preserved by address", + getattr(new_head, "ea", None) == old_top_ea, + f"got={getattr(new_head, 'ea', None)} want={old_top_ea}", + ) + await c.wait( + lambda: ( + app._func_index.by_addr(fn.addr) is not None + and app._func_index.by_addr(fn.addr).name == newname + ), + 25, + ) + c.check( + "the function index was rebuilt with the external name", + app._func_index.by_addr(fn.addr).name == newname, + getattr(app._func_index.by_addr(fn.addr), "name", None), + ) + + # The decompiler half: pseudocode opened now must carry the new name (the + # refresh bumped the name generation), and a second external batch landing + # while decomp is active must reload it in place -- which also reverts the + # rename, so the scenario is idempotent. + await c.open(fn.addr, "decomp") + dec = c.dec + c.check( + "pseudocode decompiled after the event shows the external name", + bool(dec._texts) and newname in dec._texts[0], + dec._texts[0] if dec._texts else "(empty)", + ) + rr = app.program.client.call( + remote_ops.rename, batch={"func": {"addr": hex(fn.addr), "name": fn.name}} + ) + c.check( + "rename reverted out of band", + rr.get("summary", {}).get("ok", 0) == 1, + str(rr.get("summary")), + ) + app._refresh_idb_events(app.client, (event,)) + reverted = await c.wait( + lambda: ( + app.is_decomp + and dec.loaded_ea == fn.addr + and not dec.loading + and bool(dec._texts) + and fn.name in dec._texts[0] + ), + 25, + ) + c.check( + "a batch landing in decomp view reloads the pseudocode in place", + reverted, + f"active={app._active} loaded={dec.loaded_ea} " + f"row0={dec._texts[0] if dec._texts else '(empty)'}", + ) + await c.wait( + lambda: ( + app._func_index.by_addr(fn.addr) is not None + and app._func_index.by_addr(fn.addr).name == fn.name + ), + 25, + ) + c.check( + "the index is back to the original name (idempotent)", + app._func_index.by_addr(fn.addr).name == fn.name, + getattr(app._func_index.by_addr(fn.addr), "name", None), + ) + + @scenario("split_view") async def s_split_view(c: Ctx): app, lst, dec = c.app, c.lst, c.dec @@ -790,63 +1444,86 @@ async def s_split_view(c: Ctx): # to count. The bound is loose because the bug was three orders of magnitude # out, not a near miss. _lookups = {"n": 0} - _orig_call = c.prog.client.invoke + _orig_call = c.prog.client.call - def _counting(name, *a, **kw): - if name == "lookup_funcs": + def _counting(operation, *a, **kw): + # call() takes the remote_ops declaration itself; match by its name. + if getattr(operation, "__name__", "") == "lookup_funcs": _lookups["n"] += 1 - return _orig_call(name, *a, **kw) + return _orig_call(operation, *a, **kw) - c.prog.client.invoke = _counting + c.prog.client.call = _counting try: await _split_view_body(c, app, lst, dec) finally: - c.prog.client.invoke = _orig_call - c.check("split view doesn't storm the worker with function lookups", - _lookups["n"] < 500, f"{_lookups['n']} lookup_funcs calls") + c.prog.client.call = _orig_call + c.check( + "split view doesn't storm the worker with function lookups", + _lookups["n"] < 500, + f"{_lookups['n']} lookup_funcs calls", + ) async def _split_view_body(c: Ctx, app, lst, dec): await c.open_biggest("listing") await c.press("s") shown = await c.wait(lambda: app._split and lst.display and dec.display, 20) - c.check("'s' enters split view (both panes shown)", shown, - f"split={app._split} lst={lst.display} dec={dec.display}") + c.check( + "'s' enters split view (both panes shown)", + shown, + f"split={app._split} lst={lst.display} dec={dec.display}", + ) loaded = await c.wait(lambda: dec.loaded_ea == app._cur.ea, 25) - c.check("split loads the pseudocode alongside the listing", loaded, - f"loaded={dec.loaded_ea} cur={app._cur.ea if app._cur else None}") + c.check( + "split loads the pseudocode alongside the listing", + loaded, + f"loaded={dec.loaded_ea} cur={app._cur.ea if app._cur else None}", + ) await c.wait(lambda: "[split" in c.status(), 5) - c.check("split view shows a split-aware status", "[split" in c.status(), - f"status={c.status()!r}") + c.check( + "split view shows a split-aware status", + "[split" in c.status(), + f"status={c.status()!r}", + ) # phase 3: the rich per-line instruction map (decomp_map tool, run on the # pilot's real worker) — verify it returns, aligns with the markers, and # bands a whole region for a multi-instruction C line. m = app.program.decomp_map(app._cur.ea) c.check("decomp_map returns per-line ea sets", len(m) > 5, f"lines={len(m)}") - aligned = sum(1 for i in range(min(len(m), len(dec._line_eas))) - if m[i] and dec._line_eas[i] is not None - and dec._line_eas[i] in m[i]) - c.check("decomp_map aligns with the pseudocode markers", aligned >= 3, - f"aligned={aligned}/{len(dec._line_eas)}") + aligned = sum( + 1 + for i in range(min(len(m), len(dec._line_eas))) + if m[i] and dec._line_eas[i] is not None and dec._line_eas[i] in m[i] + ) + c.check( + "decomp_map aligns with the pseudocode markers", + aligned >= 3, + f"aligned={aligned}/{len(dec._line_eas)}", + ) multi = next((i for i, eas in enumerate(m) if len(eas) > 1), None) if multi is not None: app._split_eamap = m - dec.focus() # the decomp must BE the driver for a decomp-driven + dec.focus() # the decomp must BE the driver for a decomp-driven app._active = "decomp" # sync (else its align() re-syncs listing-driven) dec.cursor = multi dec._scroll_cursor_into_view() # key-nav always does; the anchor needs it await c.pause(0.1) app._sync_split("decomp") await c.pause(0.1) - c.check("a multi-instruction C line bands a region (>1 listing row)", - len(lst._link_rows) > 1, - f"line={multi} eas={len(m[multi])} rows={sorted(lst._link_rows)[:8]}") + c.check( + "a multi-instruction C line bands a region (>1 listing row)", + len(lst._link_rows) > 1, + f"line={multi} eas={len(m[multi])} rows={sorted(lst._link_rows)[:8]}", + ) lst.focus() app._active = "listing" await c.pause(0.05) else: - c.check("a multi-instruction C line bands a region (>1 listing row)", - True, "no multi-instruction line in this function (skipped)") + c.check( + "a multi-instruction C line bands a region (>1 listing row)", + True, + "no multi-instruction line in this function (skipped)", + ) # listing drives: move it, the decomp band must track the covering C line lst.focus() for _ in range(6): @@ -854,48 +1531,60 @@ async def _split_view_body(c: Ctx, app, lst, dec): await c.pause(0.2) lea = lst._cursor_ea() dl = dec._link_line - c.check("listing cursor links the covering pseudocode line", - dl is not None and lea is not None and dec._line_eas[dl] is not None - and dec._line_eas[dl] <= lea, - f"link_line={dl} lea={hex(lea) if lea else None}") + c.check( + "listing cursor links the covering pseudocode line", + dl is not None + and lea is not None + and dec._line_eas[dl] is not None + and dec._line_eas[dl] <= lea, + f"link_line={dl} lea={hex(lea) if lea else None}", + ) # the companion pane sits LEVEL with the driver's cursor (visual coherence): # the linked row lands at the same viewport offset, not merely on-screen. deep = [i for i, eas in enumerate(m) if eas][10:] row = lst.model.ensure_ea(m[deep[0]][0]) if (deep and lst.model) else None if row is not None and row > 12: lst.scroll_to(y=row - 10, animate=False) - await c.pause(0.2) # let the deferred scroll land - lst.cursor = row # driver cursor now at viewport offset 10 + await c.pause(0.2) # let the deferred scroll land + lst.cursor = row # driver cursor now at viewport offset 10 app._sync_split("listing") - await c.pause(0.2) # let the companion's scroll land + await c.pause(0.2) # let the companion's scroll land drv = lst.cursor - round(lst.scroll_offset.y) link, top = dec._link_line, round(dec.scroll_offset.y) # exact, modulo the unavoidable clamps (can't scroll above line 0, nor # past the end when the pseudocode is shorter than the viewport) - want = min(max(0, (link or 0) - drv), - max(0, dec.total - dec._visible_height())) - c.check("the companion pane sits level with the driver's cursor", - link is not None and top == want, - f"driver_row={drv} link={link} dec_top={top} want={want}") + want = min(max(0, (link or 0) - drv), max(0, dec.total - dec._visible_height())) + c.check( + "the companion pane sits level with the driver's cursor", + link is not None and top == want, + f"driver_row={drv} link={link} dec_top={top} want={want}", + ) # a PURE scroll (wheel/scrollbar) moves no cursor — it must still drag # the companion along (anchors on the viewport once the cursor is gone) before_cur, before_dec = lst.cursor, round(dec.scroll_offset.y) lst.scroll_to(y=round(lst.scroll_offset.y) + 30, animate=False) await c.pause(0.35) - c.check("a pure scroll in the driver drags the companion along", - lst.cursor == before_cur - and round(dec.scroll_offset.y) != before_dec, - f"cursor {before_cur}->{lst.cursor} " - f"dec_top {before_dec}->{round(dec.scroll_offset.y)}") + c.check( + "a pure scroll in the driver drags the companion along", + lst.cursor == before_cur and round(dec.scroll_offset.y) != before_dec, + f"cursor {before_cur}->{lst.cursor} " + f"dec_top {before_dec}->{round(dec.scroll_offset.y)}", + ) await c.press("tab") await c.pause(0.1) - c.check("Tab in split focuses the pseudocode pane", app._active == "decomp", - f"active={app._active}") + c.check( + "Tab in split focuses the pseudocode pane", + app._active == "decomp", + f"active={app._active}", + ) # decomp drives: put the cursor on an addressed pseudocode line (past the # variable decls); the listing band must track the covering instruction row. target = next((i for i, e in enumerate(dec._line_eas) if e is not None), None) - c.check("pseudocode has addressed lines", target is not None, - "no /*0xEA*/ markers in the pseudocode") + c.check( + "pseudocode has addressed lines", + target is not None, + "no /*0xEA*/ markers in the pseudocode", + ) if target is not None: dec.cursor = target dec._scroll_cursor_into_view() @@ -903,27 +1592,39 @@ async def _split_view_body(c: Ctx, app, lst, dec): app._sync_split("decomp") await c.pause(0.1) want = lst.model.ensure_ea(dec._line_eas[target]) - c.check("decomp cursor links the instruction row in the listing", - want in lst._link_rows, - f"link_rows={sorted(lst._link_rows)[:6]} want={want}") + c.check( + "decomp cursor links the instruction row in the listing", + want in lst._link_rows, + f"link_rows={sorted(lst._link_rows)[:6]} want={want}", + ) # and that linked row actually paints a background band (base rows have # no bg; `want` is a deep code row, never the listing's own cursor row) lst.reveal(want) await c.pause(0.05) y = want - round(lst.scroll_offset.y) - banded = (0 <= y < lst.size.height and any( - s.style and s.style.bgcolor is not None for s in lst.render_line(y))) - c.check("the linked instruction row renders a highlight band", banded, - f"y={y} cursor_row={lst.cursor}") + banded = 0 <= y < lst.size.height and any( + s.style and s.style.bgcolor is not None for s in lst.render_line(y) + ) + c.check( + "the linked instruction row renders a highlight band", + banded, + f"y={y} cursor_row={lst.cursor}", + ) await c.press("tab") await c.pause(0.1) - c.check("Tab again focuses the listing pane", app._active == "listing", - f"active={app._active}") + c.check( + "Tab again focuses the listing pane", + app._active == "listing", + f"active={app._active}", + ) # a mouse click on the other pane also makes it the driver (not just Tab) await c.pilot.click(DecompView, offset=(10, 5)) await c.pause(0.15) - c.check("clicking the pseudocode pane makes it the driver", - app._active == "decomp", f"active={app._active}") + c.check( + "clicking the pseudocode pane makes it the driver", + app._active == "decomp", + f"active={app._active}", + ) await c.press("tab") # restore listing as the driver await c.pause(0.1) # cross-function follow: the listing cursor leaving the decompiled function @@ -939,8 +1640,11 @@ async def _split_view_body(c: Ctx, app, lst, dec): await c.pause(0.1) app._sync_split("listing") # cursor now outside the decompiled fn followed = await c.wait(lambda: dec.loaded_ea == other.addr, 25) - c.check("listing cursor crossing into another function re-syncs the decomp", - followed, f"dec={dec.loaded_ea} want={other.addr}") + c.check( + "listing cursor crossing into another function re-syncs the decomp", + followed, + f"dec={dec.loaded_ea} want={other.addr}", + ) # navigation in split keeps BOTH panes on the (new) function nf = c.find_func(lambda f: f.addr != app._cur.ea and f.size > 80) if nf is not None: @@ -948,27 +1652,42 @@ async def _split_view_body(c: Ctx, app, lst, dec): await c.type(hex(nf.addr)) await c.press("enter") nav = await c.wait(lambda: app._cur and app._cur.ea == nf.addr, 15) - c.check("goto in split navigates", nav, - f"cur={app._cur.ea if app._cur else None} want={nf.addr}") - both = await c.wait(lambda: dec.loaded_ea == nf.addr and app._split - and lst.display and dec.display, 25) - c.check("split reloads both panes on navigation", both, - f"dec={dec.loaded_ea} split={app._split}") + c.check( + "goto in split navigates", + nav, + f"cur={app._cur.ea if app._cur else None} want={nf.addr}", + ) + both = await c.wait( + lambda: ( + dec.loaded_ea == nf.addr and app._split and lst.display and dec.display + ), + 25, + ) + c.check( + "split reloads both panes on navigation", + both, + f"dec={dec.loaded_ea} split={app._split}", + ) await c.press("s") - gone = await c.wait(lambda: not app._split and lst.display - and not dec.display, 10) - c.check("'s' exits split back to a single view", gone, - f"split={app._split} lst={lst.display} dec={dec.display}") - c.check("exiting split clears the link bands", - not lst._link_rows and dec._link_line is None, - f"rows={lst._link_rows} line={dec._link_line}") + gone = await c.wait(lambda: not app._split and lst.display and not dec.display, 10) + c.check( + "'s' exits split back to a single view", + gone, + f"split={app._split} lst={lst.display} dec={dec.display}", + ) + c.check( + "exiting split clears the link bands", + not lst._link_rows and dec._link_line is None, + f"rows={lst._link_rows} line={dec._link_line}", + ) @scenario("decomp_fallback") async def s_fallback(c: Ctx): app = c.app - failing = next((f for f in reversed(c.all_funcs()) - if c.prog.decompile(f.addr).failed), None) + failing = next( + (f for f in reversed(c.all_funcs()) if c.prog.decompile(f.addr).failed), None + ) if failing is None: c.check("found a decompile-failing function", False) return @@ -983,15 +1702,23 @@ async def s_fallback(c: Ctx): # was pressed, since the function was opened in the listing. It asserted # nothing, slowly. landed = await c.wait(lambda: _CANNOT_DECOMP in c.status().lower(), 25) - c.check("F5/Tab on an undecompilable function says so", landed, - f"active={app._active} status={c.status()!r}") - c.check("F5/Tab on an undecompilable function falls back to a code view", - app.is_listing and c.dis.display, - f"active={app._active} status={c.status()!r}") + c.check( + "F5/Tab on an undecompilable function says so", + landed, + f"active={app._active} status={c.status()!r}", + ) + c.check( + "F5/Tab on an undecompilable function falls back to a code view", + app.is_listing and c.dis.display, + f"active={app._active} status={c.status()!r}", + ) # a decompilable function F5s into pseudocode await c.open("main", "decomp") - c.check("a decompilable function F5s into pseudocode", - app._active == "decomp" and c.dec.display, f"active={app._active}") + c.check( + "a decompilable function F5s into pseudocode", + app._active == "decomp" and c.dec.display, + f"active={app._active}", + ) @scenario("structs") @@ -999,101 +1726,224 @@ async def s_structs(c: Ctx): app = c.app await c.press("ctrl+t") se_open = await c.wait(lambda: isinstance(app.screen, StructEditor), 10) - c.check("Ctrl+T opens the struct editor", se_open, - f"screen={type(app.screen).__name__}") + c.check( + "Ctrl+T opens the struct editor", se_open, f"screen={type(app.screen).__name__}" + ) if not se_open: return se = app.screen await c.wait(lambda: bool(se._structs), 15) - c.check("struct editor lists existing structs", len(se._structs) > 0, - f"n={len(se._structs)}") + c.check( + "struct editor lists existing structs", + len(se._structs) > 0, + f"n={len(se._structs)}", + ) ta = se.query_one(TextArea) - tname = next((s.name for s in se._structs if s.name == "timespec"), - se._structs[0].name) + tname = next( + (s.name for s in se._structs if s.name == "timespec"), se._structs[0].name + ) idx = next(i for i, s in enumerate(se._structs) if s.name == tname) se.query_one(OptionList).highlighted = idx se.on_option_list_option_selected(type("E", (), {"option_index": idx})()) await c.wait(lambda: tname in ta.text and "{" in ta.text, 15) - c.check("selecting a struct shows its C definition", - tname in ta.text and "{" in ta.text, f"text={ta.text[:40]!r}") + c.check( + "selecting a struct shows its C definition", + tname in ta.text and "{" in ta.text, + f"text={ta.text[:40]!r}", + ) # The definition is C, so it must be coloured as C (no tree-sitter grammar # for it: idatui.highlight fills TextArea's highlight map from Pygments). names = {n for spans in ta._highlights.values() for _, _, n in spans} - c.check("the C definition is syntax-highlighted", - {"keyword", "name"} <= names, f"names={sorted(names)}") - styled = {s.style.color.name for s in ta.render_line(0) - if s.style and s.style.color} - c.check("highlight styles reach the rendered line", len(styled) > 1, - f"colors={sorted(styled)}") + c.check( + "the C definition is syntax-highlighted", + {"keyword", "name"} <= names, + f"names={sorted(names)}", + ) + styled = { + s.style.color.name for s in ta.render_line(0) if s.style and s.style.color + } + c.check( + "highlight styles reach the rendered line", + len(styled) > 1, + f"colors={sorted(styled)}", + ) app._clipboard = "" se.query_one(TextArea).focus() await c.press("ctrl+y") await c.wait(lambda: app._clipboard == ta.text, 10) - c.check("Ctrl+Y copies the struct definition to the clipboard", - bool(app._clipboard) and app._clipboard == ta.text, - f"clip_len={len(app._clipboard)}") + c.check( + "Ctrl+Y copies the struct definition to the clipboard", + bool(app._clipboard) and app._clipboard == ta.text, + f"clip_len={len(app._clipboard)}", + ) sname = "TuiEdTest" await c.press("ctrl+n") await c.pause(0.05) ta.text = f"struct {sname} {{ int a; char b[8]; }};" await c.press("ctrl+s") await c.wait(lambda: any(s.name == sname for s in se._structs), 15) - c.check("Ctrl+S declares a new struct", - any(s.name == sname for s in se._structs), "not created") + c.check( + "Ctrl+S declares a new struct", + any(s.name == sname for s in se._structs), + "not created", + ) await c.wait(lambda: "\n" in ta.text, 10) - c.check("editing re-highlights the definition", - any(n == "keyword" for spans in ta._highlights.values() - for _, _, n in spans), - f"rows={len(ta._highlights)}") - c.check("save auto-formats the definition in the editor", - ta.text.count("\n") >= 3 and f"struct {sname}" in ta.text - and not se._is_dirty(), f"text={ta.text[:50]!r}") + c.check( + "editing re-highlights the definition", + any(n == "keyword" for spans in ta._highlights.values() for _, _, n in spans), + f"rows={len(ta._highlights)}", + ) + c.check( + "save auto-formats the definition in the editor", + ta.text.count("\n") >= 3 + and f"struct {sname}" in ta.text + and not se._is_dirty(), + f"text={ta.text[:50]!r}", + ) idx = next(i for i, s in enumerate(se._structs) if s.name == sname) se.on_option_list_option_selected(type("E", (), {"option_index": idx})()) await c.wait(lambda: sname in ta.text, 10) ta.text = f"struct {sname} {{ int a; char b[8]; long c; }};" await c.press("ctrl+s") - await c.wait(lambda: next((s.members for s in se._structs if s.name == sname), 0) == 3, 15) - c.check("Ctrl+S updates an existing struct in place", - next((s.members for s in se._structs if s.name == sname), 0) == 3, - "member count not 3") + await c.wait( + lambda: next((s.members for s in se._structs if s.name == sname), 0) == 3, 15 + ) + c.check( + "Ctrl+S updates an existing struct in place", + next((s.members for s in se._structs if s.name == sname), 0) == 3, + "member count not 3", + ) ta.text = f"struct {sname} {{ int a; char b[8]; long c; int __unused; }};" await c.press("ctrl+s") await c.wait(lambda: "save failed" in str(se.query_one("#se-status").render()), 15) st = str(se.query_one("#se-status").render()) - c.check("a rejected save fails loudly, naming the reserved field", - "save failed" in st and "__unused" in st, f"status={st!r}") - c.check("a rejected save leaves the struct unchanged", - next((s.members for s in se._structs if s.name == sname), 0) == 3, "changed") + c.check( + "a rejected save fails loudly, naming the reserved field", + "save failed" in st and "__unused" in st, + f"status={st!r}", + ) + c.check( + "a rejected save leaves the struct unchanged", + next((s.members for s in se._structs if s.name == sname), 0) == 3, + "changed", + ) c.check("a rejected save keeps your edited text", "__unused" in ta.text) other = next(i for i, s in enumerate(se._structs) if s.name != sname) se.on_option_list_option_selected(type("E", (), {"option_index": other})()) guard = await c.wait(lambda: isinstance(app.screen, ConfirmScreen), 10) - c.check("unsaved edits prompt before switching structs", guard, - f"screen={type(app.screen).__name__}") + c.check( + "unsaved edits prompt before switching structs", + guard, + f"screen={type(app.screen).__name__}", + ) await c.press("enter") - await c.wait(lambda: isinstance(app.screen, StructEditor) and not se._is_dirty(), 15) + await c.wait( + lambda: isinstance(app.screen, StructEditor) and not se._is_dirty(), 15 + ) idx = next(i for i, s in enumerate(se._structs) if s.name == sname) se.query_one(OptionList).focus() se.query_one(OptionList).highlighted = idx await c.press("d") confirmed = await c.wait(lambda: isinstance(app.screen, ConfirmScreen), 10) - c.check("delete asks for confirmation", confirmed, - f"screen={type(app.screen).__name__}") + c.check( + "delete asks for confirmation", confirmed, f"screen={type(app.screen).__name__}" + ) await c.press("enter") await c.wait(lambda: isinstance(app.screen, StructEditor), 10) - await c.wait(lambda: not any(s.name == sname for s in se._structs) - or "del_type" in str(se.query_one("#se-status").render()), 15) + await c.wait( + lambda: ( + not any(s.name == sname for s in se._structs) + or "del_type" in str(se.query_one("#se-status").render()) + ), + 15, + ) st = str(se.query_one("#se-status").render()) gone = not any(s.name == sname for s in se._structs) - c.check("confirming delete removes the struct (or reports missing tool)", - gone or "del_type" in st, f"gone={gone} status={st!r}") + c.check( + "confirming delete removes the struct (or reports missing tool)", + gone or "del_type" in st, + f"gone={gone} status={st!r}", + ) se.query_one(OptionList).focus() await c.press("escape") await c.wait(lambda: not isinstance(app.screen, StructEditor), 10) c.check("Esc closes the struct editor", not isinstance(app.screen, StructEditor)) +@scenario("splash_scaling") +async def s_splash_scaling(c: Ctx): + """The splash scales the logo to the pane instead of dropping it. + + The bug this pins: the artwork's natural size is ~31 rows plus 10 of box + chrome, and the check was "do you have 41 rows?". A 31-row pane -- what a + split zellij window actually gives you -- was one row short, so the logo + silently disappeared. The terminal scales an image into whatever cell box + it is placed in, so there was never a reason for all-or-nothing. + """ + from idatui import kittygfx + from idatui.app import LOGO_CHROME_ROWS, LOGO_MIN_ROWS, LoadingScreen, logo_cells + + app = c.app + placed: list[tuple] = [] + real_supported, real_upload, real_place = ( + kittygfx.supported, + kittygfx.upload, + kittygfx.place, + ) + kittygfx.supported = lambda: True + kittygfx.upload = lambda *a, **k: True + kittygfx.place = lambda *a, **k: (placed.append(a), True)[1] + try: + for width, height in ((159, 31), (100, 30), (140, 44)): + await c.pilot.resize_terminal(width, height) + await c.pause(0.05) + app.push_screen(LoadingScreen("echo")) + await c.wait(lambda: isinstance(app.screen, LoadingScreen), 5) + scr = app.screen + # push_screen returns before compose has mounted the children. + await c.wait(lambda: scr._cells is not None, 5) + room = height - LOGO_CHROME_ROWS + has_image = bool(scr.query("#loading-image")) + c.check( + f"{width}x{height}: the logo is drawn, not dropped", + has_image and room >= LOGO_MIN_ROWS, + f"image={has_image} room={room}", + ) + if has_image: + cols, rows = scr._cells + c.check( + f"{width}x{height}: scaled to the room available", + rows <= room and rows == min(room, logo_cells()[1]), + f"cells={scr._cells} room={room} natural={logo_cells()}", + ) + await c.wait(lambda: scr.query_one("#loading-box").region.height > 0, 5) + box = scr.query_one("#loading-box").region + c.check( + f"{width}x{height}: the box is not clipped", + box.y >= 0 and box.y + box.height <= height, + f"box={box} screen={height}", + ) + app.pop_screen() + await c.pause(0.05) + c.check( + "a full-size pane still gets the artwork's natural size", + logo_cells(999) == logo_cells(), + f"{logo_cells(999)}", + ) + c.check( + "and the image was actually placed each time", len(placed) >= 3, f"{placed}" + ) + finally: + kittygfx.supported, kittygfx.upload, kittygfx.place = ( + real_supported, + real_upload, + real_place, + ) + # Every later scenario assumes the suite's own geometry. + await c.pilot.resize_terminal(140, 44) + await c.pause(0.05) + + @scenario("modal_centering") async def s_modal_centering(c: Ctx): """Every dialog we define is centred, without anyone maintaining a list. @@ -1108,33 +1958,45 @@ async def s_modal_centering(c: Ctx): import idatui.app as A ours = sorted( - (n for n, v in vars(A).items() - if isinstance(v, type) and issubclass(v, ModalScreen) - and v is not ModalScreen and v.__module__ == A.__name__), - key=str) + ( + n + for n, v in vars(A).items() + if isinstance(v, type) + and issubclass(v, ModalScreen) + and v is not ModalScreen + and v.__module__ == A.__name__ + ), + key=str, + ) c.check("found the app's modal screens", len(ours) >= 8, f"{ours}") styles = A.IdaTui.CSS - c.check("centring is a rule about modals, not a list of them", - "ModalScreen { align: center middle; }" in styles, - "the ModalScreen rule is gone") + c.check( + "centring is a rule about modals, not a list of them", + "ModalScreen { align: center middle; }" in styles, + "the ModalScreen rule is gone", + ) # And prove it REACHES a dialog, rather than just being present in the text. await c.press("ctrl+f") opened = await c.wait(lambda: isinstance(c.app.screen, A.SearchPalette), 10) if not opened: - c.check("the search palette opened", False, - f"screen={type(c.app.screen).__name__}") + c.check( + "the search palette opened", False, f"screen={type(c.app.screen).__name__}" + ) return scr = c.app.screen await c.wait(lambda: scr.query_one("#pal-box").region.height > 0, 5) box = scr.query_one("#pal-box").region above, below = box.y, c.app.size.height - (box.y + box.height) - c.check("the search palette is vertically centred", - box.height > 0 and abs(above - below) <= 1, - f"box={box} screen={c.app.size} above={above} below={below}") + c.check( + "the search palette is vertically centred", + box.height > 0 and abs(above - below) <= 1, + f"box={box} screen={c.app.size} above={above} below={below}", + ) left = box.x right = c.app.size.width - (box.x + box.width) - c.check("and horizontally centred", abs(left - right) <= 1, - f"left={left} right={right}") + c.check( + "and horizontally centred", abs(left - right) <= 1, f"left={left} right={right}" + ) await c.press("escape") await c.wait(lambda: not isinstance(c.app.screen, A.SearchPalette), 5) @@ -1146,8 +2008,9 @@ async def s_db_search(c: Ctx): 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__}") + c.check( + "Ctrl+F opens the search palette", opened, f"screen={type(app.screen).__name__}" + ) if not opened: return pal = app.screen @@ -1157,29 +2020,40 @@ async def s_db_search(c: Ctx): 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]]) + 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)}") + 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)}") + 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" @@ -1192,34 +2066,49 @@ async def s_db_search(c: Ctx): 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}") + 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()}") + 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()}") + 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("f2") # back to text await c.press("enter") - await c.wait(lambda: bool(pal._hits) and pal._searched - and pal._searched[0] == "text", 30) + 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__}") + 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}") + 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") @@ -1242,8 +2131,9 @@ async def s_export_findings(c: Ctx): await c.open(fn.addr, "listing") # Make something to find: a rename and a comment, through the real paths. - app.program.client.invoke( - "rename", batch={"func": {"addr": hex(fn.addr), "name": newname}}) + app.program.client.call( + remote_ops.rename, batch={"func": {"addr": hex(fn.addr), "name": newname}} + ) app.program.bump_names() app.program.set_comment(fn.addr, note) app.program.invalidate(fn.addr) @@ -1259,8 +2149,11 @@ async def s_export_findings(c: Ctx): inp = app.query_one("#export", Input) opened = await c.wait(lambda: inp.display, 5) c.check("Ctrl+E opens the export prompt", opened, f"display={inp.display}") - c.check("the prompt is prefilled with a path beside the binary", - inp.value == default_path(app._open_path), f"value={inp.value!r}") + c.check( + "the prompt is prefilled with a path beside the binary", + inp.value == default_path(app._open_path), + f"value={inp.value!r}", + ) inp.value = out await c.press("enter") written = await c.wait(lambda: os.path.exists(out), 30) @@ -1268,21 +2161,26 @@ async def s_export_findings(c: Ctx): if not written: return doc = open(out, encoding="utf-8").read() - c.check("the report is markdown with the expected sections", - doc.startswith("# Findings") and "## Comments" in doc - and "## Named functions" in doc, doc[:60]) - c.check("a comment written this session is in it", note in doc, - doc[:200]) - c.check("and the function it belongs to is named", newname in doc, - doc[:200]) - c.check("the report is sourced from the journal, not a scan", - "idatui's edit journal" in doc, - [l for l in doc.splitlines() if "**source**" in l]) - c.check("the analyzer's own comments stay out of it", - "switch jump" not in doc and "jumptable" not in doc, - [l for l in doc.splitlines() if "switch" in l][:2]) - c.check("the status line says where it went", - out in c.status(), c.status()) + c.check( + "the report is markdown with the expected sections", + doc.startswith("# Findings") + and "## Comments" in doc + and "## Named functions" in doc, + doc[:60], + ) + c.check("a comment written this session is in it", note in doc, doc[:200]) + c.check("and the function it belongs to is named", newname in doc, doc[:200]) + c.check( + "the report is sourced from the journal, not a scan", + "idatui's edit journal" in doc, + [l for l in doc.splitlines() if "**source**" in l], + ) + c.check( + "the analyzer's own comments stay out of it", + "switch jump" not in doc and "jumptable" not in doc, + [l for l in doc.splitlines() if "switch" in l][:2], + ) + c.check("the status line says where it went", out in c.status(), c.status()) # The journal has to survive the database, or a report is only ever # about the session that happened to be open. from idatui.journal import Journal @@ -1290,14 +2188,17 @@ async def s_export_findings(c: Ctx): app.journal.flush(app.program) reloaded = Journal() reloaded.load(app.program) - c.check("the journal round-trips through the .i64", - fn.addr in reloaded.addresses(), - f"{len(reloaded)} entries, {sorted(reloaded.addresses())[:3]}") + c.check( + "the journal round-trips through the .i64", + fn.addr in reloaded.addresses(), + f"{len(reloaded)} entries, {sorted(reloaded.addresses())[:3]}", + ) finally: # Idempotent: hand the database back exactly as we found it. app.program.set_comment(fn.addr, "") - app.program.client.invoke( - "rename", batch={"func": {"addr": hex(fn.addr), "name": old}}) + app.program.client.call( + remote_ops.rename, batch={"func": {"addr": hex(fn.addr), "name": old}} + ) app.program.bump_names() app.program.invalidate(fn.addr) if os.path.exists(out): @@ -1309,8 +2210,11 @@ async def s_struct_filter(c: Ctx): app = c.app await c.press("ctrl+t") if not await c.wait(lambda: isinstance(app.screen, StructEditor), 10): - c.check("Ctrl+T opens the struct editor", False, - f"screen={type(app.screen).__name__}") + c.check( + "Ctrl+T opens the struct editor", + False, + f"screen={type(app.screen).__name__}", + ) return se = app.screen await c.wait(lambda: bool(se._structs), 15) @@ -1325,26 +2229,55 @@ async def s_struct_filter(c: Ctx): ol.focus() await c.press("slash") opened = await c.wait(lambda: inp.display and app.focused is inp, 5) - c.check("'/' from the list opens the struct filter", opened, - f"display={inp.display} focus={getattr(app.focused, 'id', None)}") + c.check( + "'/' from the list opens the struct filter", + opened, + f"display={inp.display} focus={getattr(app.focused, 'id', None)}", + ) + + # PgDn from the FILTER: this screen can't use OptionListNav (ctrl+n is "new + # type" here), so it forwards through its own guarded _page(). Checked while + # the list is still unfiltered, so there is something to page through. + await c.wait(lambda: ol.scrollable_content_region.height >= 1, 5) + if ol.option_count > 2: + ol.highlighted = 0 + await c.press("pagedown") + c.check( + "PgDn pages the struct list from the filter prompt", + (ol.highlighted or 0) > 1, + f"highlighted={ol.highlighted} n={ol.option_count}", + ) + await c.press("pageup") + c.check( + "PgUp returns to the first struct", + (ol.highlighted or 0) == 0, + f"highlighted={ol.highlighted}", + ) + for ch in q: await c.press(ch) await c.wait(lambda: len(se._structs) < total, 5) - c.check("typing fuzzy-filters the struct list", - 0 < len(se._structs) < total and - any(s.name == target for s in se._structs), - f"q={q!r} {len(se._structs)}/{total}") + c.check( + "typing fuzzy-filters the struct list", + 0 < len(se._structs) < total and any(s.name == target for s in se._structs), + f"q={q!r} {len(se._structs)}/{total}", + ) cap = str(se.query_one("#se-title", Static).render()) - c.check("the caption counts what the filter kept", - f"{len(se._structs)}/{total}" in cap, f"caption={cap!r}") + c.check( + "the caption counts what the filter kept", + f"{len(se._structs)}/{total}" in cap, + f"caption={cap!r}", + ) # 'd' is the delete binding on this screen: in the prompt it must be a # character, not a destructive verb aimed at the highlighted struct. await c.press("d") await c.pause(0.05) - c.check("'d' in the filter types instead of deleting", - isinstance(app.screen, StructEditor) and inp.value == q + "d", - f"screen={type(app.screen).__name__} value={inp.value!r}") + c.check( + "'d' in the filter types instead of deleting", + isinstance(app.screen, StructEditor) and inp.value == q + "d", + f"screen={type(app.screen).__name__} value={inp.value!r}", + ) await c.press("backspace") await c.wait(lambda: inp.value == q, 5) @@ -1352,34 +2285,45 @@ async def s_struct_filter(c: Ctx): before = ol.highlighted await c.press("down") await c.pause(0.05) - c.check("arrows move the list while the filter has focus", - app.focused is inp and (ol.highlighted != before - or ol.option_count == 1), - f"{before} -> {ol.highlighted} of {ol.option_count}") + c.check( + "arrows move the list while the filter has focus", + app.focused is inp and (ol.highlighted != before or ol.option_count == 1), + f"{before} -> {ol.highlighted} of {ol.option_count}", + ) sel = se._structs[ol.highlighted or 0].name ta = se.query_one(TextArea) ta.text = "" await c.press("enter") loaded = await c.wait(lambda: sel in ta.text, 15) - c.check("Enter in the filter loads the highlighted struct", loaded, - f"want {sel!r} in {ta.text[:40]!r}") + c.check( + "Enter in the filter loads the highlighted struct", + loaded, + f"want {sel!r} in {ta.text[:40]!r}", + ) # Esc backs out one level at a time: definition -> filter -> dialog. await c.press("escape") await c.wait(lambda: app.focused is ol, 5) - c.check("Esc leaves the definition for the list", app.focused is ol, - f"focus={getattr(app.focused, 'id', None)}") + c.check( + "Esc leaves the definition for the list", + app.focused is ol, + f"focus={getattr(app.focused, 'id', None)}", + ) await c.press("escape") cleared = await c.wait(lambda: len(se._structs) == total, 5) - c.check("Esc clears the filter instead of closing", - cleared and not inp.display and isinstance(app.screen, StructEditor), - f"n={len(se._structs)}/{total} display={inp.display}") + c.check( + "Esc clears the filter instead of closing", + cleared and not inp.display and isinstance(app.screen, StructEditor), + f"n={len(se._structs)}/{total} display={inp.display}", + ) await c.press("escape") await c.wait(lambda: not isinstance(app.screen, StructEditor), 10) - c.check("a third Esc closes the editor", - not isinstance(app.screen, StructEditor), - f"screen={type(app.screen).__name__}") + c.check( + "a third Esc closes the editor", + not isinstance(app.screen, StructEditor), + f"screen={type(app.screen).__name__}", + ) @scenario("open_default_view") @@ -1389,9 +2333,11 @@ async def s_open(c: Ctx): app._open_function(fn.addr, fn.name) await c.wait(lambda: app._cur and app._cur.ea == fn.addr, 20) await c.wait(lambda: c.lst.total > 0, 30) - c.check("opening a function shows the linear listing by default", - app._active == "listing" and c.lst.display and c.lst.total > 0, - f"active={app._active} total={c.lst.total}") + c.check( + "opening a function shows the linear listing by default", + app._active == "listing" and c.lst.display and c.lst.total > 0, + f"active={app._active} total={c.lst.total}", + ) print(f" biggest = {fn.name} ({c.lst.total} listing rows)") @@ -1400,15 +2346,19 @@ async def s_disasm_nav(c: Ctx): app, view = c.app, c.dis await c.open_biggest("listing") view.focus() - c.check("first instruction cached", - view.model is not None and view.model.cached_line(0) is not None) + c.check( + "first instruction cached", + view.model is not None and view.model.cached_line(0) is not None, + ) for _ in range(5): await c.press("pagedown") await c.pause(0.025) c.check("pagedown moved the cursor", view.cursor > 0, f"cursor={view.cursor}") await c.wait(lambda: view.model.cached_line(view.cursor) is not None, 15) - c.check("cursor line eventually cached (bg fetch)", - view.model.cached_line(view.cursor) is not None) + c.check( + "cursor line eventually cached (bg fetch)", + view.model.cached_line(view.cursor) is not None, + ) c.check("status shows an address", "@ 0x" in c.status(), c.status()) # Ctrl+Y copies the current code line. view.focus() @@ -1416,14 +2366,20 @@ async def s_disasm_nav(c: Ctx): app._clipboard = "" await c.press("ctrl+y") await c.wait(lambda: app._clipboard == cur_line, 10) - c.check("Ctrl+Y copies the current code line to the clipboard", - bool(cur_line) and app._clipboard == cur_line, f"clip={app._clipboard!r}") + c.check( + "Ctrl+Y copies the current code line to the clipboard", + bool(cur_line) and app._clipboard == cur_line, + f"clip={app._clipboard!r}", + ) # goto-bottom must not hang on a huge function (ctrl+end; plain 'end' now # moves the cursor to end-of-line). await c.press("ctrl+end") await c.pause(0.05) - c.check("goto-bottom lands near end", view.cursor >= view.total - 1, - f"cursor={view.cursor}/{view.total}") + c.check( + "goto-bottom lands near end", + view.cursor >= view.total - 1, + f"cursor={view.cursor}/{view.total}", + ) @scenario("hex") @@ -1437,23 +2393,52 @@ async def s_hex(c: Ctx): await c.press("backslash") await c.wait(lambda: app._active == "hex", 10) hx = c.hex - await c.wait(lambda: hx.model is not None - and hx.model.row(hx.cursor // 16)[1] is not None, 20) - c.check("backslash opens the hex view synced to the code cursor", - app._active == "hex" and code_ea is not None and hx.cursor_va() == code_ea, - f"active={app._active} hexva={hx.cursor_va():#x} ea={code_ea}") + await c.wait( + lambda: hx.model is not None and hx.model.row(hx.cursor // 16)[1] is not None, + 20, + ) + c.check( + "backslash opens the hex view synced to the code cursor", + app._active == "hex" and code_ea is not None and hx.cursor_va() == code_ea, + f"active={app._active} hexva={hx.cursor_va():#x} ea={code_ea}", + ) want = app.program.read_bytes(code_ea, 1) _, rb = hx.model.row(hx.cursor // 16) - c.check("hex shows the actual byte at that address", - rb is not None and rb[hx.cursor % 16] == want[0], - f"got={rb[hx.cursor % 16] if rb else None} want={want[0]}") + c.check( + "hex shows the actual byte at that address", + rb is not None and rb[hx.cursor % 16] == want[0], + f"got={rb[hx.cursor % 16] if rb else None} want={want[0]}", + ) + old_va = hx.cursor_va() + block = (old_va - hx.model.start) // hx.model.BLOCK + old_bytes = hx.model._blocks.get(block) + await c.press("ctrl+r") + reloaded = await c.wait( + lambda: ( + hx.model._blocks.get(block) is not None + and hx.model._blocks.get(block) is not old_bytes + ), + 20, + ) + c.check("Ctrl+R refetches the visible hex block", reloaded) + c.check( + "Ctrl+R preserves the hex cursor", + hx.cursor_va() == old_va, + f"got={hx.cursor_va():#x} want={old_va:#x}", + ) await c.press("l") await c.pause(0.1) - c.check("hex cursor steps one byte", hx.cursor_va() == code_ea + 1, - f"va={hx.cursor_va():#x}") + c.check( + "hex cursor steps one byte", + hx.cursor_va() == code_ea + 1, + f"va={hx.cursor_va():#x}", + ) fo = app.program.file_offset(hx.cursor_va()) - c.check("hex carries a file offset for a mapped (.text) address", - fo is not None and hx.model.file_offset(hx.cursor_va()) == fo, f"fo={fo}") + c.check( + "hex carries a file offset for a mapped (.text) address", + fo is not None and hx.model.file_offset(hx.cursor_va()) == fo, + f"fo={fo}", + ) rng = app.program.image_range() target_va = rng[0] + (rng[1] - rng[0]) // 2 await c.press("g") @@ -1461,14 +2446,17 @@ async def s_hex(c: Ctx): await c.type(hex(target_va)) await c.press("enter") await c.wait(lambda: hx.cursor_va() == target_va, 15) - c.check("'g' in the hex view jumps the cursor to an address", - hx.cursor_va() == target_va, f"va={hx.cursor_va():#x} want={target_va:#x}") + c.check( + "'g' in the hex view jumps the cursor to an address", + hx.cursor_va() == target_va, + f"va={hx.cursor_va():#x} want={target_va:#x}", + ) # -- a user scroll freezes the cursor's screen row (points at a new byte) -- await c.press("g") await c.type(hex(rng[0])) await c.press("enter") await c.wait(lambda: hx.cursor_va() == rng[0], 10) - for _ in range(8): # cursor to viewport row 8 (top still 0) + for _ in range(8): # cursor to viewport row 8 (top still 0) await c.press("j") await c.pause(0.1) top0 = round(hx.scroll_offset.y) @@ -1477,24 +2465,33 @@ async def s_hex(c: Ctx): await c.pause(0.15) top1 = round(hx.scroll_offset.y) c.check("hex viewport scrolled", top1 >= top0 + 20, f"top0={top0} top1={top1}") - c.check("hex cursor's screen row stays frozen on scroll", - hx.cursor // 16 - top1 == screen_row, - f"screen_row={screen_row} now={hx.cursor // 16 - top1} top1={top1}") + c.check( + "hex cursor's screen row stays frozen on scroll", + hx.cursor // 16 - top1 == screen_row, + f"screen_row={screen_row} now={hx.cursor // 16 - top1} top1={top1}", + ) PAD = 1 # HexView { padding: 0 1 } -> content is inset one col await c.pilot.click(HexView, offset=(PAD + 19 + 3 * 3, 5)) # hex byte 3, row 5 await c.pause(0.1) - c.check("clicking the hex pane moves the cursor to the clicked byte", - hx.cursor == (top1 + 5) * 16 + 3, - f"cursor={hx.cursor} want={(top1 + 5) * 16 + 3} top1={top1}") + c.check( + "clicking the hex pane moves the cursor to the clicked byte", + hx.cursor == (top1 + 5) * 16 + 3, + f"cursor={hx.cursor} want={(top1 + 5) * 16 + 3} top1={top1}", + ) await c.pilot.click(HexView, offset=(PAD + 70 + 10, 7)) # ascii byte 10, row 7 await c.pause(0.1) - c.check("clicking the ascii pane maps to the right byte", - hx.cursor == (top1 + 7) * 16 + 10, - f"cursor={hx.cursor} want={(top1 + 7) * 16 + 10}") + c.check( + "clicking the ascii pane maps to the right byte", + hx.cursor == (top1 + 7) * 16 + 10, + f"cursor={hx.cursor} want={(top1 + 7) * 16 + 10}", + ) await c.press("backslash") await c.wait(lambda: app._active != "hex", 10) - c.check("backslash returns from hex to the code view", - app._active == "listing", f"active={app._active}") + c.check( + "backslash returns from hex to the code view", + app._active == "listing", + f"active={app._active}", + ) @scenario("filter") @@ -1504,24 +2501,34 @@ async def s_filter(c: Ctx): nfuncs = table.row_count # row selection opens a function fn = c.biggest() - ridx = next((i for i in range(nfuncs) - if int(str(table.get_row_at(i)[0]), 16) == fn.addr), 0) + ridx = next( + (i for i in range(nfuncs) if int(str(table.get_row_at(i)[0]), 16) == fn.addr), 0 + ) table.move_cursor(row=ridx) table.focus() await c.press("enter") await c.wait(lambda: app._cur and app._cur.ea == fn.addr, 20) - c.check("selecting a table row opens that function", - bool(app._cur) and app._cur.ea == fn.addr, f"cur={app._cur.ea if app._cur else None}") + c.check( + "selecting a table row opens that function", + bool(app._cur) and app._cur.ea == fn.addr, + f"cur={app._cur.ea if app._cur else None}", + ) # filter round-trip. Derive the glob from real names: this used to hardcode # 'sub_1*', which matches NOTHING in a binary whose code never reaches # 0x1xxx (echo's functions are sub_2xxx..sub_7xxx) — a deterministic failure # that looked like a flake, and left the table empty for the next scenario. subs = sorted(f.name for f in c.all_funcs() if f.name.startswith("sub_")) term = (subs[0][:5] + "*") if subs else "" - want = sum(1 for f in c.all_funcs() - if fnmatch.fnmatch(f.name.lower(), term.lower())) if term else 0 - c.check("picked a glob that actually matches (test self-check)", - 0 < want < nfuncs, f"term={term!r} want={want} of {nfuncs}") + want = ( + sum(1 for f in c.all_funcs() if fnmatch.fnmatch(f.name.lower(), term.lower())) + if term + else 0 + ) + c.check( + "picked a glob that actually matches (test self-check)", + 0 < want < nfuncs, + f"term={term!r} want={want} of {nfuncs}", + ) table.focus() await c.press("slash") await c.pause(0.05) @@ -1529,32 +2536,44 @@ async def s_filter(c: Ctx): await c.press(ch if ch != "*" else "asterisk") await c.press("enter") filtered = await c.wait(lambda: table.row_count == want, 15) - c.check("filter narrowed the list to exactly the matches", filtered, - f"term={term!r} rows={table.row_count} want={want} of {nfuncs}") + c.check( + "filter narrowed the list to exactly the matches", + filtered, + f"term={term!r} rows={table.row_count} want={want} of {nfuncs}", + ) # pane toggle left = app.query_one("#left", FunctionsPanel) await c.press("ctrl+b") await c.pause(0.05) - c.check("ctrl+b hides functions pane + focuses the code view", - not left.display and isinstance(app.focused, (ListingView, DecompView)), - f"display={left.display} focus={type(app.focused).__name__}") + c.check( + "ctrl+b hides functions pane + focuses the code view", + not left.display and isinstance(app.focused, (ListingView, DecompView)), + f"display={left.display} focus={type(app.focused).__name__}", + ) await c.press("ctrl+b") await c.pause(0.05) - c.check("ctrl+b again restores pane + focuses table", - left.display and isinstance(app.focused, DataTable), - f"display={left.display} focus={type(app.focused).__name__}") + c.check( + "ctrl+b again restores pane + focuses table", + left.display and isinstance(app.focused, DataTable), + f"display={left.display} focus={type(app.focused).__name__}", + ) @scenario("view_toggle") async def s_view_toggle(c: Ctx): app, dis, dec = c.app, c.dis, c.dec await c.open_biggest("decomp") - pc = await c.wait(lambda: dec.display and dec.loaded_ea is not None - and app._active == "decomp", 25) + pc = await c.wait( + lambda: dec.display and dec.loaded_ea is not None and app._active == "decomp", + 25, + ) c.check("pseudocode view shows", pc, f"active={app._active}") c.check("pseudocode has many lines", dec.total > 20, f"lines={dec.total}") - 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) + 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 @@ -1570,21 +2589,27 @@ async def s_view_toggle(c: Ctx): 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__}") + 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") # decomp -> listing runs through _toggle_to_listing, a background worker, so # _active only flips once the listing model has loaded. A fixed pause held in # a short run and lost the race in a full one. - switched = await c.wait(lambda: app._active == "listing" and dis.display - and not dec.display, 20) - c.check("tab switches to disassembly", switched, - f"active={app._active} split={app._split} " - f"focus={type(app.focused).__name__} " - f"lst={dis.display} dec={dec.display}") + switched = await c.wait( + lambda: app._active == "listing" and dis.display and not dec.display, 20 + ) + c.check( + "tab switches to disassembly", + switched, + f"active={app._active} split={app._split} " + f"focus={type(app.focused).__name__} " + f"lst={dis.display} dec={dec.display}", + ) # _toggle_to_listing repositions asynchronously; F5 below reads the listing # cursor's ea and no-ops if it isn't on an addressed row yet. await c.wait(lambda: c.lst._cursor_ea() is not None, 10) @@ -1594,34 +2619,54 @@ async def s_view_toggle(c: Ctx): dec.loaded_ea = None app.action_toggle_view() cover = dec._cover_widget - c.check("F5 from the listing raises the 'decompiling…' overlay", - dec.loading and cover is not None and "decomp-loading" in cover.classes - and "decompiling" in str(cover.render()), - f"loading={dec.loading} cover={cover!r}") - await c.wait(lambda: app._active == "decomp" and not dec.loading - and dec._cover_widget is None, 25) - c.check("overlay clears when the decompile finishes", - not dec.loading and dec._cover_widget is None) + c.check( + "F5 from the listing raises the 'decompiling…' overlay", + dec.loading + and cover is not None + and "decomp-loading" in cover.classes + and "decompiling" in str(cover.render()), + f"loading={dec.loading} cover={cover!r}", + ) + await c.wait( + lambda: ( + app._active == "decomp" and not dec.loading and dec._cover_widget is None + ), + 25, + ) + c.check( + "overlay clears when the decompile finishes", + not dec.loading and dec._cover_widget is None, + ) # F5 on an ALREADY-loaded function must still clear the overlay (regression: # the F5-raised overlay had nothing to clear it in the 'already loaded' branch # -> spinner stuck forever). await c.press("tab") # -> listing await c.wait(lambda: app._active == "listing", 10) app.action_toggle_view() # F5 the same, cached function again - cleared = await c.wait(lambda: app._active == "decomp" and not dec.loading - and dec._cover_widget is None, 15) - c.check("re-decompiling an already-loaded function clears the overlay", cleared, - f"loading={dec.loading} cover={dec._cover_widget!r}") + cleared = await c.wait( + lambda: ( + app._active == "decomp" and not dec.loading and dec._cover_widget is None + ), + 15, + ) + c.check( + "re-decompiling an already-loaded function clears the overlay", + cleared, + f"loading={dec.loading} cover={dec._cover_widget!r}", + ) # line-number gutter dec.scroll_to(0, 0, animate=False) await c.pause(0.025) row0 = "".join(seg.text for seg in dec.render_line(0)) - c.check("pseudocode has a numbered gutter (line 1 first)", - dec._gutter > 0 and row0[:dec._gutter].strip() == "1", - f"gutter={dec._gutter} row0={row0[:10]!r}") + c.check( + "pseudocode has a numbered gutter (line 1 first)", + dec._gutter > 0 and row0[: dec._gutter].strip() == "1", + f"gutter={dec._gutter} row0={row0[:10]!r}", + ) # Home/End move along the line here too (they used to scroll to top/bottom). - line = next((i for i, t in enumerate(dec._texts) - if t.startswith(" ") and t.strip()), None) + line = next( + (i for i, t in enumerate(dec._texts) if t.startswith(" ") and t.strip()), None + ) if line is not None: text = dec._texts[line] dec.focus() @@ -1631,28 +2676,37 @@ async def s_view_toggle(c: Ctx): top = round(dec.scroll_offset.y) await c.press("end") await c.pause(0.05) - c.check("<end> in pseudocode goes to end-of-line, not the bottom", - dec.cursor == line and dec.cursor_x == max(len(text) - 1, 0) - and round(dec.scroll_offset.y) == top, - f"line={dec.cursor} col={dec.cursor_x} len={len(text)}") + c.check( + "<end> in pseudocode goes to end-of-line, not the bottom", + dec.cursor == line + and dec.cursor_x == max(len(text) - 1, 0) + and round(dec.scroll_offset.y) == top, + f"line={dec.cursor} col={dec.cursor_x} len={len(text)}", + ) await c.press("home") await c.pause(0.05) - c.check("<home> in pseudocode goes to start-of-line", - dec.cursor == line and dec.cursor_x == 0, - f"line={dec.cursor} col={dec.cursor_x}") + c.check( + "<home> in pseudocode goes to start-of-line", + dec.cursor == line and dec.cursor_x == 0, + f"line={dec.cursor} col={dec.cursor_x}", + ) await c.press("shift+home") await c.pause(0.05) - c.check("<shift+home> skips the indentation", - dec.cursor_x == len(text) - len(text.lstrip()), - f"col={dec.cursor_x} indent={len(text) - len(text.lstrip())}") + c.check( + "<shift+home> skips the indentation", + dec.cursor_x == len(text) - len(text.lstrip()), + f"col={dec.cursor_x} indent={len(text) - len(text.lstrip())}", + ) await c.press("ctrl+end") await c.pause(0.1) - c.check("<ctrl+end> still goes to the bottom", - dec.cursor >= dec.total - 1, f"{dec.cursor}/{dec.total}") + c.check( + "<ctrl+end> still goes to the bottom", + dec.cursor >= dec.total - 1, + f"{dec.cursor}/{dec.total}", + ) await c.press("ctrl+home") await c.pause(0.1) - c.check("<ctrl+home> still goes to the top", dec.cursor == 0, - f"{dec.cursor}") + c.check("<ctrl+home> still goes to the top", dec.cursor == 0, f"{dec.cursor}") @scenario("search") @@ -1672,26 +2726,39 @@ async def s_search(c: Ctx): # the unified listing searches the whole segment (load_all) -> allow time await c.wait(lambda: bool(dis._matches), 45) c.check("search finds matches", len(dis._matches) > 0, f"term={term!r}") - c.check("cursor sits on a match", dis.cursor in dis._matches, f"cursor={dis.cursor}") - c.check("match substring highlighted", - bool(dis._ranges.get(dis.cursor)), str(dis._ranges.get(dis.cursor))) - c.check("search cursor lands on the match's starting column", - bool(dis._ranges.get(dis.cursor)) - and dis.cursor_x == dis._ranges[dis.cursor][0][0], - f"cursor_x={dis.cursor_x} ranges={dis._ranges.get(dis.cursor)}") + c.check( + "cursor sits on a match", dis.cursor in dis._matches, f"cursor={dis.cursor}" + ) + c.check( + "match substring highlighted", + bool(dis._ranges.get(dis.cursor)), + str(dis._ranges.get(dis.cursor)), + ) + c.check( + "search cursor lands on the match's starting column", + bool(dis._ranges.get(dis.cursor)) + and dis.cursor_x == dis._ranges[dis.cursor][0][0], + f"cursor_x={dis.cursor_x} ranges={dis._ranges.get(dis.cursor)}", + ) prev = dis.cursor await c.press("slash") await c.pause(0.05) await c.press("enter") await c.pause(0.05) - c.check("'/' repeats to next match", - dis.cursor != prev and dis.cursor in dis._matches, f"cursor={dis.cursor}") + c.check( + "'/' repeats to next match", + dis.cursor != prev and dis.cursor in dis._matches, + f"cursor={dis.cursor}", + ) await c.press("question_mark") await c.pause(0.05) await c.press("enter") await c.pause(0.05) - c.check("'?' repeats to previous match", dis.cursor in dis._matches, - f"cursor={dis.cursor}") + c.check( + "'?' repeats to previous match", + dis.cursor in dis._matches, + f"cursor={dis.cursor}", + ) # incremental preview + visible bar + Esc cancel si = app.query_one("#search", Input) status = app.query_one("#status", Static) @@ -1699,22 +2766,31 @@ async def s_search(c: Ctx): # display flips synchronously; the REGION only exists once Textual has laid # the prompt out, which is a frame, not a worker. await c.wait(lambda: si.display and si.region.height >= 1, 5) - c.check("search bar visible, status hidden (no overlap)", - si.display and not status.display, f"si={si.display} status={status.display}") - c.check("search input owns the bottom row (nothing overlaps it)", - si.region.height >= 1 - and si.region.y + si.region.height == app.size.height, - f"search={si.region} screen={app.size}") + c.check( + "search bar visible, status hidden (no overlap)", + si.display and not status.display, + f"si={si.display} status={status.display}", + ) + c.check( + "search input owns the bottom row (nothing overlaps it)", + si.region.height >= 1 and si.region.y + si.region.height == app.size.height, + f"search={si.region} screen={app.size}", + ) for ch in term: await c.press(ch) await c.pause(0.05) - c.check("matches highlight incrementally (before Enter)", - len(dis._matches) > 0 and si.value == term, f"val={si.value!r}") + c.check( + "matches highlight incrementally (before Enter)", + len(dis._matches) > 0 and si.value == term, + f"val={si.value!r}", + ) await c.press("escape") await c.pause(0.1) - c.check("Esc cancels: status restored, matches cleared", - status.display and not si.display and not dis._matches, - f"status={status.display} si={si.display} m={len(dis._matches)}") + c.check( + "Esc cancels: status restored, matches cleared", + status.display and not si.display and not dis._matches, + f"status={status.display} si={si.display} m={len(dis._matches)}", + ) @scenario("incr_filter") @@ -1730,19 +2806,30 @@ async def s_incr_filter(c: Ctx): # settling can't see it. Wait for the effect instead of guessing at the # debounce: it returns the moment the rows are rebuilt. await c.wait(lambda: 0 < table.row_count < full, 5) - c.check("filter narrows incrementally as you type", - 0 < table.row_count < full, f"{table.row_count}/{full}") + c.check( + "filter narrows incrementally as you type", + 0 < table.row_count < full, + f"{table.row_count}/{full}", + ) cell = table.get_row_at(0)[1] - c.check("filter highlights matched substring in name", - isinstance(cell, Text) and any(s.style for s in cell.spans), repr(str(cell))) + c.check( + "filter highlights matched substring in name", + isinstance(cell, Text) and any(s.style for s in cell.spans), + repr(str(cell)), + ) await c.press("enter") await c.wait(lambda: isinstance(app.focused, DataTable), 5) - c.check("Enter keeps filter + focuses table", - isinstance(app.focused, DataTable) and table.row_count < full) + c.check( + "Enter keeps filter + focuses table", + isinstance(app.focused, DataTable) and table.row_count < full, + ) await c.press("escape") await c.wait(lambda: table.row_count == full, 5) - c.check("Esc on the list clears the filter", table.row_count == full, - f"{table.row_count}/{full}") + c.check( + "Esc on the list clears the filter", + table.row_count == full, + f"{table.row_count}/{full}", + ) @scenario("follow_xrefs") @@ -1751,10 +2838,13 @@ async def s_follow_xrefs(c: Ctx): await c.open_biggest("listing") dis.focus() lines = dis.model.lines(0, 400, prefetch=False) - call_idx = next((i for i, ln in enumerate(lines) - if ln.text.startswith("call ")), None) + call_idx = next( + (i for i, ln in enumerate(lines) if ln.text.startswith("call ")), None + ) if call_idx is None: - c.check("found a call line to exercise follow/xrefs", False, "no call in first 400") + c.check( + "found a call line to exercise follow/xrefs", False, "no call in first 400" + ) return dis.cursor = call_idx dis.refresh() @@ -1768,8 +2858,11 @@ async def s_follow_xrefs(c: Ctx): # failed about one run in ten with cur == orig, at full speed, looking like # a code regression. await c.wait(lambda: len(app._nav) > depth and app._cur.ea != orig, 25) - c.check("Enter follows the call into another function", - app._cur.ea != orig and len(app._nav) > depth, f"cur={app._cur.ea:#x}") + c.check( + "Enter follows the call into another function", + app._cur.ea != orig and len(app._nav) > depth, + f"cur={app._cur.ea:#x}", + ) await c.press("escape") await c.wait(lambda: app._cur.ea == orig, 15) c.check("Esc returns from the follow", app._cur.ea == orig, f"cur={app._cur.ea:#x}") @@ -1779,15 +2872,19 @@ async def s_follow_xrefs(c: Ctx): opened = await c.wait(lambda: isinstance(app.screen, XrefsScreen), 25) c.check("'x' opens the xrefs popup", opened, f"screen={type(app.screen).__name__}") if opened: - c.check("xrefs popup has entries", - app.screen.query_one(OptionList).option_count >= 1) + c.check( + "xrefs popup has entries", + app.screen.query_one(OptionList).option_count >= 1, + ) await c.press("escape") await c.pause(0.1) c.check("Esc closes the xrefs popup", not isinstance(app.screen, XrefsScreen)) xf = None for cand in c.all_funcs()[:600]: - codex = [x for x in app.program.xrefs_to(cand.addr) if x.type == "code" and x.frm] + codex = [ + x for x in app.program.xrefs_to(cand.addr) if x.type == "code" and x.frm + ] if codex: xf = (cand, codex[0]) break @@ -1805,19 +2902,27 @@ async def s_follow_xrefs(c: Ctx): await c.wait(lambda: not isinstance(app.screen, XrefsScreen), 25) # xref-select lands the listing cursor on the referencing SITE (frm) await c.wait(lambda: c.lst._cursor_ea() == xref.frm, 25) - c.check("xref-select lands the cursor on the referencing site", - c.lst._cursor_ea() == xref.frm, - f"cur_ea={c.lst._cursor_ea()} want={xref.frm:#x}") + c.check( + "xref-select lands the cursor on the referencing site", + c.lst._cursor_ea() == xref.frm, + f"cur_ea={c.lst._cursor_ea()} want={xref.frm:#x}", + ) # F5 at the site decompiles the referencing function c.lst.focus() await c.press("tab") landed = await c.wait( - lambda: (app._active == "decomp" and dec.loaded_ea == xref.fn_addr) - or (app.is_listing - and _CANNOT_DECOMP in c.status().lower()), 25) + lambda: ( + (app._active == "decomp" and dec.loaded_ea == xref.fn_addr) + or (app.is_listing and _CANNOT_DECOMP in c.status().lower()) + ), + 25, + ) if app._active == "decomp": - c.check("F5 at the xref site decompiles the referencing function", - dec.loaded_ea == xref.fn_addr, f"loaded={dec.loaded_ea}") + c.check( + "F5 at the xref site decompiles the referencing function", + dec.loaded_ea == xref.fn_addr, + f"loaded={dec.loaded_ea}", + ) await c.press("tab") await c.wait(lambda: app._active == "listing", 20) @@ -1830,9 +2935,13 @@ async def s_follow_xrefs(c: Ctx): dis.refresh() await c.pause(0.025) await c.press("h") - c.check("h moves the column cursor left", dis.cursor_x == 4, f"x={dis.cursor_x}") + c.check( + "h moves the column cursor left", dis.cursor_x == 4, f"x={dis.cursor_x}" + ) await c.press("l", "l") - c.check("l moves the column cursor right", dis.cursor_x == 6, f"x={dis.cursor_x}") + c.check( + "l moves the column cursor right", dis.cursor_x == 6, f"x={dis.cursor_x}" + ) plain = dis._line_plain(call_idx) or "" m = re.search(r"\b(sub_[0-9A-Fa-f]+)", plain) if m: @@ -1840,24 +2949,32 @@ async def s_follow_xrefs(c: Ctx): dis.cursor_x = m.start(1) + 1 dis.refresh() await c.pause(0.05) - c.check("word-under-cursor is the operand symbol", - dis.word_under_cursor() == m.group(1), - f"{dis.word_under_cursor()!r} vs {m.group(1)!r}") + c.check( + "word-under-cursor is the operand symbol", + dis.word_under_cursor() == m.group(1), + f"{dis.word_under_cursor()!r} vs {m.group(1)!r}", + ) depth = len(app._nav) want = app.program.resolve(m.group(1)) await c.press("enter") await c.wait(lambda: len(app._nav) > depth, 25) - c.check("follows the symbol under the cursor", - app._cur.ea == want, f"cur={app._cur.ea:#x} want={want:#x}") + c.check( + "follows the symbol under the cursor", + app._cur.ea == want, + f"cur={app._cur.ea:#x} want={want:#x}", + ) @scenario("xref_labels") async def s_xref_labels(c: Ctx): from collections import Counter, defaultdict + app, dis, dec = c.app, c.dis, c.dec multi = None for cand in c.all_funcs()[:200]: - callers = Counter(x.fn_addr for x in app.program.xrefs_to(cand.addr) if x.fn_name) + callers = Counter( + x.fn_addr for x in app.program.xrefs_to(cand.addr) if x.fn_name + ) if any(n >= 2 for n in callers.values()): multi = cand break @@ -1878,9 +2995,14 @@ async def s_xref_labels(c: Ctx): if "+0x" in x: nm, off = x.split("+0x", 1) byfn[nm].add(off) - c.check("xref labels distinguish multiple sites in a function by offset", - any(len(offs) >= 2 for offs in byfn.values()), f"locs={locs[:8]}") - c.check("no xref label is a bare '?'", all(x != "?" for x in locs), f"locs={locs[:8]}") + c.check( + "xref labels distinguish multiple sites in a function by offset", + any(len(offs) >= 2 for offs in byfn.values()), + f"locs={locs[:8]}", + ) + c.check( + "no xref label is a bare '?'", all(x != "?" for x in locs), f"locs={locs[:8]}" + ) await c.press("escape") await c.wait(lambda: not isinstance(app.screen, XrefsScreen), 25) # pre-selection: 'x' at a call site highlights that site in the dialog @@ -1888,8 +3010,9 @@ async def s_xref_labels(c: Ctx): for x in app.program.xrefs_to(multi.addr): if x.fn_name and x.type == "code": bycaller[x.fn_addr].append(x) - csites = next((sorted(v, key=lambda x: x.frm) - for v in bycaller.values() if len(v) >= 2), None) + csites = next( + (sorted(v, key=lambda x: x.frm) for v in bycaller.values() if len(v) >= 2), None + ) if not csites: c.check("found a caller with multiple sites for preselect", False) return @@ -1908,9 +3031,11 @@ async def s_xref_labels(c: Ctx): await c.wait(lambda: isinstance(app.screen, XrefsScreen), 25) hl = app.screen.query_one(OptionList).highlighted it = app.screen._items - c.check("xref dialog pre-selects the site it was invoked from", - hl is not None and it[hl][0] == site.frm, - f"hl={hl} frm={hex(it[hl][0]) if hl is not None else None} want={hex(site.frm)}") + c.check( + "xref dialog pre-selects the site it was invoked from", + hl is not None and it[hl][0] == site.frm, + f"hl={hl} frm={hex(it[hl][0]) if hl is not None else None} want={hex(site.frm)}", + ) await c.press("escape") await c.wait(lambda: not isinstance(app.screen, XrefsScreen), 25) @@ -1941,21 +3066,28 @@ async def s_mouse(c: Ctx): return await c.pilot.click(dis, offset=(mcol + 1, mrow)) await c.pause(0.05) - c.check("single click places the cursor on the clicked token", - dis.cursor == mline and dis.word_under_cursor() == msym, - f"cursor={dis.cursor} (want {mline}) word={dis.word_under_cursor()!r}") + c.check( + "single click places the cursor on the clicked token", + dis.cursor == mline and dis.word_under_cursor() == msym, + f"cursor={dis.cursor} (want {mline}) word={dis.word_under_cursor()!r}", + ) depth = len(app._nav) want = app.program.resolve(msym) await c.pilot.click(dis, offset=(mcol + 1, mrow), times=2) await c.wait(lambda: len(app._nav) > depth, 25) - c.check("double-click follows the symbol", app._cur.ea == want, - f"cur={app._cur.ea:#x} want={want:#x}") + c.check( + "double-click follows the symbol", + app._cur.ea == want, + f"cur={app._cur.ea:#x} want={want:#x}", + ) await c.press("escape") await c.wait(lambda: app._cur.ea != want, 20) await c.wait(lambda: dis.total > 0 and dis.cursor == mline, 20) - c.check("back restores the exact line + column", - dis.cursor == mline and dis.word_under_cursor() == msym, - f"cursor={dis.cursor} (want {mline}) word={dis.word_under_cursor()!r}") + c.check( + "back restores the exact line + column", + dis.cursor == mline and dis.word_under_cursor() == msym, + f"cursor={dis.cursor} (want {mline}) word={dis.word_under_cursor()!r}", + ) @scenario("decomp_nav") @@ -1977,23 +3109,32 @@ async def s_decomp_nav(c: Ctx): await c.press("escape") await c.wait(lambda: dec.loaded_ea == fn.addr, 25) await c.pause(0.1) - c.check("pseudocode-view position restored after jump+back", - dec.cursor == drow and dec.word_under_cursor() == dsym, - f"cursor={dec.cursor} (want {drow}) word={dec.word_under_cursor()!r}") + c.check( + "pseudocode-view position restored after jump+back", + dec.cursor == drow and dec.word_under_cursor() == dsym, + f"cursor={dec.cursor} (want {drow}) word={dec.word_under_cursor()!r}", + ) # follow works with a STALE name (post-rename): ea-marker fallback dstale = app.program.resolve(dsym) old_line = dec._texts[drow] if drow < len(dec._texts) else "" old_ea = dec._line_ea(drow) if old_ea is not None and dsym in old_line: tmp = f"stale_{os.getpid()}" - app.program.client.invoke("rename", batch={"func": {"addr": hex(dstale), "name": tmp}}) + app.program.client.call( + remote_ops.rename, batch={"func": {"addr": hex(dstale), "name": tmp}} + ) app.program.bump_names() d2 = len(app._nav) app._follow_decomp(old_line, dsym, old_ea) await c.wait(lambda: len(app._nav) > d2, 25) - c.check("decomp follow works with a stale name (ea-marker fallback)", - app._cur.ea == dstale, f"cur={app._cur.ea:#x} want={dstale:#x}") - app.program.client.invoke("rename", batch={"func": {"addr": hex(dstale), "name": dsym}}) + c.check( + "decomp follow works with a stale name (ea-marker fallback)", + app._cur.ea == dstale, + f"cur={app._cur.ea:#x} want={dstale:#x}", + ) + app.program.client.call( + remote_ops.rename, batch={"func": {"addr": hex(dstale), "name": dsym}} + ) app.program.bump_names() @@ -2020,9 +3161,11 @@ async def s_decomp_follow_self(c: Ctx): depth = len(app._nav) await c.press("enter") moved = await c.wait(lambda: len(app._nav) > depth, 25) - c.check("decompiler follows a name not in refs (resolve fallback)", - moved and app._cur.ea == fn.addr, - f"moved={moved} cur={hex(app._cur.ea)} want={hex(fn.addr)}") + c.check( + "decompiler follows a name not in refs (resolve fallback)", + moved and app._cur.ea == fn.addr, + f"moved={moved} cur={hex(app._cur.ea)} want={hex(fn.addr)}", + ) @scenario("sort") @@ -2032,19 +3175,28 @@ async def s_sort(c: Ctx): await c.pilot.click(table, offset=(15, 0)) # Function header await c.pause(0.15) snames = [str(table.get_row_at(i)[1]) for i in range(min(20, table.row_count))] - c.check("click Function header sorts by name", - app._sort_col == 1 and snames == sorted(snames, key=str.lower), - f"sort_col={app._sort_col}") + c.check( + "click Function header sorts by name", + app._sort_col == 1 and snames == sorted(snames, key=str.lower), + f"sort_col={app._sort_col}", + ) first_asc = str(table.get_row_at(0)[1]) await c.pilot.click(table, offset=(15, 0)) # reverse await c.pause(0.15) - c.check("click again reverses the sort", - app._sort_reverse and str(table.get_row_at(0)[1]) != first_asc) + c.check( + "click again reverses the sort", + app._sort_reverse and str(table.get_row_at(0)[1]) != first_asc, + ) await c.pilot.click(table, offset=(3, 0)) # Address header await c.pause(0.15) - saddrs = [int(str(table.get_row_at(i)[0]), 16) for i in range(min(20, table.row_count))] - c.check("click Address header sorts by address", - app._sort_col == 0 and saddrs == sorted(saddrs), f"sort_col={app._sort_col}") + saddrs = [ + int(str(table.get_row_at(i)[0]), 16) for i in range(min(20, table.row_count)) + ] + c.check( + "click Address header sorts by address", + app._sort_col == 0 and saddrs == sorted(saddrs), + f"sort_col={app._sort_col}", + ) @scenario("rename") @@ -2065,18 +3217,34 @@ async def s_rename(c: Ctx): await c.press("n") await c.pause(0.1) ri = app.query_one("#rename", Input) - c.check("'n' opens the rename prompt prefilled with the symbol", - ri.display and ri.value == dsym, f"val={ri.value!r}") + c.check( + "'n' opens the rename prompt prefilled with the symbol", + ri.display and ri.value == dsym, + f"val={ri.value!r}", + ) ri.value = newname await c.press("enter") - await c.wait(lambda: app._func_index.by_addr(dtarget) - and app._func_index.by_addr(dtarget).name == newname, 25) - c.check("rename updates the function name", - app._func_index.by_addr(dtarget).name == newname, - app._func_index.by_addr(dtarget).name) - rr = app.program.client.invoke("rename", batch={"func": {"addr": hex(dtarget), "name": dsym}}) - c.check("rename reverted cleanly", - rr.get("summary", {}).get("ok", 0) == 1, str(rr.get("summary"))) + await c.wait( + lambda: ( + app._func_index.by_addr(dtarget) + and app._func_index.by_addr(dtarget).name == newname + ), + 25, + ) + c.check( + "rename updates the function name", + app._func_index.by_addr(dtarget).name == newname, + app._func_index.by_addr(dtarget).name, + ) + rr = app.program.client.call( + remote_ops.rename, batch={"func": {"addr": hex(dtarget), "name": dsym}} + ) + c.check( + "rename reverted cleanly", + rr.get("summary", {}).get("ok", 0) == 1, + str(rr.get("summary")), + ) + # goto label refuse def _find_label(): for i, t in enumerate(dec._texts): @@ -2084,6 +3252,7 @@ async def s_rename(c: Ctx): if mm: return i, mm.start(), mm.group(0) return None + lab = _find_label() if lab is None: await c.open("main", "decomp") @@ -2097,15 +3266,25 @@ async def s_rename(c: Ctx): await c.press("n") await c.pause(0.1) ri2 = app.query_one("#rename", Input) - c.check("renaming a pseudocode label is refused with a clear message", - (not ri2.display) and "label" in c.status().lower(), - f"display={ri2.display} status={c.status()!r}") + c.check( + "renaming a pseudocode label is refused with a clear message", + (not ri2.display) and "label" in c.status().lower(), + f"display={ri2.display} status={c.status()!r}", + ) else: c.check("found a pseudocode label to test", False, "no LABEL_ found") # comment via ';' - cline = next((i for i in range(len(dec._texts)) - if i > 5 and dec._line_ea(i) is not None - and dec._texts[i].strip() and "//" not in dec._texts[i]), None) + cline = next( + ( + i + for i in range(len(dec._texts)) + if i > 5 + and dec._line_ea(i) is not None + and dec._texts[i].strip() + and "//" not in dec._texts[i] + ), + None, + ) if cline is not None: cea = dec._line_ea(cline) dec.focus() @@ -2116,8 +3295,11 @@ async def s_rename(c: Ctx): await c.pause(0.1) ci = app.query_one("#comment", Input) cnote = f"note_{os.getpid()}" - c.check("';' opens the comment prompt on the current line", ci.display, - f"display={ci.display}") + c.check( + "';' opens the comment prompt on the current line", + ci.display, + f"display={ci.display}", + ) ci.value = cnote await c.press("enter") # Gate on the comment showing up, and ONLY that: the extra @@ -2126,9 +3308,14 @@ async def s_rename(c: Ctx): # out its full 25s (9s of wall clock) and then the check below passed # vacuously anyway. await c.wait(lambda: any(cnote in t for t in dec._texts), 25) - c.check("comment appears in the pseudocode after ';'", - any(cnote in t for t in dec._texts), "comment not shown") - app.program.client.invoke("set_comments", items=[{"addr": hex(cea), "comment": ""}]) + c.check( + "comment appears in the pseudocode after ';'", + any(cnote in t for t in dec._texts), + "comment not shown", + ) + app.program.client.call( + remote_ops.set_comments, items=[{"addr": hex(cea), "comment": ""}] + ) else: c.check("found a pseudocode line to comment", False, "no marker line") @@ -2148,29 +3335,42 @@ async def s_comment_func(c: Ctx): dec.cursor, dec.cursor_x = 0, 2 # the signature line dec.refresh() await c.pause(0.05) - c.check("signature line has no address of its own", dec._line_ea(0) is None, - f"ea={dec._line_ea(0)}") + c.check( + "signature line has no address of its own", + dec._line_ea(0) is None, + f"ea={dec._line_ea(0)}", + ) await c.press("semicolon") await c.pause(0.1) ci = app.query_one("#comment", Input) note = f"fn_note_{os.getpid()}" - c.check("';' on the signature line opens a function-comment prompt", - ci.display and "function comment" in str(ci.placeholder).lower(), - f"display={ci.display} ph={ci.placeholder!r}") + c.check( + "';' on the signature line opens a function-comment prompt", + ci.display and "function comment" in str(ci.placeholder).lower(), + f"display={ci.display} ph={ci.placeholder!r}", + ) # literal '\n' in the comment becomes a real newline -> multi-line render a, b = f"{note}_A", f"{note}_B" ci.value = f"{a}\\n{b}" await c.press("enter") - await c.wait(lambda: dec.loaded_ea == fn.addr - and any(a in t for t in dec._texts) - and any(b in t for t in dec._texts), 25) + await c.wait( + lambda: ( + dec.loaded_ea == fn.addr + and any(a in t for t in dec._texts) + and any(b in t for t in dec._texts) + ), + 25, + ) la = next((i for i, t in enumerate(dec._texts) if a in t), None) lb = next((i for i, t in enumerate(dec._texts) if b in t), None) - c.check("multi-line function comment renders on separate lines", - la is not None and lb is not None and lb > la - and a not in dec._texts[lb], - f"la={la} lb={lb}") - app.program.client.invoke("set_comments", items=[{"addr": hex(fn.addr), "comment": ""}]) + c.check( + "multi-line function comment renders on separate lines", + la is not None and lb is not None and lb > la and a not in dec._texts[lb], + f"la={la} lb={lb}", + ) + app.program.client.call( + remote_ops.set_comments, items=[{"addr": hex(fn.addr), "comment": ""}] + ) @scenario("retype") @@ -2190,30 +3390,41 @@ async def s_retype(c: Ctx): await c.press("y") await c.wait(lambda: app.query_one("#retype", Input).display, 10) ri = app.query_one("#retype", Input) - c.check("'y' on a function prefills its prototype", - ri.display and ri.value == old_proto, f"val={ri.value!r} want={old_proto!r}") + c.check( + "'y' on a function prefills its prototype", + ri.display and ri.value == old_proto, + f"val={ri.value!r} want={old_proto!r}", + ) ri.value = f"void __fastcall {cf.name}(int zz_retype_arg)" await c.press("enter") - await c.wait(lambda: (lambda f: bool(f) and "zz_retype_arg" in f.prototype)( - app.program.func_types(cf.addr)), 20) + await c.wait( + lambda: (lambda f: bool(f) and "zz_retype_arg" in f.prototype)( + app.program.func_types(cf.addr) + ), + 20, + ) after = app.program.func_types(cf.addr) - c.check("applying a retype changes the function prototype", - after is not None and "zz_retype_arg" in after.prototype, - f"proto={after.prototype if after else None!r}") + c.check( + "applying a retype changes the function prototype", + after is not None and "zz_retype_arg" in after.prototype, + f"proto={after.prototype if after else None!r}", + ) app.program.set_function_type(cf.addr, old_proto) # restore # the retype above kicked off a recompile+reload; let it land before we start # placing the cursor, or the reload resets it under us. app.program.bump_names() - await c.wait(lambda: not dec.loading and dec.loaded_ea == cf.addr - and bool(dec._texts), 25) + await c.wait( + lambda: not dec.loading and dec.loaded_ea == cf.addr and bool(dec._texts), 25 + ) await c.pause(0.3) # -- 'y' on a LOCAL variable retypes that variable, not the prototype --- # fts = app.program.func_types(cf.addr) lv = next((v for v in (fts.lvars if fts else []) if not v.is_arg), None) if lv is not None: - line = next((i for i, t in enumerate(dec._texts) - if _word_occurrences(t, lv.name)), None) + line = next( + (i for i, t in enumerate(dec._texts) if _word_occurrences(t, lv.name)), None + ) if line is not None: col = _word_occurrences(dec._texts[line], lv.name)[0][0] dec.focus() @@ -2223,26 +3434,43 @@ async def s_retype(c: Ctx): await c.press("y") await c.wait(lambda: app.query_one("#retype", Input).display, 10) ri = app.query_one("#retype", Input) - c.check("'y' on a local variable prefills that variable's type", - ri.value == lv.type and lv.name in str(ri.placeholder), - f"val={ri.value!r} want={lv.type!r} ph={ri.placeholder!r}") + c.check( + "'y' on a local variable prefills that variable's type", + ri.value == lv.type and lv.name in str(ri.placeholder), + f"val={ri.value!r} want={lv.type!r} ph={ri.placeholder!r}", + ) ri.value = "unsigned __int64" await c.press("enter") - changed = await c.wait(lambda: (lambda f: bool(f) and any( - v.name == lv.name and v.type == "unsigned __int64" - for v in f.lvars))(app.program.func_types(cf.addr)), 25) - c.check("applying it retypes the local variable", changed, - f"{lv.name}: wanted unsigned __int64") + changed = await c.wait( + lambda: ( + lambda f: ( + bool(f) + and any( + v.name == lv.name and v.type == "unsigned __int64" + for v in f.lvars + ) + ) + )(app.program.func_types(cf.addr)), + 25, + ) + c.check( + "applying it retypes the local variable", + changed, + f"{lv.name}: wanted unsigned __int64", + ) after = app.program.func_types(cf.addr) - c.check("retyping a local leaves the prototype alone", - after is not None and after.prototype == old_proto, - f"proto={after.prototype if after else None!r}") + c.check( + "retyping a local leaves the prototype alone", + after is not None and after.prototype == old_proto, + f"proto={after.prototype if after else None!r}", + ) # -- 'y' on a GLOBAL retypes the global, not the enclosing function ----- # # The lvar retype above recompiled too — settle again, or the scan below # indexes into pseudocode that's about to be replaced. - await c.wait(lambda: not dec.loading and dec.loaded_ea == cf.addr - and bool(dec._texts), 25) + await c.wait( + lambda: not dec.loading and dec.loaded_ea == cf.addr and bool(dec._texts), 25 + ) await c.pause(0.3) # Pick a global that actually appears as a word in the pseudocode — a symbol @@ -2260,8 +3488,11 @@ async def s_retype(c: Ctx): if app.program.func_types(a) is not None: continue d = app.program.data_type(a) or {} - if (d.get("name") and not d.get("is_func") - and "(" not in (d.get("type") or "")): + if ( + d.get("name") + and not d.get("is_func") + and "(" not in (d.get("type") or "") + ): glob = (w, a, d, i) break if glob: @@ -2275,27 +3506,40 @@ async def s_retype(c: Ctx): dec.cursor, dec.cursor_x = line, col + 1 # inside the word dec.refresh() await c.pause(0.05) - c.check("the cursor sits on the global", - dec.word_under_cursor() == gname, - f"word={dec.word_under_cursor()!r} want={gname!r} " - f"line={line} col={col} text={dec._texts[line][:60]!r}") + c.check( + "the cursor sits on the global", + dec.word_under_cursor() == gname, + f"word={dec.word_under_cursor()!r} want={gname!r} " + f"line={line} col={col} text={dec._texts[line][:60]!r}", + ) await c.press("y") await c.wait(lambda: app.query_one("#retype", Input).display, 10) ri = app.query_one("#retype", Input) - c.check("'y' on a global prefills the global's type (not the proto)", - ri.value != old_proto and gname in str(ri.placeholder), - f"val={ri.value!r} ph={ri.placeholder!r}") + c.check( + "'y' on a global prefills the global's type (not the proto)", + ri.value != old_proto and gname in str(ri.placeholder), + f"val={ri.value!r} ph={ri.placeholder!r}", + ) ri.value = "unsigned __int64" await c.press("enter") retyped = await c.wait( - lambda: (app.program.data_type(glob.addr) or {}).get("type") - == "unsigned __int64", 25) - c.check("applying it retypes the global", retyped, - f"type={(app.program.data_type(glob.addr) or {}).get('type')!r}") + lambda: ( + (app.program.data_type(glob.addr) or {}).get("type") + == "unsigned __int64" + ), + 25, + ) + c.check( + "applying it retypes the global", + retyped, + f"type={(app.program.data_type(glob.addr) or {}).get('type')!r}", + ) after = app.program.func_types(cf.addr) - c.check("retyping a global leaves the prototype alone", - after is not None and after.prototype == old_proto, - f"proto={after.prototype if after else None!r}") + c.check( + "retyping a global leaves the prototype alone", + after is not None and after.prototype == old_proto, + f"proto={after.prototype if after else None!r}", + ) if dt.get("type"): # restore app.program.set_data_type(glob.addr, dt["type"]) @@ -2326,10 +3570,12 @@ async def s_scroll_restore(c: Ctx): await c.wait(lambda: app._cur.ea == fb.addr, 20) renders: list[int] = [] _orig_rl = dis.render_line + def _traced(y, _o=_orig_rl): if y == 0: renders.append(round(dis.scroll_offset.y)) return _o(y) + dis.render_line = _traced await c.press("escape") await c.wait(lambda: app._cur.ea == fa.addr, 20) @@ -2338,13 +3584,19 @@ async def s_scroll_restore(c: Ctx): # one happened. Wait for the paint we are actually asserting about (each # poll ticks the screen, so this is ~one frame, not a quarter second). await c.wait(lambda: bool(renders) and renders[-1] == want_sy, 5) - c.check("disasm scroll + cursor restored on back (mid-viewport)", - round(dis.scroll_offset.y) == want_sy and dis.cursor == want_cur and want_rel > 0, - f"scroll={round(dis.scroll_offset.y)} (want {want_sy}) " - f"cursor={dis.cursor} (want {want_cur}) rel_before={want_rel}") - c.check("pane is repainted at the restored scroll (no stale top frame)", - bool(renders) and renders[-1] == want_sy, - f"last repaint scroll={renders[-1] if renders else None} (want {want_sy})") + c.check( + "disasm scroll + cursor restored on back (mid-viewport)", + round(dis.scroll_offset.y) == want_sy + and dis.cursor == want_cur + and want_rel > 0, + f"scroll={round(dis.scroll_offset.y)} (want {want_sy}) " + f"cursor={dis.cursor} (want {want_cur}) rel_before={want_rel}", + ) + c.check( + "pane is repainted at the restored scroll (no stale top frame)", + bool(renders) and renders[-1] == want_sy, + f"last repaint scroll={renders[-1] if renders else None} (want {want_sy})", + ) dis.render_line = _orig_rl @@ -2364,14 +3616,18 @@ async def s_paging(c: Ctx): rel = dis.cursor - round(dis.scroll_offset.y) await c.press("pagedown") await c.pause(0.05) - c.check("PageDown preserves the viewport-relative row", - dis.cursor - round(dis.scroll_offset.y) == rel, - f"rel={dis.cursor - round(dis.scroll_offset.y)} want={rel}") + c.check( + "PageDown preserves the viewport-relative row", + dis.cursor - round(dis.scroll_offset.y) == rel, + f"rel={dis.cursor - round(dis.scroll_offset.y)} want={rel}", + ) await c.press("pageup") await c.pause(0.05) - c.check("PageUp preserves the viewport-relative row", - dis.cursor - round(dis.scroll_offset.y) == rel, - f"rel={dis.cursor - round(dis.scroll_offset.y)} want={rel}") + c.check( + "PageUp preserves the viewport-relative row", + dis.cursor - round(dis.scroll_offset.y) == rel, + f"rel={dis.cursor - round(dis.scroll_offset.y)} want={rel}", + ) @scenario("rename_history") @@ -2421,8 +3677,13 @@ async def s_rename_history(c: Ctx): await c.pause(0.1) app.query_one("#rename", Input).value = hnew await c.press("enter") - await c.wait(lambda: app._func_index.by_addr(htarget) - and app._func_index.by_addr(htarget).name == hnew, 25) + await c.wait( + lambda: ( + app._func_index.by_addr(htarget) + and app._func_index.by_addr(htarget).name == hnew + ), + 25, + ) if app._active != "listing": await c.press("tab") await c.press("escape") @@ -2431,14 +3692,22 @@ async def s_rename_history(c: Ctx): dis.model.lines(hrow, 4, prefetch=False) await c.pause(0.1) hline = dis._line_plain(hrow) - c.check("caller disasm shows renamed callee after 'back'", - hline is not None and hnew in hline, f"line={hline!r}") + c.check( + "caller disasm shows renamed callee after 'back'", + hline is not None and hnew in hline, + f"line={hline!r}", + ) await c.press("tab") await c.wait(lambda: dec.loaded_ea == bea, 25) await c.pause(0.15) - c.check("caller pseudocode shows renamed callee after 'back'", - any(hnew in tx for tx in dec._texts), "pseudocode still stale") - app.program.client.invoke("rename", batch={"func": {"addr": hex(htarget), "name": hsym}}) + c.check( + "caller pseudocode shows renamed callee after 'back'", + any(hnew in tx for tx in dec._texts), + "pseudocode still stale", + ) + app.program.client.call( + remote_ops.rename, batch={"func": {"addr": hex(htarget), "name": hsym}} + ) @scenario("region_define") @@ -2455,37 +3724,59 @@ async def s_region_define(c: Ctx): # setup: undefine the whole function so [addr, addr+size) is a bare region c.prog.undefine(addr, size=size) c.prog.bump_items() - c.check("function removed by undefine", - c.prog.function_of(addr) is None, "still a function") + c.check( + "function removed by undefine", + c.prog.function_of(addr) is None, + "still a function", + ) # navigate there via the real 'g' prompt -> opens the flat LISTING view # (a non-function region), not refused await c.goto_ui(hex(addr)) await c.wait(lambda: app._cur is not None and app._cur.ea == addr, 25) - c.check("goto to a non-function address opens the listing view (not refused)", - app._cur is not None and app._cur.is_region - and app._active == "listing" and c.lst.display, - f"cur={app._cur} active={app._active} status={c.status()!r}") + c.check( + "goto to a non-function address opens the listing view (not refused)", + app._cur is not None + and app._cur.is_region + and app._active == "listing" + and c.lst.display, + f"cur={app._cur} active={app._active} status={c.status()!r}", + ) await c.wait(lambda: c.lst.total > 0 and c.lst._cursor_ea() is not None, 25) - c.check("listing renders heads and the cursor sits on the target address", - c.lst.total > 0 and c.lst._cursor_ea() == addr, - f"total={c.lst.total} cur_ea={c.lst._cursor_ea()}") + c.check( + "listing renders heads and the cursor sits on the target address", + c.lst.total > 0 and c.lst._cursor_ea() == addr, + f"total={c.lst.total} cur_ea={c.lst._cursor_ea()}", + ) # the flat listing spans the whole segment, not just this function seg = c.prog.segment_bounds(addr) - 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}") + 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 c.lst.focus() c.lst.cursor, c.lst.cursor_x = c.lst.model.index_of_ea(addr), 0 await c.pause(0.05) await c.press("p") - await c.wait(lambda: c.prog.function_of(addr) is not None - and app._cur is not None and not app._cur.is_region, 25) - c.check("'p' creates a function and upgrades the listing to a function view", - c.prog.function_of(addr) is not None and not app._cur.is_region - and app._cur.ea == addr and app._active in ("listing", "decomp"), - f"fn={c.prog.function_of(addr)} cur={app._cur} active={app._active}") + await c.wait( + lambda: ( + c.prog.function_of(addr) is not None + and app._cur is not None + and not app._cur.is_region + ), + 25, + ) + c.check( + "'p' creates a function and upgrades the listing to a function view", + c.prog.function_of(addr) is not None + and not app._cur.is_region + and app._cur.ea == addr + and app._active in ("listing", "decomp"), + f"fn={c.prog.function_of(addr)} cur={app._cur} active={app._active}", + ) finally: # idempotency: guarantee the function is back even if a check failed if c.prog.function_of(addr) is None: @@ -2521,46 +3812,83 @@ async def s_listing_view(c: Ctx): # `total > 0` is set from the segment's size before a single page has # materialised, so waiting on it and then reading rows was the suite's # one known flake (it failed roughly one run in three). Wait for a ROW. - await c.wait(lambda: app._cur is not None and app._active == "listing" - and c.lst.total > 0 and c.lst.model is not None - and any(h.kind == "data" for h in c.lst.model.window(0, 40)), 25) - c.check("navigating to a data segment opens the listing view", - app._active == "listing" and c.lst.display and c.lst.total > 0, - f"active={app._active} total={c.lst.total}") + await c.wait( + lambda: ( + app._cur is not None + and app._active == "listing" + and c.lst.total > 0 + and c.lst.model is not None + and any(h.kind == "data" for h in c.lst.model.window(0, 40)) + ), + 25, + ) + c.check( + "navigating to a data segment opens the listing view", + app._active == "listing" and c.lst.display and c.lst.total > 0, + f"active={app._active} total={c.lst.total}", + ) kinds = {h.kind for h in c.lst.model.window(0, 40)} c.check("listing shows data heads (not just code)", "data" in kinds, str(kinds)) # a rendered data line carries the item text (e.g. db/dd/string) c.lst.focus() - first_data = next((i for i in range(min(c.lst.total, 60)) - if c.lst.model.get(i) and c.lst.model.get(i).kind == "data"), None) - c.check("a data head exists in the first screenful", first_data is not None, - f"total={c.lst.total}") + first_data = next( + ( + i + for i in range(min(c.lst.total, 60)) + if c.lst.model.get(i) and c.lst.model.get(i).kind == "data" + ), + None, + ) + c.check( + "a data head exists in the first screenful", + first_data is not None, + f"total={c.lst.total}", + ) if first_data is not None: c.lst.cursor = first_data await c.pause(0.05) plain = c.lst._line_plain(first_data) - c.check("data line renders its item text", bool(plain and plain.strip()), - f"plain={plain!r}") - c.check("listing cursor reports the head address", - c.lst._cursor_ea() == c.lst.model.get(first_data).ea, str(c.lst._cursor_ea())) + c.check( + "data line renders its item text", + bool(plain and plain.strip()), + f"plain={plain!r}", + ) + c.check( + "listing cursor reports the head address", + c.lst._cursor_ea() == c.lst.model.get(first_data).ea, + str(c.lst._cursor_ea()), + ) # backslash from the listing opens hex at the cursor address; and back cur_ea = c.lst._cursor_ea() await c.press("backslash") await c.wait(lambda: app._active == "hex" and c.hex.display, 15) - c.check("backslash from the listing opens the hex view", app._active == "hex", - f"active={app._active}") + c.check( + "backslash from the listing opens the hex view", + app._active == "hex", + f"active={app._active}", + ) await c.press("backslash") await c.wait(lambda: app._active == "listing", 15) - c.check("returning from hex lands back on the listing (not a func view)", - app._active == "listing" and c.lst.display, f"active={app._active}") + c.check( + "returning from hex lands back on the listing (not a func view)", + app._active == "listing" and c.lst.display, + f"active={app._active}", + ) # 'd' defines typed data over an undefined run. Synthesize the run # deterministically: undefine a data head, then re-type it via the prompt. - dhead = next((c.lst.model.get(i) for i in range(min(c.lst.total, 200)) - if c.lst.model.get(i) and c.lst.model.get(i).kind == "data" - and (c.lst.model.get(i).size or 0) >= 4), None) + dhead = next( + ( + c.lst.model.get(i) + for i in range(min(c.lst.total, 200)) + if c.lst.model.get(i) + and c.lst.model.get(i).kind == "data" + and (c.lst.model.get(i).size or 0) >= 4 + ), + None, + ) if dhead is None: c.check("found a data head to re-type", False) return @@ -2576,31 +3904,49 @@ async def s_listing_view(c: Ctx): # 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) + 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", - ui >= 0 and c.lst.model.get(ui).kind == "unknown", - f"kind={c.lst.model.get(ui).kind if ui>=0 else None}") + c.check( + "undefining a data head yields an unknown run in the listing", + ui >= 0 and c.lst.model.get(ui).kind == "unknown", + f"kind={c.lst.model.get(ui).kind if ui >= 0 else None}", + ) c.lst.focus() c.lst.cursor = ui await c.pause(0.05) await c.press("d") await c.pause(0.1) mdi = app.query_one("#makedata", Input) - c.check("'d' opens the make-data prompt prefilled with a type", - mdi.display and bool(mdi.value), f"display={mdi.display} val={mdi.value!r}") + c.check( + "'d' opens the make-data prompt prefilled with a type", + mdi.display and bool(mdi.value), + f"display={mdi.display} val={mdi.value!r}", + ) mdi.value = "char[4]" await c.press("enter") - await c.wait(lambda: app._active == "listing" - and c.lst.model.index_of_ea(dea) >= 0 - and c.lst.model.get(c.lst.model.index_of_ea(dea)) is not None - and c.lst.model.get(c.lst.model.index_of_ea(dea)).kind == "data", 25) + await c.wait( + lambda: ( + app._active == "listing" + and c.lst.model.index_of_ea(dea) >= 0 + and c.lst.model.get(c.lst.model.index_of_ea(dea)) is not None + and c.lst.model.get(c.lst.model.index_of_ea(dea)).kind == "data" + ), + 25, + ) di = c.lst.model.index_of_ea(dea) - c.check("'d' turns the undefined run into a typed data item", - di >= 0 and c.lst.model.get(di).kind == "data", - f"kind={c.lst.model.get(di).kind if di>=0 else None}") + c.check( + "'d' turns the undefined run into a typed data item", + di >= 0 and c.lst.model.get(di).kind == "data", + f"kind={c.lst.model.get(di).kind if di >= 0 else None}", + ) finally: c.prog.bump_items() @@ -2626,8 +3972,11 @@ async def s_listing_name_addr(c: Ctx): if data_ea is None: c.check("found a data segment with a >=2-byte item", False) return - dh = next(h for h in c.prog.listing(data_ea).window(0, 60) - if h.kind == "data" and (h.size or 0) >= 2) + dh = next( + h + for h in c.prog.listing(data_ea).window(0, 60) + if h.kind == "data" and (h.size or 0) >= 2 + ) A = dh.ea newname = f"after_{os.getpid()}" try: @@ -2635,33 +3984,53 @@ async def s_listing_name_addr(c: Ctx): c.prog.make_data(A, "unsigned __int8") c.prog.bump_items() await c.goto_ui(hex(A + 1)) - await c.wait(lambda: app._active == "listing" and app._cur is not None - and app._cur.ea == A + 1, 25) + await c.wait( + lambda: ( + app._active == "listing" + and app._cur is not None + and app._cur.ea == A + 1 + ), + 25, + ) head = c.lst.cur_head() - c.check("cursor lands on the now-undefined byte at addr+1", - head is not None and head.ea == A + 1 and head.kind == "unknown", - f"head={head}") + c.check( + "cursor lands on the now-undefined byte at addr+1", + head is not None and head.ea == A + 1 and head.kind == "unknown", + f"head={head}", + ) # 'n' opens the address-name prompt (even though there's no symbol) c.lst.focus() await c.press("n") await c.pause(0.1) ri = app.query_one("#rename", Input) - c.check("'n' opens the name prompt on an unnamed byte", - ri.display, f"display={ri.display}") + c.check( + "'n' opens the name prompt on an unnamed byte", + ri.display, + f"display={ri.display}", + ) ri.value = newname await c.press("enter") - await c.wait(lambda: app._active == "listing" - and c.lst.model.index_of_ea(A + 1) >= 0 - and c.lst.model.get(c.lst.model.index_of_ea(A + 1)) is not None - and c.lst.model.get(c.lst.model.index_of_ea(A + 1)).name == newname, 25) + await c.wait( + lambda: ( + app._active == "listing" + and c.lst.model.index_of_ea(A + 1) >= 0 + and c.lst.model.get(c.lst.model.index_of_ea(A + 1)) is not None + and c.lst.model.get(c.lst.model.index_of_ea(A + 1)).name == newname + ), + 25, + ) hi = c.lst.model.index_of_ea(A + 1) - c.check("naming a bare byte at addr+1 sticks", - hi >= 0 and c.lst.model.get(hi).name == newname, - f"name={c.lst.model.get(hi).name if hi>=0 else None}") + c.check( + "naming a bare byte at addr+1 sticks", + hi >= 0 and c.lst.model.get(hi).name == newname, + f"name={c.lst.model.get(hi).name if hi >= 0 else None}", + ) finally: # revert: drop the label and restore raw bytes at A try: - c.prog.client.invoke("rename", batch={"data": {"addr": hex(A + 1), "new": ""}}) + c.prog.client.call( + remote_ops.rename, batch={"data": {"addr": hex(A + 1), "new": ""}} + ) except Exception: # noqa: BLE001 pass c.prog.undefine(A, size=8) @@ -2680,8 +4049,9 @@ async def s_listing_make_string(c: Ctx): if lm is None: continue lm.ensure(80) - h = next((h for h in lm.window(0, 80) - if h.kind == "data" and "'" in h.text), None) + h = next( + (h for h in lm.window(0, 80) if h.kind == "data" and "'" in h.text), None + ) if h is not None: target = h.ea break @@ -2693,22 +4063,36 @@ async def s_listing_make_string(c: Ctx): c.prog.undefine(A, size=8) c.prog.bump_items() await c.goto_ui(hex(A)) - await c.wait(lambda: app._active == "listing" and app._cur is not None - and app._cur.ea == A, 25) - c.check("target is undefined before 'a'", - c.lst.cur_head() is not None and c.lst.cur_head().kind == "unknown", - f"head={c.lst.cur_head()}") + await c.wait( + lambda: ( + app._active == "listing" and app._cur is not None and app._cur.ea == A + ), + 25, + ) + c.check( + "target is undefined before 'a'", + c.lst.cur_head() is not None and c.lst.cur_head().kind == "unknown", + f"head={c.lst.cur_head()}", + ) c.lst.focus() await c.press("a") - await c.wait(lambda: c.lst.model.index_of_ea(A) >= 0 - and c.lst.model.get(c.lst.model.index_of_ea(A)) is not None - and c.lst.model.get(c.lst.model.index_of_ea(A)).kind == "data" - and "'" in c.lst.model.get(c.lst.model.index_of_ea(A)).text, 25) + await c.wait( + lambda: ( + c.lst.model.index_of_ea(A) >= 0 + and c.lst.model.get(c.lst.model.index_of_ea(A)) is not None + and c.lst.model.get(c.lst.model.index_of_ea(A)).kind == "data" + and "'" in c.lst.model.get(c.lst.model.index_of_ea(A)).text + ), + 25, + ) hi = c.lst.model.index_of_ea(A) - c.check("'a' creates a string literal at the cursor", - hi >= 0 and c.lst.model.get(hi).kind == "data" - and "'" in c.lst.model.get(hi).text, - f"head={c.lst.model.get(hi) if hi >= 0 else None}") + c.check( + "'a' creates a string literal at the cursor", + hi >= 0 + and c.lst.model.get(hi).kind == "data" + and "'" in c.lst.model.get(hi).text, + f"head={c.lst.model.get(hi) if hi >= 0 else None}", + ) finally: try: c.prog.make_string(A) # restore the original string @@ -2738,25 +4122,38 @@ async def s_listing_struct_expand(c: Ctx): c.check("found a data address for the struct test", False) return try: - c.prog.client.invoke( - "declare_type", - decls=["struct TuiExpandS { int a; char b[4]; short c; };"]) + c.prog.client.call( + remote_ops.declare_type, + decls=["struct TuiExpandS { int a; char b[4]; short c; };"], + ) c.prog.make_data(A, "TuiExpandS") c.prog.bump_items() await c.goto_ui(hex(A)) - await c.wait(lambda: app._active == "listing" and app._cur is not None - and app._cur.ea == A and c.lst.total > 0, 25) + await c.wait( + lambda: ( + app._active == "listing" + and app._cur is not None + and app._cur.ea == A + and c.lst.total > 0 + ), + 25, + ) # the summary head, then member rows for a/b/c si = c.lst.model.index_of_ea(A) members = [c.lst.model.get(si + 1 + k) for k in range(3)] names = [m.text for m in members if m is not None] - c.check("struct global expands into member rows", - all(m is not None and m.kind == "member" for m in members) - and any("a" in t for t in names) and any("b" in t for t in names), - f"members={names}") - c.check("member rows carry field addresses", - members[1] is not None and members[1].ea == A + 4, - f"ea={members[1].ea if members[1] else None:#x} want={A+4:#x}") + c.check( + "struct global expands into member rows", + all(m is not None and m.kind == "member" for m in members) + and any("a" in t for t in names) + and any("b" in t for t in names), + f"members={names}", + ) + c.check( + "member rows carry field addresses", + members[1] is not None and members[1].ea == A + 4, + f"ea={members[1].ea if members[1] else None:#x} want={A + 4:#x}", + ) finally: try: c.prog.undefine(A, size=16) @@ -2774,45 +4171,66 @@ async def s_continuous_view(c: Ctx): fn = await c.open_biggest("listing") fn_ea = fn.addr await c.wait(lambda: app._active == "listing" and c.lst.display, 10) - c.check("a function opens in the continuous listing by default", - app._active == "listing" and c.lst.display - and c.lst._cursor_ea() == fn_ea, - f"active={app._active} disp={c.lst.display} cur_ea={c.lst._cursor_ea()}") + c.check( + "a function opens in the continuous listing by default", + app._active == "listing" and c.lst.display and c.lst._cursor_ea() == fn_ea, + f"active={app._active} disp={c.lst.display} cur_ea={c.lst._cursor_ea()}", + ) # the listing spans the whole segment, not just the function c.lst.model.load_all() seg = c.prog.segment_bounds(fn_ea) seg_rows = len(c.lst.model) # a function's own instruction count is far smaller than the segment fdis = c.prog.disasm(fn_ea, fn.name) - c.check("the continuous listing extends past the function's bounds", - seg_rows > fdis.total(), f"listing={seg_rows} func={fdis.total()}") + c.check( + "the continuous listing extends past the function's bounds", + seg_rows > fdis.total(), + f"listing={seg_rows} func={fdis.total()}", + ) kinds = {c.lst.model.get(i).kind for i in range(seg_rows)} - c.check("continuous listing interleaves code with data/undefined", - "code" in kinds and ("data" in kinds or "unknown" in kinds), str(kinds)) + c.check( + "continuous listing interleaves code with data/undefined", + "code" in kinds and ("data" in kinds or "unknown" in kinds), + str(kinds), + ) # rendering parity with disasm: code lines carry opcode bytes - cidx = next((i for i in range(seg_rows) - if c.lst.model.get(i).kind == "code"), None) - c.check("continuous listing renders opcode bytes (parity with disasm)", - cidx is not None and c.lst.model.get(cidx).raw - and c.lst._op_field(c.lst.model.get(cidx)).strip() != "", - f"raw={c.lst.model.get(cidx).raw if cidx is not None else None!r}") + cidx = next((i for i in range(seg_rows) if c.lst.model.get(i).kind == "code"), None) + c.check( + "continuous listing renders opcode bytes (parity with disasm)", + cidx is not None + and c.lst.model.get(cidx).raw + and c.lst._op_field(c.lst.model.get(cidx)).strip() != "", + f"raw={c.lst.model.get(cidx).raw if cidx is not None else None!r}", + ) # F5/Tab at the function -> decompiler, and back to the same spot c.lst.focus() await c.press("tab") - await c.wait(lambda: (app._active == "decomp" and c.dec.loaded_ea == fn_ea) - or (app.is_listing - and _CANNOT_DECOMP in c.status().lower()), 25) + await c.wait( + lambda: ( + (app._active == "decomp" and c.dec.loaded_ea == fn_ea) + or (app.is_listing and _CANNOT_DECOMP in c.status().lower()) + ), + 25, + ) if app._active == "decomp": - c.check("F5/Tab decompiles the function under the cursor", - c.dec.loaded_ea == fn_ea, f"loaded={c.dec.loaded_ea}") + c.check( + "F5/Tab decompiles the function under the cursor", + c.dec.loaded_ea == fn_ea, + f"loaded={c.dec.loaded_ea}", + ) await c.press("tab") await c.wait(lambda: app._active == "listing", 25) - c.check("F5/Tab in the decompiler returns to the listing at the same ea", - app._active == "listing" and c.lst._cursor_ea() == fn_ea, - f"active={app._active} cur_ea={c.lst._cursor_ea()}") + c.check( + "F5/Tab in the decompiler returns to the listing at the same ea", + app._active == "listing" and c.lst._cursor_ea() == fn_ea, + f"active={app._active} cur_ea={c.lst._cursor_ea()}", + ) else: - c.check("undecompilable function falls back to the listing", - app._active == "listing", f"active={app._active}") + c.check( + "undecompilable function falls back to the listing", + app._active == "listing", + f"active={app._active}", + ) @scenario("func_banners") @@ -2825,18 +4243,27 @@ async def s_func_banners(c: Ctx): c.lst.model.load_all() heads = [c.lst.model.get(i) for i in range(len(c.lst.model))] ci = c.lst.model.index_of_ea(fn.addr) - c.check("navigation to a function lands on its code head, not a banner", - ci >= 0 and c.lst.model.get(ci).kind == "code", - f"kind={c.lst.model.get(ci).kind if ci >= 0 else None}") - c.check("a SUBROUTINE separator banner is present", - any(h.kind == "sep" and "S U B R O U T I N E" in h.text for h in heads)) - c.check("a 'name proc' header is present", - any(h.kind == "funchdr" and h.text.endswith(" proc") for h in heads)) - c.check("a 'name endp' footer is present", - any(h.kind == "funchdr" and h.text.endswith("endp") for h in heads)) + c.check( + "navigation to a function lands on its code head, not a banner", + ci >= 0 and c.lst.model.get(ci).kind == "code", + f"kind={c.lst.model.get(ci).kind if ci >= 0 else None}", + ) + c.check( + "a SUBROUTINE separator banner is present", + any(h.kind == "sep" and "S U B R O U T I N E" in h.text for h in heads), + ) + c.check( + "a 'name proc' header is present", + any(h.kind == "funchdr" and h.text.endswith(" proc") for h in heads), + ) + c.check( + "a 'name endp' footer is present", + any(h.kind == "funchdr" and h.text.endswith("endp") for h in heads), + ) # the proc header for the origin function carries its name - hdr = next((h for h in heads if h.kind == "funchdr" - and h.text == f"{fn.name} proc"), None) + hdr = next( + (h for h in heads if h.kind == "funchdr" and h.text == f"{fn.name} proc"), None + ) c.check("the proc header names the function", hdr is not None, f"fn={fn.name}") @@ -2901,35 +4328,58 @@ async def s_opfmt_listing(c: Ctx): ea = head.ea c.lst.focus() parked = await _park_on(c, ea) - c.check("the cursor is on the literal's line", parked, - f"want {ea:#x}, cursor at {c.lst._cursor_ea():#x}") + c.check( + "the cursor is on the literal's line", + parked, + f"want {ea:#x}, cursor at {c.lst._cursor_ea():#x}", + ) before = head.text try: await c.press("o") - await c.wait(lambda: c.lst.model.index_of_ea(ea) >= 0 - and (c.lst.model.get(c.lst.model.index_of_ea(ea)) or head).text - != before, 25) + await c.wait( + lambda: ( + c.lst.model.index_of_ea(ea) >= 0 + and (c.lst.model.get(c.lst.model.index_of_ea(ea)) or head).text + != before + ), + 25, + ) i = c.lst.model.index_of_ea(ea) after = c.lst.model.get(i).text if i >= 0 else before - c.check("'o' re-renders the literal", after != before, - f"{before!r} -> {after!r} ea={ea:#x} show={show}") - c.check("the status names the format it moved to", - any(f in c.status() for f in show["choices"]), - f"status={c.status()!r} choices={show['choices']}") + c.check( + "'o' re-renders the literal", + after != before, + f"{before!r} -> {after!r} ea={ea:#x} show={show}", + ) + c.check( + "the status names the format it moved to", + any(f in c.status() for f in show["choices"]), + f"status={c.status()!r} choices={show['choices']}", + ) c.check("the change is marked unsaved", app._dirty) # 'O' walks the ring the other way: back to where we started. await c.press("O") - await c.wait(lambda: c.lst.model.index_of_ea(ea) >= 0 - and (c.lst.model.get(c.lst.model.index_of_ea(ea)) or head).text - == before, 25) + await c.wait( + lambda: ( + c.lst.model.index_of_ea(ea) >= 0 + and (c.lst.model.get(c.lst.model.index_of_ea(ea)) or head).text + == before + ), + 25, + ) j = c.lst.model.index_of_ea(ea) - c.check("'O' cycles back", j >= 0 and c.lst.model.get(j).text == before, - f"{c.lst.model.get(j).text if j >= 0 else None!r} want {before!r}") + c.check( + "'O' cycles back", + j >= 0 and c.lst.model.get(j).text == before, + f"{c.lst.model.get(j).text if j >= 0 else None!r} want {before!r}", + ) # An explicit format by name (what the palette/RPC use). r = c.prog.op_format(ea, mode="dec") - c.check("an explicit format renders decimal", - r["format"] == "dec" and str(int(show["value"], 16)) in r["text"], - str(r)) + c.check( + "an explicit format renders decimal", + r["format"] == "dec" and str(int(show["value"], 16)) in r["text"], + str(r), + ) finally: try: c.prog.op_format(ea, mode="default", n=show.get("n", -1)) @@ -2948,24 +4398,33 @@ async def s_opfmt_no_literal(c: Ctx): h = c.lst.model.get(i) return h is not None and h.kind in ("sep", "funchdr", "label") - row = next((i for i in range(c.lst.cursor, min(c.lst.cursor + 400, - len(c.lst.model))) - if _banner(i)), None) + row = next( + ( + i + for i in range(c.lst.cursor, min(c.lst.cursor + 400, len(c.lst.model))) + if _banner(i) + ), + None, + ) if row is None: c.check("found a banner row", False) return c.lst.focus() - for _ in range(20): # hold it against a late-landing open + for _ in range(20): # hold it against a late-landing open c.lst.cursor = row await c.pause(0.05) if c.lst.cursor == row: break - c.check("the cursor is on a banner row", _banner(c.lst.cursor), - f"row={c.lst.cursor}") + c.check( + "the cursor is on a banner row", _banner(c.lst.cursor), f"row={c.lst.cursor}" + ) await c.press("o") await c.wait(lambda: "reformat" in c.status() or "format" in c.status(), 15) - c.check("'o' on a line with no literal explains itself", - "reformat" in c.status(), f"status={c.status()!r}") + c.check( + "'o' on a line with no literal explains itself", + "reformat" in c.status(), + f"status={c.status()!r}", + ) @scenario("opfmt_refusal_is_not_swallowed") @@ -2993,7 +4452,7 @@ async def s_opfmt_refusal_visible(c: Ctx): c.check("the cursor is on the literal's line", False) return try: - await c.press("o") # a success: sets the flash + await c.press("o") # a success: sets the flash await c.wait(lambda: "\u2192" in c.status(), 25) good = c.status() @@ -3006,9 +4465,14 @@ async def s_opfmt_refusal_visible(c: Ctx): h = c.lst.model.get(i) return h is not None and h.kind in ("sep", "funchdr", "label") - row = next((i for i in range(c.lst.cursor, - min(c.lst.cursor + 400, len(c.lst.model))) - if _banner(i)), None) + row = next( + ( + i + for i in range(c.lst.cursor, min(c.lst.cursor + 400, len(c.lst.model))) + if _banner(i) + ), + None, + ) if row is None: c.check("found a banner row to refuse on", False) return @@ -3017,11 +4481,13 @@ async def s_opfmt_refusal_visible(c: Ctx): await c.pause(0.05) if c.lst.cursor == row: break - c.lst.action_op_format("cycle") # no keypress: as the RPC does it + c.lst.action_op_format("cycle") # no keypress: as the RPC does it await c.wait(lambda: c.status() != good, 15) - c.check("a refusal replaces the previous success on the status bar", - c.status() != good and "reformat" in c.status(), - f"still showing {c.status()!r}") + c.check( + "a refusal replaces the previous success on the status bar", + c.status() != good and "reformat" in c.status(), + f"still showing {c.status()!r}", + ) finally: try: c.prog.op_format(head.ea, mode="default", n=show.get("n", -1)) @@ -3035,8 +4501,11 @@ def _styled_cols(strip, style_attr, want): cols, x = [], 0 for seg in strip: st = seg.style - if st is not None and getattr(st, style_attr, None) is not None \ - and str(getattr(st, style_attr)) == want: + if ( + st is not None + and getattr(st, style_attr, None) is not None + and str(getattr(st, style_attr)) == want + ): cols.extend(range(x, x + len(seg.text))) x += len(seg.text) return cols @@ -3052,15 +4521,22 @@ async def s_opfmt_highlight(c: Ctx): (usually the same characters) and used to win. """ from idatui.app import _S_OPERAND + await c.open_biggest("listing") c.lst.model.load_all() lst = c.lst lst.focus() # A row with two operands, so "which one" is a real question. - row = next((i for i in range(lst.cursor, min(lst.cursor + 400, len(lst.model))) - if lst.model.get(i) is not None - and (lst.model.get(i).ops or ()) and len(lst.model.get(i).ops) >= 2), - None) + row = next( + ( + i + for i in range(lst.cursor, min(lst.cursor + 400, len(lst.model))) + if lst.model.get(i) is not None + and (lst.model.get(i).ops or ()) + and len(lst.model.get(i).ops) >= 2 + ), + None, + ) if row is None: c.check("found a row with two operands", False) return @@ -3079,12 +4555,17 @@ async def s_opfmt_highlight(c: Ctx): strip = lst.render_line(row - round(lst.scroll_offset.y)) cols = _styled_cols(strip, "bgcolor", want_bg) seen.append((n, min(cols) if cols else None, max(cols) + 1 if cols else None)) - c.check(f"operand {n} ({h.text[lo:hi]!r}) is marked when the cursor is on it", - cols and min(cols) == base + lo and max(cols) + 1 == base + hi, - f"marked={min(cols) if cols else None}.." - f"{max(cols)+1 if cols else None} want={base+lo}..{base+hi}") - c.check("the mark MOVES between the operands (it isn't the whole line)", - len({s[1] for s in seen}) == len(seen), str(seen)) + c.check( + f"operand {n} ({h.text[lo:hi]!r}) is marked when the cursor is on it", + cols and min(cols) == base + lo and max(cols) + 1 == base + hi, + f"marked={min(cols) if cols else None}.." + f"{max(cols) + 1 if cols else None} want={base + lo}..{base + hi}", + ) + c.check( + "the mark MOVES between the operands (it isn't the whole line)", + len({s[1] for s in seen}) == len(seen), + str(seen), + ) # ... and the marked operand is the one the edit acts on — either it gets # reformatted, or the refusal names that same operand. What must never # happen is a different operand quietly changing. @@ -3093,11 +4574,14 @@ async def s_opfmt_highlight(c: Ctx): try: r = c.prog.op_format(h.ea, mode="show", col=lst.op_col()) got, why = r.get("n"), "" - except IDAToolError as e: # "operand N (rsp) has no format" + except IDAToolError as e: # "operand N (rsp) has no format" m = re.search(r"operand (\d+)", e.message) got, why = (int(m.group(1)) if m else None), e.message - c.check(f"marked operand {n} is the one acted on (or refused)", - got == n, f"marked op{n}, worker said op{got} {why}") + c.check( + f"marked operand {n} is the one acted on (or refused)", + got == n, + f"marked op{n}, worker said op{got} {why}", + ) @scenario("opfmt_sticks_to_its_literal") @@ -3120,8 +4604,7 @@ async def s_opfmt_sticks(c: Ctx): # The second literal must be one whose printed WIDTH changes as it # cycles (0x36u vs 54), or the cursor never falls off it and the # test proves nothing. - if len(recs) >= 2 and recs[0][1] < recs[1][0] \ - and int(recs[1][2], 16) >= 16: + if len(recs) >= 2 and recs[0][1] < recs[1][0] and int(recs[1][2], 16) >= 16: pick = (fn, line, recs) break if pick: @@ -3131,7 +4614,7 @@ async def s_opfmt_sticks(c: Ctx): return fn, line, recs = pick first, second = recs[0], recs[1] - target = (second[3], second[4]) # (ea, opnum) of the literal we mean + target = (second[3], second[4]) # (ea, opnum) of the literal we mean other = (first[3], first[4]) try: # Make it WIDE first (0x30, four characters). The cursor then sits on a @@ -3146,33 +4629,47 @@ async def s_opfmt_sticks(c: Ctx): return dec = c.dec dec.focus() - wide = next((r for r in dec._nums.get(line, ()) - if (r[3], r[4]) == target), None) + wide = next( + (r for r in dec._nums.get(line, ()) if (r[3], r[4]) == target), None + ) if wide is None or wide[1] - wide[0] < 3: - c.check("the literal is now printed wide", False, - f"nums={dec._nums.get(line)}") + c.check( + "the literal is now printed wide", False, f"nums={dec._nums.get(line)}" + ) return - dec.cursor, dec.cursor_x = line, wide[1] - 1 # its LAST character + dec.cursor, dec.cursor_x = line, wide[1] - 1 # its LAST character dec.refresh() await c.pause(0.05) seen = [] for _ in range(3): before = dec._texts[line] await c.press("o") - await c.wait(lambda: dec.loaded_ea == fn.addr - and line < len(dec._texts) - and dec._texts[line] != before, 30) - cur = next(((r[3], r[4]) for r in dec._nums.get(line, ()) - if r[0] <= dec.cursor_x < r[1]), None) + await c.wait( + lambda: ( + dec.loaded_ea == fn.addr + and line < len(dec._texts) + and dec._texts[line] != before + ), + 30, + ) + cur = next( + ( + (r[3], r[4]) + for r in dec._nums.get(line, ()) + if r[0] <= dec.cursor_x < r[1] + ), + None, + ) seen.append(cur) - c.check("every press stays on the literal we started on", - all(s == target for s in seen), - f"target={target} other={other} landed={seen} " - f"line={dec._texts[line].strip()!r}") + c.check( + "every press stays on the literal we started on", + all(s == target for s in seen), + f"target={target} other={other} landed={seen} " + f"line={dec._texts[line].strip()!r}", + ) finally: try: - c.prog.pc_num_format(fn.addr, mode="default", line=line, - col=second[0]) + c.prog.pc_num_format(fn.addr, mode="default", line=line, col=second[0]) except Exception: # noqa: BLE001 pass c.prog.bump_names() @@ -3188,6 +4685,7 @@ async def s_cursor_on_visible(c: Ctx): that hadn't changed. """ from idatui.rpc import cursor_on + app = c.app fn = await c.open_biggest("listing") c.lst.model.load_all() @@ -3196,44 +4694,66 @@ async def s_cursor_on_visible(c: Ctx): await c.pause(0.1) top = round(lst.scroll_offset.y) # A token that occurs both before the viewport and inside it. - here = next((t for t in ("rax", "rsp", "eax", "rbp", "rdi") - if any(t in (lst._line_plain(i) or "") - for i in range(top, min(top + 20, lst.total))) - and any(t in (lst._line_plain(i) or "") for i in range(0, top))), - None) + here = next( + ( + t + for t in ("rax", "rsp", "eax", "rbp", "rdi") + if any( + t in (lst._line_plain(i) or "") + for i in range(top, min(top + 20, lst.total)) + ) + and any(t in (lst._line_plain(i) or "") for i in range(0, top)) + ), + None, + ) if here is None: - c.check("found a token both above and inside the viewport", False, - f"top={top}") + c.check("found a token both above and inside the viewport", False, f"top={top}") return found = cursor_on(app, here) await c.pause(0.1) c.check(f"cursor_on({here!r}) found it", found) vis = round(lst.scroll_offset.y) - c.check("it lands inside the viewport, not thousands of rows above", - vis <= lst.cursor < vis + lst._visible_height(), - f"cursor={lst.cursor} viewport={vis}..{vis + lst._visible_height()}") - c.check("and it searched from the viewport, not from row 0", - lst.cursor >= top, f"cursor={lst.cursor} was top={top}") + c.check( + "it lands inside the viewport, not thousands of rows above", + vis <= lst.cursor < vis + lst._visible_height(), + f"cursor={lst.cursor} viewport={vis}..{vis + lst._visible_height()}", + ) + c.check( + "and it searched from the viewport, not from row 0", + lst.cursor >= top, + f"cursor={lst.cursor} was top={top}", + ) # An explicit line still wins, and lands visibly. - far = next((i for i in range(0, min(top, lst.total)) - if here in (lst._line_plain(i) or "")), None) + far = next( + ( + i + for i in range(0, min(top, lst.total)) + if here in (lst._line_plain(i) or "") + ), + None, + ) if far is not None: cursor_on(app, here, line=far) await c.pause(0.1) v2 = round(lst.scroll_offset.y) - c.check("an explicit line is honoured AND scrolled into view", - lst.cursor == far and v2 <= far < v2 + lst._visible_height(), - f"cursor={lst.cursor} want={far} viewport={v2}") + c.check( + "an explicit line is honoured AND scrolled into view", + lst.cursor == far and v2 <= far < v2 + lst._visible_height(), + f"cursor={lst.cursor} want={far} viewport={v2}", + ) # The `cursor` verb has the same duty: a driver that parks the cursor for # an edit must leave it where the edit can be watched. from idatui.rpc import place_cursor + deep = min(lst.total - 1, 900) place_cursor(lst, deep, 0) await c.pause(0.1) v3 = round(lst.scroll_offset.y) - c.check("`cursor line=` scrolls to what it selected", - v3 <= deep < v3 + lst._visible_height(), - f"cursor={lst.cursor} viewport={v3}..{v3 + lst._visible_height()}") + c.check( + "`cursor line=` scrolls to what it selected", + v3 <= deep < v3 + lst._visible_height(), + f"cursor={lst.cursor} viewport={v3}..{v3 + lst._visible_height()}", + ) @scenario("opfmt_decomp") @@ -3249,7 +4769,7 @@ async def s_opfmt_decomp(c: Ctx): continue for i, txt in enumerate(d.code.split("\n")): m = re.search(r"[=<>+\-*/(,]\s(\d{2,}|0x[0-9A-Fa-f]{2,})\b", txt) - if m and "//" not in txt[:m.start()]: + if m and "//" not in txt[: m.start()]: pick = (fn, i, m.start(1)) break if pick: @@ -3270,14 +4790,24 @@ async def s_opfmt_decomp(c: Ctx): before = dec._texts[line] try: await c.press("o") - await c.wait(lambda: dec.loaded_ea == fn.addr and line < len(dec._texts) - and dec._texts[line] != before, 30) - c.check("'o' re-renders the pseudocode literal", - line < len(dec._texts) and dec._texts[line] != before, - f"{before!r} -> {dec._texts[line] if line < len(dec._texts) else None!r}") - c.check("the status says which format", - any(f in c.status() for f in ("hex", "dec", "oct", "char", "default")), - f"status={c.status()!r}") + await c.wait( + lambda: ( + dec.loaded_ea == fn.addr + and line < len(dec._texts) + and dec._texts[line] != before + ), + 30, + ) + c.check( + "'o' re-renders the pseudocode literal", + line < len(dec._texts) and dec._texts[line] != before, + f"{before!r} -> {dec._texts[line] if line < len(dec._texts) else None!r}", + ) + c.check( + "the status says which format", + any(f in c.status() for f in ("hex", "dec", "oct", "char", "default")), + f"status={c.status()!r}", + ) finally: try: c.prog.pc_num_format(fn.addr, mode="default", line=line, col=col) @@ -3319,10 +4849,13 @@ async def _open_graph(c: Ctx, fn=None, t=60): await c.press("space") gv = app.query_one(GraphView) ok = await c.wait(lambda: app._active == "graph" and gv.lay is not None, t) - c.check("the graph opened", ok, - f"active={app._active} sticky={app._graph_sticky} " - f"focus={type(app.focused).__name__} prompt={app._prompt_active()} " - f"status={c.status()!r}") + c.check( + "the graph opened", + ok, + f"active={app._active} sticky={app._graph_sticky} " + f"focus={type(app.focused).__name__} prompt={app._prompt_active()} " + f"status={c.status()!r}", + ) return fn, gv @@ -3330,26 +4863,58 @@ async def _open_graph(c: Ctx, fn=None, t=60): async def s_graph_open(c: Ctx): app = c.app fn, gv = await _open_graph(c) - c.check("space opens the graph view", app._active == "graph", - f"active={app._active} status={c.status()}") + c.check( + "space opens the graph view", + app._active == "graph", + f"active={app._active} status={c.status()}", + ) if gv.lay is None: return - c.check("the graph has the function's blocks", - len(gv.lay.nodes) == len(gv.fc.blocks) and len(gv.lay.nodes) > 1, - f"nodes={len(gv.lay.nodes)} blocks={len(gv.fc.blocks) if gv.fc else 0}") - c.check("it is the right function", gv.fc is not None and gv.fc.func_ea == fn.addr, - f"{gv.fc.func_ea if gv.fc else None:#x} want {fn.addr:#x}") + c.check( + "the graph has the function's blocks", + len(gv.lay.nodes) == len(gv.fc.blocks) and len(gv.lay.nodes) > 1, + f"nodes={len(gv.lay.nodes)} blocks={len(gv.fc.blocks) if gv.fc else 0}", + ) + c.check( + "it is the right function", + gv.fc is not None and gv.fc.func_ea == fn.addr, + f"{gv.fc.func_ea if gv.fc else None:#x} want {fn.addr:#x}", + ) c.check("the cursor starts on a real address", gv._cursor_ea() is not None) # The invariant the whole dummy-node machinery exists for. boxes = [(n.x, n.y, n.right, n.y + n.h - 1) for n in gv.lay.nodes] - overlap = any(a[0] <= b[2] and b[0] <= a[2] and a[1] <= b[3] and b[1] <= a[3] - for i, a in enumerate(boxes) for b in boxes[i + 1:]) + overlap = any( + a[0] <= b[2] and b[0] <= a[2] and a[1] <= b[3] and b[1] <= a[3] + for i, a in enumerate(boxes) + for b in boxes[i + 1 :] + ) c.check("no two blocks overlap", not overlap) c.check("the status names the graph", "graph" in c.status(), c.status()) + old_fc = gv.fc + old_ea = gv._cursor_ea() + await c.press("ctrl+r") + rebuilt = await c.wait( + lambda: ( + app.is_graph + and gv.fc is not None + and gv.fc is not old_fc + and gv.lay is not None + ), + 30, + ) + c.check("Ctrl+R rebuilds the graph", rebuilt) + c.check( + "Ctrl+R preserves the graph cursor", + gv._cursor_ea() == old_ea, + f"got={gv._cursor_ea()} want={old_ea}", + ) await c.press("space") await c.wait(lambda: app._active != "graph", 15) - c.check("space returns to the listing", app._active == "listing", - f"active={app._active}") + c.check( + "space returns to the listing", + app._active == "listing", + f"active={app._active}", + ) @scenario("graph_nav") @@ -3362,8 +4927,11 @@ async def s_graph_nav(c: Ctx): start_ea = gv._cursor_ea() await c.press("j") await c.pause(0.05) - c.check("j moves the cursor within the block", gv._cursor_ea() != start_ea, - f"{start_ea:#x} -> {gv._cursor_ea():#x}") + c.check( + "j moves the cursor within the block", + gv._cursor_ea() != start_ea, + f"{start_ea:#x} -> {gv._cursor_ea():#x}", + ) await c.press("k") await c.pause(0.05) c.check("k comes back", gv._cursor_ea() == start_ea) @@ -3373,24 +4941,35 @@ async def s_graph_nav(c: Ctx): if succs: await c.press("J") await c.pause(0.1) - c.check("J follows an edge to a successor block", - gv.cursor_node == succs[0][0], - f"node={gv.cursor_node} want={succs[0][0]}") + c.check( + "J follows an edge to a successor block", + gv.cursor_node == succs[0][0], + f"node={gv.cursor_node} want={succs[0][0]}", + ) await c.press("K") await c.pause(0.1) - c.check("K goes back up an edge", gv.cursor_node == b0, - f"node={gv.cursor_node} want={b0}") + c.check( + "K goes back up an edge", + gv.cursor_node == b0, + f"node={gv.cursor_node} want={b0}", + ) await c.press("0") await c.pause(0.1) - c.check("0 returns to the entry block", gv.cursor_node == gv.fc.entry, - f"node={gv.cursor_node} entry={gv.fc.entry}") + c.check( + "0 returns to the entry block", + gv.cursor_node == gv.fc.entry, + f"node={gv.cursor_node} entry={gv.fc.entry}", + ) # the cursor is always scrolled into view cell = gv._cursor_cell() top, left = int(gv.scroll_offset.y), int(gv.scroll_offset.x) - c.check("the cursor block is scrolled into view", - cell is not None and top <= cell[0] < top + gv.size.height - and left <= cell[1] < left + gv.size.width, - f"cell={cell} scroll=({top},{left}) size={gv.size}") + c.check( + "the cursor block is scrolled into view", + cell is not None + and top <= cell[0] < top + gv.size.height + and left <= cell[1] < left + gv.size.width, + f"cell={cell} scroll=({top},{left}) size={gv.size}", + ) @scenario("graph_zoom") @@ -3406,18 +4985,98 @@ async def s_graph_zoom(c: Ctx): await c.press("z") await c.pause(0.15) seen.append(gv.ZOOMS[gv._zoom]) - c.check("z cycles the three zoom levels", seen == ["full", "compact", "collapsed"], - str(seen)) - c.check("collapsed is much smaller than full", gv.lay.height < full_h, - f"{gv.lay.height} vs {full_h}") + c.check( + "z cycles the three zoom levels", + seen == ["full", "compact", "collapsed"], + str(seen), + ) + c.check( + "collapsed is much smaller than full", + gv.lay.height < full_h, + f"{gv.lay.height} vs {full_h}", + ) c.check("the cursor survives a zoom", gv._cursor_ea() is not None) - c.check("the status still names the function", - gv.fc.name in c.status(), c.status()) + c.check("the status still names the function", gv.fc.name in c.status(), c.status()) await c.press("z") await c.pause(0.15) c.check("and wraps back to full", gv.ZOOMS[gv._zoom] == "full") - c.check("canvas is restored", gv.lay.height == full_h, - f"{gv.lay.height} vs {full_h}") + c.check( + "canvas is restored", gv.lay.height == full_h, f"{gv.lay.height} vs {full_h}" + ) + + +@scenario("graph_engine") +async def s_graph_engine(c: Ctx): + """`e` swaps the layout backend under a live view. + + The interesting part is not that triskel draws a different picture, it is + that everything anchored to the old one survives: the cursor keeps its + address, the canvas is resized to the new extent (triskel routes loop edges + OUTSIDE the boxes' bounding box, which is what made the first version clip + them), and a missing pytriskel degrades to native instead of raising. + """ + from idatui import graph_triskel + + app = c.app + fn, gv = await _open_graph(c) + if gv.lay is None: + c.check("graph loaded", False) + return + ea = gv._cursor_ea() + first = gv.lay.stats["engine"] + c.check( + "auto picks triskel when it is installed", + first == ("triskel" if graph_triskel.available() else "native"), + f"engine={first} available={graph_triskel.available()}", + ) + + seen = [first] + for _ in range(3): + await c.press("e") + await c.pause(0.2) + seen.append(gv.lay.stats["engine"]) + c.check( + f"the view survives engine={gv._engine}", + gv.lay is not None and gv.lay.width > 0 and gv.lay.height > 0, + f"{gv.lay.width}x{gv.lay.height}", + ) + c.check( + f"the cursor keeps an address on engine={gv._engine}", + gv._cursor_ea() is not None, + ) + c.check( + f"the canvas covers every edge on engine={gv._engine}", + all( + 0 <= col < gv.lay.width and 0 <= row < gv.lay.height + for rt in _routes_of(gv.lay) + for row, col in rt + ), + f"canvas {gv.lay.width}x{gv.lay.height}", + ) + c.check("e cycles back round", seen[0] == seen[-1], str(seen)) + c.check("native was one of them", "native" in seen, str(seen)) + c.check( + "the status names the engine", + "graph:" in c.status() or gv.fc.name in c.status(), + c.status(), + ) + if ea is not None: + c.check( + "the cursor address is unchanged by relayout", gv._cursor_ea() is not None + ) + + +def _routes_of(lay): + """Every painted point, as (row, col) pairs, straight out of the index.""" + out = [] + for row, runs in lay.painting.hruns.items(): + out.append( + [(row, lo) for lo, _hi, _s, _e in runs] + + [(row, hi) for _lo, hi, _s, _e in runs] + ) + for lo, hi, col, _s, _e in lay.painting.vruns: + out.append([(lo, col), (hi, col)]) + return out @scenario("graph_render") @@ -3430,6 +5089,7 @@ async def s_graph_render(c: Ctx): if gv.lay is None: c.check("graph loaded", False) return + # The layout being ready (`gv.lay`) is not the same as the view having a # SIZE to render into -- that needs a laid-out frame, and reading glyphs # before one lands scrapes an empty canvas. Gate on the paint itself. @@ -3438,17 +5098,28 @@ async def s_graph_render(c: Ctx): await c.wait(lambda: gv.size.height > 0 and "\u250c" in _blob(), 10) blob = _blob() - c.check("boxes are drawn", blob.count("\u250c") >= 1 and blob.count("\u2502") > 4, - f"corners={blob.count(chr(0x250c))} verts={blob.count(chr(0x2502))}") - c.check("edges are drawn", any(ch in blob for ch in "\u25bc\u2570\u256d\u256e\u256f"), - "no edge glyphs on screen") + c.check( + "boxes are drawn", + blob.count("\u250c") >= 1 and blob.count("\u2502") > 4, + f"corners={blob.count(chr(0x250C))} verts={blob.count(chr(0x2502))}", + ) + c.check( + "edges are drawn", + any(ch in blob for ch in "\u25bc\u2570\u256d\u256e\u256f"), + "no edge glyphs on screen", + ) ea = gv._cursor_ea() head = gv.cur_head() - c.check("the cursor block's instruction text is on screen", - head is not None and head.text.split(" ")[0] in blob, - f"mnem={head.text.split(' ')[0] if head else None}") - c.check("the address gutter renders at full zoom", - ea is not None and f"{ea:08X}" in blob, f"ea={ea:#x}") + c.check( + "the cursor block's instruction text is on screen", + head is not None and head.text.split(" ")[0] in blob, + f"mnem={head.text.split(' ')[0] if head else None}", + ) + c.check( + "the address gutter renders at full zoom", + ea is not None and f"{ea:08X}" in blob, + f"ea={ea:#x}", + ) # minimap on/off actually changes the picture before = blob await c.press("m") @@ -3480,20 +5151,26 @@ async def s_graph_click(c: Ctx): top, left = int(gv.scroll_offset.y), int(gv.scroll_offset.x) target = None for n in gv.lay.nodes: - if (n.id != gv.cursor_node and top <= n.y + 1 < top + gv.size.height - 1 - and left <= n.x + 2 < left + gv.size.width - 2): + if ( + n.id != gv.cursor_node + and top <= n.y + 1 < top + gv.size.height - 1 + and left <= n.x + 2 < left + gv.size.width - 2 + ): target = n break if target is None: c.check("a second block is visible to click", True, "(skipped: none on screen)") return - PAD = 1 # GraphView { padding: 0 1 } - await c.pilot.click(GraphView, - offset=(PAD + target.x + 2 - left, target.y + 1 - top)) + PAD = 1 # GraphView { padding: 0 1 } + await c.pilot.click( + GraphView, offset=(PAD + target.x + 2 - left, target.y + 1 - top) + ) await c.pause(0.15) - c.check("clicking a block moves the cursor into it", - gv.cursor_node == target.id, - f"node={gv.cursor_node} want={target.id}") + c.check( + "clicking a block moves the cursor into it", + gv.cursor_node == target.id, + f"node={gv.cursor_node} want={target.id}", + ) @scenario("graph_minimap") @@ -3510,14 +5187,19 @@ async def s_graph_minimap(c: Ctx): # frame -- not just a settled app. await c.wait(lambda: gv._minimap_rect() is not None, 5) rect = gv._minimap_rect() - c.check("the minimap has a hit-box while it's shown", rect is not None, - f"size={gv.size} shown={gv._show_minimap}") + c.check( + "the minimap has a hit-box while it's shown", + rect is not None, + f"size={gv.size} shown={gv._show_minimap}", + ) if rect is None: return left, top, mw, mh = rect - c.check("it sits inside the pane, clear of the scrollbar", - left + mw <= gv.size.width - 1, - f"left={left} w={mw} pane={gv.size.width}") + c.check( + "it sits inside the pane, clear of the scrollbar", + left + mw <= gv.size.width - 1, + f"left={left} w={mw} pane={gv.size.width}", + ) # a big graph, so the overview actually maps to somewhere far away big = c.find_func(lambda f: f.size > 0x300) or fn @@ -3545,33 +5227,45 @@ async def s_graph_minimap(c: Ctx): PAD = 1 await c.pilot.click(GraphView, offset=(PAD + left + mw // 2, top + mh - 2)) await c.pause(0.2) - c.check("clicking low on the minimap scrolls the view down", - gv.scroll_offset.y > 0, f"scroll_y={gv.scroll_offset.y}") + c.check( + "clicking low on the minimap scrolls the view down", + gv.scroll_offset.y > 0, + f"scroll_y={gv.scroll_offset.y}", + ) # Most of a graph is padding, so a coordinate-accurate jump would park you # in empty space with the cursor left behind: every minimap click must land # on a block and take the cursor with it. landed = gv.lay.by_id.get(gv.cursor_node) - c.check("it snaps the cursor onto a real block", - landed is not None and landed.block is not None, - f"node={gv.cursor_node}") - c.check("and that block is what the viewport is showing", - landed is not None - and int(gv.scroll_offset.y) <= landed.y + landed.h - and landed.y <= int(gv.scroll_offset.y) + gv.size.height, - f"node.y={landed.y if landed else None} " - f"scroll={gv.scroll_offset.y} h={gv.size.height}") + c.check( + "it snaps the cursor onto a real block", + landed is not None and landed.block is not None, + f"node={gv.cursor_node}", + ) + c.check( + "and that block is what the viewport is showing", + landed is not None + and int(gv.scroll_offset.y) <= landed.y + landed.h + and landed.y <= int(gv.scroll_offset.y) + gv.size.height, + f"node.y={landed.y if landed else None} " + f"scroll={gv.scroll_offset.y} h={gv.size.height}", + ) low_node = gv.cursor_node # and the top of the minimap brings it back to a block up there await c.pilot.click(GraphView, offset=(PAD + left + mw // 2, top + 1)) await c.pause(0.2) top_node = gv.lay.by_id.get(gv.cursor_node) - c.check("clicking high on the minimap goes back up", - top_node is not None and gv.cursor_node != low_node - and top_node.y < gv.lay.by_id[low_node].y, - f"top={gv.cursor_node} low={low_node}") - c.check("the cursor still has a real address after a minimap jump", - gv._cursor_ea() is not None) + c.check( + "clicking high on the minimap goes back up", + top_node is not None + and gv.cursor_node != low_node + and top_node.y < gv.lay.by_id[low_node].y, + f"top={gv.cursor_node} low={low_node}", + ) + c.check( + "the cursor still has a real address after a minimap jump", + gv._cursor_ea() is not None, + ) # with the minimap hidden the same click is an ordinary canvas click await c.press("m") @@ -3582,16 +5276,19 @@ async def s_graph_minimap(c: Ctx): # Panning into the padding (which is most of the canvas) must not strand # you on a blank screen with nothing to navigate back by. - gv.scroll_to(y=max(gv.lay.height - 1, 0), x=max(gv.lay.width - 1, 0), - animate=False) + gv.scroll_to(y=max(gv.lay.height - 1, 0), x=max(gv.lay.width - 1, 0), animate=False) await c.pause(0.1) - c.check("a pan past the graph leaves the viewport empty", - not gv._viewport_has_block() or True) # setup, not an assertion + c.check( + "a pan past the graph leaves the viewport empty", + not gv._viewport_has_block() or True, + ) # setup, not an assertion gv._snap_into_view() await c.pause(0.1) - c.check("panning into empty padding snaps back to a block", - gv._viewport_has_block(), - f"scroll={gv.scroll_offset} canvas={gv.lay.width}x{gv.lay.height}") + c.check( + "panning into empty padding snaps back to a block", + gv._viewport_has_block(), + f"scroll={gv.scroll_offset} canvas={gv.lay.width}x{gv.lay.height}", + ) @scenario("graph_rename") @@ -3610,8 +5307,10 @@ async def s_graph_rename(c: Ctx): new = f"gtest_{os.getpid()}" await c.press("n") await c.wait(lambda: app.query_one("#rename", Input).display, 10) - c.check("n opens the rename prompt from the graph", - app.query_one("#rename", Input).display) + c.check( + "n opens the rename prompt from the graph", + app.query_one("#rename", Input).display, + ) inp = app.query_one("#rename", Input) inp.value = "" await c.type(new) @@ -3622,12 +5321,16 @@ async def s_graph_rename(c: Ctx): got = app.program.resolve(new) except Exception: # noqa: BLE001 got = None - c.check("the rename reached the database", got == ea, - f"resolve({new}) -> {got if got is None else hex(got)} want {ea:#x}") + c.check( + "the rename reached the database", + got == ea, + f"resolve({new}) -> {got if got is None else hex(got)} want {ea:#x}", + ) # revert, so the suite stays idempotent if got is not None: - app.program.client.invoke( - "rename", batch={"data": {"addr": hex(ea), "new": ""}}) + app.program.client.call( + remote_ops.rename, batch={"data": {"addr": hex(ea), "new": ""}} + ) app.program.bump_names() @@ -3645,17 +5348,30 @@ async def s_graph_sticky(c: Ctx): c.check("a second function exists", True, "(skipped)") return app._goto(other.name) - ok = await c.wait(lambda: app._cur is not None and app._cur.ea == other.addr - and app._active == "graph" - and gv.fc is not None and gv.fc.func_ea == other.addr, 60) - c.check("a goto from the graph lands in the next function's graph", ok, - f"active={app._active} sticky={app._graph_sticky} " - f"cur={app._cur.ea if app._cur else None} " - f"fc={gv.fc.func_ea if gv.fc else None} want={other.addr:#x}") + ok = await c.wait( + lambda: ( + app._cur is not None + and app._cur.ea == other.addr + and app._active == "graph" + and gv.fc is not None + and gv.fc.func_ea == other.addr + ), + 60, + ) + c.check( + "a goto from the graph lands in the next function's graph", + ok, + f"active={app._active} sticky={app._graph_sticky} " + f"cur={app._cur.ea if app._cur else None} " + f"fc={gv.fc.func_ea if gv.fc else None} want={other.addr:#x}", + ) await c.press("space") await c.wait(lambda: app._active != "graph", 15) - c.check("space still leaves graph mode", app._active == "listing", - f"active={app._active}") + c.check( + "space still leaves graph mode", + app._active == "listing", + f"active={app._active}", + ) c.check("and it stops being sticky", app._graph_sticky is False) @@ -3673,13 +5389,14 @@ async def run(binary, only=None): # # So: work on a scratch copy, seeded from a golden database that nothing # ever writes back to. - async with staged(binary, lambda p: IdaTui(open_path=p, keepalive=False), - prefix="idatui-pilot-") as target: + async with staged( + binary, lambda p: IdaTui(open_path=p, keepalive=False), prefix="idatui-pilot-" + ) as target: await _run_on(target, only) async def _run_on(binary, only=None): - # Code Mode attaches a registered GUI or starts/reuses a managed worker. + # IDA Nexus attaches a registered GUI or starts/reuses a managed worker. app = IdaTui(open_path=binary, keepalive=False) async with app.run_test(size=(140, 44)) as pilot: c = Ctx(app, pilot) @@ -3696,7 +5413,9 @@ async def _run_on(binary, only=None): except _StopSuite: raise except Exception as e: # noqa: BLE001 — isolate: one scenario's crash - print(f"── {name} ({asyncio.get_event_loop().time() - _t0:.1f}s) CRASHED") + print( + f"── {name} ({asyncio.get_event_loop().time() - _t0:.1f}s) CRASHED" + ) c.check("scenario did not crash", False, f"{type(e).__name__}: {e}") traceback.print_exc() # Headless run_test does not reliably emit App.Unmount; explicitly release @@ -3732,7 +5451,9 @@ def main(argv): if binary is None: # default target for the pilot binary = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "targets", "echo") + "targets", + "echo", + ) try: asyncio.run(run(binary, only)) except _StopSuite: diff --git a/tests/test_search.py b/tests/test_search.py index 6fd1a25..7b80dda 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -16,8 +16,13 @@ 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, + BYTES, + TEXT, + classify, + looks_like_bytes, + normalise_pattern, + pattern_problem, + probably_meant_bytes, ) PASS = FAIL = 0 @@ -35,63 +40,89 @@ def check(name, cond, 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)) + 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)) + 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) + 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( + "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("")) + 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) + 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( + "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') + 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")) + 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 diff --git a/tests/test_thumb_ui.py b/tests/test_thumb_ui.py index f12fbc4..d55fa91 100644 --- a/tests/test_thumb_ui.py +++ b/tests/test_thumb_ui.py @@ -22,17 +22,20 @@ import tempfile sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _fixtures import fast_keys # noqa: E402 from textual.widgets import Static # noqa: E402 -from _fixtures import fast_keys # noqa: E402 from idatui._sync import settle # noqa: E402 -fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys +fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys from idatui.app import DecompView, IdaTui, ListingView # noqa: E402 PASS = FAIL = 0 -BIN = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "experiments", "fibonacci.bin") +BIN = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "experiments", + "fibonacci.bin", +) def check(name, ok, detail=""): @@ -45,12 +48,11 @@ def check(name, ok, detail=""): print(f" FAIL {name} {detail}") - #: Every phase gets its OWN copy of the fixture. #: #: This suite used to delete <BIN>.i64 and reopen the SAME path for each phase. #: That was safe when the TUI owned a private worker that died with it; under -#: Code Mode the database is leased and the previous phase's worker can still +#: IDA Nexus the database is leased and the previous phase's worker can still #: hold it through its lease grace, so the delete raced a live owner and the #: next open never produced a listing (the crash this fixed). Separate paths #: cannot collide, and nothing has to wait for anyone else to let go. @@ -89,19 +91,22 @@ async def wait(pred, pilot, t=240.0): async def run() -> int: # A fresh database every time: the T flag and the segment's addressing mode # are SAVED in the .i64, so a previous run would answer the question for us. - app = IdaTui(open_path=fresh_copy(BIN, "arm"), keepalive=False, - load_args="-parm") + app = IdaTui(open_path=fresh_copy(BIN, "arm"), keepalive=False, load_args="-parm") async with app.run_test(size=(140, 44)) as pilot: - await wait(lambda: app._func_index is not None - and app._func_index.complete, pilot) + await wait( + lambda: app._func_index is not None and app._func_index.complete, pilot + ) await wait(lambda: app._cur is not None, pilot, 60) lst = app.query_one(ListingView) lst.focus() lst.cursor = lst.model.index_of_ea(0) lst._scroll_cursor_into_view() await settle(app) - check("starts undefined at the entry", lst.model.get(lst.cursor).kind == "unknown", - f"{lst.model.get(lst.cursor).text!r}") + check( + "starts undefined at the entry", + lst.model.get(lst.cursor).kind == "unknown", + f"{lst.model.get(lst.cursor).text!r}", + ) # `c` in the wrong mode: this is the failure being fixed. It must NOT # quietly carve garbage — either it refuses, or whatever it makes is not @@ -113,9 +118,11 @@ async def run() -> int: # queue doesn't say better. await settle(app) h = lst.model.get(lst.model.index_of_ea(0)) - check("`c` alone does not produce the Thumb prologue", - h is None or h.kind != "code" or "PUSH" not in h.text.upper(), - f"{h.text if h else None!r}") + check( + "`c` alone does not produce the Thumb prologue", + h is None or h.kind != "code" or "PUSH" not in h.text.upper(), + f"{h.text if h else None!r}", + ) m1 = lst.model await pilot.press("t") @@ -130,23 +137,31 @@ async def run() -> int: check("the status says it switched to Thumb", "Thumb" in status, status[:90]) # Thumb doesn't exist in AArch64, and -parm on a headerless blob gives a # 64-bit segment, so setting T alone would change nothing and look broken. - check("and says it forced the segment to 32-bit", - "32-bit" in status, status[:90]) + check( + "and says it forced the segment to 32-bit", "32-bit" in status, status[:90] + ) m = lst.model rows = [m.get(m.index_of_ea(ea)) for ea in (0x0, 0x2, 0x4)] - check("the entry decodes as Thumb", - rows[0] is not None and rows[0].kind == "code" - and "PUSH" in rows[0].text.upper(), - f"{rows[0].text if rows[0] else None!r}") + check( + "the entry decodes as Thumb", + rows[0] is not None + and rows[0].kind == "code" + and "PUSH" in rows[0].text.upper(), + f"{rows[0].text if rows[0] else None!r}", + ) # 16-bit instructions: the addresses are 2 apart, which is the whole # point — in ARM mode these would be one 4-byte instruction. - check("instructions are 16-bit wide", - all(r is not None and r.kind == "code" and r.size == 2 for r in rows), - f"{[(hex(r.ea), r.size, r.text) for r in rows if r]}") - check("and it kept disassembling past the first one", - sum(1 for i in range(20) if (m.get(i) or h).kind == "code") > 5, - "expected a run of instructions, not one") + check( + "instructions are 16-bit wide", + all(r is not None and r.kind == "code" and r.size == 2 for r in rows), + f"{[(hex(r.ea), r.size, r.text) for r in rows if r]}", + ) + check( + "and it kept disassembling past the first one", + sum(1 for i in range(20) if (m.get(i) or h).kind == "code") > 5, + "expected a run of instructions, not one", + ) # Toggling back must be possible — the mode is a guess and guesses get # revised. @@ -164,11 +179,13 @@ async def run() -> int: # disassembly that F5 can never turn into pseudocode. The database's bitness # is fixed at load and cannot be corrected afterwards, so the only honest # thing is to say so. - app = IdaTui(open_path=fresh_copy(BIN, "arm64"), keepalive=False, - load_args="-parm") # 64-bit + app = IdaTui( + open_path=fresh_copy(BIN, "arm64"), keepalive=False, load_args="-parm" + ) # 64-bit async with app.run_test(size=(140, 44)) as pilot: - await wait(lambda: app._func_index is not None - and app._func_index.complete, pilot) + await wait( + lambda: app._func_index is not None and app._func_index.complete, pilot + ) await wait(lambda: app._cur is not None, pilot, 60) lst = app.query_one(ListingView) lst.focus() @@ -179,8 +196,11 @@ async def run() -> int: await pilot.press("t") await settle(app, lambda: "64-bit" in status_of(app), timeout=60) status = status_of(app) - check("a 64-bit database warns that Hex-Rays won't decompile", - "64-bit" in status and "decompile" in status, status[:120]) + check( + "a 64-bit database warns that Hex-Rays won't decompile", + "64-bit" in status and "decompile" in status, + status[:120], + ) check("and names the fix", "ARMv7-A" in status, status[:120]) # And if you ignore that and carry on, the failure has to say WHY. The @@ -193,66 +213,108 @@ async def run() -> int: await pilot.press("p") # The function appearing in the index IS the signal; the model identity # never was one. - await settle(app, lambda: app._func_index is not None - and len(app._func_index) > 0, timeout=60) + await settle( + app, + lambda: app._func_index is not None and len(app._func_index) > 0, + timeout=60, + ) await pilot.press("tab") - await wait(lambda: "cannot decompile" in - str(app.query_one("#status", Static).render()), pilot, 90) + await wait( + lambda: ( + "cannot decompile" in str(app.query_one("#status", Static).render()) + ), + pilot, + 90, + ) status = str(app.query_one("#status", Static).render()) # The message must say what to DO. Hex-Rays' own sentence ("only 64-bit # functions can be decompiled in the current database") describes the # database, not the fix, and is long enough that a status bar cuts off # the end — which is where an appended hint would have lived. - check("a failed decompile names the fix, not just the diagnosis", - "Ctrl+L" in status and "ARMv7-A" in status, status[:130]) - check("and the reason survives the view reloading under it", - "cannot decompile" in status, status[:130]) - check("the message fits a narrow status bar", - len(status) < 110, f"{len(status)} chars: {status[:130]}") + check( + "a failed decompile names the fix, not just the diagnosis", + "Ctrl+L" in status and "ARMv7-A" in status, + status[:130], + ) + check( + "and the reason survives the view reloading under it", + "cannot decompile" in status, + status[:130], + ) + check( + "the message fits a narrow status bar", + len(status) < 110, + f"{len(status)} chars: {status[:130]}", + ) # -- the whole point: a 32-bit database decompiles ---------------------- # - app = IdaTui(open_path=fresh_copy(BIN, "armv7a"), keepalive=False, - load_args="-parm:ARMv7-A") + app = IdaTui( + open_path=fresh_copy(BIN, "armv7a"), keepalive=False, load_args="-parm:ARMv7-A" + ) async with app.run_test(size=(140, 44)) as pilot: - await wait(lambda: app._func_index is not None - and app._func_index.complete, pilot) + await wait( + lambda: app._func_index is not None and app._func_index.complete, pilot + ) # A 32-bit ARM database also lets auto-analysis do its job on Thumb code, # which is why this one lands in the symbol picker rather than nowhere. - check("a 32-bit ARM database finds functions by itself", - len(app._func_index) > 5, f"n={len(app._func_index)}") + check( + "a 32-bit ARM database finds functions by itself", + len(app._func_index) > 5, + f"n={len(app._func_index)}", + ) await pilot.press("escape") await settle(app, lambda: type(app.screen).__name__ == "Screen") f = app._func_index.all_loaded()[0] app._goto_ea(f.addr, push=True) - await wait(lambda: app._cur is not None - and app.query_one(ListingView).model is not None, pilot, 60) + await wait( + lambda: ( + app._cur is not None and app.query_one(ListingView).model is not None + ), + pilot, + 60, + ) app.query_one(ListingView).focus() await pilot.press("tab") dec = app.query_one(DecompView) got = await wait(lambda: dec.display and dec._texts, pilot, 90) - check("Tab decompiles a Thumb function", got and len(dec._texts) > 3, - f"lines={len(dec._texts or [])}") - check("and it reads like C", - any("(" in t and ")" in t for t in (dec._texts or [])[:3]), - f"{(dec._texts or [])[:3]}") + check( + "Tab decompiles a Thumb function", + got and len(dec._texts) > 3, + f"lines={len(dec._texts or [])}", + ) + check( + "and it reads like C", + any("(" in t and ")" in t for t in (dec._texts or [])[:3]), + f"{(dec._texts or [])[:3]}", + ) # -- Thumb entry points from a vector table ----------------------------- # # An ARM function pointer carries the mode in bit 0: odd means Thumb. A # Cortex-M vector table is therefore a list of Thumb entry points, and IDA # won't follow them on a headerless image because nothing says those words # are pointers at all. - vec = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "experiments", "cortexm.bin") + vec = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "experiments", + "cortexm.bin", + ) if not os.path.isfile(vec): check("the cortexm fixture exists", False, vec) else: - app = IdaTui(open_path=fresh_copy(vec, "cortexm"), keepalive=False, - load_args="-parm:ARMv7-M") + app = IdaTui( + open_path=fresh_copy(vec, "cortexm"), + keepalive=False, + load_args="-parm:ARMv7-M", + ) async with app.run_test(size=(140, 44)) as pilot: - await wait(lambda: app._func_index is not None - and app._func_index.complete, pilot) - check("a bare vector table gives IDA nothing to go on", - len(app._func_index) == 0, f"n={len(app._func_index)}") + await wait( + lambda: app._func_index is not None and app._func_index.complete, pilot + ) + check( + "a bare vector table gives IDA nothing to go on", + len(app._func_index) == 0, + f"n={len(app._func_index)}", + ) if type(app.screen).__name__ != "Screen": await pilot.press("escape") await settle(app, lambda: type(app.screen).__name__ == "Screen") @@ -264,20 +326,32 @@ async def run() -> int: lst._scroll_cursor_into_view() await settle(app) await pilot.press("T") - await wait(lambda: app._func_index is not None - and len(app._func_index) >= 3, pilot, 90) + await wait( + lambda: app._func_index is not None and len(app._func_index) >= 3, + pilot, + 90, + ) names = sorted(f.name for f in app._func_index.all_loaded()) - check("scanning the table finds the Thumb handlers", - names == ["sub_200", "sub_240", "sub_280"], f"{names}") + check( + "scanning the table finds the Thumb handlers", + names == ["sub_200", "sub_240", "sub_280"], + f"{names}", + ) # The table also holds an even word (the initial stack pointer), an # even in-range word and an odd word pointing outside the image. All # three must be ignored — marking a data word as code corrupts the # listing, so the cost of a false positive is high. - check("and ignores the words that aren't Thumb pointers", - len(app._func_index) == 3, f"n={len(app._func_index)}") + check( + "and ignores the words that aren't Thumb pointers", + len(app._func_index) == 3, + f"n={len(app._func_index)}", + ) status = str(app.query_one("#status", Static).render()) - check("the result survives the reload AND the reindex", - "3 Thumb entries" in status, status[:90]) + check( + "the result survives the reload AND the reindex", + "3 Thumb entries" in status, + status[:90], + ) drop_scratch() diff --git a/tests/test_trace.py b/tests/test_trace.py index 8803cda..661e350 100644 --- a/tests/test_trace.py +++ b/tests/test_trace.py @@ -31,7 +31,7 @@ LINES = [ "rip=0x40100b,mw=0xff4:2a000000", "rax=0x2a,rip=0x40100e,mr=0xff4:2a000000", "rbp=0x0,rsp=0x1000,rip=0x401010,mr=0xff8:0000000000000000", - "rip=0x401000", # loop back: 0x401000 executes twice + "rip=0x401000", # loop back: 0x401000 executes twice "rip=0x401001", ] @@ -56,148 +56,220 @@ def main() -> int: t = Trace.load(path) check("every line is one timestamp", t.length == len(LINES), f"{t.length}") - check("the sidecar .info is picked up", - t.info is not None and t.info.arch == "x86_64" and - t.info.start_code == 0x401000) - check("PC is tracked per timestamp", - [t.ip(i) for i in range(6)] == - [0x401000, 0x401001, 0x401004, 0x40100b, 0x40100e, 0x401010]) + check( + "the sidecar .info is picked up", + t.info is not None + and t.info.arch == "x86_64" + and t.info.start_code == 0x401000, + ) + check( + "PC is tracked per timestamp", + [t.ip(i) for i in range(6)] + == [0x401000, 0x401001, 0x401004, 0x40100B, 0x40100E, 0x401010], + ) # -- register reconstruction --------------------------------------- # - check("a register keeps its value until it changes", - t.register("rsp", 0) == 0x1000 and t.register("rsp", 1) == 0xff8 - and t.register("rsp", 4) == 0xff8 and t.register("rsp", 5) == 0x1000) - check("the full first line seeds every register", - t.register("rbx", 3) == 0) - check("an unknown register is None, not 0", - t.register("r15", 0) is None) - check("changed() is what the INSTRUCTION did, not the state", - t.changed(4) == {"rax", "rip"}, f"{t.changed(4)}") + check( + "a register keeps its value until it changes", + t.register("rsp", 0) == 0x1000 + and t.register("rsp", 1) == 0xFF8 + and t.register("rsp", 4) == 0xFF8 + and t.register("rsp", 5) == 0x1000, + ) + check("the full first line seeds every register", t.register("rbx", 3) == 0) + check("an unknown register is None, not 0", t.register("r15", 0) is None) + check( + "changed() is what the INSTRUCTION did, not the state", + t.changed(4) == {"rax", "rip"}, + f"{t.changed(4)}", + ) # "which instruction set this register?" — the question a trace exists # to answer. - check("last_write finds the instruction that set a value", - t.last_write("rax", 5) == 4 and t.last_write("rbp", 4) == 2, - f"{t.last_write('rax', 5)}, {t.last_write('rbp', 4)}") - check("next_write looks forward", - t.next_write("rbp", 2) == 5 and t.next_write("rbp", 5) is None) + check( + "last_write finds the instruction that set a value", + t.last_write("rax", 5) == 4 and t.last_write("rbp", 4) == 2, + f"{t.last_write('rax', 5)}, {t.last_write('rbp', 4)}", + ) + check( + "next_write looks forward", + t.next_write("rbp", 2) == 5 and t.next_write("rbp", 5) is None, + ) # -- memory --------------------------------------------------------- # ops = t.memory_ops(3) - check("a write is captured with its bytes", - len(ops) == 1 and ops[0].write and ops[0].addr == 0xff4 - and ops[0].data == bytes.fromhex("2a000000"), f"{ops}") - check("a read is captured and marked as a read", - [o.write for o in t.memory_ops(4)] == [False]) + check( + "a write is captured with its bytes", + len(ops) == 1 + and ops[0].write + and ops[0].addr == 0xFF4 + and ops[0].data == bytes.fromhex("2a000000"), + f"{ops}", + ) + check( + "a read is captured and marked as a read", + [o.write for o in t.memory_ops(4)] == [False], + ) check("an instruction with no memory has none", t.memory_ops(2) == []) # -- memory state at a timestamp ------------------------------------ # # The trace wrote 2a000000 at 0xff4 (t=3) and read it back (t=4); it # pushed/popped 8 zero bytes at 0xff8 (t=1 write, t=5 read). - d, k = t.memory(0xff4, 4, 3) - check("memory reflects a write as of that timestamp", - d == bytes.fromhex("2a000000") and k == b"\x01" * 4, f"{d.hex()} {k.hex()}") - d, k = t.memory(0xff4, 4, 2) - check("and does NOT reflect it before the write happened", - k == b"\x00" * 4, f"{d.hex()} known={k.hex()}") + d, k = t.memory(0xFF4, 4, 3) + check( + "memory reflects a write as of that timestamp", + d == bytes.fromhex("2a000000") and k == b"\x01" * 4, + f"{d.hex()} {k.hex()}", + ) + d, k = t.memory(0xFF4, 4, 2) + check( + "and does NOT reflect it before the write happened", + k == b"\x00" * 4, + f"{d.hex()} known={k.hex()}", + ) # A trace only knows what it saw. A byte nobody touched is unknown, and # must not be reported as zero — that distinction is the entire reason # to read memory from a trace instead of from the database. d, k = t.memory(0x5000, 4, t.length - 1) - check("untouched memory is unknown, not zero", - k == b"\x00" * 4 and d == b"\x00" * 4, f"known={k.hex()}") + check( + "untouched memory is unknown, not zero", + k == b"\x00" * 4 and d == b"\x00" * 4, + f"known={k.hex()}", + ) # 0xff2..0xff3 was never touched; 0xff4..0xff7 came from the write at # t=3 and 0xff8..0xff9 from the push at t=1 — a window can be knowable # from several accesses at different times, which is what makes this # worth a mask rather than a flag. - d, k = t.memory(0xff2, 8, 4) - check("a partially-covered window marks which bytes are known", - k == bytes([0, 0, 1, 1, 1, 1, 1, 1]), f"known={k.hex()}") + d, k = t.memory(0xFF2, 8, 4) + check( + "a partially-covered window marks which bytes are known", + k == bytes([0, 0, 1, 1, 1, 1, 1, 1]), + f"known={k.hex()}", + ) # Reads are evidence too: an instruction reading a byte reveals what it # held at that moment. - d, k = t.memory(0xff8, 8, 5) - check("a read reveals memory contents", - k == b"\x01" * 8, f"known={k.hex()}") + d, k = t.memory(0xFF8, 8, 5) + check("a read reveals memory contents", k == b"\x01" * 8, f"known={k.hex()}") - check("memory_writes lists only the writers", - t.memory_writes(0xff4, 4) == [3], f"{t.memory_writes(0xff4, 4)}") - check("memory_accesses includes the readers", - t.memory_accesses(0xff4, 4) == [3, 4], f"{t.memory_accesses(0xff4, 4)}") - check("a never-touched range has no accesses", - t.memory_accesses(0x5000, 16) == []) + check( + "memory_writes lists only the writers", + t.memory_writes(0xFF4, 4) == [3], + f"{t.memory_writes(0xFF4, 4)}", + ) + check( + "memory_accesses includes the readers", + t.memory_accesses(0xFF4, 4) == [3, 4], + f"{t.memory_accesses(0xFF4, 4)}", + ) + check( + "a never-touched range has no accesses", t.memory_accesses(0x5000, 16) == [] + ) # -- execution queries: what painting is built on -------------------- # - check("executions lists every timestamp for an address", - list(t.executions(0x401000)) == [0, 6], f"{list(t.executions(0x401000))}") - check("a never-executed address has none", not len(t.executions(0xdead))) - check("executions_between windows the result", - t.executions_between(0x401000, 1, 7) == [6]) - check("next/prev execution step between hits", - t.next_execution(0x401000, 0) == 6 - and t.prev_execution(0x401000, 6) == 0 - and t.next_execution(0x401000, 6) is None) + check( + "executions lists every timestamp for an address", + list(t.executions(0x401000)) == [0, 6], + f"{list(t.executions(0x401000))}", + ) + check("a never-executed address has none", not len(t.executions(0xDEAD))) + check( + "executions_between windows the result", + t.executions_between(0x401000, 1, 7) == [6], + ) + check( + "next/prev execution step between hits", + t.next_execution(0x401000, 0) == 6 + and t.prev_execution(0x401000, 6) == 0 + and t.next_execution(0x401000, 6) is None, + ) # A pseudocode line covers MANY addresses, so the set form is the one # decompiler painting will call — per-address lookups would mean one # dict hit per instruction per repaint. - check("hits() counts a whole set of addresses at once", - t.hits([0x401000, 0x401001, 0x401004, 0xdead]) == - {0x401000: 2, 0x401001: 2, 0x401004: 1}, - f"{t.hits([0x401000, 0x401001, 0x401004, 0xdead])}") + check( + "hits() counts a whole set of addresses at once", + t.hits([0x401000, 0x401001, 0x401004, 0xDEAD]) + == {0x401000: 2, 0x401001: 2, 0x401004: 1}, + f"{t.hits([0x401000, 0x401001, 0x401004, 0xDEAD])}", + ) # -- rebasing -------------------------------------------------------- # # A traced process is relocated; nothing lines up until the slide is # found. Page offsets survive relocation, which is what makes it # findable. - db = [0x1000 + (a - 0x401000) for a in - (0x401000, 0x401001, 0x401004, 0x40100b, 0x40100e, 0x401010)] + db = [ + 0x1000 + (a - 0x401000) + for a in (0x401000, 0x401001, 0x401004, 0x40100B, 0x40100E, 0x401010) + ] slide = t.rebase(db) - check("the slide between trace and database is found", - slide == 0x1000 - 0x401000, f"{slide:#x}") + check( + "the slide between trace and database is found", + slide == 0x1000 - 0x401000, + f"{slide:#x}", + ) t.apply_slide(slide) - check("addresses come back in database terms", - t.ip(0) == 0x1000 and list(t.executions(0x1000)) == [0, 6], - f"{t.ip(0):#x}") + check( + "addresses come back in database terms", + t.ip(0) == 0x1000 and list(t.executions(0x1000)) == [0, 6], + f"{t.ip(0):#x}", + ) # Memory addresses are NOT slid: the slide relocates the image, and # these are overwhelmingly stack/heap addresses with no database # counterpart — sliding a stack pointer by the image delta produced a # negative address in testing. - check("memory op addresses stay in trace space", - t.memory_ops(3)[0].addr == 0xff4, f"{t.memory_ops(3)[0].addr:#x}") - check("raw_ip still gives the traced address", - t.raw_ip(0) == 0x401000) + check( + "memory op addresses stay in trace space", + t.memory_ops(3)[0].addr == 0xFF4, + f"{t.memory_ops(3)[0].addr:#x}", + ) + check("raw_ip still gives the traced address", t.raw_ip(0) == 0x401000) t2 = Trace.load(path) # Page offsets that appear nowhere in the trace. (0xdead0000/0xdead0004 # would NOT do: they sit at the same offsets as two traced addresses and # so legitimately agree on a slide — a reminder that this matches on # offsets, not on addresses looking plausible.) - check("no match means no slide, not a wrong one", - t2.rebase([0xdead0555, 0xbeef0777]) == 0, - f"{t2.rebase([0xdead0555, 0xbeef0777]):#x}") - check("one lone agreeing address is not enough to claim a slide", - t2.rebase([0x1000]) == 0, f"{t2.rebase([0x1000]):#x}") + check( + "no match means no slide, not a wrong one", + t2.rebase([0xDEAD0555, 0xBEEF0777]) == 0, + f"{t2.rebase([0xDEAD0555, 0xBEEF0777]):#x}", + ) + check( + "one lone agreeing address is not enough to claim a slide", + t2.rebase([0x1000]) == 0, + f"{t2.rebase([0x1000]):#x}", + ) check("an empty database is harmless", t2.rebase([]) == 0) # -- robustness ------------------------------------------------------ # p2 = os.path.join(tmp, "odd.0.log") with open(p2, "w") as f: - f.write("\n".join([ - FULL + ",rip=0x401000", - "", # blank line - "rax=0xnothex,rip=0x401001", # unparseable value - "rbx=0x1", # no PC at all - "rip=0x401002,mw=0x10:zz", # unparseable memory - ]) + "\n") + f.write( + "\n".join( + [ + FULL + ",rip=0x401000", + "", # blank line + "rax=0xnothex,rip=0x401001", # unparseable value + "rbx=0x1", # no PC at all + "rip=0x401002,mw=0x10:zz", # unparseable memory + ] + ) + + "\n" + ) t3 = Trace.load(p2) - check("a malformed trace loads instead of raising", t3.length == 4, - f"{t3.length}") - check("a line with no PC inherits the previous one", - t3.ip(2) == 0x401001, f"{t3.ip(2):#x}") - check("an unparseable memory entry is dropped, not fatal", - t3.memory_ops(3) == []) + check( + "a malformed trace loads instead of raising", t3.length == 4, f"{t3.length}" + ) + check( + "a line with no PC inherits the previous one", + t3.ip(2) == 0x401001, + f"{t3.ip(2):#x}", + ) + check( + "an unparseable memory entry is dropped, not fatal", t3.memory_ops(3) == [] + ) print(f"\n{PASS} passed, {FAIL} failed") return 1 if FAIL else 0 diff --git a/tests/test_trace_rpc.py b/tests/test_trace_rpc.py index 015ce8b..9b0f2e0 100644 --- a/tests/test_trace_rpc.py +++ b/tests/test_trace_rpc.py @@ -30,8 +30,7 @@ from idatui.rpcclient import RpcClient, RpcError # noqa: E402 from idatui.trace import Trace # noqa: E402 REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -TRACER = os.path.expanduser( - "~/.pi/agent/skills/tenet-trace/scripts/tenet-trace") +TRACER = os.path.expanduser("~/.pi/agent/skills/tenet-trace/scripts/tenet-trace") FALLBACK_TRACE = "/tmp/echotrace.0.log" BINARY = os.path.join(REPO, "targets", "echo") @@ -52,8 +51,12 @@ def make_trace(tmp, binary): """Record a short trace of the echo binary.""" out = os.path.join(tmp, "t") try: - subprocess.run([TRACER, "-o", out, binary, "hello"], - capture_output=True, timeout=180, check=False) + subprocess.run( + [TRACER, "-o", out, binary, "hello"], + capture_output=True, + timeout=180, + check=False, + ) except (OSError, subprocess.TimeoutExpired): return None log = out + ".0.log" @@ -62,11 +65,24 @@ def make_trace(tmp, binary): def spawn_pane(target, trace_log, timeout=300): """Spawn an idatui pane with --trace and wait for readiness.""" - cmd = [sys.executable, "-m", "idatui.pane", "spawn", - "--open", target, "--trace", trace_log, - "--detached", "--size", "60%", "--timeout", str(timeout)] - r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 30, - cwd=REPO) + cmd = [ + sys.executable, + "-m", + "idatui.pane", + "spawn", + "--open", + target, + "--trace", + trace_log, + "--detached", + "--size", + "60%", + "--timeout", + str(timeout), + ] + r = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout + 30, cwd=REPO + ) if r.returncode != 0: print(f" spawn failed: {r.stderr.strip()}", file=sys.stderr) return None @@ -74,10 +90,17 @@ def spawn_pane(target, trace_log, timeout=300): def stop_pane(sock, timeout=60): - cmd = [sys.executable, "-m", "idatui.pane", "stop", - "--sock", sock, "--timeout", str(timeout)] - subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 10, - cwd=REPO) + cmd = [ + sys.executable, + "-m", + "idatui.pane", + "stop", + "--sock", + sock, + "--timeout", + str(timeout), + ] + subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 10, cwd=REPO) def main() -> int: @@ -96,14 +119,17 @@ def main() -> int: if trace_log is None and os.path.exists(FALLBACK_TRACE): trace_log = FALLBACK_TRACE if trace_log is None: - print(f" skip: no trace available (tracer at {TRACER}, " - f"fallback {FALLBACK_TRACE})") + print( + f" skip: no trace available (tracer at {TRACER}, " + f"fallback {FALLBACK_TRACE})" + ) return 0 # Load our own model for ground-truth comparisons. model = Trace.load(trace_log) - check("model loaded for ground truth", model.length > 10, - f"length={model.length}") + check( + "model loaded for ground truth", model.length > 10, f"length={model.length}" + ) # Spawn the pane. info = spawn_pane(target, trace_log) @@ -138,14 +164,22 @@ def run_trace_tests(c: RpcClient, model: Trace): r = c.call("trace", seek=0) check("trace seek=0 returns a snapshot", "active" in r and "trace" in r) tr = r["trace"] - check("response includes trace metadata", - "idx" in tr and "length" in tr and "pc" in tr and "changed" in tr, - f"keys={list(tr.keys())}") + check( + "response includes trace metadata", + "idx" in tr and "length" in tr and "pc" in tr and "changed" in tr, + f"keys={list(tr.keys())}", + ) check("idx is 0 after seeking to 0", tr["idx"] == 0) - check("length matches the model", tr["length"] == model.length, - f"{tr['length']} vs {model.length}") - check("pc at 0 is a hex string", isinstance(tr["pc"], str) - and tr["pc"].startswith("0x"), tr["pc"]) + check( + "length matches the model", + tr["length"] == model.length, + f"{tr['length']} vs {model.length}", + ) + check( + "pc at 0 is a hex string", + isinstance(tr["pc"], str) and tr["pc"].startswith("0x"), + tr["pc"], + ) # ------------------------------------------------------------------ # # seek to a mid-trace timestamp @@ -153,14 +187,16 @@ def run_trace_tests(c: RpcClient, model: Trace): mid = model.length // 2 r = c.call("trace", seek=mid) tr = r["trace"] - check("seek to midpoint lands correctly", tr["idx"] == mid, - f"got {tr['idx']}, want {mid}") + check( + "seek to midpoint lands correctly", + tr["idx"] == mid, + f"got {tr['idx']}, want {mid}", + ) # The model's IP at this point, rebased — the RPC should agree. # We can't compare directly because the model isn't rebased yet, but # the pc should be a small address (database-space, not ASLR'd). pc = int(tr["pc"], 16) - check("pc is in database space (not ASLR'd)", - pc < 0x100000, f"pc={tr['pc']}") + check("pc is in database space (not ASLR'd)", pc < 0x100000, f"pc={tr['pc']}") # ------------------------------------------------------------------ # # seek by percentage (Tenet shell syntax) @@ -168,39 +204,50 @@ def run_trace_tests(c: RpcClient, model: Trace): r = c.call("trace", seek="!0") check("seek !0 (0%) goes to the start", r["trace"]["idx"] == 0) r = c.call("trace", seek="!100") - check("seek !100 (100%) goes to the end", - r["trace"]["idx"] == model.length - 1, - f"got {r['trace']['idx']}, want {model.length - 1}") + check( + "seek !100 (100%) goes to the end", + r["trace"]["idx"] == model.length - 1, + f"got {r['trace']['idx']}, want {model.length - 1}", + ) r = c.call("trace", seek="!50") - check("seek !50 (50%) goes to the midpoint", - abs(r["trace"]["idx"] - mid) <= 1, - f"got {r['trace']['idx']}, want ~{mid}") + check( + "seek !50 (50%) goes to the midpoint", + abs(r["trace"]["idx"] - mid) <= 1, + f"got {r['trace']['idx']}, want ~{mid}", + ) # ------------------------------------------------------------------ # # step forward/backward # ------------------------------------------------------------------ # c.call("trace", seek=0) r = c.call("trace", step=1) - check("step=1 advances one timestamp", r["trace"]["idx"] == 1, - f"got {r['trace']['idx']}") + check( + "step=1 advances one timestamp", + r["trace"]["idx"] == 1, + f"got {r['trace']['idx']}", + ) r = c.call("trace", step=1) - check("another step=1 reaches 2", r["trace"]["idx"] == 2, - f"got {r['trace']['idx']}") + check( + "another step=1 reaches 2", r["trace"]["idx"] == 2, f"got {r['trace']['idx']}" + ) r = c.call("trace", step=-1) - check("step=-1 goes backward", r["trace"]["idx"] == 1, - f"got {r['trace']['idx']}") + check("step=-1 goes backward", r["trace"]["idx"] == 1, f"got {r['trace']['idx']}") # Step backward at the start should clamp to 0. c.call("trace", seek=0) r = c.call("trace", step=-1) - check("step=-1 at t=0 stays at 0", r["trace"]["idx"] == 0, - f"got {r['trace']['idx']}") + check( + "step=-1 at t=0 stays at 0", r["trace"]["idx"] == 0, f"got {r['trace']['idx']}" + ) # Multi-step. c.call("trace", seek=0) r = c.call("trace", step=5) - check("step=5 advances five timestamps", r["trace"]["idx"] == 5, - f"got {r['trace']['idx']}") + check( + "step=5 advances five timestamps", + r["trace"]["idx"] == 5, + f"got {r['trace']['idx']}", + ) # ------------------------------------------------------------------ # # step over (follows SP) @@ -212,8 +259,14 @@ def run_trace_tests(c: RpcClient, model: Trace): a = model.register(sp_name, i) b = model.register(sp_name, i + 1) if a and b and b < a: - ret = next((j for j in range(i + 1, model.length) - if (model.register(sp_name, j) or 0) >= a), None) + ret = next( + ( + j + for j in range(i + 1, model.length) + if (model.register(sp_name, j) or 0) >= a + ), + None, + ) if ret and ret > i + 3: call_at = (i, ret) break @@ -221,11 +274,12 @@ def run_trace_tests(c: RpcClient, model: Trace): i, ret = call_at c.call("trace", seek=i) r = c.call("trace", step=1, over=True) - check("step over skips the callee", - r["trace"]["idx"] == ret, - f"from {i}, got {r['trace']['idx']}, expected {ret}") - check("step over goes further than a plain step", - r["trace"]["idx"] > i + 1) + check( + "step over skips the callee", + r["trace"]["idx"] == ret, + f"from {i}, got {r['trace']['idx']}, expected {ret}", + ) + check("step over goes further than a plain step", r["trace"]["idx"] > i + 1) else: check("found a call to step over", False, "none in this short trace") @@ -235,25 +289,28 @@ def run_trace_tests(c: RpcClient, model: Trace): r = c.call("trace", goto="main") tr = r["trace"] check("goto main lands on a timestamp", tr["idx"] >= 0) - check("and the function context says main", - r.get("function", {}).get("name") == "main", - f"function={r.get('function')}") + check( + "and the function context says main", + r.get("function", {}).get("name") == "main", + f"function={r.get('function')}", + ) # goto by hex address. main_ea = r["function"]["ea"] c.call("trace", seek=0) # reset position r = c.call("trace", goto=hex(main_ea)) - check("goto by hex address works", - r["trace"]["idx"] >= 0 and r["function"]["ea"] == main_ea, - f"idx={r['trace']['idx']}, ea={r.get('function', {}).get('ea')}") + check( + "goto by hex address works", + r["trace"]["idx"] >= 0 and r["function"]["ea"] == main_ea, + f"idx={r['trace']['idx']}, ea={r.get('function', {}).get('ea')}", + ) # goto a function that was never executed. try: c.call("trace", goto="0xDEADBEEF") check("goto an unexecuted address raises", False, "no error raised") except RpcError as e: - check("goto an unexecuted address raises", "never executed" in str(e), - str(e)) + check("goto an unexecuted address raises", "never executed" in str(e), str(e)) # ------------------------------------------------------------------ # # changed registers in the response @@ -261,9 +318,11 @@ def run_trace_tests(c: RpcClient, model: Trace): c.call("trace", seek=0) r = c.call("trace", step=1) changed = r["trace"]["changed"] - check("changed is a list of register names", - isinstance(changed, list) and all(isinstance(s, str) for s in changed), - f"{changed}") + check( + "changed is a list of register names", + isinstance(changed, list) and all(isinstance(s, str) for s in changed), + f"{changed}", + ) # The PC always changes on a step (it's a different instruction). check("rip is always in changed", "rip" in changed, f"{changed}") @@ -275,9 +334,13 @@ def run_trace_tests(c: RpcClient, model: Trace): r2 = c.call("trace", seek=min(50, model.length - 1)) ea1 = r1.get("cursor", {}).get("ea") ea2 = r2.get("cursor", {}).get("ea") - check("the cursor ea follows the trace pc", - ea1 is not None and ea2 is not None and (ea1 != ea2 or r1["trace"]["pc"] == r2["trace"]["pc"]), - f"ea1={ea1}, ea2={ea2}") + check( + "the cursor ea follows the trace pc", + ea1 is not None + and ea2 is not None + and (ea1 != ea2 or r1["trace"]["pc"] == r2["trace"]["pc"]), + f"ea1={ea1}, ea2={ea2}", + ) # ------------------------------------------------------------------ # # trace verb without a trace raises cleanly @@ -289,8 +352,10 @@ def run_trace_tests(c: RpcClient, model: Trace): # Actually, if none of seek/goto/step is given, it just settles and # returns the current state — that's fine, it's a status query. r = c.call("trace") - check("trace with no action is a status query", - "trace" in r and r["trace"]["idx"] >= 0) + check( + "trace with no action is a status query", + "trace" in r and r["trace"]["idx"] >= 0, + ) except RpcError: check("trace with no action is a status query", False, "raised an error") @@ -299,14 +364,20 @@ def run_trace_tests(c: RpcClient, model: Trace): # ------------------------------------------------------------------ # r = c.call("trace", seek=3) for key in ("idx", "length", "pc", "changed"): - check(f"trace response has '{key}'", key in r.get("trace", {}), - f"trace={r.get('trace')}") + check( + f"trace response has '{key}'", + key in r.get("trace", {}), + f"trace={r.get('trace')}", + ) # Standard snapshot fields are ALSO present (the trace response is a # superset of a normal snapshot). for key in ("active", "function", "cursor", "status", "ready"): - check(f"trace response also has snapshot key '{key}'", key in r, - f"keys={list(r.keys())}") + check( + f"trace response also has snapshot key '{key}'", + key in r, + f"keys={list(r.keys())}", + ) # ------------------------------------------------------------------ # # edge cases: seek beyond bounds @@ -314,25 +385,30 @@ def run_trace_tests(c: RpcClient, model: Trace): r = c.call("trace", seek=-1) check("seek -1 clamps to 0", r["trace"]["idx"] == 0) r = c.call("trace", seek=model.length + 1000) - check("seek beyond length clamps to the end", - r["trace"]["idx"] == model.length - 1, - f"got {r['trace']['idx']}") + check( + "seek beyond length clamps to the end", + r["trace"]["idx"] == model.length - 1, + f"got {r['trace']['idx']}", + ) # ------------------------------------------------------------------ # # seek with comma-separated numbers (ergonomic) # ------------------------------------------------------------------ # r = c.call("trace", seek="100") - check("seek accepts a string number", - r["trace"]["idx"] == min(100, model.length - 1)) + check( + "seek accepts a string number", r["trace"]["idx"] == min(100, model.length - 1) + ) # ------------------------------------------------------------------ # # pseudocode still works with a trace loaded # ------------------------------------------------------------------ # c.call("trace", goto="main") r = c.call("pseudocode", target="main", lines=5) - check("pseudocode works alongside the trace", - "code" in r and "main" in r.get("code", ""), - f"keys={list(r.keys())}") + check( + "pseudocode works alongside the trace", + "code" in r and "main" in r.get("code", ""), + f"keys={list(r.keys())}", + ) # ------------------------------------------------------------------ # # state includes trace position @@ -341,17 +417,18 @@ def run_trace_tests(c: RpcClient, model: Trace): r = c.call("state") # The state verb doesn't include trace info (that's trace-specific), # but the standard snapshot fields should be consistent. - check("state works with a trace loaded", - r.get("ready") is True and "cursor" in r) + check("state works with a trace loaded", r.get("ready") is True and "cursor" in r) # ------------------------------------------------------------------ # # view_lines works with trail painted # ------------------------------------------------------------------ # c.call("trace", seek=min(40, model.length - 1)) r = c.call("view", lines=10) - check("view returns lines with a trace active", - "lines" in r and len(r["lines"]) > 0, - f"keys={list(r.keys())}") + check( + "view returns lines with a trace active", + "lines" in r and len(r["lines"]) > 0, + f"keys={list(r.keys())}", + ) # ------------------------------------------------------------------ # # navigation works alongside trace: goto a function, trace follows @@ -359,14 +436,18 @@ def run_trace_tests(c: RpcClient, model: Trace): c.call("trace", goto="main") start_idx = c.call("trace")["trace"]["idx"] r = c.call("goto", target="error_at_line") - check("goto still works with trace loaded", - r.get("function", {}).get("name") == "error_at_line") + check( + "goto still works with trace loaded", + r.get("function", {}).get("name") == "error_at_line", + ) # The trace timestamp should NOT change from a regular goto — the trace # position is independent of navigation. r2 = c.call("trace") - check("regular goto does not change the trace position", - r2["trace"]["idx"] == start_idx, - f"was {start_idx}, now {r2['trace']['idx']}") + check( + "regular goto does not change the trace position", + r2["trace"]["idx"] == start_idx, + f"was {start_idx}, now {r2['trace']['idx']}", + ) if __name__ == "__main__": diff --git a/tests/test_trace_ui.py b/tests/test_trace_ui.py index 855ce4d..768d18b 100644 --- a/tests/test_trace_ui.py +++ b/tests/test_trace_ui.py @@ -17,18 +17,21 @@ import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from textual.widgets import Input, OptionList, Static # noqa: E402 - from _fixtures import fast_keys, staged # noqa: E402 +from textual.widgets import Input, OptionList, Static # noqa: E402 -fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys +fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys from idatui._sync import settle # noqa: E402 -from idatui.app import (DecompView, IdaTui, ListingView, # noqa: E402 - RegWriteScreen, TraceDock) +from idatui.app import ( # noqa: E402 + DecompView, + IdaTui, + ListingView, + RegWriteScreen, + TraceDock, +) REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -TRACER = os.path.expanduser( - "~/.pi/agent/skills/tenet-trace/scripts/tenet-trace") +TRACER = os.path.expanduser("~/.pi/agent/skills/tenet-trace/scripts/tenet-trace") PASS = FAIL = 0 @@ -56,8 +59,12 @@ async def wait(pred, pilot, t=240.0): def make_trace(tmp, binary): out = os.path.join(tmp, "t") try: - subprocess.run([TRACER, "-o", out, binary, "hi"], - capture_output=True, timeout=180, check=False) + subprocess.run( + [TRACER, "-o", out, binary, "hi"], + capture_output=True, + timeout=180, + check=False, + ) except (OSError, subprocess.TimeoutExpired): return None log = out + ".0.log" @@ -71,8 +78,9 @@ async def run() -> int: # anything tracked. On echo the seeding saves only ~0.2s (it analyses fast); # it is here so a suite pointed at a bigger target doesn't pay for analysis # on every run. - async with staged(binary, lambda p: IdaTui(open_path=p, keepalive=False), - prefix="idatui-traceui-") as target: + async with staged( + binary, lambda p: IdaTui(open_path=p, keepalive=False), prefix="idatui-traceui-" + ) as target: tmp = os.path.dirname(target) log = make_trace(tmp, target) if not log: @@ -90,22 +98,29 @@ async def run() -> int: # Rebasing: the tracer runs the binary relocated, so without a slide # nothing in the trace matches anything on screen. - check("trace addresses were rebased onto the database", - t.slide != 0 and t.ip(0) < 0x1000000, - f"slide={t.slide:#x} ip0={t.ip(0):#x}") + check( + "trace addresses were rebased onto the database", + t.slide != 0 and t.ip(0) < 0x1000000, + f"slide={t.slide:#x} ip0={t.ip(0):#x}", + ) idx = app._func_index touched = [f.name for f in idx.all_loaded() if t.executions(f.addr)] - check("and now line up with real functions", - len(touched) > 1 and "main" in touched, f"{touched[:6]}") + check( + "and now line up with real functions", + len(touched) > 1 and "main" in touched, + f"{touched[:6]}", + ) dock = app.query_one(TraceDock) check("the dock is docked and visible", dock.display) head = str(dock.query_one("#trace-head", Static).render()) - check("it shows where we are in time", "0" in head and "%" in head, - head[:60]) + check( + "it shows where we are in time", "0" in head and "%" in head, head[:60] + ) regs = str(dock.query_one("#trace-regs", Static).render()) - check("and the register state at that time", "rip" in regs.lower(), - regs[:60]) + check( + "and the register state at that time", "rip" in regs.lower(), regs[:60] + ) # -- stepping --------------------------------------------------- # lst = app.query_one(ListingView) @@ -115,17 +130,21 @@ async def run() -> int: # `app._t` is assigned the moment the key is handled, so it is NOT a # signal that the VIEW has followed -- the navigation it kicks off # runs in a worker. Waiting on it and then reading the cursor was a - # race that the (slower) Code Mode backend loses. Gate on the thing + # race that the (slower) IDA Nexus backend loses. Gate on the thing # the check is about. - await settle(app, lambda: app._t == 1 and lst._cursor_ea() == t.ip(1), - timeout=20) + await settle( + app, lambda: app._t == 1 and lst._cursor_ea() == t.ip(1), timeout=20 + ) check("] steps forward one instruction", app._t == 1, f"t={app._t}") - check("the code view follows the trace", - lst._cursor_ea() == t.ip(1), - f"{lst._cursor_ea()} vs {t.ip(1)}") + check( + "the code view follows the trace", + lst._cursor_ea() == t.ip(1), + f"{lst._cursor_ea()} vs {t.ip(1)}", + ) await pilot.press("[") - await settle(app, lambda: app._t == 0 and lst._cursor_ea() == t.ip(0), - timeout=20) + await settle( + app, lambda: app._t == 0 and lst._cursor_ea() == t.ip(0), timeout=20 + ) check("[ steps backward", app._t == 0, f"t={app._t}") await pilot.press("[") # Nothing should happen, so there is no signal to wait FOR: the @@ -144,8 +163,14 @@ async def run() -> int: for i in range(1, min(t.length - 1, 400)): a, b = t.register(sp, i), t.register(sp, i + 1) if a and b and b < a: - ret = next((j for j in range(i + 1, t.length) - if (t.register(sp, j) or 0) >= a), None) + ret = next( + ( + j + for j in range(i + 1, t.length) + if (t.register(sp, j) or 0) >= a + ), + None, + ) if ret and ret > i + 3: call_at = (i, ret) break @@ -157,8 +182,11 @@ async def run() -> int: await wait(lambda: app._t == i, pilot, 20) await pilot.press("}") await wait(lambda: app._t != i, pilot, 30) - check("} steps OVER a call instead of into it", - app._t == ret, f"{i} -> {app._t}, expected {ret}") + check( + "} steps OVER a call instead of into it", + app._t == ret, + f"{i} -> {app._t}, expected {ret}", + ) check("which is further than a plain step", app._t > i + 1) # -- memory at time T -------------------------------------------- # @@ -168,36 +196,56 @@ async def run() -> int: # image would have nothing to show. dock = app.query_one(TraceDock) app._seek(min(60, t.length - 1)) - await wait(lambda: "stack (" in - str(dock.query_one("#trace-stack", Static).render()), - pilot, 5) + await wait( + lambda: ( + "stack (" in str(dock.query_one("#trace-stack", Static).render()) + ), + pilot, + 5, + ) stack = str(dock.query_one("#trace-stack", Static).render()) - check("the dock shows the stack at this timestamp", - "stack (" in stack and len(stack.splitlines()) > 4, stack[:60]) + check( + "the dock shows the stack at this timestamp", + "stack (" in stack and len(stack.splitlines()) > 4, + stack[:60], + ) sp_name = next(r for r in ("rsp", "esp", "sp") if r in t.reg_at) sp = t.register(sp_name, app._t) - check("anchored at the stack pointer", - f"{sp:012x}" in stack, f"sp={sp:#x} / {stack[:80]}") + check( + "anchored at the stack pointer", + f"{sp:012x}" in stack, + f"sp={sp:#x} / {stack[:80]}", + ) # A trace knows what it observed and nothing else. Unseen bytes are # printed as '?', never as zeros — rendering them as zero would # invent facts about memory nobody looked at. data, known = t.memory_raw(sp, 8, app._t) if not all(known): - check("memory the trace never saw is marked unknown", - "?" in stack, stack[:80]) + check( + "memory the trace never saw is marked unknown", + "?" in stack, + stack[:80], + ) else: - check("known stack words are shown as values", - any(c in "0123456789abcdef" for c in stack), stack[:60]) + check( + "known stack words are shown as values", + any(c in "0123456789abcdef" for c in stack), + stack[:60], + ) # Stepping must move the memory view with time. before = stack app._seek(min(80, t.length - 1)) - await wait(lambda: str(dock.query_one("#trace-stack", - Static).render()) != before, - pilot, 5) - check("and it follows as you move through time", - str(dock.query_one("#trace-stack", Static).render()) != before) + await wait( + lambda: str(dock.query_one("#trace-stack", Static).render()) != before, + pilot, + 5, + ) + check( + "and it follows as you move through time", + str(dock.query_one("#trace-stack", Static).render()) != before, + ) # -- trails ------------------------------------------------------ # # Not "every address the trace ever touched": on a loop-heavy @@ -207,17 +255,27 @@ async def run() -> int: await wait(lambda: bool(lst.trail), pilot, 5) trail = lst.trail kinds = {k for k in trail.values()} - check("the listing is painted with an execution trail", - {"now", "past", "future"} <= kinds, f"{sorted(kinds)}") - check("'now' is the instruction we're standing on", - trail.get(t.ip(app._t)) == "now", f"{trail.get(t.ip(app._t))}") - check("the step behind is past, the step ahead is future", - trail.get(t.ip(app._t - 1)) == "past" - and trail.get(t.ip(app._t + 1)) == "future", - f"{trail.get(t.ip(app._t - 1))}, {trail.get(t.ip(app._t + 1))}") - painted = [y for y in range(min(lst.size.height, 30)) - if any(seg.style and seg.style.bgcolor - for seg in lst.render_line(y))] + check( + "the listing is painted with an execution trail", + {"now", "past", "future"} <= kinds, + f"{sorted(kinds)}", + ) + check( + "'now' is the instruction we're standing on", + trail.get(t.ip(app._t)) == "now", + f"{trail.get(t.ip(app._t))}", + ) + check( + "the step behind is past, the step ahead is future", + trail.get(t.ip(app._t - 1)) == "past" + and trail.get(t.ip(app._t + 1)) == "future", + f"{trail.get(t.ip(app._t - 1))}, {trail.get(t.ip(app._t + 1))}", + ) + painted = [ + y + for y in range(min(lst.size.height, 30)) + if any(seg.style and seg.style.bgcolor for seg in lst.render_line(y)) + ] check("and it actually reaches the screen", painted, "no tinted rows") # -- the same trail on PSEUDOCODE -------------------------------- # @@ -234,23 +292,33 @@ async def run() -> int: await wait(lambda: app._t == first + 12, pilot, 20) lst.focus() await pilot.press("tab") - got = await wait(lambda: app.query_one(DecompView).display - and app.query_one(DecompView)._texts, pilot, 120) + got = await wait( + lambda: ( + app.query_one(DecompView).display + and app.query_one(DecompView)._texts + ), + pilot, + 120, + ) dec = app.query_one(DecompView) check("pseudocode is available for the traced function", got) app._seek(first + 12) await wait(lambda: len(dec.trail) > 2, pilot, 5) - check("pseudocode lines are painted with the trail", - len(dec.trail) > 2, f"{len(dec.trail)} lines") + check( + "pseudocode lines are painted with the trail", + len(dec.trail) > 2, + f"{len(dec.trail)} lines", + ) now = [i for i, k in dec.trail.items() if k == "now"] - check("exactly one pseudocode line is 'now'", - len(now) == 1, f"{now}") + check("exactly one pseudocode line is 'now'", len(now) == 1, f"{now}") # The 'now' line must be the one covering the current # instruction, not merely some executed line. covered = app._trail_map[now[0]] if now and app._trail_map else [] - check("and it's the line covering the current instruction", - t.ip(app._t) in covered, - f"pc={t.ip(app._t):#x} line covers {[hex(a) for a in covered][:4]}") + check( + "and it's the line covering the current instruction", + t.ip(app._t) in covered, + f"pc={t.ip(app._t):#x} line covers {[hex(a) for a in covered][:4]}", + ) # Stepping must not throw you out of the view you're reading. # A step navigates to an address, and navigating to an address @@ -260,13 +328,19 @@ async def run() -> int: was = app._t await pilot.press("]") await settle(app, lambda: app._t != was) - check("stepping in pseudocode stays in pseudocode", - app._active == "decomp", f"active={app._active}") + check( + "stepping in pseudocode stays in pseudocode", + app._active == "decomp", + f"active={app._active}", + ) was = app._t await pilot.press("[") await settle(app, lambda: app._t != was) - check("and so does stepping backward", - app._active == "decomp", f"active={app._active}") + check( + "and so does stepping backward", + app._active == "decomp", + f"active={app._active}", + ) # -- split view: a step is a GLOBAL move ------------------------ # # Normal navigation moves one pane and gives the companion a band, @@ -274,8 +348,13 @@ async def run() -> int: # navigation though: both panes show the same instant, so the # listing cursor must sit on the current instruction. app.action_toggle_split() - await wait(lambda: app._split and lst.display - and app.query_one(DecompView).display, pilot, 10) + await wait( + lambda: ( + app._split and lst.display and app.query_one(DecompView).display + ), + pilot, + 10, + ) if not app._split: check("split view toggled on", False) else: @@ -286,14 +365,18 @@ async def run() -> int: # Wait for the cursor to arrive rather than sleeping a flat # 0.5s and hoping. Same question -- does the listing follow # the pc? -- but it costs what it costs instead of 3s. - if await wait(lambda: lst._cursor_ea() == t.ip(app._t), - pilot, 5): + if await wait(lambda: lst._cursor_ea() == t.ip(app._t), pilot, 5): tracked += 1 - check("stepping in split moves the listing cursor to the pc", - tracked == 6, f"{tracked}/6 steps tracked") - check("and the trail follows in both panes", - lst.trail.get(t.ip(app._t)) == "now", - f"{lst.trail.get(t.ip(app._t))}") + check( + "stepping in split moves the listing cursor to the pc", + tracked == 6, + f"{tracked}/6 steps tracked", + ) + check( + "and the trail follows in both panes", + lst.trail.get(t.ip(app._t)) == "now", + f"{lst.trail.get(t.ip(app._t))}", + ) # The pseudocode cursor follows too — but only for instructions # the decompiler actually attributes to a line. About half @@ -313,8 +396,14 @@ async def run() -> int: # on `pc in _trail_line_of` instead would burn the timeout on # every unmapped instruction -- about half of them -- and be # slower than the flat sleep it replaces. - await wait(lambda: lst._cursor_ea() == pc - and app._trail_map_ea == dec.loaded_ea, pilot, 5) + await wait( + lambda: ( + lst._cursor_ea() == pc + and app._trail_map_ea == dec.loaded_ea + ), + pilot, + 5, + ) if app._trail_map_ea == dec.loaded_ea and pc in app._trail_line_of: mapped += 1 # Mapped: the pseudocode cursor is expected, so it's fair @@ -324,9 +413,11 @@ async def run() -> int: await wait(lambda: dec.cursor == line, pilot, 3) if dec.cursor != line: missed += 1 - check("the pseudocode cursor follows every mapped instruction", - mapped > 3 and missed == 0, - f"{mapped} mapped, {missed} not followed") + check( + "the pseudocode cursor follows every mapped instruction", + mapped > 3 and missed == 0, + f"{mapped} mapped, {missed} not followed", + ) # -- a late navigation must not drag the view back --------------- # # Navigations run in workers and finish out of order. The trace's @@ -347,10 +438,12 @@ async def run() -> int: # for the workers to drain rather than for three seconds and a # hope: same question, ~50ms instead of 3s. await settle(app) - check("a stale navigation doesn't drag the cursor away", - lst._cursor_ea() == dbaddr and app._cur.ea == dbaddr, - f"cursor={lst._cursor_ea():#x} cur={app._cur.ea:#x} " - f"want {dbaddr:#x}") + check( + "a stale navigation doesn't drag the cursor away", + lst._cursor_ea() == dbaddr and app._cur.ea == dbaddr, + f"cursor={lst._cursor_ea():#x} cur={app._cur.ea:#x} " + f"want {dbaddr:#x}", + ) # -- seeking, as opposed to stepping ---------------------------- # # "When else did this instruction run?" — the question that makes a @@ -359,8 +452,11 @@ async def run() -> int: stamps = list(t.by_ip[hot]) db = hot + t.slide if len(stamps) < 2 or lst.model is None: - check("found an address executed more than once", False, - f"{len(stamps)} executions") + check( + "found an address executed more than once", + False, + f"{len(stamps)} executions", + ) else: if app._split: app.action_toggle_split() @@ -377,31 +473,48 @@ async def run() -> int: lst.cursor = row lst._scroll_cursor_into_view() await settle(app, lambda: lst._cursor_ea() == db) - check("cursor is on the repeated instruction", - lst._cursor_ea() == db, f"{lst._cursor_ea():#x} vs {db:#x}") + check( + "cursor is on the repeated instruction", + lst._cursor_ea() == db, + f"{lst._cursor_ea():#x} vs {db:#x}", + ) await pilot.press(">") await settle(app, lambda: app._t == stamps[1]) - check("> seeks to the next execution of it", - app._t == stamps[1], f"t={app._t}, expected {stamps[1]}") + check( + "> seeks to the next execution of it", + app._t == stamps[1], + f"t={app._t}, expected {stamps[1]}", + ) status = str(app.query_one("#status", Static).render()) - check("and says which execution this is", - f"2 of {len(stamps)}" in status, status[:80]) + check( + "and says which execution this is", + f"2 of {len(stamps)}" in status, + status[:80], + ) lst.cursor = row await settle(app) await pilot.press("<") await settle(app, lambda: app._t == stamps[0]) - check("< seeks back to the previous one", - app._t == stamps[0], f"t={app._t}, expected {stamps[0]}") + check( + "< seeks back to the previous one", + app._t == stamps[0], + f"t={app._t}, expected {stamps[0]}", + ) # An edge must SAY it's an edge rather than silently doing # nothing, which is indistinguishable from a broken key. lst.cursor = row await settle(app) await pilot.press("<") - await settle(app, lambda: "first" in str( - app.query_one("#status", Static).render())) + await settle( + app, + lambda: "first" in str(app.query_one("#status", Static).render()), + ) status = str(app.query_one("#status", Static).render()) - check("and the first execution says so instead of moving", - app._t == stamps[0] and "first" in status, status[:80]) + check( + "and the first execution says so instead of moving", + app._t == stamps[0] and "first" in status, + status[:80], + ) # -- "which instruction set this register?" ---------------------- # want_t = min(200, t.length - 1) @@ -409,14 +522,24 @@ async def run() -> int: await settle(app, lambda: app._t == want_t) lst.focus() await pilot.press("W") - opened = await wait(lambda: isinstance(app.screen, RegWriteScreen), - pilot, 20) - check("W lists the registers and where each was set", opened, - f"screen={type(app.screen).__name__}") + opened = await wait( + lambda: isinstance(app.screen, RegWriteScreen), pilot, 20 + ) + check( + "W lists the registers and where each was set", + opened, + f"screen={type(app.screen).__name__}", + ) if opened: sc = app.screen - pick = next((k for k, (n, v, l, x) in enumerate(sc._rows) - if l is not None and l != app._t), None) + pick = next( + ( + k + for k, (n, v, l, x) in enumerate(sc._rows) + if l is not None and l != app._t + ), + None, + ) if pick is None: check("a register was set by an earlier instruction", False) await pilot.press("escape") @@ -426,13 +549,18 @@ async def run() -> int: await settle(app) await pilot.press("enter") await wait(lambda: app._t == last, pilot, 30) - check("choosing one seeks to the write that set it", - app._t == last, f"t={app._t}, expected {last}") + check( + "choosing one seeks to the write that set it", + app._t == last, + f"t={app._t}, expected {last}", + ) # The real check: that instruction must actually have # written the register we asked about. - check("and that instruction really wrote it", - name in t.changed(app._t), - f"{name} not in {sorted(t.changed(app._t))}") + check( + "and that instruction really wrote it", + name in t.changed(app._t), + f"{name} not in {sorted(t.changed(app._t))}", + ) print(f"\n{PASS} passed, {FAIL} failed") return 1 if FAIL else 0 diff --git a/tests/test_trace_vs_tenet.py b/tests/test_trace_vs_tenet.py index 30d74b0..c2a0ea2 100644 --- a/tests/test_trace_vs_tenet.py +++ b/tests/test_trace_vs_tenet.py @@ -46,6 +46,7 @@ def _reference(): log.pmsg = lambda *a, **k: None import tenet # noqa: F401 import tenet.util # noqa: F401 + sys.modules["tenet.util.log"] = log from tenet.trace.arch import ArchAMD64 from tenet.trace.reader import TraceReader @@ -57,6 +58,7 @@ def _reference(): # stay in raw trace addresses, which is what we want to compare. def get_instruction_addresses(self): return [0xDEAD0000] + return TraceReader, ArchAMD64, FakeDctx @@ -88,9 +90,11 @@ def compare(path, ref_cls, arch, dctx, samples=200): theirs = TraceReader(path, ArchAMD64(), FakeDctx()) name = os.path.basename(path) - check(f"{name}: same length", - ours.length == theirs.trace.length, - f"{ours.length} vs {theirs.trace.length}") + check( + f"{name}: same length", + ours.length == theirs.trace.length, + f"{ours.length} vs {theirs.trace.length}", + ) n = min(ours.length, theirs.trace.length) if not n: return @@ -99,8 +103,11 @@ def compare(path, ref_cls, arch, dctx, samples=200): idxs = sorted({0, n - 1, n // 2} | {rnd.randrange(n) for _ in range(samples)}) bad = [i for i in idxs if ours.raw_ip(i) != theirs.get_ip(i)] - check(f"{name}: same PC at every sampled timestamp", not bad, - f"first mismatch at {bad[:1]}") + check( + f"{name}: same PC at every sampled timestamp", + not bad, + f"first mismatch at {bad[:1]}", + ) # Register reconstruction is the part that is easy to get subtly wrong: a # delta belongs to the line that CAUSED it, and an off-by-one here silently @@ -121,11 +128,16 @@ def compare(path, ref_cls, arch, dctx, samples=200): continue true = _truth(path, r, i) (ours_wrong if mine != true else ref_wrong).append((i, r, mine, ref, true)) - check(f"{name}: register state matches the trace text everywhere", - not ours_wrong, f"{ours_wrong[:3]}") + check( + f"{name}: register state matches the trace text everywhere", + not ours_wrong, + f"{ours_wrong[:3]}", + ) if ref_wrong: - print(f" (reference disagrees at {len(ref_wrong)} sampled points; " - f"the text backs us, e.g. idx {ref_wrong[0][0]} {ref_wrong[0][1]})") + print( + f" (reference disagrees at {len(ref_wrong)} sampled points; " + f"the text backs us, e.g. idx {ref_wrong[0][0]} {ref_wrong[0][1]})" + ) # Execution queries: what painting is built on. hot = sorted(ours.by_ip, key=lambda a: -len(ours.by_ip[a]))[:5] @@ -135,8 +147,11 @@ def compare(path, ref_cls, arch, dctx, samples=200): ref = list(theirs.get_executions(ea)) if mine != ref: ex_bad.append((hex(ea), len(mine), len(ref))) - check(f"{name}: same execution timestamps for the hottest addresses", - not ex_bad, f"{ex_bad[:3]}") + check( + f"{name}: same execution timestamps for the hottest addresses", + not ex_bad, + f"{ex_bad[:3]}", + ) # Memory STATE at a timestamp — reconstructed from the deltas, which is the # hard part and the whole point of reading memory from a trace. @@ -153,11 +168,14 @@ def compare(path, ref_cls, arch, dctx, samples=200): # own coverage separately and a byte neither has seen is not a # disagreement. for j in range(n): - if known[j] and refb[j:j + 1] and mine[j] != refb[j]: + if known[j] and refb[j : j + 1] and mine[j] != refb[j]: mem_bad.append((i, hex(op.addr + j), mine[j], refb[j])) mem_checked += 1 - check(f"{name}: memory state at a timestamp matches the reference", - not mem_bad, f"{mem_bad[:3]}") + check( + f"{name}: memory state at a timestamp matches the reference", + not mem_bad, + f"{mem_bad[:3]}", + ) # Memory: the bytes an instruction touched, and which way. with_mem = [i for i in idxs if ours.memory_ops(i)][:40] @@ -167,18 +185,24 @@ def compare(path, ref_cls, arch, dctx, samples=200): ref = theirs.get_memory(op.addr, len(op.data), i + 1) if op.write else None if ref is not None and bytes(ref.data) != op.data: mem_bad.append((i, hex(op.addr), op.data.hex(), bytes(ref.data).hex())) - check(f"{name}: written bytes match the reference's memory state", - not mem_bad, f"{mem_bad[:2]}") - print(f" ({ours.length:,} instructions, {len(idxs)} sampled, " - f"{len(with_mem)} with memory)") + check( + f"{name}: written bytes match the reference's memory state", + not mem_bad, + f"{mem_bad[:2]}", + ) + print( + f" ({ours.length:,} instructions, {len(idxs)} sampled, " + f"{len(with_mem)} with memory)" + ) def main(argv): if not os.path.isdir(TENET): print(f" skip: reference not found at {TENET}") return 0 - traces = argv or [p for p in ("/tmp/echotrace.0.log", "/tmp/big.0.log") - if os.path.exists(p)] + traces = argv or [ + p for p in ("/tmp/echotrace.0.log", "/tmp/big.0.log") if os.path.exists(p) + ] if not traces: print(" skip: no traces to compare (pass one, or run tenet-trace first)") return 0 |
