aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_blob_ui.py
blob: b30bba5b4ae04387103e298c8f1cd1f23c6f73e7 (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
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
#!/usr/bin/env python3
"""A blob that analyses to NOTHING must still be usable.

Zero functions is the normal outcome for a raw image described wrongly — and it
used to leave two empty panes and a status that said "functions still loading…",
which was a lie: loading had finished, there was simply nothing to land on.

Needs IDA (spawns a real worker). ~40s.
"""
import asyncio
import os
import sys
import tempfile

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

from textual.widgets import Input, Static  # noqa: E402

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

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}")


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:
    with tempfile.TemporaryDirectory() as tmp:
        # Random bytes so IDA finds no functions... but with REAL AArch64
        # instructions planted at a known offset. Whether arbitrary random bytes
        # happen to decode is chance, and a test that depends on chance tells you
        # nothing on the run where it fails.
        data = bytearray(os.urandom(64 * 1024))
        # -parm puts IDA in AArch64 mode, so these are A64 encodings; the ARM32
        # spelling of a nop (0xE1A00000) is NOT decodable there and made this
        # test fail for a reason that had nothing to do with what it checks.
        planted = 0x40                       # file offset -> ea 0x4040
        for k, insn in enumerate((0xD503201F,   # nop
                                  0xD503201F,   # nop
                                  0xD65F03C0)):  # ret  <- the run must stop here
            data[planted + k * 4:planted + k * 4 + 4] = insn.to_bytes(4, "little")
        blob = os.path.join(tmp, "rnd.bin")
        with open(blob, "wb") as f:
            f.write(bytes(data))

        # Skip the dialog by answering up front; this test is about what
        # happens AFTER a described blob turns out to contain nothing.
        app = IdaTui(open_path=blob, keepalive=False, load_args="-parm -b400")
        async with app.run_test(size=(140, 44)) as pilot:
            ok = await wait(lambda: app._func_index is not None
                            and app._func_index.complete, pilot)
            check("a random blob still finishes loading", ok)
            check("and really has no functions", len(app._func_index) == 0,
                  f"n={len(app._func_index)}")

            landed = await wait(lambda: app._cur is not None, pilot, 60)
            check("it lands somewhere instead of leaving empty panes", landed,
                  f"cur={app._cur}")
            lst = app.query_one(ListingView)
            check("the listing actually has rows to show",
                  lst.total > 0, f"total={lst.total}")
            check("landed at the image base", app._cur is not None
                  and app._cur.ea == 0x4000, f"{app._cur.ea if app._cur else None:#x}")

            status = str(app.query_one("#status", Static).render())
            check("the status says there are no functions (not 'still loading')",
                  "no functions" in status and "still loading" not in status,
                  status[:90])
            check("and points at the likely cause",
                  "processor" in status and "Ctrl+L" in status, status[:90])

            # The hint is a property of the database, so it must survive moving
            # around — an earlier version wrote it once and the next status
            # write erased it.
            lst.focus()
            await pilot.press("down")
            await pilot.press("down")
            await pilot.pause(0.3)
            status2 = str(app.query_one("#status", Static).render())
            check("the hint survives navigating", "no functions" in status2,
                  status2[:90])

            # -- byte-granular carving ---------------------------------- #
            # An undefined run arrives as ONE head ("db N dup(?)"). It has to
            # PRESENT as one row per byte or there is no cursor position to
            # press `c` at, which is how IDA works and the only way to find an
            # instruction stream that doesn't start at the run's first byte.
            m = lst.model
            check("an undefined run presents one row per byte",
                  m.loaded() >= 4096 and len(m._heads) < 64,
                  f"rows={m.loaded()} physical heads={len(m._heads)}")
            rows = m.window(0, 4)
            check("each row is a single addressable byte",
                  [h.ea for h in rows] == [0x4000, 0x4001, 0x4002, 0x4003]
                  and all(h.size == 1 for h in rows),
                  f"{[(hex(h.ea), h.size) for h in rows]}")
            check("and shows its value, not a placeholder",
                  all(h.text.startswith("db ") and "dup" not in h.text
                      for h in rows), f"{[h.text for h in rows]}")

            # The point of all this: land on an arbitrary byte and convert it.
            i = m.index_of_ea(0x4021)
            check("an address inside the run resolves to its own row",
                  i >= 0 and m.get(i).ea == 0x4021,
                  f"row={i} ea={m.get(i).ea if i >= 0 else None}")

            target = 0x4000 + planted        # a NOP we put there ourselves
            lst.cursor = m.index_of_ea(target)
            lst._scroll_cursor_into_view()
            await pilot.pause(0.1)
            check("the cursor sits on the byte we aimed at",
                  lst._cursor_ea() == target,
                  f"{lst._cursor_ea():#x} want {target:#x}")
            await pilot.press("c")
            await pilot.pause(2.0)
            # Defining an item REBUILDS the listing model, so re-read it from the
            # view: holding the old object shows the pre-edit rows and looks
            # exactly like the edit silently failing.
            m = lst.model
            h = m.get(m.index_of_ea(target))
            check("`c` on a chosen byte carves an instruction there",
                  h is not None and h.kind == "code",
                  f"kind={h.kind if h else None} text={h.text if h else None!r}")
            if h is not None and h.kind == "code":
                print(f"       carved {target:#x}: {h.text}")
                # `c` runs until something stops it, like IDA — one instruction
                # at a time means pressing it once per opcode for the length of
                # a routine. We planted nop/nop/nop/ret, so it must take all
                # four and stop AT the ret, not run on into the random bytes
                # after it.
                run = [m.get(m.index_of_ea(target + k * 4)) for k in range(3)]
                check("`c` keeps going until control flow ends",
                      all(x is not None and x.kind == "code" for x in run),
                      f"{[(hex(x.ea), x.kind) for x in run if x]}")
                check("and stops at the ret instead of running into junk",
                      m.get(m.index_of_ea(target + 12)).kind == "unknown",
                      f"{m.get(m.index_of_ea(target + 12)).text!r}")
                status = str(app.query_one("#status", Static).render())
                check("the status reports what the run did",
                      "3 instructions" in status and "control flow" in status,
                      status[:80])
                check("the carved row spans the instruction, not one byte",
                      h.size == 4, f"size={h.size}")
                check("bytes before it stay individually addressable",
                      m.get(m.index_of_ea(target - 1)).size == 1
                      and m.get(m.index_of_ea(target - 1)).ea == target - 1)

            # -- an edit must not move the view -------------------------- #
            # Every mutation rebuilds the model, and row indices don't survive
            # that: carving collapses four byte rows into one instruction row.
            # All the edit paths go through ViewAnchor, which remembers
            # ADDRESSES. This runs in its own app, so unlike the shared scenario
            # suite it can mutate the database freely.
            lst.cursor = m.index_of_ea(0x4200)
            lst._scroll_cursor_into_view()
            await pilot.pause(0.4)
            ctop = lst.model.get(round(lst.scroll_offset.y)).ea
            ccur = lst._cursor_ea()
            old = lst.model
            await pilot.press("semicolon")
            await wait(lambda: app.query_one("#comment", Input).display, pilot, 20)
            for ch in "note":
                await pilot.press(ch)
            await pilot.press("enter")
            await wait(lambda: lst.model is not old and lst.model is not None,
                       pilot, 30)
            await pilot.pause(0.4)
            check("commenting leaves the view where it was",
                  lst.model.get(round(lst.scroll_offset.y)).ea == ctop
                  and lst._cursor_ea() == ccur,
                  f"top {ctop:#x} -> "
                  f"{lst.model.get(round(lst.scroll_offset.y)).ea:#x}, "
                  f"cursor {ccur:#x} -> {lst._cursor_ea():#x}")

            # -- carving must not move the view -------------------------- #
            # Defining code collapses rows (four byte rows become one
            # instruction), so anything that remembers a row INDEX puts you
            # somewhere else afterwards. Scroll down far enough that there is a
            # viewport to lose, then check the top ADDRESS is unchanged.
            far = 0x4000 + 0x600
            lst.cursor = lst.model.index_of_ea(far)
            lst._scroll_cursor_into_view()
            await pilot.pause(0.4)
            top_before = lst.model.get(round(lst.scroll_offset.y)).ea
            cur_before = lst._cursor_ea()
            check("scrolled somewhere with rows above us",
                  round(lst.scroll_offset.y) > 0, f"top={lst.scroll_offset.y}")
            await pilot.press("c")
            await pilot.pause(2.5)
            m2 = lst.model
            top_after = m2.get(round(lst.scroll_offset.y)).ea
            check("carving leaves the scroll position where it was",
                  top_after == top_before,
                  f"{top_before:#x} -> {top_after:#x}")
            check("and leaves the cursor on the same address",
                  lst._cursor_ea() == cur_before,
                  f"{cur_before:#x} -> {lst._cursor_ea():#x}")

            # -- `p` after carving: the rest of the app must notice ------ #
            # The "no functions" hint was latched at load and only cleared on a
            # reload, so it kept telling you the processor/base were wrong long
            # after you'd defined a function. The function index was never
            # rebuilt either, which meant Ctrl+N couldn't find what `p` made.
            check("no functions yet, and the hint says so",
                  len(app._func_index) == 0
                  and "no functions" in str(app.query_one("#status", Static).render()),
                  f"n={len(app._func_index)}")
            lst.cursor = lst.model.index_of_ea(target)
            lst._scroll_cursor_into_view()
            await pilot.pause(0.3)
            mp = lst.model
            await pilot.press("p")
            await wait(lambda: lst.model is not mp and lst.model is not None,
                       pilot, 40)
            await wait(lambda: app._func_index is not None
                       and len(app._func_index) > 0, pilot, 60)
            check("`p` creates a function the index can see",
                  len(app._func_index) == 1, f"n={len(app._func_index)}")
            status = str(app.query_one("#status", Static).render())
            check("and the stale 'no functions' hint is gone",
                  "no functions" not in status, status[:90])
            check("the status names the function it made",
                  "created function" in status, status[:90])

            await pilot.press("ctrl+l")
            opened = await wait(lambda: isinstance(app.screen, ConfirmScreen), pilot, 20)
            check("Ctrl+L offers to reload with different options", opened,
                  f"screen={type(app.screen).__name__}")
            if opened:
                note = str(app.screen.query_one("#confirm-note", Static).render())
                # We just made a function, so it must NOT claim nothing is lost —
                # reloading throws the database away and that is now a real cost.
                check("the confirmation counts what would be lost",
                      "1 function," in note and "nothing is lost" not in note,
                      note[:80])
                await pilot.press("escape")
                await pilot.pause(0.3)
                check("declining leaves the binary open",
                      not isinstance(app.screen, ConfirmScreen) and app._cur is not None)

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


if __name__ == "__main__":
    raise SystemExit(asyncio.run(run()))