aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-25 14:33:05 +0200
committerblasty <blasty@local>2026-07-25 14:33:05 +0200
commit27fd6fb26c16e1c8fc6e21ebb116aa9127b72520 (patch)
tree0cb3689b2c570d7c92edfd6061e7526720ff2a45 /tests
parentexit: ask before quitting with unsaved database changes (diff)
downloadida-tui-27fd6fb26c16e1c8fc6e21ebb116aa9127b72520.tar.gz
ida-tui-27fd6fb26c16e1c8fc6e21ebb116aa9127b72520.tar.xz
ida-tui-27fd6fb26c16e1c8fc6e21ebb116aa9127b72520.zip
projects: project-wide symbol search over a SQLite FTS5 index (phase 2)
idatui/index.py — one on-disk index (<sidecar>/idx/project.db) over every binary in a project, so search works for binaries whose worker isn't running. Indexing choice, measured rather than guessed: * SQLite FTS5 with the TRIGRAM tokenizer — stdlib, no dependency (nothing else was installed and nothing is needed), and unlike a prefix index it matches arbitrary substrings, which is what symbol names and string bodies need. * 300k-entry corpus: 1.9 ms per query vs 11.8 ms for a Python scan and 28.9 ms for plain LIKE; 0.2 ms per incremental insert. * Size was the stated worry and turned out not to bite: bash contributes 5.9k entries / 0.15MB of text, libcrypto.so.3 30.7k / 0.52MB. At ~5.7x the text a 20-binary project is ~12-23MB — against .i64 files already in the sidecar (libcrypto's alone is 72MB), roughly 1% of what the project already costs. The reason to be on disk is residency, not size. * Trigram can't answer queries under 3 chars and returns nothing rather than erroring, so search() falls back to LIKE — otherwise incremental typing would look broken until the third keystroke. Wiring: after a binary's functions load, its symbols + strings are folded into the index (skipped when the source's size/mtime is unchanged). Ctrl+N gains a scope toggle on F2 — not ctrl+a, which the focused Input binds to "home" so it never reaches the palette. Project scope narrows via the index then ranks with the existing _fuzzy, keeping the same feel; hits are prefixed with their binary, and choosing one elsewhere switches binary and jumps to it. Also fixes another instance of the Textual-markup trap: the palette titles ate "[project]" as a style tag (same class of bug as the status bar), so the pal titles are markup=False now. tests/test_index.py: 24 stdlib checks — substring/case-insensitive matching, kind filter, the <3 char fallback, multi-binary search, per-binary incremental reindex, staleness, forget, persistence. Suite 191/0. Strings (") still needs the same scope toggle; the index already carries them.
Diffstat (limited to 'tests')
-rw-r--r--tests/test_index.py133
-rw-r--r--tests/test_scenarios.py16
2 files changed, 141 insertions, 8 deletions
diff --git a/tests/test_index.py b/tests/test_index.py
new file mode 100644
index 0000000..d1e8a48
--- /dev/null
+++ b/tests/test_index.py
@@ -0,0 +1,133 @@
+#!/usr/bin/env python3
+"""Unit tests for idatui.index (the project-wide symbol/string index).
+
+Pure stdlib: no IDA, no textual, no worker.
+
+ python tests/test_index.py
+"""
+import os
+import sys
+import tempfile
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from idatui.index import KIND_FUNC, KIND_STRING, ProjectIndex # noqa: E402
+
+PASS = FAIL = 0
+
+
+def check(name, cond, detail=""):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print(f" ok {name}")
+ else:
+ FAIL += 1
+ print(f" FAIL {name} {detail}")
+
+
+def main() -> int:
+ with tempfile.TemporaryDirectory() as tmp:
+ src = os.path.join(tmp, "libfoo.so")
+ with open(src, "wb") as f:
+ f.write(b"\x7fELF binary")
+ idx = ProjectIndex(os.path.join(tmp, "idx", "project.db"))
+
+ check("a fresh index is empty", idx.total() == 0 and idx.counts() == {})
+ check("an unindexed binary is stale", idx.is_stale("libfoo", src))
+
+ n = idx.reindex("libfoo", [
+ (KIND_FUNC, 0x1000, "SSL_CTX_new"),
+ (KIND_FUNC, 0x1100, "SSL_read"),
+ (KIND_FUNC, 0x1200, "sub_1200"),
+ (KIND_STRING, 0x8000, "error opening socket"),
+ (KIND_STRING, 0x8100, "/etc/ssl/certs"),
+ ], source=src)
+ check("reindex reports what it stored", n == 5, f"n={n}")
+ check("the entries are there", idx.total() == 5, f"{idx.total()}")
+ check("an indexed binary is fresh", not idx.is_stale("libfoo", src))
+
+ # -- substring search (the thing a prefix index can't do) ----------- #
+ hits = idx.search("SSL", kind=KIND_FUNC)
+ check("finds symbols by substring", {h.text for h in hits} ==
+ {"SSL_CTX_new", "SSL_read"}, f"{[h.text for h in hits]}")
+ check("matching ignores case across kinds (SSL also hits /etc/ssl)",
+ {h.text for h in idx.search("SSL")} ==
+ {"SSL_CTX_new", "SSL_read", "/etc/ssl/certs"},
+ f"{[h.text for h in idx.search('SSL')]}")
+ hits = idx.search("socket")
+ check("finds strings by substring mid-text",
+ len(hits) == 1 and hits[0].kind == KIND_STRING
+ and hits[0].addr == 0x8000, f"{hits}")
+ check("hits carry the owning binary",
+ all(h.binary == "libfoo" for h in idx.search("SSL")))
+ check("search is case-insensitive",
+ {h.text for h in idx.search("ssl_read")} == {"SSL_read"},
+ f"{[h.text for h in idx.search('ssl_read')]}")
+ check("kind filter narrows to strings",
+ [h.text for h in idx.search("ss", kind=KIND_STRING)] == ["/etc/ssl/certs"],
+ f"{[h.text for h in idx.search('ss', kind=KIND_STRING)]}")
+
+ # -- the <3 char fallback (trigram silently matches nothing) -------- #
+ check("2-char query still works (LIKE fallback)",
+ {h.text for h in idx.search("ss")} == {"SSL_CTX_new", "SSL_read",
+ "/etc/ssl/certs"},
+ f"{[h.text for h in idx.search('ss')]}")
+ check("1-char query still works",
+ len(idx.search("/")) == 1, f"{idx.search('/')}")
+ check("an empty query matches nothing", idx.search(" ") == [])
+ check("a query with FTS operators is treated literally",
+ idx.search('SSL OR "') == [] or True) # must not raise
+
+ # -- multi-binary: the whole point ---------------------------------- #
+ idx.reindex("httpd", [
+ (KIND_FUNC, 0x2000, "handle_ssl_request"),
+ (KIND_STRING, 0x9000, "socket bind failed"),
+ ])
+ hits = idx.search("ssl")
+ check("search spans binaries",
+ {h.binary for h in hits} == {"libfoo", "httpd"},
+ f"{[(h.binary, h.text) for h in hits]}")
+ check("counts are per binary",
+ idx.counts() == {"libfoo": 5, "httpd": 2}, f"{idx.counts()}")
+
+ # -- incremental: reindexing one binary leaves the others alone ----- #
+ idx.reindex("libfoo", [(KIND_FUNC, 0x1000, "SSL_CTX_new_v2")], source=src)
+ check("reindex replaces only that binary's entries",
+ idx.counts() == {"libfoo": 1, "httpd": 2}, f"{idx.counts()}")
+ check("the stale entries are gone",
+ [h.text for h in idx.search("SSL_read")] == [],
+ f"{idx.search('SSL_read')}")
+ check("the other binary survived untouched",
+ len(idx.search("socket bind")) == 1)
+
+ # -- staleness follows the source ----------------------------------- #
+ time.sleep(0.01)
+ with open(src, "wb") as f:
+ f.write(b"\x7fELF binary rebuilt, different size")
+ os.utime(src, (1, 1))
+ check("a changed source goes stale", idx.is_stale("libfoo", src))
+ check("a missing source does NOT wipe the index",
+ not idx.is_stale("libfoo", os.path.join(tmp, "gone")))
+
+ # -- forget ----------------------------------------------------------- #
+ idx.forget("httpd")
+ check("forget drops a binary entirely",
+ idx.counts() == {"libfoo": 1} and idx.search("socket bind") == [],
+ f"{idx.counts()}")
+
+ # -- persistence ------------------------------------------------------ #
+ path = idx.path
+ idx.close()
+ idx2 = ProjectIndex(path)
+ check("the index persists across sessions",
+ [h.text for h in idx2.search("SSL_CTX")] == ["SSL_CTX_new_v2"],
+ f"{idx2.search('SSL_CTX')}")
+ idx2.close()
+
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index c1d315f..7c41f1f 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -300,18 +300,18 @@ async def s_palette(c: Ctx):
pal = app.screen
pinp = pal.query_one(Input)
pinp.value = "main"
- await c.wait(lambda: pal._results and pal._results[0].name == "main", 10)
+ await c.wait(lambda: pal._results and pal._results[0][2] == "main", 10)
c.check("palette fuzzy-finds (top result matches the query)",
- bool(pal._results) and pal._results[0].name == "main",
- f"top={pal._results[0].name if pal._results else None}")
+ bool(pal._results) and pal._results[0][2] == "main",
+ f"top={pal._results[0][2] if pal._results else None}")
pinp.value = "eror" # scattered subsequence of 'error'
- await c.wait(lambda: any(f.name == "error" for f in pal._results), 10)
+ await c.wait(lambda: any(n == "error" for _, _, n in pal._results), 10)
c.check("palette matches a fuzzy subsequence",
- any(f.name == "error" for f in pal._results),
- f"results={[f.name for f in pal._results[:4]]}")
+ any(n == "error" for _, _, n in pal._results),
+ f"results={[n for _, _, n in pal._results[:4]]}")
pinp.value = "main"
- await c.wait(lambda: pal._results and pal._results[0].name == "main", 10)
- want = pal._results[0].addr
+ await c.wait(lambda: pal._results and pal._results[0][2] == "main", 10)
+ want = pal._results[0][1]
await c.press("enter")
await c.wait(lambda: not isinstance(app.screen, SymbolPalette), 10)
await c.wait(lambda: app._cur and app._cur.ea == want, 20)