diff options
| author | blasty <peter@haxx.in> | 2026-08-21 12:14:46 +0200 |
|---|---|---|
| committer | blasty <peter@haxx.in> | 2026-08-21 12:15:15 +0200 |
| commit | 02d02417800184fb76cd0245cdaa94c437aa4081 (patch) | |
| tree | 7589e6e2e8426bb7370fc56eee52d1559c9d7e57 /tests/test_blob_ui.py | |
| parent | adopt ruff: pinned formatter + import sorting, opt-in pre-commit hook (diff) | |
| download | ida-tui-02d02417800184fb76cd0245cdaa94c437aa4081.tar.gz ida-tui-02d02417800184fb76cd0245cdaa94c437aa4081.tar.xz ida-tui-02d02417800184fb76cd0245cdaa94c437aa4081.zip | |
reformat: ruff format + import sort, mechanically (see ruff.toml)
No behavior. Listed in .git-blame-ignore-revs (next commit).
Diffstat (limited to 'tests/test_blob_ui.py')
| -rw-r--r-- | tests/test_blob_ui.py | 284 |
1 files changed, 184 insertions, 100 deletions
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 |
