Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions declib/api/decompiler_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,8 @@ def _send_request(self, request: Dict[str, Any]) -> Any:
raise ValueError(error_msg)
elif error_type == "AttributeError":
raise AttributeError(error_msg)
elif error_type == "NotImplementedError":
raise NotImplementedError(error_msg)
else:
raise RuntimeError(f"{error_type}: {error_msg}")

Expand Down Expand Up @@ -562,6 +564,14 @@ def disassemble(self, addr: int, **kwargs) -> Optional[str]:
def read_memory(self, addr: int, size: int) -> Optional[bytes]:
"""Read raw bytes from the loaded program."""
return self._send_request({"type": "method_call", "method_name": "read_memory", "args": [addr, size]})

def save(self, path: Optional[str] = None) -> bool:
"""Persist the backend's analysis to disk. Raises NotImplementedError for in-memory backends."""
return self._send_request({"type": "method_call", "method_name": "save", "args": [path]})

def set_persist_on_close(self, value: bool) -> bool:
"""Control whether the backend flushes to disk on teardown."""
return self._send_request({"type": "method_call", "method_name": "set_persist_on_close", "args": [value]})

def get_callgraph(self, only_names=False):
"""Get the call graph"""
Expand Down
34 changes: 34 additions & 0 deletions declib/api/decompiler_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,40 @@ def read_memory(self, addr: int, size: int) -> Optional[bytes]:
"""
raise NotImplementedError

# ------------------------------------------------------------------
# Persistence
# ------------------------------------------------------------------
#: Whether the backend should flush its analysis to disk when the
#: interface is torn down (``shutdown()`` -> ``_deinit_headless_components``).
#: Backends override the default to match their native behavior (IDA
#: discards on close, Ghidra saves on close). ``set_persist_on_close``
#: flips it so ``decompiler stop --save/--discard`` can control the final
#: flush without an extra round-trip.
persist_on_close: bool = False

def save(self, path: Optional[str] = None) -> bool:
"""Persist the current analysis to the backend's on-disk database/project.

This is the durable-artifact primitive: after ``save()`` returns True,
renames/retypes/comments made through DecLib survive a stop + reload of
the same project directory. Backends that hold no persistent database
(e.g. angr, which is purely in-memory) raise ``NotImplementedError``.

@param path: Optional explicit output path. When ``None`` the backend
writes to whatever database/project it already has open.
@return: True on success.
"""
raise NotImplementedError("This backend does not support saving to disk.")

def set_persist_on_close(self, value: bool) -> bool:
"""Control whether teardown flushes analysis to disk.

Used by ``decompiler stop --save/--discard`` to decide the final flush
for backends (like Ghidra) that persist on close by default.
"""
self.persist_on_close = bool(value)
return True

def get_callgraph(self, only_names=False) -> nx.DiGraph:
"""
Returns the callgraph of the binary. This is a dict of function addresses to a list of function addresses
Expand Down
73 changes: 69 additions & 4 deletions declib/cli/decompiler_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
Subcommands implemented:
- load start a server on a binary
- list list running servers
- stop stop one or all servers
- stop stop one or all servers (with --save/--discard)
- save persist backend analysis to disk (durable artifacts)
- list_functions list functions in the binary, optionally filtered by regex
- decompile decompile a function by name or address
- disassemble disassemble a function by name or address
Expand Down Expand Up @@ -43,6 +44,7 @@
EXIT_USER_ERROR = 1 # user asked for something that didn't happen
EXIT_NOT_FOUND = 1 # missing function/name/binary
EXIT_RUNTIME_ERROR = 1 # unhandled/unknown failure
EXIT_UNSUPPORTED = 2 # feature not implemented for the selected backend

from declib.api import server_registry
from declib.decompilers import SUPPORTED_DECOMPILERS
Expand Down Expand Up @@ -310,12 +312,17 @@ def cmd_list(args) -> int:
return 0


def _stop_server_by_record(record: Dict) -> bool:
def _stop_server_by_record(record: Dict, save_mode: Optional[str] = None) -> bool:
"""Shut down the server process backing `record`.

Asks the server to shut itself down gracefully, falling back to SIGTERM/SIGKILL
on the PID if the request fails. Returns True if we believe the process is
gone (or never existed) by the time we return.

``save_mode`` controls the final flush to disk before teardown:
- ``"save"`` — persist analysis (``client.save()``) first.
- ``"discard"`` — tell the backend not to persist on close.
- ``None`` — leave the backend's default behavior alone.
"""
from declib.api.decompiler_client import DecompilerClient

Expand All @@ -329,6 +336,18 @@ def _stop_server_by_record(record: Dict) -> bool:
_l.warning("Could not connect to server %s: %s", server_id, exc)
client = None
if client is not None:
if save_mode == "save":
try:
client.save()
except NotImplementedError:
_l.debug("Backend %s does not support saving; nothing to flush.", server_id)
except Exception as exc:
_l.warning("save before stop failed for %s: %s", server_id, exc)
elif save_mode == "discard":
try:
client.set_persist_on_close(False)
except Exception as exc:
_l.debug("set_persist_on_close(False) failed for %s: %s", server_id, exc)
try:
client._send_request({"type": "shutdown_server"})
graceful = True
Expand Down Expand Up @@ -381,6 +400,8 @@ def _wait_for_process_exit(pid, timeout: float) -> bool:


def cmd_stop(args) -> int:
if args.save and args.discard:
raise SystemExit("decompiler stop: --save and --discard are mutually exclusive")
records = server_registry.list_servers()
if args.all:
targets = records
Expand All @@ -394,14 +415,32 @@ def cmd_stop(args) -> int:
if not targets:
raise SystemExit("No matching server to stop")

save_mode = "save" if args.save else "discard" if args.discard else None

results = []
for record in targets:
ok = _stop_server_by_record(record)
results.append({"id": record.get("id"), "stopped": bool(ok)})
ok = _stop_server_by_record(record, save_mode=save_mode)
results.append({"id": record.get("id"), "stopped": bool(ok), "save_mode": save_mode})
_emit(args, {"stopped": results})
return 0


def cmd_save(args) -> int:
"""Persist the selected server's analysis to its on-disk database/project."""
with _with_client(args) as client:
try:
ok = bool(client.save(args.path))
except NotImplementedError as exc:
# Re-raise so main() renders the friendly "not implemented" message
# and returns EXIT_UNSUPPORTED.
raise NotImplementedError(
str(exc) or "this backend has no persistent database to save "
"(e.g. angr is purely in-memory)."
)
_emit(args, {"saved": ok, "path": args.path})
return EXIT_OK if ok else EXIT_USER_ERROR


# ---------------------------------------------------------------------------
# decompile / disassemble
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1289,9 +1328,28 @@ def build_parser() -> argparse.ArgumentParser:
p_stop.add_argument("--id", dest="id", help="Server ID to stop.")
p_stop.add_argument("--binary", dest="binary", help="Stop servers for this binary.")
p_stop.add_argument("--all", action="store_true", help="Stop every running server.")
p_stop.add_argument("--save", action="store_true",
help="Persist analysis to disk before stopping (durable .i64/.gpr/.bndb).")
p_stop.add_argument("--discard", action="store_true",
help="Explicitly drop unsaved edits on stop (default for IDA; overrides Ghidra's save-on-close).")
_add_output_args(p_stop)
p_stop.set_defaults(func=cmd_stop)

# save
p_save = sub.add_parser(
"save",
help=(
"Persist the backend's analysis to disk so renames/types/comments "
"survive a stop + reload. IDA writes its .i64, Ghidra saves the "
"project, Binary Ninja writes a .bndb; angr (in-memory) is unsupported."
),
)
p_save.add_argument("--path", dest="path", default=None,
help="Explicit output path (backend-dependent; defaults to the open database).")
_add_server_filter_args(p_save)
_add_output_args(p_save)
p_save.set_defaults(func=cmd_save)

# decompile
p_dec = sub.add_parser("decompile", help="Decompile a function by name or address.")
p_dec.add_argument("target", help="Function name or address (hex/decimal).")
Expand Down Expand Up @@ -1477,6 +1535,13 @@ def main(argv: Optional[List[str]] = None) -> int:
return args.func(args) or EXIT_OK
except SystemExit:
raise
except NotImplementedError as exc:
# A backend that doesn't implement the requested capability. Surface a
# clean, distinct message + exit code so scripts can tell "unsupported
# on this backend" apart from a genuine failure.
msg = str(exc) or "not implemented for this backend"
print(f"not implemented: {msg}", file=sys.stderr)
return EXIT_UNSUPPORTED
except Exception as exc: # noqa: BLE001
_l.exception("Unhandled error: %s", exc)
print(f"Error: {exc}", file=sys.stderr)
Expand Down
19 changes: 19 additions & 0 deletions declib/decompilers/binja/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,25 @@ def __del__(self):
if self.headless and BN_AVAILABLE:
self.bv.file.close()

def save(self, path=None) -> bool:
"""Persist analysis to a Binary Ninja database (``.bndb``).

``binaryninja.load`` opens the raw file with no backing database, so
the first save creates one; subsequent saves snapshot into it. When no
``path`` is given we derive ``<binary>.bndb`` next to the binary.
"""
if self.bv is None:
return False
file_metadata = self.bv.file
try:
if file_metadata.filename and file_metadata.filename.endswith(".bndb"):
return bool(file_metadata.save_auto_snapshot())
target = path or (str(self._binary_path) + ".bndb")
return bool(file_metadata.create_database(target))
except Exception as e:
l.warning("Failed to save Binary Ninja database: %s", e)
return False

#
# GUI
#
Expand Down
23 changes: 21 additions & 2 deletions declib/decompilers/ghidra/compat/headless.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,16 +138,35 @@ def _setup_project(

return project, program

def close_program(program, project) -> bool:
def save_program(program, project) -> bool:
"""Flush the program's changes into its project file on disk.

Ghidra writes edits into the in-memory ``ProgramDB`` via transactions;
``GhidraProject.save`` is what persists them to the ``.gpr``/``.rep`` so a
later reopen sees the renames/types/comments.
"""
try:
project.save(program)
return True
except Exception as e:
_l.critical("Failed to save program: %s", e)
return False


def close_program(program, project, save=True) -> bool:
"""
Returns true if closing was successful, false otherwise.

When ``save`` is False the in-memory changes are discarded (the on-disk
project keeps whatever was last saved — the freshly-analyzed program), which
is how ``decompiler stop --discard`` reverts unsaved work.
"""
from ghidra.app.script import GhidraScriptUtil

try:
GhidraScriptUtil.releaseBundleHostReference()
project.save(program)
if save:
project.save(program)
project.close()
return True
except Exception as e:
Expand Down
21 changes: 19 additions & 2 deletions declib/decompilers/ghidra/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

from .artifact_lifter import GhidraArtifactLifter
from .compat.transaction import ghidra_transaction
from .compat.headless import close_program, open_program
from .compat.headless import close_program, open_program, save_program
from .compat.state import get_current_address

if typing.TYPE_CHECKING:
Expand Down Expand Up @@ -82,6 +82,11 @@ def __init__(
self._results_queue = queue.Queue()
self.requires_main_thread_dispatch = not kwargs.get("headless", False)

# Ghidra flushes the ProgramDB into the project on teardown by default,
# so a reload already sees prior work. `decompiler stop --discard` flips
# this off to drop unsaved edits.
self.persist_on_close = True

super().__init__(
name="ghidra",
artifact_lifter=GhidraArtifactLifter(self),
Expand All @@ -100,10 +105,22 @@ def _init_gui_components(self, *args, **kwargs):

def _deinit_headless_components(self):
if self._program is not None and self._project is not None:
close_program(self._program, self._project)
close_program(self._program, self._project, save=bool(self.persist_on_close))
self._project = None
self._program = None

def save(self, path=None) -> bool:
"""Persist the Ghidra program into its project on disk.

``path`` is ignored: Ghidra saves back into the project the binary was
imported into (set via ``--project-dir``). Returns False when running
against a caller-provided program with no owned project.
"""
if self._program is None or self._project is None:
_l.warning("No owned Ghidra project to save (program provided externally).")
return False
return save_program(self._program, self._project)

def _init_headless_components(self, *args, **kwargs):
if self._program is not None:
# We were already provided a program object as part of the instantiation, so just use it
Expand Down
20 changes: 20 additions & 0 deletions declib/decompilers/ida/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -1831,6 +1831,26 @@ def wait_for_idc_initialization():
idc.auto_wait()


@execute_write
def save_database(path=None):
"""Flush the in-memory IDB to disk.

idalib opens the database in memory and (by default) discards it on
``close_database(False)``; this writes it out so renames/types/comments
become a durable ``.i64``/``.idb`` artifact. With no ``path`` we save to
whatever database file IDA already has open (honoring any ``-o`` redirect
from ``project_dir``). ``idc.save_database`` returns None on success, so we
treat "no exception" as success.
"""
target = path or idc.get_idb_path() or ""
try:
idc.save_database(target, 0)
except Exception as e:
_l.warning("save_database failed for %r: %s", target, e)
return False
return True


def initialize_decompiler():
return bool(ida_hexrays.init_hexrays_plugin())

Expand Down
Loading
Loading