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,1214 @@
1
+ """Heuristic struct-field recovery from memory operands (phase 2h).
2
+
3
+ :func:`suggest_struct` disassembles a *bounded* window of a function and reads
4
+ the memory operands of every decoded instruction:
5
+
6
+ 1. Only three addressing shapes are understood: ``[reg]``, ``[reg + 0xNN]`` and
7
+ ``[reg - 0xNN]`` (a decimal displacement -- ``[rbp - 8]`` -- is accepted
8
+ exactly like a hexadecimal one). Anything with an index register, a scale or
9
+ a segment prefix (``[rdi + rcx*4]``, ``fs:[rax]``) is skipped rather than
10
+ guessed.
11
+ 2. The field **width** comes from the Intel size keyword on the operand
12
+ (``byte``/``word``/``dword``/``qword`` = 1/2/4/8), falling back to the
13
+ pointer width of the binary when the operand carries no keyword.
14
+ 3. The access **direction** is a write when the memory operand is the *first*
15
+ operand, and a read otherwise. Instructions that only dereference a memory
16
+ operand -- ``cmp``, ``cmpsb``/``cmpsw``/``cmpsd``/``cmpsq``, ``test``,
17
+ ``call``, ``jmp`` and ``push`` -- are always reads, even though the memory
18
+ operand comes first.
19
+ 4. The signedness of an access comes from the **mnemonic**: sign-extending
20
+ operations (``movsx``, ``movsxd``, ``sar``, ``idiv``, ``cqo``, ``cdq``,
21
+ ``cwd``, ``cbw``) mark the operand as signed, zero-extending or unsigned
22
+ operations (``movzx``, ``shr``, ``div``, ``mul``) mark it as unsigned, and
23
+ every other mnemonic (``mov``, ``add``, ``cmp``, ...) carries no hint at all.
24
+ The hint is aggregated per offset like the width: only-signed accesses give
25
+ ``signed=True``, only-unsigned accesses give ``signed=False``, no hint gives
26
+ ``None``, and a contradiction gives ``None`` plus a
27
+ :class:`SignednessConflictWarning` naming the offset.
28
+
29
+ The accesses are then aggregated **per offset**: the dominant width of an offset
30
+ is the one seen most often (ties go to the wider access, which is the safer
31
+ assumption), and the read/write counts are totals for that offset over all
32
+ widths. Fields are returned sorted by ascending offset, so the result is
33
+ deterministic for a given input.
34
+
35
+ Two cheap layout hints are derived from those fields:
36
+
37
+ * ``alignment`` is the greatest common divisor of the **absolute** offsets,
38
+ ignoring offset 0 (which is a multiple of everything). It is ``None`` when
39
+ fewer than two distinct absolute non-zero offsets were seen, because a single
40
+ offset carries no information about a layout. Natural alignment therefore shows up as
41
+ a power-of-two gcd; odd offsets collapse it to 1.
42
+ * ``array_groups`` are **maximal runs** of at least
43
+ :data:`_MIN_ARRAY_ELEMENTS` consecutive fields (in ascending offset order)
44
+ that share one width *and* one stride between neighbours. Each run becomes one
45
+ :class:`ArrayGroup` starting at its first field. A run is never extended past a
46
+ width change or a stride change, and never overlaps the next group.
47
+
48
+ Both hints are computed from the fields that are actually reported, i.e. after
49
+ the ``max_fields`` cap, so a group never refers to a field that was dropped.
50
+
51
+ :func:`suggest_structs` merges several windows -- one per address of the same
52
+ binary -- into a single :class:`StructSuggestion`. Each address is decoded
53
+ exactly once with the same budget, and the per-window fields are folded
54
+ together:
55
+
56
+ * There is one merged field per offset. Its read/write counters are the sums
57
+ over the windows that saw the offset, and its ``first_access`` is the earliest
58
+ access any window recorded.
59
+ * Equal per-window widths are kept as they are. When the windows disagree the
60
+ widest width wins and a :class:`WidthConflictWarning` names the offset and the
61
+ widths: a window that only ever saw a ``dword`` is not evidence *against* the
62
+ ``qword`` another window saw.
63
+ * Signedness is folded with the rule used inside one window -- consistent hints
64
+ survive, no hint stays unknown, and a contradiction becomes ``None`` plus one
65
+ :class:`SignednessConflictWarning`. A contradiction *inside* a window has
66
+ already warned during that window's own aggregation; the merge does not warn
67
+ about it a second time, because it only sees the resolved per-window hints.
68
+ * ``alignment``, ``array_groups`` and ``truncated`` are recomputed from the
69
+ merged fields, so a run that only becomes visible across windows is still
70
+ found.
71
+
72
+ The merged work is bounded too: at most ``max_windows`` addresses are accepted
73
+ (:data:`MAX_MERGED_WINDOWS`), duplicate addresses are rejected instead of being
74
+ merged twice, and every window costs at most one decode of ``max_instructions``
75
+ instructions. The merged field set stays capped at :data:`DEFAULT_MAX_FIELDS`
76
+ distinct offsets, which is lossless: each window is capped there as well and the
77
+ cap always keeps the *lowest* offsets, so an offset a window dropped cannot
78
+ belong to the lowest merged offsets either.
79
+
80
+ Everything here is a *heuristic*: a struct field recovered from operand shapes
81
+ is a hypothesis about how a function treats memory around one base register. It
82
+ is not a verified type, and it says nothing about a field's purpose or lifetime.
83
+ The ``signed`` hint is only a reading of the mnemonics that happened to be in the
84
+ window, so ``None`` there means "no signedness evidence", never "safe", and a
85
+ sign-extending *read* does not prove the field is a signed integer. Every
86
+ evidence record says so and sits at
87
+ :attr:`~codevanta.evidence.confidence.Confidence.MEDIUM`, never higher.
88
+
89
+ Work is bounded three ways: the caller's ``max_instructions`` budget (default
90
+ :data:`DEFAULT_MAX_INSTRUCTIONS`), the ``max_fields`` cap (default
91
+ :data:`DEFAULT_MAX_FIELDS`; extras are dropped and reported through
92
+ :attr:`StructSuggestion.truncated`), and the file-backed bytes of the containing
93
+ section -- at most ``max_instructions * 16`` bytes are read. The analysed file
94
+ is only ever read from disk, never mapped or executed.
95
+
96
+ Known, deliberate limits: ``lea`` and ``nop`` are skipped because their bracket
97
+ operands are not memory accesses; read-modify-write instructions (``add ...,
98
+ [rdi+8]``, ``cmpxchg``) count as writes only; operand syntax from other
99
+ architectures (ARM/MIPS ``[x0, #0x10]``) does not match at all, so a
100
+ non-Intel-shaped binary simply yields no fields instead of wrong ones.
101
+ """
102
+
103
+ from __future__ import annotations
104
+
105
+ import re
106
+ import warnings
107
+ from collections.abc import Collection, Iterable, Sequence
108
+ from dataclasses import dataclass
109
+ from math import gcd
110
+ from pathlib import Path
111
+ from typing import Any
112
+
113
+ from codevanta.core.disassembler import Disassembler, DisassemblyError
114
+ from codevanta.core.loader import BinaryInfo
115
+ from codevanta.core.report import MAX_DISASSEMBLY_COUNT, AnalysisError, read_code
116
+ from codevanta.evidence.confidence import Confidence
117
+ from codevanta.evidence.models import Evidence
118
+ from codevanta.ir.instruction import IRInstruction
119
+
120
+ __all__ = [
121
+ "ArrayGroup",
122
+ "DEFAULT_MAX_FIELDS",
123
+ "DEFAULT_MAX_INSTRUCTIONS",
124
+ "MAX_MAX_FIELDS",
125
+ "MAX_MAX_INSTRUCTIONS",
126
+ "MAX_MERGED_WINDOWS",
127
+ "MIN_MAX_FIELDS",
128
+ "MIN_MAX_INSTRUCTIONS",
129
+ "MIN_MERGED_WINDOWS",
130
+ "READ",
131
+ "SignednessConflictWarning",
132
+ "STACK_REGISTERS",
133
+ "WRITE",
134
+ "FieldAccess",
135
+ "StructSuggestion",
136
+ "SuggestedField",
137
+ "WidthConflictWarning",
138
+ "WindowOverlapWarning",
139
+ "parse_memory_operands",
140
+ "suggest_struct",
141
+ "suggest_structs",
142
+ ]
143
+
144
+ #: Default instruction budget for one suggestion.
145
+ DEFAULT_MAX_INSTRUCTIONS = 2_000
146
+
147
+ #: Smallest accepted ``max_instructions``; smaller requests are rejected.
148
+ MIN_MAX_INSTRUCTIONS = 1
149
+
150
+ #: Largest accepted ``max_instructions``; larger requests are rejected.
151
+ MAX_MAX_INSTRUCTIONS = MAX_DISASSEMBLY_COUNT
152
+
153
+ #: Default ceiling on distinct field offsets kept in one suggestion.
154
+ DEFAULT_MAX_FIELDS = 64
155
+
156
+ #: Smallest accepted ``max_fields``; smaller requests are rejected.
157
+ MIN_MAX_FIELDS = 1
158
+
159
+ #: Largest accepted ``max_fields``; larger requests are rejected.
160
+ MAX_MAX_FIELDS = 512
161
+
162
+ #: Default *and* largest number of windows :func:`suggest_structs` merges. Each
163
+ #: window costs one bounded decode, so this is the knob that keeps a merge cheap.
164
+ MAX_MERGED_WINDOWS = 16
165
+
166
+ #: Smallest accepted ``max_windows``; smaller requests are rejected.
167
+ MIN_MERGED_WINDOWS = 1
168
+
169
+ #: Direction label for a memory operand that is only read.
170
+ READ = "read"
171
+
172
+ #: Direction label for a memory operand that is written (possibly read-modify-write).
173
+ WRITE = "write"
174
+
175
+ #: Longest possible encoding of one instruction on a supported architecture.
176
+ #: Used to size the read window for the instruction budget.
177
+ _MAX_INSTRUCTION_BYTES = 16
178
+
179
+ #: Intel size keyword -> width in bytes.
180
+ _SIZE_KEYWORD_WIDTHS: dict[str, int] = {
181
+ "byte": 1,
182
+ "word": 2,
183
+ "dword": 4,
184
+ "qword": 8,
185
+ }
186
+
187
+ #: Pointer width in bytes, keyed by bitness. Unknown bitness falls back to 8.
188
+ _POINTER_WIDTH_BY_BITNESS: dict[int, int] = {16: 2, 32: 4, 64: 8}
189
+
190
+ #: Registers treated as frame pointers. Operands based on them describe a stack
191
+ #: slot or a spilled local, not a struct field, so they are skipped unless the
192
+ #: caller passes ``include_stack=True``.
193
+ STACK_REGISTERS = frozenset({"sp", "esp", "rsp", "bp", "ebp", "rbp"})
194
+
195
+ #: Instruction-pointer registers. ``[rip + disp]`` addresses a global, not a
196
+ #: field of the pointer argument, so these bases are always skipped.
197
+ INSTRUCTION_POINTER_REGISTERS = frozenset({"ip", "eip", "rip"})
198
+
199
+ #: Mnemonics whose memory operands are only compared, never assigned.
200
+ _COMPARE_MNEMONICS = frozenset({"cmp", "cmps", "cmpsb", "cmpsw", "cmpsd", "cmpsq", "test"})
201
+
202
+ #: Mnemonics that carry a bracket expression without touching memory.
203
+ _NON_ACCESSING_MNEMONICS = frozenset({"lea", "nop"})
204
+
205
+ #: Mnemonics whose result is sign-extended from the memory operand, or which do
206
+ #: signed arithmetic on it. Any access they make is treated as ``signed=True``.
207
+ #:
208
+ #: The implicit-operand sign-extension mnemonics (``cbw``, ``cwd``, ``cdq``,
209
+ #: ``cqo``) are absent on purpose: they never carry a memory operand, so they can
210
+ #: neither label a field nor appear in an access record.
211
+ _SIGNED_MNEMONICS = frozenset({"movsx", "movsxd", "sar", "idiv", "imul"})
212
+
213
+ #: Mnemonics whose result is zero-extended from the memory operand, or which do
214
+ #: unsigned arithmetic on it. Any access they make is treated as ``signed=False``.
215
+ _UNSIGNED_MNEMONICS = frozenset({"movzx", "shr", "div", "mul"})
216
+
217
+ #: A memory operand with an optional size keyword, a single base register and an
218
+ #: optional signed displacement: ``qword ptr [rdi + 0x18]``, ``[rax]``,
219
+ #: ``dword ptr [rbp - 8]``.
220
+ #:
221
+ #: An index register (``[rdi + rcx*4]``) or a segment prefix never matches, so
222
+ #: those operands are skipped instead of being mis-attributed to a field.
223
+ _MEMORY_OPERAND = re.compile(
224
+ r"^(?:(?P<size>byte|word|dword|qword)\s+ptr\s+)?"
225
+ r"\[\s*(?P<base>[A-Za-z][A-Za-z0-9]*)"
226
+ r"(?:\s*(?P<sign>[+-])\s*(?P<displacement>0x[0-9a-fA-F]+|[0-9]+))?"
227
+ r"\s*\]$"
228
+ )
229
+
230
+ _FIELD_EVIDENCE_SOURCE = "type_recovery.memory_operand"
231
+ _FIELD_EVIDENCE_METHOD = "operand_scan"
232
+ _SUGGESTION_EVIDENCE_SOURCE = "type_recovery.suggestion"
233
+ _SUGGESTION_EVIDENCE_METHOD = "operand_aggregation"
234
+ _ARRAY_EVIDENCE_SOURCE = "type_recovery.array_run"
235
+ _ARRAY_EVIDENCE_METHOD = "stride_run_detection"
236
+ _MERGED_FIELD_EVIDENCE_SOURCE = "type_recovery.merged_field"
237
+ _MERGED_FIELD_EVIDENCE_METHOD = "window_merge"
238
+ _MERGED_SUGGESTION_EVIDENCE_SOURCE = "type_recovery.merged_suggestion"
239
+ _MERGED_SUGGESTION_EVIDENCE_METHOD = "window_merge"
240
+
241
+ #: Smallest number of equally spaced, equally wide fields that is reported as an
242
+ #: :class:`ArrayGroup`. Two fields are only a coincidence; three is the minimum
243
+ #: at which a repeated stride starts to carry information.
244
+ _MIN_ARRAY_ELEMENTS = 3
245
+
246
+
247
+ class SignednessConflictWarning(UserWarning):
248
+ """Emitted when one field offset is accessed as both signed and unsigned.
249
+
250
+ The two sources of evidence contradict each other, so the offset is reported
251
+ with ``signed=None`` -- exactly like an offset with no evidence at all --
252
+ and this warning says which offset lost its hint. It is a
253
+ :class:`UserWarning`, so callers that do not care about signedness can
254
+ ignore it; callers that do can filter on it.
255
+ """
256
+
257
+
258
+ class WindowOverlapWarning(UserWarning):
259
+ """Emitted when two merged windows decode the same instruction bytes.
260
+
261
+ A partial overlap is not rejected the way a duplicate address is -- looking
262
+ further into the same function is a legitimate second window -- but the shared
263
+ instructions are then counted by both windows, so the per-offset counters are
264
+ per-window sums and can exceed the number of distinct accesses.
265
+ """
266
+
267
+
268
+ class WidthConflictWarning(UserWarning):
269
+ """Emitted when merged windows disagree about one field's dominant width.
270
+
271
+ A wider access is never contradicted by a narrower one -- a window that only
272
+ saw ``dword ptr [rdi + 0x10]`` does not prove the field is 4 bytes wide -- so
273
+ the merged field keeps the **widest** per-window width while this warning
274
+ records the offset together with every width that was seen. It is a
275
+ :class:`UserWarning`, so it stays silent unless somebody listens.
276
+ """
277
+
278
+
279
+ @dataclass(frozen=True)
280
+ class FieldAccess:
281
+ """One observed access to a field-shaped memory operand.
282
+
283
+ Attributes:
284
+ offset: Displacement relative to ``base_register``; negative for
285
+ ``[reg - N]``.
286
+ width: Access width in bytes (1, 2, 4, 8, or the pointer width).
287
+ direction: :data:`READ` or :data:`WRITE`, decided from the operand
288
+ position and the mnemonic.
289
+ base_register: Lowercase base register of the operand, e.g. ``"rdi"``.
290
+ address: Virtual address of the instruction that performed the access.
291
+ instruction: Instruction text, e.g. ``"mov rax, qword ptr [rdi + 0x10]"``.
292
+ """
293
+
294
+ offset: int
295
+ width: int
296
+ direction: str
297
+ base_register: str
298
+ address: int
299
+ instruction: str
300
+
301
+ def to_dict(self) -> dict[str, Any]:
302
+ return {
303
+ "offset": f"0x{self.offset:x}",
304
+ "width": self.width,
305
+ "direction": self.direction,
306
+ "base_register": self.base_register,
307
+ "address": f"0x{self.address:x}",
308
+ "instruction": self.instruction,
309
+ }
310
+
311
+
312
+ @dataclass(frozen=True)
313
+ class SuggestedField:
314
+ """A struct field suggested by aggregating accesses at one offset.
315
+
316
+ Attributes:
317
+ offset: Displacement of the field inside the struct.
318
+ width: Dominant access width in bytes.
319
+ read_count: Accesses classified as reads at this offset (all widths).
320
+ write_count: Accesses classified as writes at this offset (all widths).
321
+ first_access: Virtual address of the first instruction that touched the
322
+ offset, or ``None`` when no access was recorded.
323
+ evidence: Justification, always at ``Confidence.MEDIUM``.
324
+ signed: ``True`` when every signedness hint recorded at this offset came
325
+ from a sign-extending mnemonic, ``False`` when every hint came from a
326
+ zero-extending/unsigned one, and ``None`` when there was no hint or
327
+ the hints contradicted each other (in which case
328
+ :class:`SignednessConflictWarning` was raised). Omitted from
329
+ :meth:`to_dict` when ``None``.
330
+ """
331
+
332
+ offset: int
333
+ width: int
334
+ read_count: int
335
+ write_count: int
336
+ first_access: int | None
337
+ evidence: Evidence
338
+ signed: bool | None = None
339
+
340
+ @property
341
+ def access_count(self) -> int:
342
+ """Total number of accesses (reads plus writes) at this offset."""
343
+ return self.read_count + self.write_count
344
+
345
+ def to_dict(self) -> dict[str, Any]:
346
+ first = self.first_access
347
+ payload: dict[str, Any] = {
348
+ "offset": f"0x{self.offset:x}",
349
+ "width": self.width,
350
+ "read_count": self.read_count,
351
+ "write_count": self.write_count,
352
+ "access_count": self.access_count,
353
+ "first_access": f"0x{first:x}" if first is not None else None,
354
+ "evidence": self.evidence.to_dict(),
355
+ }
356
+ # An unknown signedness is an absent key, not a ``null``: it keeps the
357
+ # JSON of an unclassified field byte-identical to the pre-signedness one.
358
+ if self.signed is not None:
359
+ payload["signed"] = self.signed
360
+ return payload
361
+
362
+
363
+ @dataclass(frozen=True)
364
+ class ArrayGroup:
365
+ """A run of equally spaced, equally wide fields that looks like an array.
366
+
367
+ The run is *maximal*: it cannot be extended left or right by another field
368
+ with the same width and stride, and it never overlaps another group.
369
+
370
+ Attributes:
371
+ offset: Offset of the first field of the run.
372
+ stride: Distance in bytes between consecutive fields; always positive.
373
+ count: Number of fields in the run (at least
374
+ :data:`_MIN_ARRAY_ELEMENTS`).
375
+ width: Shared dominant width of the run's fields, in bytes.
376
+ evidence: Justification, always at ``Confidence.MEDIUM`` and always
377
+ carrying a heuristic caveat.
378
+ """
379
+
380
+ offset: int
381
+ stride: int
382
+ count: int
383
+ width: int
384
+ evidence: Evidence
385
+
386
+ @property
387
+ def span(self) -> int:
388
+ """Total byte span covered by the run, i.e. ``stride * (count - 1) + width``."""
389
+ return self.stride * (self.count - 1) + self.width
390
+
391
+ def to_dict(self) -> dict[str, Any]:
392
+ return {
393
+ "offset": f"0x{self.offset:x}",
394
+ "stride": self.stride,
395
+ "count": self.count,
396
+ "width": self.width,
397
+ "span": self.span,
398
+ "evidence": self.evidence.to_dict(),
399
+ }
400
+
401
+
402
+ @dataclass(frozen=True)
403
+ class StructSuggestion:
404
+ """The fields suggested for the struct a function receives in a register.
405
+
406
+ Attributes:
407
+ address: Virtual address the sweep started at.
408
+ fields: Suggested fields sorted by ascending offset (at most
409
+ ``max_fields`` entries).
410
+ instruction_count: Number of decoded instructions the sweep considered.
411
+ memory_access_count: Number of memory accesses the sweep recorded after
412
+ the stack and instruction-pointer filters.
413
+ max_fields: Field cap that was applied.
414
+ truncated: True when distinct offsets were dropped by ``max_fields``.
415
+ Instruction-budget exhaustion is not reported here.
416
+ include_stack: Whether stack-based operands were kept.
417
+ evidence: Justification for the suggestion as a whole.
418
+ alignment: GCD of the absolute non-zero field offsets, or ``None`` when
419
+ fewer than two distinct non-zero offsets were found.
420
+ array_groups: Maximal runs of at least :data:`_MIN_ARRAY_ELEMENTS`
421
+ equally wide, equally spaced fields, in ascending offset order.
422
+ """
423
+
424
+ address: int
425
+ fields: tuple[SuggestedField, ...]
426
+ instruction_count: int
427
+ max_fields: int
428
+ truncated: bool
429
+ include_stack: bool
430
+ evidence: Evidence
431
+ alignment: int | None = None
432
+ array_groups: tuple[ArrayGroup, ...] = ()
433
+ memory_access_count: int = 0
434
+
435
+ @property
436
+ def field_count(self) -> int:
437
+ """Number of suggested fields."""
438
+ return len(self.fields)
439
+
440
+ def to_dict(self) -> dict[str, Any]:
441
+ return {
442
+ "address": f"0x{self.address:x}",
443
+ "instruction_count": self.instruction_count,
444
+ "field_count": self.field_count,
445
+ "max_fields": self.max_fields,
446
+ "truncated": self.truncated,
447
+ "include_stack": self.include_stack,
448
+ "fields": [field.to_dict() for field in self.fields],
449
+ "alignment": self.alignment,
450
+ "array_groups": [group.to_dict() for group in self.array_groups],
451
+ "evidence": self.evidence.to_dict(),
452
+ }
453
+
454
+
455
+ def _split_operands(operands: str) -> list[str]:
456
+ """Split an operand list on top-level commas, keeping bracket groups intact."""
457
+ parts: list[str] = []
458
+ current: list[str] = []
459
+ depth = 0
460
+ for char in operands:
461
+ if char == "[":
462
+ depth += 1
463
+ elif char == "]":
464
+ depth = max(0, depth - 1)
465
+ if char == "," and depth == 0:
466
+ parts.append("".join(current).strip())
467
+ current = []
468
+ continue
469
+ current.append(char)
470
+ tail = "".join(current).strip()
471
+ if tail:
472
+ parts.append(tail)
473
+ return parts
474
+
475
+
476
+ def _pointer_width(bitness: int) -> int:
477
+ """Return the pointer width in bytes for ``bitness`` (8 when unknown)."""
478
+ return _POINTER_WIDTH_BY_BITNESS.get(bitness, 8)
479
+
480
+
481
+ def _parse_displacement(sign: str | None, displacement: str | None) -> int:
482
+ """Turn a matched ``+``/``-`` displacement into a signed offset."""
483
+ if sign is None or displacement is None:
484
+ return 0
485
+ base = 16 if displacement.lower().startswith("0x") else 10
486
+ value = int(displacement, base)
487
+ return -value if sign == "-" else value
488
+
489
+
490
+ #: Mnemonics whose memory operands are read even when they appear first.
491
+ _READ_ONLY_MNEMONICS = frozenset({"call", "jmp", "push"})
492
+
493
+
494
+ def _direction(mnemonic: str, operand_index: int) -> str:
495
+ """Classify an access: writes are first operands, compares/calls are reads."""
496
+ if mnemonic in _COMPARE_MNEMONICS or mnemonic in _READ_ONLY_MNEMONICS:
497
+ return READ
498
+ return WRITE if operand_index == 0 else READ
499
+
500
+
501
+ def _signedness(mnemonic: str) -> bool | None:
502
+ """Signedness hint for one mnemonic: ``True``, ``False``, or ``None``.
503
+
504
+ The hint is read off the mnemonic alone, because that is all the decoder
505
+ tells us: a sign-extending operation (``movsx``, ``movsxd``, ``sar``,
506
+ ``idiv``, ``cqo``, ``cdq``, ``cwd``, ``cbw``) is evidence for a signed
507
+ field, a zero-extending or unsigned one (``movzx``, ``shr``, ``div``,
508
+ ``mul``) is evidence for an unsigned field, and everything else is no
509
+ evidence either way.
510
+ """
511
+ name = mnemonic.strip().lower()
512
+ if name in _SIGNED_MNEMONICS:
513
+ return True
514
+ if name in _UNSIGNED_MNEMONICS:
515
+ return False
516
+ return None
517
+
518
+
519
+ def _signedness_text(signed: bool | None) -> str:
520
+ """Render a signedness hint for evidence prose."""
521
+ if signed is None:
522
+ return "unknown"
523
+ return "signed" if signed else "unsigned"
524
+
525
+
526
+ def _width(match: re.Match[str], pointer_width: int) -> int:
527
+ """Width from the operand's size keyword, else the pointer width."""
528
+ size_keyword = match.group("size")
529
+ if size_keyword:
530
+ width = _SIZE_KEYWORD_WIDTHS.get(size_keyword.lower())
531
+ if width is not None:
532
+ return width
533
+ return max(1, pointer_width)
534
+
535
+
536
+ def parse_memory_operands(
537
+ instruction: IRInstruction,
538
+ *,
539
+ include_stack: bool = False,
540
+ pointer_width: int = 8,
541
+ ) -> tuple[FieldAccess, ...]:
542
+ """Extract field-shaped memory accesses from a single instruction.
543
+
544
+ Args:
545
+ instruction: Decoded instruction to inspect.
546
+ include_stack: When true, operands based on :data:`STACK_REGISTERS` are
547
+ kept; by default they are dropped because they usually describe
548
+ stack slots rather than struct fields.
549
+ pointer_width: Width in bytes used when the operand carries no size
550
+ keyword, i.e. ``[rdi + 0x10]`` without ``qword ptr``.
551
+
552
+ Returns:
553
+ One :class:`FieldAccess` per memory operand, in operand order. Empty for
554
+ instructions without a supported memory operand, for stack-based
555
+ operands when ``include_stack`` is false, and for mnemonics that carry a
556
+ bracket expression without touching memory (``lea``, ``nop``).
557
+ """
558
+ mnemonic = instruction.mnemonic.strip().lower()
559
+ if not mnemonic or mnemonic in _NON_ACCESSING_MNEMONICS or mnemonic.startswith("prefetch"):
560
+ return ()
561
+ accesses: list[FieldAccess] = []
562
+ for index, operand in enumerate(_split_operands(instruction.operands)):
563
+ match = _MEMORY_OPERAND.match(operand)
564
+ if match is None:
565
+ continue
566
+ base = match.group("base").lower()
567
+ if base in INSTRUCTION_POINTER_REGISTERS:
568
+ continue
569
+ if base in STACK_REGISTERS and not include_stack:
570
+ continue
571
+ accesses.append(
572
+ FieldAccess(
573
+ offset=_parse_displacement(match.group("sign"), match.group("displacement")),
574
+ width=_width(match, pointer_width),
575
+ direction=_direction(mnemonic, index),
576
+ base_register=base,
577
+ address=instruction.address,
578
+ instruction=instruction.text,
579
+ )
580
+ )
581
+ return tuple(accesses)
582
+
583
+
584
+ def _resolve_signedness(
585
+ offset: int, observed: Collection[bool], *, stacklevel: int = 4
586
+ ) -> bool | None:
587
+ """Collapse the signedness hints of one offset, warning on a contradiction.
588
+
589
+ One kind of hint means the offset is taken to be signed (or unsigned), no
590
+ hint means it stays unknown, and a mix is reported as unknown *and*
591
+ announced through :class:`SignednessConflictWarning` -- a contradictory
592
+ offset is not the same thing as an unclassified one, even though both end up
593
+ as ``None``. ``stacklevel`` only moves the warning's reported location.
594
+ """
595
+ if len(observed) == 1:
596
+ return next(iter(observed))
597
+ if len(observed) > 1:
598
+ warnings.warn(
599
+ f"offset 0x{offset:x} is accessed both as signed and as unsigned; "
600
+ "reporting its signedness as unknown",
601
+ SignednessConflictWarning,
602
+ stacklevel=stacklevel,
603
+ )
604
+ return None
605
+
606
+
607
+ def _aggregate(accesses: Iterable[tuple[FieldAccess, bool | None]]) -> list[SuggestedField]:
608
+ """Reduce ``(access, signedness hint)`` pairs to one field per offset.
609
+
610
+ Fields come back sorted by ascending offset, so the result is deterministic.
611
+ Widths are collapsed per offset (dominant width wins, ties go to the wider
612
+ access), read/write counters are totals over all widths, and signedness is
613
+ resolved by :func:`_resolve_signedness`.
614
+ """
615
+ per_offset: dict[int, dict[int, list[int]]] = {}
616
+ hints: dict[int, set[bool]] = {}
617
+ first_access: dict[int, int] = {}
618
+ for access, signed in accesses:
619
+ counts = per_offset.setdefault(access.offset, {}).setdefault(access.width, [0, 0])
620
+ if access.direction == WRITE:
621
+ counts[1] += 1
622
+ else:
623
+ counts[0] += 1
624
+ if signed is not None:
625
+ hints.setdefault(access.offset, set()).add(signed)
626
+ first_access.setdefault(access.offset, access.address)
627
+ fields: list[SuggestedField] = []
628
+ for offset in sorted(per_offset):
629
+ widths = per_offset[offset]
630
+ # Dominant width: most frequent, ties resolved to the wider access.
631
+ dominant = max(widths.items(), key=lambda item: (item[1][0] + item[1][1], item[0]))[0]
632
+ reads = sum(count[0] for count in widths.values())
633
+ writes = sum(count[1] for count in widths.values())
634
+ first = first_access.get(offset)
635
+ signed = _resolve_signedness(offset, hints.get(offset, ()))
636
+ fields.append(
637
+ SuggestedField(
638
+ offset=offset,
639
+ width=dominant,
640
+ read_count=reads,
641
+ write_count=writes,
642
+ first_access=first,
643
+ evidence=Evidence(
644
+ source=_FIELD_EVIDENCE_SOURCE,
645
+ method=_FIELD_EVIDENCE_METHOD,
646
+ confidence=Confidence.MEDIUM,
647
+ address=first,
648
+ detail=(
649
+ f"offset 0x{offset:x}: {dominant}-byte field, "
650
+ f"{reads} read(s), {writes} write(s), "
651
+ f"signedness {_signedness_text(signed)}; "
652
+ "field is heuristic, not a verified type"
653
+ ),
654
+ extra={
655
+ "offset": offset,
656
+ "width": dominant,
657
+ "read_count": reads,
658
+ "write_count": writes,
659
+ "signed": signed,
660
+ "signed_conflict": len(hints.get(offset, ())) > 1,
661
+ },
662
+ ),
663
+ signed=signed,
664
+ )
665
+ )
666
+ return fields
667
+
668
+
669
+ def _alignment(fields: Sequence[SuggestedField]) -> int | None:
670
+ """GCD of the absolute non-zero field offsets, or ``None`` when too few.
671
+
672
+ Offset 0 is ignored because it is a multiple of every candidate alignment and
673
+ would therefore contribute nothing. Fewer than two distinct non-zero offsets
674
+ cannot distinguish an alignment from the offset itself, so the answer is
675
+ ``None`` instead of a guess.
676
+ """
677
+ magnitudes = sorted({abs(field.offset) for field in fields if field.offset != 0})
678
+ if len(magnitudes) < 2:
679
+ return None
680
+ result = magnitudes[0]
681
+ for magnitude in magnitudes[1:]:
682
+ result = gcd(result, magnitude)
683
+ return result
684
+
685
+
686
+ def _array_group_evidence(
687
+ first: SuggestedField, stride: int, count: int, width: int
688
+ ) -> Evidence:
689
+ """Build the MEDIUM-confidence evidence record for one array run."""
690
+ return Evidence(
691
+ source=_ARRAY_EVIDENCE_SOURCE,
692
+ method=_ARRAY_EVIDENCE_METHOD,
693
+ confidence=Confidence.MEDIUM,
694
+ address=first.first_access,
695
+ detail=(
696
+ f"array candidate at offset 0x{first.offset:x}: {count} fields of "
697
+ f"{width} byte(s) spaced {stride} byte(s) apart; run heuristic only, "
698
+ "it cannot tell an array from repeated struct members and is not a "
699
+ "verified type"
700
+ ),
701
+ extra={
702
+ "offset": first.offset,
703
+ "stride": stride,
704
+ "count": count,
705
+ "width": width,
706
+ },
707
+ )
708
+
709
+
710
+ def _array_groups(fields: Sequence[SuggestedField]) -> tuple[ArrayGroup, ...]:
711
+ """Group maximal runs of equally wide, equally spaced fields.
712
+
713
+ Fields must already be sorted by ascending offset, which
714
+ :func:`_aggregate` guarantees. A run needs at least
715
+ :data:`_MIN_ARRAY_ELEMENTS` fields with one shared width and one shared
716
+ stride between neighbours; the scan is linear, and every field belongs to at
717
+ most one group, so the result is deterministic.
718
+ """
719
+ groups: list[ArrayGroup] = []
720
+ total = len(fields)
721
+ start = 0
722
+ while start < total:
723
+ width = fields[start].width
724
+ end = start
725
+ stride: int | None = None
726
+ while end + 1 < total:
727
+ following = fields[end + 1]
728
+ if following.width != width:
729
+ break
730
+ step = following.offset - fields[end].offset
731
+ if stride is None:
732
+ stride = step
733
+ elif step != stride:
734
+ break
735
+ end += 1
736
+ count = end - start + 1
737
+ if stride is not None and count >= _MIN_ARRAY_ELEMENTS:
738
+ first = fields[start]
739
+ groups.append(
740
+ ArrayGroup(
741
+ offset=first.offset,
742
+ stride=stride,
743
+ count=count,
744
+ width=width,
745
+ evidence=_array_group_evidence(first, stride, count, width),
746
+ )
747
+ )
748
+ start = end + 1
749
+ else:
750
+ # No run here: retry from the next field, a shorter run may start there.
751
+ start += 1
752
+ return tuple(groups)
753
+
754
+
755
+ def _merged_width(offset: int, widths: Sequence[int]) -> int:
756
+ """Widest of the per-window widths, warning when the windows disagree.
757
+
758
+ A wide access is stronger evidence than a narrow one -- seeing a ``qword``
759
+ proves at least eight bytes, seeing a ``dword`` elsewhere proves nothing
760
+ against it -- so the widest width is kept. The disagreement is still
761
+ reported, because it says how much of the field the code was seen touching.
762
+ """
763
+ widest = max(widths)
764
+ distinct = sorted(set(widths))
765
+ if len(distinct) > 1:
766
+ rendered = ", ".join(f"{width}-byte" for width in distinct)
767
+ warnings.warn(
768
+ f"offset 0x{offset:x} is accessed with different widths ({rendered}); "
769
+ f"keeping the widest, {widest} byte(s)",
770
+ WidthConflictWarning,
771
+ stacklevel=5,
772
+ )
773
+ return widest
774
+
775
+
776
+ def _merge_field(offset: int, parts: Sequence[SuggestedField]) -> SuggestedField:
777
+ """Fold the per-window fields of one offset into a single merged field.
778
+
779
+ ``parts`` holds one field per window that saw ``offset``, in ascending
780
+ window-address order. Counters are summed, the widest width wins (see
781
+ :func:`_merged_width`), the earliest access is kept, and signedness is folded
782
+ with the same rule as inside one window (:func:`_resolve_signedness`).
783
+ """
784
+ width = _merged_width(offset, [part.width for part in parts])
785
+ reads = sum(part.read_count for part in parts)
786
+ writes = sum(part.write_count for part in parts)
787
+ hints = {part.signed for part in parts if part.signed is not None}
788
+ window_conflict = any(
789
+ bool(part.evidence.extra.get("signed_conflict")) for part in parts
790
+ )
791
+ if window_conflict:
792
+ # A window whose own accesses contradicted each other must not lose that
793
+ # contradiction to a window that only ever hinted one way.
794
+ if hints:
795
+ warnings.warn(
796
+ f"offset 0x{offset:x} was accessed as signed and unsigned inside one "
797
+ "window, so no merged signedness hint is kept",
798
+ SignednessConflictWarning,
799
+ stacklevel=5,
800
+ )
801
+ signed = None
802
+ else:
803
+ signed = _resolve_signedness(offset, hints, stacklevel=5)
804
+ first = min(
805
+ (part.first_access for part in parts if part.first_access is not None),
806
+ default=None,
807
+ )
808
+ widths = sorted({part.width for part in parts})
809
+ rendered = ", ".join(str(width_seen) for width_seen in widths)
810
+ return SuggestedField(
811
+ offset=offset,
812
+ width=width,
813
+ read_count=reads,
814
+ write_count=writes,
815
+ first_access=first,
816
+ evidence=Evidence(
817
+ source=_MERGED_FIELD_EVIDENCE_SOURCE,
818
+ method=_MERGED_FIELD_EVIDENCE_METHOD,
819
+ confidence=Confidence.MEDIUM,
820
+ address=first,
821
+ detail=(
822
+ f"offset 0x{offset:x}: {width}-byte field merged from "
823
+ f"{len(parts)} window(s) reporting width(s) {rendered}, "
824
+ f"{reads} read(s), {writes} write(s), "
825
+ f"signedness {_signedness_text(signed)}; "
826
+ "field is heuristic, not a verified type"
827
+ ),
828
+ extra={
829
+ "offset": offset,
830
+ "width": width,
831
+ "widths": widths,
832
+ "read_count": reads,
833
+ "write_count": writes,
834
+ "signed": signed,
835
+ "signed_conflict": window_conflict or len(hints) > 1,
836
+ "window_count": len(parts),
837
+ },
838
+ ),
839
+ signed=signed,
840
+ )
841
+
842
+
843
+ def _merge_window_fields(
844
+ windows: Sequence[tuple[int, StructSuggestion]],
845
+ ) -> tuple[tuple[SuggestedField, ...], bool]:
846
+ """Fold every window's fields into one field per offset.
847
+
848
+ Windows must already be in ascending address order. Offsets are visited in
849
+ ascending order, so the warnings raised by :func:`_merge_field` -- and the
850
+ merged fields themselves -- are identical for the same set of addresses
851
+ regardless of the order the caller passed them in.
852
+
853
+ Returns:
854
+ Tuple of the merged fields (sorted by ascending offset, capped at
855
+ :data:`DEFAULT_MAX_FIELDS`) and whether offsets were dropped by the cap.
856
+ """
857
+ per_offset: dict[int, list[SuggestedField]] = {}
858
+ for _address, suggestion in windows:
859
+ for field in suggestion.fields:
860
+ per_offset.setdefault(field.offset, []).append(field)
861
+ merged = [_merge_field(offset, per_offset[offset]) for offset in sorted(per_offset)]
862
+ return tuple(merged[:DEFAULT_MAX_FIELDS]), len(merged) > DEFAULT_MAX_FIELDS
863
+
864
+
865
+ def _validate_window_addresses(addresses: object, limit: int) -> tuple[int, ...]:
866
+ """Validate merged window addresses and return them in ascending order.
867
+
868
+ Every address is checked for type and sign, duplicates are rejected (a second
869
+ decode of the same address would only double-count its evidence), and the
870
+ number of windows is capped at ``limit`` so the total decode work stays
871
+ bounded by ``limit * max_instructions`` instructions. Sorting makes the merge
872
+ -- including the warnings it raises -- independent of the caller's order.
873
+ """
874
+ if isinstance(addresses, (str, bytes, bytearray)) or not isinstance(
875
+ addresses, Collection
876
+ ):
877
+ raise AnalysisError(f"addresses must be a collection of integers, got {addresses!r}")
878
+ targets: list[int] = []
879
+ seen: set[int] = set()
880
+ for address in addresses:
881
+ if isinstance(address, bool) or not isinstance(address, int):
882
+ raise AnalysisError(f"address must be an integer, got {address!r}")
883
+ if address < 0:
884
+ raise AnalysisError(f"address must be non-negative, got {address}")
885
+ if address in seen:
886
+ raise AnalysisError(f"window address 0x{address:x} appears more than once")
887
+ seen.add(address)
888
+ targets.append(address)
889
+ if not targets:
890
+ raise AnalysisError("addresses must contain at least one window address")
891
+ if len(targets) > limit:
892
+ raise AnalysisError(f"too many window addresses (limit {limit}), got {len(targets)}")
893
+ return tuple(sorted(targets))
894
+
895
+
896
+ def _require_int(name: str, value: object, minimum: int, maximum: int) -> int:
897
+ """Validate an integer option and return it as ``int``."""
898
+ if isinstance(value, bool) or not isinstance(value, int):
899
+ raise AnalysisError(f"{name} must be an integer, got {value!r}")
900
+ if value < minimum:
901
+ raise AnalysisError(f"{name} must be >= {minimum}, got {value}")
902
+ if value > maximum:
903
+ raise AnalysisError(f"{name} too large (limit {maximum}), got {value}")
904
+ return value
905
+
906
+
907
+ def suggest_struct(
908
+ path: str | Path,
909
+ info: BinaryInfo,
910
+ start: int,
911
+ max_instructions: int = DEFAULT_MAX_INSTRUCTIONS,
912
+ max_fields: int = DEFAULT_MAX_FIELDS,
913
+ include_stack: bool = False,
914
+ ) -> StructSuggestion:
915
+ """Suggest the struct fields a function accesses through memory operands.
916
+
917
+ Args:
918
+ path: Binary file the bytes are read from.
919
+ info: Loaded metadata used for address mapping, section bounds, the
920
+ decoder and the pointer width.
921
+ start: Virtual address of the first instruction; must be inside an
922
+ executable section.
923
+ max_instructions: Instruction budget
924
+ (``MIN_MAX_INSTRUCTIONS`` .. ``MAX_MAX_INSTRUCTIONS``).
925
+ max_fields: Ceiling on distinct field offsets kept
926
+ (``MIN_MAX_FIELDS`` .. ``MAX_MAX_FIELDS``). Lower offsets win, the
927
+ rest are dropped and reported through ``truncated``.
928
+ include_stack: When true, ``[rsp]``/``[rbp]``-relative operands are kept
929
+ instead of being treated as stack slots.
930
+
931
+ Returns:
932
+ The :class:`StructSuggestion` for the region, with fields sorted by
933
+ ascending offset, each carrying its aggregated ``signed`` hint, plus the
934
+ offset-gcd ``alignment`` and the ``array_groups`` runs derived from those
935
+ reported fields.
936
+
937
+ Raises:
938
+ AnalysisError: When ``start``, ``max_instructions`` or ``max_fields`` is
939
+ invalid, when ``start`` is not inside an executable section, when
940
+ the address has no file bytes, when the architecture cannot be
941
+ disassembled, or when no instruction could be decoded.
942
+
943
+ Warns:
944
+ SignednessConflictWarning: Once per offset that is accessed both as
945
+ signed and as unsigned. Warnings are resolved during aggregation,
946
+ i.e. before the ``max_fields`` cap, so they are bounded by the
947
+ number of distinct offsets in the window rather than by the cap. The
948
+ warning is informational: the suggestion itself is still returned,
949
+ with ``signed=None`` at the contradictory offsets.
950
+
951
+ Note:
952
+ Two calls with the same arguments always produce the same suggestion:
953
+ the sweep is linear, aggregation is per offset, and fields, widths,
954
+ counters and warnings are all ordered deterministically. ``alignment``
955
+ and ``array_groups`` are derived from the reported fields only, so
956
+ capping with ``max_fields`` cannot leave a group pointing at a dropped
957
+ field.
958
+ """
959
+ if isinstance(start, bool) or not isinstance(start, int):
960
+ raise AnalysisError(f"start must be an integer, got {start!r}")
961
+ if start < 0:
962
+ raise AnalysisError(f"start must be non-negative, got {start}")
963
+ instruction_budget = _require_int(
964
+ "max_instructions", max_instructions, MIN_MAX_INSTRUCTIONS, MAX_MAX_INSTRUCTIONS
965
+ )
966
+ field_cap = _require_int("max_fields", max_fields, MIN_MAX_FIELDS, MAX_MAX_FIELDS)
967
+ if not isinstance(include_stack, bool):
968
+ raise AnalysisError(f"include_stack must be a boolean, got {include_stack!r}")
969
+
970
+ section = info.section_for_va(start)
971
+ if section is None or not section.is_executable:
972
+ raise AnalysisError(f"address 0x{start:x} is not inside an executable section")
973
+
974
+ code = read_code(path, info, start, instruction_budget * _MAX_INSTRUCTION_BYTES)
975
+ try:
976
+ disassembler = Disassembler(info.architecture, info.bitness, info.endianness)
977
+ result = disassembler.disassemble(
978
+ code, base_address=start, max_instructions=instruction_budget
979
+ )
980
+ except DisassemblyError as exc:
981
+ raise AnalysisError(f"cannot disassemble 0x{start:x}: {exc}") from exc
982
+
983
+ instructions = list(result.instructions)
984
+ if not instructions:
985
+ raise AnalysisError(f"no instruction decoded at 0x{start:x}")
986
+
987
+ pointer_width = _pointer_width(info.bitness)
988
+ accesses = [
989
+ (access, _signedness(instruction.mnemonic))
990
+ for instruction in instructions
991
+ for access in parse_memory_operands(
992
+ instruction, include_stack=include_stack, pointer_width=pointer_width
993
+ )
994
+ ]
995
+ all_fields = _aggregate(accesses)
996
+ fields = tuple(all_fields[:field_cap])
997
+ truncated = len(all_fields) > field_cap
998
+ alignment = _alignment(fields)
999
+ array_groups = _array_groups(fields)
1000
+
1001
+ evidence = Evidence(
1002
+ source=_SUGGESTION_EVIDENCE_SOURCE,
1003
+ method=_SUGGESTION_EVIDENCE_METHOD,
1004
+ confidence=Confidence.MEDIUM,
1005
+ address=start,
1006
+ detail=(
1007
+ f"{len(fields)} suggested field(s) from {len(accesses)} memory access(es) "
1008
+ f"over {len(instructions)} instruction(s) at 0x{start:x}"
1009
+ + (f", alignment {alignment}" if alignment is not None else "")
1010
+ + f", {len(array_groups)} array candidate(s)"
1011
+ + (" (field cap reached)" if truncated else "")
1012
+ + "; fields are heuristic from operand shapes, not verified types"
1013
+ ),
1014
+ extra={
1015
+ "max_instructions": instruction_budget,
1016
+ "max_fields": field_cap,
1017
+ "instruction_count": len(instructions),
1018
+ "memory_access_count": len(accesses),
1019
+ "field_count": len(fields),
1020
+ "truncated": truncated,
1021
+ "include_stack": include_stack,
1022
+ "alignment": alignment,
1023
+ "array_group_count": len(array_groups),
1024
+ "decoded_bytes": result.consumed_bytes,
1025
+ },
1026
+ )
1027
+ return StructSuggestion(
1028
+ address=start,
1029
+ fields=fields,
1030
+ instruction_count=len(instructions),
1031
+ max_fields=field_cap,
1032
+ truncated=truncated,
1033
+ include_stack=include_stack,
1034
+ evidence=evidence,
1035
+ alignment=alignment,
1036
+ array_groups=array_groups,
1037
+ memory_access_count=len(accesses),
1038
+ )
1039
+
1040
+
1041
+ def _warn_on_overlapping_windows(
1042
+ windows: Sequence[tuple[int, StructSuggestion]],
1043
+ ) -> None:
1044
+ """Warn once for every window that starts inside its predecessor's decode.
1045
+
1046
+ Duplicate addresses are rejected outright, because a second decode of the same
1047
+ address only double counts. A partial overlap is a legitimate way to look
1048
+ further into one function, so it is announced instead of refused: the shared
1049
+ instructions are counted by both windows.
1050
+ """
1051
+ previous_start: int | None = None
1052
+ previous_end: int | None = None
1053
+ for start, suggestion in windows:
1054
+ if previous_end is not None and start < previous_end:
1055
+ warnings.warn(
1056
+ f"window 0x{start:x} overlaps window 0x{previous_start:x}: the shared "
1057
+ "instructions are counted by both windows, so per-offset counters are "
1058
+ "per-window sums and may exceed the number of distinct accesses",
1059
+ WindowOverlapWarning,
1060
+ stacklevel=5,
1061
+ )
1062
+ decoded = int(suggestion.evidence.extra.get("decoded_bytes") or 0)
1063
+ previous_start = start
1064
+ previous_end = start + decoded if decoded else None
1065
+
1066
+
1067
+ def suggest_structs(
1068
+ path: str | Path,
1069
+ info: BinaryInfo,
1070
+ addresses: Collection[int],
1071
+ max_instructions: int = DEFAULT_MAX_INSTRUCTIONS,
1072
+ include_stack: bool = False,
1073
+ max_windows: int = MAX_MERGED_WINDOWS,
1074
+ ) -> StructSuggestion:
1075
+ """Merge the struct evidence of several windows into one suggestion.
1076
+
1077
+ Every address is inspected with exactly one :func:`suggest_struct` call --
1078
+ same budget, same stack policy -- and the per-window fields are then folded
1079
+ into one field per offset: counters are summed, the widest per-window width
1080
+ wins, the earliest access is kept, and signedness is combined with the
1081
+ single-window rule. ``alignment``, ``array_groups`` and ``truncated`` are
1082
+ recomputed from the merged fields, so a layout that only becomes visible
1083
+ across windows is still reported.
1084
+
1085
+ Args:
1086
+ path: Binary file the bytes are read from.
1087
+ info: Loaded metadata used for address mapping, section bounds, the
1088
+ decoder and the pointer width.
1089
+ addresses: Window start addresses inside the same binary. Each must be
1090
+ inside an executable section; duplicates are rejected instead of
1091
+ being counted twice.
1092
+ max_instructions: Per-window instruction budget
1093
+ (``MIN_MAX_INSTRUCTIONS`` .. ``MAX_MAX_INSTRUCTIONS``). Every window
1094
+ is decoded once with this budget, never more.
1095
+ include_stack: When true, ``[rsp]``/``[rbp]``-relative operands are kept.
1096
+ max_windows: Ceiling on the number of merged windows
1097
+ (``MIN_MERGED_WINDOWS`` .. :data:`MAX_MERGED_WINDOWS`).
1098
+
1099
+ Returns:
1100
+ A :class:`StructSuggestion` whose ``address`` is the **lowest** merged
1101
+ address, whose ``instruction_count`` is the total over all windows, and
1102
+ whose fields are sorted by ascending offset and capped at
1103
+ :data:`DEFAULT_MAX_FIELDS`. ``max_fields`` reports that cap. The evidence
1104
+ lists the window count and every merged address in ``extra``.
1105
+
1106
+ Raises:
1107
+ AnalysisError: When ``addresses`` is not a non-empty collection of
1108
+ non-negative integers, when an address appears twice, when more than
1109
+ ``max_windows`` addresses are given, when ``max_instructions`` or
1110
+ ``max_windows`` is out of range, when ``include_stack`` is not a
1111
+ bool, or when any single window fails the :func:`suggest_struct`
1112
+ checks (not executable, no bytes, undecodable).
1113
+
1114
+ Warns:
1115
+ WidthConflictWarning: Once per offset whose per-window dominant widths
1116
+ differ; the merged field keeps the widest one.
1117
+ SignednessConflictWarning: Once per offset whose surviving per-window
1118
+ hints disagree, including an offset that contradicted itself inside one
1119
+ window while another window hinted only one way.
1120
+ WindowOverlapWarning: Once per window that starts inside its predecessor's
1121
+ decoded range; the shared instructions are then counted by both
1122
+ windows.
1123
+
1124
+ Note:
1125
+ Work is bounded four ways: at most ``max_windows`` windows
1126
+ (:data:`MAX_MERGED_WINDOWS`), at most one decode of ``max_instructions``
1127
+ instructions per window, at most :data:`DEFAULT_MAX_FIELDS` reported
1128
+ offsets per window, and therefore at most ``max_windows *
1129
+ max_instructions`` decoded instructions in total. Addresses are sorted
1130
+ before the merge, so the result -- including the order of warnings -- is
1131
+ identical for any permutation of the same address set. A window that hit
1132
+ its own field cap cannot contribute the offsets it dropped, so the merged
1133
+ ``truncated`` is the OR of the merge cap and every window cap, and
1134
+ ``window_truncated`` names the windows that hit theirs. The result is still
1135
+ a heuristic, never a verified type.
1136
+ """
1137
+ instruction_budget = _require_int(
1138
+ "max_instructions", max_instructions, MIN_MAX_INSTRUCTIONS, MAX_MAX_INSTRUCTIONS
1139
+ )
1140
+ window_budget = _require_int(
1141
+ "max_windows", max_windows, MIN_MERGED_WINDOWS, MAX_MERGED_WINDOWS
1142
+ )
1143
+ if not isinstance(include_stack, bool):
1144
+ raise AnalysisError(f"include_stack must be a boolean, got {include_stack!r}")
1145
+ targets = _validate_window_addresses(addresses, window_budget)
1146
+
1147
+ windows = [
1148
+ (
1149
+ start,
1150
+ suggest_struct(
1151
+ path,
1152
+ info,
1153
+ start,
1154
+ max_instructions=instruction_budget,
1155
+ max_fields=DEFAULT_MAX_FIELDS,
1156
+ include_stack=include_stack,
1157
+ ),
1158
+ )
1159
+ for start in targets
1160
+ ]
1161
+ fields, merged_truncated = _merge_window_fields(windows)
1162
+ alignment = _alignment(fields)
1163
+ array_groups = _array_groups(fields)
1164
+ _warn_on_overlapping_windows(windows)
1165
+ instruction_count = sum(suggestion.instruction_count for _start, suggestion in windows)
1166
+ access_count = sum(suggestion.memory_access_count for _start, suggestion in windows)
1167
+ capped_windows = [start for start, suggestion in windows if suggestion.truncated]
1168
+ truncated = merged_truncated or bool(capped_windows)
1169
+ primary = targets[0]
1170
+
1171
+ evidence = Evidence(
1172
+ source=_MERGED_SUGGESTION_EVIDENCE_SOURCE,
1173
+ method=_MERGED_SUGGESTION_EVIDENCE_METHOD,
1174
+ confidence=Confidence.MEDIUM,
1175
+ address=primary,
1176
+ detail=(
1177
+ f"{len(fields)} merged field(s) from {len(windows)} window(s) "
1178
+ f"({', '.join(f'0x{start:x}' for start in targets)}) over "
1179
+ f"{instruction_count} instruction(s)"
1180
+ + (f", alignment {alignment}" if alignment is not None else "")
1181
+ + f", {len(array_groups)} array candidate(s)"
1182
+ + (" (field cap reached)" if truncated else "")
1183
+ + "; one field per offset with the widest width winning, fields are "
1184
+ "heuristic from operand shapes, not verified types"
1185
+ ),
1186
+ extra={
1187
+ "window_count": len(windows),
1188
+ "windows": [f"0x{start:x}" for start in targets],
1189
+ "max_windows": window_budget,
1190
+ "max_instructions": instruction_budget,
1191
+ "max_fields": DEFAULT_MAX_FIELDS,
1192
+ "instruction_count": instruction_count,
1193
+ "memory_access_count": access_count,
1194
+ "field_count": len(fields),
1195
+ "truncated": truncated,
1196
+ "window_truncated": [f"0x{start:x}" for start in capped_windows],
1197
+ "include_stack": include_stack,
1198
+ "alignment": alignment,
1199
+ "array_group_count": len(array_groups),
1200
+ },
1201
+ )
1202
+ return StructSuggestion(
1203
+ address=primary,
1204
+ fields=fields,
1205
+ instruction_count=instruction_count,
1206
+ max_fields=DEFAULT_MAX_FIELDS,
1207
+ truncated=truncated,
1208
+ include_stack=include_stack,
1209
+ evidence=evidence,
1210
+ alignment=alignment,
1211
+ array_groups=array_groups,
1212
+ )
1213
+
1214
+