aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_idbpath.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 /tests/test_idbpath.py
parentMerge PR #2 from mrexodia: fix pseudocode/listing tag parsing, ida-nexus 0.13.0 (diff)
downloadida-tui-main.tar.gz
ida-tui-main.tar.xz
ida-tui-main.zip
Offer a writable database location instead of failing the openHEADmain
`ida-tui /bin/ls` asked IDA to create /bin/ls.i64, which a normal user cannot write, and what came back was the backend's account of it: idalib worker launcher 2425195 exited with status 1 [ida-nexus] Failed to open database /usr/bin/ls after a full analysis wait, naming neither the file it could not create nor the reason. Nothing in the message points at permissions, and there was no way forward from it. idatui/idbpath.py answers the question before anything is opened: where would the database go (mirroring ida_nexus's own derivation), can it go there (os.access only — the pre-flight check must not create files), and where instead. The proposal is stable per binary (~/.local/share/idatui/db/<name>-<sha1(realpath)[:8]>.i64) so reopening the same binary resumes the same names and comments, and hashed so two busyboxes from different directories cannot collide. DATA_HOME rather than CACHE_HOME on purpose: an .i64 holds the whole session, and cache directories are something cleaners may empty. The answer becomes NexusClient(output_database=...) — the option project mode already uses to keep everything in its sidecar. `--idb PATH` (also forwarded by `pane spawn`) says where without being asked. An RPC-driven TUI is never asked: `pane spawn` polls for ready and would sit out its whole timeout in front of a modal nobody can answer, so it relocates and says so in the status line. A failed open re-offers relocation after a real create probe, since os.access does not know about ACLs, a lying network mount or a full disk. tests/test_idbpath.py (pure, 32 checks) pins the path arithmetic, the two properties of the proposal and that neither check touches the user's directory; tests/test_idb_relocate_ui.py (textual, no worker, 24 checks) drives the dialog, Esc, an edited path, the untouched common case, --idb, the RPC path, and that output_database actually reaches the client. Verified end to end against a real /bin/ls: 400 functions, and a rename saved in one session comes back in the next.
Diffstat (limited to 'tests/test_idbpath.py')
-rw-r--r--tests/test_idbpath.py268
1 files changed, 268 insertions, 0 deletions
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())