diff options
| author | blasty <blasty@local> | 2026-07-25 14:33:05 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-25 14:33:05 +0200 |
| commit | 27fd6fb26c16e1c8fc6e21ebb116aa9127b72520 (patch) | |
| tree | 0cb3689b2c570d7c92edfd6061e7526720ff2a45 /tests | |
| parent | exit: ask before quitting with unsaved database changes (diff) | |
| download | ida-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.py | 133 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 16 |
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) |
