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
|
# Projects — grouping many binaries under one target
RE'ing a real target means several binaries at once: a daemon, the libraries it
links, maybe a loader. IDA has no notion of this (one IDB, one window); Ghidra
does. A **project** groups binaries so you can switch between them instantly,
search across all of them, and (later) follow calls from one into another.
## The constraint that shapes everything
`idatui/worker.py` is `serve(sock, binpath)` — **one worker process holds exactly
one database** (idalib is main-thread-only and single-DB). So N binaries = N
worker processes, each with the analyzed DB resident.
Measured cost (this box, `targets/`):
| binary | size | `.i64` | worker RSS / PSS |
|--------|------|--------|------------------|
| `bash` | 1.2 MB | 14 MB | **126 MB / 117 MB** |
| `libcrypto.so.3` | 5.9 MB | 72 MB | several hundred MB (DB working set dominates) |
PSS ≈ RSS, so workers share almost nothing — the Nth worker costs roughly its
full footprint. A ~120 MB baseline (Python + the idalib kernel) dominates for
small binaries; the DB working set dominates for big ones.
**Therefore residency is budgeted by memory, not by a fixed worker count** — a
count is the wrong knob when one project holds both a 50 KB helper and a 6 MB
crypto library.
## The key split
Two capabilities that feel like one, but aren't:
1. **Switching** to a binary needs a *live worker*.
2. **Searching across** binaries does *not* — if a per-binary index (functions,
strings, imports/exports) is cached on disk.
That split is the unlock: project-wide search stays instant across every binary,
including ones never opened this session, and only *jumping* to a hit costs a
worker spawn.
## Layout
The project file is explicit (`--project router-fw.json`); everything IDA
generates lives in a sidecar dir beside it, so **source trees stay pristine**:
```
router-fw.json # the project file
router-fw.idatui.d/
bin/httpd # staged copy of the source binary
bin/httpd.i64 # IDA's DB + scratch land here automatically
idx/httpd.json # cached index (phase 2)
```
Binaries are **staged** into `bin/` and IDA opens the staged file, so the `.i64`
and all the `.id0/.id1/.id2/.nam/.til` scratch are created there rather than next
to the original. (Today they pile up beside the source: `targets/` holds ~244 MB
of IDA litter around ~13 MB of binaries, much of it stale wedge files from
hard-killed workers.) A source whose size/mtime no longer matches the staged copy
is re-staged, and its now-stale DB dropped.
Staging **copies** rather than hardlinks. A hardlink is free, but source and
staged would be one inode: an in-place rebuild (`cp newbuild /path/bin`
truncates rather than replacing) would silently swap the bytes under an already
analysed database, with nothing to detect it. A copy costs a fraction of the
`.i64` it will grow, and leaves the sidecar self-contained — the project still
opens after the sources go away (an unmounted firmware image, a cleaned build
tree). *The unit test caught this: with hardlinks an in-place rewrite never went
stale.*
```json
{
"name": "router-fw",
"binaries": [
{"path": "/abs/sbin/httpd", "label": "httpd"},
{"path": "../lib/libauth.so"}
],
"memory_pct": 25
}
```
Relative paths resolve against the project file. `label` defaults to the
basename and must be unique (it names the staged file).
## Runtime
- **`WorkerPool`** — one `WorkerClient` per binary, spawned lazily on first
switch, kept resident until the memory budget is exceeded, then LRU-evicted.
Eviction **saves the DB first**, so returning to a binary is a DB load, not a
re-analysis. Binaries can be pinned to stay resident.
- **`BinaryState`** — per binary: `client, program, nav, cur, func_index,
pref/active/split, filter`. Switching snapshots the current state and restores
the target's. `_after_reconnect` already does exactly this swap (client +
program, reload the index, re-open the entry) — switching reuses that seam.
- **Clean shutdown** — the worker currently does `close_database(save=False)` and
is hard-killed on exit, which is why wedge files accumulate. Projects need
save-on-evict and an orderly close anyway, so that gets fixed here.
## UI
- **Binary switcher** — a palette (mirroring `SymbolPalette`/`StringsPalette`)
listing each binary with resident/cold state and function count; Enter
switches.
- **Status bar** shows the active binary.
- **Scope toggle** — the existing symbol (`Ctrl+N`) and strings (`"`) palettes
gain a this-binary ↔ whole-project toggle rather than new keys, so there's one
mental model. Project-scope hits are prefixed with the binary label; Enter
switches binary *and* jumps.
## Phases
**Phase 1 — project model + switching. DONE.** Project file + staging
(`idatui/project.py`), `WorkerPool` with budget eviction / save-on-evict /
clean shutdown (`idatui/pool.py`), `BinaryState` snapshot+restore and the switch
itself, the `Ctrl+O` switcher palette, the active binary in the status line, and
`--project` (which creates the project when given binaries). One active binary;
no cross-binary search yet.
Project mode is **additive**: with no `--project` the app is byte-for-byte the
single-binary tool it was, which is what keeps the 167-check pilot honest.
Switching reuses the `_after_reconnect` shape — swap client+program, rebuild the
index, reopen the entry. A binary whose worker is still resident restores
instantly (its `Program` and index are still in memory); an evicted one comes
back with a fresh worker but keeps its nav history, since that is just addresses.
**Phase 2 — index cache + project-wide search. (symbols done)**
`idatui/index.py` keeps one **SQLite FTS5 trigram** index at
`<sidecar>/idx/project.db`, populated per binary after its functions load and
re-done only when the source's size/mtime changes.
Why that and not a library: nothing needed installing (FTS5 + the trigram
tokenizer are stdlib), and trigram indexes arbitrary *substrings*, which is what
symbol names and string bodies need. Measured on 300k entries: **1.9 ms** per
query vs 11.8 ms for a Python scan and 28.9 ms for plain `LIKE`; 0.2 ms per
incremental insert.
Size turned out to be a non-issue. Real binaries: `bash` = 5.9k entries /
0.15 MB of text, `libcrypto.so.3` = 30.7k / 0.52 MB. The index runs ~5.7x the
text, so a 20-binary project lands around 12-23 MB — next to the `.i64` files
already in the sidecar (libcrypto's alone is 72 MB) that is ~1% of what the
project already costs. The reason to keep it on disk is **residency, not size**:
search has to work for binaries whose worker isn't running.
Querying is two-stage: the trigram index narrows across every binary, then the
existing `_fuzzy` ranks what's left, so project scope keeps the same
fuzzy-subsequence feel as local scope. Queries shorter than 3 characters fall
back to `LIKE` — a trigram index silently returns *nothing* below that, which
would make incremental typing look broken until the third keystroke.
Both palettes gain a scope toggle on **F2** (not `ctrl+a`: the focused `Input`
binds that to `home`) — `Ctrl+N` for symbols and `"` for strings. Project-scope
hits are prefixed with their binary, and choosing one in another binary switches
to it and jumps. Project scope caps at 60 rows; it saturates any cap because
every binary contributes, and a long list is what makes arrowing feel sluggish.
Ranking, both palettes: the index already guarantees the match, so ordering only
sorts — earliest match, then shortest, then `(binary, addr)`. That last tiebreak
is load-bearing: without it, a name shared by two binaries (`main`, `textdomain`,
most of libc) ties on every other field and Python falls through to comparing the
hit objects, which raises `TypeError`.
**Phase 3 — cross-binary linking (done).** Each binary's imports and exports go
into the project index alongside its functions and strings (`KIND_IMPORT`,
`KIND_EXPORT`), and following an import resolves it to the binary that provides
it.
Following a call to `strcmp` used to reach the PLT/extern entry and stop:
Hex-Rays has nothing to decompile because the code lives in a library this
binary only references. Now `_follow_import` checks whether the target address is
one of this binary's import stubs, asks the index who exports that name, and
switches there. From an `echo` + `libc` project, Enter on `strrchr(a1, 47)` lands
on `strrchr` at `0xaf960` in libc.
Three things this depends on:
*ELF symbol versioning.* The importer sees `strrchr@@GLIBC_2.2.5`; the provider
may export `strrchr`, `strrchr@GLIBC_2.2.5`, or the versioned spelling. Comparing
raw names resolves almost nothing, so `domain.link_name()` cuts at the first `@`
and both sides meet on the bare symbol. `Linkage.raw` keeps what IDA reported,
which is what the listing shows.
*Exact match, not substring.* The join uses `ProjectIndex.exact()`, not
`search()`: an import must bind to its own name, or `read` picks up `pread`,
`read_line` and `thread_start`. Exact match also works below the 3-character
trigram floor, which matters — plenty of real exports are one or two characters.
*Resolution reads the index, not a worker.* A provider resolves while its worker
is evicted; that is the reason the index is on disk. The switch itself then pages
the provider in through the normal pool path.
The reverse direction is in the xrefs dialog. `xrefs_to` only ever sees the
current database, so an exported function looks unused from the inside even when
half the project calls it. `_foreign_importers` appends the project binaries that
IMPORT the symbol — read from the on-disk index, so a caller shows up whether or
not its worker is resident — and choosing one carries a `(binary, addr)` payload
through the same switch path as a search hit, hop included. From libc's
`strrchr`: `0000C318 import [echo] strrchr`.
It only fires for a symbol this binary actually exports. A local name that
happens to collide with another binary's import is not a caller of ours.
When nothing in the project provides the symbol, `_follow_import` declines and
the local navigation proceeds — landing on the stub is still the honest answer,
and a single-binary session behaves exactly as before.
This does not retire the **PLT/import-stub** backlog item. An import whose
provider isn't in the project still lands on a stub, and that case wants the
original fix — recognise the thunk and present it as an import ("strcmp —
imported, provider not in project") rather than surfacing a decompiler error.
Phase 3 covers the valuable half; stub *presentation* stays worth doing.
**Phase 4 — polish (mostly done).**
*Nav history is per-binary, with a cross-binary way back.* Each binary keeps its
own stack in `BinaryState`; switching restores it (addresses outlive the worker,
so it survives eviction). But phase 3 made a cross-binary jump an ordinary
action, and arriving in a binary whose history is empty made it a one-way door.
`_switch_then_goto` now records the binary it came FROM, and `action_back` falls
through to that hop once local history is spent. So Esc walks back through the
function you were in, then the binary you were in. Manual switching (Ctrl+O)
records nothing — that's not navigation.
*Pre-warm follows the linkage graph, not list order.* When a binary finishes
indexing, `_prewarm_provider` warms the binary that provides the most of its
imports — where a follow is most likely to take you, so its startup is paid
before you ask. `WorkerPool.prewarm()` refuses rather than evicting: spending a
binary you visited on one you haven't is a straight downgrade, and it would throw
away that binary's caches too. At a tight budget pre-warm simply does nothing. It
estimates the cost of a not-yet-spawned worker from the largest resident one,
which is the only evidence available; if the estimate proves wrong, the
speculative worker is the one that goes.
*Driving a project.* `pane spawn --project FILE [--open BIN]` opens a project
pane. `binaries` lists the inventory (active / resident / indexed count / where
Esc returns to) and `switch {binary,addr?}` makes another one active — with an
address it goes through the same path as a search hit, so it records a hop. The
state snapshot now carries `binary` and `hops`.
Still open: project-level persistence (remember the active binary and each
binary's position across sessions — today a fresh launch auto-lands).
## Headerless blobs
An ELF/PE/Mach-O says what it is, so IDA figures it out. A raw firmware dump
says nothing, and IDA falls back to **x86 at address 0** — which on an ARM image
analyses to zero functions. It doesn't fail; it just quietly gives you nothing.
Open one without saying anything and idatui asks, the way interactive IDA does
when no loader matches:
┌ unrecognised file — processor? (20) ────────────────┐
│ fw.bin (32,768 bytes) — no loader matched; without │
│ a processor IDA assumes x86 at 0 │
│ arm │
│ arm ARM / AArch64 — little-endian │
│ armb ARM — big-endian │
│ load address, e.g. 0x8000000 (blank = 0) │
│ Enter accept · Tab base address · Esc load as IDA │
└─────────────────────────────────────────────────────┘
`formats.sniff()` decides whether to ask, and only recognises formats IDA
definitely handles (ELF, PE, Mach-O, dex, wasm, ar, COFF, Intel HEX, S-records).
Being cautious the wrong way costs one dismissible dialog; being cautious the
other way is the silent-nothing case we're trying to kill. Esc loads it the way
IDA would have, because the sniff can be wrong.
A text container is only accepted when the whole header is printable — a raw
image whose first byte happens to be `:` is far likelier than Intel HEX.
The dialog is skipped when the answer already exists: options on the command
line, options in the project, or a database that already records them.
Or say it up front:
ida-tui fw.bin --processor arm --base 0x8000000
or per binary in a project, which is where it belongs for a multi-image firmware:
{"binaries": [
{"path": "bootloader.bin", "processor": "arm", "base": "0x0"},
{"path": "app.bin", "processor": "arm", "base": "0x8000000"},
{"path": "dsp.bin", "processor": "mipsb", "base": "0x10000000"}
]}
`base` is written the way you'd say it (`0x8000000`, any base, int or string).
IDA's own `-b` switch is in **paragraphs** — `-b1000` loads at 0x10000 — so the
conversion happens in `BinaryRef.load_args`, and a base that isn't 16-byte
aligned is rejected rather than silently landing somewhere else. `ida_args`
passes anything else through untouched.
Two things worth knowing:
* The options apply to the **first** open only. Afterwards the `.i64` records how
the image was loaded, and passing the switches again makes IDA refuse to open
it — so the worker skips them once a database exists.
* Changing the processor or base of a binary you've already analysed does
nothing, because the database wins. Delete its `.i64` from the sidecar to
reload with new options.
## Decisions
- Residency is a **memory budget** (default 25% of RAM, `memory_pct`), not a
worker count; pinning supported.
- **Nav history is per-binary** to start.
- Search scope is a **toggle inside the existing palettes**.
- The project file is **explicit** (`--project`), not auto-discovered.
- IDA artifacts are **centralized** in the sidecar dir via staging.
## Open questions
- Re-staging a changed source drops its DB (renames included) — right call, but
it should warn loudly.
- Should `Esc`/back ever cross binaries (global history)? Deferred.
- Pre-warm policy: analyze the whole project up front, or purely on demand?
|