aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--idatui/launch.py26
-rw-r--r--tests/test_launch.py173
2 files changed, 195 insertions, 4 deletions
diff --git a/idatui/launch.py b/idatui/launch.py
index 1f9c51c..35221a1 100644
--- a/idatui/launch.py
+++ b/idatui/launch.py
@@ -24,9 +24,16 @@ _LOCK_SUFFIXES = (".id0", ".id1", ".id2", ".nam", ".til")
def _load_args(load: dict) -> str:
- """``load`` as IDA switches, for the single-binary path (no project ref)."""
+ """``load`` as IDA switches, for the single-binary path (no project ref).
+
+ The base goes through project._as_addr rather than bare int(): our own CLI
+ hands over an int, but a project file writes "0x8000000" as a string and
+ int() raises on that. One parser, so the two paths can't disagree about what
+ an address looks like.
+ """
from .formats import load_args
- return load_args(load.get("processor", ""), int(load.get("base", 0) or 0),
+ from .project import _as_addr
+ return load_args(load.get("processor", ""), _as_addr(load.get("base", 0)),
str(load.get("ida_args", "") or ""))
@@ -35,13 +42,24 @@ def _log(msg: str) -> None:
def _sweep_locks(binary: str) -> int:
- """Remove stale unpacked DB files next to ``binary``. Returns how many."""
+ """Remove stale unpacked DB files next to ``binary``. Returns how many.
+
+ Never touches the ``.i64`` -- that is the real database, and nothing is
+ saved unless ``idb_save`` was called -- and never the input file itself.
+ The second guard is not theoretical: ``.til`` is both an unpacked-DB suffix
+ and the extension of an IDA type library, so ``ida-tui mylib.til`` swept its
+ own argument out of existence. Same for anything named ``*.id0``/``*.nam``.
+ """
+ keep = os.path.abspath(binary)
stem = os.path.splitext(binary)[0]
n = 0
for base in (binary, stem): # IDA may key on the full name or the stem
for suf in _LOCK_SUFFIXES:
+ victim = base + suf
+ if os.path.abspath(victim) == keep:
+ continue # that's what the user asked us to open
try:
- os.remove(base + suf)
+ os.remove(victim)
n += 1
except OSError:
pass
diff --git a/tests/test_launch.py b/tests/test_launch.py
new file mode 100644
index 0000000..c6ed8f1
--- /dev/null
+++ b/tests/test_launch.py
@@ -0,0 +1,173 @@
+#!/usr/bin/env python3
+"""The launcher's file handling -- the part that deletes things.
+
+`_sweep_locks` runs automatically when a database fails to open, and it removes
+files next to the user's binary. That is exactly the kind of code that must not
+be tested by trying it, so it is tested here: which files it takes, which it
+must never take, and what it reports.
+
+Pure: no IDA, no worker, no Textual.
+"""
+from __future__ import annotations
+
+import os
+import sys
+import tempfile
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+#: pure: file handling only, nothing is opened.
+#: Read by tests/run.py (--fast skips every NEEDS_IDA file).
+NEEDS_IDA = False
+
+from idatui.launch import _LOCK_SUFFIXES, _load_args, _sweep_locks # 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(*paths):
+ for p in paths:
+ with open(p, "wb") as fh:
+ fh.write(b"x")
+
+
+def t_sweeps_the_scratch_files():
+ """IDA unpacks a .i64 into .id0/.id1/.id2/.nam/.til while it is open; a
+ hard-killed worker leaves them and the .i64 then refuses to reopen."""
+ with tempfile.TemporaryDirectory() as d:
+ binary = os.path.join(d, "echo")
+ touch(binary, *[binary + s for s in _LOCK_SUFFIXES])
+ n = _sweep_locks(binary)
+ check("every unpacked scratch file is swept", n == len(_LOCK_SUFFIXES),
+ f"swept {n} of {len(_LOCK_SUFFIXES)}")
+ check("none of them survive",
+ not any(os.path.exists(binary + s) for s in _LOCK_SUFFIXES))
+ check("the binary itself is untouched", os.path.exists(binary))
+
+
+def t_sweeps_by_stem_too():
+ """IDA keys the scratch on the full name or the stem depending on how the
+ database was created, so both are swept."""
+ with tempfile.TemporaryDirectory() as d:
+ binary = os.path.join(d, "prog.elf")
+ stem = os.path.join(d, "prog")
+ touch(binary, stem + ".id0", stem + ".nam", binary + ".id1")
+ n = _sweep_locks(binary)
+ check("scratch named after the stem is swept too", n == 3, f"n={n}")
+ check("stem-keyed files are gone",
+ not os.path.exists(stem + ".id0")
+ and not os.path.exists(stem + ".nam"))
+ check("full-name-keyed files are gone", not os.path.exists(binary + ".id1"))
+ check("the binary itself is untouched", os.path.exists(binary))
+
+
+def t_never_the_database():
+ """The .i64 IS the database. Nothing is saved unless idb_save was called, so
+ deleting it throws away every rename and comment in the session."""
+ with tempfile.TemporaryDirectory() as d:
+ binary = os.path.join(d, "echo")
+ db = binary + ".i64"
+ stem_db = os.path.join(d, "echo.i64")
+ touch(binary, db, binary + ".id0")
+ _sweep_locks(binary)
+ check("the .i64 is never swept", os.path.exists(db))
+ check("nor the stem-keyed .i64", os.path.exists(stem_db))
+ check(".i64 is not in the suffix list", ".i64" not in _LOCK_SUFFIXES,
+ str(_LOCK_SUFFIXES))
+
+
+def t_never_the_input_itself():
+ """`.til` is both an unpacked-DB suffix and the extension of an IDA type
+ library, so `ida-tui mylib.til` used to sweep its own argument out of
+ existence -- irreversibly, on a path that runs automatically when an open
+ fails. Same for anything named *.id0/*.id1/*.id2/*.nam.
+ """
+ for suf in _LOCK_SUFFIXES:
+ with tempfile.TemporaryDirectory() as d:
+ binary = os.path.join(d, "mylib" + suf)
+ touch(binary)
+ _sweep_locks(binary)
+ check(f"a binary named *{suf} is not deleted by its own sweep",
+ os.path.exists(binary), f"{binary} was removed")
+
+
+def t_relative_path_is_still_the_input():
+ """The guard compares absolute paths -- a relative argument names the same
+ file and must be protected the same way."""
+ with tempfile.TemporaryDirectory() as d:
+ cwd = os.getcwd()
+ try:
+ os.chdir(d)
+ touch("mylib.til")
+ _sweep_locks("mylib.til")
+ check("a relative path to the input is protected too",
+ os.path.exists("mylib.til"))
+ finally:
+ os.chdir(cwd)
+
+
+def t_missing_files_are_fine():
+ with tempfile.TemporaryDirectory() as d:
+ binary = os.path.join(d, "nothing-here")
+ touch(binary)
+ n = _sweep_locks(binary)
+ check("sweeping with nothing to sweep reports 0", n == 0, f"n={n}")
+ check("and does not raise", True)
+
+
+def t_leaves_the_neighbours_alone():
+ with tempfile.TemporaryDirectory() as d:
+ binary = os.path.join(d, "echo")
+ other = os.path.join(d, "other.id0") # another binary's scratch
+ src = os.path.join(d, "echo.c")
+ touch(binary, other, src, binary + ".id0")
+ _sweep_locks(binary)
+ check("another binary's scratch is left alone", os.path.exists(other))
+ check("unrelated neighbours are left alone", os.path.exists(src))
+ check("our own scratch is still swept", not os.path.exists(binary + ".id0"))
+
+
+def t_load_args():
+ """The single-binary path turns the load options into IDA switches."""
+ check("no options means no switches", _load_args({}) == "", repr(_load_args({})))
+ a = _load_args({"processor": "arm", "base": 0x8000})
+ check("a processor reaches the switches", "-parm" in a, a)
+ # -b is in PARAGRAPHS, not bytes: 0x8000 >> 4 == 0x800.
+ check("a base is converted to paragraphs", "-b800" in a, a)
+ b = _load_args({"base": "0x1000"})
+ check("a base given as a hex STRING is accepted (project files write those)",
+ "-b100" in b, b)
+ check("no base means no -b switch", "-b" not in _load_args({"processor": "arm"}),
+ _load_args({"processor": "arm"}))
+ c = _load_args({"ida_args": "-p1"})
+ check("extra ida_args are passed through", "-p1" in c, c)
+
+
+def main() -> int:
+ for fn in (t_sweeps_the_scratch_files, t_sweeps_by_stem_too,
+ t_never_the_database, t_never_the_input_itself,
+ t_relative_path_is_still_the_input, t_missing_files_are_fine,
+ t_leaves_the_neighbours_alone, t_load_args):
+ print(f"\n{fn.__name__}")
+ try:
+ 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())