diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_formats.py | 122 | ||||
| -rw-r--r-- | tests/test_index.py | 71 | ||||
| -rw-r--r-- | tests/test_pool.py | 29 | ||||
| -rw-r--r-- | tests/test_project.py | 75 | ||||
| -rw-r--r-- | tests/test_project_ui.py | 72 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 87 |
6 files changed, 455 insertions, 1 deletions
diff --git a/tests/test_formats.py b/tests/test_formats.py new file mode 100644 index 0000000..ab72e8d --- /dev/null +++ b/tests/test_formats.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Format sniffing + load-switch construction (pure stdlib, no IDA).""" +import os +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from idatui.formats import (PROCESSORS, load_args, # noqa: E402 + needs_load_options, sniff) + +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 main() -> int: + with tempfile.TemporaryDirectory() as tmp: + def w(name, data): + p = os.path.join(tmp, name) + with open(p, "wb") as f: + f.write(data) + return p + + # -- formats IDA can load on its own: never interrupt the user -------- # + for name, head in (("elf", b"\x7fELF\x02\x01\x01"), ("pe", b"MZ\x90\x00"), + ("macho", b"\xcf\xfa\xed\xfe"), ("dex", b"dex\n035\x00"), + ("wasm", b"\x00asm\x01\x00")): + p = w(name, head + bytes(60)) + check(f"{name} is recognised (no dialog)", + sniff(p) is not None and not needs_load_options(p), f"{sniff(p)}") + + # -- the case this exists for ----------------------------------------- # + blob = w("fw.bin", bytes(range(256)) * 4) + check("a headerless blob is not recognised (ask)", + sniff(blob) is None and needs_load_options(blob)) + + # Intel HEX / S-records are text containers IDA does load. + hexf = w("f.hex", b":10010000214601360121470136007EFE09D2190140\n") + check("Intel HEX is recognised", sniff(hexf) == "Intel HEX", f"{sniff(hexf)}") + srec = w("f.s19", b"S00600004844521B\nS1130000285F245F2212226A000424290008237C\n") + check("Motorola S-records are recognised", + sniff(srec) == "Motorola S-record", f"{sniff(srec)}") + + # A binary that merely STARTS with ':' is not Intel HEX. Text formats are + # only accepted when the whole head is printable, or half the firmware in + # the world gets mis-detected on one byte. + colon = w("colon.bin", b":\x00\xff\xfe\x01\x02" + bytes(58)) + check("a blob starting with ':' is not mistaken for Intel HEX", + sniff(colon) is None, f"{sniff(colon)}") + + check("an empty file is not recognised", sniff(w("empty", b"")) is None) + check("a missing file never asks (nothing to load)", + not needs_load_options(os.path.join(tmp, "nope"))) + check("a directory never asks", not needs_load_options(tmp)) + + # -- switch construction ------------------------------------------------- # + # -b is in PARAGRAPHS: 0x8000000 >> 4 == 0x800000. Getting this wrong loads + # the image 16x off and every address in the database is wrong. + check("base is converted to paragraphs", + load_args("arm", 0x8000000) == "-parm -b800000", + load_args("arm", 0x8000000)) + check("processor alone", load_args("mipsb") == "-pmipsb", load_args("mipsb")) + check("base alone", load_args("", 0x10000) == "-b1000", load_args("", 0x10000)) + check("base 0 emits no switch (it's the default)", + load_args("arm", 0) == "-parm", load_args("arm", 0)) + check("nothing in, nothing out", load_args() == "") + check("extra switches pass through", + load_args("arm", 0, "-T binary") == "-parm -T binary") + + check("the processor list leads with the common targets", + [n for n, _ in PROCESSORS[:3]] == ["arm", "armb", "metapc"], + f"{[n for n, _ in PROCESSORS[:3]]}") + + # Every offered name must have been checked against a real IDA, because a + # wrong one is REJECTED (rc=4) with nothing useful said — handing the user a + # dead end from inside the dialog meant to rescue them. This list is the + # output of tools/verify_procs.py; adding a processor without re-running it + # fails here on purpose. + VERIFIED = { + "arm", "armb", "metapc", "mipsl", "mipsb", "ppc", "ppcl", "sh4", "68k", + "riscv", "tricore", "xtensa", "avr", "z80", "tms320c6", "m32r", "arc", + "h8300", "sparcb", "sparcl", "s390", + } + offered = {n for n, _ in PROCESSORS} + check("every offered processor name is IDA-verified", + offered <= VERIFIED, f"unverified: {sorted(offered - VERIFIED)}") + + # These are module FILENAMES or common aliases, not -p names. IDA refuses + # them; they were in the list until a real run said otherwise. + for wrong in ("h8", "sparc", "arm64", "aarch64", "mips", "m68k"): + check(f"{wrong!r} is not offered (IDA rejects it)", wrong not in offered) + + # ...but someone WILL type them, so the labels have to carry the alias. + def finds(q): + ql = q.lower() + return [n for n, d in PROCESSORS if ql in n.lower() or ql in d.lower()] + check("typing 'arm64' still finds ARM", "arm" in finds("arm64"), f"{finds('arm64')}") + check("typing 'aarch64' still finds ARM", "arm" in finds("aarch64")) + check("typing 'm68k' still finds 68k", "68k" in finds("m68k"), f"{finds('m68k')}") + check("typing 'mips' finds both endiannesses", + set(finds("mips")) == {"mipsl", "mipsb"}, f"{finds('mips')}") + check("every processor entry has a human label", + all(n and d for n, d in PROCESSORS)) + check("endianness is spelled out where it matters", + all(any(w in d.lower() for w in ("endian",)) + for n, d in PROCESSORS if n in ("arm", "armb", "mipsb", "mipsl"))) + + print(f"\n{PASS} passed, {FAIL} failed") + return 1 if FAIL else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_index.py b/tests/test_index.py index d1e8a48..e14f715 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -11,7 +11,8 @@ 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 +from idatui.index import (KIND_EXPORT, KIND_FUNC, KIND_IMPORT, # noqa: E402 + KIND_STRING, ProjectIndex) PASS = FAIL = 0 @@ -116,6 +117,74 @@ def main() -> int: idx.counts() == {"libfoo": 1} and idx.search("socket bind") == [], f"{idx.counts()}") + # -- cross-binary linkage join (phase 3) ------------------------------ # + idx.reindex("app", [ + (KIND_FUNC, 0x1000, "main"), + (KIND_IMPORT, 0x2000, "strcmp"), + (KIND_IMPORT, 0x2008, "read"), + (KIND_IMPORT, 0x2010, "SSL_new"), + ]) + idx.reindex("libc", [ + (KIND_EXPORT, 0x8000, "strcmp"), + (KIND_EXPORT, 0x8100, "read"), + (KIND_EXPORT, 0x8200, "pread"), + (KIND_EXPORT, 0x8300, "read_line"), + (KIND_FUNC, 0x8000, "strcmp"), + ]) + idx.reindex("libssl", [(KIND_EXPORT, 0x9000, "SSL_new")]) + + prov = idx.providers("strcmp", exclude="app") + check("an import resolves to the binary that exports it", + [(h.binary, h.addr) for h in prov] == [("libc", 0x8000)], + f"{[(h.binary, hex(h.addr)) for h in prov]}") + + # The whole point of exact(): substring search would drag in pread, + # read_line and thread_start, and 'read' is also below the trigram floor + # for some engines — an import must bind to its exact name or nothing. + prov = idx.providers("read", exclude="app") + check("the join is exact, not substring", + [(h.binary, h.addr) for h in prov] == [("libc", 0x8100)], + f"{[(h.binary, h.text) for h in prov]}") + + check("a short name still resolves (below the trigram floor)", + [h.binary for h in idx.providers("SSL_new", exclude="app")] == ["libssl"], + f"{idx.providers('SSL_new')}") + + check("an unprovided import resolves to nothing", + idx.providers("dlopen", exclude="app") == []) + + check("exclude keeps a binary from resolving to itself", + idx.providers("strcmp", exclude="libc") == [], + f"{idx.providers('strcmp', exclude='libc')}") + + imp = idx.importers("strcmp") + check("the reverse join finds who imports an export", + [(h.binary, h.addr) for h in imp] == [("app", 0x2000)], + f"{[(h.binary, hex(h.addr)) for h in imp]}") + + check("kind keeps functions out of the linkage join", + [h.binary for h in idx.providers("strcmp")] == ["libc"], + "a KIND_FUNC row named strcmp must not answer as an export") + + idx.forget("libc") + check("forgetting a provider unresolves its imports", + idx.providers("strcmp", exclude="app") == []) + + # -- ELF symbol versioning -------------------------------------------- # + # The importer sees strrchr@@GLIBC_2.2.5 while the provider may export a + # different spelling; raw names would resolve almost nothing. link_name + # cuts at the first '@' so both sides meet on the bare symbol. + from idatui.domain import link_name + check("link_name strips an ELF version suffix", + link_name("strrchr@@GLIBC_2.2.5") == "strrchr", + link_name("strrchr@@GLIBC_2.2.5")) + check("link_name leaves an unversioned name alone", + link_name("strrchr") == "strrchr") + check("link_name handles a single-@ version", + link_name("SSL_new@OPENSSL_3.0.0") == "SSL_new") + check("link_name doesn't eat a leading @", + link_name("@weird") == "@weird", link_name("@weird")) + # -- persistence ------------------------------------------------------ # path = idx.path idx.close() diff --git a/tests/test_pool.py b/tests/test_pool.py index d4347d9..5c6e2c4 100644 --- a/tests/test_pool.py +++ b/tests/test_pool.py @@ -155,6 +155,35 @@ def main() -> int: check("default budget is derived, not a fixed worker count", pool3.budget_mb >= 256, pool3.budget_mb) + # -- prewarm: speculative, and never at the cost of a real binary ------ # + with tempfile.TemporaryDirectory() as tmp: + proj = _mkproject(tmp, n=3) + made2 = {} + + def spawn2(ref, ttl): + c = FakeClient(ref, mem=100) + made2[ref.label] = c + return c + + pool = WorkerPool(proj, budget_mb=250, spawn=spawn2, + mem_fn=lambda c: c.mem) + labels = [r.label for r in proj.refs] + a, b, c_ = labels[0], labels[1], labels[2] + pool.get(a) + pool.set_active(a) + check("prewarm warms a binary when the budget has room", + pool.prewarm(b) is True and b in pool.resident(), f"{pool.resident()}") + check("prewarm is a no-op for something already resident", + pool.prewarm(b) is False) + # 2 x 100MB resident, estimate 100 more -> 300 > 250: must refuse + check("prewarm refuses rather than making room", + pool.prewarm(c_) is False and c_ not in pool.resident(), + f"resident={pool.resident()} mem={pool.memory_mb()}/{pool.budget_mb}") + check("refusing to prewarm evicts nothing", + set(pool.resident()) == {a, b}, f"{pool.resident()}") + check("prewarm ignores a label outside the project", + pool.prewarm("nope") is False) + print(f"\n{PASS} passed, {FAIL} failed") return 1 if FAIL else 0 diff --git a/tests/test_project.py b/tests/test_project.py index 1f93d4a..91fd250 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -124,6 +124,41 @@ def main() -> int: extra = _bin(os.path.join(src, "extra")) proj2.add(extra, label="extra") check("add() appends a binary", proj2.by_label("extra") is not None) + + # -- re-adding must not duplicate (matched by resolved path) --------- # + n = len(proj2.refs) + proj2.add(extra) + check("re-adding the same path is a no-op", len(proj2.refs) == n, + f"{[r.label for r in proj2.refs]}") + os.chdir(src) + proj2.add("./extra") # same file, relative + proj2.add(os.path.join(src, "..", "src", "extra")) # same file, messy + check("a different spelling of the same path is a no-op", + len(proj2.refs) == n, f"{[r.label for r in proj2.refs]}") + link = os.path.join(src, "extra_link") + os.symlink(extra, link) + proj2.add(link) + check("a symlink to an existing binary is a no-op", + len(proj2.refs) == n, f"{[r.label for r in proj2.refs]}") + + # ...but a DIFFERENT file with the same basename must still be added + other_dir = os.path.join(tmp, "other2") + os.makedirs(other_dir) + twin = _bin(os.path.join(other_dir, "extra"), b"\x7fELF a different extra") + proj2.add(twin) + check("a same-named file from another directory IS added", + len(proj2.refs) == n + 1 + and proj2.by_source(twin) is not None + and proj2.by_source(extra) is not proj2.by_source(twin), + f"{[(r.label, r.source) for r in proj2.refs[-2:]]}") + check("the twins get distinct labels", + len({r.label for r in proj2.refs}) == len(proj2.refs), + f"{[r.label for r in proj2.refs]}") + check("create() also drops repeats on the command line", + len(Project.create(os.path.join(tmp, "dup.json"), + [extra, "./extra", extra]).refs) == 1) + os.chdir(tmp) + proj2.remove(proj2.by_source(twin).label) check("remove() drops one", proj2.remove("extra") and proj2.by_label("extra") is None) @@ -150,6 +185,46 @@ def main() -> int: except ProjectError: check("staging a missing binary raises ProjectError", True) + # -- load options for headerless blobs --------------------------------- # + with tempfile.TemporaryDirectory() as tmp: + src = os.path.join(tmp, "src"); os.makedirs(src) + blob = os.path.join(src, "fw.bin") + with open(blob, "wb") as f: + f.write(b"\x00" * 64) + proj = Project.create(os.path.join(tmp, "p.json"), [blob], name="p", + load={"processor": "arm", "base": 0x8000000}) + r = proj.refs[0] + check("create() records load options per binary", + r.processor == "arm" and r.base == 0x8000000, + f"proc={r.processor!r} base={r.base:#x}") + # -b is PARAGRAPHS: 0x8000000 >> 4 == 0x800000. Getting this wrong loads + # the image 16x too high and every address in the database is wrong. + check("base is converted to IDA's paragraph units", + r.load_args == "-parm -b800000", r.load_args) + + proj2 = Project.load(proj.path) + check("load options survive a round-trip through the file", + proj2.refs[0].load_args == "-parm -b800000", + proj2.refs[0].load_args) + + blob2 = os.path.join(src, "other.bin") + with open(blob2, "wb") as f: + f.write(b"\x00" * 64) + r2 = proj2.add(blob2, load={"processor": "mipsb"}) + check("add() takes load options too", + r2.load_args == "-pmipsb", r2.load_args) + + # a normal ELF needs none of this and must pass nothing + check("a binary with no load options passes no switches", + Project.create(os.path.join(tmp, "q.json"), [blob], + name="q").refs[0].load_args == "") + + # addresses get written by hand, so accept how people write them + proj3 = Project.create(os.path.join(tmp, "r.json"), [blob], name="r", + load={"base": "0x1000"}) + check("a base given as a hex STRING is parsed", + proj3.refs[0].base == 0x1000, f"{proj3.refs[0].base}") + print(f"\n{PASS} passed, {FAIL} failed") return 1 if FAIL else 0 diff --git a/tests/test_project_ui.py b/tests/test_project_ui.py index 731811d..54c06bd 100644 --- a/tests/test_project_ui.py +++ b/tests/test_project_ui.py @@ -168,6 +168,78 @@ async def run(bins): await pilot.press("escape") await pilot.pause(0.2) + # -- a cross-binary jump is not a one-way door ----------------- # + # Nav history is per-binary, so arriving in another binary lands you + # in an empty history. Esc must fall through to the binary you came + # from, or a project search hit (and, since phase 3, following an + # import) strands you. + here, there = app._binary, (first if app._binary == second else second) + hops0 = len(app._hops) + target = app._index.search("main", limit=200) + tgt = next((h for h in target if h.binary == there), None) + if tgt is None: + check("cross-binary jump records a hop", False, "no hit in the other binary") + else: + app._switch_then_goto(tgt.binary, tgt.addr) + jumped = await settle(lambda: app._binary == there + and app._func_index is not None + and app._func_index.complete, 180) + check("a project hit switches to the other binary", jumped, + f"binary={app._binary} want={there}") + check("the jump records where it came from", + len(app._hops) == hops0 + 1 and app._hops[-1] == here, + f"hops={app._hops}") + # spend the local history first, then Esc must cross back + for _ in range(6): + if not app._hops or app._binary != there: + break + await pilot.press("escape") + await pilot.pause(0.6) + returned = await settle(lambda: app._binary == here, 180) + check("Esc crosses back to the binary the jump came from", + returned, f"binary={app._binary} want={here} hops={app._hops}") + check("the hop is consumed, not repeated", + not app._hops, f"hops={app._hops}") + + # -- xrefs: callers in OTHER project binaries ------------------ # + # xrefs_to only sees this database, so an exported function looks + # unused from the inside even when the rest of the project calls it. + # The selection rule is "only for a symbol we actually export"; two + # executables share no linkage, so here it must stay quiet. + from idatui.app import XrefsScreen + fake = app._foreign_importers(app._cur.ea, "strrchr", None) + check("no cross-binary callers for a symbol this binary doesn't export", + fake == [], f"{fake}") + + # The routing a real cross-binary caller takes: the dialog carries a + # (binary, addr) payload instead of a bare address, and choosing it + # goes through the same switch path as a search hit — hop included, + # so Esc comes back. + where_from = app._binary + other = first if where_from == second else second + hit = next((h for h in app._index.search("main", limit=200) + if h.binary == other), None) + if hit is None: + check("a cross-binary xref jumps to the other binary", False, + "no symbol found in the other binary") + else: + hops0 = len(app._hops) + app.push_screen( + XrefsScreen("xrefs to fake", [((hit.binary, hit.addr), + f"{hit.addr:08X} import [{hit.binary}]")]), + app._on_xref_chosen) + await settle(lambda: isinstance(app.screen, XrefsScreen), 20) + await pilot.press("enter") + jumped = await settle(lambda: app._binary == other + and app._func_index is not None + and app._func_index.complete, 180) + check("a cross-binary xref jumps to the other binary", jumped, + f"binary={app._binary} want={other}") + check("and records a hop so Esc returns", + len(app._hops) == hops0 + 1 and app._hops[-1] == where_from, + f"hops={app._hops}") + app._hops.clear() + # -- and the same toggle for strings --------------------------- # from idatui.app import StringsPalette await pilot.press("quotation_mark") diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 091ddc8..6ccbf0d 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -309,6 +309,15 @@ async def s_palette(c: Ctx): c.check("palette matches a fuzzy subsequence", any(n == "error" for _, _, n in pal._results), f"results={[n for _, _, n in pal._results[:4]]}") + # Every other query here is lowercase, which is how a case bug hid for so + # long: the name was lowered but the query wasn't, so ONE capital matched + # nothing. Invisible on lowercase C symbols, fatal on a library that + # capitalises (PEM_read_bio found 0 of 10093 functions in libcrypto). + pinp.value = "MAIN" + await c.wait(lambda: any(n == "main" for _, _, n in pal._results), 10) + c.check("palette matching is case-insensitive in BOTH directions", + any(n == "main" 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][2] == "main", 10) want = pal._results[0][1] @@ -318,6 +327,21 @@ async def s_palette(c: Ctx): c.check("selecting a palette entry opens that function", bool(app._cur) and app._cur.ea == want, f"cur={app._cur.ea if app._cur else None}") + # Re-open the function we are ALREADY standing on. That used to append an + # identical nav entry, and the extra Esc it bought popped the stack without + # changing anything on screen — a dead keypress, which is precisely what + # "back is broken" feels like from the keyboard. + depth = len(app._nav) + await c.press("ctrl+n") + await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10) + pal2 = app.screen + pal2.query_one(Input).value = "main" + await c.wait(lambda: pal2._results and pal2._results[0][2] == "main", 10) + await c.press("enter") + await c.wait(lambda: not isinstance(app.screen, SymbolPalette), 10) + await c.pause(0.4) + c.check("re-opening the current function doesn't stack a duplicate", + len(app._nav) == depth, f"nav {depth} -> {len(app._nav)}") await c.press("ctrl+n") await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10) await c.press("escape") @@ -325,6 +349,52 @@ async def s_palette(c: Ctx): c.check("Esc closes the palette", not isinstance(app.screen, SymbolPalette)) +@scenario("load_options") +async def s_load_options(c: Ctx): + """The dialog must never appear for a file IDA can load itself — the whole + suite runs on an ELF, so a false positive here would block every run.""" + from idatui.app import LoadOptionsScreen + app = c.app + c.check("no load dialog for a recognised binary", + not isinstance(app.screen, LoadOptionsScreen), + f"screen={type(app.screen).__name__}") + c.check("and the app agrees it shouldn't ask", + not app._should_ask_load_options()) + # The dialog itself, driven directly: it has to come back with switches the + # worker can use, and -b has to be paragraphs. + from idatui.formats import load_args, needs_load_options, sniff + c.check("the running target sniffs as a real format", + sniff(app._open_path) is not None and not needs_load_options(app._open_path), + f"{sniff(app._open_path)}") + c.check("dialog output converts a base to paragraphs", + load_args("arm", 0x8000000) == "-parm -b800000") + + # Tab is a PRIORITY app binding (disasm<->pseudocode), so it fired even with + # a modal up and nothing in a dialog could be tabbed to. That is why the load + # dialog's address field was unreachable — and it was broken in every other + # modal too. + from idatui.app import LoadOptionsScreen + app.push_screen(LoadOptionsScreen("/tmp/probe.bin", 1234)) + await c.wait(lambda: isinstance(app.screen, LoadOptionsScreen), 10) + sc = app.screen + first = app.focused + await c.press("tab") + await c.pause(0.2) + c.check("Tab moves focus inside a modal instead of toggling the view", + app.focused is not first and isinstance(app.screen, LoadOptionsScreen), + f"focus={getattr(app.focused, 'id', None)}") + c.check("Tab in the load dialog lands on the address field", + getattr(app.focused, "id", None) == "load-base", + f"focus={getattr(app.focused, 'id', None)}") + await c.press("tab") + await c.pause(0.2) + c.check("Tab again returns to the processor filter", + getattr(app.focused, "id", None) == "pal-input", + f"focus={getattr(app.focused, 'id', None)}") + await c.press("escape") + await c.wait(lambda: not isinstance(app.screen, LoadOptionsScreen), 10) + + @scenario("command_palette") async def s_command_palette(c: Ctx): app = c.app @@ -887,6 +957,23 @@ async def s_view_toggle(c: Ctx): styled = any(seg.style is not None and seg.style.color is not None for strip in dec._strips[:min(dec.total, 200)] for seg in strip) c.check("pseudocode is syntax-highlighted", styled) + # Cancelling a prompt must hand focus back to the pane you were READING. + # _code_view() used to choose on _pref, which was only ever "listing", so it + # focused the hidden listing and the pseudocode stopped answering the + # keyboard — arrows did nothing at all until you clicked. + dec.focus() + await c.pause(0.05) + line0 = dec.cursor + await c.press("g") + await c.wait(lambda: app.query_one("#goto", Input).display, 10) + await c.press("escape") + await c.wait(lambda: not app.query_one("#goto", Input).display, 10) + await c.press("down") + await c.press("down") + await c.pause(0.2) + c.check("cancelling goto leaves focus in the pseudocode (arrows still work)", + dec.cursor > line0, + f"cursor {line0} -> {dec.cursor} focus={type(app.focused).__name__}") dec.focus() # Tab only toggles the view from a code pane; elsewhere it's await c.pause(0.05) # focus-next, which would silently leave us in decomp await c.press("tab") |
