aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--tests/_fixtures.py13
-rwxr-xr-xtests/run.py97
-rw-r--r--tests/test_blob_ui.py284
-rw-r--r--tests/test_codemode_client.py162
-rw-r--r--tests/test_diag.py93
-rw-r--r--tests/test_findings.py219
-rw-r--r--tests/test_formats.py170
-rw-r--r--tests/test_graph.py269
-rw-r--r--tests/test_index.py283
-rw-r--r--tests/test_kittygfx.py258
-rw-r--r--tests/test_launch.py30
-rw-r--r--tests/test_nexus_client.py499
-rw-r--r--tests/test_pool.py183
-rw-r--r--tests/test_project.py231
-rw-r--r--tests/test_project_ui.py341
-rw-r--r--tests/test_rawimage_rpc.py196
-rw-r--r--tests/test_scenarios.py3675
-rw-r--r--tests/test_search.py109
-rw-r--r--tests/test_thumb_ui.py218
-rw-r--r--tests/test_trace.py248
-rw-r--r--tests/test_trace_rpc.py259
-rw-r--r--tests/test_trace_ui.py358
-rw-r--r--tests/test_trace_vs_tenet.py64
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