diff options
Diffstat (limited to 'tests/test_blob_ui.py')
| -rw-r--r-- | tests/test_blob_ui.py | 235 |
1 files changed, 235 insertions, 0 deletions
diff --git a/tests/test_blob_ui.py b/tests/test_blob_ui.py new file mode 100644 index 0000000..f930401 --- /dev/null +++ b/tests/test_blob_ui.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""A blob that analyses to NOTHING must still be usable. + +Zero functions is the normal outcome for a raw image described wrongly — and it +used to leave two empty panes and a status that said "functions still loading…", +which was a lie: loading had finished, there was simply nothing to land on. + +Needs IDA (spawns a real worker). ~40s. +""" +import asyncio +import os +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from textual.widgets import Input, Static # noqa: E402 + +from idatui.app import ConfirmScreen, IdaTui, ListingView # 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}") + + +async def wait(pred, pilot, t=240.0): + for _ in range(int(t / 0.05)): + await pilot.pause(0.05) + try: + if pred(): + return True + except Exception: # noqa: BLE001 + pass + return False + + +async def run() -> int: + with tempfile.TemporaryDirectory() as tmp: + # Random bytes so IDA finds no functions... but with REAL AArch64 + # instructions planted at a known offset. Whether arbitrary random bytes + # happen to decode is chance, and a test that depends on chance tells you + # nothing on the run where it fails. + data = bytearray(os.urandom(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. + planted = 0x40 # file offset -> ea 0x4040 + 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") + blob = os.path.join(tmp, "rnd.bin") + with open(blob, "wb") as f: + f.write(bytes(data)) + + # Skip the dialog by answering up front; this test is about what + # happens AFTER a described blob turns out to contain nothing. + app = IdaTui(open_path=blob, keepalive=False, load_args="-parm -b400") + 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) + 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)}") + + 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}") + 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}") + + 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]) + + # The hint is a property of the database, so it must survive moving + # around — an earlier version wrote it once and the next status + # write erased it. + lst.focus() + await pilot.press("down") + await pilot.press("down") + await pilot.pause(0.3) + status2 = str(app.query_one("#status", Static).render()) + 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 + # PRESENT as one row per byte or there is no cursor position to + # 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)}") + 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]}") + + # 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}") + + target = 0x4000 + planted # a NOP we put there ourselves + lst.cursor = m.index_of_ea(target) + lst._scroll_cursor_into_view() + await pilot.pause(0.1) + 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") + await pilot.pause(2.0) + # Defining an item REBUILDS the listing model, so re-read it from the + # view: holding the old object shows the pre-edit rows and looks + # exactly like the edit silently failing. + m = lst.model + h = m.get(m.index_of_ea(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}") + 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 + # at a time means pressing it once per opcode for the length of + # a routine. We planted nop/nop/nop/ret, so it must take all + # 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}") + 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) + + # -- an edit must not move the view -------------------------- # + # Every mutation rebuilds the model, and row indices don't survive + # that: carving collapses four byte rows into one instruction row. + # All the edit paths go through ViewAnchor, which remembers + # ADDRESSES. This runs in its own app, so unlike the shared scenario + # suite it can mutate the database freely. + lst.cursor = m.index_of_ea(0x4200) + lst._scroll_cursor_into_view() + await pilot.pause(0.4) + ctop = lst.model.get(round(lst.scroll_offset.y)).ea + ccur = lst._cursor_ea() + old = lst.model + await pilot.press("semicolon") + await wait(lambda: app.query_one("#comment", Input).display, pilot, 20) + for ch in "note": + await pilot.press(ch) + await pilot.press("enter") + await wait(lambda: lst.model is not old and lst.model is not None, + pilot, 30) + await pilot.pause(0.4) + 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 + # instruction), so anything that remembers a row INDEX puts you + # somewhere else afterwards. Scroll down far enough that there is a + # viewport to lose, then check the top ADDRESS is unchanged. + far = 0x4000 + 0x600 + lst.cursor = lst.model.index_of_ea(far) + lst._scroll_cursor_into_view() + await pilot.pause(0.4) + 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}") + await pilot.press("c") + await pilot.pause(2.5) + 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}") + + 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__}") + if opened: + note = str(app.screen.query_one("#confirm-note", Static).render()) + check("and says nothing is lost when there's nothing to lose", + "nothing is lost" in note, note[:70]) + await pilot.press("escape") + await pilot.pause(0.3) + 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 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(run())) |
