aboutsummaryrefslogtreecommitdiffstats
path: root/idatui/app.py
diff options
context:
space:
mode:
Diffstat (limited to 'idatui/app.py')
-rw-r--r--idatui/app.py81
1 files changed, 77 insertions, 4 deletions
diff --git a/idatui/app.py b/idatui/app.py
index 608cc4d..4a603f3 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -50,7 +50,9 @@ from . import kittygfx
from .edit_ctl import EditController
from .prompt import PromptBar
from .trace_ctl import TraceController
+from . import findings
from .highlight import CTextArea, highlight_c
+from .journal import Journal
from .errors import IDAToolError, IDAConnectionError
from .codemode_client import CodeModeClient, registered_database
@@ -3444,6 +3446,7 @@ _HELP = (
("B", "cycle the opcode-bytes column"),
("Ctrl+B", "show/hide the names pane"),
("Ctrl+T", "structs / types editor"),
+ ("Ctrl+E", "export findings as markdown"),
("Ctrl+P", "command palette"),
)),
("Move", (
@@ -4539,6 +4542,8 @@ class StructEditor(ModalScreen):
self._set_status(f"save failed — {msg}", error=True)
return
self._loaded = name
+ if name:
+ self.app.journal.record("type", None, name)
if formatted:
# Reformat the editor to IDA's canonical layout (keeps cursor at top).
ta = self.query_one("#se-edit", TextArea)
@@ -4590,6 +4595,7 @@ class StructEditor(ModalScreen):
return
if getattr(self.app, "_dirty", None) is not None:
self.app._dirty = True
+ self.app.journal.record("del_type", None, name)
self._refresh()
self._set_status(f"deleted {name}")
@@ -4664,6 +4670,8 @@ class IdaCommands(Provider):
app.action_strings),
("Switch binary…", "another binary in the project (Ctrl+O)",
app.action_switch_binary),
+ ("Export findings…", "your comments, names and types as markdown "
+ "(Ctrl+E)", app.action_export),
("Keyboard shortcuts", "the key cheatsheet (F1 or H)",
app.action_help),
("Follow symbol under cursor", "jump to the referenced symbol (Enter)",
@@ -4774,6 +4782,10 @@ class IdaTui(App):
height: 1; border: none; padding: 0 1;
background: $primary-darken-3; color: $text;
}
+ #export {
+ height: 1; border: none; padding: 0 1;
+ background: $success-darken-3; color: $text;
+ }
#status { height: 1; background: $panel; color: $text; padding: 0 1; }
.decomp-loading {
width: 100%; height: 100%; content-align: center middle;
@@ -4882,6 +4894,7 @@ class IdaTui(App):
Binding("q", "quit", "Quit"),
Binding("ctrl+n", "symbols", "Symbols"),
Binding("ctrl+t", "structs", "Structs"),
+ Binding("ctrl+e", "export", "Export", show=False),
Binding("backslash", "hex", "Hex"),
Binding("s", "toggle_split", "Split", show=False),
# IDA's own key for text/graph. Graph mode is opt-in and self-contained:
@@ -4993,9 +5006,12 @@ class IdaTui(App):
#: The one-line prompts above the footer. Each holds its own context
#: for exactly as long as it is on screen; see idatui/prompt.py.
self.prompts = PromptBar(self, "search", "rename", "comment",
- "retype", "makedata", "goto")
+ "retype", "makedata", "goto", "export")
#: Everything that writes to the database (idatui/edit_ctl.py).
self.edits = EditController(self)
+ #: What those writes were, so the findings export can say which
+ #: comments and names are YOURS -- the database cannot (journal.py).
+ self.journal = Journal()
self._xref_focus_name: str | None = None
self._dirty = False
@@ -5045,6 +5061,10 @@ class IdaTui(App):
gi.display = False
gi.can_focus = False
yield gi
+ xi = Input(id="export")
+ xi.display = False
+ xi.can_focus = False
+ yield xi
# markup=False: the status is plain text full of [listing]/[split]/[label]
# markers and symbol names that may contain brackets. With Textual markup
# on, a single-word marker parses as a style tag and is silently eaten —
@@ -6304,6 +6324,50 @@ class IdaTui(App):
inp.value = self._filter_term
inp.focus()
+ def action_export(self) -> None:
+ """Ctrl+E: write what you have worked out to a markdown report.
+
+ Prefilled with a path beside the binary, because the common case is
+ "just write it" and the rare case is worth one edit.
+ """
+ if self.program is None:
+ self._status("no database open", priority=True)
+ return
+ inp = self.query_one("#export", Input)
+ inp.placeholder = "export findings to… (markdown) — Enter"
+ inp.can_focus = True
+ inp.display = True
+ inp.value = findings.default_path(self._open_path or "findings")
+ self.query_one("#status", Static).display = False
+ inp.focus()
+
+ def _end_export(self) -> None:
+ inp = self.query_one("#export", Input)
+ inp.display = False
+ inp.can_focus = False
+ self.query_one("#status", Static).display = True
+
+ def export_findings(self, path: str | None = None) -> None:
+ """Gather + write the report off the UI thread (it walks the database)."""
+ self._status("exporting findings…", priority=True)
+ self._export_worker(path)
+
+ @work(thread=True, exclusive=True, group="export")
+ def _export_worker(self, path: str | None) -> None:
+ try:
+ self.journal.load(self.program)
+ self.journal.flush(self.program)
+ out, f = findings.export(self.program, self._open_path or "", path,
+ journal=self.journal)
+ except Exception as e: # noqa: BLE001 -- a bad path is a message, not a crash
+ self.call_from_thread(self._status, f"export failed: {e}", True)
+ return
+ n_named = len(findings._user_names(f))
+ self.call_from_thread(
+ self._status,
+ f"exported {len(f.comments)} comments, {n_named} names, "
+ f"{len(f.types)} types → {out}", True)
+
def action_goto(self) -> None:
inp = self.query_one("#goto", Input)
inp.placeholder = ("hex goto: 0xADDR or name — Enter" if self.is_hex
@@ -6892,6 +6956,7 @@ class IdaTui(App):
def _save(self) -> None:
assert self.program is not None
try:
+ self.journal.flush(self.program) # ride along into the .i64
self.program.client.save_database()
except Exception as e: # noqa: BLE001
self.app.call_from_thread(self._status, f"save failed: {e}")
@@ -7191,8 +7256,8 @@ class IdaTui(App):
event.prevent_default()
if prompt.id == "search":
self._end_search(cancel=True)
- elif prompt.id == "goto":
- self._end_goto()
+ elif prompt.id in ("goto", "export"):
+ self._end_goto() if prompt.id == "goto" else self._end_export()
(self.query_one(HexView) if self.is_hex
else (self._code_view() or self.query_one(ListingView))).focus()
else:
@@ -7240,6 +7305,13 @@ class IdaTui(App):
if value:
self._goto(value)
return
+ if inp.id == "export":
+ self._end_export()
+ (self.query_one(HexView) if self.is_hex
+ else (self._code_view() or self.query_one(ListingView))).focus()
+ if value:
+ self.export_findings(value)
+ return
# filter mode: already applied incrementally; Enter just confirms + closes.
self._apply_filter(value)
self.query_one("#func-table", DataTable).focus()
@@ -7461,7 +7533,8 @@ class IdaTui(App):
# Prompt overlays that own the keyboard while visible; a background
# navigation must not yank focus out from under them (else typed keys leak
# into a code view as destructive verbs — e.g. 'u' = undefine).
- _PROMPT_IDS = ("search", "rename", "comment", "retype", "goto", "func-filter")
+ _PROMPT_IDS = ("search", "rename", "comment", "retype", "goto", "export",
+ "func-filter")
def _prompt_active(self) -> bool:
for iid in self._PROMPT_IDS: