1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
|
"""Execution-trace navigation: the controller behind `--trace FILE`.
`idatui.trace` is the *reader* (parse the Tenet delta log, answer questions about
it). This is the half that used to live in `IdaTui`: the trace's position in
time, the trail painted onto the listing/pseudocode/hex, and every key that
moves through it (`[` `]` `{` `}` `<` `>` `W`).
Why it's a plain object and not a mixin: Textual only merges ``BINDINGS`` from
``DOMNode`` subclasses, so a mixin's bindings are silently dropped. The keys
therefore stay declared on ``IdaTui``, whose ``action_*`` methods are one-line
delegates into here. Same reason the ``@work(thread=True)`` entry point stays on
the app -- Textual's worker machinery wants a ``DOMNode`` host.
The controller owns the trace state. ``IdaTui`` keeps forwarding properties
(``app._trace``, ``app._t``, ``app._trail_map``...) because the pilot suite and
the RPC layer read them by those names; see ``IdaTui._trace``.
"""
from __future__ import annotations
import bisect
import os
from typing import TYPE_CHECKING
from . import diag
if TYPE_CHECKING: # pragma: no cover
from .app import IdaTui
_app_mod = None
def _views():
"""The widget classes, imported lazily.
``app.py`` imports this module at the top, so importing it back at module
scope would be a cycle. Nothing here needs the classes until a trace is
actually driven, by which point ``app`` is fully imported.
"""
global _app_mod
if _app_mod is None:
from . import app as _m
_app_mod = _m
return _app_mod
class TraceController:
"""Where we are in the trace, and everything that moves us."""
def __init__(self, app: "IdaTui", path: str = "") -> None:
self.app = app
self.path = path or "" # the Tenet trace to explore, if any
self.trace = None # the loaded Trace, once analysed
self.t = 0 # current timestamp in that trace
self.trail_map = [] # decomp_map for trail_map_ea
self.trail_map_ea = None
self.trail_line_of: dict[int, int] = {} # ea -> pseudocode line
self.trail_eas: list[int] = [] # sorted keys of trail_line_of
self.trail_span = None # ea span of that function
self.pending_line = None # step waiting on a re-decompile
@property
def armed(self) -> bool:
"""A trace was asked for but hasn't been parsed yet."""
return bool(self.path) and self.trace is None
def _need_trace(self) -> bool:
"""Complain once, in one voice, rather than at four call sites."""
if self.trace is None:
self.app._status("no trace loaded (--trace FILE)")
return False
return True
# -- loading ------------------------------------------------------------ #
def load(self) -> None:
"""Parse the trace and line it up with the database.
Runs after the function index exists: rebasing needs the database's
addresses, and without it nothing in the trace matches anything on
screen (our echo trace runs at 0x7ffff6faa000; the database has that
code at 0x2000).
Called on a worker thread, so every touch of the UI hops back.
"""
from .trace import Trace
app = self.app
path = self.path
try:
def note(n):
app.call_from_thread(
app._status, f"trace: {n:,} instructions\u2026")
trace = Trace.load(path, progress=note)
except OSError as e:
app.call_from_thread(app._status, f"trace: {e}")
return
if not trace.length:
app.call_from_thread(
app._status, f"trace: {os.path.basename(path)} is empty")
return
idx = app._func_index
addrs = [f.addr for f in idx.all_loaded()] if idx is not None else []
slide = trace.rebase(addrs)
trace.apply_slide(slide)
hit = sum(1 for f in (idx.all_loaded() if idx else [])
if trace.executions(f.addr))
app.call_from_thread(self.ready, trace, slide, hit)
def ready(self, trace, slide: int, hit: int) -> None:
app = self.app
self.trace = trace
self.t = 0
dock = app.query_one(_views().TraceDock)
dock.display = True
dock.show(trace, 0)
where = (f"rebased {slide:+#x}" if slide else "no rebase needed")
app._status(f"trace: {trace.length:,} instructions, {hit} functions "
f"touched ({where})", priority=True)
self.seek(0, follow=True)
# -- trace navigation --------------------------------------------------- #
def seek(self, idx: int, follow: bool = True) -> None:
"""Move to timestamp ``idx``; ``follow`` takes the code view with it."""
app = self.app
t = self.trace
if t is None or not t.length:
return
# A seek invalidates any navigation still in flight. They run in workers
# and finish out of order: the trace's opening seek lands on the entry
# point, takes a while, and used to arrive AFTER later seeks — dragging
# the cursor back to _start while the trace was elsewhere, permanently.
#
# Bumped HERE and not in _goto_ea. Doing it for every navigation is the
# more general rule ("the last thing you asked for wins") but it also
# lets an ordinary follow be dropped by whatever navigates next, and the
# only evidence I have is about seeks. Narrow fix for the measured bug.
app._nav_seq += 1
self.t = max(0, min(int(idx), t.length - 1))
app.query_one(_views().TraceDock).show(t, self.t)
self.paint_trail()
if not follow:
return
pc = t.ip(self.t)
if app._split and self.seek_split(pc):
return
# Stay in whichever view you're reading. Without prefer_decomp a step
# from the pseudocode navigates to an address, which opens the listing —
# so stepping through C threw you out of C on the first keypress.
app._goto_ea(pc, push=False,
prefer_decomp=(app.is_decomp))
def seek_split(self, pc: int) -> bool:
"""Put BOTH panes on ``pc``. True if handled.
Normal navigation moves one pane and gives the companion a band, never a
cursor — that rule exists so the two can't chase each other. A trace step
isn't navigation though: time is a single global position, and both views
are showing the same instant, so both cursors belong on it.
The scroll anchoring is unchanged: after placing the cursors, the usual
_sync_split still bands the companion and aligns it to the driver's
screen row, so the eye tracks straight across.
"""
app = self.app
M = _views()
lst = app.query_one(M.ListingView)
if lst.model is None:
return False
row = lst.model.ensure_ea(pc)
if row is None or row < 0:
return False # not in this listing (other segment): full nav
lst.cursor = row
lst._scroll_cursor_into_view()
# Has execution actually left the decompiled function? Ask the map the
# trail painting keeps, which is keyed to what the decompiler currently
# HOLDS. _split_range comes from the guarded async path and lags, so a
# stale one made every step look like a function change: the decompiler
# bounced main -> PLT stub -> main, each bounce costing a synchronous
# 769-line map fetch on the UI thread.
span = self.trail_span
inside = (pc in self.trail_line_of
or (span is not None and span[0] <= pc <= span[1]))
if not inside:
self.pending_line = pc
app._resync_decomp_async(pc)
return True
self.place_decomp_at(pc)
app._sync_split(app._active)
return True
def place_decomp_at(self, pc: int) -> None:
"""Move the pseudocode cursor to the line covering ``pc``.
Uses the map the trail painting already keeps (keyed to the decompiler's
CURRENTLY loaded function), not the split view's _split_ea2line. That one
is refreshed by a guarded async path — it drops a result if _cur moved
while it was in flight — and a burst of steps moves _cur constantly, so
during stepping it is frequently a map of the function you just left.
"""
app = self.app
dec = app.query_one(_views().DecompView)
line = None
if self.trail_map_ea == dec.loaded_ea and self.trail_line_of:
# EXACT match only. The decompiler doesn't attribute every
# instruction to a line (about half of main's aren't), and the
# tempting fallback — the nearest mapped instruction at or before
# the pc — is unsound: C lines are not monotonic in address, so
# 0x24a8 early in main resolved to line 708, "sub_2040();", near the
# end. A cursor that jumps to an unrelated statement is worse than
# one that waits; the trail still marks where we are.
line = self.trail_line_of.get(pc)
if line is None:
line = app._split_ea2line.get(pc)
if line is None:
line = dec.line_for_ea(pc)
if line is not None:
dec.goto(line, dec.cursor_x)
# -- painting ----------------------------------------------------------- #
def paint_trail(self) -> None:
"""Push the execution trail into the code views.
Recomputed per seek rather than per repaint: it's ~200 lookups, and a
repaint happens far more often than a step.
"""
app = self.app
M = _views()
t = self.trace
if t is None:
return
hx = app._try_view(M.HexView) # None until it's mounted
if hx is not None:
hx.trace, hx.trace_idx = t, self.t
if hx.display:
hx.refresh()
trail = t.trail(self.t)
lst = app._try_view(M.ListingView)
if lst is not None:
lst.trail = trail
lst.refresh()
self.paint_trail_decomp(trail)
def paint_trail_decomp(self, trail: dict) -> None:
"""Map the instruction trail onto pseudocode lines.
This is the thing Tenet can't do: it paints disassembly, because that's
where a trace's addresses live. We already have decomp_map (built for
the split view) saying which instructions each pseudocode line covers,
so the same trail lands on C.
A line covers many instructions, so it takes the strongest kind present:
'now' wins over 'past' wins over 'future' — if the instruction you are
standing on is part of this line, this line is where you are.
"""
app = self.app
dec = app._try_view(_views().DecompView)
if dec is None:
return
ea = dec.loaded_ea
if not dec.display or ea is None or app.program is None:
dec.trail = {}
return
if self.trail_map_ea != ea:
# One index, built once per decompiled function and shared with the
# split view (_apply_split_map fills the same fields). decomp_map is
# an RPC and stepping is interactive, so paying it per keystroke —
# or twice, once for each of two parallel maps — would be felt.
try:
app._apply_split_map(ea, app.program.decomp_map(ea))
except Exception as e: # noqa: BLE001
# The pseudocode simply stops being painted with the trail, with
# nothing on screen to say why.
diag.note(f"trail: decomp_map({ea:#x})", e)
self.trail_map, self.trail_map_ea = [], ea
self.trail_line_of, self.trail_eas = {}, []
self.trail_span = None
rank = {"future": 0, "past": 1, "now": 2}
lines: dict[int, str] = {}
for i, eas in enumerate(self.trail_map or []):
best = None
for a in eas:
k = trail.get(a)
if k is not None and (best is None or rank[k] > rank[best]):
best = k
if best is not None:
lines[i] = best
dec.trail = lines
dec.refresh()
pend, self.pending_line = self.pending_line, None
if pend is not None and app._split:
# The function was still decompiling when the step happened; land
# now that its line map exists.
self.place_decomp_at(pend)
app._sync_split(app._active)
def adopt_map(self, ea: int, m: list, ea2line: dict, span) -> None:
"""Take the line map the split view just built.
ONE index, shared with the split view: the trace path used to keep a
parallel copy of exactly this, fetched separately and keyed differently,
which is how the two ended up describing different functions.
"""
self.trail_map, self.trail_map_ea = m, ea
self.trail_line_of = dict(ea2line)
self.trail_eas = sorted(self.trail_line_of)
self.trail_span = span
# -- stepping ----------------------------------------------------------- #
def step(self, delta: int) -> None:
if not self._need_trace():
return
self.seek(self.t + delta)
def step_over(self, direction: int) -> None:
"""Step over a call by following the stack pointer.
A call pushes, so the callee runs with SP BELOW where we started;
stepping until SP comes back up lands after the call returns. Cheaper
and more robust than recognising call instructions per architecture,
which is what the mode makes it: if this instruction doesn't call
anything, SP is already >= the start and it degenerates to one step.
"""
if not self._need_trace():
return
t = self.trace
sp_name = "rsp" if "rsp" in t.reg_at else ("esp" if "esp" in t.reg_at else "sp")
sp0 = t.register(sp_name, self.t)
i = self.t + direction
limit = 200000 # a runaway search must not hang the UI
while 0 <= i < t.length and limit > 0:
sp = t.register(sp_name, i)
if sp0 is None or sp is None or sp >= sp0:
break
i += direction
limit -= 1
self.seek(max(0, min(i, t.length - 1)))
def seek_hit(self, direction: int) -> None:
"""Seek to the next/previous time the focused view's subject was touched.
Two different questions with one pair of keys, because the answer to
"which thing?" is already on screen: in a code view it's the instruction
under the cursor ("when else did this run?"), in hex it's the byte under
the cursor ("who else touched this?").
"""
app = self.app
M = _views()
if not self._need_trace():
return
t = self.trace
if app.is_hex:
hx = app._try_view(M.HexView)
va = hx.cursor_va() if hx is not None else None
if va is None:
return
stamps = t.memory_accesses(va, 1)
what = f"access to {va:#x}"
else:
view = app._active_code_view()
if isinstance(view, M.DecompView):
# A C line is not one address, so ask about the whole statement:
# "when else did this line run?" is the question, and it's the
# union of its instructions' executions. Falling back to the
# line's single /*ea*/ marker would answer a narrower question
# and often no question at all, since most lines have no marker.
line = view.cursor
eas = []
if (self.trail_map_ea == view.loaded_ea
and 0 <= line < len(self.trail_map or [])):
eas = list(self.trail_map[line])
if not eas:
one = view._line_ea(line)
eas = [one] if one is not None else []
if not eas:
app._status("this line has no instructions to seek on",
priority=True)
return
stamps = sorted({x for e in eas for x in t.executions(e)})
what = f"execution of C line {line + 1}"
else:
ea = view._cursor_ea() if view is not None else None
if ea is None:
app._status("no address on this line", priority=True)
return
stamps = list(t.executions(ea))
what = f"execution of {ea:#x}"
if not stamps:
app._status(f"no {what} in this trace", priority=True)
return
if direction > 0:
i = bisect.bisect_right(stamps, self.t)
else:
i = bisect.bisect_left(stamps, self.t) - 1
if not (0 <= i < len(stamps)):
edge = "last" if direction > 0 else "first"
app._status(f"already at the {edge} {what} "
f"({len(stamps)} in the trace)", priority=True)
return
self.seek(stamps[i])
app._status(f"{what}: {i + 1} of {len(stamps)} @ t={stamps[i]:,}",
priority=True)
def seek_reg_write(self) -> None:
"""W: which instruction set each register to its current value."""
app = self.app
if not self._need_trace():
return
t = self.trace
rows = []
for name in t.registers:
v = t.register(name, self.t)
if v is None:
continue
rows.append((name, v, t.last_write(name, self.t),
t.next_write(name, self.t)))
if rows:
app.push_screen(_views().RegWriteScreen(rows, self.t),
self._on_reg_write_chosen)
def _on_reg_write_chosen(self, idx) -> None: # type: ignore[no-untyped-def]
if idx is not None:
self.seek(int(idx))
|