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,289 @@
1
+ """Run a generated gdb session and return its parsed trace report.
2
+
3
+ :mod:`codevanta.analysis.dynamic` writes the session script and
4
+ :mod:`codevanta.analysis.trace` reads a saved log; this module is the narrow
5
+ bridge between them: it *actually runs* the generated script through gdb and
6
+ hands the captured stdout to :func:`codevanta.analysis.trace.parse_gdb_trace`.
7
+ On a machine without gdb -- a Windows workstation, a slim CI container --
8
+ nothing here could run against a real process, so the module is built to work
9
+ without one:
10
+
11
+ * :func:`gdb_available` probes ``PATH`` with ``shutil.which`` and nothing
12
+ else. It never imports ``frida`` and never executes gdb, so the probe cannot
13
+ fail.
14
+ * :func:`run_breakpoints` takes an injectable ``runner`` callable that stands
15
+ in for ``subprocess.run``. A test (or an embedding host) can supply any
16
+ callable with the same contract and drive the full
17
+ build -> write -> run -> parse -> cleanup pipeline against canned output,
18
+ byte for byte as gdb would have printed it.
19
+
20
+ The injection contract
21
+ ----------------------
22
+ ``runner`` is called as ``runner(command, **kwargs)``: ``command`` is the exact
23
+ ``argv`` tuple (``("gdb", "-q", "-batch", "-x", <script>, "--args", <target>)``)
24
+ and ``kwargs`` carry ``capture_output=True``, ``text=True`` and
25
+ ``timeout=<seconds>``. It must return an object exposing ``.returncode`` (an
26
+ ``int``), ``.stdout`` and ``.stderr`` -- the ``subprocess.CompletedProcess``
27
+ that ``subprocess.run(..., capture_output=True, text=True)`` returns satisfies
28
+ this. ``runner=None`` (the default) means ``subprocess.run`` itself.
29
+
30
+ Every failure names what is missing or wrong
31
+ --------------------------------------------
32
+ A missing gdb executable, a session that exceeds ``timeout``, an empty or
33
+ non-``int`` address list, an out-of-range ``max_hits`` -- all raise
34
+ :class:`~codevanta.core.report.AnalysisError` naming the offending input, and
35
+ none of them leaves a script file behind. A non-zero gdb exit code is *not*
36
+ an error: the output is still parsed and the condition is reported as a
37
+ warning on the :class:`GdbRunResult` (the parser's own warnings live on
38
+ ``report.warnings``). The script file is always removed once the run ends,
39
+ whether it succeeded, failed or raised; when ``script_dir`` is supplied, only
40
+ the file this module wrote is removed -- the directory itself and its other
41
+ contents are left alone.
42
+ """
43
+
44
+
45
+ from __future__ import annotations
46
+
47
+ import shutil
48
+ import subprocess
49
+ import tempfile
50
+ from collections.abc import Callable, Iterable, Sequence
51
+ from dataclasses import dataclass
52
+ from pathlib import Path
53
+ from typing import Any, Protocol, TypeAlias
54
+
55
+ from codevanta.analysis.dynamic import DEFAULT_MAX_HITS, build_gdb_script, script_file_name
56
+ from codevanta.analysis.trace import TraceReport, parse_gdb_trace
57
+ from codevanta.core.functions import discover_functions
58
+ from codevanta.core.loader import BinaryInfo
59
+ from codevanta.core.report import AnalysisError
60
+
61
+ __all__ = [
62
+ "DEFAULT_TIMEOUT",
63
+ "GdbRunResult",
64
+ "Runner",
65
+ "RunnerOutcome",
66
+ "gdb_available",
67
+ "run_breakpoints",
68
+ ]
69
+
70
+ #: Default wall-clock budget for one gdb session, in seconds.
71
+ DEFAULT_TIMEOUT = 60.0
72
+
73
+
74
+ class RunnerOutcome(Protocol):
75
+ """What a runner must return: ``subprocess.CompletedProcess``-shaped.
76
+
77
+ Attributes:
78
+ returncode: The gdb process exit code (``0`` on success).
79
+ stdout: Everything gdb printed, decoded to text.
80
+ stderr: Everything gdb wrote to its error stream, decoded to text.
81
+ """
82
+
83
+ returncode: int
84
+ stdout: str
85
+ stderr: str
86
+
87
+
88
+ #: A callable standing in for ``subprocess.run``; see :func:`run_breakpoints`.
89
+ Runner: TypeAlias = Callable[..., RunnerOutcome]
90
+
91
+
92
+ def _default_runner(command: Sequence[str], **kwargs: Any) -> RunnerOutcome:
93
+ """Run ``command`` through :func:`subprocess.run`; the production runner."""
94
+ return subprocess.run(command, **kwargs)
95
+
96
+
97
+ def gdb_available() -> bool:
98
+ """Return ``True`` when the ``gdb`` executable can be found on ``PATH``.
99
+
100
+ The probe is ``shutil.which("gdb")`` and nothing else: no provider package
101
+ is imported (Frida in particular is never touched) and no process is
102
+ started, so asking cannot fail and cannot have side effects.
103
+ """
104
+ return shutil.which("gdb") is not None
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class GdbRunResult:
109
+ """Everything one gdb session produced, plus how it was run.
110
+
111
+ Attributes:
112
+ report: The parsed trace: hits, backtraces, function attribution and
113
+ the parser's own warnings.
114
+ exit_code: The exit code gdb itself returned. Non-zero is reported as
115
+ a warning, not an error: the output was still parsed.
116
+ script_path: Path of the session script that was executed. The file is
117
+ removed before this result is returned, so the path is a record,
118
+ not a handle.
119
+ command: The exact ``argv`` tuple that was (or would have been)
120
+ executed.
121
+ raw_output_path: Path where the raw gdb output was persisted, or
122
+ ``None``. The default run parses stdout in memory and persists
123
+ nothing.
124
+ warnings: Run-level warnings: a non-zero gdb exit code, a script file
125
+ that could not be removed. Parse-level warnings are on
126
+ ``report.warnings``.
127
+
128
+ """
129
+
130
+ report: TraceReport
131
+ exit_code: int
132
+ script_path: str
133
+ command: tuple[str, ...]
134
+ raw_output_path: str | None
135
+ warnings: tuple[str, ...] = ()
136
+
137
+ def to_dict(self) -> dict[str, Any]:
138
+ """Serialize the run for the JSON report (JSON-safe values only)."""
139
+ return {
140
+ "report": self.report.to_dict(),
141
+ "exit_code": self.exit_code,
142
+ "script_path": self.script_path,
143
+ "command": list(self.command),
144
+ "raw_output_path": self.raw_output_path,
145
+ "warnings": list(self.warnings),
146
+ }
147
+
148
+
149
+ def _require_timeout(timeout: float) -> float:
150
+ """Return ``timeout`` after checking it is a positive number of seconds."""
151
+ if isinstance(timeout, bool) or not isinstance(timeout, int | float):
152
+ raise AnalysisError(
153
+ f"timeout must be a number of seconds, got {type(timeout).__name__}: {timeout!r}"
154
+ )
155
+ if timeout <= 0:
156
+ raise AnalysisError(f"timeout must be positive, got {timeout}")
157
+ return float(timeout)
158
+
159
+
160
+ def _as_text(value: Any) -> str:
161
+ """Return ``value`` as text, tolerating ``None`` and bytes."""
162
+ if value is None:
163
+ return ""
164
+ if isinstance(value, bytes):
165
+ return value.decode("utf-8", "replace")
166
+ return str(value)
167
+
168
+
169
+ def _outcome_parts(outcome: RunnerOutcome) -> tuple[int, str]:
170
+ """Return ``(exit_code, stdout_text)`` from a runner outcome.
171
+
172
+ Raises:
173
+ AnalysisError: the outcome's ``returncode`` is not an ``int`` -- a
174
+ runner that does not honour the injection contract.
175
+ """
176
+ returncode = outcome.returncode
177
+ if isinstance(returncode, bool) or not isinstance(returncode, int):
178
+ raise AnalysisError(
179
+ f"runner returned a non-int returncode, got {type(returncode).__name__}: "
180
+ f"{returncode!r}"
181
+ )
182
+ return returncode, _as_text(outcome.stdout)
183
+
184
+
185
+ def run_breakpoints(
186
+ path: str | Path,
187
+ info: BinaryInfo,
188
+ addresses: Iterable[int],
189
+ *,
190
+ max_hits: int = DEFAULT_MAX_HITS,
191
+ timeout: float = DEFAULT_TIMEOUT,
192
+ runner: Runner | None = None,
193
+ script_dir: Path | None = None,
194
+ ) -> GdbRunResult:
195
+ """Build the gdb script, run it, and return the parsed trace report.
196
+
197
+ The script is generated with
198
+ :func:`codevanta.analysis.dynamic.build_gdb_script` (which validates every
199
+ input and raises :class:`~codevanta.core.report.AnalysisError` on an empty
200
+ or non-``int`` address list, an address outside an executable section, or
201
+ an out-of-range ``max_hits``), written to ``script_dir`` or to a private
202
+ temporary directory, executed as
203
+ ``("gdb", "-q", "-batch", "-x", <script>, "--args", str(path))`` through
204
+ ``runner`` (``subprocess.run`` by default), and its captured stdout is
205
+ parsed with :func:`codevanta.analysis.trace.parse_gdb_trace`, attributing
206
+ every hit to a candidate from
207
+ :func:`codevanta.core.functions.discover_functions`.
208
+
209
+ Args:
210
+ path: Target binary, spelled the way gdb should see it.
211
+ info: Binary metadata for the script's registers and section checks,
212
+ and for function attribution in the parsed report.
213
+ addresses: Breakpoint addresses, in file virtual-address space.
214
+ max_hits: Hit ceiling the *script* enforces; the parser keeps
215
+ everything the session printed.
216
+ timeout: Wall-clock budget for the session, in seconds. Exceeding it
217
+ raises :class:`~codevanta.core.report.AnalysisError`.
218
+ runner: Injectable replacement for ``subprocess.run``; see the module
219
+ docstring for the exact contract. ``None`` means
220
+ ``subprocess.run``.
221
+ script_dir: Directory to write the session script into, instead of a
222
+ private temporary directory. The script file is removed after the
223
+ run either way; the directory itself and its other contents are
224
+ left alone.
225
+
226
+ Raises:
227
+ AnalysisError: A missing gdb executable, a session that exceeded
228
+ ``timeout``, any script-validation failure (bad addresses, bad
229
+ ``max_hits``), or a runner whose outcome violates the injection
230
+ contract -- each message names what is missing or wrong.
231
+
232
+ Returns:
233
+ The parsed :class:`~codevanta.analysis.trace.TraceReport` wrapped in a
234
+ :class:`GdbRunResult` with the exit code, the executed ``argv`` and
235
+ any run-level warnings.
236
+ """
237
+ _require_timeout(timeout)
238
+ script = build_gdb_script(path, info, addresses, max_hits=max_hits)
239
+ execute = runner if runner is not None else _default_runner
240
+ warnings: list[str] = []
241
+ temp_dir: tempfile.TemporaryDirectory[str] | None = None
242
+ script_path: Path | None = None
243
+ try:
244
+ if script_dir is None:
245
+ temp_dir = tempfile.TemporaryDirectory(prefix="codevanta-gdb-")
246
+ directory = Path(temp_dir.name)
247
+ else:
248
+ directory = Path(script_dir)
249
+ directory.mkdir(parents=True, exist_ok=True)
250
+ script_path = directory / script_file_name("gdb")
251
+ script_path.write_text(script.text, encoding="utf-8", newline="\n")
252
+ command = ("gdb", "-q", "-batch", "-x", str(script_path), "--args", str(path))
253
+ try:
254
+ outcome = execute(command, capture_output=True, text=True, timeout=timeout)
255
+ except subprocess.TimeoutExpired as exc:
256
+ raise AnalysisError(
257
+ f"gdb session exceeded its timeout of {timeout} seconds and was terminated; "
258
+ "raise timeout or reduce the breakpoint workload"
259
+ ) from exc
260
+ except FileNotFoundError as exc:
261
+ raise AnalysisError(
262
+ "gdb was not found: the runner raised FileNotFoundError, so the gdb "
263
+ "executable is missing; install gdb and make sure it is on PATH "
264
+ "(gdb_available() reports whether it is)"
265
+ ) from exc
266
+ exit_code, stdout = _outcome_parts(outcome)
267
+ if exit_code != 0:
268
+ warnings.append(
269
+ f"gdb exited with non-zero code {exit_code}; its output was still parsed"
270
+ )
271
+ finally:
272
+ if temp_dir is not None:
273
+ temp_dir.cleanup()
274
+ elif script_path is not None:
275
+ try:
276
+ script_path.unlink()
277
+ except OSError as exc:
278
+ warnings.append(f"the session script could not be removed: {exc}")
279
+
280
+ report = parse_gdb_trace(stdout, info=info, functions=discover_functions(info))
281
+ return GdbRunResult(
282
+ report=report,
283
+ exit_code=exit_code,
284
+ script_path=str(script_path),
285
+ command=command,
286
+ raw_output_path=None,
287
+ warnings=tuple(warnings),
288
+ )
289
+
@@ -0,0 +1,304 @@
1
+ """DWARF debug-info extraction via the optional pyelftools backend.
2
+
3
+ Producer debug info is the strongest symbol evidence available for a build
4
+ that kept it: real function names, byte-exact extents, and data-type layouts
5
+ straight from the compiler. This module reads ``.debug_info`` through
6
+ :mod:`elftools` (pyelftools), an **optional** dependency:
7
+
8
+ pip install -e ".[dwarf]" # or ".[dev]" for CI
9
+
10
+ When the extra is missing, :func:`extract_debug_info` raises
11
+ :class:`~codevanta.core.report.AnalysisError` naming the extra — the same
12
+ graceful-refusal contract as the decompiler providers (#32).
13
+
14
+ Scope (stated honestly):
15
+ * ELF only. pyelftools reads the ELF container; PE/Mach-O are rejected with a
16
+ typed message rather than half-parsed.
17
+ * DWARF v2-5 via pyelftools; ``.debug_info`` DIEs only. No line tables, no
18
+ inline chains, no expression evaluation.
19
+ * PDB (PE) is out of scope here — the LIEF OSS build gates its PDB parser
20
+ behind the Extended distribution, so there is no OSS backend to wrap yet.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from codevanta.core.loader import BinaryInfo
30
+ from codevanta.core.report import AnalysisError
31
+ from codevanta.evidence.confidence import Confidence
32
+ from codevanta.evidence.models import Evidence
33
+
34
+ __all__ = [
35
+ "DEFAULT_LIMIT",
36
+ "MAX_LIMIT",
37
+ "DebugFunction",
38
+ "DebugType",
39
+ "DebugInfoResult",
40
+ "extract_debug_info",
41
+ ]
42
+
43
+ #: Default cap on returned debug functions (mirrors ``functions`` defaults).
44
+ DEFAULT_LIMIT = 500
45
+
46
+ #: Hard ceiling accepted for ``limit`` (bounded-resource rule).
47
+ MAX_LIMIT = 100_000
48
+
49
+ #: DIE tags mapped to a short kind label for type entries.
50
+ _TYPE_KINDS = {
51
+ "DW_TAG_structure_type": "struct",
52
+ "DW_TAG_union_type": "union",
53
+ "DW_TAG_enumeration_type": "enum",
54
+ "DW_TAG_class_type": "class",
55
+ }
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class DebugFunction:
60
+ """One DWARF subprogram: producer-grade name and byte-exact extent."""
61
+
62
+ name: str
63
+ address: int
64
+ size: int
65
+ linkage_name: str | None
66
+ is_external: bool
67
+ evidence: Evidence
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ data: dict[str, Any] = {
71
+ "name": self.name,
72
+ "address": f"0x{self.address:x}",
73
+ "size": self.size,
74
+ "is_external": self.is_external,
75
+ "evidence": self.evidence.to_dict(),
76
+ }
77
+ if self.linkage_name is not None:
78
+ data["linkage_name"] = self.linkage_name
79
+ return data
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class DebugType:
84
+ """One named data-type layout from the producer (struct/union/enum/class)."""
85
+
86
+ name: str
87
+ kind: str
88
+ byte_size: int
89
+ evidence: Evidence
90
+
91
+ def to_dict(self) -> dict[str, Any]:
92
+ return {
93
+ "name": self.name,
94
+ "kind": self.kind,
95
+ "byte_size": self.byte_size,
96
+ "evidence": self.evidence.to_dict(),
97
+ }
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class DebugInfoResult:
102
+ """Everything ``.debug_info`` yielded, with counts and truncation flags."""
103
+
104
+ functions: list[DebugFunction]
105
+ types: list[DebugType]
106
+ truncated_functions: bool
107
+ truncated_types: bool
108
+ evidence: Evidence
109
+ warnings: tuple[str, ...] = ()
110
+
111
+ def to_dict(self) -> dict[str, Any]:
112
+ return {
113
+ "function_count": len(self.functions),
114
+ "type_count": len(self.types),
115
+ "truncated_functions": self.truncated_functions,
116
+ "truncated_types": self.truncated_types,
117
+ "functions": [f.to_dict() for f in self.functions],
118
+ "types": [t.to_dict() for t in self.types],
119
+ "warnings": list(self.warnings),
120
+ "evidence": self.evidence.to_dict(),
121
+ }
122
+
123
+
124
+ def _require_elf(info: BinaryInfo) -> None:
125
+ if info.format != "ELF":
126
+ raise AnalysisError(
127
+ f"debug-info extraction supports ELF only, got {info.format!r}; "
128
+ "PE PDB parsing needs a backend that is not available yet"
129
+ )
130
+
131
+
132
+ def _load_elftools(path: Path):
133
+ """Import pyelftools lazily and open ``path``; refuse with a typed error."""
134
+ try:
135
+ from elftools.elf.elffile import ELFFile
136
+ except ImportError as exc:
137
+ raise AnalysisError(
138
+ "pyelftools is not installed: run pip install -e \".[dwarf]\" "
139
+ "(or .[dev]) to enable debug-info extraction"
140
+ ) from exc
141
+ handle = path.open("rb")
142
+ try:
143
+ return ELFFile(handle), handle
144
+ except Exception:
145
+ handle.close()
146
+ raise
147
+
148
+
149
+ def extract_debug_info(
150
+ path: str | Path,
151
+ info: BinaryInfo,
152
+ *,
153
+ limit: int = DEFAULT_LIMIT,
154
+ ) -> DebugInfoResult:
155
+ """Extract named functions and data-type layouts from ELF DWARF.
156
+
157
+ Args:
158
+ path: File the metadata in ``info`` was loaded from (read-only).
159
+ info: Normalized binary metadata; must be an ELF.
160
+ limit: Cap on functions and types returned each (1..100000).
161
+
162
+ Returns:
163
+ A :class:`DebugInfoResult`; empty lists with warnings when the binary
164
+ carries no ``.debug_info``.
165
+
166
+ Raises:
167
+ AnalysisError: If the binary is not an ELF, ``limit`` is out of range,
168
+ or pyelftools is not installed.
169
+ """
170
+ if limit < 1:
171
+ raise AnalysisError("limit must be >= 1")
172
+ if limit > MAX_LIMIT:
173
+ raise AnalysisError(f"limit too large (limit {MAX_LIMIT})")
174
+ _require_elf(info)
175
+
176
+ elf_file, handle = _load_elftools(Path(path))
177
+ try:
178
+ if not elf_file.has_dwarf_info():
179
+ return DebugInfoResult(
180
+ functions=[],
181
+ types=[],
182
+ truncated_functions=False,
183
+ truncated_types=False,
184
+ evidence=Evidence(
185
+ source="dwarf.debug_info",
186
+ method="elf_scan",
187
+ confidence=Confidence.HIGH,
188
+ detail="binary carries no DWARF debug info",
189
+ ),
190
+ warnings=("no .debug_info section in this ELF",),
191
+ )
192
+
193
+ dwarf_info = elf_file.get_dwarf_info()
194
+ functions: list[DebugFunction] = []
195
+ types: list[DebugType] = []
196
+ warnings: list[str] = []
197
+ truncated_functions = False
198
+ truncated_types = False
199
+
200
+ for cu in dwarf_info.iter_CUs():
201
+ for die in cu.iter_DIEs():
202
+ if die.tag == "DW_TAG_subprogram":
203
+ if truncated_functions:
204
+ continue
205
+ low_attr = die.attributes.get("DW_AT_low_pc")
206
+ decl_attr = die.attributes.get("DW_AT_declaration")
207
+ if low_attr is None or decl_attr is not None:
208
+ continue # prototype or outlined without a body here
209
+ name_attr = die.attributes.get("DW_AT_name")
210
+ name = (
211
+ str(name_attr.value, "utf-8", "replace")
212
+ if name_attr is not None
213
+ else ""
214
+ )
215
+ if not name:
216
+ continue
217
+ high_attr = die.attributes.get("DW_AT_high_pc")
218
+ if high_attr is None:
219
+ size = 0
220
+ elif high_attr.form == "DW_FORM_addr":
221
+ size = max(0, int(high_attr.value) - int(low_attr.value))
222
+ else:
223
+ # data forms store an offset from low_pc
224
+ size = int(high_attr.value)
225
+ linkage = die.attributes.get("DW_AT_MIPS_linkage_name") or die.attributes.get(
226
+ "DW_AT_linkage_name"
227
+ )
228
+ functions.append(
229
+ DebugFunction(
230
+ name=name,
231
+ address=int(low_attr.value),
232
+ size=size,
233
+ linkage_name=(
234
+ str(linkage.value, "utf-8", "replace")
235
+ if linkage is not None
236
+ else None
237
+ ),
238
+ is_external=bool(
239
+ die.attributes.get("DW_AT_external") is not None
240
+ ),
241
+ evidence=Evidence(
242
+ source="dwarf.debug_info",
243
+ method="DIE_subprogram",
244
+ confidence=Confidence.HIGH,
245
+ address=int(low_attr.value),
246
+ detail=f"{name}: DWARF subprogram, {size} bytes",
247
+ ),
248
+ )
249
+ )
250
+ if len(functions) >= limit:
251
+ truncated_functions = True
252
+
253
+ elif die.tag in _TYPE_KINDS:
254
+ if truncated_types:
255
+ continue
256
+ name_attr = die.attributes.get("DW_AT_name")
257
+ size_attr = die.attributes.get("DW_AT_byte_size")
258
+ if name_attr is None or size_attr is None:
259
+ continue # anonymous or declaration-only layout
260
+ types.append(
261
+ DebugType(
262
+ name=str(name_attr.value, "utf-8", "replace"),
263
+ kind=_TYPE_KINDS[die.tag],
264
+ byte_size=int(size_attr.value),
265
+ evidence=Evidence(
266
+ source="dwarf.debug_info",
267
+ method="DIE_structure_type"
268
+ if die.tag == "DW_TAG_structure_type"
269
+ else "DIE_type",
270
+ confidence=Confidence.HIGH,
271
+ detail=(
272
+ f"{str(name_attr.value, 'utf-8', 'replace')}: "
273
+ f"{_TYPE_KINDS[die.tag]}, {int(size_attr.value)} bytes"
274
+ ),
275
+ ),
276
+ )
277
+ )
278
+ if len(types) >= limit:
279
+ truncated_types = True
280
+ finally:
281
+ handle.close()
282
+
283
+ warnings = []
284
+ if truncated_functions:
285
+ warnings.append(f"function limit {limit} reached; more subprograms exist")
286
+ if truncated_types:
287
+ warnings.append(f"type limit {limit} reached; more named types exist")
288
+
289
+ return DebugInfoResult(
290
+ functions=functions,
291
+ types=types,
292
+ truncated_functions=truncated_functions,
293
+ truncated_types=truncated_types,
294
+ evidence=Evidence(
295
+ source="dwarf.debug_info",
296
+ method="debug_info_scan",
297
+ confidence=Confidence.HIGH,
298
+ detail=(
299
+ f"{len(functions)} DWARF function(s), {len(types)} named type(s); "
300
+ "producer metadata, highest-grade symbol evidence"
301
+ ),
302
+ ),
303
+ warnings=tuple(warnings),
304
+ )