aboutsummaryrefslogtreecommitdiffstats
path: root/idatui/journal.py
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-08-07 22:55:27 +0200
committerblasty <blasty@local>2026-08-07 22:55:27 +0200
commit2eb2a0a8cff586fffecfcb068c53b65e8f6f9839 (patch)
tree82ccb8e8fa9608c41862a13d04827d7c76ac99ee /idatui/journal.py
parentSPEED.md: the 85ms keypress, and what settle() still cannot see (diff)
downloadida-tui-2eb2a0a8cff586fffecfcb068c53b65e8f6f9839.tar.gz
ida-tui-2eb2a0a8cff586fffecfcb068c53b65e8f6f9839.tar.xz
ida-tui-2eb2a0a8cff586fffecfcb068c53b65e8f6f9839.zip
Export findings as markdown (Ctrl+E), and the journal that makes it true
The output of an RE session is what you worked out, and it was locked in a .i64 that only IDA can read. Ctrl+E (or `drive export`, or the `export` RPC verb) writes it out: your comments grouped by function with the line each annotates, the names and prototypes you set, the types you declared. **The hard part was provenance, and it needed a mechanism, not a filter.** A database does not record WHO wrote a comment or a name. IDA's analyzer sets `; switch 73 cases` and `; s1` with the same `set_cmt` a person uses, and the ELF loader sets `elf_gnu_hash_nbuckets` and `File class: 64-bit` the same way. Four probes, all negative: the FF_COMM flag is identical, `get_cmt` returns them all, `generate_disasm_line` tags every one of them COLOR_REGCMT (not COLOR_AUTOCMT), and they survive with auto-comments switched off. A first cut filtered by shape and produced a report whose first screen was ELF header trivia and `; jumptable ... case 99`. So idatui journals its own edits (idatui/journal.py) into a netnode in the database: it rides along in the .i64, it is still there next session, and the report is then exactly what was done here -- 2 findings out of a database carrying 693 other annotations. Recorded at the choke points in edit_ctl (rename, name-address, comment, retype) and in the struct editor; flushed on save, on export and on quit, so no edit pays a round trip. Without a journal (a database worked on in the IDA GUI, or predating this) the report falls back to filtering by shape -- dummy names, imports, loader segments, the analyzer's stereotyped switch/jumptable strings -- and says so in the document rather than claiming authorship it cannot prove. idatui/findings.py splits gather (needs IDA) from render (does not), so the formatting, grouping, sorting, escaping and the empty cases are tested offline: tests/test_findings.py, 32 checks, no worker, 0.1s. The pilot scenario covers the round trip that matters -- edit through the UI, export, find it in the file, and reload the journal from the .i64. Full suite: 842 passed, 0 failed, 51.2s.
Diffstat (limited to 'idatui/journal.py')
-rw-r--r--idatui/journal.py107
1 files changed, 107 insertions, 0 deletions
diff --git a/idatui/journal.py b/idatui/journal.py
new file mode 100644
index 0000000..a5a07de
--- /dev/null
+++ b/idatui/journal.py
@@ -0,0 +1,107 @@
+"""A record of the edits idatui makes, kept inside the database.
+
+**Why this has to exist.** A findings report wants to say "here is what *you*
+worked out", and the database cannot answer that. IDA's own analyzer writes
+comments with the same `set_cmt` a person uses (`; s1` on an argument setup,
+`; switch 73 cases` on a jump table), and the loader writes both comments and
+names for the file header. Four separate probes agree that nothing tells them
+apart: the `FF_COMM` flag is identical, `get_cmt` returns them all, the colour
+tag in `generate_disasm_line` is `COLOR_REGCMT` for every one of them, and they
+survive with auto-comments switched off. So authorship is not recoverable after
+the fact -- it has to be recorded as it happens, which is what this does.
+
+It lives in an IDA **netnode**, so it is saved into the `.i64` with everything
+else and is still there next session. The entries are small and additive; the
+journal is metadata *about* edits that themselves live in the database, so
+losing it degrades the report to a heuristic rather than losing work.
+"""
+
+from __future__ import annotations
+
+import json
+import threading
+import time
+
+#: Where the blob lives inside the database.
+NODE = "$ idatui.journal"
+
+#: Cap: a long session is hundreds of edits, not hundreds of thousands, and the
+#: blob is rewritten whole. Oldest entries fall off first.
+MAX_ENTRIES = 20000
+
+
+class Journal:
+ """Append-only log of what was edited, with lazy load and explicit flush.
+
+ Writing through to the database on every keystroke-sized edit would put a
+ round trip in the way of the user; the in-memory list is authoritative
+ during a session and :meth:`flush` persists it at the points that already
+ mean "keep this": saving, exporting, and quitting.
+ """
+
+ def __init__(self) -> None:
+ self.entries: list[dict] = []
+ self._dirty = False
+ self._loaded = False
+ self._lock = threading.Lock()
+
+ # -- recording ---------------------------------------------------------- #
+ def record(self, kind: str, ea: int | None = None, detail: str = "",
+ extra: dict | None = None) -> None:
+ """Note one edit: ``kind`` is 'rename' / 'comment' / 'retype' / …"""
+ entry = {"k": str(kind), "t": int(time.time())}
+ if ea is not None:
+ entry["ea"] = int(ea)
+ if detail:
+ entry["d"] = str(detail)[:400]
+ if extra:
+ entry.update(extra)
+ with self._lock:
+ self.entries.append(entry)
+ if len(self.entries) > MAX_ENTRIES:
+ del self.entries[:len(self.entries) - MAX_ENTRIES]
+ self._dirty = True
+
+ def addresses(self, kinds: tuple[str, ...] | None = None) -> set[int]:
+ """Every address touched (optionally only by certain kinds of edit)."""
+ with self._lock:
+ return {e["ea"] for e in self.entries
+ if "ea" in e and (kinds is None or e.get("k") in kinds)}
+
+ def __len__(self) -> int:
+ return len(self.entries)
+
+ # -- persistence -------------------------------------------------------- #
+ def load(self, program) -> None:
+ """Read the journal out of the database, once. Never raises."""
+ if self._loaded:
+ return
+ self._loaded = True
+ try:
+ raw = program.journal_get()
+ except Exception: # noqa: BLE001 -- an old database simply has none
+ return
+ if not raw:
+ return
+ try:
+ data = json.loads(raw)
+ except Exception: # noqa: BLE001
+ return
+ if isinstance(data, list):
+ with self._lock:
+ # Prepend: what is already in memory happened later.
+ self.entries = [e for e in data if isinstance(e, dict)] + self.entries
+
+ def flush(self, program) -> bool:
+ """Write the journal back if it changed. Returns whether it wrote."""
+ with self._lock:
+ if not self._dirty:
+ return False
+ payload = json.dumps(self.entries, separators=(",", ":"))
+ try:
+ program.journal_put(payload)
+ except Exception: # noqa: BLE001 -- never let bookkeeping break an edit
+ return False
+ with self._lock:
+ self._dirty = False
+ return True