From 761a9e9466b53e8c3bce2aff623be0f0a66f29f1 Mon Sep 17 00:00:00 2001 From: Eric Gustafson Date: Wed, 22 Jul 2026 20:13:04 -0700 Subject: [PATCH 1/4] Add prototype JADX managed-code backend --- .gitignore | 3 +- README.md | 3 +- declib/__main__.py | 2 +- declib/api/decompiler_client.py | 94 +++ declib/api/decompiler_interface.py | 76 ++- declib/cli/decompiler_cli.py | 238 ++++++++ declib/decompilers/__init__.py | 4 +- declib/decompilers/jadx/__init__.py | 3 + declib/decompilers/jadx/interface.py | 163 +++++ declib/decompilers/jadx/worker/__init__.py | 1 + .../decompilers/jadx/worker/build.gradle.kts | 43 ++ .../jadx/worker/settings.gradle.kts | 1 + .../java/declib/jadxworker/JadxService.java | 563 ++++++++++++++++++ .../src/main/java/declib/jadxworker/Main.java | 61 ++ .../declib/jadxworker/JadxServiceTest.java | 38 ++ declib/decompilers/jadx/worker_client.py | 242 ++++++++ declib/skills/decompiler/SKILL.md | 33 +- docs/decompiler_cli.md | 8 +- docs/jadx.md | 67 +++ pyproject.toml | 5 + tests/test_jadx.py | 114 ++++ 21 files changed, 1752 insertions(+), 10 deletions(-) create mode 100644 declib/decompilers/jadx/__init__.py create mode 100644 declib/decompilers/jadx/interface.py create mode 100644 declib/decompilers/jadx/worker/__init__.py create mode 100644 declib/decompilers/jadx/worker/build.gradle.kts create mode 100644 declib/decompilers/jadx/worker/settings.gradle.kts create mode 100644 declib/decompilers/jadx/worker/src/main/java/declib/jadxworker/JadxService.java create mode 100644 declib/decompilers/jadx/worker/src/main/java/declib/jadxworker/Main.java create mode 100644 declib/decompilers/jadx/worker/src/test/java/declib/jadxworker/JadxServiceTest.java create mode 100644 declib/decompilers/jadx/worker_client.py create mode 100644 docs/jadx.md create mode 100644 tests/test_jadx.py diff --git a/.gitignore b/.gitignore index 69d0979f..cb018a12 100644 --- a/.gitignore +++ b/.gitignore @@ -129,6 +129,7 @@ dmypy.json .pyre/ .idea/ +.gradle/ **/*.rep/* **/*.gpr @@ -136,4 +137,4 @@ dmypy.json **/*.lock~ **/*.swap **/*.i64 -**/*.DS_STORE \ No newline at end of file +**/*.DS_STORE diff --git a/README.md b/README.md index 4ae818c5..2d5c5f49 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ The minimum Python version is **3.10**. - Binary Ninja: **>= 2.4** - angr-management: **>= 9.0** - Ghidra: **>= 12.0** (started in PyGhidra mode) +- JADX: **>= 1.5.6** (headless Java/Android support; see + [the JADX backend guide](./docs/jadx.md)) ## Usage DecLib exposes all decompiler API through the abstract class `DecompilerInterface`. The `DecompilerInterface` @@ -109,4 +111,3 @@ one of the many BinSync projects.
RevEng AI

- diff --git a/declib/__main__.py b/declib/__main__.py index 040d8452..db5e7c98 100644 --- a/declib/__main__.py +++ b/declib/__main__.py @@ -137,7 +137,7 @@ def main(): """ ) parser.add_argument( - "--decompiler", choices=["ida", "ghidra", "binja", "angr"], help=""" + "--decompiler", choices=["ida", "ghidra", "binja", "angr", "jadx"], help=""" Force a specific decompiler for the server. If not specified, auto-detection will be used. """ ) diff --git a/declib/api/decompiler_client.py b/declib/api/decompiler_client.py index a2004839..f11295c2 100644 --- a/declib/api/decompiler_client.py +++ b/declib/api/decompiler_client.py @@ -573,6 +573,100 @@ def list_imports(self) -> List: """Return (lifted_addr, name, library) tuples for imported symbols.""" return self._send_request({"type": "method_call", "method_name": "list_imports"}) + # Managed-code API (JVM/Dex backends such as JADX). These methods use + # opaque stable references rather than native integer addresses. + def managed_capabilities(self) -> Dict: + return self._send_request({ + "type": "method_call", + "method_name": "managed_capabilities", + }) + + def managed_list_classes(self, filter=None, limit=1000) -> List[Dict]: + return self._send_request({ + "type": "method_call", + "method_name": "managed_list_classes", + "kwargs": {"filter": filter, "limit": limit}, + }) + + def managed_list_methods(self, class_ref=None, filter=None, limit=2000) -> List[Dict]: + return self._send_request({ + "type": "method_call", + "method_name": "managed_list_methods", + "kwargs": { + "class_ref": class_ref, + "filter": filter, + "limit": limit, + }, + }) + + def managed_list_fields(self, class_ref=None, filter=None, limit=2000) -> List[Dict]: + return self._send_request({ + "type": "method_call", + "method_name": "managed_list_fields", + "kwargs": { + "class_ref": class_ref, + "filter": filter, + "limit": limit, + }, + }) + + def managed_class_source(self, ref: str) -> Dict: + return self._send_request({ + "type": "method_call", + "method_name": "managed_class_source", + "args": [ref], + }) + + def managed_method_source(self, ref: str) -> Dict: + return self._send_request({ + "type": "method_call", + "method_name": "managed_method_source", + "args": [ref], + }) + + def managed_class_xrefs(self, ref: str) -> List[Dict]: + return self._send_request({ + "type": "method_call", + "method_name": "managed_class_xrefs", + "args": [ref], + }) + + def managed_method_xrefs(self, ref: str, direction="both") -> Dict: + return self._send_request({ + "type": "method_call", + "method_name": "managed_method_xrefs", + "args": [ref], + "kwargs": {"direction": direction}, + }) + + def managed_field_xrefs(self, ref: str) -> List[Dict]: + return self._send_request({ + "type": "method_call", + "method_name": "managed_field_xrefs", + "args": [ref], + }) + + def managed_list_resources(self, filter=None, limit=2000) -> List[Dict]: + return self._send_request({ + "type": "method_call", + "method_name": "managed_list_resources", + "kwargs": {"filter": filter, "limit": limit}, + }) + + def managed_get_resource(self, path: str, max_bytes=1024 * 1024) -> Dict: + return self._send_request({ + "type": "method_call", + "method_name": "managed_get_resource", + "args": [path], + "kwargs": {"max_bytes": max_bytes}, + }) + + def managed_get_manifest(self) -> Dict: + return self._send_request({ + "type": "method_call", + "method_name": "managed_get_manifest", + }) + def backend_eval(self, code: str, mode: str = "eval") -> Dict: """UNSAFE: run arbitrary Python in the backend process. See the interface docstring.""" return self._send_request({"type": "method_call", "method_name": "backend_eval", "args": [code], "kwargs": {"mode": mode}}) diff --git a/declib/api/decompiler_interface.py b/declib/api/decompiler_interface.py index c47d5d78..114f55f4 100644 --- a/declib/api/decompiler_interface.py +++ b/declib/api/decompiler_interface.py @@ -22,7 +22,7 @@ Enum, Struct, FunctionArgument, Context, Decompilation, Typedef ) from declib.decompilers import SUPPORTED_DECOMPILERS, ANGR_DECOMPILER, \ - BINJA_DECOMPILER, IDA_DECOMPILER, GHIDRA_DECOMPILER + BINJA_DECOMPILER, IDA_DECOMPILER, GHIDRA_DECOMPILER, JADX_DECOMPILER _l = logging.getLogger(name=__name__) @@ -668,6 +668,76 @@ def list_imports(self) -> List[Tuple[int, str, str]]: """ raise NotImplementedError("Import listing is not implemented for this backend.") + # + # Managed-code API + # + + def managed_capabilities(self) -> Dict: + """Describe managed-code capabilities and the currently loaded input.""" + raise NotImplementedError("Managed code is not implemented for this backend.") + + def managed_list_classes( + self, + filter: Optional[str] = None, + limit: int = 1000, + ) -> List[Dict]: + """List classes using stable opaque references rather than addresses.""" + raise NotImplementedError("Managed code is not implemented for this backend.") + + def managed_list_methods( + self, + class_ref: Optional[str] = None, + filter: Optional[str] = None, + limit: int = 2000, + ) -> List[Dict]: + """List methods, preserving the full descriptor in each ``ref``.""" + raise NotImplementedError("Managed code is not implemented for this backend.") + + def managed_list_fields( + self, + class_ref: Optional[str] = None, + filter: Optional[str] = None, + limit: int = 2000, + ) -> List[Dict]: + """List fields using stable opaque references.""" + raise NotImplementedError("Managed code is not implemented for this backend.") + + def managed_class_source(self, ref: str) -> Dict: + """Return decompiled source for a managed-code class.""" + raise NotImplementedError("Managed code is not implemented for this backend.") + + def managed_method_source(self, ref: str) -> Dict: + """Return decompiled source for a managed-code method.""" + raise NotImplementedError("Managed code is not implemented for this backend.") + + def managed_class_xrefs(self, ref: str) -> List[Dict]: + """Return nodes that reference a managed-code class.""" + raise NotImplementedError("Managed code is not implemented for this backend.") + + def managed_method_xrefs(self, ref: str, direction: str = "both") -> Dict: + """Return callers and/or callees for a managed-code method.""" + raise NotImplementedError("Managed code is not implemented for this backend.") + + def managed_field_xrefs(self, ref: str) -> List[Dict]: + """Return nodes that reference a managed-code field.""" + raise NotImplementedError("Managed code is not implemented for this backend.") + + def managed_list_resources( + self, + filter: Optional[str] = None, + limit: int = 2000, + ) -> List[Dict]: + """List resources in an Android/JVM input.""" + raise NotImplementedError("Managed code is not implemented for this backend.") + + def managed_get_resource(self, path: str, max_bytes: int = 1024 * 1024) -> Dict: + """Decode a text or binary Android/JVM resource.""" + raise NotImplementedError("Managed code is not implemented for this backend.") + + def managed_get_manifest(self) -> Dict: + """Decode and return AndroidManifest.xml.""" + raise NotImplementedError("Managed code is not implemented for this backend.") + def backend_eval(self, code: str, mode: str = "eval") -> Dict: """UNSAFE escape hatch: run arbitrary Python inside the backend process. @@ -1426,6 +1496,10 @@ def discover( extra_kwargs = {"flat_api": DecompilerInterface._find_global_in_call_frames('__this__')} if project_dir: extra_kwargs["project_location"] = project_dir + elif current_decompiler == JADX_DECOMPILER: + from declib.decompilers.jadx.interface import JadxInterface + deci_class = JadxInterface + extra_kwargs = {} else: raise ValueError("Please use DecLib with our supported decompiler set!") diff --git a/declib/cli/decompiler_cli.py b/declib/cli/decompiler_cli.py index 2bd538b9..a0a44f55 100644 --- a/declib/cli/decompiler_cli.py +++ b/declib/cli/decompiler_cli.py @@ -34,6 +34,11 @@ - get_callers functions (call sites only) that call a target - read typed reads: int/string/struct at an address - read_memory read raw bytes from the binary at an address +- class list/source/xrefs for managed-code classes +- method list/source/xrefs for managed-code methods +- field list/xrefs for managed-code fields +- resource list/get Android and JVM resources +- manifest print the decoded Android manifest - install-skill install the bundled Agent Skill so LLMs learn the CLI """ import argparse @@ -2261,6 +2266,109 @@ def cmd_install_skill(args) -> int: return 0 +# --------------------------------------------------------------------------- +# managed code (JVM / Dex) +# --------------------------------------------------------------------------- + +def _emit_managed_source(args, payload: Dict) -> int: + text = payload.get("text", "") + if args.max_chars is not None: + if args.max_chars < 1: + raise SystemExit("--max-chars must be at least 1") + shaped = _shape_decompilation_text(text, max_chars=args.max_chars) + text = shaped.pop("text") + shaped.pop("selected_source_lines") + payload = {**payload, "text": text, **shaped} + if args.raw: + print(text, end="" if text.endswith("\n") else "\n") + else: + _emit(args, payload, text_field="text") + return 0 + + +def cmd_managed_class(args) -> int: + with _with_client(args) as client: + if args.managed_action == "list": + _emit_list( + args, + client.managed_list_classes(filter=args.filter, limit=args.limit), + ) + return 0 + if args.managed_action == "source": + return _emit_managed_source(args, client.managed_class_source(args.ref)) + if args.managed_action == "xrefs": + _emit_list(args, client.managed_class_xrefs(args.ref)) + return 0 + raise SystemExit(f"Unknown class action: {args.managed_action}") + + +def cmd_managed_method(args) -> int: + with _with_client(args) as client: + if args.managed_action == "list": + _emit_list( + args, + client.managed_list_methods( + class_ref=args.class_ref, + filter=args.filter, + limit=args.limit, + ), + ) + return 0 + if args.managed_action == "source": + return _emit_managed_source(args, client.managed_method_source(args.ref)) + if args.managed_action == "xrefs": + _emit( + args, + client.managed_method_xrefs(args.ref, direction=args.direction), + ) + return 0 + raise SystemExit(f"Unknown method action: {args.managed_action}") + + +def cmd_managed_field(args) -> int: + with _with_client(args) as client: + if args.managed_action == "list": + _emit_list( + args, + client.managed_list_fields( + class_ref=args.class_ref, + filter=args.filter, + limit=args.limit, + ), + ) + return 0 + if args.managed_action == "xrefs": + _emit_list(args, client.managed_field_xrefs(args.ref)) + return 0 + raise SystemExit(f"Unknown field action: {args.managed_action}") + + +def cmd_managed_resource(args) -> int: + with _with_client(args) as client: + if args.managed_action == "list": + _emit_list( + args, + client.managed_list_resources(filter=args.filter, limit=args.limit), + ) + return 0 + if args.managed_action == "get": + payload = client.managed_get_resource( + args.path, + max_bytes=args.max_bytes, + ) + if "text" in payload: + return _emit_managed_source(args, payload) + _emit(args, payload) + return 0 + raise SystemExit(f"Unknown resource action: {args.managed_action}") + + +def cmd_managed_manifest(args) -> int: + with _with_client(args) as client: + payload = client.managed_get_manifest() + return _emit_managed_source(args, payload) + + # --------------------------------------------------------------------------- # shared helpers # --------------------------------------------------------------------------- @@ -2375,6 +2483,17 @@ def _add_output_args(p: argparse.ArgumentParser) -> None: p.add_argument("--json", action="store_true", help="Emit JSON output instead of text.") +def _add_managed_source_args(p: argparse.ArgumentParser) -> None: + p.add_argument("--raw", action="store_true", help="Print only source text.") + p.add_argument( + "--max-chars", + type=int, + help="Limit returned source to at most N characters.", + ) + _add_server_filter_args(p) + _add_output_args(p) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="decompiler", @@ -2449,6 +2568,125 @@ def build_parser() -> argparse.ArgumentParser: _add_output_args(p_lf) p_lf.set_defaults(func=cmd_list_functions) + # managed-code classes + p_class = sub.add_parser( + "class", + help="Inspect classes in a JVM/Dex backend such as JADX.", + ) + class_sub = p_class.add_subparsers(dest="managed_action", required=True) + + p_class_list = class_sub.add_parser("list", help="List classes.") + p_class_list.add_argument("--filter", help="Case-insensitive regex filter.") + p_class_list.add_argument("--limit", type=int, default=1000) + _add_server_filter_args(p_class_list) + _add_output_args(p_class_list) + p_class_list.set_defaults(func=cmd_managed_class) + + p_class_source = class_sub.add_parser("source", help="Decompile a class.") + p_class_source.add_argument("ref", help="Stable class reference from `class list`.") + _add_managed_source_args(p_class_source) + p_class_source.set_defaults(func=cmd_managed_class) + + p_class_xrefs = class_sub.add_parser("xrefs", help="List references to a class.") + p_class_xrefs.add_argument("ref", help="Stable class reference from `class list`.") + _add_server_filter_args(p_class_xrefs) + _add_output_args(p_class_xrefs) + p_class_xrefs.set_defaults(func=cmd_managed_class) + + # managed-code methods + p_method = sub.add_parser( + "method", + help="Inspect methods in a JVM/Dex backend such as JADX.", + ) + method_sub = p_method.add_subparsers(dest="managed_action", required=True) + + p_method_list = method_sub.add_parser("list", help="List methods.") + p_method_list.add_argument( + "--class", + dest="class_ref", + help="Restrict to a stable class reference.", + ) + p_method_list.add_argument("--filter", help="Case-insensitive regex filter.") + p_method_list.add_argument("--limit", type=int, default=2000) + _add_server_filter_args(p_method_list) + _add_output_args(p_method_list) + p_method_list.set_defaults(func=cmd_managed_method) + + p_method_source = method_sub.add_parser("source", help="Decompile a method.") + p_method_source.add_argument( + "ref", + help="Full stable method reference from `method list`, including descriptor.", + ) + _add_managed_source_args(p_method_source) + p_method_source.set_defaults(func=cmd_managed_method) + + p_method_xrefs = method_sub.add_parser( + "xrefs", + help="List callers and/or callees of a method.", + ) + p_method_xrefs.add_argument( + "ref", + help="Full stable method reference from `method list`, including descriptor.", + ) + p_method_xrefs.add_argument( + "--direction", + choices=["callers", "callees", "both"], + default="both", + ) + _add_server_filter_args(p_method_xrefs) + _add_output_args(p_method_xrefs) + p_method_xrefs.set_defaults(func=cmd_managed_method) + + # managed-code fields + p_field = sub.add_parser( + "field", + help="Inspect fields in a JVM/Dex backend such as JADX.", + ) + field_sub = p_field.add_subparsers(dest="managed_action", required=True) + + p_field_list = field_sub.add_parser("list", help="List fields.") + p_field_list.add_argument("--class", dest="class_ref", help="Restrict to a class.") + p_field_list.add_argument("--filter", help="Case-insensitive regex filter.") + p_field_list.add_argument("--limit", type=int, default=2000) + _add_server_filter_args(p_field_list) + _add_output_args(p_field_list) + p_field_list.set_defaults(func=cmd_managed_field) + + p_field_xrefs = field_sub.add_parser("xrefs", help="List references to a field.") + p_field_xrefs.add_argument("ref", help="Stable field reference from `field list`.") + _add_server_filter_args(p_field_xrefs) + _add_output_args(p_field_xrefs) + p_field_xrefs.set_defaults(func=cmd_managed_field) + + # managed-code resources and Android manifest + p_resource = sub.add_parser( + "resource", + help="Inspect Android/JVM resources in a managed-code backend.", + ) + resource_sub = p_resource.add_subparsers(dest="managed_action", required=True) + + p_resource_list = resource_sub.add_parser("list", help="List resources.") + p_resource_list.add_argument("--filter", help="Case-insensitive regex filter.") + p_resource_list.add_argument("--limit", type=int, default=2000) + _add_server_filter_args(p_resource_list) + _add_output_args(p_resource_list) + p_resource_list.set_defaults(func=cmd_managed_resource) + + p_resource_get = resource_sub.add_parser("get", help="Decode a resource.") + p_resource_get.add_argument("path", help="Resource path from `resource list`.") + p_resource_get.add_argument( + "--max-bytes", + type=int, + default=1024 * 1024, + help="Maximum decoded bytes returned for a binary resource (default: 1 MiB).", + ) + _add_managed_source_args(p_resource_get) + p_resource_get.set_defaults(func=cmd_managed_resource) + + p_manifest = sub.add_parser("manifest", help="Decode the Android manifest.") + _add_managed_source_args(p_manifest) + p_manifest.set_defaults(func=cmd_managed_manifest) + # stop p_stop = sub.add_parser("stop", help="Stop a running server.") p_stop.add_argument("--id", dest="id", help="Server ID to stop.") diff --git a/declib/decompilers/__init__.py b/declib/decompilers/__init__.py index 20068b34..b200015f 100644 --- a/declib/decompilers/__init__.py +++ b/declib/decompilers/__init__.py @@ -2,7 +2,9 @@ IDA_DECOMPILER = "ida" BINJA_DECOMPILER = "binja" GHIDRA_DECOMPILER = "ghidra" +JADX_DECOMPILER = "jadx" SUPPORTED_DECOMPILERS = { - ANGR_DECOMPILER, IDA_DECOMPILER, BINJA_DECOMPILER, GHIDRA_DECOMPILER + ANGR_DECOMPILER, IDA_DECOMPILER, BINJA_DECOMPILER, GHIDRA_DECOMPILER, + JADX_DECOMPILER, } diff --git a/declib/decompilers/jadx/__init__.py b/declib/decompilers/jadx/__init__.py new file mode 100644 index 00000000..c57e9ad5 --- /dev/null +++ b/declib/decompilers/jadx/__init__.py @@ -0,0 +1,3 @@ +from .interface import JadxInterface + +__all__ = ["JadxInterface"] diff --git a/declib/decompilers/jadx/interface.py b/declib/decompilers/jadx/interface.py new file mode 100644 index 00000000..b820c7fc --- /dev/null +++ b/declib/decompilers/jadx/interface.py @@ -0,0 +1,163 @@ +import hashlib +from pathlib import Path +from typing import Dict, List, Optional + +from declib.api.artifact_lifter import ArtifactLifter +from declib.api.decompiler_interface import DecompilerInterface +from declib.artifacts import Function + +from .worker_client import JadxWorkerClient + + +class JadxInterface(DecompilerInterface): + """Headless JADX backend using a transport-private Java worker.""" + + def __init__( + self, + *, + worker_executable: Optional[str] = None, + worker_timeout: float = 120.0, + **kwargs, + ): + if not kwargs.get("binary_path"): + raise ValueError("JADX requires binary_path for an APK, DEX, JAR, or class input") + self._worker_executable = worker_executable + self._worker_timeout = worker_timeout + self._worker: Optional[JadxWorkerClient] = None + self._jadx_info: Dict = {} + self._binary_hash: Optional[str] = None + # JADX is embedded as a headless library; there is no DecLib GUI plugin + # mode for this backend. + kwargs["headless"] = True + super().__init__( + name="jadx", + artifact_lifter=ArtifactLifter(self), + default_func_prefix="", + **kwargs, + ) + + def _init_headless_components(self, *args, **kwargs): + super()._init_headless_components(*args, **kwargs) + self._worker = JadxWorkerClient( + executable=self._worker_executable, + request_timeout=self._worker_timeout, + ) + try: + self._jadx_info = self._worker.call( + "load", + {"path": str(self._binary_path)}, + timeout=max(self._worker_timeout, 300.0), + ) + except Exception: + self._worker.close() + self._worker = None + raise + + def _deinit_headless_components(self): + if self._worker is not None: + self._worker.close() + self._worker = None + + @property + def binary_base_addr(self) -> int: + return 0 + + @property + def binary_hash(self) -> str: + if self._binary_hash is None: + digest = hashlib.md5() + with Path(self._binary_path).open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + self._binary_hash = digest.hexdigest() + return self._binary_hash + + @property + def binary_arch(self) -> str: + return "dalvik" + + @property + def default_pointer_size(self) -> int: + return 4 + + def managed_capabilities(self) -> Dict: + return dict(self._jadx_info) + + def managed_list_classes( + self, + filter: Optional[str] = None, + limit: int = 1000, + ) -> List[Dict]: + return self._call("list_classes", {"filter": filter, "limit": limit}) + + def managed_list_methods( + self, + class_ref: Optional[str] = None, + filter: Optional[str] = None, + limit: int = 2000, + ) -> List[Dict]: + return self._call( + "list_methods", + {"class_ref": class_ref, "filter": filter, "limit": limit}, + ) + + def managed_list_fields( + self, + class_ref: Optional[str] = None, + filter: Optional[str] = None, + limit: int = 2000, + ) -> List[Dict]: + return self._call( + "list_fields", + {"class_ref": class_ref, "filter": filter, "limit": limit}, + ) + + def managed_class_source(self, ref: str) -> Dict: + return self._call("class_source", {"ref": ref}) + + def managed_method_source(self, ref: str) -> Dict: + return self._call("method_source", {"ref": ref}) + + def managed_class_xrefs(self, ref: str) -> List[Dict]: + return self._call("class_xrefs", {"ref": ref}) + + def managed_method_xrefs(self, ref: str, direction: str = "both") -> Dict: + return self._call("method_xrefs", {"ref": ref, "direction": direction}) + + def managed_field_xrefs(self, ref: str) -> List[Dict]: + return self._call("field_xrefs", {"ref": ref}) + + def managed_list_resources( + self, + filter: Optional[str] = None, + limit: int = 2000, + ) -> List[Dict]: + return self._call("list_resources", {"filter": filter, "limit": limit}) + + def managed_get_resource(self, path: str, max_bytes: int = 1024 * 1024) -> Dict: + return self._call( + "get_resource", + {"path": path, "max_bytes": max_bytes}, + ) + + def managed_get_manifest(self) -> Dict: + return self._call("get_manifest") + + def fast_get_function(self, func_addr) -> Optional[Function]: + return None + + def get_func_size(self, func_addr) -> int: + return 0 + + def get_func_containing(self, addr: int) -> Optional[Function]: + return None + + def _functions(self) -> Dict[int, Function]: + # Managed methods deliberately use opaque descriptors instead of fake + # native addresses. See managed_list_methods(). + return {} + + def _call(self, method: str, params: Optional[Dict] = None): + if self._worker is None: + raise RuntimeError("JADX worker is not running") + return self._worker.call(method, params) diff --git a/declib/decompilers/jadx/worker/__init__.py b/declib/decompilers/jadx/worker/__init__.py new file mode 100644 index 00000000..5ba538d0 --- /dev/null +++ b/declib/decompilers/jadx/worker/__init__.py @@ -0,0 +1 @@ +"""Build and launch support for the Java JADX worker.""" diff --git a/declib/decompilers/jadx/worker/build.gradle.kts b/declib/decompilers/jadx/worker/build.gradle.kts new file mode 100644 index 00000000..e9030abd --- /dev/null +++ b/declib/decompilers/jadx/worker/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + application + java +} + +repositories { + mavenCentral() + google() +} + +dependencies { + implementation("io.github.skylot:jadx-core:1.5.6") + implementation("com.google.code.gson:gson:2.14.0") + implementation("org.slf4j:slf4j-simple:2.0.18") + + runtimeOnly("io.github.skylot:jadx-dex-input:1.5.6") + runtimeOnly("io.github.skylot:jadx-java-input:1.5.6") + runtimeOnly("io.github.skylot:jadx-smali-input:1.5.6") + runtimeOnly("io.github.skylot:jadx-aab-input:1.5.6") + runtimeOnly("io.github.skylot:jadx-xapk-input:1.5.6") + + testImplementation(platform("org.junit:junit-bom:5.11.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +application { + mainClass.set("declib.jadxworker.Main") + applicationName = "declib-jadx-worker" + applicationDefaultJvmArgs = listOf( + "-Xms128m", + "-Xmx4g", + "-XX:+UseG1GC", + ) +} + +tasks.withType().configureEach { + options.release.set(17) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/declib/decompilers/jadx/worker/settings.gradle.kts b/declib/decompilers/jadx/worker/settings.gradle.kts new file mode 100644 index 00000000..4029c4f9 --- /dev/null +++ b/declib/decompilers/jadx/worker/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "declib-jadx-worker" diff --git a/declib/decompilers/jadx/worker/src/main/java/declib/jadxworker/JadxService.java b/declib/decompilers/jadx/worker/src/main/java/declib/jadxworker/JadxService.java new file mode 100644 index 00000000..999edbd9 --- /dev/null +++ b/declib/decompilers/jadx/worker/src/main/java/declib/jadxworker/JadxService.java @@ -0,0 +1,563 @@ +package declib.jadxworker; + +import java.io.File; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; + +import com.google.gson.JsonObject; + +import jadx.api.JadxArgs; +import jadx.api.JadxDecompiler; +import jadx.api.ICodeInfo; +import jadx.api.JavaClass; +import jadx.api.JavaField; +import jadx.api.JavaMethod; +import jadx.api.JavaNode; +import jadx.api.ResourceFile; +import jadx.api.ResourceType; +import jadx.api.ResourcesLoader; +import jadx.api.data.impl.JadxNodeRef; +import jadx.api.metadata.ICodeNodeRef; +import jadx.api.metadata.annotations.NodeDeclareRef; +import jadx.core.xmlgen.ResContainer; + +/** + * Transport-independent, read-only JADX service. + */ +public final class JadxService implements AutoCloseable { + private JadxDecompiler decompiler; + private Path inputPath; + private final Map classesByRef = new LinkedHashMap<>(); + + public Object dispatch(String method, JsonObject params) { + return switch (method) { + case "ping" -> Map.of("status", "ok", "version", "1"); + case "load" -> load(requiredString(params, "path")); + case "info" -> info(); + case "list_classes" -> listClasses( + optionalString(params, "filter"), optionalInt(params, "limit", 1000)); + case "list_methods" -> listMethods( + optionalString(params, "class_ref"), + optionalString(params, "filter"), + optionalInt(params, "limit", 2000)); + case "list_fields" -> listFields( + optionalString(params, "class_ref"), + optionalString(params, "filter"), + optionalInt(params, "limit", 2000)); + case "class_source" -> classSource(requiredString(params, "ref")); + case "method_source" -> methodSource(requiredString(params, "ref")); + case "class_xrefs" -> classXrefs(requiredString(params, "ref")); + case "method_xrefs" -> methodXrefs( + requiredString(params, "ref"), + optionalString(params, "direction")); + case "field_xrefs" -> fieldXrefs(requiredString(params, "ref")); + case "list_resources" -> listResources( + optionalString(params, "filter"), optionalInt(params, "limit", 2000)); + case "get_resource" -> getResource( + requiredString(params, "path"), + optionalInt(params, "max_bytes", 1024 * 1024)); + case "get_manifest" -> getManifest(); + case "shutdown" -> { + close(); + yield Map.of("status", "closed"); + } + default -> throw new IllegalArgumentException("Unknown worker method: " + method); + }; + } + + private Map load(String pathValue) { + close(); + inputPath = Path.of(pathValue).toAbsolutePath().normalize(); + File input = inputPath.toFile(); + if (!input.isFile()) { + throw new IllegalArgumentException("Input file does not exist: " + inputPath); + } + + JadxArgs args = new JadxArgs(); + args.setInputFile(input); + args.setThreadsCount(Math.max(1, Math.min(4, Runtime.getRuntime().availableProcessors()))); + decompiler = new JadxDecompiler(args); + decompiler.load(); + for (JavaClass cls : decompiler.getClassesWithInners()) { + classesByRef.put(classRef(cls), cls); + } + return info(); + } + + private Map info() { + requireLoaded(); + Map result = new LinkedHashMap<>(); + result.put("path", inputPath.toString()); + result.put("classes", decompiler.getClassesWithInners().size()); + result.put("resources", decompiler.getResources().size()); + result.put("errors", decompiler.getErrorsCount()); + result.put("warnings", decompiler.getWarnsCount()); + result.put("capabilities", List.of( + "classes", "methods", "fields", "source", "xrefs", + "resources", "manifest")); + return result; + } + + private List> listClasses(String filter, int limit) { + requireLoaded(); + Pattern pattern = compileFilter(filter); + List> result = new ArrayList<>(); + for (JavaClass cls : decompiler.getClassesWithInners()) { + Map item = classDto(cls); + if (matches(pattern, item)) { + result.add(item); + if (reachedLimit(result, limit)) { + break; + } + } + } + return result; + } + + private List> listMethods(String classRef, String filter, int limit) { + requireLoaded(); + Pattern pattern = compileFilter(filter); + List> result = new ArrayList<>(); + for (JavaClass cls : selectedClasses(classRef)) { + for (JavaMethod method : cls.getMethods()) { + Map item = methodDto(method); + if (matches(pattern, item)) { + result.add(item); + if (reachedLimit(result, limit)) { + return result; + } + } + } + } + return result; + } + + private List> listFields(String classRef, String filter, int limit) { + requireLoaded(); + Pattern pattern = compileFilter(filter); + List> result = new ArrayList<>(); + for (JavaClass cls : selectedClasses(classRef)) { + for (JavaField field : cls.getFields()) { + Map item = fieldDto(field); + if (matches(pattern, item)) { + result.add(item); + if (reachedLimit(result, limit)) { + return result; + } + } + } + } + return result; + } + + private Map classSource(String ref) { + JavaClass cls = findClass(ref); + return Map.of( + "ref", classRef(cls), + "language", "java", + "text", cls.getCode()); + } + + private Map methodSource(String ref) { + JavaMethod method = findMethod(ref); + return Map.of( + "ref", methodRef(method), + "class_ref", classRef(method.getDeclaringClass()), + "language", "java", + "text", extractMethodSource(method)); + } + + /** + * JADX's JavaMethod#getCodeStr intentionally includes comments back to the + * preceding blank line. For the first method in a class that can include + * the class declaration itself. Use public code metadata to cut exactly + * from the method declaration line through its matching END annotation. + */ + private String extractMethodSource(JavaMethod method) { + ICodeInfo codeInfo = method.getTopParentClass().getCodeInfo(); + if (!codeInfo.hasMetadata()) { + return method.getCodeStr(); + } + String code = codeInfo.getCodeStr(); + int definition = method.getDefPos(); + int lineBreak = code.lastIndexOf('\n', Math.max(0, definition - 1)); + int start = lineBreak == -1 ? 0 : lineBreak + 1; + int[] nested = {0}; + Integer end = codeInfo.getCodeMetadata().searchDown(definition + 1, (position, annotation) -> { + switch (annotation.getAnnType()) { + case DECLARATION -> { + ICodeNodeRef node = ((NodeDeclareRef) annotation).getNode(); + switch (node.getAnnType()) { + case CLASS, METHOD -> nested[0]++; + default -> { + } + } + } + case END -> { + if (nested[0] == 0) { + return position; + } + nested[0]--; + } + default -> { + } + } + return null; + }); + if (end == null || end < start || end > code.length()) { + return method.getCodeStr(); + } + return code.substring(start, end); + } + + private List> classXrefs(String ref) { + return nodesToDtos(findClass(ref).getUseIn()); + } + + private Map methodXrefs(String ref, String directionValue) { + JavaMethod method = findMethod(ref); + String direction = directionValue == null ? "both" : directionValue.toLowerCase(Locale.ROOT); + if (!List.of("callers", "callees", "both").contains(direction)) { + throw new IllegalArgumentException( + "direction must be one of callers, callees, or both"); + } + Map result = new LinkedHashMap<>(); + result.put("ref", methodRef(method)); + if (direction.equals("callers") || direction.equals("both")) { + result.put("callers", nodesToDtos(method.getUseIn())); + } + if (direction.equals("callees") || direction.equals("both")) { + result.put("callees", nodesToDtos(method.getUsed())); + } + return result; + } + + private List> fieldXrefs(String ref) { + return nodesToDtos(findField(ref).getUseIn()); + } + + private List> listResources(String filter, int limit) { + requireLoaded(); + Pattern pattern = compileFilter(filter); + List> result = new ArrayList<>(); + for (ResourceFile resource : decompiler.getResources()) { + Map item = resourceDto(resource); + if (matches(pattern, item)) { + result.add(item); + if (reachedLimit(result, limit)) { + break; + } + } + } + return result; + } + + private Map getResource(String pathValue, int maxBytes) { + if (maxBytes < 1) { + throw new IllegalArgumentException("max_bytes must be at least 1"); + } + ResourceFile resource = findResource(pathValue); + return resourceContent(resource, maxBytes); + } + + private Map getManifest() { + requireLoaded(); + for (ResourceFile resource : decompiler.getResources()) { + if (resource.getType() == ResourceType.MANIFEST + || resource.getOriginalName().endsWith("AndroidManifest.xml") + || resource.getDeobfName().endsWith("AndroidManifest.xml")) { + return resourceContent(resource, 1024 * 1024); + } + } + throw new IllegalArgumentException("AndroidManifest.xml was not found"); + } + + private Map resourceContent(ResourceFile resource, int maxBytes) { + ResContainer container = resource.loadContent(); + Map result = new LinkedHashMap<>(resourceDto(resource)); + result.putAll(containerContent(container, maxBytes)); + return result; + } + + private Map containerContent(ResContainer container, int maxBytes) { + Map result = new LinkedHashMap<>(); + result.put("container_name", container.getName()); + result.put("container_type", container.getDataType().name().toLowerCase(Locale.ROOT)); + switch (container.getDataType()) { + case TEXT, RES_TABLE -> { + result.put("encoding", "utf-8"); + result.put("text", container.getText().getCodeStr()); + if (!container.getSubFiles().isEmpty()) { + List> children = new ArrayList<>(); + for (ResContainer child : container.getSubFiles()) { + Map childResult = new LinkedHashMap<>(); + childResult.put("path", child.getName()); + childResult.putAll(containerContent(child, maxBytes)); + children.add(childResult); + } + result.put("children", children); + } + } + case DECODED_DATA -> { + byte[] data = container.getDecodedData(); + result.put("encoding", "base64"); + int outputSize = Math.min(data.length, maxBytes); + result.put("size", outputSize); + result.put("total_size", data.length); + result.put("truncated", outputSize < data.length); + result.put( + "data", + Base64.getEncoder().encodeToString( + outputSize == data.length + ? data + : Arrays.copyOf(data, outputSize))); + } + case RES_LINK -> result.putAll(rawResourceContent(container.getResLink(), maxBytes)); + } + return result; + } + + private Map rawResourceContent(ResourceFile resource, int maxBytes) { + try { + return ResourcesLoader.decodeStream(resource, (declaredSize, stream) -> { + byte[] read = stream.readNBytes(maxBytes + 1); + int outputSize = Math.min(read.length, maxBytes); + boolean truncated = read.length > maxBytes + || (declaredSize >= 0 && declaredSize > outputSize); + Map result = new LinkedHashMap<>(); + result.put("encoding", "base64"); + result.put("size", outputSize); + if (declaredSize >= 0) { + result.put("total_size", declaredSize); + } + result.put("truncated", truncated); + result.put( + "data", + Base64.getEncoder().encodeToString( + outputSize == read.length + ? read + : Arrays.copyOf(read, outputSize))); + return result; + }); + } catch (Exception exception) { + throw new IllegalStateException( + "Failed to read resource: " + resource.getOriginalName(), + exception); + } + } + + private List selectedClasses(String classRef) { + if (classRef == null || classRef.isBlank()) { + return decompiler.getClassesWithInners(); + } + return Collections.singletonList(findClass(classRef)); + } + + private JavaClass findClass(String ref) { + requireLoaded(); + JavaClass direct = classesByRef.get(ref); + if (direct != null) { + return direct; + } + for (JavaClass cls : decompiler.getClassesWithInners()) { + if (classRef(cls).equals(ref) + || cls.getRawName().equals(ref) + || cls.getFullName().equals(ref)) { + return cls; + } + } + throw new IllegalArgumentException("Class not found: " + ref); + } + + private JavaMethod findMethod(String ref) { + requireLoaded(); + int separator = ref.indexOf("->"); + if (separator < 1) { + throw new IllegalArgumentException( + "Method reference must contain a declaring class and descriptor: " + ref); + } + JavaClass cls = findClass(ref.substring(0, separator)); + for (JavaMethod method : cls.getMethods()) { + if (methodRef(method).equals(ref)) { + return method; + } + } + throw new IllegalArgumentException("Method not found: " + ref); + } + + private JavaField findField(String ref) { + requireLoaded(); + int separator = ref.indexOf("->"); + if (separator < 1) { + throw new IllegalArgumentException( + "Field reference must contain a declaring class and descriptor: " + ref); + } + JavaClass cls = findClass(ref.substring(0, separator)); + for (JavaField field : cls.getFields()) { + if (fieldRef(field).equals(ref)) { + return field; + } + } + throw new IllegalArgumentException("Field not found: " + ref); + } + + private ResourceFile findResource(String pathValue) { + requireLoaded(); + for (ResourceFile resource : decompiler.getResources()) { + if (resource.getOriginalName().equals(pathValue) + || resource.getDeobfName().equals(pathValue)) { + return resource; + } + } + throw new IllegalArgumentException("Resource not found: " + pathValue); + } + + private Map classDto(JavaClass cls) { + Map item = new LinkedHashMap<>(); + item.put("kind", "class"); + item.put("ref", classRef(cls)); + item.put("name", cls.getName()); + item.put("full_name", cls.getFullName()); + item.put("raw_name", cls.getRawName()); + item.put("package", cls.getPackage()); + return item; + } + + private Map methodDto(JavaMethod method) { + Map item = new LinkedHashMap<>(); + item.put("kind", "method"); + item.put("ref", methodRef(method)); + item.put("class_ref", classRef(method.getDeclaringClass())); + item.put("name", method.getName()); + item.put("full_name", method.getFullName()); + item.put("arguments", method.getArguments().stream().map(Objects::toString).toList()); + item.put("return_type", method.getReturnType().toString()); + item.put("access", method.getAccessFlags().toString()); + item.put("constructor", method.isConstructor()); + return item; + } + + private Map fieldDto(JavaField field) { + Map item = new LinkedHashMap<>(); + item.put("kind", "field"); + item.put("ref", fieldRef(field)); + item.put("class_ref", classRef(field.getDeclaringClass())); + item.put("name", field.getName()); + item.put("raw_name", field.getRawName()); + item.put("full_name", field.getFullName()); + item.put("type", field.getType().toString()); + item.put("access", field.getAccessFlags().toString()); + return item; + } + + private Map resourceDto(ResourceFile resource) { + Map item = new LinkedHashMap<>(); + item.put("kind", "resource"); + item.put("path", resource.getDeobfName()); + item.put("original_path", resource.getOriginalName()); + item.put("type", resource.getType().name().toLowerCase(Locale.ROOT)); + item.put( + "content_type", + resource.getType().getContentType().name().toLowerCase(Locale.ROOT)); + return item; + } + + private List> nodesToDtos(List nodes) { + List> result = new ArrayList<>(); + for (JavaNode node : nodes) { + if (node instanceof JavaMethod method) { + result.add(methodDto(method)); + } else if (node instanceof JavaField field) { + result.add(fieldDto(field)); + } else if (node instanceof JavaClass cls) { + result.add(classDto(cls)); + } else { + Map item = new LinkedHashMap<>(); + item.put("kind", node.getClass().getSimpleName()); + item.put("name", node.getName()); + item.put("full_name", node.getFullName()); + result.add(item); + } + } + return result; + } + + private static String classRef(JavaClass cls) { + return JadxNodeRef.forCls(cls).toString(); + } + + private static String methodRef(JavaMethod method) { + return JadxNodeRef.forMth(method).toString(); + } + + private static String fieldRef(JavaField field) { + return JadxNodeRef.forFld(field).toString(); + } + + private static Pattern compileFilter(String filter) { + return filter == null || filter.isBlank() + ? null + : Pattern.compile(filter, Pattern.CASE_INSENSITIVE); + } + + private static boolean matches(Pattern pattern, Map item) { + if (pattern == null) { + return true; + } + for (Object value : item.values()) { + if (value != null && pattern.matcher(value.toString()).find()) { + return true; + } + } + return false; + } + + private static boolean reachedLimit(List values, int limit) { + return limit > 0 && values.size() >= limit; + } + + private static String requiredString(JsonObject params, String name) { + String value = optionalString(params, name); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Missing required parameter: " + name); + } + return value; + } + + private static String optionalString(JsonObject params, String name) { + return params.has(name) && !params.get(name).isJsonNull() + ? params.get(name).getAsString() + : null; + } + + private static int optionalInt(JsonObject params, String name, int defaultValue) { + return params.has(name) && !params.get(name).isJsonNull() + ? params.get(name).getAsInt() + : defaultValue; + } + + private void requireLoaded() { + if (decompiler == null) { + throw new IllegalStateException("No input is loaded"); + } + } + + @Override + public void close() { + if (decompiler != null) { + decompiler.close(); + decompiler = null; + } + classesByRef.clear(); + inputPath = null; + } +} diff --git a/declib/decompilers/jadx/worker/src/main/java/declib/jadxworker/Main.java b/declib/decompilers/jadx/worker/src/main/java/declib/jadxworker/Main.java new file mode 100644 index 00000000..1a08b293 --- /dev/null +++ b/declib/decompilers/jadx/worker/src/main/java/declib/jadxworker/Main.java @@ -0,0 +1,61 @@ +package declib.jadxworker; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * Line-delimited JSON request/response transport for {@link JadxService}. + * + *

JADX and all of its objects remain inside this JVM. DecLib only receives + * structured values and stable raw Java/Dex references.

+ */ +public final class Main { + private static final Gson GSON = new Gson(); + + private Main() { + } + + public static void main(String[] args) throws Exception { + System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "warn"); + try (JadxService service = new JadxService(); + BufferedReader input = new BufferedReader( + new InputStreamReader(System.in, StandardCharsets.UTF_8)); + PrintWriter output = new PrintWriter(System.out, true, StandardCharsets.UTF_8)) { + String line; + while ((line = input.readLine()) != null) { + if (line.isBlank()) { + continue; + } + JsonObject response = new JsonObject(); + try { + JsonObject request = JsonParser.parseString(line).getAsJsonObject(); + JsonElement id = request.get("id"); + response.add("id", id == null ? JsonNull.INSTANCE : id.deepCopy()); + String method = request.get("method").getAsString(); + JsonObject params = request.has("params") + ? request.getAsJsonObject("params") + : new JsonObject(); + response.add("result", GSON.toJsonTree(service.dispatch(method, params))); + } catch (Throwable throwable) { + JsonObject error = new JsonObject(); + error.addProperty("type", throwable.getClass().getSimpleName()); + error.addProperty( + "message", + throwable.getMessage() == null + ? throwable.toString() + : throwable.getMessage()); + response.add("error", error); + } + output.println(GSON.toJson(response)); + } + } + } +} diff --git a/declib/decompilers/jadx/worker/src/test/java/declib/jadxworker/JadxServiceTest.java b/declib/decompilers/jadx/worker/src/test/java/declib/jadxworker/JadxServiceTest.java new file mode 100644 index 00000000..5d8e2485 --- /dev/null +++ b/declib/decompilers/jadx/worker/src/test/java/declib/jadxworker/JadxServiceTest.java @@ -0,0 +1,38 @@ +package declib.jadxworker; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonObject; + +class JadxServiceTest { + @Test + void pingDoesNotRequireLoadedInput() { + try (JadxService service = new JadxService()) { + Object result = service.dispatch("ping", new JsonObject()); + assertEquals("ok", ((Map) result).get("status")); + } + } + + @Test + void loadedOperationsFailClearlyBeforeLoad() { + try (JadxService service = new JadxService()) { + assertThrows( + IllegalStateException.class, + () -> service.dispatch("info", new JsonObject())); + } + } + + @Test + void unknownMethodIsRejected() { + try (JadxService service = new JadxService()) { + assertThrows( + IllegalArgumentException.class, + () -> service.dispatch("not_a_method", new JsonObject())); + } + } +} diff --git a/declib/decompilers/jadx/worker_client.py b/declib/decompilers/jadx/worker_client.py new file mode 100644 index 00000000..83c1d5fb --- /dev/null +++ b/declib/decompilers/jadx/worker_client.py @@ -0,0 +1,242 @@ +import json +import os +import queue +import shlex +import shutil +import subprocess +import threading +from collections import deque +from pathlib import Path +from typing import Any, Dict, Optional + +from filelock import FileLock + + +class JadxWorkerError(RuntimeError): + """Raised when the Java JADX worker rejects a request or exits.""" + + +class JadxWorkerClient: + """Synchronous JSONL client for the long-lived Java JADX worker.""" + + def __init__( + self, + *, + executable: Optional[str] = None, + request_timeout: float = 120.0, + build_if_missing: bool = True, + ): + self.request_timeout = request_timeout + self._request_lock = threading.Lock() + self._responses: queue.Queue = queue.Queue() + self._stderr_tail = deque(maxlen=100) + self._next_id = 1 + self._closed = False + + command = self.resolve_command( + executable=executable, + build_if_missing=build_if_missing, + ) + self._process = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + bufsize=1, + ) + self._stdout_thread = threading.Thread( + target=self._read_stdout, + name="declib-jadx-stdout", + daemon=True, + ) + self._stderr_thread = threading.Thread( + target=self._read_stderr, + name="declib-jadx-stderr", + daemon=True, + ) + self._stdout_thread.start() + self._stderr_thread.start() + self.call("ping", timeout=15.0) + + @classmethod + def resolve_command( + cls, + *, + executable: Optional[str] = None, + build_if_missing: bool = True, + ) -> list[str]: + override = executable or os.getenv("DECLIB_JADX_WORKER") + if override: + command = shlex.split(override) + if not command: + raise JadxWorkerError("DECLIB_JADX_WORKER is empty") + return command + + worker_dir = Path(__file__).with_name("worker") + script_name = "declib-jadx-worker.bat" if os.name == "nt" else "declib-jadx-worker" + script = worker_dir / "build" / "install" / "declib-jadx-worker" / "bin" / script_name + if not script.exists() and build_if_missing: + cls.build(worker_dir) + if not script.exists(): + raise JadxWorkerError( + f"JADX worker is not built at {script}. Run " + f"`gradle --no-daemon installDist` in {worker_dir}, or set " + "DECLIB_JADX_WORKER to a prebuilt worker executable." + ) + return [str(script)] + + @staticmethod + def build(worker_dir: Path) -> None: + gradle = shutil.which("gradle") + if gradle is None: + raise JadxWorkerError( + "Gradle is required to build the JADX worker. Install Gradle or " + "set DECLIB_JADX_WORKER to a prebuilt worker executable." + ) + lock_path = worker_dir / ".build.lock" + with FileLock(str(lock_path)): + script_name = "declib-jadx-worker.bat" if os.name == "nt" else "declib-jadx-worker" + script = ( + worker_dir + / "build" + / "install" + / "declib-jadx-worker" + / "bin" + / script_name + ) + if script.exists(): + return + result = subprocess.run( + [gradle, "--no-daemon", "installDist"], + cwd=worker_dir, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + timeout=300, + check=False, + ) + if result.returncode != 0: + raise JadxWorkerError( + "Failed to build the JADX worker:\n" + result.stdout[-8000:] + ) + + def call( + self, + method: str, + params: Optional[Dict[str, Any]] = None, + *, + timeout: Optional[float] = None, + ) -> Any: + if self._closed: + raise JadxWorkerError("JADX worker is closed") + with self._request_lock: + if self._process.poll() is not None: + raise self._exit_error() + request_id = self._next_id + self._next_id += 1 + request = { + "id": request_id, + "method": method, + "params": params or {}, + } + assert self._process.stdin is not None + try: + self._process.stdin.write(json.dumps(request, separators=(",", ":")) + "\n") + self._process.stdin.flush() + except (BrokenPipeError, OSError) as exc: + raise self._exit_error() from exc + + wait_timeout = self.request_timeout if timeout is None else timeout + try: + response = self._responses.get(timeout=wait_timeout) + except queue.Empty as exc: + self._terminate_process() + raise JadxWorkerError( + f"JADX worker timed out after {wait_timeout:g}s while handling " + f"{method!r} and was terminated; reload the input to retry" + ) from exc + if isinstance(response, BaseException): + raise response + if response.get("id") != request_id: + raise JadxWorkerError( + f"JADX worker response ID mismatch: expected {request_id}, " + f"got {response.get('id')}" + ) + error = response.get("error") + if error: + error_type = error.get("type", "WorkerError") + message = error.get("message", "Unknown worker error") + if error_type == "IllegalArgumentException": + raise ValueError(message) + raise JadxWorkerError(f"{error_type}: {message}") + return response.get("result") + + def close(self) -> None: + if self._closed: + return + try: + if self._process.poll() is None: + try: + self.call("shutdown", timeout=10.0) + except Exception: + pass + finally: + self._terminate_process() + + def _read_stdout(self) -> None: + assert self._process.stdout is not None + try: + for line in self._process.stdout: + if not line.strip(): + continue + try: + self._responses.put(json.loads(line)) + except json.JSONDecodeError as exc: + self._responses.put( + JadxWorkerError( + f"JADX worker emitted invalid JSON: {line.rstrip()!r}" + ) + ) + return + finally: + if not self._closed: + self._responses.put(self._exit_error()) + + def _read_stderr(self) -> None: + assert self._process.stderr is not None + for line in self._process.stderr: + self._stderr_tail.append(line.rstrip()) + + def _exit_error(self) -> JadxWorkerError: + status = self._process.poll() + detail = "\n".join(self._stderr_tail) + message = f"JADX worker exited unexpectedly with status {status}" + if detail: + message += "\nWorker stderr tail:\n" + detail + return JadxWorkerError(message) + + def _terminate_process(self) -> None: + self._closed = True + if self._process.stdin is not None and not self._process.stdin.closed: + self._process.stdin.close() + if self._process.poll() is not None: + return + try: + self._process.wait(timeout=5.0) + return + except subprocess.TimeoutExpired: + self._process.terminate() + try: + self._process.wait(timeout=2.0) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait(timeout=2.0) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() diff --git a/declib/skills/decompiler/SKILL.md b/declib/skills/decompiler/SKILL.md index 54e99b1f..7a6d72b2 100644 --- a/declib/skills/decompiler/SKILL.md +++ b/declib/skills/decompiler/SKILL.md @@ -1,12 +1,12 @@ --- name: decompiler -description: Reverse-engineer and modify binaries with a single `decompiler` CLI that drives IDA Pro, Ghidra, Binary Ninja, or angr via DecLib. Use whenever the user asks to decompile, disassemble, look up cross references, rename functions or variables, define or change types, sync work between decompilers, search strings or functions, or otherwise inspect a binary file. Also use for multi-binary workflows (load several binaries at once and switch between them with --id). +description: Reverse-engineer and modify native binaries, Java archives, Android APKs, and Dex files with a single `decompiler` CLI that drives IDA Pro, Ghidra, Binary Ninja, angr, or JADX via DecLib. Use whenever the user asks to decompile, disassemble, inspect Java/Android classes or resources, look up cross references, rename functions or variables, define or change types, sync work between decompilers, search strings or functions, or otherwise inspect a binary file. Also use for multi-binary workflows (load several binaries at once and switch between them with --id). --- # `decompiler` — DecLib CLI for LLMs The `decompiler` command is a thin client that talks to a long-running -`DecompilerServer` (IDA / Ghidra / Binary Ninja / angr). The first `load` of a +`DecompilerServer` (IDA / Ghidra / Binary Ninja / angr / JADX). The first `load` of a binary spawns a server in the background; every subsequent call reuses that server, so repeated `decompile`/`disassemble`/`xref_*` calls are fast. @@ -20,6 +20,7 @@ That's it — the `decompiler` CLI drives every backend headlessly via DecLib and does **not** need any plugins installed inside IDA/Ghidra/Binary Ninja to run. `angr` needs no host tool at all (it's a pure Python dependency) and is the fastest way to verify the pipeline end-to-end. +JADX requires Java 17+ and Gradle for its automatic first-load worker build. ## Mental model @@ -28,10 +29,30 @@ and is the fastest way to verify the pipeline end-to-end. | **Server** | A headless `declib --server` process holding a single binary open. Identified by a short ID. | | **Client** | Every `decompiler ` call is a short-lived client that picks a server, does one thing, and exits. | | **Registry** | `decompiler list` / the shared registry under the declib state dir. Each record has `id`, `backend`, `binary_path`, `socket_path`, `pid`. Use `decompiler list --show-registry` to print just the path. | -| **Address form** | Servers expose **lifted** addresses (relative to the binary base). The CLI accepts either lifted (`0x71d`) or absolute (`0x40071d`) and does the conversion. JSON output always includes both `addr` (int) and `addr_hex` (hex string). | +| **Identity** | Native backends expose **lifted** integer addresses. JADX exposes stable class, field, and full method-descriptor `ref` strings; never treat them as addresses or omit a method descriptor. | ## First moves on a new binary +For an APK, DEX, AAB, XAPK, JAR, or class file, select JADX and begin with +classes, methods, and the manifest/resources. Copy complete `ref` values from +JSON list output; descriptors make overloaded methods unambiguous. + +```bash +decompiler load ./challenge.apk --backend jadx +decompiler manifest --raw +decompiler class list --filter 'challenge|MainActivity' --json +decompiler method list --class 'com.example.MainActivity' --json +decompiler method source \ + 'com.example.MainActivity->checkFlag(Ljava/lang/String;)Z' --raw +decompiler method xrefs \ + 'com.example.MainActivity->checkFlag(Ljava/lang/String;)Z' --json +decompiler resource list --filter 'xml|json|assets' --json +``` + +JADX xrefs can trigger whole-program usage analysis and consume substantially +more time and memory than listing or decompiling one class. Use them after +narrowing to an interesting method. + **Always prefer IDA Pro when it's available** (`--backend ida`) — it generally produces the cleanest decompilation and the most accurate type recovery. If IDA fails to load the binary (missing license, unsupported @@ -134,6 +155,7 @@ decompiler load ./my-binary --backend ida # PREFERRED: IDA Pro (needs insta decompiler load ./my-binary --backend ghidra # FALLBACK: needs GHIDRA_INSTALL_DIR decompiler load ./my-binary --backend angr # LAST RESORT: pure-Python, always available decompiler load ./my-binary --backend binja # Binary Ninja, needs license +decompiler load ./challenge.apk --backend jadx # Java/Android managed code ``` If the IDA `load` fails (e.g. unsupported file format, decompiler error), @@ -155,6 +177,11 @@ same binary. | `stop` | Shut down one or all servers. `--save` flushes analysis to disk first; `--discard` drops unsaved edits. | `--id`, `--binary`, `--all`, `--save`, `--discard`, `--json` | | `save` | Persist backend analysis to disk so renames/types/comments survive a reload. | `--path`, `--id`, `--binary`, `--backend`, `--json` | | `list_functions` | Enumerate every function (ADDR, SIZE, NAME). | `--filter REGEX`, `--json` | +| `class list/source/xrefs` | List, decompile, or find references to managed-code classes. | stable class `ref`, `--filter`, `--max-chars`, `--json` | +| `method list/source/xrefs` | List, decompile, or find callers/callees for JVM/Dex methods. | full method `ref`, `--class`, `--direction`, `--max-chars`, `--json` | +| `field list/xrefs` | List managed-code fields or their references. | stable field `ref`, `--class`, `--filter`, `--json` | +| `resource list/get` | List or decode Android/JVM resources. Binary reads default to at most 1 MiB. | path, `--filter`, `--raw`, `--max-chars`, `--max-bytes`, `--json` | +| `manifest` | Decode AndroidManifest.xml. | `--raw`, `--max-chars`, `--json` | | `decompile ` | Pseudocode for a function (name or address). | `--raw`, `--map-lines`, `--lines`, `--grep`, `--context`, `--max-chars`, `--output`, `--json` | | `disassemble ` | Assembly for a function. | `--raw`, same | | `xref_to ` | Every reference (code + data) to the target. | `--decompile`, same | diff --git a/docs/decompiler_cli.md b/docs/decompiler_cli.md index d3dd1121..dd7de33a 100644 --- a/docs/decompiler_cli.md +++ b/docs/decompiler_cli.md @@ -65,6 +65,10 @@ Pick a backend you have available: - **ghidra** — requires `GHIDRA_INSTALL_DIR` and uses PyGhidra. - **binja** — requires a Binary Ninja license. - **ida** — requires IDA Pro. +- **jadx** — requires Java 17+ and Gradle for the first worker build. It uses + stable JVM/Dex references and has dedicated `class`, `method`, `field`, + `resource`, and `manifest` commands; see the + [JADX backend guide](./jadx.md). --- @@ -128,7 +132,7 @@ Load a binary, starting a headless server if one isn't already running for it. ```bash -decompiler load [--backend {angr,ghidra,binja,ida}] +decompiler load [--backend {angr,ghidra,binja,ida,jadx}] [--id SERVER_ID] [--force | --replace] [--timeout SECONDS] @@ -684,7 +688,7 @@ to know which one to talk to. Narrow with any combination of: - **`--id `** — exact match. - **`--binary `** — match by binary path (resolved to an absolute path). -- **`--backend `** — match by backend. +- **`--backend `** — match by backend. If zero servers match, the CLI errors out and tells you to run `decompiler load`. If multiple match, it prints a disambiguation list: diff --git a/docs/jadx.md b/docs/jadx.md new file mode 100644 index 00000000..c22ccb8c --- /dev/null +++ b/docs/jadx.md @@ -0,0 +1,67 @@ +# JADX backend + +The JADX backend analyzes APK, DEX, JAR, class, Smali, AAB, and XAPK inputs +through `jadx-core`. It does not depend on MCP. DecLib starts a private, +long-lived Java worker for each JADX server and exchanges structured JSON over +the worker's standard input and output. + +## Setup + +The prototype worker requires Java 17 or newer and Gradle. It is built +automatically on the first JADX load: + +```bash +decompiler load ./challenge.apk --backend jadx +``` + +To build it ahead of time: + +```bash +cd declib/decompilers/jadx/worker +gradle --no-daemon test installDist +``` + +Set `DECLIB_JADX_WORKER` to use a prebuilt worker command. The generated +launcher accepts additional JVM arguments through +`DECLIB_JADX_WORKER_OPTS`. Its default maximum heap is 4 GiB: + +```bash +export DECLIB_JADX_WORKER_OPTS="-Xmx8g" +``` + +## Usage + +Managed-code objects use stable JVM/Dex references instead of fake native +addresses. Always copy the complete `ref` from a list command; method +descriptors distinguish overloads. + +```bash +decompiler class list --filter 'challenge' --json +decompiler class source 'com.example.MainActivity' --raw + +decompiler method list --class 'com.example.MainActivity' --json +decompiler method source \ + 'com.example.MainActivity->checkFlag(Ljava/lang/String;)Z' --raw +decompiler method xrefs \ + 'com.example.MainActivity->checkFlag(Ljava/lang/String;)Z' --json + +decompiler field list --class 'com.example.MainActivity' --json +decompiler resource list --filter 'xml|json' --json +decompiler resource get 'res/values/strings.xml' --max-chars 8000 --json +decompiler manifest --raw +``` + +Class source, method source, text resources, and the manifest can be bounded +with `--max-chars`. Binary resources are base64 encoded and bounded to 1 MiB +by default; change that limit with `resource get --max-bytes`. + +## Native API differences + +JADX methods are intentionally not exposed through DecLib's address-keyed +`functions` artifact dictionary. Native operations such as memory reads, +segments, byte patches, and define/undefine have no JVM/Dex equivalent. + +Callers, callees, and other xrefs can trigger JADX whole-program usage +analysis. On large applications this can take substantially more time and +memory than listing or decompiling a single class. The 4 GiB default worker +heap bounds this work; raise it explicitly for unusually large applications. diff --git a/pyproject.toml b/pyproject.toml index 6d3ea985..8edf0d10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,11 @@ include-package-data = true [tool.setuptools.package-data] "declib.skills" = ["**/SKILL.md", "**/*.md"] +"declib.decompilers.jadx.worker" = [ + "build.gradle.kts", + "settings.gradle.kts", + "src/main/java/**/*.java", +] [tool.setuptools.packages] find = {namespaces = false} diff --git a/tests/test_jadx.py b/tests/test_jadx.py new file mode 100644 index 00000000..9dfc0703 --- /dev/null +++ b/tests/test_jadx.py @@ -0,0 +1,114 @@ +import shlex +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from declib.api.decompiler_interface import DecompilerInterface +from declib.decompilers.jadx.interface import JadxInterface +from declib.decompilers.jadx.worker_client import JadxWorkerClient + + +class FakeWorker: + def __init__(self, **kwargs): + self.calls = [] + self.closed = False + + def call(self, method, params=None, timeout=None): + self.calls.append((method, params, timeout)) + if method == "load": + return { + "path": params["path"], + "classes": 2, + "resources": 1, + "errors": 0, + "warnings": 0, + "capabilities": ["classes", "methods"], + } + if method == "list_classes": + return [{"kind": "class", "ref": "example.Main", "name": "Main"}] + if method == "method_source": + return { + "ref": params["ref"], + "language": "java", + "text": "void run() {}", + } + return {} + + def close(self): + self.closed = True + + +def test_jadx_interface_uses_opaque_managed_references(tmp_path): + apk = tmp_path / "sample.apk" + apk.write_bytes(b"not needed by fake worker") + + with patch( + "declib.decompilers.jadx.interface.JadxWorkerClient", + FakeWorker, + ): + interface = JadxInterface(binary_path=apk, headless=True) + try: + assert interface.binary_base_addr == 0 + assert interface.binary_arch == "dalvik" + assert interface._functions() == {} + assert interface.managed_list_classes()[0]["ref"] == "example.Main" + source = interface.managed_method_source("example.Main->run()V") + assert source["text"] == "void run() {}" + finally: + worker = interface._worker + interface.shutdown() + assert worker.closed + + +def test_discover_constructs_jadx_backend(tmp_path): + apk = tmp_path / "sample.apk" + apk.write_bytes(b"sample") + sentinel = MagicMock() + + with patch( + "declib.decompilers.jadx.interface.JadxInterface", + return_value=sentinel, + ) as constructor: + result = DecompilerInterface.discover( + force_decompiler="jadx", + binary_path=apk, + headless=True, + ) + + assert result is sentinel + constructor.assert_called_once_with(binary_path=apk, headless=True) + + +def test_worker_client_json_protocol(tmp_path): + worker = tmp_path / "fake_worker.py" + worker.write_text( + """ +import json +import sys +for line in sys.stdin: + request = json.loads(line) + if request["method"] == "fail": + response = { + "id": request["id"], + "error": {"type": "IllegalArgumentException", "message": "bad input"}, + } + else: + response = { + "id": request["id"], + "result": {"method": request["method"], "params": request["params"]}, + } + print(json.dumps(response), flush=True) +""".strip() + + "\n", + encoding="utf-8", + ) + + command = f"{shlex.quote(sys.executable)} {shlex.quote(str(worker))}" + with JadxWorkerClient(executable=command) as client: + assert client.call("echo", {"value": 7}) == { + "method": "echo", + "params": {"value": 7}, + } + with pytest.raises(ValueError, match="bad input"): + client.call("fail") From ecd2db998076a9ec44d42bafed5d6cfc8f94ffc0 Mon Sep 17 00:00:00 2001 From: Eric Gustafson Date: Wed, 22 Jul 2026 22:43:39 -0700 Subject: [PATCH 2/4] Make the JADX runtime optional --- .github/workflows/dec-tests.yml | 9 + .github/workflows/release.yml | 17 +- README.md | 3 +- declib/cli/decompiler_cli.py | 32 +++ .../jadx/worker/bridge/declib-jadx-worker.jar | Bin 0 -> 15121 bytes .../decompilers/jadx/worker/build.gradle.kts | 15 ++ .../src/main/java/declib/jadxworker/Main.java | 7 +- .../worker/src/main/resources/logback.xml | 13 + declib/decompilers/jadx/worker_client.py | 247 ++++++++++++++++-- declib/skills/decompiler/SKILL.md | 7 +- docs/decompiler_cli.md | 22 +- docs/jadx.md | 21 +- pyproject.toml | 2 + tests/test_jadx.py | 81 ++++++ 14 files changed, 438 insertions(+), 38 deletions(-) create mode 100644 declib/decompilers/jadx/worker/bridge/declib-jadx-worker.jar create mode 100644 declib/decompilers/jadx/worker/src/main/resources/logback.xml diff --git a/.github/workflows/dec-tests.yml b/.github/workflows/dec-tests.yml index 61ce27c6..52144ea7 100644 --- a/.github/workflows/dec-tests.yml +++ b/.github/workflows/dec-tests.yml @@ -46,6 +46,15 @@ jobs: with: distribution: "oracle" java-version: "21" + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: "8.12.1" + - name: Test the JADX bridge + run: | + gradle --no-daemon -p declib/decompilers/jadx/worker test stageBridge + git diff --exit-code -- declib/decompilers/jadx/worker/bridge/declib-jadx-worker.jar + pytest tests/test_jadx.py -v - name: Install Ghidra uses: antoniovazquezblanco/setup-ghidra@v2.0.12 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d918205..b4d50792 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,12 +29,27 @@ jobs: uses: actions/setup-python@v2 with: python-version: '3.10' + - name: Set up Java 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '8.12.1' + - name: Build and test the JADX bridge + run: | + gradle --no-daemon -p declib/decompilers/jadx/worker test stageBridge + git diff --exit-code -- declib/decompilers/jadx/worker/bridge/declib-jadx-worker.jar - name: Install build run: pip install build - name: Build dists run: python -m build + - name: Verify the JADX bridge is packaged + run: unzip -l dist/*.whl | grep 'declib/decompilers/jadx/worker/bridge/declib-jadx-worker.jar' - name: Release to PyPI uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 with: user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/README.md b/README.md index 2d5c5f49..ac103ecb 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ The minimum Python version is **3.10**. - angr-management: **>= 9.0** - Ghidra: **>= 12.0** (started in PyGhidra mode) - JADX: **>= 1.5.6** (headless Java/Android support; see - [the JADX backend guide](./docs/jadx.md)) + [the JADX backend guide](./docs/jadx.md)). JADX is optional and is not + bundled with DecLib. ## Usage DecLib exposes all decompiler API through the abstract class `DecompilerInterface`. The `DecompilerInterface` diff --git a/declib/cli/decompiler_cli.py b/declib/cli/decompiler_cli.py index a0a44f55..bb38ecc5 100644 --- a/declib/cli/decompiler_cli.py +++ b/declib/cli/decompiler_cli.py @@ -39,6 +39,7 @@ - field list/xrefs for managed-code fields - resource list/get Android and JVM resources - manifest print the decoded Android manifest +- backend inspect optional backend runtime availability - install-skill install the bundled Agent Skill so LLMs learn the CLI """ import argparse @@ -77,6 +78,7 @@ _SERVER_LOG_TAIL_BYTES = 8192 _BATCH_CLIENT = ContextVar("declib_batch_client", default=None) _BATCH_DISALLOWED_COMMANDS = { + "backend", "batch", "install-skill", "list", @@ -2266,6 +2268,22 @@ def cmd_install_skill(args) -> int: return 0 +# --------------------------------------------------------------------------- +# optional backend runtimes +# --------------------------------------------------------------------------- + +def cmd_backend(args) -> int: + if args.backend_action != "status": + raise SystemExit(f"Unknown backend action: {args.backend_action}") + if args.name == "jadx": + from declib.decompilers.jadx.worker_client import JadxWorkerClient + + status = JadxWorkerClient.runtime_status() + _emit(args, status) + return EXIT_OK if status["available"] else EXIT_NOT_FOUND + raise SystemExit(f"Backend runtime status is not implemented for {args.name!r}") + + # --------------------------------------------------------------------------- # managed code (JVM / Dex) # --------------------------------------------------------------------------- @@ -2544,6 +2562,20 @@ def build_parser() -> argparse.ArgumentParser: _add_output_args(p_list) p_list.set_defaults(func=cmd_list) + # optional backend runtime diagnostics + p_backend = sub.add_parser( + "backend", + help="Inspect optional backend runtime availability.", + ) + backend_sub = p_backend.add_subparsers(dest="backend_action", required=True) + p_backend_status = backend_sub.add_parser( + "status", + help="Check whether an optional backend runtime is ready.", + ) + p_backend_status.add_argument("name", choices=["jadx"]) + _add_output_args(p_backend_status) + p_backend_status.set_defaults(func=cmd_backend) + # batch p_batch = sub.add_parser( "batch", diff --git a/declib/decompilers/jadx/worker/bridge/declib-jadx-worker.jar b/declib/decompilers/jadx/worker/bridge/declib-jadx-worker.jar new file mode 100644 index 0000000000000000000000000000000000000000..55e821ea41d0f033a200444d38f78177768b9890 GIT binary patch literal 15121 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.configureEach { options.release.set(17) } +tasks.withType().configureEach { + isPreserveFileTimestamps = false + isReproducibleFileOrder = true +} + +tasks.register("stageBridge") { + dependsOn(tasks.jar) + from(tasks.jar.flatMap { it.archiveFile }) + into(layout.projectDirectory.dir("bridge")) + rename { "declib-jadx-worker.jar" } +} + tasks.test { useJUnitPlatform() } diff --git a/declib/decompilers/jadx/worker/src/main/java/declib/jadxworker/Main.java b/declib/decompilers/jadx/worker/src/main/java/declib/jadxworker/Main.java index 1a08b293..0c431429 100644 --- a/declib/decompilers/jadx/worker/src/main/java/declib/jadxworker/Main.java +++ b/declib/decompilers/jadx/worker/src/main/java/declib/jadxworker/Main.java @@ -25,10 +25,15 @@ private Main() { public static void main(String[] args) throws Exception { System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "warn"); + // Stdout is the JSONL protocol. Capture it for protocol responses, then + // redirect any direct or third-party stdout writes to stderr so a JADX + // log line can never desynchronize the client. + PrintWriter output = new PrintWriter(System.out, true, StandardCharsets.UTF_8); + System.setOut(System.err); try (JadxService service = new JadxService(); BufferedReader input = new BufferedReader( new InputStreamReader(System.in, StandardCharsets.UTF_8)); - PrintWriter output = new PrintWriter(System.out, true, StandardCharsets.UTF_8)) { + output) { String line; while ((line = input.readLine()) != null) { if (line.isBlank()) { diff --git a/declib/decompilers/jadx/worker/src/main/resources/logback.xml b/declib/decompilers/jadx/worker/src/main/resources/logback.xml new file mode 100644 index 00000000..99e977de --- /dev/null +++ b/declib/decompilers/jadx/worker/src/main/resources/logback.xml @@ -0,0 +1,13 @@ + + + + System.err + + %-5level - %msg%n + + + + + + diff --git a/declib/decompilers/jadx/worker_client.py b/declib/decompilers/jadx/worker_client.py index 83c1d5fb..2b73473f 100644 --- a/declib/decompilers/jadx/worker_client.py +++ b/declib/decompilers/jadx/worker_client.py @@ -19,6 +19,11 @@ class JadxWorkerError(RuntimeError): class JadxWorkerClient: """Synchronous JSONL client for the long-lived Java JADX worker.""" + WORKER_MAIN_CLASS = "declib.jadxworker.Main" + TESTED_JADX_VERSION = "1.5.6" + MINIMUM_JADX_VERSION = (1, 5, 6) + DEFAULT_JVM_OPTIONS = ("-Xms128m", "-Xmx4g", "-XX:+UseG1GC") + def __init__( self, *, @@ -75,41 +80,237 @@ def resolve_command( return command worker_dir = Path(__file__).with_name("worker") - script_name = "declib-jadx-worker.bat" if os.name == "nt" else "declib-jadx-worker" - script = worker_dir / "build" / "install" / "declib-jadx-worker" / "bin" / script_name - if not script.exists() and build_if_missing: - cls.build(worker_dir) - if not script.exists(): + jadx_jar, _ = cls.find_jadx_runtime() + if jadx_jar is None: + raise JadxWorkerError( + "Official JADX runtime not found. Install JADX " + f"{cls.TESTED_JADX_VERSION} or newer and put `jadx` on PATH, " + "set JADX_HOME to its installation directory, set " + "DECLIB_JADX_JAR to its jadx-*-all.jar, or set " + "DECLIB_JADX_WORKER to a complete worker command." + ) + + java, java_version = cls.find_java() + if java is None: + raise JadxWorkerError( + "Java 17 or newer is required for the JADX backend. Install a " + "JRE/JDK and put `java` on PATH or set JAVA_HOME." + ) + if java_version is not None and java_version < 17: + raise JadxWorkerError( + f"Java 17 or newer is required for the JADX backend; found " + f"Java {java_version} at {java}." + ) + + bridge_jar = cls.find_bridge_jar(worker_dir) + if bridge_jar is None and build_if_missing: + cls.build_bridge(worker_dir) + bridge_jar = cls.find_bridge_jar(worker_dir) + if bridge_jar is None: + raise JadxWorkerError( + f"DecLib's JADX bridge is missing under {worker_dir}. Released " + "wheels include it; from a source checkout run " + f"`gradle --no-daemon -p {worker_dir} jar`, or set " + "DECLIB_JADX_WORKER to a complete worker command." + ) + + raw_jvm_options = os.getenv("DECLIB_JADX_WORKER_OPTS") + jvm_options = ( + shlex.split(raw_jvm_options) + if raw_jvm_options is not None + else list(cls.DEFAULT_JVM_OPTIONS) + ) + return [ + str(java), + *jvm_options, + "-cp", + os.pathsep.join((str(bridge_jar), str(jadx_jar))), + cls.WORKER_MAIN_CLASS, + ] + + @classmethod + def find_bridge_jar(cls, worker_dir: Optional[Path] = None) -> Optional[Path]: + worker_dir = worker_dir or Path(__file__).with_name("worker") + candidates = ( + worker_dir / "bridge" / "declib-jadx-worker.jar", + worker_dir / "build" / "libs" / "declib-jadx-worker.jar", + ) + return next((path for path in candidates if path.is_file()), None) + + @classmethod + def find_jadx_runtime(cls) -> tuple[Optional[Path], Optional[str]]: + override = os.getenv("DECLIB_JADX_JAR") + if override: + path = Path(override).expanduser().resolve() + if not path.is_file(): + raise JadxWorkerError(f"DECLIB_JADX_JAR does not exist: {path}") + version = cls._version_from_jar(path) + cls._validate_jadx_version(path, version) + return path, version + + homes = [] + env_home = os.getenv("JADX_HOME") + if env_home: + homes.append(Path(env_home).expanduser()) + + jadx_executable = shutil.which("jadx") + if jadx_executable: + executable = Path(jadx_executable).expanduser().resolve() + homes.extend((executable.parent.parent, executable.parent)) + + visited = set() + for home in homes: + home = home.resolve() + if home in visited: + continue + visited.add(home) + for lib_dir in (home / "lib", home / "libexec" / "lib", home): + if not lib_dir.is_dir(): + continue + jars = sorted( + ( + *lib_dir.glob("jadx-*-all.jar"), + *lib_dir.glob("jadx-all.jar"), + ), + reverse=True, + ) + if jars: + version = cls._version_from_jar(jars[0]) + cls._validate_jadx_version(jars[0], version) + return jars[0], version + return None, None + + @classmethod + def _validate_jadx_version( + cls, + path: Path, + version: Optional[str], + ) -> None: + if ( + version is not None + and cls._version_tuple(version) < cls.MINIMUM_JADX_VERSION + ): raise JadxWorkerError( - f"JADX worker is not built at {script}. Run " - f"`gradle --no-daemon installDist` in {worker_dir}, or set " - "DECLIB_JADX_WORKER to a prebuilt worker executable." + f"JADX {version} at {path} is too old; " + f"{cls.TESTED_JADX_VERSION} or newer is required." + ) + + @staticmethod + def _version_from_jar(path: Path) -> Optional[str]: + prefix = "jadx-" + suffix = "-all.jar" + name = path.name + if not name.startswith(prefix) or not name.endswith(suffix): + return None + return name[len(prefix):-len(suffix)] + + @staticmethod + def _version_tuple(version: str) -> tuple[int, ...]: + parts = [] + for part in version.split("."): + digits = "".join(char for char in part if char.isdigit()) + if not digits: + break + parts.append(int(digits)) + return tuple(parts) + + @staticmethod + def find_java() -> tuple[Optional[Path], Optional[int]]: + java_home = os.getenv("JAVA_HOME") + candidates = [] + if java_home: + executable = "java.exe" if os.name == "nt" else "java" + candidates.append(Path(java_home).expanduser() / "bin" / executable) + on_path = shutil.which("java") + if on_path: + candidates.append(Path(on_path)) + + java = next((path.resolve() for path in candidates if path.is_file()), None) + if java is None: + return None, None + try: + result = subprocess.run( + [str(java), "-version"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + timeout=10, + check=False, ) - return [str(script)] + except (OSError, subprocess.SubprocessError): + return java, None + + first_line = result.stdout.splitlines()[0] if result.stdout else "" + quoted = first_line.split('"', 2) + version = quoted[1] if len(quoted) >= 3 else first_line + first_component = version.split(".", 1)[0] + if first_component == "1": + components = version.split(".") + first_component = components[1] if len(components) > 1 else "" + digits = "".join(char for char in first_component if char.isdigit()) + return java, int(digits) if digits else None + + @classmethod + def runtime_status(cls) -> Dict[str, Any]: + override = os.getenv("DECLIB_JADX_WORKER") + if override: + return { + "available": True, + "source": "DECLIB_JADX_WORKER", + "worker_command": override, + "tested_jadx_version": cls.TESTED_JADX_VERSION, + } + + bridge = cls.find_bridge_jar() + java, java_version = cls.find_java() + try: + jadx_jar, jadx_version = cls.find_jadx_runtime() + runtime_error = None + except JadxWorkerError as exc: + jadx_jar, jadx_version = None, None + runtime_error = str(exc) + + reasons = [] + if bridge is None: + reasons.append("DecLib JADX bridge JAR is missing") + if java is None: + reasons.append("Java is not installed") + elif java_version is not None and java_version < 17: + reasons.append(f"Java {java_version} is older than Java 17") + if runtime_error: + reasons.append(runtime_error) + elif jadx_jar is None: + reasons.append("official JADX runtime was not found") + + return { + "available": not reasons, + "source": "official-jadx", + "bridge_jar": str(bridge) if bridge else None, + "java": str(java) if java else None, + "java_version": java_version, + "jadx_jar": str(jadx_jar) if jadx_jar else None, + "jadx_version": jadx_version, + "tested_jadx_version": cls.TESTED_JADX_VERSION, + "reasons": reasons, + } @staticmethod - def build(worker_dir: Path) -> None: + def build_bridge(worker_dir: Path) -> None: gradle = shutil.which("gradle") if gradle is None: raise JadxWorkerError( - "Gradle is required to build the JADX worker. Install Gradle or " - "set DECLIB_JADX_WORKER to a prebuilt worker executable." + "The DecLib JADX bridge is not built, and Gradle is unavailable. " + "Released wheels include the bridge; source checkouts require " + "Gradle for this developer build step." ) lock_path = worker_dir / ".build.lock" with FileLock(str(lock_path)): - script_name = "declib-jadx-worker.bat" if os.name == "nt" else "declib-jadx-worker" - script = ( - worker_dir - / "build" - / "install" - / "declib-jadx-worker" - / "bin" - / script_name - ) - if script.exists(): + bridge_jar = worker_dir / "build" / "libs" / "declib-jadx-worker.jar" + if bridge_jar.exists(): return result = subprocess.run( - [gradle, "--no-daemon", "installDist"], + [gradle, "--no-daemon", "jar"], cwd=worker_dir, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, diff --git a/declib/skills/decompiler/SKILL.md b/declib/skills/decompiler/SKILL.md index 7a6d72b2..77daad82 100644 --- a/declib/skills/decompiler/SKILL.md +++ b/declib/skills/decompiler/SKILL.md @@ -20,7 +20,10 @@ That's it — the `decompiler` CLI drives every backend headlessly via DecLib and does **not** need any plugins installed inside IDA/Ghidra/Binary Ninja to run. `angr` needs no host tool at all (it's a pure Python dependency) and is the fastest way to verify the pipeline end-to-end. -JADX requires Java 17+ and Gradle for its automatic first-load worker build. +JADX is optional and requires the official JADX 1.5.6+ distribution plus +Java 17+. Check it with `decompiler backend status jadx --json`. DecLib finds +it through `JADX_HOME` or `jadx` on `PATH`; Gradle is only needed when +developing DecLib itself from a source checkout. ## Mental model @@ -38,6 +41,7 @@ classes, methods, and the manifest/resources. Copy complete `ref` values from JSON list output; descriptors make overloaded methods unambiguous. ```bash +decompiler backend status jadx --json decompiler load ./challenge.apk --backend jadx decompiler manifest --raw decompiler class list --filter 'challenge|MainActivity' --json @@ -213,6 +217,7 @@ same binary. | `exec ""` / `exec --file

` | **UNSAFE**: run Python in the backend process. | `--file`, same | | `read int/string/struct [...]` | Typed reads: decode memory as an integer, C string, or defined struct. | `--size`, `--signed`, `--endian`, `--max-len`, `--encoding`, same + `--json` | | `read_memory ` | Read raw bytes from the binary at ``. Default output is a hexdump. | `--format {hexdump,hex,raw}`, same + `--json` (base64-encoded bytes) | +| `backend status jadx` | Check the optional Java/JADX runtime without loading an input. | `--json` | | `install-skill` | Install this file for Claude Code or Codex. | `--agent`, `--dest`, `--force`, `--json` | ### `xref_to` vs `get_callers` diff --git a/docs/decompiler_cli.md b/docs/decompiler_cli.md index dd7de33a..965c00b0 100644 --- a/docs/decompiler_cli.md +++ b/docs/decompiler_cli.md @@ -31,6 +31,7 @@ and can be installed with `decompiler install-skill`. - [`rename`](#rename) - [`list_strings`](#list_strings) - [`get_callers`](#get_callers) + - [`backend status`](#backend-status) - [`install-skill`](#install-skill) - [Server selection (`--id`, `--binary`, `--backend`)](#server-selection) - [JSON output (`--json`, `--raw`)](#json-output) @@ -65,9 +66,11 @@ Pick a backend you have available: - **ghidra** — requires `GHIDRA_INSTALL_DIR` and uses PyGhidra. - **binja** — requires a Binary Ninja license. - **ida** — requires IDA Pro. -- **jadx** — requires Java 17+ and Gradle for the first worker build. It uses - stable JVM/Dex references and has dedicated `class`, `method`, `field`, - `resource`, and `manifest` commands; see the +- **jadx** — optional; requires the official JADX 1.5.6+ distribution and + Java 17+. DecLib finds it via `JADX_HOME` or `jadx` on `PATH`. Check setup + with `decompiler backend status jadx --json`. It uses stable JVM/Dex + references and has dedicated `class`, `method`, `field`, `resource`, and + `manifest` commands; see the [JADX backend guide](./jadx.md). --- @@ -658,6 +661,19 @@ to the backend's lifted form, so both refer to the same byte instead of the backend double-adding the image base. An address at or above the image base is treated as absolute; a smaller one is already lifted. +### `backend status` + +Check whether an optional backend runtime is installed without starting a +decompiler server: + +```bash +decompiler backend status jadx [--json] +``` + +The JSON response reports the bridge JAR, Java runtime, official JADX JAR and +detected versions. It exits successfully when the backend is ready and exits +`1` with a `reasons` list when setup is incomplete. + ### `install-skill` Copy the bundled Agent Skill into a supported agent skill directory so Claude diff --git a/docs/jadx.md b/docs/jadx.md index c22ccb8c..00b4a603 100644 --- a/docs/jadx.md +++ b/docs/jadx.md @@ -7,23 +7,28 @@ the worker's standard input and output. ## Setup -The prototype worker requires Java 17 or newer and Gradle. It is built -automatically on the first JADX load: +JADX is optional and is not bundled with DecLib. Install the official JADX +1.5.6 or newer distribution and Java 17 or newer. DecLib finds JADX through +`JADX_HOME` or a `jadx` executable on `PATH`: ```bash +export JADX_HOME=/opt/jadx +decompiler backend status jadx --json decompiler load ./challenge.apk --backend jadx ``` -To build it ahead of time: +Released DecLib wheels contain the small Java bridge used to communicate with +JADX. Gradle is only needed when developing DecLib from a source checkout and +the bridge has not been built: ```bash -cd declib/decompilers/jadx/worker -gradle --no-daemon test installDist +gradle --no-daemon -p declib/decompilers/jadx/worker test jar ``` -Set `DECLIB_JADX_WORKER` to use a prebuilt worker command. The generated -launcher accepts additional JVM arguments through -`DECLIB_JADX_WORKER_OPTS`. Its default maximum heap is 4 GiB: +Set `DECLIB_JADX_JAR` to select a specific official `jadx-*-all.jar`, or +`DECLIB_JADX_WORKER` to use a completely custom worker command. Additional +JVM arguments can be supplied through `DECLIB_JADX_WORKER_OPTS`; the default +maximum heap is 4 GiB: ```bash export DECLIB_JADX_WORKER_OPTS="-Xmx8g" diff --git a/pyproject.toml b/pyproject.toml index 8edf0d10..c7814f96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,9 @@ include-package-data = true "declib.decompilers.jadx.worker" = [ "build.gradle.kts", "settings.gradle.kts", + "bridge/declib-jadx-worker.jar", "src/main/java/**/*.java", + "src/main/resources/**/*", ] [tool.setuptools.packages] diff --git a/tests/test_jadx.py b/tests/test_jadx.py index 9dfc0703..51e9557d 100644 --- a/tests/test_jadx.py +++ b/tests/test_jadx.py @@ -1,5 +1,7 @@ +import os import shlex import sys +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -112,3 +114,82 @@ def test_worker_client_json_protocol(tmp_path): } with pytest.raises(ValueError, match="bad input"): client.call("fail") + + +def test_find_official_jadx_runtime_from_home(tmp_path, monkeypatch): + jadx_home = tmp_path / "jadx" + jadx_jar = jadx_home / "lib" / "jadx-1.5.6-all.jar" + jadx_jar.parent.mkdir(parents=True) + jadx_jar.write_bytes(b"test") + monkeypatch.setenv("JADX_HOME", str(jadx_home)) + monkeypatch.delenv("DECLIB_JADX_JAR", raising=False) + monkeypatch.setattr("shutil.which", lambda name: None) + + assert JadxWorkerClient.find_jadx_runtime() == (jadx_jar, "1.5.6") + + +def test_find_official_jadx_runtime_rejects_old_version(tmp_path, monkeypatch): + jadx_home = tmp_path / "jadx" + jadx_jar = jadx_home / "lib" / "jadx-1.5.5-all.jar" + jadx_jar.parent.mkdir(parents=True) + jadx_jar.write_bytes(b"test") + monkeypatch.setenv("JADX_HOME", str(jadx_home)) + monkeypatch.delenv("DECLIB_JADX_JAR", raising=False) + monkeypatch.setattr("shutil.which", lambda name: None) + + with pytest.raises(RuntimeError, match="too old"): + JadxWorkerClient.find_jadx_runtime() + + +def test_resolve_command_uses_thin_bridge_and_official_jadx( + tmp_path, + monkeypatch, +): + java = tmp_path / "java" + bridge = tmp_path / "declib-jadx-worker.jar" + jadx = tmp_path / "jadx-1.5.6-all.jar" + for path in (java, bridge, jadx): + path.write_bytes(b"test") + + monkeypatch.delenv("DECLIB_JADX_WORKER", raising=False) + monkeypatch.setenv("DECLIB_JADX_WORKER_OPTS", "-Xmx2g") + with ( + patch.object( + JadxWorkerClient, + "find_jadx_runtime", + return_value=(jadx, "1.5.6"), + ), + patch.object( + JadxWorkerClient, + "find_java", + return_value=(java, 21), + ), + patch.object( + JadxWorkerClient, + "find_bridge_jar", + return_value=bridge, + ), + ): + command = JadxWorkerClient.resolve_command(build_if_missing=False) + + assert command == [ + str(java), + "-Xmx2g", + "-cp", + f"{bridge}{os.pathsep}{jadx}", + JadxWorkerClient.WORKER_MAIN_CLASS, + ] + + +def test_runtime_status_reports_missing_optional_runtime(monkeypatch): + monkeypatch.delenv("DECLIB_JADX_WORKER", raising=False) + with ( + patch.object(JadxWorkerClient, "find_bridge_jar", return_value=Path("/bridge.jar")), + patch.object(JadxWorkerClient, "find_java", return_value=(Path("/java"), 21)), + patch.object(JadxWorkerClient, "find_jadx_runtime", return_value=(None, None)), + ): + status = JadxWorkerClient.runtime_status() + + assert status["available"] is False + assert status["source"] == "official-jadx" + assert status["reasons"] == ["official JADX runtime was not found"] From 44275b8bf613aff2337e0d3bef8b703ba2eb3935 Mon Sep 17 00:00:00 2001 From: Eric Gustafson Date: Thu, 23 Jul 2026 22:22:27 -0700 Subject: [PATCH 3/4] Allow decompiler CI to run without proprietary secrets --- .github/workflows/dec-tests.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/dec-tests.yml b/.github/workflows/dec-tests.yml index 52144ea7..7ae436f4 100644 --- a/.github/workflows/dec-tests.yml +++ b/.github/workflows/dec-tests.yml @@ -27,14 +27,16 @@ jobs: run: | python -m pip install --upgrade pip pip install .[test] - - name: Download BS Artifact & Install IDA + - name: Download BS test artifacts + run: git clone https://github.com/binsync/bs-artifacts.git /tmp/bs-artifacts + - name: Install IDA + if: ${{ env.TOOLING_KEY != '' }} run: | - (git clone https://github.com/binsync/bs-artifacts.git /tmp/bs-artifacts && \ - cd /tmp/bs-artifacts && \ - ./helpers/setup_ida_ci.sh) + cd /tmp/bs-artifacts + ./helpers/setup_ida_ci.sh # taken from https://github.com/mandiant/capa/blob/master/.github/workflows/tests.yml#L107-L147 - name: Install Binary Ninja - if: ${{ env.BN_SERIAL != 0 }} + if: ${{ env.BN_SERIAL != '' && env.BN_LICENSE != '' }} run: | mkdir ./.github/binja curl "https://raw.githubusercontent.com/Vector35/binaryninja-api/6812c97/scripts/download_headless.py" -o ./.github/binja/download_headless.py @@ -62,10 +64,8 @@ jobs: auth_token: ${{ secrets.GITHUB_TOKEN }} - name: Pytest run: | - # these two test must be run in separate python environments, due to the way ghidra bridge works - # you also must run these tests in the exact order shown here - TEST_BINARIES_DIR=/tmp/bs-artifacts/binaries pytest \ - tests/test_decompilers.py \ - tests/test_client_server.py \ - tests/test_decompiler_cli.py \ - -sv + tests=(tests/test_client_server.py tests/test_decompiler_cli.py) + if [[ -n "$TOOLING_KEY" && -n "$BN_SERIAL" && -n "$BN_LICENSE" ]]; then + tests=(tests/test_decompilers.py "${tests[@]}") + fi + TEST_BINARIES_DIR=/tmp/bs-artifacts/binaries pytest "${tests[@]}" -sv From 35633e8534b76423387f458d378076d9201eff5f Mon Sep 17 00:00:00 2001 From: Eric Gustafson Date: Fri, 24 Jul 2026 10:00:59 -0700 Subject: [PATCH 4/4] Revert "Allow decompiler CI to run without proprietary secrets" This reverts commit 44275b8bf613aff2337e0d3bef8b703ba2eb3935. --- .github/workflows/dec-tests.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/dec-tests.yml b/.github/workflows/dec-tests.yml index 7ae436f4..52144ea7 100644 --- a/.github/workflows/dec-tests.yml +++ b/.github/workflows/dec-tests.yml @@ -27,16 +27,14 @@ jobs: run: | python -m pip install --upgrade pip pip install .[test] - - name: Download BS test artifacts - run: git clone https://github.com/binsync/bs-artifacts.git /tmp/bs-artifacts - - name: Install IDA - if: ${{ env.TOOLING_KEY != '' }} + - name: Download BS Artifact & Install IDA run: | - cd /tmp/bs-artifacts - ./helpers/setup_ida_ci.sh + (git clone https://github.com/binsync/bs-artifacts.git /tmp/bs-artifacts && \ + cd /tmp/bs-artifacts && \ + ./helpers/setup_ida_ci.sh) # taken from https://github.com/mandiant/capa/blob/master/.github/workflows/tests.yml#L107-L147 - name: Install Binary Ninja - if: ${{ env.BN_SERIAL != '' && env.BN_LICENSE != '' }} + if: ${{ env.BN_SERIAL != 0 }} run: | mkdir ./.github/binja curl "https://raw.githubusercontent.com/Vector35/binaryninja-api/6812c97/scripts/download_headless.py" -o ./.github/binja/download_headless.py @@ -64,8 +62,10 @@ jobs: auth_token: ${{ secrets.GITHUB_TOKEN }} - name: Pytest run: | - tests=(tests/test_client_server.py tests/test_decompiler_cli.py) - if [[ -n "$TOOLING_KEY" && -n "$BN_SERIAL" && -n "$BN_LICENSE" ]]; then - tests=(tests/test_decompilers.py "${tests[@]}") - fi - TEST_BINARIES_DIR=/tmp/bs-artifacts/binaries pytest "${tests[@]}" -sv + # these two test must be run in separate python environments, due to the way ghidra bridge works + # you also must run these tests in the exact order shown here + TEST_BINARIES_DIR=/tmp/bs-artifacts/binaries pytest \ + tests/test_decompilers.py \ + tests/test_client_server.py \ + tests/test_decompiler_cli.py \ + -sv