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,1201 @@
1
+ """Control-flow graph construction by linear sweep (phase 2).
2
+
3
+ This module is a *phase 2* analysis step. It decodes one contiguous region of
4
+ an executable section with :class:`~codevanta.core.disassembler.Disassembler`
5
+ and derives basic blocks from purely local rules:
6
+
7
+ 1. **Linear sweep.** ``max_instructions`` instruction slots are read from the
8
+ file (bounded by the section's file-backed bytes) and decoded sequentially
9
+ from ``start``. The sweep stops at ``max_instructions`` or at the end of the
10
+ section; ``FunctionCFG.truncated`` says whether the sweep stopped early.
11
+ 2. **Leaders.** ``start``; the instruction that follows any terminator; and the
12
+ direct target of a branch when that target is itself a decoded instruction
13
+ address.
14
+ 3. **Blocks.** Consecutive instructions between two leaders form one
15
+ :class:`BasicBlock`; the block ``end`` is the address just past its last
16
+ instruction (exclusive).
17
+ 4. **Successors.** ``ret``-like instructions and unconditional jumps have no
18
+ fallthrough; conditional jumps and ordinary instructions fall through to the
19
+ next instruction; unconditional and conditional jumps additionally gain
20
+ their direct branch target. Successors are deduplicated, sorted, and always
21
+ reference a decoded block start.
22
+
23
+ The sweep is deliberately conservative: block boundaries, terminators, and
24
+ successors are heuristic (mnemonic based), never proof of the true control
25
+ flow. Every block and the function carry :class:`Evidence` stating exactly how
26
+ they were produced, and the target binary is only ever read, never executed.
27
+ Indirect branches (``jmp rax``, ``jmp qword ptr [...]``, jump tables) are not
28
+ resolved in phase 2, so their blocks end with an unresolved terminator.
29
+
30
+ :func:`build_recursive_cfg` offers the same block model from a different
31
+ traversal: instead of sweeping a contiguous window it *follows* control flow
32
+ from ``start`` with a worklist of block starts and a visited set, so
33
+ unreachable bytes (padding, alignment tables, data embedded in ``.text``) are
34
+ never reported as code. ``build_cfg`` stays the flat-window baseline.
35
+
36
+ A traversal can decode one block *past* a leader it only discovers later (a
37
+ backward ``jmp`` into the middle of an already decoded run). A post-traversal
38
+ split pass therefore cuts every block at the leaders inside it, so the reported
39
+ ranges are disjoint and every instruction address is reported exactly once.
40
+
41
+ :func:`build_recursive_cfg` can *optionally* resolve indirect jumps through
42
+ pointer tables (``jmp qword ptr [rip + disp]``, ``jmp qword ptr [abs]``) with
43
+ :func:`codevanta.analysis.jump_tables.resolve_jump_table`. The behaviour is
44
+ opt-in (``resolve_indirect=False`` by default, so every pre-existing call and
45
+ its results are unchanged): when enabled, the memory operand is turned into an
46
+ absolute table address, the table is decoded, and the executable targets become
47
+ successors -- deduplicated, sorted and followed by the traversal like any other
48
+ leader. Resolution is bounded per traversal (``max_jump_tables`` tables,
49
+ ``max_table_entries`` slots each). Register-indirect jumps (``jmp rax``,
50
+ ``jmp qword ptr [rax]``), unresolvable expressions and tables beyond the budget
51
+ leave the block without those successors and are reported once through
52
+ ``FunctionCFG.warnings``. Nothing is executed; a table is only read through the
53
+ containing section's file bytes.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ from collections import deque
59
+ from dataclasses import dataclass
60
+ from pathlib import Path
61
+ from typing import Any
62
+
63
+ from codevanta.analysis.jump_tables import MAX_ENTRIES as _JUMP_TABLE_MAX_ENTRIES
64
+ from codevanta.analysis.jump_tables import resolve_jump_table
65
+ from codevanta.core.disassembler import Disassembler, DisassemblyError
66
+ from codevanta.core.functions import discover_functions
67
+ from codevanta.core.loader import BinaryInfo
68
+ from codevanta.core.report import MAX_DISASSEMBLY_COUNT, AnalysisError, read_code
69
+ from codevanta.evidence.confidence import Confidence
70
+ from codevanta.evidence.models import Evidence
71
+ from codevanta.ir.instruction import IRInstruction
72
+
73
+ __all__ = ["BasicBlock", "FunctionCFG", "build_cfg", "build_recursive_cfg"]
74
+
75
+ #: Widest instruction any supported architecture can decode (x86 allows 15).
76
+ _MAX_INSTRUCTION_BYTES = 16
77
+
78
+ #: Decode window for a single recursively traversed block. A block normally
79
+ #: ends at a terminator long before this, so the cap only bounds pathological
80
+ #: runs of unterminated instructions (such a run is simply split into more
81
+ #: blocks at the fallthrough, which yields the same successors).
82
+ _MAX_BLOCK_INSTRUCTIONS = 256
83
+
84
+ #: Mnemonics that unconditionally end a block and never fall through.
85
+ _RET_MNEMONICS = frozenset(
86
+ {"ret", "retn", "retf", "retq", "iret", "iretd", "iretq", "eret"}
87
+ )
88
+
89
+ #: Unconditional jump mnemonics per supported architecture (x86 / ARM / AArch64).
90
+ _UNCONDITIONAL_JUMPS = frozenset({"jmp", "ljmp", "b", "br", "bx"})
91
+
92
+ #: Conditional branch mnemonics that are not covered by a ``j``/``b.`` prefix.
93
+ _CONDITIONAL_JUMP_MNEMONICS = frozenset(
94
+ {
95
+ # AArch64 compare-and-branch / test-and-branch
96
+ "cbz",
97
+ "cbnz",
98
+ "tbz",
99
+ "tbnz",
100
+ # ARM (A32/T32) conditional branches, written without the "b." infix
101
+ "beq",
102
+ "bne",
103
+ "bhs",
104
+ "blo",
105
+ "bcs",
106
+ "bcc",
107
+ "bmi",
108
+ "bpl",
109
+ "bvs",
110
+ "bvc",
111
+ "bhi",
112
+ "bls",
113
+ "bge",
114
+ "blt",
115
+ "bgt",
116
+ "ble",
117
+ "bal",
118
+ }
119
+ )
120
+
121
+
122
+ #: Default cap on jump tables resolved during one recursive traversal.
123
+ _DEFAULT_MAX_JUMP_TABLES = 8
124
+
125
+ #: Default cap on pointer slots decoded from a single jump table.
126
+ _DEFAULT_MAX_TABLE_ENTRIES = 256
127
+
128
+ #: ``ptr`` size keyword that denotes a pointer-sized memory read, by width.
129
+ _POINTER_SIZE_KEYWORDS = {4: "dword", 8: "qword"}
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class BasicBlock:
134
+ """A single-entry run of instructions with its outgoing edges.
135
+
136
+ Attributes:
137
+ start: Virtual address of the first instruction in the block.
138
+ end: Virtual address just past the last instruction (exclusive).
139
+ instructions: Decoded instructions as ``IRInstruction.to_dict()`` data.
140
+ successors: Sorted, deduplicated successor block addresses.
141
+ terminator: Mnemonic of the block's final instruction when it ends the
142
+ block unconditionally or conditionally, else ``None``.
143
+ evidence: How this block was derived.
144
+ """
145
+
146
+ start: int
147
+ end: int
148
+ instructions: list[dict[str, Any]]
149
+ successors: tuple[int, ...]
150
+ terminator: str | None
151
+ evidence: Evidence
152
+
153
+ @property
154
+ def instruction_count(self) -> int:
155
+ """Number of instructions in the block."""
156
+ return len(self.instructions)
157
+
158
+ def to_dict(self) -> dict[str, Any]:
159
+ """Serialize the block for the JSON report."""
160
+ return {
161
+ "start": f"0x{self.start:x}",
162
+ "end": f"0x{self.end:x}",
163
+ "instruction_count": self.instruction_count,
164
+ "terminator": self.terminator,
165
+ "successors": [f"0x{address:x}" for address in self.successors],
166
+ "instructions": list(self.instructions),
167
+ "evidence": self.evidence.to_dict(),
168
+ }
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class FunctionCFG:
173
+ """Control-flow graph for one function region.
174
+
175
+ Attributes:
176
+ function_address: Requested start address (also the entry block start).
177
+ function_name: Name from function discovery when a candidate starts at
178
+ ``function_address``, else ``None``.
179
+ blocks: Blocks in ascending address order with disjoint ranges.
180
+ instruction_count: Total number of decoded instructions; for
181
+ :func:`build_recursive_cfg` this counts *unique* instruction
182
+ addresses across the disjoint blocks.
183
+ truncated: ``True`` when instruction decoding or block reporting was
184
+ cut short by a limit, so the graph may be incomplete.
185
+ evidence: How the graph as a whole was derived.
186
+ warnings: Human-readable caveats about the sweep.
187
+ """
188
+
189
+ function_address: int
190
+ function_name: str | None
191
+ blocks: list[BasicBlock]
192
+ instruction_count: int
193
+ truncated: bool
194
+ evidence: Evidence
195
+ warnings: tuple[str, ...] = ()
196
+
197
+ def to_dict(self) -> dict[str, Any]:
198
+ """Serialize the CFG for the JSON report."""
199
+ return {
200
+ "function_address": f"0x{self.function_address:x}",
201
+ "function_name": self.function_name,
202
+ "block_count": len(self.blocks),
203
+ "instruction_count": self.instruction_count,
204
+ "truncated": self.truncated,
205
+ "warnings": list(self.warnings),
206
+ "blocks": [block.to_dict() for block in self.blocks],
207
+ "evidence": self.evidence.to_dict(),
208
+ }
209
+
210
+
211
+ def build_cfg(
212
+ path: str | Path,
213
+ info: BinaryInfo,
214
+ start: int,
215
+ max_instructions: int = 2000,
216
+ max_blocks: int = 500,
217
+ ) -> FunctionCFG:
218
+ """Build a CFG for the region starting at ``start`` by linear sweep.
219
+
220
+ Args:
221
+ path: File the bytes are read from (read-only; never executed).
222
+ info: Loaded binary metadata used for address mapping and decoding.
223
+ start: Virtual address of the first instruction; must be inside an
224
+ executable section.
225
+ max_instructions: Instruction budget for the sweep (1 ..
226
+ ``MAX_DISASSEMBLY_COUNT``).
227
+ max_blocks: Maximum number of blocks to report (>= 1). When the sweep
228
+ yields more blocks the graph is cut and marked ``truncated``.
229
+
230
+ Returns:
231
+ The reconstructed :class:`FunctionCFG`.
232
+
233
+ Raises:
234
+ AnalysisError: When ``start`` is not executable code, when a limit is
235
+ out of range, when the start address has no file bytes, or when the
236
+ architecture cannot be disassembled.
237
+ """
238
+ if max_instructions < 1:
239
+ raise AnalysisError("max_instructions must be >= 1")
240
+ if max_instructions > MAX_DISASSEMBLY_COUNT:
241
+ raise AnalysisError(f"max_instructions too large (limit {MAX_DISASSEMBLY_COUNT})")
242
+ if max_blocks < 1:
243
+ raise AnalysisError("max_blocks must be >= 1")
244
+ if max_blocks > MAX_DISASSEMBLY_COUNT:
245
+ raise AnalysisError(f"max_blocks too large (limit {MAX_DISASSEMBLY_COUNT})")
246
+
247
+ section = info.section_for_va(start)
248
+ if section is None or not section.is_executable:
249
+ raise AnalysisError(f"address 0x{start:x} is not inside an executable section")
250
+
251
+ code = read_code(path, info, start, max_instructions * _MAX_INSTRUCTION_BYTES)
252
+ try:
253
+ disassembler = Disassembler(info.architecture, info.bitness, info.endianness)
254
+ result = disassembler.disassemble(
255
+ code,
256
+ base_address=start,
257
+ max_instructions=max_instructions,
258
+ )
259
+ except DisassemblyError as exc:
260
+ raise AnalysisError(f"cannot disassemble 0x{start:x}: {exc}") from exc
261
+
262
+ instructions = list(result.instructions)
263
+ warnings: list[str] = []
264
+ truncated = bool(result.truncated)
265
+ if truncated:
266
+ warnings.append(
267
+ f"linear sweep stopped after {len(instructions)} instruction(s) "
268
+ f"(max_instructions={max_instructions}); the graph may be incomplete"
269
+ )
270
+ elif instructions and result.consumed_bytes < len(code):
271
+ warnings.append(
272
+ f"linear sweep stopped at 0x{start + result.consumed_bytes:x} before the end "
273
+ "of the section (undecodable bytes)"
274
+ )
275
+
276
+ blocks, blocks_truncated, block_warnings = _split_blocks(instructions, max_blocks)
277
+ truncated = truncated or blocks_truncated
278
+ warnings.extend(block_warnings)
279
+ if not instructions:
280
+ warnings.append(f"no instructions decoded at 0x{start:x}")
281
+
282
+ evidence = Evidence(
283
+ source="cfg.linear_sweep",
284
+ method="linear_sweep",
285
+ confidence=Confidence.MEDIUM,
286
+ address=start,
287
+ detail=(
288
+ f"linear sweep decoded {len(instructions)} instruction(s) into {len(blocks)} "
289
+ "block(s); block boundaries are heuristic (leader/terminator rules, phase 2)"
290
+ ),
291
+ )
292
+ return FunctionCFG(
293
+ function_address=start,
294
+ function_name=_function_name_for(info, start),
295
+ blocks=blocks,
296
+ instruction_count=len(instructions),
297
+ truncated=truncated,
298
+ evidence=evidence,
299
+ warnings=tuple(warnings),
300
+ )
301
+
302
+
303
+ def build_recursive_cfg(
304
+ path: str | Path,
305
+ info: BinaryInfo,
306
+ start: int,
307
+ max_instructions: int = 2000,
308
+ max_blocks: int = 500,
309
+ resolve_indirect: bool = False,
310
+ max_jump_tables: int = _DEFAULT_MAX_JUMP_TABLES,
311
+ max_table_entries: int = _DEFAULT_MAX_TABLE_ENTRIES,
312
+ ) -> FunctionCFG:
313
+ """Build a CFG by *following* control flow from ``start`` (recursive traversal).
314
+
315
+ Unlike :func:`build_cfg` this never sweeps a contiguous window: a worklist
316
+ of block starts plus a visited set drive the traversal, so only code
317
+ reachable from ``start`` becomes a block.
318
+
319
+ Traversal rules:
320
+
321
+ 1. **Worklist.** ``start`` is the first leader. Each block is decoded from
322
+ its leader until a terminator mnemonic, the instruction budget, an
323
+ already-visited leader (the block ends just before it), or the end of an
324
+ executable section.
325
+ 2. **Successors.** A conditional jump leads to its fallthrough *and* its
326
+ direct target, an unconditional jump to its direct target only (no
327
+ fallthrough), a return to nothing, and any other instruction to its
328
+ fallthrough. Only targets inside an executable, file-backed section are
329
+ kept.
330
+ 3. **Calls stay flat.** ``call`` is an ordinary instruction: the return
331
+ address is the fallthrough and the callee is *not* followed.
332
+ 4. **Bounded.** ``max_instructions`` caps decoded instructions and
333
+ ``max_blocks`` caps reported blocks; the visited set plus the worklist
334
+ make cycles terminate. Hitting either limit marks the graph
335
+ ``truncated``.
336
+ 5. **Disjoint blocks.** Traversal hypotheses can overlap: a block decoded
337
+ before a backward ``jmp`` is discovered runs past that target. A post-
338
+ traversal split pass cuts every block at the leaders inside it (a split
339
+ leading segment falls through to the split address and has no
340
+ terminator, the trailing segment keeps the original terminator and
341
+ successors), then keeps the most complete block per start address. The
342
+ reported ranges therefore never overlap and ``instruction_count`` counts
343
+ unique instruction addresses.
344
+ 6. **Indirect jumps (opt-in).** With ``resolve_indirect=True`` an indirect
345
+ ``jmp`` through a pointer-sized memory operand (``jmp qword ptr [rip +
346
+ disp]`` or ``jmp qword ptr [abs]``) becomes a jump-table lookup: the
347
+ operand is turned into an absolute table address and
348
+ :func:`~codevanta.analysis.jump_tables.resolve_jump_table` decodes up to
349
+ ``max_table_entries`` slots from it. Executable, file-backed targets
350
+ become that block's successors (deduplicated, sorted) and are followed
351
+ like any other leader. At most ``max_jump_tables`` tables are resolved
352
+ per traversal; register/indexed operands, unreadable tables and tables
353
+ past the budget leave the block without those successors, and every such
354
+ limitation is reported once (prefixed with the block start) in
355
+ :attr:`FunctionCFG.warnings`. Nothing is executed.
356
+
357
+ Args:
358
+ path: File the bytes are read from (read-only; never executed).
359
+ info: Loaded binary metadata used for address mapping and decoding.
360
+ start: Virtual address of the first instruction; must be inside an
361
+ executable section.
362
+ max_instructions: Total instruction budget (1 ..
363
+ ``MAX_DISASSEMBLY_COUNT``).
364
+ max_blocks: Maximum number of blocks to report (1 ..
365
+ ``MAX_DISASSEMBLY_COUNT``).
366
+ resolve_indirect: When true, indirect jumps through a pointer table are
367
+ resolved (see rule 6). ``False`` (the default) keeps the historical
368
+ behaviour exactly: an indirect jump simply has no successor and is
369
+ counted as unresolved.
370
+ max_jump_tables: Maximum number of jump tables resolved during this
371
+ traversal (1 .. ``MAX_DISASSEMBLY_COUNT``); ignored unless
372
+ ``resolve_indirect`` is true.
373
+ max_table_entries: Maximum pointer slots decoded per table (1 ..
374
+ ``jump_tables.MAX_ENTRIES``); ignored unless ``resolve_indirect`` is
375
+ true.
376
+
377
+ Returns:
378
+ The reconstructed :class:`FunctionCFG` with blocks in ascending
379
+ address order.
380
+
381
+ Raises:
382
+ AnalysisError: When ``start`` is not executable code, when a limit is
383
+ out of range (including the jump-table limits), or when the
384
+ architecture cannot be disassembled.
385
+ """
386
+ _validate_limits(max_instructions, max_blocks)
387
+ _validate_indirect_limits(max_jump_tables, max_table_entries)
388
+
389
+ section = info.section_for_va(start)
390
+ if section is None or not section.is_executable:
391
+ raise AnalysisError(f"address 0x{start:x} is not inside an executable section")
392
+
393
+ try:
394
+ disassembler = Disassembler(info.architecture, info.bitness, info.endianness)
395
+ except DisassemblyError as exc:
396
+ raise AnalysisError(f"cannot disassemble 0x{start:x}: {exc}") from exc
397
+
398
+ # Leaders already queued are block starts: this set *is* the visited set.
399
+ leaders: set[int] = {start}
400
+ worklist: deque[int] = deque([start])
401
+ decoded: dict[int, _Segment] = {}
402
+ remaining = max_instructions
403
+ indirect_unresolved = 0
404
+ budget_exhausted = False
405
+ dropped: list[int] = []
406
+ warnings: list[str] = []
407
+ tables: _JumpTableBudget | None = (
408
+ _JumpTableBudget(max_tables=max_jump_tables, max_entries=max_table_entries)
409
+ if resolve_indirect
410
+ else None
411
+ )
412
+
413
+ while worklist and remaining > 0 and len(decoded) < max_blocks:
414
+ leader = worklist.popleft()
415
+ if leader in decoded: # pragma: no cover - a leader is queued once
416
+ continue
417
+ chunk, reason = _decode_block(path, info, disassembler, leader, leaders, remaining)
418
+ if not chunk:
419
+ warnings.append(f"no decodable instruction at block start 0x{leader:x}")
420
+ continue
421
+ if reason == "budget":
422
+ budget_exhausted = True
423
+ remaining -= len(chunk)
424
+ last = chunk[-1]
425
+ table_targets: tuple[int, ...] | None = None
426
+ if (
427
+ tables is not None
428
+ and _terminator_kind(last.mnemonic) == "jmp"
429
+ and _direct_target(last.operands) is None
430
+ ):
431
+ table_targets = _jump_table_successors(
432
+ path, info, tables, leader, last, warnings
433
+ )
434
+ successors, unresolved = _recursive_successors(
435
+ info, last, dropped, table_targets
436
+ )
437
+ indirect_unresolved += int(unresolved)
438
+ decoded[leader] = _Segment(
439
+ instructions=tuple(chunk),
440
+ successors=successors,
441
+ terminator=_terminator_mnemonic(last.mnemonic),
442
+ )
443
+ for successor in successors: # sorted, so queue order stays deterministic
444
+ if successor not in leaders:
445
+ leaders.add(successor)
446
+ worklist.append(successor)
447
+
448
+ # Overlapping traversal hypotheses become disjoint blocks here.
449
+ segments = _split_at_leaders(list(decoded.values()))
450
+ block_limit_from_split = len(segments) > max_blocks
451
+ if block_limit_from_split:
452
+ segments = segments[:max_blocks]
453
+ blocks = [_to_block(segment) for segment in segments]
454
+ # Disjoint ranges mean each instruction address is reported exactly once.
455
+ instruction_count = len(
456
+ {instruction.address for segment in segments for instruction in segment.instructions}
457
+ )
458
+ if worklist and not remaining:
459
+ budget_exhausted = True
460
+ block_limit_reached = bool(worklist) and len(decoded) >= max_blocks
461
+ truncated = budget_exhausted or bool(worklist) or block_limit_from_split
462
+
463
+ if budget_exhausted:
464
+ warnings.append(
465
+ f"instruction limit reached (max_instructions={max_instructions}); "
466
+ "the graph may be incomplete"
467
+ )
468
+ if block_limit_reached:
469
+ warnings.append(
470
+ f"block limit reached (max_blocks={max_blocks}); {len(worklist)} reached "
471
+ "block(s) were not decoded and the graph may be incomplete"
472
+ )
473
+ if block_limit_from_split:
474
+ warnings.append(
475
+ f"block limit reached (max_blocks={max_blocks}); splitting blocks at "
476
+ "interior leaders produced more blocks and later ones were not reported"
477
+ )
478
+ if dropped:
479
+ warnings.append(
480
+ f"edge target(s) {_address_sample(dropped)} are outside any executable "
481
+ "section; those edges are omitted"
482
+ )
483
+ missing = sorted(
484
+ {address for block in blocks for address in block.successors}
485
+ - {block.start for block in blocks}
486
+ )
487
+ if missing and not truncated:
488
+ warnings.append(
489
+ f"edge(s) to {_address_sample(missing)} have no reported block; those edges are kept"
490
+ )
491
+ if indirect_unresolved:
492
+ warnings.append(
493
+ f"{indirect_unresolved} indirect branch target(s) were not resolved "
494
+ "(register/memory operands) and those edges are omitted"
495
+ )
496
+
497
+ # Only mention jump tables when the caller asked for them, so the evidence
498
+ # text of a default traversal stays byte-for-byte what it used to be.
499
+ table_detail = ""
500
+ if tables is not None:
501
+ table_detail = (
502
+ f"; {tables.resolved} jump table(s) resolved from indirect jumps "
503
+ f"({tables.entries} pointer slot(s) decoded, bounded by "
504
+ f"max_jump_tables={max_jump_tables} and max_table_entries={max_table_entries})"
505
+ )
506
+
507
+ evidence = Evidence(
508
+ source="cfg.recursive_traversal",
509
+ method="recursive_traversal",
510
+ confidence=Confidence.MEDIUM,
511
+ address=start,
512
+ detail=(
513
+ f"recursive traversal from 0x{start:x} followed control flow into {len(blocks)} "
514
+ f"block(s) / {instruction_count} unique instruction(s); bounded by "
515
+ f"max_instructions={max_instructions} and max_blocks={max_blocks}; "
516
+ f"{indirect_unresolved} indirect target(s) unresolved and call targets not "
517
+ "followed; blocks split at interior leaders so reported ranges are disjoint"
518
+ f"{table_detail}; boundaries are heuristic"
519
+ ),
520
+ )
521
+ return FunctionCFG(
522
+ function_address=start,
523
+ function_name=_function_name_for(info, start),
524
+ blocks=blocks,
525
+ instruction_count=instruction_count,
526
+ truncated=truncated,
527
+ evidence=evidence,
528
+ warnings=tuple(warnings),
529
+ )
530
+
531
+
532
+ def _validate_limits(max_instructions: int, max_blocks: int) -> None:
533
+ """Reject out-of-range traversal limits."""
534
+ if max_instructions < 1:
535
+ raise AnalysisError("max_instructions must be >= 1")
536
+ if max_instructions > MAX_DISASSEMBLY_COUNT:
537
+ raise AnalysisError(f"max_instructions too large (limit {MAX_DISASSEMBLY_COUNT})")
538
+ if max_blocks < 1:
539
+ raise AnalysisError("max_blocks must be >= 1")
540
+ if max_blocks > MAX_DISASSEMBLY_COUNT:
541
+ raise AnalysisError(f"max_blocks too large (limit {MAX_DISASSEMBLY_COUNT})")
542
+
543
+
544
+ def _validate_indirect_limits(max_jump_tables: int, max_table_entries: int) -> None:
545
+ """Reject out-of-range jump-table limits.
546
+
547
+ Validated unconditionally (even for ``resolve_indirect=False``) so a caller
548
+ can pre-check its bounds without having to branch on the flag.
549
+ """
550
+ if max_jump_tables < 1:
551
+ raise AnalysisError("max_jump_tables must be >= 1")
552
+ if max_jump_tables > MAX_DISASSEMBLY_COUNT:
553
+ raise AnalysisError(
554
+ f"max_jump_tables too large (limit {MAX_DISASSEMBLY_COUNT})"
555
+ )
556
+ if max_table_entries < 1:
557
+ raise AnalysisError("max_table_entries must be >= 1")
558
+ if max_table_entries > _JUMP_TABLE_MAX_ENTRIES:
559
+ raise AnalysisError(
560
+ f"max_table_entries too large (limit {_JUMP_TABLE_MAX_ENTRIES})"
561
+ )
562
+
563
+
564
+ def _decode_block(
565
+ path: str | Path,
566
+ info: BinaryInfo,
567
+ disassembler: Disassembler,
568
+ leader: int,
569
+ known_starts: set[int],
570
+ budget: int,
571
+ ) -> tuple[list[IRInstruction], str]:
572
+ """Decode the single basic block that begins at ``leader``.
573
+
574
+ Args:
575
+ path: File the bytes are read from (read-only).
576
+ info: Loaded binary metadata.
577
+ disassembler: Decoder already bound to the binary's architecture.
578
+ leader: Block start; assumed to be executable and file-backed.
579
+ known_starts: Leaders already queued, i.e. addresses that must begin a
580
+ block. Decoding stops *before* re-entering one of them.
581
+ budget: Instructions still available in the overall budget.
582
+
583
+ Returns:
584
+ ``(instructions, reason)`` where ``reason`` is ``"terminator"``,
585
+ ``"leader"``, ``"budget"``, ``"section"``, ``"window"`` (the per-block
586
+ decode window filled up, so the block continues at the fallthrough) or
587
+ ``"empty"``.
588
+ """
589
+ if budget <= 0:
590
+ return [], "budget"
591
+ window = min(budget, _MAX_BLOCK_INSTRUCTIONS)
592
+ try:
593
+ code = read_code(path, info, leader, window * _MAX_INSTRUCTION_BYTES)
594
+ except AnalysisError:
595
+ return [], "empty"
596
+ try:
597
+ result = disassembler.disassemble(code, base_address=leader, max_instructions=window)
598
+ except DisassemblyError as exc: # pragma: no cover - decoder is already validated
599
+ raise AnalysisError(f"cannot disassemble 0x{leader:x}: {exc}") from exc
600
+
601
+ chunk: list[IRInstruction] = []
602
+ for instruction in result.instructions:
603
+ if len(chunk) >= budget:
604
+ return chunk, "budget"
605
+ if chunk and instruction.address in known_starts:
606
+ return chunk, "leader"
607
+ chunk.append(instruction)
608
+ if _terminator_kind(instruction.mnemonic) is not None:
609
+ return chunk, "terminator"
610
+ if not _is_executable_address(info, instruction.address + instruction.size):
611
+ return chunk, "section"
612
+ return chunk, "window" if chunk else "empty"
613
+
614
+
615
+ def _recursive_successors(
616
+ info: BinaryInfo,
617
+ last: IRInstruction,
618
+ dropped: list[int],
619
+ indirect_targets: tuple[int, ...] | None = None,
620
+ ) -> tuple[tuple[int, ...], bool]:
621
+ """Return ``(successors, indirect_unresolved)`` for a block's final instruction.
622
+
623
+ Conditional jumps yield fallthrough *and* target, unconditional jumps only
624
+ their direct target (never a fallthrough), returns nothing, and ordinary
625
+ instructions (calls included) only the fallthrough. Targets outside an
626
+ executable, file-backed section are discarded and recorded in ``dropped``.
627
+
628
+ Args:
629
+ info: Loaded binary metadata used for the executable/file-backed check.
630
+ last: Final instruction of the block.
631
+ dropped: Collector for edge targets that are not executable code.
632
+ indirect_targets: Already-resolved targets of an indirect ``jmp`` (a jump
633
+ table), or ``None`` when indirect branches are not resolved. ``None``
634
+ keeps the historical behaviour: an indirect ``jmp``/``Jcc`` gets no
635
+ successor and is reported as unresolved. An empty tuple means "the
636
+ jump was resolved and really leads nowhere", which is *not* counted
637
+ as unresolved.
638
+ """
639
+ kind = _terminator_kind(last.mnemonic)
640
+ candidates: list[int] = []
641
+ unresolved = False
642
+
643
+ if kind in (None, "cond"): # ordinary instructions and conditional jumps fall through
644
+ candidates.append(last.address + last.size)
645
+ if kind in ("jmp", "cond"): # direct branch targets only
646
+ target = _direct_target(last.operands)
647
+ if target is not None:
648
+ candidates.append(target)
649
+ elif indirect_targets is None:
650
+ unresolved = True
651
+ else:
652
+ candidates.extend(indirect_targets)
653
+
654
+ kept: set[int] = set()
655
+ for address in candidates:
656
+ if _is_executable_address(info, address):
657
+ kept.add(address)
658
+ else:
659
+ dropped.append(address)
660
+ return tuple(sorted(kept)), unresolved
661
+
662
+
663
+ @dataclass
664
+ class _JumpTableBudget:
665
+ """Book-keeping for jump-table resolution during one traversal.
666
+
667
+ Attributes:
668
+ max_tables: Tables that may still be resolved (``max_jump_tables``).
669
+ max_entries: Slots decoded per table (``max_table_entries``).
670
+ resolved: Tables actually resolved so far.
671
+ entries: Pointer slots decoded across every resolved table.
672
+ limit_warned: ``True`` once the ``max_tables`` warning was emitted, so it
673
+ is reported exactly once per traversal.
674
+ """
675
+
676
+ max_tables: int
677
+ max_entries: int
678
+ resolved: int = 0
679
+ entries: int = 0
680
+ limit_warned: bool = False
681
+
682
+ @property
683
+ def exhausted(self) -> bool:
684
+ """``True`` once no further table may be resolved."""
685
+ return self.resolved >= self.max_tables
686
+
687
+
688
+ def _jump_table_successors(
689
+ path: str | Path,
690
+ info: BinaryInfo,
691
+ budget: _JumpTableBudget,
692
+ block_start: int,
693
+ last: IRInstruction,
694
+ warnings: list[str],
695
+ ) -> tuple[int, ...] | None:
696
+ """Resolve the pointer table behind one indirect ``jmp`` into block starts.
697
+
698
+ The dispatch operand is turned into an absolute table address, the table is
699
+ decoded with :func:`~codevanta.analysis.jump_tables.resolve_jump_table`
700
+ (bounded by ``budget.max_entries``) and the executable, file-backed targets
701
+ are returned deduplicated and sorted. Every table-level problem is appended
702
+ to ``warnings`` prefixed with ``block_start``, so the caveat can be traced
703
+ back to the block that caused it. The table module's own warnings are
704
+ surfaced verbatim, including the conventional zero terminator: "the scan
705
+ ended where a terminator was expected" is still a statement about how much
706
+ of the graph is guesswork, and dropping it would hide the reason a table
707
+ looked short.
708
+
709
+ Args:
710
+ path: Binary to read (read-only; never executed).
711
+ info: Loaded binary metadata.
712
+ budget: Per-traversal jump-table budget, updated in place.
713
+ block_start: Start address of the block, used as the warning prefix.
714
+ last: Final instruction of the block (an indirect ``jmp``).
715
+ warnings: Collector for human-readable caveats.
716
+
717
+ Returns:
718
+ The resolved successor addresses, an empty tuple when the table yielded
719
+ no usable target, or ``None`` when the jump could not be resolved at all
720
+ (register/indexed operand, unreadable table, or spent budget). ``None``
721
+ makes the caller count the block as an unresolved indirect branch.
722
+ """
723
+ table_address = _indirect_table_address(info, last)
724
+ if table_address is None:
725
+ return None
726
+ if budget.exhausted:
727
+ if not budget.limit_warned:
728
+ budget.limit_warned = True
729
+ warnings.append(
730
+ f"block 0x{block_start:x}: jump-table limit reached "
731
+ f"(max_jump_tables={budget.max_tables}); further indirect jumps were "
732
+ "left unresolved"
733
+ )
734
+ return None
735
+
736
+ try:
737
+ result = resolve_jump_table(path, info, table_address, max_entries=budget.max_entries)
738
+ except AnalysisError as exc:
739
+ warnings.append(
740
+ f"block 0x{block_start:x}: jump table at 0x{table_address:x} could not be "
741
+ f"read ({exc}); that edge is omitted"
742
+ )
743
+ return None
744
+
745
+ budget.resolved += 1
746
+ budget.entries += len(result.entries)
747
+ if result.truncated:
748
+ warnings.append(
749
+ f"block 0x{block_start:x}: jump table at 0x{table_address:x} was truncated at "
750
+ f"max_table_entries={budget.max_entries}; some targets are missing"
751
+ )
752
+ for warning in result.warnings:
753
+ warnings.append(
754
+ f"block 0x{block_start:x}: jump table at 0x{table_address:x}: {warning}"
755
+ )
756
+
757
+ targets: list[int] = []
758
+ unusable: list[int] = []
759
+ for entry in result.entries:
760
+ if _is_executable_address(info, entry.target):
761
+ targets.append(entry.target)
762
+ else:
763
+ unusable.append(entry.target)
764
+ if unusable: # bounded: one warning, at most eight addresses named
765
+ warnings.append(
766
+ f"block 0x{block_start:x}: jump-table target(s) {_address_sample(unusable)} "
767
+ "are not executable file bytes; those edges are omitted"
768
+ )
769
+ return tuple(sorted(set(targets)))
770
+
771
+
772
+ @dataclass(frozen=True)
773
+ class _Segment:
774
+ """One decoded instruction run with the edges the traversal gave it.
775
+
776
+ Internal to :func:`build_recursive_cfg`: the traversal records raw runs and
777
+ only turns them into :class:`BasicBlock` objects after the split pass, so the
778
+ split can discard instructions without touching serialized data.
779
+ """
780
+
781
+ instructions: tuple[IRInstruction, ...]
782
+ successors: tuple[int, ...]
783
+ terminator: str | None
784
+ split: bool = False
785
+
786
+ @property
787
+ def start(self) -> int:
788
+ """Virtual address of the first instruction in the run."""
789
+ return self.instructions[0].address
790
+
791
+ @property
792
+ def end(self) -> int:
793
+ """Virtual address just past the last instruction (exclusive)."""
794
+ last = self.instructions[-1]
795
+ return last.address + last.size
796
+
797
+
798
+ def _split_at_leaders(decoded: list[_Segment]) -> list[_Segment]:
799
+ """Turn traversed runs into blocks with disjoint address ranges.
800
+
801
+ Traversal is driven by a worklist, so a run decoded early can reach past a
802
+ leader that is only discovered later (a backward ``jmp`` into an already
803
+ decoded region); two reported blocks would then claim the same instruction
804
+ addresses. This pass removes that overlap:
805
+
806
+ 1. **Leaders.** Every traversed block start is a leader.
807
+ 2. **Split.** Each run is cut at every leader that appears strictly inside
808
+ it. The leading piece of such a cut falls through to the split address
809
+ (its only successor) and reports no terminator; the trailing piece keeps
810
+ the original terminator and successor set.
811
+ 3. **Dedupe.** Pieces sharing a start address collapse to the most complete
812
+ one (most instructions, then farthest end, then most successors), which
813
+ is deterministic because the input is ordered by start first.
814
+ 4. **Order and clip.** The result is sorted by start and clipped so ranges
815
+ never overlap.
816
+
817
+ Args:
818
+ decoded: Runs exactly as the traversal decoded them, in any order.
819
+
820
+ Returns:
821
+ Disjoint segments in ascending address order.
822
+ """
823
+ ordered = sorted(decoded, key=lambda segment: segment.start)
824
+ leaders = {segment.start for segment in ordered}
825
+ candidates = [piece for segment in ordered for piece in _cut_segment(segment, leaders)]
826
+ return _clip_overlaps(_most_complete(candidates))
827
+
828
+
829
+ def _cut_segment(segment: _Segment, leaders: set[int]) -> list[_Segment]:
830
+ """Cut one run at every leader that is strictly inside it."""
831
+ instructions = segment.instructions
832
+ cuts = [
833
+ index
834
+ for index, instruction in enumerate(instructions)
835
+ if index > 0 and instruction.address in leaders
836
+ ]
837
+ if not cuts:
838
+ return [segment]
839
+
840
+ bounds = [0, *cuts, len(instructions)]
841
+ pieces: list[_Segment] = []
842
+ for position in range(len(bounds) - 1):
843
+ piece = instructions[bounds[position] : bounds[position + 1]]
844
+ trailing = position + 2 >= len(bounds)
845
+ pieces.append(
846
+ _Segment(
847
+ instructions=piece,
848
+ # Only the tail keeps the original edges; a cut piece simply
849
+ # falls through to the split address.
850
+ successors=segment.successors
851
+ if trailing
852
+ else (instructions[bounds[position + 1]].address,),
853
+ terminator=segment.terminator if trailing else None,
854
+ split=True,
855
+ )
856
+ )
857
+ return pieces
858
+
859
+
860
+ def _most_complete(segments: list[_Segment]) -> list[_Segment]:
861
+ """Keep the most complete segment per start address, ordered by start."""
862
+ best: dict[int, _Segment] = {}
863
+ for segment in segments: # deterministic input order breaks exact ties
864
+ current = best.get(segment.start)
865
+ if current is None or _completeness(segment) > _completeness(current):
866
+ best[segment.start] = segment
867
+ return sorted(best.values(), key=lambda segment: segment.start)
868
+
869
+
870
+ def _completeness(segment: _Segment) -> tuple[int, int, int]:
871
+ """Ranking key used to choose between segments that share a start address."""
872
+ return (len(segment.instructions), segment.end, len(segment.successors))
873
+
874
+
875
+ def _clip_overlaps(segments: list[_Segment]) -> list[_Segment]:
876
+ """Trim a run whose instructions still reach into the next kept block.
877
+
878
+ The split already guarantees disjointness whenever leaders land on
879
+ instruction boundaries of the containing run. Overlapping decodes of the
880
+ same bytes can disagree about boundaries, so any instruction that would
881
+ cross the next block start is dropped here and the run simply falls through
882
+ to it. A run is never emptied: an undecodable first instruction is kept as
883
+ is and reported through the traversal's warnings.
884
+ """
885
+ clipped: list[_Segment] = []
886
+ for index, segment in enumerate(segments):
887
+ if index + 1 >= len(segments):
888
+ clipped.append(segment)
889
+ continue
890
+ next_start = segments[index + 1].start
891
+ kept = tuple(
892
+ instruction
893
+ for instruction in segment.instructions
894
+ if instruction.address + instruction.size <= next_start
895
+ )
896
+ if not kept or len(kept) == len(segment.instructions):
897
+ clipped.append(segment)
898
+ continue
899
+ clipped.append(
900
+ _Segment(
901
+ instructions=kept,
902
+ successors=(next_start,),
903
+ terminator=None,
904
+ split=True,
905
+ )
906
+ )
907
+ return clipped
908
+
909
+
910
+ def _to_block(segment: _Segment) -> BasicBlock:
911
+ """Serialize one disjoint segment into a :class:`BasicBlock`."""
912
+ detail = f"{len(segment.instructions)} instructions reached by recursive traversal"
913
+ if segment.split:
914
+ detail += " and split at interior leaders"
915
+ return BasicBlock(
916
+ start=segment.start,
917
+ end=segment.end,
918
+ instructions=[instruction.to_dict() for instruction in segment.instructions],
919
+ successors=segment.successors,
920
+ terminator=segment.terminator,
921
+ evidence=Evidence(
922
+ source="cfg.recursive_traversal",
923
+ method="basic_block",
924
+ confidence=Confidence.MEDIUM,
925
+ address=segment.start,
926
+ detail=detail,
927
+ ),
928
+ )
929
+
930
+
931
+ def _split_blocks(
932
+ instructions: list[IRInstruction],
933
+ max_blocks: int,
934
+ ) -> tuple[list[BasicBlock], bool, list[str]]:
935
+ """Split one linear instruction stream into blocks with successors."""
936
+ if not instructions:
937
+ return [], False, []
938
+
939
+ addresses = [instruction.address for instruction in instructions]
940
+ decoded = set(addresses)
941
+
942
+ leaders: set[int] = {addresses[0]}
943
+ for index, instruction in enumerate(instructions):
944
+ if _terminator_kind(instruction.mnemonic) is None:
945
+ continue
946
+ if index + 1 < len(instructions):
947
+ leaders.add(addresses[index + 1])
948
+ target = _direct_target(instruction.operands)
949
+ if target is not None and target in decoded:
950
+ leaders.add(target)
951
+
952
+ start_indexes = [index for index, address in enumerate(addresses) if address in leaders]
953
+
954
+ blocks: list[BasicBlock] = []
955
+ dropped: list[int] = []
956
+ for position, first in enumerate(start_indexes):
957
+ last = (
958
+ start_indexes[position + 1] - 1
959
+ if position + 1 < len(start_indexes)
960
+ else len(instructions) - 1
961
+ )
962
+ chunk = instructions[first : last + 1]
963
+ if not chunk: # pragma: no cover - leaders are instruction indexes
964
+ continue
965
+ blocks.append(
966
+ BasicBlock(
967
+ start=chunk[0].address,
968
+ end=chunk[-1].address + chunk[-1].size,
969
+ instructions=[instruction.to_dict() for instruction in chunk],
970
+ successors=_successors(chunk[-1], leaders, dropped),
971
+ terminator=_terminator_mnemonic(chunk[-1].mnemonic),
972
+ evidence=Evidence(
973
+ source="cfg.linear_sweep",
974
+ method="basic_block",
975
+ confidence=Confidence.MEDIUM,
976
+ address=chunk[0].address,
977
+ detail=f"{len(chunk)} instructions",
978
+ ),
979
+ )
980
+ )
981
+
982
+ warnings: list[str] = []
983
+ if dropped: # bounded: only the first few unresolved edge targets are listed
984
+ unique = list(dict.fromkeys(dropped))
985
+ shown = ", ".join(f"0x{address:x}" for address in unique[:8])
986
+ extra = f" (+{len(unique) - 8} more)" if len(unique) > 8 else ""
987
+ warnings.append(
988
+ f"edge target(s) {shown}{extra} were not decoded; those edges are omitted"
989
+ )
990
+
991
+ truncated = False
992
+ if len(blocks) > max_blocks:
993
+ blocks = blocks[:max_blocks]
994
+ truncated = True
995
+ warnings.append(
996
+ f"block limit reached (max_blocks={max_blocks}); later blocks were not reported"
997
+ )
998
+ return blocks, truncated, warnings
999
+
1000
+
1001
+ def _successors(
1002
+ last: IRInstruction,
1003
+ block_starts: set[int],
1004
+ dropped: list[int],
1005
+ ) -> tuple[int, ...]:
1006
+ """Compute the sorted, deduplicated successor set of a final instruction."""
1007
+ kind = _terminator_kind(last.mnemonic)
1008
+ candidates: list[int] = []
1009
+
1010
+ if kind in (None, "cond"): # ordinary instructions and conditional jumps fall through
1011
+ candidates.append(last.address + last.size)
1012
+ if kind in ("jmp", "cond"): # direct branch targets
1013
+ target = _direct_target(last.operands)
1014
+ if target is not None:
1015
+ candidates.append(target)
1016
+
1017
+ successors: set[int] = set()
1018
+ for address in candidates:
1019
+ if address in block_starts:
1020
+ successors.add(address)
1021
+ else:
1022
+ dropped.append(address)
1023
+ return tuple(sorted(successors))
1024
+
1025
+
1026
+ def _terminator_kind(mnemonic: str) -> str | None:
1027
+ """Classify a mnemonic as ``"ret"``, ``"jmp"``, ``"cond"`` or ``None``."""
1028
+ name = (mnemonic or "").strip().lower()
1029
+ if not name:
1030
+ return None
1031
+ if name in _RET_MNEMONICS or name.startswith("ret"):
1032
+ return "ret"
1033
+ if name in _UNCONDITIONAL_JUMPS:
1034
+ return "jmp"
1035
+ if name in _CONDITIONAL_JUMP_MNEMONICS or name.startswith("b."):
1036
+ return "cond"
1037
+ if name.startswith("j"): # x86 Jcc family: je, jne, jle, jrcxz, ...
1038
+ return "cond"
1039
+ return None
1040
+
1041
+
1042
+ def _terminator_mnemonic(mnemonic: str) -> str | None:
1043
+ """Return the normalized terminator mnemonic, or ``None`` for a fallthrough end."""
1044
+ name = (mnemonic or "").strip().lower()
1045
+ if not name or _terminator_kind(name) is None:
1046
+ return None
1047
+ return name
1048
+
1049
+
1050
+ def _direct_target(operands: str) -> int | None:
1051
+ """Return the immediate branch target encoded in ``operands``, if any.
1052
+
1053
+ Only straight hexadecimal immediates are accepted (``jmp 0x401006``,
1054
+ ``b.eq #0x401006``). Register and memory operands (``jmp rax``,
1055
+ ``jmp qword ptr [rip + 0x20]``) are indirect and therefore unresolved.
1056
+ """
1057
+ text = (operands or "").strip()
1058
+ if not text:
1059
+ return None
1060
+ lowered = text.lower()
1061
+ if any(marker in lowered for marker in ("[", "]", "ptr", ":")):
1062
+ return None
1063
+ if "," in text:
1064
+ return None
1065
+ token = text.lstrip("#").strip().lower()
1066
+ if not token.startswith("0x"):
1067
+ return None
1068
+ try:
1069
+ return int(token, 16)
1070
+ except ValueError:
1071
+ return None
1072
+
1073
+
1074
+ def _pointer_width(info: BinaryInfo) -> int | None:
1075
+ """Return the pointer width in bytes implied by ``info.bitness``."""
1076
+ bitness = int(getattr(info, "bitness", 0) or 0)
1077
+ if bitness == 64:
1078
+ return 8
1079
+ if bitness == 32:
1080
+ return 4
1081
+ return None
1082
+
1083
+
1084
+ def _split_terms(text: str) -> list[tuple[int, str]]:
1085
+ """Split a memory-operand body into ``(sign, term)`` pairs.
1086
+
1087
+ ``"rip + 0xffa"`` becomes ``[(1, "rip "), (1, " 0xffa")]`` and
1088
+ ``"0x401000 - 0x10"`` becomes ``[(1, "0x401000 "), (-1, " 0x10")]``. Only
1089
+ ``+``/``-`` separate terms; everything else stays in the term so the caller
1090
+ can accept or reject it.
1091
+ """
1092
+ terms: list[tuple[int, str]] = []
1093
+ sign = 1
1094
+ current = ""
1095
+ for char in text:
1096
+ if char in "+-":
1097
+ terms.append((sign, current))
1098
+ sign = -1 if char == "-" else 1
1099
+ current = ""
1100
+ continue
1101
+ current += char
1102
+ terms.append((sign, current))
1103
+ return terms
1104
+
1105
+
1106
+ def _hex_operand(term: str) -> int | None:
1107
+ """Return the value of a ``0x``-prefixed hexadecimal term, else ``None``."""
1108
+ token = term.strip()
1109
+ if not token.startswith("0x"):
1110
+ return None
1111
+ try:
1112
+ return int(token, 16)
1113
+ except ValueError:
1114
+ return None
1115
+
1116
+
1117
+ def _indirect_table_address(info: BinaryInfo, last: IRInstruction) -> int | None:
1118
+ """Return the table address an indirect ``jmp`` reads through, if it is one.
1119
+
1120
+ Only a pointer-sized memory operand is accepted: ``jmp qword ptr [rip +
1121
+ disp]`` on a 64-bit binary (``dword ptr [abs]`` on a 32-bit one) or its
1122
+ absolute form ``jmp qword ptr [0x402000]``. A RIP-relative displacement is
1123
+ resolved against the address of the *next* instruction, exactly as the CPU
1124
+ would. Everything else -- ``jmp rax``, an indexed operand such as ``jmp
1125
+ qword ptr [rax*8 + 0x402000]``, a symbol expression, a narrower or wider
1126
+ read -- returns ``None``: the operand may still be a dispatcher, but this
1127
+ function cannot justify a table address for it.
1128
+
1129
+ Args:
1130
+ info: Loaded binary metadata; only ``bitness`` is used, to know which
1131
+ ``ptr`` size keyword denotes a pointer read.
1132
+ last: Instruction whose operands are inspected.
1133
+
1134
+ Returns:
1135
+ The absolute table address, or ``None`` when the operand cannot be
1136
+ resolved to one.
1137
+ """
1138
+ width = _pointer_width(info)
1139
+ if width is None:
1140
+ return None
1141
+ keyword = _POINTER_SIZE_KEYWORDS[width]
1142
+ operands = (last.operands or "").strip().lower()
1143
+ if "[" not in operands or "]" not in operands:
1144
+ return None
1145
+ head, _, rest = operands.partition("[")
1146
+ inside, _, tail = rest.partition("]")
1147
+ if tail.strip(): # trailing syntax we do not model
1148
+ return None
1149
+ if "ptr" not in head:
1150
+ return None
1151
+ if head.split("ptr")[0].strip() != keyword:
1152
+ return None
1153
+
1154
+ base: str | None = None
1155
+ displacement = 0
1156
+ for sign, term in _split_terms(inside):
1157
+ term = term.strip()
1158
+ if not term:
1159
+ continue
1160
+ if term == "rip":
1161
+ if base is not None:
1162
+ return None
1163
+ base = term
1164
+ continue
1165
+ value = _hex_operand(term)
1166
+ if value is None:
1167
+ return None # register, symbol, scaled index or non-hex literal
1168
+ displacement += sign * value
1169
+
1170
+ address = last.address + last.size + displacement if base == "rip" else displacement
1171
+ return address if address > 0 else None
1172
+
1173
+
1174
+ def _is_executable_address(info: BinaryInfo, address: int) -> bool:
1175
+ """Return ``True`` when ``address`` is executable *and* backed by file bytes."""
1176
+ if address < 0:
1177
+ return False
1178
+ section = info.section_for_va(address)
1179
+ if section is None or not section.is_executable:
1180
+ return False
1181
+ return info.va_to_offset(address) is not None
1182
+
1183
+
1184
+ def _address_sample(addresses: list[int]) -> str:
1185
+ """Format at most eight addresses for a warning, noting how many were cut."""
1186
+ unique = list(dict.fromkeys(addresses))
1187
+ shown = ", ".join(f"0x{address:x}" for address in unique[:8])
1188
+ return f"{shown} (+{len(unique) - 8} more)" if len(unique) > 8 else shown
1189
+
1190
+
1191
+ def _function_name_for(info: BinaryInfo, start: int) -> str | None:
1192
+ """Return the discovered function name for ``start``, if there is one."""
1193
+ try:
1194
+ candidates = discover_functions(info)
1195
+ except Exception: # pragma: no cover - discovery must never break CFG building
1196
+ return None
1197
+ for candidate in candidates:
1198
+ if candidate.address == start and candidate.name:
1199
+ return candidate.name
1200
+ return None
1201
+