diff options
| -rw-r--r-- | experiments/opfmt_tools.py | 391 | ||||
| -rw-r--r-- | tests/test_rawimage_rpc.py | 45 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 464 |
3 files changed, 900 insertions, 0 deletions
diff --git a/experiments/opfmt_tools.py b/experiments/opfmt_tools.py new file mode 100644 index 0000000..535688c --- /dev/null +++ b/experiments/opfmt_tools.py @@ -0,0 +1,391 @@ +"""Tool-level checks for op_format / pc_num_format against a live idalib +database, run on the REAL tool sources (server/patch_server.py's injected BODY, +exec'd here with the @tool/@idasync decorators stubbed out). + +Faster than the pilot suite and independent of the TUI, so it is the right place +for the IDA-side edge cases: which stops a value gets offered, what an offset +does to the database, what Hex-Rays will and won't render. + + python3 experiments/opfmt_tools.py # 29 checks, ~40s +""" +import importlib.util +import os +import shutil +import sys +from typing import Annotated # noqa: F401 (the BODY annotates with it) + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO) + +src = os.path.join(REPO, "targets", "echo") +tmp = "/tmp/opfmt_tools_echo" +shutil.copy(src, tmp) +for e in ".i64 .id0 .id1 .id2 .nam .til".split(): + try: + os.remove(tmp + e) + except OSError: + pass +seed = src + ".pristine.i64" +if os.path.exists(seed): + shutil.copy(seed, tmp + ".i64") + +import idapro # noqa: E402 +idapro.enable_console_messages(False) +assert idapro.open_database(tmp, run_auto_analysis=True) == 0 +import ida_auto # noqa: E402 +ida_auto.auto_wait() + +import ida_typeinf, idaapi, ida_bytes, ida_lines # noqa: E402,F401 + +spec = importlib.util.spec_from_file_location( + "_patch", os.path.join(REPO, "server", "patch_server.py")) +patch = importlib.util.module_from_spec(spec) +spec.loader.exec_module(patch) + + +def parse_address(s): + if isinstance(s, int): + return s + s = str(s).strip() + try: + return int(s, 0) + except ValueError: + ea = idaapi.get_name_ea(idaapi.BADADDR, s) + if ea == idaapi.BADADDR: + raise ValueError(f"bad address {s!r}") + return ea + + +NS = { + "Annotated": Annotated, + "tool": lambda f: f, + "idasync": lambda f: f, + "ida_typeinf": ida_typeinf, + "parse_address": parse_address, + "_parse_type_tinfo": lambda s: None, +} +exec(compile(patch.BODY, "<idatui-ext>", "exec"), NS) +op_format = NS["op_format"] +pc_num_format = NS["pc_num_format"] +heads = NS["heads"] + +OK = FAIL = 0 + + +def check(name, cond, detail=""): + global OK, FAIL + if cond: + OK += 1 + print(f" ok {name}") + else: + FAIL += 1 + print(f" FAIL {name} {detail}") + + +def line(ea): + return NS["_idatui_line_text"](ea) + + +print("\n=== listing: cycle an immediate ===") +ea = parse_address("main") +# an instruction with a >9 literal, so hex and decimal actually look different +target = None +e = ea +while e < ea + 0x400: + c = NS["_idatui_op_candidates"](e) + for n in c: + v, _w = NS["_idatui_op_value"](e, n) + if v and v > 9 and not ida_bytes.is_mapped(v): + target = (e, n, v) + break + if target: + break + e = ida_bytes.next_head(e, ea + 0x800) +print("target:", hex(target[0]), "n=", target[1], "value", hex(target[2]), + "|", line(target[0])) +tea, tn, tv = target + +r = op_format(addr=hex(tea), mode="show") +print(" show:", r) +check("show reports the operand and its choices", + r.get("n") == tn and "hex" in r.get("choices", []), str(r)) +check("show doesn't change anything", r.get("applied") is False, str(r)) + +seen = [] +for i in range(8): + r = op_format(addr=hex(tea), mode="cycle") + seen.append((r.get("format"), r.get("text"))) + print(f" cycle -> {r.get('prev')} -> {r.get('format')}: {r.get('text')}") +check("cycling returns to where it started", + seen[0][0] == seen[len(r.get("choices", []))][0] + if len(seen) > len(r.get("choices", [])) else True, str(seen)) +check("decimal renders differently from hex", + any(s[1] != seen[0][1] for s in seen), str(seen)) + +r = op_format(addr=hex(tea), mode="dec") +check("explicit dec sticks", r.get("format") == "dec" and str(tv) in r.get("text", ""), + str(r)) +r = op_format(addr=hex(tea), mode="back") +print(" back ->", r.get("format"), r.get("text")) +check("back steps the ring the other way", r.get("format") == "hex", str(r)) +r = op_format(addr=hex(tea), mode="default") +check("default clears the user format", r.get("format") == "default", str(r)) + +print("\n=== listing: a literal that IS an address becomes a reference ===") +e, off_target = ea, None +while e < ea + 0x800: + for n in NS["_idatui_op_candidates"](e): + v, _w = NS["_idatui_op_value"](e, n) + if v and ida_bytes.is_mapped(v): + off_target = (e, n) + break + if off_target: + break + e = ida_bytes.next_head(e, ea + 0x800) +print("target:", off_target and hex(off_target[0]), "|", + off_target and line(off_target[0])) +if off_target: + import ida_name # noqa: E402 + oe, on = off_target + v, _w = NS["_idatui_op_value"](oe, on) + named = bool(ida_name.get_ea_name(v)) + r = op_format(addr=hex(oe), n=on, mode="show") + print(" ", hex(oe), "n=", on, "|", r.get("text"), r.get("choices"), + "target named:", named) + check("an unnamed target is not a cycle stop (it would invent a name)", + ("offset" in r.get("choices", [])) == named, str(r)) + r = op_format(addr=hex(oe), n=on, mode="offset") + print(" offset ->", r.get("text")) + check("but asking explicitly makes the reference", + r.get("format") == "offset" and "offset" in r.get("text", ""), str(r)) + r = op_format(addr=hex(oe), n=on, mode="show") + check("and from then on the ring includes it", + "offset" in r.get("choices", []), str(r)) + r = op_format(addr=hex(oe), n=on, mode="hex") + print(" hex ->", r.get("text")) + check("and back to a number", r.get("format") == "hex" + and "offset" not in r.get("text", ""), str(r)) + op_format(addr=hex(oe), n=on, mode="default") +else: + print(" (no literal-that-is-an-address in this function)") + +print("\n=== listing: column -> operand ===") +txt = line(tea) +spans = NS["_idatui_op_spans"](tea, txt) +print(" text:", repr(txt), "spans:", spans) +if len(spans) >= 2: + r = op_format(addr=hex(tea), col=spans[0][0], mode="show") + check("a column inside operand 0 picks operand 0 (or the first literal)", + r.get("n") in (spans[0][2], NS["_idatui_op_candidates"](tea)[0]), str(r)) + r = op_format(addr=hex(tea), col=spans[-1][0], mode="show") + check("a column inside the last operand picks it", r.get("n") == spans[-1][2], + str(r)) + +print("\n=== listing: an unmapped value refuses to become an offset ===") +r = op_format(addr=hex(tea), n=tn, mode="offset") +print(" ", r.get("error") or r) +check("offset on a non-address is refused, not invented", + bool(r.get("error")) or ida_bytes.is_mapped(tv), str(r)) + +print("\n=== listing: char is only offered when it renders as one ===") +r = op_format(addr=hex(tea), n=tn, mode="show") +check("0x%x isn't offered as a char" % tv, + ("char" in r["choices"]) == NS["_idatui_printable"](tv), str(r)) + +print("\n=== listing: a stack variable can be cycled AND put back ===") +stk = None +for f in (parse_address("main"),): + e = f + while e < f + 0x600 and stk is None: + for n in NS["_idatui_op_candidates"](e): + if NS["_idatui_op_fmt"](e, n) == "stack": + stk = (e, n) + break + e = ida_bytes.next_head(e, f + 0x600) +print(" stkvar operand:", stk and hex(stk[0]), "|", stk and line(stk[0])) +if stk: + se, sn = stk + orig = line(se) + r = op_format(addr=hex(se), n=sn, mode="cycle") + print(" cycle ->", r.get("format"), r.get("text"), "|", r.get("warn")) + check("leaving a stack variable says so, and how to undo it", + "stack" in (r.get("warn") or ""), str(r)) + ring = [op_format(addr=hex(se), n=sn, mode="cycle") + for _ in range(len(r["choices"]))] + print(" ring:", [(x["format"], x["text"]) for x in ring]) + check("the ring is the same at every step (a lap comes home)", + [x["format"] for x in ring] == r["choices"][1:] + r["choices"][:1], + f"{[x['format'] for x in ring]} vs {r['choices']}") + r = op_format(addr=hex(se), n=sn, mode="stack") + check("'stack' puts the frame variable back", + r.get("format") == "stack" and r.get("text") == orig, + f"{r.get('text')!r} want {orig!r}") +else: + print(" (no stack-variable operand found)") + +print("\n=== data item ===") +import ida_segment # noqa: E402 +seg = ida_segment.get_segm_by_name(".data") +if seg: + de = seg.start_ea + picked = None + for _ in range(24): + f = ida_bytes.get_flags(de) + if ida_bytes.is_data(f) and NS["_idatui_op_fmt"](de, 0) == "default": + v, _w = NS["_idatui_op_value"](de, 0) + if v: + picked = de + break + de = ida_bytes.next_head(de, seg.end_ea) + if de == idaapi.BADADDR: + break + print(" data item:", picked and hex(picked), "|", picked and line(picked)) + if picked: + r = op_format(addr=hex(picked), mode="dec") + print(" dec ->", r.get("text")) + check("a data value reformats too", r.get("format") == "dec", str(r)) + op_format(addr=hex(picked), mode="default") + +print("\n=== undefined bytes say what to do instead ===") +seg = ida_segment.get_segm_by_name(".rodata") or ida_segment.getnseg(0) +ue, e = None, seg.start_ea +while e < seg.end_ea and ue is None: + f = ida_bytes.get_flags(e) + if not (ida_bytes.is_code(f) or ida_bytes.is_data(f)): + ue = e + e += 1 +if ue is not None: + r = op_format(addr=hex(ue), mode="cycle") + print(" ", hex(ue), "->", r.get("error")) + check("undefined bytes are refused with the fix, not a silent no-op", + "define" in (r.get("error") or ""), str(r)) +else: + print(" (no undefined bytes)") + +print("\n=== pseudocode ===") +r = pc_num_format(addr="main", mode="show", line=-1) +print(" show without a line:", r.get("error")) +check("no line is an error, not a guess", bool(r.get("error")), str(r)) + +import ida_hexrays # noqa: E402 +cf = ida_hexrays.decompile(parse_address("main")) +sv = cf.get_pseudocode() +pcline = None +for i in range(len(sv)): + nums = NS["_idatui_pc_nums"](cf, sv[i]) + if nums and nums[0]["value"] > 9: + pcline = (i, nums[0]) + break +print(" line", pcline[0], repr(ida_lines.tag_remove(sv[pcline[0]].line).strip()), + "num:", pcline[1]) +i, num = pcline +r = pc_num_format(addr="main", line=i, mode="show") +check("show finds the literal", r.get("ea") == hex(num["ea"]), str(r)) +r = pc_num_format(addr="main", line=i, mode="hex") +print(" hex ->", r.get("text")) +check("hex renders 0x in the pseudocode", "0x" in r.get("text", ""), str(r)) +r = pc_num_format(addr="main", line=i, mode="cycle") +print(" cycle ->", r.get("format"), r.get("text")) +r = pc_num_format(addr="main", line=i, mode="char") +print(" char ->", r.get("format"), r.get("text")) +r = pc_num_format(addr="main", line=i, mode="bin") +print(" bin ->", r.get("error")) +check("binary is refused with a reason", bool(r.get("error")), str(r)) +r = pc_num_format(addr="main", line=i, mode="default") +print(" default ->", r.get("text")) +check("default restores Hex-Rays' own choice", + r.get("text") == ida_lines.tag_remove(sv[i].line).strip(), str(r)) +r = pc_num_format(addr="main", line=i, mode="show") +check("and the format reads back as default", r.get("format") == "default", str(r)) +print("\n=== pseudocode: the ring visits every stop ===") +ring = [] +for _ in range(len(r.get("choices", [])) * 2): + rr = pc_num_format(addr="main", line=i, mode="cycle") + ring.append((rr.get("format"), rr.get("text"))) +print(" ", [x[0] for x in ring]) +check("every stop in the ring is reached", + set(x[0] for x in ring) == set(r["choices"]), f"{ring} vs {r['choices']}") +check("the ring's renderings are distinct", + len({x[1] for x in ring}) >= len(r["choices"]) - 1, str(ring)) +pc_num_format(addr="main", line=i, mode="default") + +print("\n=== pseudocode: col picks the literal ===") +multi = None +for k in range(len(sv)): + nums = NS["_idatui_pc_nums"](cf, sv[k]) + if len(nums) >= 2: + multi = (k, nums) + break +if multi: + k, nums = multi + plain = ida_lines.tag_remove(sv[k].line) + print(" line", k, repr(plain.strip()), [(x["x0"], x["value"]) for x in nums]) + compact = NS["_idatui_compact"](plain) + # a column in the compacted text that lands on the SECOND number + x = nums[1]["x0"] + # map forward: find where that char went in the compacted line + col = len(NS["_idatui_compact"](plain[:x]).rstrip()) if x else 0 + r = pc_num_format(addr="main", line=k, col=col, mode="show") + print(" col", col, "->", r.get("ea"), r.get("value")) + check("a column selects the number under it", + r.get("value") == hex(nums[1]["value"]) + or r.get("value") == hex(nums[0]["value"]), str(r)) +else: + print(" (no line with two literals)") + +print("\n=== operand extents ship with every listing row ===") +r = NS["heads"](addr=hex(tea), count=6, annotate=False) +row = next((h for h in r["heads"] if int(h["ea"], 16) == tea), None) +print(" row:", {k: v for k, v in (row or {}).items() if k in ("ea", "text", "ops")}) +check("a code row carries operand extents", bool(row and row.get("ops")), str(row)) +if row and row.get("ops"): + t = row["text"] + for lo, hi, n in row["ops"]: + print(f" op{n}: {t[lo:hi]!r}") + check("the extents index the row's own text", + all(t[lo:hi].strip() for lo, hi, n in row["ops"]), str(row["ops"])) + # and they agree with what op_format picks for a column inside them + ok = True + for lo, hi, n in row["ops"]: + got = op_format(addr=hex(tea), col=(lo + hi) // 2, mode="show") + if not got.get("error") and got.get("n") != n: + ok = False + check("a column inside an extent selects that same operand", ok) + +print("\n=== the cursor on a non-formattable operand is told, not redirected ===") +regop = None +for lo, hi, n in (row or {}).get("ops", []): + if n not in NS["_idatui_op_candidates"](tea): + regop = (lo, hi, n) +if regop: + lo, hi, n = regop + r = op_format(addr=hex(tea), col=(lo + hi) // 2, mode="cycle") + print(" ", repr(row["text"][lo:hi]), "->", r.get("error")) + check("it names the operand and the one that CAN change", + bool(r.get("error")) and "operand" in r["error"], str(r)) +else: + print(" (this instruction has no register-only operand)") + +print("\n=== pseudocode: every literal located in one call ===") +pn = NS["pc_nums"](addr="main") +print(" nums:", len(pn["nums"]), "over", pn["lines"], "lines") +check("pc_nums finds literals", len(pn["nums"]) > 5, str(pn)[:200]) +multi2 = {} +for rec in pn["nums"]: + multi2.setdefault(rec["line"], []).append(rec) +two = next((v for v in multi2.values() if len(v) >= 2), None) +if two: + import ida_hexrays as _hx + cf2 = _hx.decompile(parse_address("main")) + disp = NS["_idatui_compact"](ida_lines.tag_remove(cf2.get_pseudocode()[two[0]["line"]].line)) + print(" line:", repr(disp.strip())) + for rec in two: + print(f" x{rec['x0']}..{rec['x1']} = {disp[rec['x0']:rec['x1']]!r} value {rec['value']}") + check("spans land on the literals in the DISPLAYED text", + all(disp[r0["x0"]:r0["x1"]].strip() for r0 in two), str(two)) + check("distinct literals get distinct spans", + two[0]["x0"] != two[1]["x0"], str(two)) + +print(f"\n{OK} passed, {FAIL} failed") +idapro.close_database(save=False) +sys.exit(1 if FAIL else 0) diff --git a/tests/test_rawimage_rpc.py b/tests/test_rawimage_rpc.py index 4da6348..df1166d 100644 --- a/tests/test_rawimage_rpc.py +++ b/tests/test_rawimage_rpc.py @@ -22,6 +22,7 @@ Requires: tmux or zellij, IDA (idalib). ~2min. """ import json import os +import re import subprocess import sys import tempfile @@ -118,6 +119,50 @@ def main() -> int: check("define rejects an unknown kind", bad is not None and "unknown define kind" in bad, str(bad)) + # -- opfmt (how a literal is displayed) --------------------- # + # Thumb code is full of small immediates -- the thing 'o' exists + # for -- but the ENTRY POINT hasn't got one, so find a line that + # has. `show` asks without editing, which is how a driver does + # that: the rendered text alone can't be trusted (a listing read + # before an ARM/Thumb switch shows the old decoding). + c.call("goto", target="0x0", delay_ms=0) + seen = c.call("view", lines=40).get("lines", []) + lit = None + for ln in seen: + m = re.match(r"([0-9A-F]{8})\s+(.*)", ln.get("text", "")) + if not (m and re.search(r"#(0x[0-9A-Fa-f]{2,}|[1-9]\d+)\b", + m.group(2))): + continue + ea_s = "0x" + m.group(1) + st = c.call("opfmt", mode="show", target=ea_s, delay_ms=0 + ).get("opfmt", {}).get("status", "") + if "no literal" not in st: + lit = (ea_s, st) + break + check("the blob has an immediate to reformat", lit is not None, + json.dumps([ln.get("text") for ln in seen[:8]])) + if lit is not None: + tgt, st0 = lit + check("opfmt show reports the stops without editing", + "[" in st0 and "dec" in st0, st0) + r = c.call("opfmt", mode="dec", target=tgt, delay_ms=0) + st1 = r.get("opfmt", {}).get("status", "") + check("opfmt sets a named format", "dec" in st1, st1) + r = c.call("opfmt", mode="cycle") + st2 = r.get("opfmt", {}).get("status", "") + check("opfmt cycles on from there", "\u2192" in st2, st2) + r = c.call("opfmt", mode="default") + check("opfmt hands the operand back to IDA", + "default" in r.get("opfmt", {}).get("status", ""), + r.get("opfmt", {}).get("status", "")) + badfmt = None + try: + c.call("opfmt", mode="roman") + except RpcError as e: + badfmt = str(e) + check("opfmt rejects an unknown mode", badfmt is not None + and "unknown opfmt mode" in badfmt, str(badfmt)) + # -- rename_many -------------------------------------------- # fns = c.call("functions", limit=200) ea = min(f["ea"] for f in fns) if fns else None diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 9d77680..80fdc8d 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -31,6 +31,7 @@ from idatui.app import ( # noqa: E402 HelpScreen, ListingView, QuitScreen, StringsPalette, StructEditor, SymbolPalette, XrefsScreen, _str_display, _word_occurrences, ) +from idatui.errors import IDAToolError # noqa: E402 from textual.widgets import ( # noqa: E402 DataTable, Input, OptionList, Static, TextArea, ) @@ -2300,6 +2301,469 @@ async def s_func_banners(c: Ctx): c.check("the proc header names the function", hdr is not None, f"fn={fn.name}") +def _find_literal(c, start=0, limit=150): + """(head, show) for a listing row at/after ``start`` whose literal is worth + reformatting: a value over 9, so hex and decimal actually LOOK different. + + Scans FORWARD FROM THE CURSOR rather than from row 0: the listing is + continuous over the whole segment, so row 0 is nowhere near the function + that was opened. + """ + for i in range(start, min(start + limit, len(c.lst.model))): + h = c.lst.model.get(i) + if h is None or h.kind != "code": + continue + try: + show = c.prog.op_format(h.ea, mode="show") + except Exception: # noqa: BLE001 -- no literal on this line + continue + v = show.get("value") + if v and int(v, 16) > 9 and {"hex", "dec"} <= set(show.get("choices", [])): + return h, show + return None + + +async def _park_on(c, ea, tries=25): + """Put the listing cursor on ``ea`` and make sure it STAYS there. + + An open that is still settling lands its own cursor when its worker + finishes, which silently moves a cursor a test set by hand — and then the + keypress under test edits somewhere else entirely. + """ + held = 0 + for _ in range(tries): + if c.lst._cursor_ea() == ea: + held += 1 + if held >= 3: + return True + else: + held = 0 + i = c.lst.model.index_of_ea(ea) + if i >= 0: + c.lst.cursor = i + c.lst.cursor_x = c.lst._insn_col(i) + await c.pause(0.1) + return False + + +@scenario("opfmt_listing") +async def s_opfmt_listing(c: Ctx): + """'o' cycles how the literal under the cursor is DISPLAYED (IDA's 'o'): + hex -> dec -> bin -> ... -> default, 'O' the other way, and the listing + re-renders in place.""" + app = c.app + await c.open_biggest("listing") + c.lst.model.load_all() + found = _find_literal(c, start=c.lst.cursor) + if found is None: + c.check("found a listing literal to reformat", False) + return + head, show = found + ea = head.ea + c.lst.focus() + parked = await _park_on(c, ea) + c.check("the cursor is on the literal's line", parked, + f"want {ea:#x}, cursor at {c.lst._cursor_ea():#x}") + before = head.text + try: + await c.press("o") + await c.wait(lambda: c.lst.model.index_of_ea(ea) >= 0 + and (c.lst.model.get(c.lst.model.index_of_ea(ea)) or head).text + != before, 25) + i = c.lst.model.index_of_ea(ea) + after = c.lst.model.get(i).text if i >= 0 else before + c.check("'o' re-renders the literal", after != before, + f"{before!r} -> {after!r} ea={ea:#x} show={show}") + c.check("the status names the format it moved to", + any(f in c.status() for f in show["choices"]), + f"status={c.status()!r} choices={show['choices']}") + c.check("the change is marked unsaved", app._dirty) + # 'O' walks the ring the other way: back to where we started. + await c.press("O") + await c.wait(lambda: c.lst.model.index_of_ea(ea) >= 0 + and (c.lst.model.get(c.lst.model.index_of_ea(ea)) or head).text + == before, 25) + j = c.lst.model.index_of_ea(ea) + c.check("'O' cycles back", j >= 0 and c.lst.model.get(j).text == before, + f"{c.lst.model.get(j).text if j >= 0 else None!r} want {before!r}") + # An explicit format by name (what the palette/RPC use). + r = c.prog.op_format(ea, mode="dec") + c.check("an explicit format renders decimal", + r["format"] == "dec" and str(int(show["value"], 16)) in r["text"], + str(r)) + finally: + try: + c.prog.op_format(ea, mode="default", n=show.get("n", -1)) + except Exception: # noqa: BLE001 + pass + c.prog.bump_names() + + +@scenario("opfmt_no_literal") +async def s_opfmt_no_literal(c: Ctx): + """A line with nothing to reformat says so instead of picking something.""" + await c.open_biggest("listing") + c.lst.model.load_all() + + def _banner(i): + h = c.lst.model.get(i) + return h is not None and h.kind in ("sep", "funchdr", "label") + + row = next((i for i in range(c.lst.cursor, min(c.lst.cursor + 400, + len(c.lst.model))) + if _banner(i)), None) + if row is None: + c.check("found a banner row", False) + return + c.lst.focus() + for _ in range(20): # hold it against a late-landing open + c.lst.cursor = row + await c.pause(0.05) + if c.lst.cursor == row: + break + c.check("the cursor is on a banner row", _banner(c.lst.cursor), + f"row={c.lst.cursor}") + await c.press("o") + await c.wait(lambda: "reformat" in c.status() or "format" in c.status(), 15) + c.check("'o' on a line with no literal explains itself", + "reformat" in c.status(), f"status={c.status()!r}") + + +@scenario("opfmt_refusal_is_not_swallowed") +async def s_opfmt_refusal_visible(c: Ctx): + """A refusal right after a successful format still reaches the status bar. + + A result written without priority loses to the PREVIOUS result's flash for + 8 seconds, so a refusal left the last success on the bar — and the RPC + snapshot reads that same bar, so a driver saw text that looked like the + edit had worked. + + Driven the way the RPC verb drives it: the action called directly, with no + keystroke. A keypress clears the flash on its way in, which is why pressing + 'o' hides this bug entirely and only a driver ever saw it. + """ + await c.open_biggest("listing") + c.lst.model.load_all() + found = _find_literal(c, start=c.lst.cursor) + if found is None: + c.check("found a listing literal to reformat", False) + return + head, show = found + c.lst.focus() + if not await _park_on(c, head.ea): + c.check("the cursor is on the literal's line", False) + return + try: + await c.press("o") # a success: sets the flash + await c.wait(lambda: "\u2192" in c.status(), 25) + good = c.status() + + # ... and, within the flash window, 'o' where there is nothing to do. + # The edit rebuilt the segment model, so this is a different (empty) + # one than the load_all above filled. + c.lst.model.load_all() + + def _banner(i): + h = c.lst.model.get(i) + return h is not None and h.kind in ("sep", "funchdr", "label") + + row = next((i for i in range(c.lst.cursor, + min(c.lst.cursor + 400, len(c.lst.model))) + if _banner(i)), None) + if row is None: + c.check("found a banner row to refuse on", False) + return + for _ in range(20): + c.lst.cursor = row + await c.pause(0.05) + if c.lst.cursor == row: + break + c.lst.action_op_format("cycle") # no keypress: as the RPC does it + await c.wait(lambda: c.status() != good, 15) + c.check("a refusal replaces the previous success on the status bar", + c.status() != good and "reformat" in c.status(), + f"still showing {c.status()!r}") + finally: + try: + c.prog.op_format(head.ea, mode="default", n=show.get("n", -1)) + except Exception: # noqa: BLE001 + pass + c.prog.bump_names() + + +def _styled_cols(strip, style_attr, want): + """Columns of ``strip`` whose rendered style has ``style_attr`` == want.""" + cols, x = [], 0 + for seg in strip: + st = seg.style + if st is not None and getattr(st, style_attr, None) is not None \ + and str(getattr(st, style_attr)) == want: + cols.extend(range(x, x + len(seg.text))) + x += len(seg.text) + return cols + + +@scenario("opfmt_highlight") +async def s_opfmt_highlight(c: Ctx): + """The literal 'o' would reformat is MARKED on screen before you press it. + + A line can carry several literals and the cursor picks one; without showing + which, you find out by pressing and reading the status. The marker must also + survive the cursor-line decoration, which paints the word under the cursor + (usually the same characters) and used to win. + """ + from idatui.app import _S_OPERAND + await c.open_biggest("listing") + c.lst.model.load_all() + lst = c.lst + lst.focus() + # A row with two operands, so "which one" is a real question. + row = next((i for i in range(lst.cursor, min(lst.cursor + 400, len(lst.model))) + if lst.model.get(i) is not None + and (lst.model.get(i).ops or ()) and len(lst.model.get(i).ops) >= 2), + None) + if row is None: + c.check("found a row with two operands", False) + return + h = lst.model.get(row) + if not await _park_on(c, h.ea): + c.check("parked on the two-operand row", False) + return + row = lst.model.index_of_ea(h.ea) + h = lst.model.get(row) + base = lst._insn_col(row) + want_bg = str(_S_OPERAND.bgcolor) + seen = [] + for lo, hi, n in h.ops: + lst.cursor_x = base + lo + await c.pause(0.05) + strip = lst.render_line(row - round(lst.scroll_offset.y)) + cols = _styled_cols(strip, "bgcolor", want_bg) + seen.append((n, min(cols) if cols else None, max(cols) + 1 if cols else None)) + c.check(f"operand {n} ({h.text[lo:hi]!r}) is marked when the cursor is on it", + cols and min(cols) == base + lo and max(cols) + 1 == base + hi, + f"marked={min(cols) if cols else None}.." + f"{max(cols)+1 if cols else None} want={base+lo}..{base+hi}") + c.check("the mark MOVES between the operands (it isn't the whole line)", + len({s[1] for s in seen}) == len(seen), str(seen)) + # ... and the marked operand is the one the edit acts on — either it gets + # reformatted, or the refusal names that same operand. What must never + # happen is a different operand quietly changing. + for lo, hi, n in h.ops: + lst.cursor_x = base + lo + try: + r = c.prog.op_format(h.ea, mode="show", col=lst.op_col()) + got, why = r.get("n"), "" + except IDAToolError as e: # "operand N (rsp) has no format" + m = re.search(r"operand (\d+)", e.message) + got, why = (int(m.group(1)) if m else None), e.message + c.check(f"marked operand {n} is the one acted on (or refused)", + got == n, f"marked op{n}, worker said op{got} {why}") + + +@scenario("opfmt_sticks_to_its_literal") +async def s_opfmt_sticks(c: Ctx): + """Pressing 'o' twice cycles the SAME literal, even when the line reflows. + + A reformat changes the printed width (``48`` -> ``0x30``), which moves every + literal to its right. Holding the cursor column meant the second press + landed on a different literal — you cycle one number and a neighbour + changes. + """ + app = c.app + pick = None + for fn in c.all_funcs()[:60]: + d = c.prog.decompile(fn.addr) + if d.failed or not d.code: + continue + nums = c.prog.pc_nums(fn.addr) + for line, recs in sorted(nums.items()): + # The second literal must be one whose printed WIDTH changes as it + # cycles (0x36u vs 54), or the cursor never falls off it and the + # test proves nothing. + if len(recs) >= 2 and recs[0][1] < recs[1][0] \ + and int(recs[1][2], 16) >= 16: + pick = (fn, line, recs) + break + if pick: + break + if pick is None: + c.check("found a pseudocode line with two literals", False) + return + fn, line, recs = pick + first, second = recs[0], recs[1] + target = (second[3], second[4]) # (ea, opnum) of the literal we mean + other = (first[3], first[4]) + try: + # Make it WIDE first (0x30, four characters). The cursor then sits on a + # column that stops existing when the literal is printed short (48) -- + # which is the whole failure: the next press finds no literal under the + # cursor and quietly falls back to the first one on the line. + c.prog.pc_num_format(fn.addr, mode="hex", line=line, col=second[0]) + c.prog.bump_names() + await c.open(fn.addr, "decomp") + if app._active != "decomp": + c.check("pseudocode view opened", False, f"active={app._active}") + return + dec = c.dec + dec.focus() + wide = next((r for r in dec._nums.get(line, ()) + if (r[3], r[4]) == target), None) + if wide is None or wide[1] - wide[0] < 3: + c.check("the literal is now printed wide", False, + f"nums={dec._nums.get(line)}") + return + dec.cursor, dec.cursor_x = line, wide[1] - 1 # its LAST character + dec.refresh() + await c.pause(0.05) + seen = [] + for _ in range(3): + before = dec._texts[line] + await c.press("o") + await c.wait(lambda: dec.loaded_ea == fn.addr + and line < len(dec._texts) + and dec._texts[line] != before, 30) + cur = next(((r[3], r[4]) for r in dec._nums.get(line, ()) + if r[0] <= dec.cursor_x < r[1]), None) + seen.append(cur) + c.check("every press stays on the literal we started on", + all(s == target for s in seen), + f"target={target} other={other} landed={seen} " + f"line={dec._texts[line].strip()!r}") + finally: + try: + c.prog.pc_num_format(fn.addr, mode="default", line=line, + col=second[0]) + except Exception: # noqa: BLE001 + pass + c.prog.bump_names() + + +@scenario("cursor_on_stays_visible") +async def s_cursor_on_visible(c: Ctx): + """``cursor_on`` lands somewhere you can SEE, near where you are. + + It used to scan from row 0 of the whole segment and move the cursor without + scrolling, so `drive fmt dec 18h` in main reformatted an ``18h`` 170 rows + away, off screen: the driver reported success and the pane showed a line + that hadn't changed. + """ + from idatui.rpc import cursor_on + app = c.app + fn = await c.open_biggest("listing") + c.lst.model.load_all() + lst = c.lst + lst.focus() + await c.pause(0.1) + top = round(lst.scroll_offset.y) + # A token that occurs both before the viewport and inside it. + here = next((t for t in ("rax", "rsp", "eax", "rbp", "rdi") + if any(t in (lst._line_plain(i) or "") + for i in range(top, min(top + 20, lst.total))) + and any(t in (lst._line_plain(i) or "") for i in range(0, top))), + None) + if here is None: + c.check("found a token both above and inside the viewport", False, + f"top={top}") + return + found = cursor_on(app, here) + await c.pause(0.1) + c.check(f"cursor_on({here!r}) found it", found) + vis = round(lst.scroll_offset.y) + c.check("it lands inside the viewport, not thousands of rows above", + vis <= lst.cursor < vis + lst._visible_height(), + f"cursor={lst.cursor} viewport={vis}..{vis + lst._visible_height()}") + c.check("and it searched from the viewport, not from row 0", + lst.cursor >= top, f"cursor={lst.cursor} was top={top}") + # An explicit line still wins, and lands visibly. + far = next((i for i in range(0, min(top, lst.total)) + if here in (lst._line_plain(i) or "")), None) + if far is not None: + cursor_on(app, here, line=far) + await c.pause(0.1) + v2 = round(lst.scroll_offset.y) + c.check("an explicit line is honoured AND scrolled into view", + lst.cursor == far and v2 <= far < v2 + lst._visible_height(), + f"cursor={lst.cursor} want={far} viewport={v2}") + # The `cursor` verb has the same duty: a driver that parks the cursor for + # an edit must leave it where the edit can be watched. + from idatui.rpc import place_cursor + deep = min(lst.total - 1, 900) + place_cursor(lst, deep, 0) + await c.pause(0.1) + v3 = round(lst.scroll_offset.y) + c.check("`cursor line=` scrolls to what it selected", + v3 <= deep < v3 + lst._visible_height(), + f"cursor={lst.cursor} viewport={v3}..{v3 + lst._visible_height()}") + + +@scenario("opfmt_decomp") +async def s_opfmt_decomp(c: Ctx): + """'o' in the pseudocode reformats the C literal under the cursor. Hex-Rays + keeps its own number formats, so this is a different edit from the + listing's — and the decompilation re-renders.""" + app = c.app + pick = None + for fn in c.all_funcs()[:60]: + d = c.prog.decompile(fn.addr) + if d.failed or not d.code: + continue + for i, txt in enumerate(d.code.split("\n")): + m = re.search(r"[=<>+\-*/(,]\s(\d{2,}|0x[0-9A-Fa-f]{2,})\b", txt) + if m and "//" not in txt[:m.start()]: + pick = (fn, i, m.start(1)) + break + if pick: + break + if pick is None: + c.check("found a pseudocode number to reformat", False) + return + fn, line, col = pick + await c.open(fn.addr, "decomp") + if app._active != "decomp": + c.check("pseudocode view opened", False, f"active={app._active}") + return + dec = c.dec + dec.focus() + dec.cursor, dec.cursor_x = line, col + dec.refresh() + await c.pause(0.05) + before = dec._texts[line] + try: + await c.press("o") + await c.wait(lambda: dec.loaded_ea == fn.addr and line < len(dec._texts) + and dec._texts[line] != before, 30) + c.check("'o' re-renders the pseudocode literal", + line < len(dec._texts) and dec._texts[line] != before, + f"{before!r} -> {dec._texts[line] if line < len(dec._texts) else None!r}") + c.check("the status says which format", + any(f in c.status() for f in ("hex", "dec", "oct", "char", "default")), + f"status={c.status()!r}") + finally: + try: + c.prog.pc_num_format(fn.addr, mode="default", line=line, col=col) + except Exception: # noqa: BLE001 + pass + c.prog.bump_names() + + +@scenario("opfmt_opcode_key_moved") +async def s_opfmt_opcode_key_moved(c: Ctx): + """'o' now belongs to the operand format, so the opcode-bytes column moved + to 'B' — and still cycles off/limited/full.""" + await c.open_biggest("listing") + lst = c.lst + lst.focus() + await c.pause(0.05) + modes = [lst._op_mode] + for _ in range(3): + await c.press("B") + await c.pause(0.05) + modes.append(lst._op_mode) + c.check("'B' cycles the opcode-bytes column", len(set(modes)) == 3, str(modes)) + c.check("and returns to where it started", modes[0] == modes[3], str(modes)) + + # --------------------------------------------------------------------------- # # Runner # --------------------------------------------------------------------------- # |
