declib 3.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- declib/__init__.py +9 -0
- declib/__main__.py +190 -0
- declib/api/__init__.py +13 -0
- declib/api/artifact_dict.py +153 -0
- declib/api/artifact_lifter.py +161 -0
- declib/api/decompiler_client.py +1219 -0
- declib/api/decompiler_interface.py +1261 -0
- declib/api/decompiler_server.py +782 -0
- declib/api/server_registry.py +171 -0
- declib/api/type_definition_parser.py +201 -0
- declib/api/type_parser.py +409 -0
- declib/api/utils.py +31 -0
- declib/artifacts/__init__.py +93 -0
- declib/artifacts/artifact.py +311 -0
- declib/artifacts/comment.py +49 -0
- declib/artifacts/context.py +61 -0
- declib/artifacts/decompilation.py +35 -0
- declib/artifacts/enum.py +53 -0
- declib/artifacts/formatting.py +27 -0
- declib/artifacts/func.py +433 -0
- declib/artifacts/global_variable.py +31 -0
- declib/artifacts/patch.py +49 -0
- declib/artifacts/segment.py +37 -0
- declib/artifacts/stack_variable.py +50 -0
- declib/artifacts/struct.py +184 -0
- declib/artifacts/typedef.py +59 -0
- declib/cli/__init__.py +3 -0
- declib/cli/decompiler_cli.py +1487 -0
- declib/configuration.py +184 -0
- declib/decompiler_stubs/__init__.py +0 -0
- declib/decompiler_stubs/angr_declib/__init__.py +4 -0
- declib/decompiler_stubs/binja_declib/__init__.py +4 -0
- declib/decompiler_stubs/ida_declib.py +8 -0
- declib/decompilers/__init__.py +8 -0
- declib/decompilers/angr/__init__.py +11 -0
- declib/decompilers/angr/artifact_lifter.py +46 -0
- declib/decompilers/angr/compat.py +262 -0
- declib/decompilers/angr/interface.py +949 -0
- declib/decompilers/binja/__init__.py +0 -0
- declib/decompilers/binja/artifact_lifter.py +32 -0
- declib/decompilers/binja/hooks.py +201 -0
- declib/decompilers/binja/interface.py +795 -0
- declib/decompilers/ghidra/__init__.py +0 -0
- declib/decompilers/ghidra/artifact_lifter.py +60 -0
- declib/decompilers/ghidra/compat/__init__.py +0 -0
- declib/decompilers/ghidra/compat/headless.py +156 -0
- declib/decompilers/ghidra/compat/imports.py +78 -0
- declib/decompilers/ghidra/compat/state.py +54 -0
- declib/decompilers/ghidra/compat/transaction.py +30 -0
- declib/decompilers/ghidra/hooks.py +242 -0
- declib/decompilers/ghidra/interface.py +1433 -0
- declib/decompilers/ida/__init__.py +0 -0
- declib/decompilers/ida/artifact_lifter.py +51 -0
- declib/decompilers/ida/compat.py +2054 -0
- declib/decompilers/ida/hooks.py +700 -0
- declib/decompilers/ida/ida_ui.py +80 -0
- declib/decompilers/ida/interface.py +659 -0
- declib/logger.py +101 -0
- declib/plugin_installer.py +259 -0
- declib/skills/__init__.py +24 -0
- declib/skills/decompiler/SKILL.md +316 -0
- declib/ui/__init__.py +33 -0
- declib/ui/qt_objects.py +146 -0
- declib/ui/utils.py +115 -0
- declib/ui/version.py +14 -0
- declib-3.8.0.dist-info/METADATA +138 -0
- declib-3.8.0.dist-info/RECORD +71 -0
- declib-3.8.0.dist-info/WHEEL +5 -0
- declib-3.8.0.dist-info/entry_points.txt +3 -0
- declib-3.8.0.dist-info/licenses/LICENSE +24 -0
- declib-3.8.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
# Note to reader: most of this code was generated by Claude 4.5. It may contain errors and was designed
|
|
2
|
+
# in tandem with decompiler_client.py and the tests/test_client_server.py file. This comment will be
|
|
3
|
+
# removed when the majority of the file is owned by a human author.
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import pickle
|
|
7
|
+
import queue
|
|
8
|
+
import socket
|
|
9
|
+
import struct
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import tempfile
|
|
13
|
+
import os
|
|
14
|
+
from typing import Optional, Dict, Any, List
|
|
15
|
+
|
|
16
|
+
from declib.api.decompiler_interface import DecompilerInterface
|
|
17
|
+
from declib.api import server_registry
|
|
18
|
+
from declib.artifacts.formatting import ArtifactFormat
|
|
19
|
+
|
|
20
|
+
_l = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# JSON, not TOML: the `toml` package's encoder mangles raw `\x` escapes,
|
|
23
|
+
# which show up in decompilation text for C char literals like `'\x01'`.
|
|
24
|
+
_WIRE_FMT = ArtifactFormat.JSON
|
|
25
|
+
|
|
26
|
+
# Sentinel used to poke the main-thread dispatcher awake on shutdown.
|
|
27
|
+
_MAIN_THREAD_SHUTDOWN = object()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _MainThreadError:
|
|
31
|
+
"""Wrap exceptions that occurred on the main thread so the waiting client
|
|
32
|
+
thread can re-raise them after receiving the result."""
|
|
33
|
+
|
|
34
|
+
__slots__ = ("exc",)
|
|
35
|
+
|
|
36
|
+
def __init__(self, exc: BaseException):
|
|
37
|
+
self.exc = exc
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SocketProtocol:
|
|
41
|
+
"""Helper class for socket protocol message framing"""
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def send_message(sock: socket.socket, data: Any) -> None:
|
|
45
|
+
"""Send a pickled message with length prefix"""
|
|
46
|
+
try:
|
|
47
|
+
pickled_data = pickle.dumps(data)
|
|
48
|
+
msg_len = len(pickled_data)
|
|
49
|
+
|
|
50
|
+
# Send 4-byte length prefix
|
|
51
|
+
sock.sendall(struct.pack('!I', msg_len))
|
|
52
|
+
# Send pickled data
|
|
53
|
+
sock.sendall(pickled_data)
|
|
54
|
+
except (ConnectionError, BrokenPipeError, OSError) as e:
|
|
55
|
+
# Expected during shutdown when socket is closed, just re-raise
|
|
56
|
+
raise
|
|
57
|
+
except Exception as e:
|
|
58
|
+
# Unexpected error - log it
|
|
59
|
+
_l.error(f"Failed to send message (pickle.dumps): {e}")
|
|
60
|
+
_l.error(f"Data type: {type(data)}")
|
|
61
|
+
if hasattr(data, '__dict__'):
|
|
62
|
+
_l.error(f"Data dict: {data.__dict__}")
|
|
63
|
+
raise
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def recv_message(sock: socket.socket) -> Any:
|
|
67
|
+
"""Receive a pickled message with length prefix"""
|
|
68
|
+
pickled_data = b''
|
|
69
|
+
try:
|
|
70
|
+
# Receive 4-byte length prefix
|
|
71
|
+
len_data = sock.recv(4)
|
|
72
|
+
if len(len_data) != 4:
|
|
73
|
+
raise ConnectionError("Failed to receive message length")
|
|
74
|
+
|
|
75
|
+
msg_len = struct.unpack('!I', len_data)[0]
|
|
76
|
+
|
|
77
|
+
# Receive the pickled data
|
|
78
|
+
while len(pickled_data) < msg_len:
|
|
79
|
+
chunk = sock.recv(msg_len - len(pickled_data))
|
|
80
|
+
if not chunk:
|
|
81
|
+
raise ConnectionError("Connection closed while receiving message")
|
|
82
|
+
pickled_data += chunk
|
|
83
|
+
|
|
84
|
+
return pickle.loads(pickled_data)
|
|
85
|
+
except (ConnectionError, socket.timeout):
|
|
86
|
+
# Expected during shutdown or normal timeout, just re-raise without logging
|
|
87
|
+
raise
|
|
88
|
+
except Exception as e:
|
|
89
|
+
# Unexpected error - log it
|
|
90
|
+
_l.error(f"Failed to receive message (pickle.loads): {e}")
|
|
91
|
+
if pickled_data:
|
|
92
|
+
_l.error(f"Received {len(pickled_data)} bytes of pickle data")
|
|
93
|
+
raise
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SocketServerHandler:
|
|
97
|
+
"""Handler for individual client connections"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, deci: DecompilerInterface, server: 'DecompilerServer' = None):
|
|
100
|
+
self.deci = deci
|
|
101
|
+
self.server = server
|
|
102
|
+
self._light_caches = {}
|
|
103
|
+
self._cache_lock = threading.Lock()
|
|
104
|
+
self._cache_ttl = 10.0
|
|
105
|
+
|
|
106
|
+
def _dispatch(self, func, *args, **kwargs):
|
|
107
|
+
"""Call ``func`` either directly or via the server's main-thread queue.
|
|
108
|
+
|
|
109
|
+
Backends like IDA reject cross-thread API access, so the server
|
|
110
|
+
declares ``_requires_main_thread`` and we route everything through
|
|
111
|
+
its dispatcher. For thread-safe backends (ghidra headless, angr,
|
|
112
|
+
binja) we short-circuit to a direct call.
|
|
113
|
+
"""
|
|
114
|
+
if self.server is None or not self.server.requires_main_thread:
|
|
115
|
+
return func(*args, **kwargs)
|
|
116
|
+
return self.server.run_on_main_thread(func, *args, **kwargs)
|
|
117
|
+
|
|
118
|
+
def handle_client(self, client_socket: socket.socket, addr: str):
|
|
119
|
+
"""Handle a client connection"""
|
|
120
|
+
_l.info(f"Client connected: {addr}")
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
while True:
|
|
124
|
+
try:
|
|
125
|
+
request = SocketProtocol.recv_message(client_socket)
|
|
126
|
+
response = self._process_request(request, client_socket=client_socket)
|
|
127
|
+
SocketProtocol.send_message(client_socket, response)
|
|
128
|
+
except ConnectionError:
|
|
129
|
+
# Client disconnected
|
|
130
|
+
break
|
|
131
|
+
except Exception as e:
|
|
132
|
+
# Send error response
|
|
133
|
+
error_response = {"error": str(e), "type": type(e).__name__}
|
|
134
|
+
try:
|
|
135
|
+
SocketProtocol.send_message(client_socket, error_response)
|
|
136
|
+
except:
|
|
137
|
+
break
|
|
138
|
+
finally:
|
|
139
|
+
# Remove from event subscribers if subscribed
|
|
140
|
+
if self.server:
|
|
141
|
+
with self.server._event_subscribers_lock:
|
|
142
|
+
if client_socket in self.server._event_subscribers:
|
|
143
|
+
self.server._event_subscribers.remove(client_socket)
|
|
144
|
+
_l.debug("Removed client from event subscribers")
|
|
145
|
+
|
|
146
|
+
client_socket.close()
|
|
147
|
+
_l.info(f"Client disconnected: {addr}")
|
|
148
|
+
|
|
149
|
+
def _process_request(self, request: Dict[str, Any], client_socket: socket.socket = None) -> Any:
|
|
150
|
+
"""Process a client request and return response"""
|
|
151
|
+
request_type = request.get("type")
|
|
152
|
+
|
|
153
|
+
if request_type == "subscribe_events":
|
|
154
|
+
# Client wants to subscribe to artifact change events
|
|
155
|
+
if self.server and client_socket:
|
|
156
|
+
with self.server._event_subscribers_lock:
|
|
157
|
+
if client_socket not in self.server._event_subscribers:
|
|
158
|
+
self.server._event_subscribers.append(client_socket)
|
|
159
|
+
_l.info(f"Client subscribed to events (total subscribers: {len(self.server._event_subscribers)})")
|
|
160
|
+
return {"status": "subscribed"}
|
|
161
|
+
else:
|
|
162
|
+
return {"status": "error", "message": "Server not available"}
|
|
163
|
+
|
|
164
|
+
elif request_type == "unsubscribe_events":
|
|
165
|
+
# Client wants to unsubscribe from events
|
|
166
|
+
if self.server and client_socket:
|
|
167
|
+
with self.server._event_subscribers_lock:
|
|
168
|
+
if client_socket in self.server._event_subscribers:
|
|
169
|
+
self.server._event_subscribers.remove(client_socket)
|
|
170
|
+
_l.info(f"Client unsubscribed from events (total subscribers: {len(self.server._event_subscribers)})")
|
|
171
|
+
return {"status": "unsubscribed"}
|
|
172
|
+
else:
|
|
173
|
+
return {"status": "error", "message": "Server not available"}
|
|
174
|
+
|
|
175
|
+
elif request_type == "server_info":
|
|
176
|
+
# Return the metadata cached by the server at init time. Reading
|
|
177
|
+
# ``deci.binary_hash`` or ``deci.binary_path`` here would re-enter
|
|
178
|
+
# the backend from a worker thread — which IDA/idalib rejects
|
|
179
|
+
# with "Function can be called from the main thread only".
|
|
180
|
+
if self.server is not None and self.server._cached_server_info is not None:
|
|
181
|
+
return dict(self.server._cached_server_info)
|
|
182
|
+
return {
|
|
183
|
+
"name": "DecLib DecompilerServer (AF_UNIX)",
|
|
184
|
+
"version": "3.0.0",
|
|
185
|
+
"decompiler": self.deci.name if self.deci else "unknown",
|
|
186
|
+
"protocol": "unix_socket",
|
|
187
|
+
"binary_hash": None,
|
|
188
|
+
"binary_path": None,
|
|
189
|
+
"server_id": self.server.server_id if self.server else None,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
elif request_type == "get_light_artifacts":
|
|
193
|
+
collection_name = request.get("collection_name")
|
|
194
|
+
return self._get_light_artifacts(collection_name)
|
|
195
|
+
|
|
196
|
+
elif request_type == "get_full_artifact":
|
|
197
|
+
collection_name = request.get("collection_name")
|
|
198
|
+
key = request.get("key")
|
|
199
|
+
|
|
200
|
+
def _fetch_full_artifact():
|
|
201
|
+
collection = getattr(self.deci, collection_name)
|
|
202
|
+
return collection[key]
|
|
203
|
+
|
|
204
|
+
artifact = self._dispatch(_fetch_full_artifact)
|
|
205
|
+
|
|
206
|
+
# Serialize the full artifact safely
|
|
207
|
+
if hasattr(artifact, 'dumps') and hasattr(artifact, '__class__'):
|
|
208
|
+
try:
|
|
209
|
+
return {
|
|
210
|
+
'type': artifact.__class__.__name__,
|
|
211
|
+
'module': artifact.__class__.__module__,
|
|
212
|
+
'data': artifact.dumps(fmt=_WIRE_FMT),
|
|
213
|
+
'is_artifact': True
|
|
214
|
+
}
|
|
215
|
+
except Exception as e:
|
|
216
|
+
_l.warning(f"Failed to serialize full artifact: {e}")
|
|
217
|
+
# Fall back to direct return, which might fail with pickle
|
|
218
|
+
return artifact
|
|
219
|
+
else:
|
|
220
|
+
return artifact
|
|
221
|
+
|
|
222
|
+
elif request_type == "method_call":
|
|
223
|
+
method_name = request.get("method_name")
|
|
224
|
+
args = request.get("args", [])
|
|
225
|
+
kwargs = request.get("kwargs", {})
|
|
226
|
+
|
|
227
|
+
# Handle dotted method names like "art_lifter.lift"
|
|
228
|
+
if "." in method_name:
|
|
229
|
+
obj = self.deci
|
|
230
|
+
for attr in method_name.split("."):
|
|
231
|
+
obj = getattr(obj, attr)
|
|
232
|
+
method = obj
|
|
233
|
+
else:
|
|
234
|
+
# Get the method from the decompiler interface
|
|
235
|
+
method = getattr(self.deci, method_name)
|
|
236
|
+
result = self._dispatch(method, *args, **kwargs)
|
|
237
|
+
|
|
238
|
+
# Check if result is an artifact and serialize it properly
|
|
239
|
+
if hasattr(result, 'dumps') and hasattr(result, '__class__'):
|
|
240
|
+
# This looks like an artifact, serialize it safely
|
|
241
|
+
try:
|
|
242
|
+
return {
|
|
243
|
+
'type': result.__class__.__name__,
|
|
244
|
+
'module': result.__class__.__module__,
|
|
245
|
+
'data': result.dumps(fmt=_WIRE_FMT),
|
|
246
|
+
'is_artifact': True
|
|
247
|
+
}
|
|
248
|
+
except Exception as e:
|
|
249
|
+
_l.warning(f"Failed to serialize result artifact: {e}")
|
|
250
|
+
# Fall back to direct return, which might fail with pickle
|
|
251
|
+
return result
|
|
252
|
+
else:
|
|
253
|
+
# Not an artifact, return as-is
|
|
254
|
+
return result
|
|
255
|
+
|
|
256
|
+
elif request_type == "property_get":
|
|
257
|
+
property_name = request.get("property_name")
|
|
258
|
+
return self._dispatch(lambda: getattr(self.deci, property_name))
|
|
259
|
+
|
|
260
|
+
elif request_type == "shutdown_deci":
|
|
261
|
+
if self.deci and self.server is not None and not self.server._deci_shutdown_done:
|
|
262
|
+
# Route through the main-thread dispatcher for IDA — calling
|
|
263
|
+
# idapro.close_database() from a worker thread raises
|
|
264
|
+
# "Function can be called from the main thread only".
|
|
265
|
+
self._dispatch(self.deci.shutdown)
|
|
266
|
+
self.server._deci_shutdown_done = True
|
|
267
|
+
return {"status": "shutdown"}
|
|
268
|
+
|
|
269
|
+
elif request_type == "shutdown_server":
|
|
270
|
+
# Tear the server down asynchronously so we can still reply.
|
|
271
|
+
if self.server is not None:
|
|
272
|
+
threading.Thread(
|
|
273
|
+
target=self.server.stop, name="declib-server-shutdown", daemon=True
|
|
274
|
+
).start()
|
|
275
|
+
return {"status": "stopping"}
|
|
276
|
+
|
|
277
|
+
else:
|
|
278
|
+
raise ValueError(f"Unknown request type: {request_type}")
|
|
279
|
+
|
|
280
|
+
def _get_light_artifacts(self, collection_name: str) -> Dict:
|
|
281
|
+
"""Get light artifacts for a collection, computing and caching on first request"""
|
|
282
|
+
with self._cache_lock:
|
|
283
|
+
cache_entry = self._light_caches.get(collection_name)
|
|
284
|
+
|
|
285
|
+
# Check if we have a valid cache entry
|
|
286
|
+
if cache_entry and time.time() - cache_entry["timestamp"] < self._cache_ttl:
|
|
287
|
+
return cache_entry["items"]
|
|
288
|
+
|
|
289
|
+
# Cache miss or stale - compute light artifacts on-demand
|
|
290
|
+
_l.debug(f"Computing light artifacts for {collection_name} on-demand")
|
|
291
|
+
try:
|
|
292
|
+
collection = getattr(self.deci, collection_name)
|
|
293
|
+
if hasattr(collection, '_lifted_art_lister'):
|
|
294
|
+
start_time = time.time()
|
|
295
|
+
light_items = self._dispatch(collection._lifted_art_lister)
|
|
296
|
+
end_time = time.time()
|
|
297
|
+
|
|
298
|
+
# Convert artifacts to serializable format using their own serialization
|
|
299
|
+
serializable_items = {}
|
|
300
|
+
for addr, artifact in light_items.items():
|
|
301
|
+
try:
|
|
302
|
+
# Use the artifact's built-in serialization which handles complex objects
|
|
303
|
+
serialized = artifact.dumps(fmt=_WIRE_FMT)
|
|
304
|
+
# Store as a tuple of (type_name, serialized_data) for reconstruction
|
|
305
|
+
serializable_items[addr] = {
|
|
306
|
+
'type': artifact.__class__.__name__,
|
|
307
|
+
'module': artifact.__class__.__module__,
|
|
308
|
+
'data': serialized
|
|
309
|
+
}
|
|
310
|
+
except Exception as e:
|
|
311
|
+
_l.warning(f"Failed to serialize {artifact.__class__.__name__} at 0x{addr:x}: {e}")
|
|
312
|
+
# Skip problematic artifacts rather than failing completely
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
# Cache the serializable artifacts
|
|
316
|
+
self._light_caches[collection_name] = {
|
|
317
|
+
"items": serializable_items,
|
|
318
|
+
"timestamp": time.time()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
_l.info(f"Computed {len(serializable_items)} light {collection_name} in {end_time - start_time:.3f}s")
|
|
322
|
+
return serializable_items
|
|
323
|
+
else:
|
|
324
|
+
_l.warning(f"Collection {collection_name} does not support light artifacts")
|
|
325
|
+
return {}
|
|
326
|
+
|
|
327
|
+
except Exception as e:
|
|
328
|
+
_l.warning(f"Failed to compute light artifacts for {collection_name}: {e}")
|
|
329
|
+
# Return stale cache if available, otherwise empty dict
|
|
330
|
+
if cache_entry:
|
|
331
|
+
_l.debug(f"Returning stale cache for {collection_name} due to error")
|
|
332
|
+
return cache_entry["items"]
|
|
333
|
+
return {}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class DecompilerServer:
|
|
337
|
+
"""
|
|
338
|
+
A server that exposes DecompilerInterface APIs over AF_UNIX sockets.
|
|
339
|
+
|
|
340
|
+
This server wraps a DecompilerInterface instance and provides network access
|
|
341
|
+
to all its public methods and artifact collections through AF_UNIX sockets.
|
|
342
|
+
"""
|
|
343
|
+
|
|
344
|
+
def __init__(self,
|
|
345
|
+
decompiler_interface: Optional[DecompilerInterface] = None,
|
|
346
|
+
socket_path: Optional[str] = None,
|
|
347
|
+
server_id: Optional[str] = None,
|
|
348
|
+
register: bool = True,
|
|
349
|
+
**interface_kwargs):
|
|
350
|
+
"""
|
|
351
|
+
Initialize the DecompilerServer.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
decompiler_interface: An existing DecompilerInterface instance. If None,
|
|
355
|
+
one will be created using DecompilerInterface.discover()
|
|
356
|
+
socket_path: Path for the AF_UNIX socket. If None, a path is derived from server_id.
|
|
357
|
+
server_id: Optional explicit server ID. If None, a new one is generated.
|
|
358
|
+
register: If True, write the server info into the shared registry.
|
|
359
|
+
**interface_kwargs: Arguments passed to DecompilerInterface.discover() if
|
|
360
|
+
decompiler_interface is None
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
self.server_id = server_id or server_registry.new_server_id()
|
|
364
|
+
self.socket_path = socket_path
|
|
365
|
+
self._register = register
|
|
366
|
+
self._registered = False
|
|
367
|
+
self._server_socket = None
|
|
368
|
+
self._server_thread = None
|
|
369
|
+
self._running = False
|
|
370
|
+
self._clients = []
|
|
371
|
+
self._client_threads = []
|
|
372
|
+
|
|
373
|
+
# Main-thread dispatch: some backends (notably IDA/idalib) reject
|
|
374
|
+
# cross-thread API access. For those we route backend calls through
|
|
375
|
+
# a queue so they run on the thread that set the backend up.
|
|
376
|
+
self._main_thread_queue: "queue.Queue" = queue.Queue()
|
|
377
|
+
self._main_thread_ident: Optional[int] = None
|
|
378
|
+
|
|
379
|
+
# Event subscription tracking
|
|
380
|
+
self._event_subscribers = [] # List of sockets subscribed to events
|
|
381
|
+
self._event_subscribers_lock = threading.Lock()
|
|
382
|
+
|
|
383
|
+
# Track whether deci.shutdown() already ran, so teardown is idempotent
|
|
384
|
+
# across the worker-initiated stop() and the main-thread __exit__.
|
|
385
|
+
self._deci_shutdown_done = False
|
|
386
|
+
|
|
387
|
+
# Initialize the decompiler interface
|
|
388
|
+
if decompiler_interface is not None:
|
|
389
|
+
self.deci = decompiler_interface
|
|
390
|
+
else:
|
|
391
|
+
if interface_kwargs and interface_kwargs.get("headless", False):
|
|
392
|
+
forced_decompiler = interface_kwargs.get("force_decompiler", None)
|
|
393
|
+
if forced_decompiler is None:
|
|
394
|
+
_l.warning(f"Using a headless interface without setting a decompiler has unpredictable behavior!")
|
|
395
|
+
_l.info(f"Using headless interface utilizing %s", forced_decompiler)
|
|
396
|
+
else:
|
|
397
|
+
_l.info("Discovering decompiler interface...")
|
|
398
|
+
|
|
399
|
+
self.deci = DecompilerInterface.discover(**interface_kwargs)
|
|
400
|
+
if self.deci is None:
|
|
401
|
+
raise RuntimeError("Failed to discover decompiler interface")
|
|
402
|
+
|
|
403
|
+
# Cache static metadata on the *main* thread so that the connection
|
|
404
|
+
# handshake (`server_info`) never touches the backend from a worker
|
|
405
|
+
# thread — IDA/idalib raises "Function can be called from the main
|
|
406
|
+
# thread only" the moment such access happens.
|
|
407
|
+
self._cached_server_info = self._build_static_server_info()
|
|
408
|
+
|
|
409
|
+
# Create socket handler
|
|
410
|
+
self.handler = SocketServerHandler(self.deci, server=self)
|
|
411
|
+
|
|
412
|
+
# Register artifact change callbacks to broadcast events
|
|
413
|
+
self._register_artifact_callbacks()
|
|
414
|
+
|
|
415
|
+
# Generate socket path if not provided
|
|
416
|
+
if self.socket_path is None:
|
|
417
|
+
socket_path = server_registry.default_socket_path(self.server_id)
|
|
418
|
+
self.socket_path = socket_path
|
|
419
|
+
self._temp_dir = os.path.dirname(socket_path)
|
|
420
|
+
else:
|
|
421
|
+
self._temp_dir = None
|
|
422
|
+
|
|
423
|
+
_l.info(f"DecompilerServer initialized with {self.deci.name} interface (id={self.server_id})")
|
|
424
|
+
_l.info(f"Socket path: {self.socket_path}")
|
|
425
|
+
|
|
426
|
+
def _build_static_server_info(self) -> Dict[str, Any]:
|
|
427
|
+
"""Collect immutable server metadata on whatever thread calls us.
|
|
428
|
+
|
|
429
|
+
This runs from ``__init__`` — i.e. the thread that constructed the
|
|
430
|
+
deci (the main thread in the CLI path). Capturing the values here
|
|
431
|
+
means ``server_info`` replies can be served from any worker thread
|
|
432
|
+
without re-entering backends like IDA that reject cross-thread API
|
|
433
|
+
calls.
|
|
434
|
+
"""
|
|
435
|
+
binary_path = None
|
|
436
|
+
binary_hash = None
|
|
437
|
+
if self.deci:
|
|
438
|
+
try:
|
|
439
|
+
raw_path = self.deci.binary_path
|
|
440
|
+
binary_path = str(raw_path) if raw_path else None
|
|
441
|
+
except Exception as exc:
|
|
442
|
+
_l.debug("Failed to cache binary_path: %s", exc)
|
|
443
|
+
try:
|
|
444
|
+
binary_hash = self.deci.binary_hash
|
|
445
|
+
except Exception as exc:
|
|
446
|
+
_l.debug("Failed to cache binary_hash: %s", exc)
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
"name": "DecLib DecompilerServer (AF_UNIX)",
|
|
450
|
+
"version": "3.0.0",
|
|
451
|
+
"decompiler": self.deci.name if self.deci else "unknown",
|
|
452
|
+
"protocol": "unix_socket",
|
|
453
|
+
"binary_hash": binary_hash,
|
|
454
|
+
"binary_path": binary_path,
|
|
455
|
+
"server_id": self.server_id,
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
def _register_artifact_callbacks(self):
|
|
459
|
+
"""Register callbacks to broadcast artifact changes to subscribed clients"""
|
|
460
|
+
from declib.artifacts import Comment, Struct, Enum, Typedef, GlobalVariable, FunctionHeader, StackVariable
|
|
461
|
+
|
|
462
|
+
# Register callbacks for different artifact types
|
|
463
|
+
self.deci.artifact_change_callbacks[Comment].append(
|
|
464
|
+
lambda artifact, **kwargs: self._broadcast_event("comment_changed", artifact, **kwargs)
|
|
465
|
+
)
|
|
466
|
+
self.deci.artifact_change_callbacks[Struct].append(
|
|
467
|
+
lambda artifact, **kwargs: self._broadcast_event("struct_changed", artifact, **kwargs)
|
|
468
|
+
)
|
|
469
|
+
self.deci.artifact_change_callbacks[Enum].append(
|
|
470
|
+
lambda artifact, **kwargs: self._broadcast_event("enum_changed", artifact, **kwargs)
|
|
471
|
+
)
|
|
472
|
+
self.deci.artifact_change_callbacks[Typedef].append(
|
|
473
|
+
lambda artifact, **kwargs: self._broadcast_event("typedef_changed", artifact, **kwargs)
|
|
474
|
+
)
|
|
475
|
+
self.deci.artifact_change_callbacks[GlobalVariable].append(
|
|
476
|
+
lambda artifact, **kwargs: self._broadcast_event("global_variable_changed", artifact, **kwargs)
|
|
477
|
+
)
|
|
478
|
+
self.deci.artifact_change_callbacks[FunctionHeader].append(
|
|
479
|
+
lambda artifact, **kwargs: self._broadcast_event("function_header_changed", artifact, **kwargs)
|
|
480
|
+
)
|
|
481
|
+
self.deci.artifact_change_callbacks[StackVariable].append(
|
|
482
|
+
lambda artifact, **kwargs: self._broadcast_event("stack_variable_changed", artifact, **kwargs)
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
def _broadcast_event(self, event_type: str, artifact, **kwargs):
|
|
486
|
+
"""Broadcast an artifact change event to all subscribed clients"""
|
|
487
|
+
with self._event_subscribers_lock:
|
|
488
|
+
if not self._event_subscribers:
|
|
489
|
+
_l.debug(f"No subscribers for event: {event_type}")
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
# Serialize the artifact
|
|
493
|
+
try:
|
|
494
|
+
serialized_artifact = {
|
|
495
|
+
'type': artifact.__class__.__name__,
|
|
496
|
+
'module': artifact.__class__.__module__,
|
|
497
|
+
'data': artifact.dumps(fmt=_WIRE_FMT),
|
|
498
|
+
'is_artifact': True
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
event_message = {
|
|
502
|
+
"event_type": event_type,
|
|
503
|
+
"artifact": serialized_artifact,
|
|
504
|
+
"kwargs": kwargs
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
# Send to all subscribers
|
|
508
|
+
dead_subscribers = []
|
|
509
|
+
for subscriber_socket in self._event_subscribers:
|
|
510
|
+
try:
|
|
511
|
+
SocketProtocol.send_message(subscriber_socket, event_message)
|
|
512
|
+
_l.debug(f"Broadcasted {event_type} to subscriber")
|
|
513
|
+
except Exception as e:
|
|
514
|
+
_l.warning(f"Failed to send event to subscriber: {e}")
|
|
515
|
+
dead_subscribers.append(subscriber_socket)
|
|
516
|
+
|
|
517
|
+
# Remove dead subscribers
|
|
518
|
+
for dead_socket in dead_subscribers:
|
|
519
|
+
self._event_subscribers.remove(dead_socket)
|
|
520
|
+
_l.debug("Removed dead subscriber")
|
|
521
|
+
|
|
522
|
+
except Exception as e:
|
|
523
|
+
_l.error(f"Failed to broadcast event {event_type}: {e}")
|
|
524
|
+
|
|
525
|
+
def start(self):
|
|
526
|
+
"""Start the server in a separate thread"""
|
|
527
|
+
if self._running:
|
|
528
|
+
_l.warning("Server is already running")
|
|
529
|
+
return
|
|
530
|
+
|
|
531
|
+
_l.info(f"Starting DecompilerServer on {self.socket_path}")
|
|
532
|
+
|
|
533
|
+
# Create socket (AF_UNIX if available, else AF_INET)
|
|
534
|
+
if hasattr(socket, "AF_UNIX"):
|
|
535
|
+
self._server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
536
|
+
self._server_socket.settimeout(1.0)
|
|
537
|
+
if os.path.exists(self.socket_path):
|
|
538
|
+
os.unlink(self.socket_path)
|
|
539
|
+
self._server_socket.bind(self.socket_path)
|
|
540
|
+
else:
|
|
541
|
+
self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
542
|
+
self._server_socket.settimeout(1.0)
|
|
543
|
+
self._server_socket.bind(('127.0.0.1', 0))
|
|
544
|
+
port = self._server_socket.getsockname()[1]
|
|
545
|
+
try:
|
|
546
|
+
with open(self.socket_path, 'w') as f:
|
|
547
|
+
f.write(str(port))
|
|
548
|
+
except Exception as e:
|
|
549
|
+
_l.error(f"Failed to write port to {self.socket_path}: {e}")
|
|
550
|
+
self._server_socket.listen(5)
|
|
551
|
+
|
|
552
|
+
# Set running flag before starting thread
|
|
553
|
+
self._running = True
|
|
554
|
+
|
|
555
|
+
# Start server in a separate thread
|
|
556
|
+
self._server_thread = threading.Thread(target=self._server_loop, daemon=True)
|
|
557
|
+
self._server_thread.start()
|
|
558
|
+
|
|
559
|
+
# Register in shared registry so other processes can find us.
|
|
560
|
+
if self._register:
|
|
561
|
+
try:
|
|
562
|
+
binary_path = str(self.deci.binary_path) if self.deci and self.deci.binary_path else None
|
|
563
|
+
binary_hash = None
|
|
564
|
+
try:
|
|
565
|
+
binary_hash = self.deci.binary_hash if self.deci else None
|
|
566
|
+
except Exception:
|
|
567
|
+
binary_hash = None
|
|
568
|
+
server_registry.register_server({
|
|
569
|
+
"id": self.server_id,
|
|
570
|
+
"socket_path": self.socket_path,
|
|
571
|
+
"backend": self.deci.name if self.deci else None,
|
|
572
|
+
"binary_path": binary_path,
|
|
573
|
+
"binary_hash": binary_hash,
|
|
574
|
+
})
|
|
575
|
+
self._registered = True
|
|
576
|
+
except Exception as exc:
|
|
577
|
+
_l.warning("Failed to register server: %s", exc)
|
|
578
|
+
|
|
579
|
+
_l.info(f"DecompilerServer started successfully on unix://{self.socket_path}")
|
|
580
|
+
_l.info("Connect with: DecompilerClient.discover('unix://{}')".format(self.socket_path))
|
|
581
|
+
|
|
582
|
+
def _server_loop(self):
|
|
583
|
+
"""Main server loop"""
|
|
584
|
+
try:
|
|
585
|
+
while self._running:
|
|
586
|
+
try:
|
|
587
|
+
client_socket, addr = self._server_socket.accept()
|
|
588
|
+
self._clients.append(client_socket)
|
|
589
|
+
|
|
590
|
+
# Handle client in separate thread
|
|
591
|
+
client_thread = threading.Thread(
|
|
592
|
+
target=self.handler.handle_client,
|
|
593
|
+
args=(client_socket, str(addr)),
|
|
594
|
+
daemon=True
|
|
595
|
+
)
|
|
596
|
+
self._client_threads.append(client_thread)
|
|
597
|
+
client_thread.start()
|
|
598
|
+
|
|
599
|
+
except socket.timeout:
|
|
600
|
+
# Normal timeout, continue loop to check if we should stop
|
|
601
|
+
continue
|
|
602
|
+
except OSError:
|
|
603
|
+
# Socket was closed
|
|
604
|
+
break
|
|
605
|
+
except Exception as e:
|
|
606
|
+
_l.error(f"Error accepting client: {e}")
|
|
607
|
+
|
|
608
|
+
except Exception as e:
|
|
609
|
+
_l.error(f"Server loop error: {e}")
|
|
610
|
+
finally:
|
|
611
|
+
_l.info("Server loop ended")
|
|
612
|
+
|
|
613
|
+
def stop(self):
|
|
614
|
+
"""Stop the server"""
|
|
615
|
+
if not self._running:
|
|
616
|
+
_l.warning("Server is not running")
|
|
617
|
+
return
|
|
618
|
+
|
|
619
|
+
_l.info("Stopping DecompilerServer...")
|
|
620
|
+
self._running = False
|
|
621
|
+
|
|
622
|
+
# Wake the main-thread dispatcher so it can exit `wait_for_shutdown`.
|
|
623
|
+
try:
|
|
624
|
+
self._main_thread_queue.put_nowait(_MAIN_THREAD_SHUTDOWN)
|
|
625
|
+
except Exception:
|
|
626
|
+
pass
|
|
627
|
+
|
|
628
|
+
# Close all client connections
|
|
629
|
+
for client in self._clients:
|
|
630
|
+
try:
|
|
631
|
+
client.close()
|
|
632
|
+
except:
|
|
633
|
+
pass
|
|
634
|
+
|
|
635
|
+
# Close server socket
|
|
636
|
+
if self._server_socket:
|
|
637
|
+
self._server_socket.close()
|
|
638
|
+
|
|
639
|
+
# Wait for threads to finish (short timeout since we use daemon threads)
|
|
640
|
+
if self._server_thread and self._server_thread.is_alive():
|
|
641
|
+
self._server_thread.join(timeout=2.0)
|
|
642
|
+
|
|
643
|
+
for thread in self._client_threads:
|
|
644
|
+
if thread.is_alive():
|
|
645
|
+
thread.join(timeout=0.5)
|
|
646
|
+
|
|
647
|
+
# Clean up socket file and temp directory
|
|
648
|
+
if os.path.exists(self.socket_path):
|
|
649
|
+
os.unlink(self.socket_path)
|
|
650
|
+
|
|
651
|
+
if self._temp_dir and os.path.exists(self._temp_dir):
|
|
652
|
+
try:
|
|
653
|
+
os.rmdir(self._temp_dir)
|
|
654
|
+
except:
|
|
655
|
+
pass
|
|
656
|
+
|
|
657
|
+
# Remove from registry
|
|
658
|
+
if self._registered:
|
|
659
|
+
try:
|
|
660
|
+
server_registry.unregister_server(self.server_id)
|
|
661
|
+
except Exception as exc:
|
|
662
|
+
_l.debug("Failed to unregister server %s: %s", self.server_id, exc)
|
|
663
|
+
self._registered = False
|
|
664
|
+
|
|
665
|
+
# Shutdown the decompiler interface. For backends that need the main
|
|
666
|
+
# thread (IDA/idalib), defer to the main thread which will run the
|
|
667
|
+
# shutdown after leaving the dispatch loop — doing it from a worker
|
|
668
|
+
# thread here raises "Function can be called from the main thread
|
|
669
|
+
# only". wait_for_shutdown() / __exit__ pick it up via
|
|
670
|
+
# _shutdown_deci_if_needed().
|
|
671
|
+
if self.deci and not self._deci_shutdown_done:
|
|
672
|
+
on_main = (
|
|
673
|
+
self._main_thread_ident is None
|
|
674
|
+
or threading.get_ident() == self._main_thread_ident
|
|
675
|
+
)
|
|
676
|
+
if on_main or not self.requires_main_thread:
|
|
677
|
+
try:
|
|
678
|
+
self.deci.shutdown()
|
|
679
|
+
self._deci_shutdown_done = True
|
|
680
|
+
except Exception as e:
|
|
681
|
+
_l.warning(f"Error shutting down decompiler: {e}")
|
|
682
|
+
|
|
683
|
+
_l.info("DecompilerServer stopped")
|
|
684
|
+
|
|
685
|
+
def is_running(self) -> bool:
|
|
686
|
+
"""Check if the server is currently running"""
|
|
687
|
+
return self._running
|
|
688
|
+
|
|
689
|
+
@property
|
|
690
|
+
def requires_main_thread(self) -> bool:
|
|
691
|
+
"""Whether backend API calls must be routed to the main thread.
|
|
692
|
+
|
|
693
|
+
Set by the decompiler interface; IDA's idalib is the canonical case.
|
|
694
|
+
"""
|
|
695
|
+
if not self.deci:
|
|
696
|
+
return False
|
|
697
|
+
return bool(getattr(self.deci, "requires_main_thread_dispatch", False))
|
|
698
|
+
|
|
699
|
+
def run_on_main_thread(self, func, *args, **kwargs):
|
|
700
|
+
"""Run ``func(*args, **kwargs)`` on the server's main thread.
|
|
701
|
+
|
|
702
|
+
If the calling thread *is* the main thread, execute inline — this
|
|
703
|
+
avoids a deadlock when the main thread is itself invoking a method
|
|
704
|
+
(e.g. during ``__enter__`` / ``start``).
|
|
705
|
+
"""
|
|
706
|
+
if self._main_thread_ident is not None and threading.get_ident() == self._main_thread_ident:
|
|
707
|
+
return func(*args, **kwargs)
|
|
708
|
+
|
|
709
|
+
result_q: "queue.Queue" = queue.Queue(maxsize=1)
|
|
710
|
+
self._main_thread_queue.put((func, args, kwargs, result_q))
|
|
711
|
+
result = result_q.get()
|
|
712
|
+
if isinstance(result, _MainThreadError):
|
|
713
|
+
raise result.exc
|
|
714
|
+
return result
|
|
715
|
+
|
|
716
|
+
def _main_thread_dispatch_loop(self):
|
|
717
|
+
"""Drain backend work from the main-thread queue until shutdown.
|
|
718
|
+
|
|
719
|
+
Only used for backends that require main-thread dispatch (IDA).
|
|
720
|
+
Runs on the thread that called ``wait_for_shutdown`` — i.e. the
|
|
721
|
+
thread that originally created the ``deci``.
|
|
722
|
+
"""
|
|
723
|
+
self._main_thread_ident = threading.get_ident()
|
|
724
|
+
while self._running:
|
|
725
|
+
try:
|
|
726
|
+
item = self._main_thread_queue.get(timeout=0.25)
|
|
727
|
+
except queue.Empty:
|
|
728
|
+
continue
|
|
729
|
+
if item is _MAIN_THREAD_SHUTDOWN:
|
|
730
|
+
break
|
|
731
|
+
func, args, kwargs, result_q = item
|
|
732
|
+
try:
|
|
733
|
+
result = func(*args, **kwargs)
|
|
734
|
+
except BaseException as exc: # relay every failure, including Java exceptions
|
|
735
|
+
result = _MainThreadError(exc)
|
|
736
|
+
result_q.put(result)
|
|
737
|
+
|
|
738
|
+
def wait_for_shutdown(self):
|
|
739
|
+
"""Wait for the server to be shut down (blocking)"""
|
|
740
|
+
if self.requires_main_thread:
|
|
741
|
+
# Become the main-thread dispatcher. This blocks until stop().
|
|
742
|
+
try:
|
|
743
|
+
self._main_thread_dispatch_loop()
|
|
744
|
+
except KeyboardInterrupt:
|
|
745
|
+
_l.info("Received interrupt signal, stopping server...")
|
|
746
|
+
self.stop()
|
|
747
|
+
# Now that we're back on the main thread with the dispatch loop
|
|
748
|
+
# drained, finish any backend teardown stop() had to defer.
|
|
749
|
+
self._shutdown_deci_if_needed()
|
|
750
|
+
return
|
|
751
|
+
|
|
752
|
+
if self._server_thread and self._server_thread.is_alive():
|
|
753
|
+
try:
|
|
754
|
+
self._server_thread.join()
|
|
755
|
+
except KeyboardInterrupt:
|
|
756
|
+
_l.info("Received interrupt signal, stopping server...")
|
|
757
|
+
self.stop()
|
|
758
|
+
|
|
759
|
+
def _shutdown_deci_if_needed(self):
|
|
760
|
+
"""Run deci.shutdown() once, from the caller's thread.
|
|
761
|
+
|
|
762
|
+
Callers must ensure they are on the thread that owns the backend
|
|
763
|
+
(typically the main thread). Idempotent.
|
|
764
|
+
"""
|
|
765
|
+
if not self.deci or self._deci_shutdown_done:
|
|
766
|
+
return
|
|
767
|
+
try:
|
|
768
|
+
self.deci.shutdown()
|
|
769
|
+
except Exception as e:
|
|
770
|
+
_l.warning(f"Error shutting down decompiler: {e}")
|
|
771
|
+
finally:
|
|
772
|
+
self._deci_shutdown_done = True
|
|
773
|
+
|
|
774
|
+
def __enter__(self):
|
|
775
|
+
"""Context manager entry"""
|
|
776
|
+
self.start()
|
|
777
|
+
return self
|
|
778
|
+
|
|
779
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
780
|
+
"""Context manager exit"""
|
|
781
|
+
self.stop()
|
|
782
|
+
self._shutdown_deci_if_needed()
|