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
|
"""Multi-binary projects: group the binaries of one target under a project file.
A project is an explicit JSON file plus a sidecar directory beside it::
router-fw.json # the project file
router-fw.idatui.d/
bin/httpd # hardlink (or 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 every ``.id0/.id1/.id2/.nam/.til`` scratch file are created inside
the sidecar instead of next to the original. Source trees stay pristine.
Staging **copies** rather than hardlinks. A hardlink would be free, but source
and staged would be one inode: an in-place rebuild (``cp newbuild /path/bin``
truncates instead of 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, decouples the two completely, and leaves the sidecar
self-contained — the project still opens after the sources go away (an unmounted
firmware image, a cleaned build tree).
A source whose size/mtime no longer matches the staged copy is re-staged, and its
now-stale database is dropped (the DB describes the old bytes).
stdlib-only, like the domain/worker layers — the TUI is the only Textual consumer.
"""
from __future__ import annotations
import json
import os
import shutil
from dataclasses import dataclass
SIDECAR_SUFFIX = ".idatui.d"
DEFAULT_MEMORY_PCT = 25
#: The database plus the working files IDA unpacks beside it while it's open.
DB_SUFFIXES = (".i64", ".idb", ".id0", ".id1", ".id2", ".nam", ".til")
#: Just the unpacked scratch — safe to delete when no worker holds the DB.
SCRATCH_SUFFIXES = (".id0", ".id1", ".id2", ".nam", ".til")
class ProjectError(Exception):
"""A malformed project file, or a binary that can't be staged."""
@dataclass(frozen=True)
class BinaryRef:
"""One binary in a project: where it came from, and where IDA works on it."""
label: str # unique within the project; names the staged file
source: str # absolute path to the original binary
staged: str # absolute path IDA actually opens (inside the sidecar)
#: How to LOAD it. Only meaningful for a headerless blob: an ELF/PE says what
#: it is, a raw firmware image doesn't, and IDA defaults to metapc at 0.
processor: str = "" # IDA processor name: arm, armb, mipsb, metapc, …
base: int = 0 # load address (natural, e.g. 0x8000000)
ida_args: str = "" # escape hatch: extra IDA command-line switches
@property
def db(self) -> str:
"""The database IDA creates for the staged file."""
return self.staged + ".i64"
@property
def load_args(self) -> str:
"""``processor``/``base`` as IDA command-line switches.
``-b`` is in PARAGRAPHS, not bytes — ``-b1000`` loads at 0x10000. That
trap is worth hiding: projects say ``"base": "0x8000000"`` and the one
conversion lives in ``formats.load_args``.
"""
from .formats import load_args
return load_args(self.processor, self.base, self.ida_args)
def _as_addr(v) -> int:
"""A load address from JSON: int, or a string in any base ("0x8000000").
Addresses are written by hand in a project file, so accept how people write
them rather than demanding decimal.
"""
if v is None or v == "":
return 0
if isinstance(v, int):
return v
try:
return int(str(v), 0)
except ValueError:
return 0
def _stat_key(path: str) -> tuple[int, int] | None:
"""(size, mtime) identity used to spot a source that changed under us."""
try:
st = os.stat(path)
except OSError:
return None
return (st.st_size, int(st.st_mtime))
def _unlink(path: str) -> bool:
try:
os.remove(path)
return True
except OSError:
return False
class Project:
"""A set of binaries analysed together, with all IDA artifacts corralled."""
def __init__(self, path: str, name: str, entries: list[dict],
memory_pct: int = DEFAULT_MEMORY_PCT) -> None:
self.path = os.path.abspath(os.path.expanduser(path))
self.name = name
self.memory_pct = memory_pct
self._entries = entries # raw, as written to the file
self._refs = self._build_refs()
# -- construction ------------------------------------------------------ #
@classmethod
def load(cls, path: str) -> "Project":
path = os.path.abspath(os.path.expanduser(path))
try:
with open(path) as f:
raw = json.load(f)
except OSError as e:
raise ProjectError(f"cannot read project {path}: {e}") from e
except ValueError as e:
raise ProjectError(f"malformed project {path}: {e}") from e
if not isinstance(raw, dict):
raise ProjectError(f"malformed project {path}: expected an object")
entries = raw.get("binaries")
if not isinstance(entries, list) or not entries:
raise ProjectError(f"project {path} lists no binaries")
norm: list[dict] = []
for e in entries:
if isinstance(e, str):
e = {"path": e}
if not isinstance(e, dict) or not e.get("path"):
raise ProjectError(f"project {path}: bad binary entry {e!r}")
# Keep every recognised key: a whitelist of path/label silently
# dropped the load options on the first save, so a blob's processor
# and base vanished the moment the project was reopened.
norm.append({k: e[k] for k in
("path", "label", "processor", "base", "ida_args")
if e.get(k) not in (None, "")})
name = raw.get("name") or os.path.splitext(os.path.basename(path))[0]
try:
pct = int(raw.get("memory_pct", DEFAULT_MEMORY_PCT))
except (TypeError, ValueError):
pct = DEFAULT_MEMORY_PCT
return cls(path, str(name), norm, max(1, min(pct, 90)))
@classmethod
def create(cls, path: str, binaries: list[str], name: str | None = None,
memory_pct: int = DEFAULT_MEMORY_PCT, load: dict | None = None) -> "Project":
"""Write a new project file listing ``binaries`` (an ad-hoc project).
``load`` carries per-binary load options (processor/base/ida_args) that
apply to every binary given here — a headerless blob needs them, and one
command line normally adds blobs of the same kind.
"""
if not binaries:
raise ProjectError("a project needs at least one binary")
entries, seen = [], set()
for b in binaries: # the same file twice on one command line is a typo
p = os.path.abspath(os.path.expanduser(b))
key = os.path.realpath(p)
if key in seen:
continue
seen.add(key)
e = {"path": p}
e.update({k: v for k, v in (load or {}).items() if v})
entries.append(e)
path = os.path.abspath(os.path.expanduser(path))
proj = cls(path, name or os.path.splitext(os.path.basename(path))[0],
entries, memory_pct)
proj.save()
return proj
def save(self) -> None:
data = {"name": self.name, "memory_pct": self.memory_pct,
"binaries": self._entries}
tmp = self.path + ".tmp"
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
with open(tmp, "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
os.replace(tmp, self.path)
# -- layout ------------------------------------------------------------ #
@property
def sidecar(self) -> str:
"""The directory holding staged binaries, databases and indexes."""
return os.path.splitext(self.path)[0] + SIDECAR_SUFFIX
@property
def bin_dir(self) -> str:
return os.path.join(self.sidecar, "bin")
@property
def index_dir(self) -> str:
return os.path.join(self.sidecar, "idx")
def index_path(self, ref: BinaryRef) -> str:
"""Where the cached per-binary index lives (phase 2)."""
return os.path.join(self.index_dir, ref.label + ".json")
# -- binaries ---------------------------------------------------------- #
def _build_refs(self) -> tuple[BinaryRef, ...]:
base = os.path.dirname(self.path)
refs: list[BinaryRef] = []
used: set[str] = set()
for e in self._entries:
src = os.path.expanduser(str(e["path"]))
if not os.path.isabs(src): # relative to the project file
src = os.path.join(base, src)
src = os.path.abspath(src)
label = str(e.get("label") or os.path.basename(src)) or "binary"
label = label.replace("/", "_").replace(os.sep, "_")
if label in used: # labels name files; keep them unique + stable
n = 2
while f"{label}_{n}" in used:
n += 1
label = f"{label}_{n}"
used.add(label)
refs.append(BinaryRef(
label=label, source=src,
staged=os.path.join(self.bin_dir, label),
processor=str(e.get("processor") or ""),
base=_as_addr(e.get("base")),
ida_args=str(e.get("ida_args") or "")))
return tuple(refs)
@property
def refs(self) -> tuple[BinaryRef, ...]:
return self._refs
def set_load(self, label: str, processor: str = "", base: int = 0,
ida_args: str = "") -> BinaryRef | None:
"""Record how ``label`` should be loaded, and persist it.
Answered once: the dialog that asks writes the answer here, so reopening
the project doesn't ask again — and neither does adding the same blob to
another project, since it travels with the entry.
"""
ref = self.by_label(label)
if ref is None:
return None
i = self._refs.index(ref)
e = self._entries[i]
if processor:
e["processor"] = processor
if base:
e["base"] = int(base)
if ida_args:
e["ida_args"] = ida_args
self._refs = self._build_refs()
self.save()
return self._refs[i]
def by_label(self, label: str) -> BinaryRef | None:
return next((r for r in self._refs if r.label == label), None)
def by_source(self, binary: str) -> BinaryRef | None:
"""The entry for ``binary``, matched by resolved path.
Identity is the real path, not the file name: a project can legitimately
hold two different ``foo.elf`` from different directories (the labels
disambiguate them), but the same file must not be listed twice — and
``./a.elf``, ``/abs/a.elf`` and a symlink to it are all the same file.
"""
key = os.path.realpath(os.path.abspath(os.path.expanduser(binary)))
return next((r for r in self._refs
if os.path.realpath(r.source) == key), None)
def add(self, binary: str, label: str | None = None,
load: dict | None = None) -> BinaryRef:
"""Add a binary, or return the existing entry if it's already here."""
existing = self.by_source(binary)
if existing is not None:
return existing
entry = {"path": os.path.abspath(os.path.expanduser(binary))}
if label:
entry["label"] = label
entry.update({k: v for k, v in (load or {}).items() if v})
self._entries.append(entry)
self._refs = self._build_refs()
return self._refs[-1]
def remove(self, label: str) -> bool:
ref = self.by_label(label)
if ref is None:
return False
i = self._refs.index(ref)
del self._entries[i]
self._refs = self._build_refs()
return True
# -- staging ----------------------------------------------------------- #
def is_stale(self, ref: BinaryRef) -> bool:
"""True when the staged file is missing or no longer matches its source."""
staged = _stat_key(ref.staged)
return staged is None or staged != _stat_key(ref.source)
def stage(self, ref: BinaryRef) -> str:
"""Ensure ``ref`` is staged in the sidecar; returns the staged path.
Re-staging a changed source drops its database: the DB describes the old
bytes, so keeping it would silently mismatch the disassembly (any renames
in it are lost, which is why callers should say so out loud).
"""
if not os.path.isfile(ref.source):
raise ProjectError(f"no such binary: {ref.source}")
if not self.is_stale(ref):
return ref.staged
os.makedirs(self.bin_dir, exist_ok=True)
tmp = ref.staged + ".staging"
_unlink(tmp)
# copy2 (not link): keeps size+mtime so freshness compares cleanly, while
# leaving the staged bytes immune to an in-place rewrite of the source.
shutil.copy2(ref.source, tmp)
os.replace(tmp, ref.staged)
for suf in DB_SUFFIXES: # the old DB describes the old bytes
_unlink(ref.staged + suf)
return ref.staged
def stage_all(self, progress=None) -> list[BinaryRef]:
out = []
for ref in self._refs:
if progress is not None:
progress(f"staging {ref.label}\u2026")
self.stage(ref)
out.append(ref)
return out
def sweep_scratch(self, ref: BinaryRef) -> int:
"""Delete IDA's unpacked working files (never the ``.i64``) for ``ref``.
A hard-killed worker leaves them behind and the database then refuses to
reopen. Only safe when no worker holds it.
"""
return sum(1 for suf in SCRATCH_SUFFIXES if _unlink(ref.staged + suf))
def has_db(self, ref: BinaryRef) -> bool:
"""True once the binary has been analysed and saved at least once."""
return os.path.isfile(ref.db)
def __repr__(self) -> str: # pragma: no cover - debug aid
return f"<Project {self.name!r} {len(self._refs)} binaries {self.path}>"
|