diff options
| author | blasty <peter@haxx.in> | 2026-09-25 14:28:45 +0200 |
|---|---|---|
| committer | blasty <peter@haxx.in> | 2026-09-25 14:28:45 +0200 |
| commit | 9dea7bc83ff9b3b952a761a2d0f92b75abdd6e7e (patch) | |
| tree | d0ccd92980a96250596a4aaaebccf21f0cffd74d | |
| parent | README: friendlier rewrite; move IDA connection internals to CONTRIBUTING (diff) | |
| parent | Fix parsing of pseudocode tags (diff) | |
| download | ida-tui-9dea7bc83ff9b3b952a761a2d0f92b75abdd6e7e.tar.gz ida-tui-9dea7bc83ff9b3b952a761a2d0f92b75abdd6e7e.tar.xz ida-tui-9dea7bc83ff9b3b952a761a2d0f92b75abdd6e7e.zip | |
Merge PR #2 from mrexodia: fix pseudocode/listing tag parsing, ida-nexus 0.13.0
https://github.com/blasty/ida-tui/pull/2 — pseudocode address anchors are
now split off only as the trailing /*0x…*/ our decompile tool appends, so a
marker-shaped string literal or source comment survives display and cursor
columns. The listing span walker asks the kernel (tag_skipcode) how long
each colour code is instead of assuming two bytes, so semantic group tags
with kind/TID headers stay invisible on kernels that emit them. Bumps
ida-nexus to 0.13.0.
Verified locally on IDA 9.4 + ida-nexus 0.13.0: full suite 974 passed
(955 on main + 19 new pure checks). Old vs new span walker give identical
output over 60k bash heads (no 0x36 tags on this kernel); the new walker
costs 19.8 vs 12.3 us/line, next to 7.6 us for generate_disasm_line.
| -rw-r--r-- | idatui/app.py | 26 | ||||
| -rw-r--r-- | idatui/domain.py | 15 | ||||
| -rw-r--r-- | idatui/remote_tools.py | 71 | ||||
| -rw-r--r-- | pyproject.toml | 3 | ||||
| -rw-r--r-- | tests/test_pseudocode.py | 58 | ||||
| -rw-r--r-- | tests/test_remote_tags.py | 134 | ||||
| -rw-r--r-- | uv.lock | 19 |
7 files changed, 256 insertions, 70 deletions
diff --git a/idatui/app.py b/idatui/app.py index 75dc9d1..52f474b 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -51,7 +51,7 @@ from textual.widgets import ( from textual.widgets.option_list import Option from . import diag, findings, graph, kittygfx, search -from .domain import Func, Head, ListingModel, Program, Struct +from .domain import Func, Head, ListingModel, Program, Struct, split_pseudocode_line from .edit_ctl import EditController from .errors import IDAConnectionError from .highlight import CTextArea, highlight_c @@ -193,12 +193,6 @@ _S_DECOMP_WAIT = Style(color="#7c8b9e", italic=True) # 'decompiling' label _S_DECOMP_DOTS = Style(color="#626c7a") # trailing ellipsis _S_LINK = Style(bgcolor="#233044") # split view: rows linked to the other pane's cursor -# Hex-Rays appends a `/*0xEA*/` address marker to each pseudocode line (we fetch -# with include_addresses so we have a per-line anchor). Matched here to extract -# the address and strip the marker from the display. -_ADDR_MARK_RE = re.compile(r"/\*\s*0x([0-9A-Fa-f]+)\s*\*/") -_ADDR_MARK_STRIP_RE = re.compile(r"\s*/\*\s*0x[0-9A-Fa-f]+\s*\*/") - @dataclass class BinaryState: @@ -1786,9 +1780,9 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True line_eas: list[int | None] = [] clean: list[str] = [] for ln in text.split("\n"): - eas = _ADDR_MARK_RE.findall(ln) - line_eas.append(int(eas[-1], 16) if eas else None) - clean.append(_ADDR_MARK_STRIP_RE.sub("", ln).rstrip()) + source, line_ea = split_pseudocode_line(ln) + line_eas.append(line_ea) + clean.append(source) seglists = highlight_c("\n".join(clean)) self._strips = [Strip(segs) for segs in seglists] self._texts = ["".join(seg.text for seg in segs) for segs in seglists] @@ -8225,7 +8219,7 @@ class IdaTui(App): lines = (dec.code or "").splitlines() if not (0 <= line_idx < len(lines)): return 0 - clean = _ADDR_MARK_STRIP_RE.sub("", lines[line_idx]) + clean, _ = split_pseudocode_line(lines[line_idx]) m = re.search(rf"\b{re.escape(name)}\b", clean) return m.start() if m else 0 @@ -8255,7 +8249,8 @@ class IdaTui(App): best: tuple[int, int] | None = None best_key: tuple[int, int] | None = None for i, ln in enumerate(lines): - m = pat.search(_ADDR_MARK_STRIP_RE.sub("", ln)) + clean, _ = split_pseudocode_line(ln) + m = pat.search(clean) if not m: continue # rank: nearest to the anchor; tie -> the line at/after the anchor. @@ -8277,10 +8272,9 @@ class IdaTui(App): return -1 best_idx, best_ea = -1, -1 for i, line in enumerate(dec.code.splitlines()): - for m in re.findall(r"/\*\s*0x([0-9A-Fa-f]+)\s*\*/", line): - e = int(m, 16) - if best_ea < e <= ea: - best_ea, best_idx = e, i + _, e = split_pseudocode_line(line) + if e is not None and best_ea < e <= ea: + best_ea, best_idx = e, i return best_idx @staticmethod diff --git a/idatui/domain.py b/idatui/domain.py index 1e5a863..941cc59 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -2626,6 +2626,21 @@ def _parse_xrefs(payload) -> list[Xref]: return out +_PSEUDOCODE_ANCHOR_RE = re.compile(r"[ \t]*/\*[ \t]*0x([0-9A-Fa-f]+)[ \t]*\*/[ \t]*$") + + +def split_pseudocode_line(line: str) -> tuple[str, int | None]: + """Separate the legacy trailing address annotation from a pseudocode line. + + Only the final annotation is metadata. Identical text inside a string or an + earlier source comment must survive, both for display and cursor columns. + """ + match = _PSEUDOCODE_ANCHOR_RE.search(line) + if match is None: + return line.rstrip(), None + return line[: match.start()].rstrip(), int(match.group(1), 16) + + def _parse_decompilation(ea: int, payload) -> Decompilation: if not isinstance(payload, dict): return Decompilation(ea, None, True, "unexpected payload", False, None) diff --git a/idatui/remote_tools.py b/idatui/remote_tools.py index 6681379..7176015 100644 --- a/idatui/remote_tools.py +++ b/idatui/remote_tools.py @@ -14,8 +14,8 @@ injected BODY, which the IDA Nexus port deletes): Keeping the originals rather than paraphrasing them is deliberate: this is the most performance-tuned and most behaviour-sensitive code in the project (the -span walker is a single regex pass because a per-character loop was the most -expensive thing the listing did, and the cycle only offers stops that change +span walker skips text runs with a regex because a per-character loop was the +most expensive thing the listing did, and the cycle only offers stops that change what you see). A re-implementation drifts from it silently. This file is SOURCE SHIPPED AS TEXT to the database process; it is never @@ -252,7 +252,7 @@ _IDATUI_TAGS = None _IDATUI_OPND_TAGS = None -_IDATUI_CTL = None # re: a tag = one of three control chars plus its argument +_IDATUI_CTL = None # re: find a control byte; the kernel determines its size _IDATUI_TAGINFO = None @@ -292,12 +292,11 @@ def _idatui_spans(line): if _IDATUI_CTL is None: import re as _re - # One capturing split gives [text, tag, text, tag, ..., text] in a - # single C pass. A per-character python loop over the line used to be - # the most expensive thing the `heads` tool did, and a line is ~54 - # characters but only ~13 tags -- everything between two tags is already - # exactly one span's worth of text. - _IDATUI_CTL = _re.compile("([\\x01\\x02\\x03](?s:.))") + # Search for control bytes, not two-byte pairs: semantic tags have + # kernel-dependent headers, possibly containing control bytes themselves. + # Still skip ordinary text in C rather than walking it character by + # character (previously the most expensive part of `heads`). + _IDATUI_CTL = _re.compile("[\\x01-\\x04]") if _IDATUI_TAGINFO is None: _IDATUI_TAGINFO = { tag: (_IDATUI_TAGS.get(tag, "text"), _IDATUI_OPND_TAGS.get(tag)) @@ -305,43 +304,41 @@ def _idatui_spans(line): } taginfo = _IDATUI_TAGINFO plain_tag = ("text", None) - on, off, esc = "\x01", "\x02", "\x03" + on, off, esc, inv = "\x01", "\x02", "\x03", "\x04" addr_tag = chr(getattr(ida_lines, "COLOR_ADDR", 0x28)) - addr_len = int(getattr(ida_lines, "COLOR_ADDR_SIZE", 16)) - parts = _IDATUI_CTL.split(line) + semantic_tag = "\x36" + skipcode = ida_lines.tag_skipcode spans, stack = [], [] # stack entries: (kind, operand index|None) kind, opnd = "text", None # state the current run of text belongs to pend = "" - skip = 0 # characters of an address payload still due - i, n = 0, len(parts) + i, n = 0, len(line) while i < n: - txt = parts[i] - i += 1 - if skip: - if len(txt) <= skip: - skip -= len(txt) - txt = "" - else: - txt = txt[skip:] - skip = 0 - if txt: - pend += txt - if i >= n: + match = _IDATUI_CTL.search(line, i) + if match is None: + pend += line[i:] break - pair = parts[i] - i += 1 - if skip: # a tag INSIDE an address payload: 2 chars - skip = skip - 2 if skip > 2 else 0 + at = match.start() + pend += line[i:at] + size = skipcode(line[at:]) + if size <= 0: # malformed code: retain it and always make progress + pend += line[at] + i = at + 1 continue - ch = pair[0] + i = min(at + size, n) + ch = line[at] + if ch == inv: # one byte, no following argument to consume + continue + if at + 1 >= n: + break + tag = line[at + 1] if ch == esc: # escaped literal: keep the char it guards - pend += pair[1] + pend += tag continue - tag = pair[1] - if ch == on and tag == addr_tag: - # An embedded target address, not display text: 16 hex digits that - # must not reach the screen. Deliberately NOT a span boundary. - skip = addr_len + if tag == semantic_tag or (ch == on and tag == addr_tag): + # Structural metadata is invisible and does not change colour or + # operand ownership. Older kernels emit headerless semantic groups; + # newer ones include a kind and sometimes a TID. Only skipcode knows + # the correct length, so never hard-code these payload sizes. continue if pend: spans.append([kind, pend, opnd]) diff --git a/pyproject.toml b/pyproject.toml index c16d406..d893616 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ requires-python = ">=3.11" # ida-nexus supplies GUI discovery, shared idalib workers, leases, and the # execute_python/ida-domain database surface. dependencies = [ - "ida-nexus>=0.7.0", + "ida-nexus>=0.13.0", "textual>=8", "pygments>=2", # Used directly for pseudocode highlighting. ] @@ -29,4 +29,3 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["idatui"] - diff --git a/tests/test_pseudocode.py b/tests/test_pseudocode.py new file mode 100644 index 0000000..b3b9556 --- /dev/null +++ b/tests/test_pseudocode.py @@ -0,0 +1,58 @@ +"""IDA-free regressions for pseudocode display text and address annotations.""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from idatui.domain import split_pseudocode_line # noqa: E402 + +NEEDS_IDA = False +PASS = FAIL = 0 + + +def check(name, line, expected): + global PASS, FAIL + actual = split_pseudocode_line(line) + if actual == expected: + PASS += 1 + print(f" ok {name}") + else: + FAIL += 1 + print(f" FAIL {name}: {actual!r} != {expected!r}") + + +def main(): + check( + "trailing annotation", " return 42; /*0x401000*/", (" return 42;", 0x401000) + ) + source = ' printf("before /*0x1234*/ after", 42);' + check("marker-shaped string survives", source + " /*0x401000*/", (source, 0x401000)) + check("string without annotation is not an anchor", source, (source, None)) + source = " x = /*0x1234*/ y;" + check( + "inline source comment survives", source + " /*0x401000*/", (source, 0x401000) + ) + source = " return x; /*0x1234*/" + check( + "only the final annotation is removed", + source + " /*0x401000*/", + (source, 0x401000), + ) + check( + "non-address source comment", + " return x; /* explanation */", + (" return x; /* explanation */", None), + ) + check( + "annotation whitespace and hex casing", " x;\t/* 0xAbCd */ ", (" x;", 0xABCD) + ) + check("address zero is valid", "x; /*0x0*/", ("x;", 0)) + check("blank line", " ", ("", None)) + print(f"\n{PASS} passed, {FAIL} failed") + return bool(FAIL) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_remote_tags.py b/tests/test_remote_tags.py new file mode 100644 index 0000000..2c2b8aa --- /dev/null +++ b/tests/test_remote_tags.py @@ -0,0 +1,134 @@ +"""IDA-free regressions for the source-shipped listing tag parser.""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path +from types import SimpleNamespace + +NEEDS_IDA = False +PASS = FAIL = 0 +ON, OFF, ESC, INV = "\x01", "\x02", "\x03", "\x04" +ADDR, SEM = "\x28", "\x36" +INSN, REG, NUM, OPND = "\x05", "\x29", "\x0c", "\x40" + + +def load_parser(semantic_headers=True): + # remote_tools is shipped as source and imports IDAPython at module scope. + # Compile the actual parser, not a copy, with only its IDA surface faked. + path = Path(__file__).resolve().parents[1] / "idatui" / "remote_tools.py" + tree = ast.parse(path.read_text()) + node = next( + n + for n in tree.body + if isinstance(n, ast.FunctionDef) and n.name == "_idatui_spans" + ) + calls = [] + + def skipcode(at): + calls.append(at) + if at[0] == INV: + return 1 + if at.startswith(ON + ADDR): + return 18 + if semantic_headers and at.startswith(ON + SEM): + return 19 if at[2] == "\x03" else 3 + return 2 + + ns = { + "_re": re, + "ida_lines": SimpleNamespace( + COLOR_ADDR=0x28, COLOR_ADDR_SIZE=16, tag_skipcode=skipcode + ), + "_IDATUI_TAGS": {INSN: "insn", REG: "reg", NUM: "num"}, + "_IDATUI_OPND_TAGS": {OPND: 0}, + "_IDATUI_CTL": None, + "_IDATUI_TAGINFO": None, + } + exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), "exec"), ns) + return ns["_idatui_spans"], calls + + +def tag(t, text): + return ON + t + text + OFF + t + + +def check(name, actual, expected): + global PASS, FAIL + if actual == expected: + PASS += 1 + print(f" ok {name}") + else: + FAIL += 1 + print(f" FAIL {name}: {actual!r} != {expected!r}") + + +def main(): + parse, calls = load_parser() + check( + "ordinary colours and operand coordinates", + parse( + tag(INSN, "mov") + + " " + + tag(OPND, tag(REG, "eax") + ", " + tag(NUM, "42")) + ), + ( + [ + ["insn", "mov"], + ["text", " "], + ["reg", "eax"], + ["text", ", "], + ["num", "42"], + ], + [[4, 11, 0]], + ), + ) + # Semantic metadata can itself contain bytes that look like presentation + # controls. The kernel's header length, not a two-byte regex, owns them. + name = ON + SEM + "\x01" + "main" + OFF + SEM + check( + "semantic name preserves enclosing colour", + parse(tag(INSN, "call " + name)), + ([["insn", "call main"]], []), + ) + typed = ON + SEM + "\x03" + "0123456789ABCDEF" + tag(REG, "MY_TYPE") + OFF + SEM + check( + "type-id payload is invisible and operand survives", + parse(tag(OPND, typed + "+" + tag(NUM, "4"))), + ([["reg", "MY_TYPE"], ["text", "+"], ["num", "4"]], [[0, 9, 0]]), + ) + check( + "address payload is invisible", + parse(tag(REG, "a" + ON + ADDR + "0123456789ABCDEF" + "b")), + ([["reg", "ab"]], []), + ) + check( + "inverse is a one-byte code, including at end", + parse(tag(REG, "a" + INV + "b" + INV)), + ([["reg", "ab"]], []), + ) + check( + "escaped controls are literal", + parse("a" + ESC + ON + "b"), + ([["text", "a" + ON + "b"]], []), + ) + check("kernel skipcode is consulted", bool(calls), True) + old_parse, _ = load_parser(semantic_headers=False) + check( + "headerless semantic group preserves enclosing colour", + old_parse(tag(INSN, "call " + tag(SEM, "main"))), + ([["insn", "call main"]], []), + ) + check( + "unknown presentation tags retain their text", + parse(tag("\x7f", "unknown")), + ([["text", "unknown"]], []), + ) + check("empty line", parse(""), ([], [])) + print(f"\n{PASS} passed, {FAIL} failed") + return bool(FAIL) + + +if __name__ == "__main__": + raise SystemExit(main()) @@ -27,16 +27,14 @@ wheels = [ [[package]] name = "ida-nexus" -version = "0.7.0" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ida-domain" }, - { name = "packaging" }, - { name = "zeromcp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b6/93/2f87cbd64ffc45e181542f133ba8db101c9049155b5f015d9d318f32dcfb/ida_nexus-0.7.0.tar.gz", hash = "sha256:838698c6a2456d474da833b2f4955da9f1fca4a488c95a157539a3c663a919ba", size = 220190, upload-time = "2026-08-20T21:52:18.228Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/27/47787679ae5af7e1b441ac00f47622f01c4c768d5843345cdbb9f24e3bea/ida_nexus-0.13.0.tar.gz", hash = "sha256:2742b97b05ed1a4545ed5ea8119926b83ab676e0a1cdaf8b0aaba18ced98dac2", size = 186300, upload-time = "2026-09-23T13:41:38.111Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/c2/58704fc74618c7867cf7542d3150a75a7678aae2fff7960fa8e5cc67d934/ida_nexus-0.7.0-py3-none-any.whl", hash = "sha256:a5006c7170a0a758a598b864d6fa248bf4eccbea848a30fc6a3eeeadf638d07a", size = 127656, upload-time = "2026-08-20T21:52:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e6/21b4bd9b252cd41195499a6e5f7fcc9c8db9c5f6aed52326b8e3152dad65/ida_nexus-0.13.0-py3-none-any.whl", hash = "sha256:a6566254f42b91e963417842d4147d858f669e5de50a7f145d3ad642483b1179", size = 104246, upload-time = "2026-09-23T13:41:39.371Z" }, ] [[package]] @@ -66,7 +64,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "ida-nexus", specifier = ">=0.7.0" }, + { name = "ida-nexus", specifier = ">=0.13.0" }, { name = "pygments", specifier = ">=2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.3" }, @@ -257,12 +255,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d wheels = [ { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, ] - -[[package]] -name = "zeromcp" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/d7/606457fc543e5b25f190b1513c3623cbb9683cf27d62be0656ebf2c7b26b/zeromcp-1.8.0.tar.gz", hash = "sha256:682817c488593ff4ec831f79db06415f2d2ff31db98e58e5fff9d9116c042ff3", size = 99866, upload-time = "2026-08-10T19:17:11.221Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/d9/f46820d2eca9c4c984348d9a2f39d4e59192cdaaebc12895e92d38a742f6/zeromcp-1.8.0-py3-none-any.whl", hash = "sha256:7f270a901f0e2327c369509757c455faa1eeb0513e758dbbb25f4923f089f77a", size = 23666, upload-time = "2026-08-10T19:17:10.027Z" }, -] |
