diff options
| author | blasty <peter@haxx.in> | 2026-09-25 14:59:48 +0200 |
|---|---|---|
| committer | blasty <peter@haxx.in> | 2026-09-25 14:59:48 +0200 |
| commit | 15425df0a6e09984fdab3f86354620d4d8656806 (patch) | |
| tree | cf92f86fd343cbfc04f19be895a337b304c576ec /tests/test_idb_relocate_ui.py | |
| parent | Merge PR #2 from mrexodia: fix pseudocode/listing tag parsing, ida-nexus 0.13.0 (diff) | |
| download | ida-tui-main.tar.gz ida-tui-main.tar.xz ida-tui-main.zip | |
`ida-tui /bin/ls` asked IDA to create /bin/ls.i64, which a normal user
cannot write, and what came back was the backend's account of it:
idalib worker launcher 2425195 exited with status 1
[ida-nexus] Failed to open database /usr/bin/ls
after a full analysis wait, naming neither the file it could not create
nor the reason. Nothing in the message points at permissions, and there
was no way forward from it.
idatui/idbpath.py answers the question before anything is opened: where
would the database go (mirroring ida_nexus's own derivation), can it go
there (os.access only — the pre-flight check must not create files), and
where instead. The proposal is stable per binary
(~/.local/share/idatui/db/<name>-<sha1(realpath)[:8]>.i64) so reopening
the same binary resumes the same names and comments, and hashed so two
busyboxes from different directories cannot collide. DATA_HOME rather
than CACHE_HOME on purpose: an .i64 holds the whole session, and cache
directories are something cleaners may empty.
The answer becomes NexusClient(output_database=...) — the option project
mode already uses to keep everything in its sidecar. `--idb PATH` (also
forwarded by `pane spawn`) says where without being asked. An RPC-driven
TUI is never asked: `pane spawn` polls for ready and would sit out its
whole timeout in front of a modal nobody can answer, so it relocates and
says so in the status line. A failed open re-offers relocation after a
real create probe, since os.access does not know about ACLs, a lying
network mount or a full disk.
tests/test_idbpath.py (pure, 32 checks) pins the path arithmetic, the two
properties of the proposal and that neither check touches the user's
directory; tests/test_idb_relocate_ui.py (textual, no worker, 24 checks)
drives the dialog, Esc, an edited path, the untouched common case, --idb,
the RPC path, and that output_database actually reaches the client.
Verified end to end against a real /bin/ls: 400 functions, and a rename
saved in one session comes back in the next.
Diffstat (limited to 'tests/test_idb_relocate_ui.py')
| -rw-r--r-- | tests/test_idb_relocate_ui.py | 315 |
1 files changed, 315 insertions, 0 deletions
diff --git a/tests/test_idb_relocate_ui.py b/tests/test_idb_relocate_ui.py new file mode 100644 index 0000000..43d52b6 --- /dev/null +++ b/tests/test_idb_relocate_ui.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""``ida-tui /bin/ls`` must ask where to put the database, not fail obscurely. + +IDA writes its database beside the binary, so a binary in a system directory +made IDA Nexus fail the open with + + idalib worker launcher 2425195 exited with status 1 + [ida-nexus] Failed to open database /usr/bin/ls + +after a full analysis wait -- naming neither the file it could not create nor +the reason. This drives the replacement: a pre-flight check, a dialog with a +writable default, and ``output_database`` carried into the client. + +NEEDS_IDA is True because this needs **textual** (run.py picks the IDA venv for +those); it never opens a database -- ``_connect`` is stubbed, so it costs ~1s. +""" + +#: needs textual (run.py's interpreter choice), but spawns NO worker. +#: Read by tests/run.py (--fast skips every NEEDS_IDA file). +NEEDS_IDA = True +import asyncio +import os +import shutil +import stat +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 _fixtures import fast_keys # noqa: E402 + +fast_keys() + +from idatui import idbpath # noqa: E402 +from idatui._sync import settle # noqa: E402 +from idatui.app import IdaTui, IdbLocationScreen # 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}") + + +def _app(path, **kw): + """The app with the open stubbed out: this suite is about the DECISION. + + ``_connect`` is a Textual worker; replacing the bound attribute is enough + because every caller goes through ``self._connect()``. ``connects`` records + that we would have opened the database, which is the other half of the + contract (answering the dialog must actually proceed). + """ + app = IdaTui(open_path=path, keepalive=False, **kw) + app.connects = 0 + + def stub(): + app.connects += 1 + + app._connect = stub + return app + + +def _readonly_dir(tmp, name="sysdir"): + """A directory holding a binary that the directory will not let us write.""" + d = os.path.join(tmp, name) + os.makedirs(d, exist_ok=True) + binary = os.path.join(d, "ls") + shutil.copy("/bin/ls" if os.path.exists("/bin/ls") else sys.executable, binary) + os.chmod(d, 0o500) + return d, binary + + +async def t_asks_and_relocates(tmp): + d, binary = _readonly_dir(tmp) + try: + app = _app(binary) + async with app.run_test(size=(120, 40)) as pilot: + await settle(app) + screen = app.screen + check( + "an unwritable location asks instead of opening", + isinstance(screen, IdbLocationScreen), + type(screen).__name__, + ) + check("nothing was opened while the dialog is up", app.connects == 0) + text = str(app.screen.query_one("#idb-msg").render()) + check( + "the dialog names the file IDA could not create", + binary + ".i64" in text, + text[:120], + ) + check("...and why", "not writable" in text, text[:120]) + proposed = app.screen.query_one("#idb-path").value + check( + "it proposes the stable per-binary location", + proposed == idbpath.relocated_idb(binary), + proposed, + ) + + await pilot.press("enter") + await settle(app) + check( + "accepting records it as the database location", + app._idb_path == idbpath.relocated_idb(binary), + str(app._idb_path), + ) + check( + "...creates the directory for it", + os.path.isdir(os.path.dirname(app._idb_path)), + ) + check("...and proceeds to open", app.connects == 1) + check( + "the original directory was left untouched", + os.listdir(d) == ["ls"], + str(os.listdir(d)), + ) + finally: + os.chmod(d, 0o700) + + +async def t_esc_quits(tmp): + """With nowhere to put the database there is no session to show.""" + d, binary = _readonly_dir(tmp, "sysdir2") + try: + app = _app(binary) + async with app.run_test(size=(120, 40)) as pilot: + await settle(app) + check("the dialog is up", isinstance(app.screen, IdbLocationScreen)) + await pilot.press("escape") + await settle(app) + check("Esc does not open anything", app.connects == 0) + check("Esc does not leave an empty app", app._idb_path is None) + check( + "...and does not arm the save-on-exit path", + app._save_on_exit is False, + str(app._save_on_exit), + ) + finally: + os.chmod(d, 0o700) + + +async def t_edited_path(tmp): + """The proposal is a default, not a decision.""" + d, binary = _readonly_dir(tmp, "sysdir3") + chosen = os.path.join(tmp, "elsewhere", "mine") + try: + app = _app(binary) + async with app.run_test(size=(120, 40)) as pilot: + await settle(app) + inp = app.screen.query_one("#idb-path") + inp.value = chosen + await pilot.press("enter") + await settle(app) + check( + "a typed path is used, with .i64 supplied", + app._idb_path == chosen + ".i64", + str(app._idb_path), + ) + check( + "its directory is created privately", + os.path.isdir(os.path.dirname(chosen)) + and stat.S_IMODE(os.stat(os.path.dirname(chosen)).st_mode) == 0o700, + ) + finally: + os.chmod(d, 0o700) + + +async def t_writable_is_untouched(tmp): + """The common case must not gain a dialog.""" + binary = os.path.join(tmp, "own") + shutil.copy("/bin/ls" if os.path.exists("/bin/ls") else sys.executable, binary) + app = _app(binary) + async with app.run_test(size=(120, 40)): + await settle(app) + check( + "a writable directory opens straight away", + not isinstance(app.screen, IdbLocationScreen) and app.connects == 1, + f"{type(app.screen).__name__} connects={app.connects}", + ) + check("and no database location is forced", app._idb_path is None) + + +async def t_explicit_idb_wins(tmp): + """``--idb`` is an answer already given; do not ask again.""" + d, binary = _readonly_dir(tmp, "sysdir4") + target = os.path.join(tmp, "explicit.i64") + try: + app = _app(binary, idb_path=target) + async with app.run_test(size=(120, 40)): + await settle(app) + check( + "--idb skips the dialog", + not isinstance(app.screen, IdbLocationScreen) and app.connects == 1, + f"{type(app.screen).__name__} connects={app.connects}", + ) + check("and is what gets used", app._idb_path == target, str(app._idb_path)) + finally: + os.chmod(d, 0o700) + + +async def t_rpc_driven_does_not_ask(tmp): + """A puppeteered TUI has nobody to ask, and `pane spawn` is waiting on it. + + Left as a dialog, an RPC-driven pane never reports ready and spawn burns its + whole timeout in front of a modal. Taking the proposal destroys nothing. + """ + d, binary = _readonly_dir(tmp, "sysdir_rpc") + try: + app = _app(binary, rpc_path=os.path.join(tmp, "nope.sock")) + app._start_rpc = lambda: None # the socket is not the point here + async with app.run_test(size=(120, 40)): + await settle(app) + check( + "an RPC-driven TUI relocates without asking", + not isinstance(app.screen, IdbLocationScreen) + and app._idb_path == idbpath.relocated_idb(binary), + f"{type(app.screen).__name__} {app._idb_path}", + ) + check("...and still opens", app.connects == 1, str(app.connects)) + status = str(app.query_one("#status").render()) + check( + "...and says where the database went", + app._idb_path in status and "not writable" in status, + status[:120], + ) + finally: + os.chmod(d, 0o700) + + +async def t_client_gets_output_database(tmp): + """The decision has to reach the backend, or it changes nothing. + + ``NexusClient`` is stubbed: what matters here is the argument, and + constructing the real one needs the IDA Nexus library. + """ + import idatui.app as appmod + + d, binary = _readonly_dir(tmp, "sysdir5") + seen = {} + + class FakeClient: + def __init__(self, path, **kw): + seen.update(kw, path=path) + + def connect(self, **kw): + return self + + real = appmod.NexusClient + appmod.NexusClient = FakeClient + try: + app = IdaTui( + open_path=binary, keepalive=False, idb_path=os.path.join(tmp, "x.i64") + ) + async with app.run_test(size=(120, 40)): + await settle(app) + # Off the app thread: the opener reports progress with + # call_from_thread, which refuses to run on the loop it posts to. + await asyncio.to_thread(app._open_database_client) + check( + "output_database carries the chosen location", + seen.get("output_database") == os.path.join(tmp, "x.i64"), + str(seen), + ) + check( + "and the binary is still what gets opened", + seen.get("path") == binary, + str(seen), + ) + finally: + appmod.NexusClient = real + os.chmod(d, 0o700) + + +async def run() -> int: + with tempfile.TemporaryDirectory() as tmp: + os.environ[idbpath.DB_DIR_ENV] = os.path.join(tmp, "dbs") + try: + for fn in ( + t_asks_and_relocates, + t_esc_quits, + t_edited_path, + t_writable_is_untouched, + t_explicit_idb_wins, + t_rpc_driven_does_not_ask, + t_client_gets_output_database, + ): + print(f"\n{fn.__name__}") + sub = os.path.join(tmp, fn.__name__) + os.makedirs(sub, exist_ok=True) + try: + await fn(sub) + except Exception as e: # noqa: BLE001 + import traceback + + check( + f"{fn.__name__} did not crash", + False, + f"{type(e).__name__}: {e}", + ) + traceback.print_exc() + finally: + os.environ.pop(idbpath.DB_DIR_ENV, None) + print(f"\n{PASS} passed, {FAIL} failed") + return 1 if FAIL else 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(run())) |
