aboutsummaryrefslogtreecommitdiffstats
path: root/.auto/check_edit.py
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-07 08:01:25 +0200
committeruser <user@clank>2026-08-07 08:01:25 +0200
commit16318e4bfd939666bc45fa6f5d2c17cb8f374f3b (patch)
treee0395a59d86bb7a20899aa06b7569cf781fb1e93 /.auto/check_edit.py
parentThree redundancies in the heads walk: item flags were fetched three times per... (diff)
downloadida-tui-16318e4bfd939666bc45fa6f5d2c17cb8f374f3b.tar.gz
ida-tui-16318e4bfd939666bc45fa6f5d2c17cb8f374f3b.tar.xz
ida-tui-16318e4bfd939666bc45fa6f5d2c17cb8f374f3b.zip
An item edit (c/d/u/p) keeps the listing's walk in front of it instead of discarding the model. bump_items now takes the edited address; rows before an edit keep their addresses and their row numbers, so only the pages from the edit onward are re-walked. Getting the listing back after undefining at the cursor on bash: 4890ms -> 19ms (257x). Adds .auto/check_edit.py to the gate. total_ms is flat — the bench has no item-edit phase, and the one I wrote hangs (reverted, cause recorded).
Result: {"status":"keep","total_ms":25783,"lg_boot_ms":777.9,"lg_decomp_ms":2381.9,"lg_graph_ms":1209.5,"lg_hex_ms":450,"lg_index_ms":68,"lg_listing_cold_ms":434.6,"lg_listing_warm_ms":405.2,"lg_nav_ms":6801.2,"lg_palette_ms":4.7,"lg_rename_ms":744.7,"lg_render_ms":222.4,"lg_search_ms":3885.6,"lg_split_ms":2261.4,"pure_graph_ms":216.4,"sm_boot_ms":463.6,"sm_decomp_ms":1304.6,"sm_graph_ms":740.9,"sm_hex_ms":438.4,"sm_index_ms":2.4,"sm_listing_cold_ms":270.3,"sm_listing_warm_ms":267.2,"sm_nav_ms":312.4,"sm_palette_ms":0.3,"sm_rename_ms":415.8,"sm_render_ms":257.7,"sm_search_ms":60.8,"sm_split_ms":1385.2,"fails":0}
Diffstat (limited to '.auto/check_edit.py')
-rw-r--r--.auto/check_edit.py126
1 files changed, 126 insertions, 0 deletions
diff --git a/.auto/check_edit.py b/.auto/check_edit.py
new file mode 100644
index 0000000..2472c84
--- /dev/null
+++ b/.auto/check_edit.py
@@ -0,0 +1,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())