diff options
| author | blasty <blasty@local> | 2026-07-25 11:26:11 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-25 11:26:11 +0200 |
| commit | 54e5aab945d6c27edcaf7900c5d328384143fab2 (patch) | |
| tree | c69046f7af4f93b75ed15c5e24ba969d64a3abdf /tests | |
| parent | split: propagate pure scrolls (wheel/scrollbar) to the companion pane (diff) | |
| download | ida-tui-54e5aab945d6c27edcaf7900c5d328384143fab2.tar.gz ida-tui-54e5aab945d6c27edcaf7900c5d328384143fab2.tar.xz ida-tui-54e5aab945d6c27edcaf7900c5d328384143fab2.zip | |
projects: project model + binary staging (phase 1a)
First slice of multi-binary projects (docs/PROJECTS.md): the on-disk model, with
no runtime wiring yet.
idatui/project.py (stdlib-only, like domain/worker):
* Project.load/create/save — an explicit JSON project file listing binaries;
paths resolve relative to it, labels default to the basename and are
disambiguated on collision (they name files).
* A sidecar dir beside the project file (<stem>.idatui.d/) holds bin/ (staged
binaries), their .i64 + scratch, and idx/ for phase 2. IDA opens the STAGED
file, so nothing lands in the source tree — today targets/ carries ~244MB of
IDA litter around ~13MB of binaries, much of it stale wedge files.
* stage() copies rather than hardlinks. A hardlink is free but makes source and
staged one inode, so an in-place rebuild (cp over the path truncates instead of
replacing) would silently swap the bytes under an analysed DB with nothing to
detect it. The unit test caught exactly that. A copy also leaves the sidecar
self-contained once the sources are gone.
* Re-staging a changed source drops its now-stale DB; sweep_scratch() clears the
unpacked working files a hard-killed worker leaves behind (never the .i64).
tests/test_project.py: 27 checks, pure stdlib (no IDA/textual/worker), <1s.
docs/PROJECTS.md: the full design — the one-worker-per-DB constraint with
measured costs (bash worker = 126MB RSS/117MB PSS; libcrypto's DB is 72MB, so
residency is budgeted by MEMORY, not a worker count), the switch between
"switching needs a live worker" and "searching doesn't (cached index)", and
phases 1-4.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_project.py | 158 |
1 files changed, 158 insertions, 0 deletions
diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 0000000..1f93d4a --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Unit tests for idatui.project (the multi-binary project model + staging). + +Pure stdlib: no IDA, no textual, no worker — runs anywhere in under a second. + + python tests/test_project.py +""" +import json +import os +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from idatui.project import Project, ProjectError, SIDECAR_SUFFIX # 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 _bin(path, content=b"\x7fELF fake binary"): + with open(path, "wb") as f: + f.write(content) + return path + + +def main() -> int: + with tempfile.TemporaryDirectory() as tmp: + src = os.path.join(tmp, "src") + os.makedirs(src) + httpd = _bin(os.path.join(src, "httpd")) + libauth = _bin(os.path.join(src, "libauth.so"), b"\x7fELF lib") + pfile = os.path.join(tmp, "router-fw.json") + + # -- create / load round-trip ------------------------------------- # + proj = Project.create(pfile, [httpd, libauth], name="router-fw") + check("create() writes the project file", os.path.isfile(pfile)) + proj = Project.load(pfile) + check("load() round-trips name + binaries", + proj.name == "router-fw" and len(proj.refs) == 2, + f"name={proj.name} n={len(proj.refs)}") + check("labels default to the basename", + [r.label for r in proj.refs] == ["httpd", "libauth.so"], + f"{[r.label for r in proj.refs]}") + + # -- layout -------------------------------------------------------- # + check("sidecar sits beside the project file", + proj.sidecar == os.path.join(tmp, "router-fw" + SIDECAR_SUFFIX), + proj.sidecar) + ref = proj.by_label("httpd") + check("staged path lives in the sidecar, not the source tree", + ref.staged.startswith(proj.bin_dir) and src not in ref.staged, + ref.staged) + check("db path hangs off the staged file", ref.db == ref.staged + ".i64") + + # -- staging: hardlink, freshness ---------------------------------- # + check("a binary starts out stale (not yet staged)", proj.is_stale(ref)) + proj.stage(ref) + check("stage() materialises the binary in the sidecar", + os.path.isfile(ref.staged)) + check("stage() copies (distinct inode) so the source can't be mutated " + "through it", + os.stat(ref.staged).st_ino != os.stat(ref.source).st_ino + and open(ref.staged, "rb").read() == open(ref.source, "rb").read()) + check("a staged binary is no longer stale", not proj.is_stale(ref)) + check("source tree stays clean (no IDA artifacts beside it)", + sorted(os.listdir(src)) == ["httpd", "libauth.so"], + f"{sorted(os.listdir(src))}") + + # -- a changed source re-stages and drops the stale DB ------------- # + open(ref.db, "wb").write(b"fake i64") + open(ref.staged + ".id0", "wb").write(b"scratch") + check("has_db() sees the analysed database", proj.has_db(ref)) + # rewritten IN PLACE (same inode) — the case a hardlink would miss + _bin(ref.source, b"\x7fELF fake binary v2 (longer)") + os.utime(ref.source, (1, 1)) + check("a source rebuilt in place goes stale", proj.is_stale(ref)) + proj.stage(ref) + check("re-staging refreshes the staged bytes", + open(ref.staged, "rb").read().endswith(b"v2 (longer)")) + check("re-staging drops the now-stale database", not proj.has_db(ref)) + check("re-staging drops the stale scratch too", + not os.path.exists(ref.staged + ".id0")) + + # -- scratch sweep keeps the DB ------------------------------------ # + open(ref.db, "wb").write(b"fake i64") + for suf in (".id0", ".id1", ".nam"): + open(ref.staged + suf, "wb").write(b"x") + n = proj.sweep_scratch(ref) + check("sweep_scratch() removes the unpacked working files", n == 3, f"n={n}") + check("sweep_scratch() never touches the .i64", proj.has_db(ref)) + + # -- relative paths + label collisions ----------------------------- # + sub = os.path.join(tmp, "other") + os.makedirs(sub) + dup = _bin(os.path.join(sub, "httpd"), b"\x7fELF other httpd") + with open(pfile, "w") as f: + json.dump({"name": "p", "binaries": [ + {"path": "src/httpd"}, # relative to the project file + {"path": dup}, # same basename -> collision + {"path": libauth, "label": "auth"}, + ]}, f) + proj2 = Project.load(pfile) + check("relative paths resolve against the project file", + proj2.refs[0].source == httpd, proj2.refs[0].source) + check("colliding labels are disambiguated", + [r.label for r in proj2.refs] == ["httpd", "httpd_2", "auth"], + f"{[r.label for r in proj2.refs]}") + check("explicit labels are honoured", proj2.by_label("auth") is not None) + proj2.stage_all() + check("stage_all() stages every binary to a distinct file", + len({r.staged for r in proj2.refs}) == 3 + and all(os.path.isfile(r.staged) for r in proj2.refs)) + + # -- add / remove ---------------------------------------------------- # + extra = _bin(os.path.join(src, "extra")) + proj2.add(extra, label="extra") + check("add() appends a binary", proj2.by_label("extra") is not None) + check("remove() drops one", proj2.remove("extra") + and proj2.by_label("extra") is None) + + # -- bad input ------------------------------------------------------- # + bad = os.path.join(tmp, "bad.json") + with open(bad, "w") as f: + f.write("{not json") + try: + Project.load(bad) + check("malformed JSON raises ProjectError", False, "no raise") + except ProjectError: + check("malformed JSON raises ProjectError", True) + with open(bad, "w") as f: + json.dump({"binaries": []}, f) + try: + Project.load(bad) + check("an empty project raises ProjectError", False, "no raise") + except ProjectError: + check("an empty project raises ProjectError", True) + missing = proj2.add(os.path.join(tmp, "nope")) + try: + proj2.stage(missing) + check("staging a missing binary raises ProjectError", False, "no raise") + except ProjectError: + check("staging a missing binary raises ProjectError", True) + + print(f"\n{PASS} passed, {FAIL} failed") + return 1 if FAIL else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) |
