aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_thumb_ui.py
blob: dce163567e163f8b7505dde8c1a16f0ee3320843 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
#!/usr/bin/env python3
"""ARM/Thumb decoding switch, against real Thumb code.

Thumb isn't a property of the bytes — it's a mode the CPU is in — so a raw image
gives IDA no way to know. At a Thumb entry point it decodes 16-bit instructions
as 32-bit ARM and produces confident nonsense, and `c` either fails or carves
garbage. `t` switches the mode.

Uses experiments/fibonacci.bin (real Thumb), so the encodings are not a guess.
Needs IDA. ~40s.
"""
import asyncio
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from textual.widgets import Static  # noqa: E402

from idatui.app import IdaTui, ListingView  # noqa: E402

PASS = FAIL = 0
BIN = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
                   "experiments", "fibonacci.bin")


def check(name, ok, detail=""):
    global PASS, FAIL
    if ok:
        PASS += 1
        print(f"  ok   {name}")
    else:
        FAIL += 1
        print(f"  FAIL {name}   {detail}")


async def wait(pred, pilot, t=240.0):
    for _ in range(int(t / 0.05)):
        await pilot.pause(0.05)
        try:
            if pred():
                return True
        except Exception:  # noqa: BLE001
            pass
    return False


async def run() -> int:
    # A fresh database every time: the T flag and the segment's addressing mode
    # are SAVED in the .i64, so a previous run would answer the question for us.
    for ext in (".i64", ".id0", ".id1", ".id2", ".nam", ".til"):
        try:
            os.remove(BIN + ext)
        except OSError:
            pass

    app = IdaTui(open_path=BIN, keepalive=False, load_args="-parm")
    async with app.run_test(size=(140, 44)) as pilot:
        await wait(lambda: app._func_index is not None
                   and app._func_index.complete, pilot)
        await wait(lambda: app._cur is not None, pilot, 60)
        lst = app.query_one(ListingView)
        lst.focus()
        lst.cursor = lst.model.index_of_ea(0)
        lst._scroll_cursor_into_view()
        await pilot.pause(0.3)
        check("starts undefined at the entry", lst.model.get(lst.cursor).kind == "unknown",
              f"{lst.model.get(lst.cursor).text!r}")

        # `c` in the wrong mode: this is the failure being fixed. It must NOT
        # quietly carve garbage — either it refuses, or whatever it makes is not
        # the Thumb prologue.
        m0 = lst.model
        await pilot.press("c")
        await pilot.pause(2.5)
        h = lst.model.get(lst.model.index_of_ea(0))
        check("`c` alone does not produce the Thumb prologue",
              h is None or h.kind != "code" or "PUSH" not in h.text.upper(),
              f"{h.text if h else None!r}")

        m1 = lst.model
        await pilot.press("t")
        await wait(lambda: lst.model is not m1 and lst.model is not None, pilot, 60)
        await pilot.pause(0.5)

        status = str(app.query_one("#status", Static).render())
        check("the status says it switched to Thumb", "Thumb" in status, status[:90])
        # Thumb doesn't exist in AArch64, and -parm on a headerless blob gives a
        # 64-bit segment, so setting T alone would change nothing and look broken.
        check("and says it forced the segment to 32-bit",
              "32-bit" in status, status[:90])

        m = lst.model
        rows = [m.get(m.index_of_ea(ea)) for ea in (0x0, 0x2, 0x4)]
        check("the entry decodes as Thumb",
              rows[0] is not None and rows[0].kind == "code"
              and "PUSH" in rows[0].text.upper(),
              f"{rows[0].text if rows[0] else None!r}")
        # 16-bit instructions: the addresses are 2 apart, which is the whole
        # point — in ARM mode these would be one 4-byte instruction.
        check("instructions are 16-bit wide",
              all(r is not None and r.kind == "code" and r.size == 2 for r in rows),
              f"{[(hex(r.ea), r.size, r.text) for r in rows if r]}")
        check("and it kept disassembling past the first one",
              sum(1 for i in range(20) if (m.get(i) or h).kind == "code") > 5,
              "expected a run of instructions, not one")

        # Toggling back must be possible — the mode is a guess and guesses get
        # revised.
        m2 = lst.model
        lst.cursor = lst.model.index_of_ea(0)
        await pilot.press("t")
        await wait(lambda: lst.model is not m2 and lst.model is not None, pilot, 60)
        await pilot.pause(0.5)
        status = str(app.query_one("#status", Static).render())
        check("`t` toggles back to ARM", "ARM @" in status, status[:80])

    print(f"\n{PASS} passed, {FAIL} failed")
    return 1 if FAIL else 0


if __name__ == "__main__":
    if not os.path.isfile(BIN):
        print(f"no such binary: {BIN}")
        raise SystemExit(2)
    raise SystemExit(asyncio.run(run()))