aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_scenarios.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 /tests/test_scenarios.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 'tests/test_scenarios.py')
-rw-r--r--tests/test_scenarios.py82
1 files changed, 82 insertions, 0 deletions
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 6c3177a..0b004b3 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -1093,6 +1093,88 @@ async def s_structs(c: Ctx):
c.check("Esc closes the struct editor", not isinstance(app.screen, StructEditor))
+@scenario("export_findings")
+async def s_export_findings(c: Ctx):
+ """Ctrl+E writes a markdown report of what this session worked out.
+
+ Deliberately end-to-end: the interesting failure is not the formatting (that
+ is covered offline in test_findings.py) but whether a comment and a rename
+ made through the UI come back out of the database and into the file.
+ """
+ import tempfile
+
+ from idatui.findings import default_path
+
+ app = c.app
+ fn = c.biggest()
+ tag = os.getpid()
+ newname, note = f"exp_{tag}", f"found_it_{tag}"
+ old = fn.name
+ await c.open(fn.addr, "listing")
+
+ # Make something to find: a rename and a comment, through the real paths.
+ app.program.client.invoke(
+ "rename", batch={"func": {"addr": hex(fn.addr), "name": newname}})
+ app.program.bump_names()
+ app.program.set_comment(fn.addr, note)
+ app.program.invalidate(fn.addr)
+ # ...and tell the journal, exactly as the edit controller would. The
+ # database cannot say who wrote a comment (IDA's own analyzer uses the same
+ # call), so the journal is what makes this MY finding rather than noise.
+ app.journal.record("rename", fn.addr, f"{old} → {newname}")
+ app.journal.record("comment", fn.addr, note)
+
+ out = os.path.join(tempfile.gettempdir(), f"idatui-findings-{tag}.md")
+ try:
+ await c.press("ctrl+e")
+ inp = app.query_one("#export", Input)
+ opened = await c.wait(lambda: inp.display, 5)
+ c.check("Ctrl+E opens the export prompt", opened, f"display={inp.display}")
+ c.check("the prompt is prefilled with a path beside the binary",
+ inp.value == default_path(app._open_path), f"value={inp.value!r}")
+ inp.value = out
+ await c.press("enter")
+ written = await c.wait(lambda: os.path.exists(out), 30)
+ c.check("Enter writes the report", written, f"no {out}")
+ if not written:
+ return
+ doc = open(out, encoding="utf-8").read()
+ c.check("the report is markdown with the expected sections",
+ doc.startswith("# Findings") and "## Comments" in doc
+ and "## Named functions" in doc, doc[:60])
+ c.check("a comment written this session is in it", note in doc,
+ doc[:200])
+ c.check("and the function it belongs to is named", newname in doc,
+ doc[:200])
+ c.check("the report is sourced from the journal, not a scan",
+ "idatui's edit journal" in doc,
+ [l for l in doc.splitlines() if "**source**" in l])
+ c.check("the analyzer's own comments stay out of it",
+ "switch jump" not in doc and "jumptable" not in doc,
+ [l for l in doc.splitlines() if "switch" in l][:2])
+ c.check("the status line says where it went",
+ out in c.status(), c.status())
+ # The journal has to survive the database, or a report is only ever
+ # about the session that happened to be open.
+ from idatui.journal import Journal
+
+ app.journal.flush(app.program)
+ reloaded = Journal()
+ reloaded.load(app.program)
+ c.check("the journal round-trips through the .i64",
+ fn.addr in reloaded.addresses(),
+ f"{len(reloaded)} entries, {sorted(reloaded.addresses())[:3]}")
+ finally:
+ # Idempotent: hand the database back exactly as we found it.
+ app.program.set_comment(fn.addr, "")
+ app.program.client.invoke(
+ "rename", batch={"func": {"addr": hex(fn.addr), "name": old}})
+ app.program.bump_names()
+ app.program.invalidate(fn.addr)
+ if os.path.exists(out):
+ os.remove(out)
+
+
@scenario("struct_filter")
async def s_struct_filter(c: Ctx):
app = c.app