summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-08-07 00:18:55 +0200
committerblasty <blasty@local>2026-08-07 00:18:55 +0200
commitc70acf6881c83d6de1f33022dc6210b93ed86f2e (patch)
tree9b3f613e9d5bf5d82218050bdda5831cc923e782
parentworker_client: tests, and stop resurrecting a closed worker (diff)
downloadida-tui-c70acf6881c83d6de1f33022dc6210b93ed86f2e.tar.gz
ida-tui-c70acf6881c83d6de1f33022dc6210b93ed86f2e.tar.xz
ida-tui-c70acf6881c83d6de1f33022dc6210b93ed86f2e.zip
launch: the sweep could delete the file it was asked to open
_sweep_locks removes the scratch IDA unpacks beside a .i64 (.id0/.id1/.id2/ .nam/.til) when an open fails, keyed on both the full name and the stem. It never touched the .i64, which is the dangerous one everybody thinks of. It did delete the input. '.til' is an unpacked-DB suffix AND the extension of an IDA type library, so 'ida-tui mylib.til' swept its own argument out of existence -- irreversibly, on a path that runs automatically. Same for anything named *.id0/*.id1/*.id2/*.nam. Now the sweep skips whatever it was asked to open, compared as an absolute path so a relative argument is covered too. tests/test_launch.py pins the whole contract: what it takes, what it must never take (the .i64, the input, the neighbours), and what it reports. Pure, in the --fast tier. It is the right shape of test for code whose failure mode is deleting the wrong file. Also: _load_args parsed the base with bare int(), which raises on the '0x8000000' string a project file writes. Unreachable from our own CLI (which int()s first) but the asymmetry with project._as_addr was a trap, so both go through the same parser now. 813 checks; --fast is 324 in 3.4s.
-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())