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 | |
| parent | Merge PR #2 from mrexodia: fix pseudocode/listing tag parsing, ida-nexus 0.13.0 (diff) | |
| download | ida-tui-15425df0a6e09984fdab3f86354620d4d8656806.tar.gz ida-tui-15425df0a6e09984fdab3f86354620d4d8656806.tar.xz ida-tui-15425df0a6e09984fdab3f86354620d4d8656806.zip | |
Offer a writable database location instead of failing the open
`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 '')
| -rw-r--r-- | README.md | 8 | ||||
| -rw-r--r-- | idatui/app.py | 232 | ||||
| -rw-r--r-- | idatui/idbpath.py | 158 | ||||
| -rw-r--r-- | idatui/launch.py | 28 | ||||
| -rw-r--r-- | idatui/pane.py | 13 | ||||
| -rw-r--r-- | tests/test_idb_relocate_ui.py | 315 | ||||
| -rw-r--r-- | tests/test_idbpath.py | 268 | ||||
| -rw-r--r-- | tests/test_launch.py | 23 |
8 files changed, 1038 insertions, 7 deletions
@@ -64,6 +64,14 @@ and quitting ida-tui never closes anyone else's session. > These settings only matter the first time a file is opened. On ARM, `t` switches > between ARM and Thumb at the cursor, and `T` finds Thumb code by reading the vector table. +> [!TIP] +> **Binaries you can't write next to** — `./ida-tui /bin/ls` would need IDA to +> create `/bin/ls.i64`, which isn't yours to write. ida-tui notices before opening +> anything and offers to keep the database somewhere else instead +> (`~/.local/share/idatui/db/`, one stable path per binary, so your names and +> comments come back next time). `--idb /path/to/db.i64` picks the location +> yourself, and `$IDATUI_DB_DIR` moves the default. + ## Keys | Key | What it does | diff --git a/idatui/app.py b/idatui/app.py index 52f474b..64b1216 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -4498,6 +4498,104 @@ class ProjectPalette(OptionListNav, ModalScreen): # --------------------------------------------------------------------------- # +# Where the database goes +# --------------------------------------------------------------------------- # +class IdbLocationScreen(ModalScreen): + """Where should the database go, when it can't go where IDA would put it? + + ``ida-tui /bin/ls`` asks IDA to create ``/bin/ls.i64``, which a normal user + cannot write. The backend's answer is "idalib worker launcher 2425195 exited + with status 1" -- after a full analysis wait, naming neither the file nor the + reason. This is that failure, moved to before the open and turned into a + question with a working default. + + Dismisses the chosen path (a str), or None to quit: there is no third option + that shows anything, and "carry on and fail exactly as before" is not worth a + keystroke. + """ + + BINDINGS = [ + Binding("enter", "accept", "Use this path", priority=True), + Binding("escape", "cancel", "Quit"), + ] + + def __init__(self, binary: str, reason: str, proposed: str) -> None: + super().__init__() + self._binary = binary + self._reason = reason + self._proposed = proposed + + def compose(self) -> ComposeResult: + from . import idbpath + + with Vertical(id="idb-box") as box: + box.border_title = Text("\u26a0 the database cannot be written there") + yield Static( + f" {idbpath.expected_idb(self._binary)}\n \u2014 {self._reason}.", + id="idb-msg", + markup=False, + ) + yield Static(" Keep the database here instead:", id="idb-note") + yield Input(value=self._proposed, id="idb-path") + yield Static( + f" {idbpath.describe(self._binary, self._proposed)}", + id="idb-detail", + markup=False, + ) + yield Static( + " [Enter] open it there [Esc] quit", + id="idb-help", + markup=False, + ) + + def on_mount(self) -> None: + inp = self.query_one("#idb-path", Input) + inp.focus() + inp.cursor_position = len(inp.value) + + def on_input_changed(self, event: Input.Changed) -> None: + """Keep the note honest: an edited path may already hold a database.""" + from . import idbpath + + event.stop() + value = event.value.strip() + self.query_one("#idb-detail", Static).update( + f" {idbpath.describe(self._binary, value)}" if value else "" + ) + + def on_input_submitted(self, event: Input.Submitted) -> None: + event.stop() + self.action_accept() + + def action_accept(self) -> None: + from . import idbpath + + value = self.query_one("#idb-path", Input).value.strip() + if not value: + return + target = os.path.abspath(os.path.expanduser(value)) + if not target.lower().endswith(".i64"): + # IDA Nexus derives the scratch file names from this path, and a + # database that isn't named .i64 confuses every later lookup + # (including its own registry matching). + target += ".i64" + try: + idbpath.ensure_parent(target) + except OSError as e: + self.query_one("#idb-help", Static).update(f" {e.strerror}: {target}") + return + if not os.access(os.path.dirname(target), os.W_OK | os.X_OK): + self.query_one("#idb-help", Static).update( + f" not writable either: {os.path.dirname(target)}" + ) + return + self.dismiss(target) + + def action_cancel(self) -> None: + self.dismiss(None) + + +# --------------------------------------------------------------------------- # # Confirmation dialog # --------------------------------------------------------------------------- # class ConfirmScreen(ModalScreen): @@ -5397,7 +5495,7 @@ class IdaTui(App): only thing worth looking at. Size stays per-box; everything else is here, once. */ #quit-box, #help-box, #xref-box, #pal-box, #se-box, #confirm-box, - #loading-box, #busy-box { + #idb-box, #loading-box, #busy-box { background: $panel; border: round $panel-lighten-3; border-title-color: $accent; @@ -5475,6 +5573,13 @@ class IdaTui(App): instead of the old near-black bar. */ #se-status { height: 1; background: $panel-darken-1; color: $text-muted; padding: 0 1; } + #idb-box { width: 84; max-width: 92%; height: auto; padding: 1 1; + border: round $warning; border-title-color: $warning; } + #idb-msg { height: auto; padding: 0 1; } + #idb-note { height: 1; padding: 0 1; margin-top: 1; color: $text-muted; } + #idb-path { border: none; height: 1; margin: 0 1; background: $panel; color: $text; } + #idb-detail { height: auto; padding: 0 1; color: $text-muted; } + #idb-help { height: 1; padding: 0 1; margin-top: 1; color: $text-muted; } #confirm-box { width: 60; height: auto; padding: 1 2; } #confirm-msg { height: auto; } #confirm-help { height: 1; color: $text-muted; margin-top: 1; } @@ -5537,6 +5642,7 @@ class IdaTui(App): project=None, load_args: str = "", trace_path: str = "", + idb_path: str | None = None, ) -> None: super().__init__() # Project mode is additive: with no project this is the plain @@ -5572,6 +5678,12 @@ class IdaTui(App): self._open_path = open_path self._ttl = ttl self._load_args = load_args or "" # first-open options for a headerless blob + #: Where the database lives, when not beside the binary (--idb, or the + #: answer to IdbLocationScreen). Project mode has its own answer: the + #: sidecar, decided by the pool. + self._idb_path = ( + os.path.abspath(os.path.expanduser(idb_path)) if idb_path else None + ) self._new_database = False # Ctrl+L asks IDA Nexus for a fresh IDB self._title = os.path.basename(open_path) if open_path else "" #: Where we are in the execution trace, and everything that moves us. @@ -5709,6 +5821,17 @@ class IdaTui(App): elif self._should_ask_load_options(): self._ask_load_options(self._open_path) return + self._begin_connect() + + def _begin_connect(self) -> None: + """Open the database, once every question about HOW has been answered. + + The last of those questions is where the database may be written, asked + here rather than in ``on_mount`` because the load-options dialog returns + through this path too. + """ + if self._ask_idb_location(): + return # Show a loading overlay immediately so a slow open/analysis (big binary) # isn't just dead air behind empty panes; dismissed once we land. self._loading_screen = LoadingScreen(self._loading_title()) @@ -5729,17 +5852,106 @@ class IdaTui(App): return False from .formats import needs_load_options - if os.path.exists(self._open_path + ".i64") or os.path.exists( + # Including a relocated database: it records the answer just as well as + # an .i64 beside the binary would. + if os.path.exists(self._planned_idb()) or os.path.exists( os.path.splitext(self._open_path)[0] + ".i64" ): return False try: - if registered_database(self._open_path): + if registered_database(self._open_path, output_database=self._idb_path): return False except Exception: pass # connect() will surface registry failures with full diagnostics return needs_load_options(self._open_path) + def _planned_idb(self) -> str: + """The database path this binary is heading for. + + Not always ``binary + ".i64"``: an explicit ``--idb`` wins, and a binary + whose own directory cannot hold a database is about to be offered the + relocated path, so that is where an earlier session's work will be. + """ + from . import idbpath + + if self._idb_path: + return self._idb_path + if self._open_path and idbpath.blocked_reason(self._open_path) is not None: + return idbpath.relocated_idb(self._open_path) + return idbpath.expected_idb(self._open_path or "") + + def _blocked_idb_reason(self) -> str | None: + """Why IDA can't write this binary's database beside it, if it can't. + + None in every ordinary case, including: project mode (the sidecar is + already somewhere writable), a location we were told to use (--idb or an + earlier answer), and a database some other client already has open -- + that one exists and is being written by its owner, not by us. + """ + if self._project is not None or self._idb_path is not None: + return None + if not self._open_path: + return None + from . import idbpath + + reason = idbpath.blocked_reason(self._open_path) + if reason is None: + return None + try: + if registered_database(self._open_path): + return None + except Exception: # noqa: BLE001 -- connect() reports registry failures + pass + return reason + + def _ask_idb_location(self, reason: str | None = None) -> bool: + """Offer a writable database location. True when the dialog is up.""" + reason = reason or self._blocked_idb_reason() + if reason is None or not self._open_path: + return False + from . import idbpath + + if self._rpc_path: + # Nobody is watching this one. An RPC-driven TUI is waited on by + # `pane spawn`, which polls for ready and would sit through its whole + # timeout in front of a modal nobody can answer -- so take the + # proposal, which destroys nothing, and say where it went. + self._idb_path = idbpath.ensure_parent( + idbpath.relocated_idb(self._open_path) + ) + self._status(f"{reason} \u2014 database kept at {self._idb_path}") + return False + self.push_screen( + IdbLocationScreen( + self._open_path, reason, idbpath.relocated_idb(self._open_path) + ), + self._on_idb_location, + ) + return True + + def _offer_idb_relocation(self, why: str) -> None: + """After a failed open, ask about the database if that could be the cause.""" + if ( + self._project is not None + or self._idb_path is not None + or not self._open_path + ): + return + from . import idbpath + + if idbpath.can_create(idbpath.expected_idb(self._open_path)): + return # the database was creatable; the failure is something else + self._ask_idb_location(f"cannot create it ({why.splitlines()[0]})") + + def _on_idb_location(self, target) -> None: # type: ignore[no-untyped-def] + """Esc means quit: with nowhere to put the database there is no session.""" + if not target: + self._save_on_exit = False + self.exit(message="no writable location for the database") + return + self._idb_path = target + self._begin_connect() # asks nothing more now that _idb_path is set + def action_load_options(self) -> None: """Ctrl+L: re-open this binary with different load options. @@ -5893,9 +6105,7 @@ class IdaTui(App): label2, self._pending_switch = self._pending_switch, None self._switch_binary(label2) return - self._loading_screen = LoadingScreen(self._loading_title()) - self.push_screen(self._loading_screen) - self._connect() + self._begin_connect() def _loading_title(self) -> str: return os.path.basename(self._open_path) if self._open_path else "database" @@ -6210,6 +6420,7 @@ class IdaTui(App): self._open_path, ttl=self._ttl, load_args=self._load_args, + output_database=self._idb_path, spawn=False, ) client.connect( @@ -6272,6 +6483,14 @@ class IdaTui(App): # cost one more keypress, not a restart. if "load options" in str(e): self.app.call_from_thread(self._retry_load_options) + return + # Belt and braces for the unwritable-database case. `blocked_reason` + # is an os.access answer and os.access does not know about ACLs, a + # lying network mount or a full disk; an open that failed anyway, + # where we cannot then create the database ourselves, is the same + # problem discovered the expensive way. Offer the same way out + # instead of dead-ending on the backend's "worker exited with 1". + self.app.call_from_thread(self._offer_idb_relocation, str(e)) return self.client = client self.program = program @@ -6308,6 +6527,7 @@ class IdaTui(App): self._open_path, ttl=self._ttl, load_args=self._load_args, + output_database=self._idb_path, new_database=self._new_database, ) client.connect(progress=lambda m: self.app.call_from_thread(self._status, m)) diff --git a/idatui/idbpath.py b/idatui/idbpath.py new file mode 100644 index 0000000..a9614b3 --- /dev/null +++ b/idatui/idbpath.py @@ -0,0 +1,158 @@ +"""Where the database goes when the binary's own directory can't hold it. + +IDA puts its database beside the binary: ``/bin/ls`` becomes ``/bin/ls.i64``, +plus ``.id0/.id1/.id2/.nam/.til`` scratch files unpacked into the same directory +while it is open. For anything the user doesn't own -- ``/bin``, ``/usr/lib``, a +read-only mount, a root-owned firmware drop -- that write is denied, and the only +thing to see is the backend's account of it:: + + idalib worker launcher 2425195 exited with status 1 + [ida-nexus] Failed to open database /usr/bin/ls + +which names neither the file it failed to create nor the reason. Worse, the +failure costs a full analysis wait first. So: check whether the database can be +written BEFORE opening anything, and when it can't, propose a path that works. + +IDA Nexus takes ``output_database`` for exactly this, and project mode already +relies on it (everything lands in the project's sidecar). This is the +single-binary equivalent, and the proposed location is *stable per binary* -- +reopening ``/bin/ls`` tomorrow finds yesterday's names and comments, which a +temporary directory would not. + +Pure stdlib: no IDA, no IDA Nexus, no Textual. Nothing here writes anything. +""" + +from __future__ import annotations + +import hashlib +import os + +#: Overrides where relocated databases are kept. +DB_DIR_ENV = "IDATUI_DB_DIR" + +#: Deliberately under XDG_DATA_HOME, not XDG_CACHE_HOME. An .i64 looks like a +#: cache -- it is derived from the binary and can be rebuilt -- but it also holds +#: every name, comment and type the session produced, and cache directories are +#: something cleaners are entitled to empty. +_XDG_DEFAULT = os.path.join("~", ".local", "share", "idatui", "db") + + +def expected_idb(binary: str) -> str: + """The database IDA would create for ``binary``, byte for byte as Nexus does. + + Mirrors ``ida_nexus._resolver.expected_idb_path``: a path that is already an + ``.i64`` is the database, anything else gets ``.i64`` appended (IDA 9 is + 64-bit only, so there is no ``.idb`` case to consider). + """ + path = os.path.abspath(os.path.expanduser(binary)) + return path if path.lower().endswith(".i64") else path + ".i64" + + +def db_root() -> str: + """Directory holding relocated databases.""" + override = os.environ.get(DB_DIR_ENV) + if override: + return os.path.abspath(os.path.expanduser(override)) + xdg = os.environ.get("XDG_DATA_HOME") + if xdg: + return os.path.join(os.path.abspath(os.path.expanduser(xdg)), "idatui", "db") + return os.path.abspath(os.path.expanduser(_XDG_DEFAULT)) + + +def _slug(name: str) -> str: + """``name`` reduced to something safe to paste into a shell command.""" + out = "".join(c if (c.isalnum() or c in "._-") else "_" for c in name) + return out.strip("._-")[:64] or "binary" + + +def relocated_idb(binary: str) -> str: + """A writable database path for ``binary``, stable across runs. + + Keyed by the resolved source path so the same binary always maps to the same + database (your work comes back), and hashed so two ``busybox``es from + different directories cannot collide. Flat rather than one directory per + binary: IDA unpacks its scratch files alongside the ``.i64`` under the same + stem, and the hash already makes that stem unique. + """ + real = os.path.normcase(os.path.realpath(os.path.expanduser(binary))) + digest = hashlib.sha1(real.encode("utf-8", "surrogateescape")).hexdigest()[:8] + stem = os.path.basename(real.rstrip(os.sep) or real) + if stem.lower().endswith(".i64"): + stem = stem[:-4] + return os.path.join(db_root(), f"{_slug(stem)}-{digest}.i64") + + +def blocked_reason(binary: str) -> str | None: + """Why IDA could not write ``binary``'s database here, or None if it can. + + A short phrase that completes "can't open this binary because ...", meant to + be shown to a human. ``None`` is the common answer and must stay cheap: only + ``os.access`` calls, no probe files in the user's directories. + + ``os.access`` is not the whole truth (ACLs, an NFS mount that lies, a full + disk), which is why the caller also treats a failed open as a candidate for + relocation. It is right about the case that actually happens -- a binary in a + system directory -- and being right there is worth a syscall. + """ + idb = expected_idb(binary) + src = os.path.abspath(os.path.expanduser(binary)) + if not os.path.exists(src): + return None # not ours to judge: a registered database, or a typo + parent = os.path.dirname(idb) or "." + if not os.path.isdir(parent): + return f"{parent} is not a directory" + if not os.access(parent, os.W_OK | os.X_OK): + return f"{parent} is not writable" + if os.path.exists(idb) and not os.access(idb, os.W_OK): + # The directory is fine but the database itself isn't ours -- someone + # else's analysis, or a root-owned .i64 shipped with the firmware. IDA + # rewrites the .i64 on save, so read-only is a dead end, not a view. + return f"{idb} exists but is not writable" + return None + + +def can_create(target: str) -> bool: + """Whether ``target`` can really be created right now, by trying it. + + The truthful version of ``blocked_reason``'s ``os.access`` check, and the + only one that knows about ACLs, a read-only remount or a full disk. It costs + a create + unlink in the user's directory, so it is for the path where an + open has ALREADY failed and the question is what to blame -- never for the + pre-flight check, which must not touch anything. + + An existing ``target`` is left strictly alone: this must never truncate a + database. + """ + target = os.path.abspath(os.path.expanduser(target)) + if os.path.exists(target): + return True + try: + fd = os.open(target, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except OSError: + return False + os.close(fd) + try: + os.unlink(target) + except OSError: # created it but cannot remove it: still "creatable" + pass + return True + + +def describe(binary: str, target: str) -> str: + """One line about what relocating ``binary`` to ``target`` will do.""" + if os.path.exists(target): + return "resuming the database already kept there (your names and comments)" + return f"a new database; the original {expected_idb(binary)} is never written" + + +def ensure_parent(target: str) -> str: + """Create ``target``'s directory (private) and return ``target``. + + 0700 because a database exposes the whole binary plus whatever the session + recorded about it, and this directory accumulates one for every binary + opened from a place that could not hold its own. + """ + parent = os.path.dirname(os.path.abspath(os.path.expanduser(target))) + if parent: + os.makedirs(parent, mode=0o700, exist_ok=True) + return os.path.abspath(os.path.expanduser(target)) diff --git a/idatui/launch.py b/idatui/launch.py index 6e3b432..01778f3 100644 --- a/idatui/launch.py +++ b/idatui/launch.py @@ -103,6 +103,12 @@ def main(argv: list[str] | None = None) -> int: metavar="FILE", help="Tenet execution trace to explore alongside the binary", ) + p.add_argument( + "--idb", + metavar="FILE", + help="keep the database here instead of beside the binary (an .i64 path; " + "the default for a binary in a directory you cannot write)", + ) g = p.add_argument_group( "loading a headerless blob", "An ELF/PE/Mach-O says what it is. A raw firmware dump doesn't, and IDA " @@ -145,6 +151,27 @@ def main(argv: list[str] | None = None) -> int: if args.ida_args: load["ida_args"] = args.ida_args + # Where the database goes, for a binary whose own directory cannot hold one + # (``/bin/ls`` -> ``/bin/ls.i64``). Validated HERE, before anything is staged + # or imported, so a contradictory invocation costs nothing. + idb_path = None + if args.idb: + from . import idbpath + + if args.project: + # A project already keeps every database in its sidecar, so the two + # options mean contradictory things. Refusing beats ignoring one. + _log( + "--idb is for a single binary; a project keeps its databases in " + "its sidecar" + ) + return 2 + try: + idb_path = idbpath.ensure_parent(args.idb) + except OSError as e: + _log(f"cannot create the directory for --idb {args.idb}: {e}") + return 2 + project = None binary = None if args.project: @@ -249,6 +276,7 @@ def main(argv: list[str] | None = None) -> int: trace_path=( os.path.abspath(os.path.expanduser(args.trace)) if args.trace else "" ), + idb_path=idb_path, ).run() return 0 diff --git a/idatui/pane.py b/idatui/pane.py index 67b462b..51028ae 100644 --- a/idatui/pane.py +++ b/idatui/pane.py @@ -361,6 +361,13 @@ def spawn(args) -> int: inner += ["--" + opt.replace("_", "-"), str(val)] if getattr(args, "trace", None): inner += ["--trace", os.path.abspath(os.path.expanduser(args.trace))] + # Where the database goes, for a binary whose own directory cannot hold one + # (/bin/ls). Without it the TUI picks a writable location itself and says so + # in the status line -- an RPC-driven pane is never asked, because nothing + # would answer and spawn would wait out its whole timeout in front of the + # dialog. Pass it when the location matters. + if getattr(args, "idb", None): + inner += ["--idb", os.path.abspath(os.path.expanduser(args.idb))] if args.size and mux == "zellij": print( @@ -651,6 +658,12 @@ def main(argv: list[str]) -> int: help="extra IDA command-line switches, passed through", ) sp.add_argument( + "--idb", + metavar="FILE", + help="keep the database at this .i64 path instead of beside the binary " + "(passed to idatui.launch)", + ) + sp.add_argument( "--sock", help="RPC socket path (default: auto in $XDG_RUNTIME_DIR)" ) sp.add_argument( 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())) diff --git a/tests/test_idbpath.py b/tests/test_idbpath.py new file mode 100644 index 0000000..8c39349 --- /dev/null +++ b/tests/test_idbpath.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Deciding where a binary's database can be written. + +The case: ``ida-tui /bin/ls`` asks IDA for ``/bin/ls.i64``, a normal user cannot +write ``/bin``, and the backend reports "idalib worker launcher NNN exited with +status 1" after a full analysis wait. These checks pin the pre-flight answer that +replaces that, and the two properties the proposed location must have — stable +per binary (so a session's names come back) and collision-free. + +Pure: no IDA, no IDA Nexus, no Textual. +""" + +from __future__ import annotations + +import os +import stat +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +#: pure: path arithmetic plus os.access against real temp dirs. +#: Read by tests/run.py (--fast skips every NEEDS_IDA file). +NEEDS_IDA = False + +from idatui import idbpath # 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 touch(path, mode=0o644): + with open(path, "wb") as fh: + fh.write(b"\x7fELF") + os.chmod(path, mode) + return path + + +def t_expected_idb(): + """Must agree with ida_nexus's own derivation, or we check the wrong file.""" + check( + "a binary gets .i64 appended", + idbpath.expected_idb("/bin/ls") == "/bin/ls.i64", + idbpath.expected_idb("/bin/ls"), + ) + check( + "a path that IS a database is left alone", + idbpath.expected_idb("/tmp/x/foo.i64") == "/tmp/x/foo.i64", + idbpath.expected_idb("/tmp/x/foo.i64"), + ) + check( + "the suffix check is case-insensitive, like the resolver's", + idbpath.expected_idb("/tmp/x/FOO.I64") == "/tmp/x/FOO.I64", + idbpath.expected_idb("/tmp/x/FOO.I64"), + ) + check( + "a relative path is absolute afterwards", + os.path.isabs(idbpath.expected_idb("ls")), + ) + + +def t_blocked_reason(tmp): + """The pre-flight answer: None for a writable directory, a reason otherwise.""" + good = touch(os.path.join(tmp, "app")) + check("a writable directory is not blocked", idbpath.blocked_reason(good) is None) + + ro = os.path.join(tmp, "ro") + os.mkdir(ro, 0o700) + binary = touch(os.path.join(ro, "ls")) # the binary first: /bin/ls exists + os.chmod(ro, 0o500) + reason = idbpath.blocked_reason(binary) + check("an unwritable directory is blocked", reason is not None, repr(reason)) + check("the reason names the directory", ro in (reason or ""), repr(reason)) + + # A read-only database in a writable directory is also a dead end: IDA + # rewrites the .i64 on save, so it cannot be treated as a read-only view. + b2 = touch(os.path.join(tmp, "app2")) + touch(b2 + ".i64", mode=0o444) + reason2 = idbpath.blocked_reason(b2) + check( + "an unwritable existing database is blocked", reason2 is not None, repr(reason2) + ) + check( + "...and the reason names the database, not the directory", + (reason2 or "").startswith(b2 + ".i64"), + repr(reason2), + ) + os.chmod(b2 + ".i64", 0o644) + check( + "a writable existing database is not blocked", + idbpath.blocked_reason(b2) is None, + repr(idbpath.blocked_reason(b2)), + ) + + # Not our question to answer: launch.py already rejects a path that is + # neither a file nor a registered database. + check( + "a file that does not exist is not reported as blocked", + idbpath.blocked_reason(os.path.join(tmp, "nope")) is None, + ) + os.chmod(ro, 0o700) + + +def t_no_side_effects(tmp): + """The pre-flight check must not touch the user's directory.""" + binary = touch(os.path.join(tmp, "probe-target")) + before = sorted(os.listdir(tmp)) + for _ in range(3): + idbpath.blocked_reason(binary) + check("blocked_reason creates nothing", sorted(os.listdir(tmp)) == before) + + +def t_relocated(tmp): + """Stable per binary, unique between binaries, and named like the binary.""" + os.environ[idbpath.DB_DIR_ENV] = os.path.join(tmp, "dbs") + try: + a = os.path.join(tmp, "a") + os.makedirs(a, exist_ok=True) + b = os.path.join(tmp, "b") + os.makedirs(b, exist_ok=True) + one, two = touch(os.path.join(a, "busybox")), touch(os.path.join(b, "busybox")) + p1, p2 = idbpath.relocated_idb(one), idbpath.relocated_idb(two) + check( + "the proposal is stable across calls (yesterday's work comes back)", + p1 == idbpath.relocated_idb(one), + ) + check("two binaries with the same name do not collide", p1 != p2, f"{p1} {p2}") + check("it is recognisable", os.path.basename(p1).startswith("busybox-"), p1) + check("it is an .i64", p1.endswith(".i64"), p1) + check( + "it lands under the configured root", + p1.startswith(os.path.join(tmp, "dbs")), + p1, + ) + check( + "the scratch stem is unique too (IDA unpacks beside the .i64)", + os.path.splitext(os.path.basename(p1))[0] + != os.path.splitext(os.path.basename(p2))[0], + ) + # The relocated path is the one thing that must never need the original + # directory to be writable. + check("nothing is created just by proposing", not os.path.isdir(p1)) + idbpath.ensure_parent(p1) + check("ensure_parent creates the directory", os.path.isdir(os.path.dirname(p1))) + check( + "...privately (a database exposes the binary and the session)", + stat.S_IMODE(os.stat(os.path.dirname(p1)).st_mode) == 0o700, + oct(stat.S_IMODE(os.stat(os.path.dirname(p1)).st_mode)), + ) + check( + "a database path relocates by its own name, without a doubled suffix", + os.path.basename(idbpath.relocated_idb(os.path.join(a, "fw.i64"))).count( + ".i64" + ) + == 1, + idbpath.relocated_idb(os.path.join(a, "fw.i64")), + ) + finally: + os.environ.pop(idbpath.DB_DIR_ENV, None) + + +def t_db_root(tmp): + """Databases are user DATA, not cache: cleaners may empty a cache directory.""" + saved = {k: os.environ.get(k) for k in (idbpath.DB_DIR_ENV, "XDG_DATA_HOME")} + try: + os.environ.pop(idbpath.DB_DIR_ENV, None) + os.environ["XDG_DATA_HOME"] = os.path.join(tmp, "share") + root = idbpath.db_root() + check( + "XDG_DATA_HOME is honoured", + root == os.path.join(tmp, "share", "idatui", "db"), + root, + ) + os.environ[idbpath.DB_DIR_ENV] = os.path.join(tmp, "explicit") + check( + f"{idbpath.DB_DIR_ENV} overrides it", + idbpath.db_root() == os.path.join(tmp, "explicit"), + idbpath.db_root(), + ) + os.environ.pop("XDG_DATA_HOME") + os.environ.pop(idbpath.DB_DIR_ENV) + check("the default is not a cache directory", ".cache" not in idbpath.db_root()) + finally: + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +def t_can_create(tmp): + """The truthful probe, used only after an open has already failed.""" + check( + "a writable directory can hold a database", + idbpath.can_create(os.path.join(tmp, "new.i64")), + ) + check( + "...and the probe cleans up after itself", + not os.path.exists(os.path.join(tmp, "new.i64")), + ) + ro = os.path.join(tmp, "ro2") + os.mkdir(ro, 0o500) + check( + "an unwritable directory cannot", + not idbpath.can_create(os.path.join(ro, "x.i64")), + ) + os.chmod(ro, 0o700) + existing = touch(os.path.join(tmp, "keep.i64")) + check("an existing database counts as creatable", idbpath.can_create(existing)) + with open(existing, "rb") as fh: + check( + "...and is NOT truncated by the probe", + fh.read() == b"\x7fELF", + "the probe destroyed a database", + ) + + +def t_describe(tmp): + """The dialog's one line has to distinguish resume from start-fresh.""" + target = os.path.join(tmp, "d", "ls-1234abcd.i64") + os.makedirs(os.path.dirname(target), exist_ok=True) + fresh = idbpath.describe("/bin/ls", target) + check( + "a new database says the original is untouched", "/bin/ls.i64" in fresh, fresh + ) + touch(target) + resumed = idbpath.describe("/bin/ls", target) + check( + "an existing one says the work comes back", "resum" in resumed.lower(), resumed + ) + + +def main() -> int: + with tempfile.TemporaryDirectory() as tmp: + for fn in ( + t_expected_idb, + t_blocked_reason, + t_no_side_effects, + t_relocated, + t_db_root, + t_can_create, + t_describe, + ): + print(f"\n{fn.__name__}") + sub = os.path.join(tmp, fn.__name__) + os.makedirs(sub, exist_ok=True) + try: + fn(sub) if fn is not t_expected_idb else fn() + except Exception as e: # noqa: BLE001 + import traceback + + check(f"{fn.__name__} did not crash", False, f"{type(e).__name__}: {e}") + traceback.print_exc() + print(f"\n{PASS} passed, {FAIL} failed") + return 1 if FAIL else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_launch.py b/tests/test_launch.py index 5c05d08..5d90006 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -83,8 +83,29 @@ def t_load_args(): check("extra ida_args are passed through", "-p1" in c, c) +def t_idb_option(): + """``--idb`` names where the database goes, for a binary in /bin. + + The launcher only has to accept it and hand it over; choosing a location and + asking about one live in idbpath/app (tests/test_idbpath.py). + """ + src = open(launch.__file__, encoding="utf-8").read() + check("the launcher offers --idb", '"--idb"' in src) + check("...and passes it to the app", "idb_path=idb_path" in src) + with tempfile.TemporaryDirectory() as tmp: + # A project keeps its databases in its sidecar, so the two options mean + # contradictory things; refusing beats silently ignoring one. + proj = os.path.join(tmp, "p.idatui-project") + binary = os.path.join(tmp, "bin") + touch(binary) + rc = launch.main( + ["--project", proj, binary, "--idb", os.path.join(tmp, "x.i64")] + ) + check("--idb with --project is refused", rc == 2, f"rc={rc}") + + def main() -> int: - for fn in (t_no_lock_sweeping, t_load_args): + for fn in (t_no_lock_sweeping, t_load_args, t_idb_option): print(f"\n{fn.__name__}") try: fn() |
