aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--idatui/codemode_client.py25
-rw-r--r--tests/_fixtures.py30
-rw-r--r--tests/test_blob_ui.py109
4 files changed, 136 insertions, 29 deletions
diff --git a/.gitignore b/.gitignore
index ff80117..9a7b5b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,4 @@ bin/
core
core.*
.fastfeedback/
+tests/.synthetic/
diff --git a/idatui/codemode_client.py b/idatui/codemode_client.py
index 3bf01cc..9d91335 100644
--- a/idatui/codemode_client.py
+++ b/idatui/codemode_client.py
@@ -1062,6 +1062,17 @@ class CodeModeClient:
self._last_entry: RegistryEntry | None = None
self._connect_lock = threading.Lock()
+ def _database_exists(self) -> bool:
+ """Whether the IDB this open would target is already on disk.
+
+ Its loader switches are baked in, so they must not be sent again.
+ """
+ try:
+ target = self._output_database or expected_idb_path(self._path)
+ except Exception: # noqa: BLE001 -- resolver unavailable: assume fresh
+ return False
+ return bool(target) and os.path.exists(target)
+
def connect(self, timeout: float = 1800.0, progress=None) -> "CodeModeClient":
_require_codemode()
with self._connect_lock:
@@ -1078,17 +1089,25 @@ class CodeModeClient:
deadline = time.monotonic() + min(timeout, 60.0)
while True:
try:
+ # Loader switches describe how to IMPORT a raw file and
+ # are recorded in the database it produces. Sending them
+ # again for a database that already exists is a FATAL
+ # error in IDA itself ("Switch '-b400' can be used only
+ # when loading a new file"), which kills the worker
+ # before it can report anything useful. So: describe the
+ # import only when there is an import to describe.
+ fresh = self._new_database or not self._database_exists()
handle = DatabaseHandle.open(
self._path,
spawn=self._spawn,
timeout=max(0.1, timeout),
output_database=self._output_database,
- processor=self._processor,
+ processor=self._processor if fresh else None,
# DatabaseHandle calls this image_base and wants the
# natural (16-byte aligned) address; it does the
# conversion to IDA's paragraph-based -b itself.
- image_base=self._loading_address,
- file_type=self._file_type,
+ image_base=self._loading_address if fresh else None,
+ file_type=self._file_type if fresh else None,
new_database=self._new_database,
)
break
diff --git a/tests/_fixtures.py b/tests/_fixtures.py
index c7a45d3..0b72856 100644
--- a/tests/_fixtures.py
+++ b/tests/_fixtures.py
@@ -37,6 +37,36 @@ def cache_is_fresh(binary: str) -> bool:
return os.path.exists(c) and os.path.getmtime(c) >= os.path.getmtime(binary)
+#: Generated targets live here so their pristine caches survive between runs.
+#: Gitignored; safe to delete (the next run rebuilds both).
+SYNTHETIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".synthetic")
+
+
+def synthetic(name: str, build) -> str:
+ """A generated binary at a STABLE path, rebuilt only when its bytes change.
+
+ Generated targets used to be written into a fresh TemporaryDirectory on
+ every run, which quietly defeated the whole pristine-cache scheme: a new
+ path with new bytes every time means auto-analysis is paid in full, every
+ run, forever. `test_blob_ui`'s 64KB blob cost ~40s a run that way.
+
+ ``build()`` must be DETERMINISTIC and return bytes. That is also what makes
+ the suites reproducible: a blob built from os.urandom can, by luck, contain
+ something IDA reads as a function, and then a test asserting "no functions"
+ fails for reasons no one can reproduce.
+ """
+ os.makedirs(SYNTHETIC_DIR, exist_ok=True)
+ 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
+ fh.write(data)
+ for stale in (cache_path(path), path + ".i64"):
+ if os.path.exists(stale):
+ os.remove(stale)
+ return path
+
+
async def build_pristine(binary: str, cache: str, app_factory) -> None:
"""Analyse ``binary`` once and keep the database as a golden copy.
diff --git a/tests/test_blob_ui.py b/tests/test_blob_ui.py
index f60e914..1055e5a 100644
--- a/tests/test_blob_ui.py
+++ b/tests/test_blob_ui.py
@@ -17,10 +17,13 @@ import sys
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 textual.widgets import Input, Static # noqa: E402
from idatui.app import ConfirmScreen, IdaTui, ListingView # noqa: E402
+from _fixtures import staged, synthetic # noqa: E402
+from idatui._sync import settle # noqa: E402
PASS = FAIL = 0
@@ -46,28 +49,63 @@ async def wait(pred, pilot, t=240.0):
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))
+#: File offset of the planted instruction run -> ea 0x4000 + PLANTED.
+PLANTED = 0x40
+
+
+def _blob_bytes() -> bytes:
+ """A DETERMINISTIC pseudo-random blob with real AArch64 instructions planted.
+
+ Seeded, not os.urandom: the bytes must be identical every run or the
+ pristine-database cache can never apply (this suite used to pay ~40s of
+ auto-analysis per run because the content, and the path, changed each time).
+ Determinism also removes a genuine flake -- whether 64KB of chance bytes
+ contains something IDA reads as a function is luck, and "and really has no
+ 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.
- 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))
+ 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)
+
+
+#: -parm puts IDA in AArch64 mode (the ARM32 spelling of a nop is not decodable
+#: there); -b400 sets the image base. The cached database must be built with the
+#: SAME switches, so both go through one factory.
+BLOB_ARGS = "-parm -b400"
+
+def _blob_app(path):
+ return IdaTui(open_path=path, keepalive=False, load_args=BLOB_ARGS)
+
+
+def head_at(lst, ea):
+ """The listing row for ``ea`` off the LIVE model, or None.
+
+ Always re-reads ``lst.model``: an edit may rebuild the model, and holding
+ the old object shows pre-edit rows -- which looks exactly like the edit
+ silently failing.
+ """
+ m = lst.model
+ if m is None:
+ return None
+ i = m.index_of_ea(ea)
+ return m.get(i) if i is not None and i >= 0 else None
+
+
+async def run() -> int:
+ blob_src = synthetic("rnd.bin", _blob_bytes)
+ # staged() analyses once ever and copies the result in on later runs.
+ async with staged(blob_src, _blob_app) as blob:
# 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")
+ 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)
@@ -126,7 +164,7 @@ async def run() -> int:
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 pilot.pause(0.1)
@@ -134,12 +172,18 @@ async def run() -> int:
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.
+ # 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)
+ # 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 = m.get(m.index_of_ea(target))
+ 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}")
@@ -184,9 +228,17 @@ async def run() -> int:
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)
+ # Wait for the COMMENT ITSELF to show up, not for the model object to
+ # be replaced: a comment now re-renders the listing in place (the
+ # walk is kept), so `model is not old` never becomes true and this
+ # burned its full 30s timeout on every run -- after which the check
+ # below passed vacuously, because nothing had happened at all.
+ # The prompt closing plus quiescence is the real end of the edit.
+ # (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,
@@ -208,7 +260,12 @@ async def run() -> int:
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)
+ # No predicate here on purpose: this spot is random data, so the
+ # carve may legitimately produce nothing and "the row became code"
+ # would never hold (it timed out for 30s and then passed anyway).
+ # What is being checked is that the VIEW did not move, so the gate
+ # is simply "the app has finished reacting".
+ 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",