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
|
"""Where the database goes when the binary's own directory can't hold it.
IDA puts its database beside the binary: ``/bin/ls`` becomes ``/bin/ls.i64``,
plus ``.id0/.id1/.id2/.nam/.til`` scratch files unpacked into the same directory
while it is open. For anything the user doesn't own -- ``/bin``, ``/usr/lib``, a
read-only mount, a root-owned firmware drop -- that write is denied, and the only
thing to see is the backend's account of it::
idalib worker launcher 2425195 exited with status 1
[ida-nexus] Failed to open database /usr/bin/ls
which names neither the file it failed to create nor the reason. Worse, the
failure costs a full analysis wait first. So: check whether the database can be
written BEFORE opening anything, and when it can't, propose a path that works.
IDA Nexus takes ``output_database`` for exactly this, and project mode already
relies on it (everything lands in the project's sidecar). This is the
single-binary equivalent, and the proposed location is *stable per binary* --
reopening ``/bin/ls`` tomorrow finds yesterday's names and comments, which a
temporary directory would not.
Pure stdlib: no IDA, no IDA Nexus, no Textual. Nothing here writes anything.
"""
from __future__ import annotations
import hashlib
import os
#: Overrides where relocated databases are kept.
DB_DIR_ENV = "IDATUI_DB_DIR"
#: Deliberately under XDG_DATA_HOME, not XDG_CACHE_HOME. An .i64 looks like a
#: cache -- it is derived from the binary and can be rebuilt -- but it also holds
#: every name, comment and type the session produced, and cache directories are
#: something cleaners are entitled to empty.
_XDG_DEFAULT = os.path.join("~", ".local", "share", "idatui", "db")
def expected_idb(binary: str) -> str:
"""The database IDA would create for ``binary``, byte for byte as Nexus does.
Mirrors ``ida_nexus._resolver.expected_idb_path``: a path that is already an
``.i64`` is the database, anything else gets ``.i64`` appended (IDA 9 is
64-bit only, so there is no ``.idb`` case to consider).
"""
path = os.path.abspath(os.path.expanduser(binary))
return path if path.lower().endswith(".i64") else path + ".i64"
def db_root() -> str:
"""Directory holding relocated databases."""
override = os.environ.get(DB_DIR_ENV)
if override:
return os.path.abspath(os.path.expanduser(override))
xdg = os.environ.get("XDG_DATA_HOME")
if xdg:
return os.path.join(os.path.abspath(os.path.expanduser(xdg)), "idatui", "db")
return os.path.abspath(os.path.expanduser(_XDG_DEFAULT))
def _slug(name: str) -> str:
"""``name`` reduced to something safe to paste into a shell command."""
out = "".join(c if (c.isalnum() or c in "._-") else "_" for c in name)
return out.strip("._-")[:64] or "binary"
def relocated_idb(binary: str) -> str:
"""A writable database path for ``binary``, stable across runs.
Keyed by the resolved source path so the same binary always maps to the same
database (your work comes back), and hashed so two ``busybox``es from
different directories cannot collide. Flat rather than one directory per
binary: IDA unpacks its scratch files alongside the ``.i64`` under the same
stem, and the hash already makes that stem unique.
"""
real = os.path.normcase(os.path.realpath(os.path.expanduser(binary)))
digest = hashlib.sha1(real.encode("utf-8", "surrogateescape")).hexdigest()[:8]
stem = os.path.basename(real.rstrip(os.sep) or real)
if stem.lower().endswith(".i64"):
stem = stem[:-4]
return os.path.join(db_root(), f"{_slug(stem)}-{digest}.i64")
def blocked_reason(binary: str) -> str | None:
"""Why IDA could not write ``binary``'s database here, or None if it can.
A short phrase that completes "can't open this binary because ...", meant to
be shown to a human. ``None`` is the common answer and must stay cheap: only
``os.access`` calls, no probe files in the user's directories.
``os.access`` is not the whole truth (ACLs, an NFS mount that lies, a full
disk), which is why the caller also treats a failed open as a candidate for
relocation. It is right about the case that actually happens -- a binary in a
system directory -- and being right there is worth a syscall.
"""
idb = expected_idb(binary)
src = os.path.abspath(os.path.expanduser(binary))
if not os.path.exists(src):
return None # not ours to judge: a registered database, or a typo
parent = os.path.dirname(idb) or "."
if not os.path.isdir(parent):
return f"{parent} is not a directory"
if not os.access(parent, os.W_OK | os.X_OK):
return f"{parent} is not writable"
if os.path.exists(idb) and not os.access(idb, os.W_OK):
# The directory is fine but the database itself isn't ours -- someone
# else's analysis, or a root-owned .i64 shipped with the firmware. IDA
# rewrites the .i64 on save, so read-only is a dead end, not a view.
return f"{idb} exists but is not writable"
return None
def can_create(target: str) -> bool:
"""Whether ``target`` can really be created right now, by trying it.
The truthful version of ``blocked_reason``'s ``os.access`` check, and the
only one that knows about ACLs, a read-only remount or a full disk. It costs
a create + unlink in the user's directory, so it is for the path where an
open has ALREADY failed and the question is what to blame -- never for the
pre-flight check, which must not touch anything.
An existing ``target`` is left strictly alone: this must never truncate a
database.
"""
target = os.path.abspath(os.path.expanduser(target))
if os.path.exists(target):
return True
try:
fd = os.open(target, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except OSError:
return False
os.close(fd)
try:
os.unlink(target)
except OSError: # created it but cannot remove it: still "creatable"
pass
return True
def describe(binary: str, target: str) -> str:
"""One line about what relocating ``binary`` to ``target`` will do."""
if os.path.exists(target):
return "resuming the database already kept there (your names and comments)"
return f"a new database; the original {expected_idb(binary)} is never written"
def ensure_parent(target: str) -> str:
"""Create ``target``'s directory (private) and return ``target``.
0700 because a database exposes the whole binary plus whatever the session
recorded about it, and this directory accumulates one for every binary
opened from a place that could not hold its own.
"""
parent = os.path.dirname(os.path.abspath(os.path.expanduser(target)))
if parent:
os.makedirs(parent, mode=0o700, exist_ok=True)
return os.path.abspath(os.path.expanduser(target))
|