From a0cb1a1a6f8aca7a64c102dba388e899267d5bc1 Mon Sep 17 00:00:00 2001 From: blasty Date: Sun, 26 Jul 2026 08:58:31 +0200 Subject: loading: a blob that analyses to nothing is still openable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pick a random .bin, say ARM at 0x4000, and you got two empty panes and "functions still loading…" — which was a lie; loading had finished. _auto_land falls back to the symbol picker when there's no entry function, and the picker answers an empty index with that message. Nothing ever opened. Zero functions is not a corner case. It's exactly what a real firmware image looks like when it's described wrongly, and IDA has no complaint of its own to make about it, so this was the last silent-wrong-answer in the blob path. Now: * Land in the listing at the start of the image. The bytes exist even when no code was recognised, so there is always something to show. * Say so, in the status bar, for as long as it stays true — an image with no functions is a property of the database, not an event, and writing it once meant the next status write erased it (the same clobber that bit the split view's "decompiling…"). * Ctrl+L re-asks. The .i64 has the old processor and base baked in and takes precedence over any switches, so reloading means deleting it; the confirmation says what that costs, and when there are no functions it says nothing is lost. Verified with real keys on a random 64K blob: ARM @ 0x4000 lands showing "db 65536 dup(?)" with the hint in the status; Ctrl+L -> confirm -> dialog -> metapc reloads at 0. The hint survives scrolling. tests: new tests/test_blob_ui.py (11) driving a real random blob end to end — lands, has rows, right base, honest status, hint survives navigation, Ctrl+L offers the reload and declining leaves the binary open. 202/0 scenarios, 30/0 project UI. (Harness note for future me: tmux send-keys reads "0x4000" as a hex KEY CODE and sends U+4000. Use send-keys -l for literal text.) --- tests/test_blob_ui.py | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 tests/test_blob_ui.py (limited to 'tests') diff --git a/tests/test_blob_ui.py b/tests/test_blob_ui.py new file mode 100644 index 0000000..e8d2533 --- /dev/null +++ b/tests/test_blob_ui.py @@ -0,0 +1,106 @@ +#!/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 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: + blob = os.path.join(tmp, "rnd.bin") + with open(blob, "wb") as f: + f.write(os.urandom(64 * 1024)) # random: IDA will find no code + + # 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]) + + 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())) -- cgit v1.3.1-sl0p