aboutsummaryrefslogtreecommitdiffstats
path: root/idatui/app.py
diff options
context:
space:
mode:
authorblasty <peter@haxx.in>2026-09-25 14:59:48 +0200
committerblasty <peter@haxx.in>2026-09-25 14:59:48 +0200
commit15425df0a6e09984fdab3f86354620d4d8656806 (patch)
treecf92f86fd343cbfc04f19be895a337b304c576ec /idatui/app.py
parentMerge PR #2 from mrexodia: fix pseudocode/listing tag parsing, ida-nexus 0.13.0 (diff)
downloadida-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 'idatui/app.py')
-rw-r--r--idatui/app.py232
1 files changed, 226 insertions, 6 deletions
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))