summaryrefslogtreecommitdiffstats
path: root/.auto/check_edit.py
blob: 2472c84678869738dfe9b70949577f65657a24b0 (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
"""Correctness gate for the listing after an item edit (run by .auto/checks.sh).

An item edit (`c`/`d`/`u`/`p`) changes structure, but only locally: every head
in front of it keeps its address and its row number. So `Program.bump_items(ea)`
keeps the walk up to there instead of discarding the model — worth 257x on a big
binary (4.9s to make one byte into data, against 19ms).

Keeping *anything* across a structural edit is the risky half of that, and it
fails silently: the pane shows rows that are no longer what the database says.
So this drives real edits and compares the kept model against one built from
scratch, row for row — narrow reads (what painting does) and wide ones (what
building the search body does).

The staged database is a throwaway copy and is never saved, so the edits here do
not need undoing and can be as destructive as they like.

    ~/ida-venv/bin/python .auto/check_edit.py [targets/echo]
"""
from __future__ import annotations

import os
import shutil
import sys

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

from bench import stage  # noqa: E402
from idatui.domain import Program  # noqa: E402
from idatui.worker_client import WorkerClient  # noqa: E402


def snapshot(model, total: int, wide: bool):
    if wide:
        rows = []
        for base in range(0, total, 4096):
            rows.extend(model.window(base, min(4096, total - base)))
    else:
        rows = [model.get(i) for i in range(total)]
    return [(h.ea, h.kind, h.size, h.text, h.name) if h else None for h in rows]


def main() -> int:
    target = sys.argv[1] if len(sys.argv) > 1 else "targets/echo"
    d, path = stage(os.path.join(ROOT, target))
    fails: list[str] = []
    client = WorkerClient(path)
    try:
        prog = Program(client)
        idx = prog.functions()
        idx.load_all()
        funcs = sorted(idx.all_loaded(), key=lambda f: f.addr)
        if len(funcs) < 8:
            print(f"{target}: too few functions to check")
            return 1
        seg = funcs[len(funcs) // 2].addr
        model = prog.listing(seg)
        model.load_all()
        total = len(model)

        # Edits at a spread of positions: near the end (where truncation keeps
        # nearly everything), the middle, and early on (where it must give up).
        spots = [funcs[int(len(funcs) * f)].addr for f in (0.9, 0.5, 0.05)]
        for k, ea in enumerate(spots):
            # Undefine, at three sizes. This is the edit that can coalesce
            # BACKWARDS into the undefined run in front of it, which is the
            # reason truncate_from drops two pages rather than one.
            size = (1, 4, 16)[k % 3]
            kind = f"undefine {size}B"
            try:
                prog.undefine(ea, size)
            except Exception as e:  # noqa: BLE001
                fails.append(f"{kind} at {ea:#x} failed: {e}")
                continue
            prog.bump_items(ea)

            kept = prog.listing(seg)
            kept_total = None
            got_wide = None
            if kept is not None:
                kept.load_all()
                kept_total = len(kept)
                got_wide = snapshot(kept, kept_total, wide=True)
                got_narrow = snapshot(kept, kept_total, wide=False)

            # ... against a model that knows nothing about what came before.
            prog._listings.clear()
            fresh = prog.listing(seg)
            fresh.load_all()
            want = snapshot(fresh, len(fresh), wide=False)

            if kept_total != len(fresh):
                fails.append(f"{kind} at {ea:#x}: kept model has {kept_total} "
                             f"rows, a rebuild has {len(fresh)}")
            elif got_narrow != want or got_wide != want:
                which = "narrow" if got_narrow != want else "wide"
                bad = next((i for i, (a, b) in
                            enumerate(zip(got_narrow if which == "narrow"
                                          else got_wide, want)) if a != b), None)
                fails.append(
                    f"{kind} at {ea:#x}: {which} read differs from a rebuild at "
                    f"row {bad}: {(got_narrow if which == 'narrow' else got_wide)[bad]}"
                    f" vs {want[bad]}")

            model = prog.listing(seg)
            model.load_all()
            total = len(model)

        print(f"item edits: {len(spots)} edits checked against a rebuild "
              f"({total} rows), {len(fails)} problems")
        for f in fails:
            print("  FAIL", f)
        return 1 if fails else 0
    finally:
        try:
            client.close()
        except Exception:  # noqa: BLE001
            pass
        shutil.rmtree(d, ignore_errors=True)


if __name__ == "__main__":
    sys.exit(main())