codevanta 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. codevanta/__init__.py +1 -0
  2. codevanta/analysis/__init__.py +1 -0
  3. codevanta/analysis/binary_diff.py +1168 -0
  4. codevanta/analysis/callgraph.py +542 -0
  5. codevanta/analysis/cfg.py +1201 -0
  6. codevanta/analysis/dataflow.py +762 -0
  7. codevanta/analysis/debugger.py +289 -0
  8. codevanta/analysis/debuginfo.py +304 -0
  9. codevanta/analysis/decompile.py +1094 -0
  10. codevanta/analysis/decompilers/__init__.py +250 -0
  11. codevanta/analysis/decompilers/angr_backend.py +251 -0
  12. codevanta/analysis/decompilers/base.py +278 -0
  13. codevanta/analysis/decompilers/ghidra_backend.py +316 -0
  14. codevanta/analysis/dynamic.py +756 -0
  15. codevanta/analysis/emulate.py +1349 -0
  16. codevanta/analysis/evidence_bundle.py +736 -0
  17. codevanta/analysis/function_hash.py +562 -0
  18. codevanta/analysis/graph_export.py +441 -0
  19. codevanta/analysis/html_view.py +695 -0
  20. codevanta/analysis/jump_tables.py +546 -0
  21. codevanta/analysis/solver.py +715 -0
  22. codevanta/analysis/symbolic.py +1809 -0
  23. codevanta/analysis/trace.py +646 -0
  24. codevanta/analysis/type_propagation.py +1055 -0
  25. codevanta/analysis/type_recovery.py +1214 -0
  26. codevanta/analysis/vtables.py +572 -0
  27. codevanta/analysis/xrefs.py +434 -0
  28. codevanta/api/__init__.py +1 -0
  29. codevanta/cli/__init__.py +1 -0
  30. codevanta/cli/main.py +3038 -0
  31. codevanta/core/__init__.py +31 -0
  32. codevanta/core/disassembler.py +121 -0
  33. codevanta/core/entropy.py +237 -0
  34. codevanta/core/functions.py +176 -0
  35. codevanta/core/loader.py +1351 -0
  36. codevanta/core/report.py +306 -0
  37. codevanta/core/strings.py +109 -0
  38. codevanta/evidence/__init__.py +7 -0
  39. codevanta/evidence/confidence.py +36 -0
  40. codevanta/evidence/models.py +43 -0
  41. codevanta/evidence/provenance.py +37 -0
  42. codevanta/gui/__init__.py +46 -0
  43. codevanta/gui/layout.py +1384 -0
  44. codevanta/gui/server.py +1305 -0
  45. codevanta/ir/__init__.py +6 -0
  46. codevanta/ir/adapters/__init__.py +31 -0
  47. codevanta/ir/base.py +31 -0
  48. codevanta/ir/instruction.py +42 -0
  49. codevanta/mcp_server/__init__.py +5 -0
  50. codevanta/mcp_server/server.py +710 -0
  51. codevanta/mcp_server/tools.py +1364 -0
  52. codevanta/plugins/__init__.py +748 -0
  53. codevanta/setup/__init__.py +28 -0
  54. codevanta/setup/apply.py +22 -0
  55. codevanta/setup/clients.py +285 -0
  56. codevanta/setup/plan.py +79 -0
  57. codevanta/setup/verify.py +43 -0
  58. codevanta/storage/__init__.py +1 -0
  59. codevanta/storage/session.py +514 -0
  60. codevanta-0.1.0.dist-info/METADATA +688 -0
  61. codevanta-0.1.0.dist-info/RECORD +65 -0
  62. codevanta-0.1.0.dist-info/WHEEL +5 -0
  63. codevanta-0.1.0.dist-info/entry_points.txt +2 -0
  64. codevanta-0.1.0.dist-info/licenses/LICENSE +21 -0
  65. codevanta-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,278 @@
1
+ """Backend-neutral primitives shared by every decompiler provider (phase 3).
2
+
3
+ The package :mod:`codevanta.analysis.decompilers` lets CODEVANTA use a *real*
4
+ decompiler when one happens to be installed and its own heuristic renderer
5
+ otherwise. This module holds the parts that must never depend on an optional
6
+ third-party package:
7
+
8
+ * :class:`BackendAvailability` -- the answer to "can this backend run here, and
9
+ if not, why not". A probe never raises; a missing dependency becomes an
10
+ ``available=False`` record with a human-readable :attr:`BackendAvailability.reason`.
11
+ * :class:`BackendResult` -- the backend-neutral rendering handed back to the CLI,
12
+ the MCP server and the report: ``text``, which backend produced it, the source
13
+ ``language``, the ``warnings`` collected on the way and the :class:`Evidence`
14
+ that justifies it. :meth:`BackendResult.to_dict` is JSON-safe.
15
+ * :class:`DecompilerBackend` -- the structural protocol every provider
16
+ implements (``name``, ``requires``, ``availability()``, ``decompile(...)``).
17
+ * :class:`NativeBackend` -- the fallback provider. It forwards to
18
+ :func:`codevanta.analysis.decompile.decompile_function` and can therefore
19
+ always answer, because that renderer only needs the required ``capstone``
20
+ dependency and the loaded binary metadata.
21
+ * :func:`require_budget` -- the single validation path for
22
+ ``max_instructions`` / ``max_blocks``, so every backend rejects the same
23
+ out-of-range requests with the same :class:`AnalysisError` messages as the
24
+ native renderer.
25
+
26
+ Heavy modules are imported lazily: this file imports only the standard library
27
+ plus in-project modules that are already required to run CODEVANTA, and the
28
+ optional backends (:mod:`codevanta.analysis.decompilers.angr_backend`,
29
+ :mod:`codevanta.analysis.decompilers.ghidra_backend`) import their dependency
30
+ inside ``availability()`` / ``decompile()`` only.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from dataclasses import dataclass, replace
36
+ from pathlib import Path
37
+ from typing import Any, Protocol, runtime_checkable
38
+
39
+ from codevanta.analysis.decompile import (
40
+ DEFAULT_MAX_BLOCKS,
41
+ DEFAULT_MAX_INSTRUCTIONS,
42
+ MAX_MAX_BLOCKS,
43
+ MAX_MAX_INSTRUCTIONS,
44
+ )
45
+ from codevanta.analysis.decompile import (
46
+ decompile_function as render_pseudo_c,
47
+ )
48
+ from codevanta.core.loader import BinaryInfo
49
+ from codevanta.core.report import AnalysisError
50
+ from codevanta.evidence.models import Evidence
51
+
52
+ __all__ = [
53
+ "BackendAvailability",
54
+ "BackendResult",
55
+ "DecompilerBackend",
56
+ "NativeBackend",
57
+ "require_budget",
58
+ ]
59
+
60
+
61
+ #: Source language of every rendering this layer produces. All three backends
62
+ #: emit C (the native one emits *heuristic* pseudo-C), so the tag is shared.
63
+ @dataclass(frozen=True)
64
+ class BackendAvailability:
65
+ """Whether one decompiler backend can run in this environment.
66
+
67
+ Attributes:
68
+ name: Backend identifier, one of ``ghidra``, ``angr`` or ``native``.
69
+ available: ``True`` only when :meth:`DecompilerBackend.decompile` can be
70
+ expected to run without installing anything else.
71
+ reason: Human-readable explanation. Always non-empty: for an available
72
+ backend it states what was detected, for an unavailable one it names
73
+ the missing piece and, where possible, how to install it.
74
+ requires: Packages or installations the backend needs, in the order they
75
+ are probed. Empty for the built-in backend.
76
+ """
77
+
78
+ name: str
79
+ available: bool
80
+ reason: str
81
+ requires: tuple[str, ...]
82
+
83
+ def to_dict(self) -> dict[str, Any]:
84
+ """Serialize the probe for the JSON report."""
85
+ return {
86
+ "name": self.name,
87
+ "available": self.available,
88
+ "reason": self.reason,
89
+ "requires": list(self.requires),
90
+ }
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class BackendResult:
95
+ """One decompilation rendered by a backend.
96
+
97
+ Attributes:
98
+ text: The rendering itself. Never empty: a backend that produced nothing
99
+ still returns an explanatory ``/* ... */`` comment plus a warning.
100
+ backend: Name of the backend that produced ``text`` (not the one that was
101
+ requested, which matters for ``auto``).
102
+ language: Source language tag of ``text``; always :data:`LANGUAGE` today.
103
+ warnings: Everything the backend wants the caller to know: budget
104
+ overruns it could not prevent, fallbacks, empty renderings.
105
+ evidence: How the text was produced, including which backend and which
106
+ budgets were in effect.
107
+ """
108
+
109
+ text: str
110
+ backend: str
111
+ language: str
112
+ warnings: tuple[str, ...]
113
+ evidence: Evidence
114
+
115
+ def to_dict(self) -> dict[str, Any]:
116
+ """Serialize the rendering for the JSON report (JSON-safe values only)."""
117
+ return {
118
+ "text": self.text,
119
+ "backend": self.backend,
120
+ "language": self.language,
121
+ "warnings": list(self.warnings),
122
+ "evidence": self.evidence.to_dict(),
123
+ }
124
+
125
+
126
+ @runtime_checkable
127
+ class DecompilerBackend(Protocol):
128
+ """Structural interface every provider implements.
129
+
130
+ The protocol is ``runtime_checkable`` so callers can assert that a registry
131
+ entry really exposes ``name``, ``requires``, ``availability`` and
132
+ ``decompile`` (attribute presence only; signatures are checked statically).
133
+
134
+ Attributes:
135
+ name: Short backend identifier used by :func:`get_backend`.
136
+ requires: Hard requirements, reported verbatim by
137
+ :class:`BackendAvailability`.
138
+ """
139
+
140
+ name: str
141
+ requires: tuple[str, ...]
142
+
143
+ def availability(self) -> BackendAvailability:
144
+ """Probe the environment. Must never raise and never install anything."""
145
+ ...
146
+
147
+ def decompile(
148
+ self,
149
+ path: str | Path,
150
+ info: BinaryInfo,
151
+ address: int,
152
+ max_instructions: int = ...,
153
+ max_blocks: int = ...,
154
+ ) -> BackendResult:
155
+ """Render the function at ``address``.
156
+
157
+ Raises:
158
+ AnalysisError: When the backend is unavailable, when the request is
159
+ out of range, or when the backend itself fails. Never raises
160
+ ``ImportError`` for a missing optional dependency.
161
+ """
162
+ ...
163
+
164
+
165
+ LANGUAGE = "c"
166
+
167
+ #: Prefix of a warning line inside the native renderer's header comment block.
168
+ _HEADER_WARNING_PREFIX = " * warning: "
169
+
170
+ #: Prefix of the native renderer's "more warnings follow" summary line.
171
+ _HEADER_WARNING_OVERFLOW = "(+"
172
+
173
+
174
+ def require_budget(name: str, value: int, maximum: int) -> int:
175
+ """Validate one budget argument against its hard limit.
176
+
177
+ Mirrors the native renderer's validation so the provider layer and the
178
+ renderer reject the same requests.
179
+
180
+ Args:
181
+ name: Argument name, used in the error message.
182
+ value: Requested budget.
183
+ maximum: Largest accepted value.
184
+
185
+ Returns:
186
+ ``value``, unchanged, for convenient inlining.
187
+
188
+ Raises:
189
+ AnalysisError: When ``value`` is not an ``int`` or is out of range.
190
+ """
191
+ if isinstance(value, bool) or not isinstance(value, int):
192
+ raise AnalysisError(f"{name} must be an int, not {type(value).__name__}")
193
+ if value < 1:
194
+ raise AnalysisError(f"{name} must be >= 1, got {value}")
195
+ if value > maximum:
196
+ raise AnalysisError(f"{name} too large ({value}); limit is {maximum}")
197
+ return value
198
+
199
+
200
+ def _header_warnings(body: str) -> tuple[str, ...]:
201
+ """Lift the native renderer's ``warning:`` header lines out of the body."""
202
+ warnings: list[str] = []
203
+ for line in body.splitlines():
204
+ if line == " */":
205
+ break
206
+ if not line.startswith(_HEADER_WARNING_PREFIX):
207
+ continue
208
+ text = line[len(_HEADER_WARNING_PREFIX) :].strip()
209
+ if text and not text.startswith(_HEADER_WARNING_OVERFLOW):
210
+ warnings.append(text)
211
+ return tuple(warnings)
212
+
213
+
214
+ class NativeBackend:
215
+ """The built-in heuristic pseudo-C renderer, always available.
216
+
217
+ The rendering itself lives in :mod:`codevanta.analysis.decompile`; this
218
+ provider only adapts its :class:`~codevanta.analysis.decompile.PseudoFunction`
219
+ to the backend-neutral :class:`BackendResult` (``body`` becomes ``text``) and
220
+ re-publishes the renderer's header warnings as structured warnings.
221
+ """
222
+
223
+ name: str = "native"
224
+ requires: tuple[str, ...] = ()
225
+
226
+ def availability(self) -> BackendAvailability:
227
+ """Report the built-in renderer; it needs no optional dependency."""
228
+ return BackendAvailability(
229
+ name=self.name,
230
+ available=True,
231
+ reason=(
232
+ "built-in mnemonic-level pseudo-C renderer; needs no optional "
233
+ "dependency and always runs"
234
+ ),
235
+ requires=self.requires,
236
+ )
237
+
238
+ def decompile(
239
+ self,
240
+ path: str | Path,
241
+ info: BinaryInfo,
242
+ address: int,
243
+ max_instructions: int = DEFAULT_MAX_INSTRUCTIONS,
244
+ max_blocks: int = DEFAULT_MAX_BLOCKS,
245
+ ) -> BackendResult:
246
+ """Render ``address`` with the native heuristic renderer.
247
+
248
+ Raises:
249
+ AnalysisError: When the address is not executable code, when a budget
250
+ is out of range, or when the region decodes to nothing.
251
+ """
252
+ instruction_budget = require_budget(
253
+ "max_instructions", max_instructions, MAX_MAX_INSTRUCTIONS
254
+ )
255
+ block_budget = require_budget("max_blocks", max_blocks, MAX_MAX_BLOCKS)
256
+ rendered = render_pseudo_c(
257
+ path,
258
+ info,
259
+ address,
260
+ max_instructions=instruction_budget,
261
+ max_blocks=block_budget,
262
+ )
263
+ evidence = replace(
264
+ rendered.evidence,
265
+ extra={
266
+ **rendered.evidence.extra,
267
+ "backend": self.name,
268
+ "max_instructions": instruction_budget,
269
+ "max_blocks": block_budget,
270
+ },
271
+ )
272
+ return BackendResult(
273
+ text=rendered.body,
274
+ backend=self.name,
275
+ language=LANGUAGE,
276
+ warnings=_header_warnings(rendered.body),
277
+ evidence=evidence,
278
+ )
@@ -0,0 +1,316 @@
1
+ """Ghidra decompiler backend via ``pyghidra`` (optional, imported lazily).
2
+
3
+ Ghidra is the strongest free decompiler, but it needs two independent pieces: the
4
+ ``pyghidra`` Python bridge *and* an actual Ghidra installation. Both are probed --
5
+ ``pyghidra`` by importing it, the installation through the ``GHIDRA_INSTALL_DIR``
6
+ environment variable -- and the backend only reports ``available=True`` when both
7
+ are present. Nothing is ever downloaded, extracted or launched by the probe: a
8
+ machine without Ghidra simply gets ``available=False`` and a reason saying which
9
+ half is missing.
10
+
11
+ Rendering uses pyghidra's headless entry point, ``pyghidra.open_program``: it
12
+ starts the JVM, imports and analyses the file into an in-memory project and hands
13
+ back a ``FlatProgramAPI``. Inside that context ``ghidra.app.decompiler`` is
14
+ importable through JPype, so :class:`DecompInterface` decompiles the function at
15
+ the requested address and returns C text.
16
+
17
+ Budgeting: Ghidra's decompiler has no instruction or block budget either. The
18
+ instruction budget is projected onto the decompiler *timeout* (roughly one
19
+ simulated second per 200 instructions, clamped to 5..300 s) and the resulting
20
+ basic-block count is compared with ``max_blocks`` afterwards, which can only be
21
+ reported as a warning. Both budgets travel into the evidence record.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import contextlib
27
+ import os
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from codevanta.analysis.decompile import (
32
+ DEFAULT_MAX_BLOCKS,
33
+ DEFAULT_MAX_INSTRUCTIONS,
34
+ MAX_MAX_BLOCKS,
35
+ MAX_MAX_INSTRUCTIONS,
36
+ )
37
+ from codevanta.core.loader import BinaryInfo
38
+ from codevanta.core.report import AnalysisError
39
+ from codevanta.evidence.confidence import Confidence
40
+ from codevanta.evidence.models import Evidence
41
+
42
+ from .base import (
43
+ LANGUAGE,
44
+ BackendAvailability,
45
+ BackendResult,
46
+ require_budget,
47
+ )
48
+
49
+ __all__ = ["GhidraBackend", "INSTALL_ENV", "INSTALL_HINT", "NAME"]
50
+
51
+ #: Backend identifier.
52
+ NAME = "ghidra"
53
+
54
+ #: Environment variable that points at a Ghidra installation.
55
+ INSTALL_ENV = "GHIDRA_INSTALL_DIR"
56
+
57
+ #: What a user has to install for this backend to become available.
58
+ INSTALL_HINT = (
59
+ f"pip install pyghidra and download Ghidra, then set {INSTALL_ENV} to the "
60
+ "installation directory"
61
+ )
62
+
63
+ #: Evidence source label.
64
+ _SOURCE = "ghidra.decompiler"
65
+
66
+ #: Evidence method label.
67
+ _METHOD = "pyghidra.headless.DecompInterface"
68
+
69
+ #: Entries that mark a directory as a Ghidra installation.
70
+ _INSTALL_MARKERS = ("support", "ghidraRun", "ghidraRun.bat")
71
+
72
+ #: Smallest and largest decompiler timeout, in seconds.
73
+ _MIN_TIMEOUT = 5
74
+ _MAX_TIMEOUT = 300
75
+
76
+ #: Instructions per projected timeout second.
77
+ _INSTRUCTIONS_PER_SECOND = 200
78
+
79
+ #: Comment emitted when Ghidra's decompiler produced no code.
80
+ _EMPTY_TEXT = "/* ghidra produced no pseudocode for 0x{address:x} */"
81
+
82
+
83
+ def _unavailable(availability: BackendAvailability) -> AnalysisError:
84
+ """Build the error raised when a caller asked for an unavailable backend."""
85
+ return AnalysisError(
86
+ f"decompiler backend {availability.name!r} is unavailable: {availability.reason}; "
87
+ "use backend='native' or backend='auto' instead"
88
+ )
89
+
90
+
91
+ def _timeout_seconds(instruction_budget: int) -> int:
92
+ """Project an instruction budget onto a decompiler timeout."""
93
+ seconds = instruction_budget // _INSTRUCTIONS_PER_SECOND
94
+ return max(_MIN_TIMEOUT, min(_MAX_TIMEOUT, seconds))
95
+
96
+
97
+ def _installation_problem(install_dir: str) -> str | None:
98
+ """Return why ``install_dir`` is not usable, or ``None`` when it looks right."""
99
+ if not install_dir:
100
+ return f"no Ghidra installation is configured ({INSTALL_ENV} is not set)"
101
+ root = Path(install_dir)
102
+ if not root.is_dir():
103
+ return f"{INSTALL_ENV}={install_dir!r} is not a directory"
104
+ if not any((root / marker).exists() for marker in _INSTALL_MARKERS):
105
+ return (
106
+ f"{INSTALL_ENV}={install_dir!r} does not look like a Ghidra installation "
107
+ "(no support/ directory and no ghidraRun launcher)"
108
+ )
109
+ return None
110
+
111
+
112
+ class GhidraBackend:
113
+ """Decompilation through a local Ghidra installation driven by pyghidra.
114
+
115
+ Attributes:
116
+ name: ``"ghidra"``.
117
+ requires: ``("pyghidra", "ghidra")``.
118
+ """
119
+
120
+ name: str = NAME
121
+ requires: tuple[str, ...] = ("pyghidra", "ghidra")
122
+
123
+ def availability(self) -> BackendAvailability:
124
+ """Probe ``pyghidra`` and :data:`INSTALL_ENV`; never raises, never launches."""
125
+ problems: list[str] = []
126
+ version = "unknown"
127
+ try:
128
+ import pyghidra
129
+
130
+ version = str(getattr(pyghidra, "__version__", "unknown"))
131
+ except ImportError as exc:
132
+ problems.append(
133
+ f"the 'pyghidra' package is not installed ({type(exc).__name__}: {exc})"
134
+ )
135
+ except Exception as exc: # a broken install must not break discovery
136
+ problems.append(f"importing 'pyghidra' failed ({type(exc).__name__}: {exc})")
137
+ problem = _installation_problem(os.environ.get(INSTALL_ENV, "").strip())
138
+ if problem is not None:
139
+ problems.append(problem)
140
+ if problems:
141
+ return BackendAvailability(
142
+ name=self.name,
143
+ available=False,
144
+ reason="; ".join(problems) + f"; {INSTALL_HINT}",
145
+ requires=self.requires,
146
+ )
147
+ return BackendAvailability(
148
+ name=self.name,
149
+ available=True,
150
+ reason=(
151
+ f"pyghidra {version} is importable and {INSTALL_ENV} points at a Ghidra "
152
+ "installation; the headless decompiler can run"
153
+ ),
154
+ requires=self.requires,
155
+ )
156
+
157
+ def decompile(
158
+ self,
159
+ path: str | Path,
160
+ info: BinaryInfo,
161
+ address: int,
162
+ max_instructions: int = DEFAULT_MAX_INSTRUCTIONS,
163
+ max_blocks: int = DEFAULT_MAX_BLOCKS,
164
+ ) -> BackendResult:
165
+ """Decompile ``address`` with the local Ghidra installation.
166
+
167
+ Args:
168
+ path: Binary to import into a temporary Ghidra project (read-only use
169
+ of the file; the target is analysed, never executed).
170
+ info: Loaded metadata; Ghidra imports the file itself, so only the
171
+ path is used. Kept for a uniform backend signature.
172
+ address: Virtual address of the function to decompile.
173
+ max_instructions: Projected onto the decompiler timeout; Ghidra has no
174
+ instruction budget.
175
+ max_blocks: Compared with the function's block count afterwards; an
176
+ overrun becomes a warning, not an error.
177
+
178
+ Returns:
179
+ The rendering plus any warnings Ghidra could not prevent.
180
+
181
+ Raises:
182
+ AnalysisError: When pyghidra or the Ghidra installation is missing,
183
+ when a budget is out of range, when no function exists at
184
+ ``address``, or when the decompiler reports a failure.
185
+ """
186
+ instruction_budget = require_budget(
187
+ "max_instructions", max_instructions, MAX_MAX_INSTRUCTIONS
188
+ )
189
+ block_budget = require_budget("max_blocks", max_blocks, MAX_MAX_BLOCKS)
190
+ availability = self.availability()
191
+ if not availability.available:
192
+ raise _unavailable(availability)
193
+
194
+ import pyghidra
195
+
196
+ opener = getattr(pyghidra, "open_program", None)
197
+ if not callable(opener):
198
+ raise AnalysisError(
199
+ "the installed pyghidra exposes no open_program() headless entry point; "
200
+ f"{INSTALL_HINT}"
201
+ )
202
+ timeout = _timeout_seconds(instruction_budget)
203
+ _ensure_started(pyghidra)
204
+ try:
205
+ program_context = opener(str(path), analyze=True)
206
+ except TypeError:
207
+ program_context = opener(str(path))
208
+
209
+ try:
210
+ with program_context as flat_api:
211
+ text, block_count = _decompile_in_program(flat_api, address, timeout)
212
+ except AnalysisError:
213
+ raise
214
+ except Exception as exc: # JPype surfaces Java failures as generic errors
215
+ raise AnalysisError(
216
+ f"ghidra could not decompile 0x{address:x}: {type(exc).__name__}: {exc}"
217
+ ) from exc
218
+
219
+ warnings: list[str] = []
220
+ if not text.strip():
221
+ warnings.append(
222
+ f"ghidra returned no decompilation for 0x{address:x}: the decompiler "
223
+ "completed without C text, so the rendering is a placeholder comment"
224
+ )
225
+ text = _EMPTY_TEXT.format(address=address)
226
+ if block_count > block_budget:
227
+ warnings.append(
228
+ f"ghidra decompiled {block_count} block(s), above max_blocks={block_budget}; "
229
+ "Ghidra exposes no block budget, so the text is complete rather than truncated"
230
+ )
231
+
232
+ evidence = Evidence(
233
+ source=_SOURCE,
234
+ method=_METHOD,
235
+ confidence=Confidence.MEDIUM,
236
+ address=address,
237
+ detail=(
238
+ f"ghidra decompiled the function at 0x{address:x} ({block_count} block(s)) "
239
+ f"headlessly through pyghidra with a {timeout}s timeout projected from "
240
+ f"max_instructions={instruction_budget}; Ghidra has no instruction or block "
241
+ f"budget, so max_blocks={block_budget} is checked afterwards only"
242
+ ),
243
+ extra={
244
+ "backend": self.name,
245
+ "install_dir": os.environ.get(INSTALL_ENV, ""),
246
+ "timeout_seconds": timeout,
247
+ "max_instructions": instruction_budget,
248
+ "max_blocks": block_budget,
249
+ "block_count": block_count,
250
+ },
251
+ )
252
+ return BackendResult(
253
+ text=text,
254
+ backend=self.name,
255
+ language=LANGUAGE,
256
+ warnings=tuple(warnings),
257
+ evidence=evidence,
258
+ )
259
+
260
+
261
+ def _ensure_started(pyghidra: Any) -> None:
262
+ """Start the JVM the way pyghidra expects, ignoring "already started"."""
263
+ start = getattr(pyghidra, "start", None)
264
+ if not callable(start):
265
+ return
266
+ # pyghidra raises when the JVM is already up, which is exactly the state we
267
+ # want; any other failure resurfaces from open_program() below.
268
+ with contextlib.suppress(Exception):
269
+ start()
270
+
271
+
272
+ def _decompile_in_program(
273
+ flat_api: Any,
274
+ address: int,
275
+ timeout_seconds: int,
276
+ ) -> tuple[str, int]:
277
+ """Decompile one function inside an open pyghidra program context."""
278
+ from ghidra.app.decompiler import DecompileOptions, DecompInterface
279
+
280
+ program = flat_api.getCurrentProgram()
281
+ target = flat_api.toAddr(address)
282
+ function = flat_api.getFunctionAt(target)
283
+ if function is None:
284
+ function = flat_api.getFunctionContaining(target)
285
+ if function is None:
286
+ raise AnalysisError(
287
+ f"ghidra found no function at 0x{address:x}; decompilation needs a defined function"
288
+ )
289
+ # Ghidra's Function API has no ``getBasicBlocks``; the body of a function is a
290
+ # set of address ranges, and one range is the closest portable stand-in for a
291
+ # block count. ``getBody`` is present across the versions pyghidra ships, and
292
+ # any failure here must not sink the decompilation itself, so it degrades to 0
293
+ # (reported as "unknown") instead of raising.
294
+ block_count = 0
295
+ try:
296
+ body = function.getBody()
297
+ ranges = body.getAddressRanges() if body is not None else None
298
+ block_count = len(list(ranges)) if ranges is not None else 0
299
+ except Exception: # pragma: no cover - defensive across Ghidra versions
300
+ block_count = 0
301
+ decompiler = DecompInterface()
302
+ try:
303
+ decompiler.setOptions(DecompileOptions())
304
+ decompiler.openProgram(program)
305
+ # A ``None`` task monitor is accepted and is what headless scripting uses.
306
+ result = decompiler.decompileFunction(function, timeout_seconds, None)
307
+ if result is None:
308
+ raise AnalysisError(f"ghidra returned no decompiler result for 0x{address:x}")
309
+ if not result.decompileCompleted():
310
+ message = str(result.getErrorMessage() or "no error message reported")
311
+ raise AnalysisError(f"ghidra could not decompile 0x{address:x}: {message}")
312
+ code = result.getDecompiledFunction()
313
+ text = "" if code is None else str(code.getC() or "")
314
+ finally:
315
+ decompiler.dispose()
316
+ return text, block_count