summaryrefslogtreecommitdiffstats
path: root/.auto/diff_spans.py
blob: aad4ebc14e4ff9892b46425bc335381398d24194 (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
#!/usr/bin/env python3
"""Differential check: the current `_idatui_spans` vs the one at a git ref.

The span walker turns IDA's colour-tagged disassembly line into (spans, ops).
It is on the hot path of every listing row, so it is worth optimising -- but its
output drives highlighting, operand marking and the cursor's column arithmetic,
so "faster" is only acceptable if it is byte-identical.

This pulls both implementations out of `server/patch_server.py` (the current
working tree, and whatever `--ref` names), runs them over every tagged line of
a real binary, and reports the first disagreement.

    /usr/bin/python3 .auto/diff_spans.py [--ref HEAD] [--target targets/bash]
                                         [--limit 60000]
"""
from __future__ import annotations

import argparse
import os
import shutil
import subprocess
import sys
import tempfile

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def load_impl(path: str, name: str):
    """Exec just the span-walker section of a patch_server.py's injected BODY.

    BODY is a normal (non-raw) triple-quoted string, so the escapes in it are
    resolved once by importing the module -- slicing the file text instead would
    compile the *undecoded* source and silently test a different program (an
    escaped backslash became a literal one, and every 's' in a comment turned
    into a space).
    """
    import importlib.util
    spec = importlib.util.spec_from_file_location(f"_ps_{name}", path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)          # IDA-free at import time
    body = mod.BODY
    a = body.index("def _idatui_head_row")
    b = body.index("def _idatui_struct_member_rows")
    g = {"__name__": name}
    exec(compile(body[a:b], name, "exec"), g)  # noqa: S102
    return g


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--ref", default="HEAD")
    ap.add_argument("--target", default="targets/bash")
    ap.add_argument("--limit", type=int, default=60000)
    a = ap.parse_args()

    d = tempfile.mkdtemp(prefix="diffspans-")
    old_path = os.path.join(d, "patch_server_old.py")
    with open(old_path, "w") as fh:
        fh.write(subprocess.run(
            ["git", "-C", ROOT, "show", f"{a.ref}:server/patch_server.py"],
            capture_output=True, text=True, check=True).stdout)
    gnew = load_impl(os.path.join(ROOT, "server", "patch_server.py"), "new")
    gold = load_impl(old_path, "old")
    new, old = gnew["_idatui_spans"], gold["_idatui_spans"]
    new_row, old_row = gnew["_idatui_head_row"], gold["_idatui_head_row"]

    binary = os.path.join(ROOT, a.target)
    tgt = os.path.join(d, os.path.basename(binary))
    shutil.copy2(binary, tgt)
    shutil.copy2(binary + ".i64", tgt + ".i64")
    try:
        import idapro
        if idapro.open_database(tgt, run_auto_analysis=False) != 0:
            raise SystemExit("could not open database")
        import ida_bytes
        import ida_lines
        import ida_segment
        import idaapi

        checked = bad = 0
        for si in range(ida_segment.get_segm_qty()):
            seg = ida_segment.getnseg(si)
            if seg is None:
                continue
            ea = seg.start_ea
            while ea < seg.end_ea and checked < a.limit:
                line = ida_lines.generate_disasm_line(ea, 0)
                if line:
                    checked += 1
                    ra, rb = old(line), new(line)
                    if ra != rb:
                        bad += 1
                        if bad <= 3:
                            print(f"SPAN MISMATCH @ {ea:#x}\n  line={line!r}\n"
                                  f"  old={ra!r}\n  new={rb!r}")
                    # The whole row, not just the spans: `text`, the spans/text
                    # agreement guard and the name all moved around too.
                    ra, rb = old_row(ea), new_row(ea)
                    if ra != rb:
                        bad += 1
                        if bad <= 3:
                            print(f"ROW MISMATCH @ {ea:#x}\n"
                                  f"  old={ra!r}\n  new={rb!r}")
                nxt = ida_bytes.get_item_end(ea)
                ea = nxt if nxt > ea else ea + 1
            if checked >= a.limit:
                break
        print(f"checked {checked} lines, {bad} mismatches")
        idapro.close_database(False)
        return 1 if bad else 0
    finally:
        shutil.rmtree(d, ignore_errors=True)


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