aboutsummaryrefslogtreecommitdiffstats
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
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 '')
-rw-r--r--README.md8
-rw-r--r--idatui/app.py232
-rw-r--r--idatui/idbpath.py158
-rw-r--r--idatui/launch.py28
-rw-r--r--idatui/pane.py13
-rw-r--r--tests/test_idb_relocate_ui.py315
-rw-r--r--tests/test_idbpath.py268
-rw-r--r--tests/test_launch.py23
8 files changed, 1038 insertions, 7 deletions
diff --git a/README.md b/README.md
index 0f967ac..94fd67e 100644
--- a/README.md
+++ b/README.md
@@ -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()