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,562 @@
1
+ """Content fingerprints for a single function region (phase 2g).
2
+
3
+ :func:`function_fingerprint` disassembles the function that starts at an address
4
+ with a bounded *linear sweep* and reduces the decoded instructions to one
5
+ SHA-256 digest over their **normalized shape**:
6
+
7
+ 1. Every instruction is rendered as ``"<mnemonic> <operands>"``.
8
+ 2. Numeric literals are masked: ``0x401030``, ``-0x10`` and ``42`` all become
9
+ ``#``. Consecutive masks are collapsed and whitespace is normalized, so
10
+ ``mov eax, [rbp - 0x10]`` and ``mov eax, [rbp - 0x8]`` render identically.
11
+ 3. The masked instructions are joined with ``"\\n"`` and hashed with SHA-256.
12
+
13
+ That makes the digest a *shape* fingerprint: two functions with the same
14
+ mnemonic/operand structure compare equal even when their immediates, branch
15
+ targets or frame offsets differ. This is what makes the digest useful for a
16
+ recompiled or relocated build -- and exactly why equal digests are **not** byte
17
+ equality and different digests are **not** a semantic difference. Every
18
+ fingerprint carries that caveat in its evidence at ``Confidence.MEDIUM``.
19
+
20
+ Work is bounded twice: the caller's ``max_instructions`` budget (default
21
+ :data:`DEFAULT_MAX_INSTRUCTIONS`) and the file-backed bytes of the containing
22
+ section, so at most ``max_instructions * 16`` bytes are read.
23
+ :attr:`FunctionFingerprint.truncated` says whether the digest covers only a
24
+ prefix of the function.
25
+
26
+ :func:`normalized_tokens` exposes the token stream those steps build, and
27
+ :func:`function_similarity` compares two regions as **multisets** of normalized
28
+ instructions (``collections.Counter``): the score is
29
+ ``sum(min(counts)) / sum(max(counts))``, i.e. the Jaccard overlap of the two
30
+ token multisets, where ``1.0`` means both sides share every token with the same
31
+ multiplicity and ``0.0`` means they share none. The score is therefore a
32
+ *shape* measure exactly like the digest -- see
33
+ :data:`~codevanta.analysis.function_hash.SimilarityResult` -- and never a
34
+ semantic comparison.
35
+
36
+ Nothing is mapped or executed: bytes are read with
37
+ :func:`~codevanta.core.report.read_code`, which only ever reads the file, and
38
+ the target architecture is decoded by
39
+ :class:`~codevanta.core.disassembler.Disassembler`.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import hashlib
45
+ import re
46
+ from collections import Counter
47
+ from dataclasses import dataclass
48
+ from pathlib import Path
49
+ from typing import Any
50
+
51
+ from codevanta.core.disassembler import Disassembler, DisassemblyError
52
+ from codevanta.core.loader import BinaryInfo
53
+ from codevanta.core.report import MAX_DISASSEMBLY_COUNT, AnalysisError, read_code
54
+ from codevanta.evidence.confidence import Confidence
55
+ from codevanta.evidence.models import Evidence
56
+ from codevanta.ir.instruction import IRInstruction
57
+
58
+ __all__ = [
59
+ "DEFAULT_MAX_INSTRUCTIONS",
60
+ "DIGEST_ALGORITHM",
61
+ "MAX_MAX_INSTRUCTIONS",
62
+ "MIN_MAX_INSTRUCTIONS",
63
+ "FunctionFingerprint",
64
+ "SimilarityResult",
65
+ "function_fingerprint",
66
+ "function_similarity",
67
+ "normalize_instruction",
68
+ "normalized_tokens",
69
+ ]
70
+
71
+ #: Default instruction budget for one fingerprint.
72
+ DEFAULT_MAX_INSTRUCTIONS = 2_000
73
+
74
+ #: Smallest accepted ``max_instructions``; smaller requests are rejected.
75
+ MIN_MAX_INSTRUCTIONS = 1
76
+
77
+ #: Largest accepted ``max_instructions``; larger requests are rejected.
78
+ MAX_MAX_INSTRUCTIONS = MAX_DISASSEMBLY_COUNT
79
+
80
+ #: Hash used for the digest, recorded in :attr:`FunctionFingerprint.algorithm`.
81
+ DIGEST_ALGORITHM = "sha256"
82
+
83
+ #: Longest possible encoding of one instruction on a supported architecture.
84
+ #: Used to size the read window for the instruction budget.
85
+ _MAX_INSTRUCTION_BYTES = 16
86
+
87
+ #: ``0x401030``, ``-0x10``, ``+0X1F`` -> one mask. Hex is masked before bare
88
+ #: numbers so the ``0x`` form never leaves a trailing digit behind.
89
+ _HEX_LITERAL = re.compile(r"[-+]?0[xX][0-9a-fA-F]+")
90
+
91
+ #: A bare decimal literal (``42``, ARM's ``#-1``). The lookarounds keep register
92
+ #: and operand names that merely contain digits -- ``x29``, ``dword``, ``q3`` --
93
+ #: intact.
94
+ _BARE_NUMBER = re.compile(r"(?<![0-9A-Za-z_])[-+]?[0-9]+(?![0-9A-Za-z_])")
95
+
96
+ #: Consecutive masks collapse, so ARM's ``#0x1`` (which normalizes to ``##``)
97
+ #: and x86's ``0x1`` (which normalizes to ``#``) agree.
98
+ _REPEATED_MASK = re.compile(r"#+")
99
+
100
+ _EVIDENCE_SOURCE = "function_hash.normalized"
101
+ _EVIDENCE_METHOD = "normalized_linear_disassembly"
102
+ _CAVEAT = (
103
+ "shape-based fingerprint: instructions are normalized and numeric literals are masked "
104
+ "to '#', so an equal digest is not byte equality and a different digest is not a "
105
+ "semantic difference; nothing was executed"
106
+ )
107
+
108
+ _EVIDENCE_SIMILARITY_SOURCE = "function_hash.similarity"
109
+ _EVIDENCE_SIMILARITY_METHOD = "token_multiset"
110
+ _SIMILARITY_CAVEAT = (
111
+ "shape-not-semantics comparison: the score is the multiset overlap of normalized "
112
+ "instructions with numeric literals masked to '#', so a high score is structural "
113
+ "resemblance and not proven semantic equivalence, and a low score is not proof of "
114
+ "different behaviour; sweeps are bounded prefixes and nothing was executed"
115
+ )
116
+
117
+ #: Decimals kept when a similarity score is serialized.
118
+ _SCORE_DECIMALS = 4
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class FunctionFingerprint:
123
+ """A normalized-content fingerprint for one function region.
124
+
125
+ Attributes:
126
+ address: Virtual address the sweep started at (the requested address).
127
+ digest: Lower-case hexadecimal SHA-256 of the newline-joined, normalized
128
+ instruction stream.
129
+ instruction_count: Number of instructions that contributed to
130
+ ``digest``.
131
+ truncated: ``True`` when the sweep stopped before the end of the
132
+ available bytes -- the instruction budget was reached, the section's
133
+ file-backed bytes ran out, or trailing bytes could not be decoded.
134
+ The digest then covers only a prefix of the function.
135
+ evidence: How the digest was produced, including the shape caveat and
136
+ the applied bounds.
137
+ """
138
+
139
+ address: int
140
+ digest: str
141
+ instruction_count: int
142
+ truncated: bool
143
+ evidence: Evidence
144
+
145
+ @property
146
+ def algorithm(self) -> str:
147
+ """Name of the hash used for :attr:`digest`."""
148
+ return DIGEST_ALGORITHM
149
+
150
+ def to_dict(self) -> dict[str, Any]:
151
+ """Serialize the fingerprint for the JSON report."""
152
+ return {
153
+ "address": f"0x{self.address:x}",
154
+ "algorithm": self.algorithm,
155
+ "digest": self.digest,
156
+ "instruction_count": self.instruction_count,
157
+ "truncated": self.truncated,
158
+ "evidence": self.evidence.to_dict(),
159
+ }
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class SimilarityResult:
164
+ """Shape similarity between the instruction streams of two regions.
165
+
166
+ The comparison is a **multiset** comparison of the normalized instruction
167
+ tokens of both regions, so both the identities and the multiplicities of the
168
+ instructions matter::
169
+
170
+ shared = sum((left_counts & right_counts).values()) # sum(min)
171
+ union = sum((left_counts | right_counts).values()) # sum(max)
172
+ score = shared / union
173
+
174
+ ``score`` is therefore ``1.0`` for two regions that agree token for token and
175
+ ``0.0`` for two regions that share no token at all. It is a *shape* measure:
176
+ immediates, frame offsets and branch targets are masked away before the
177
+ comparison, so equal scores are not byte equality and different scores are
178
+ not a semantic difference.
179
+
180
+ Attributes:
181
+ score: ``sum(min) / sum(max)`` of the two token multisets, in ``[0, 1]``.
182
+ shared: Number of token occurrences both sides share (``sum(min)``).
183
+ left_total: Number of token occurrences in the left region.
184
+ right_total: Number of token occurrences in the right region.
185
+ evidence: How the score was produced, including the shape caveat and the
186
+ applied bounds. Its ``address`` is the *left* region's start.
187
+ warnings: Human-readable notes about the measurement -- for example that
188
+ one side was truncated to the instruction budget, so the score
189
+ compares prefixes. Empty when neither side was cut short.
190
+ """
191
+
192
+ score: float
193
+ shared: int
194
+ left_total: int
195
+ right_total: int
196
+ evidence: Evidence
197
+ warnings: tuple[str, ...] = ()
198
+
199
+ @property
200
+ def union(self) -> int:
201
+ """Number of token occurrences in the union (``sum(max)``)."""
202
+ return self.left_total + self.right_total - self.shared
203
+
204
+ def to_dict(self) -> dict[str, Any]:
205
+ """Serialize the similarity for the JSON report (``score`` to 4 decimals)."""
206
+ return {
207
+ "score": round(self.score, _SCORE_DECIMALS),
208
+ "shared": self.shared,
209
+ "left_total": self.left_total,
210
+ "right_total": self.right_total,
211
+ "union": self.union,
212
+ "warnings": list(self.warnings),
213
+ "evidence": self.evidence.to_dict(),
214
+ }
215
+
216
+
217
+ def normalize_instruction(instruction: IRInstruction) -> str:
218
+ """Render one instruction with every numeric literal masked to ``#``.
219
+
220
+ The mnemonic is lower-cased and kept verbatim (it is the instruction's
221
+ identity); only the operand text is masked, so ``mov eax, 0x2a`` and
222
+ ``mov eax, 0x63`` both normalize to ``"mov eax, #"``. Whitespace inside the
223
+ operands is collapsed, which makes the result independent of the
224
+ disassembler's spacing.
225
+
226
+ Args:
227
+ instruction: Decoded instruction to normalize.
228
+
229
+ Returns:
230
+ ``"<mnemonic> <masked operands>"``, or just the mnemonic when the
231
+ instruction takes no operands.
232
+ """
233
+ mnemonic = (instruction.mnemonic or "").strip().lower()
234
+ masked = _HEX_LITERAL.sub("#", instruction.operands or "")
235
+ masked = _BARE_NUMBER.sub("#", masked)
236
+ masked = _REPEATED_MASK.sub("#", masked)
237
+ masked = " ".join(masked.split())
238
+ return f"{mnemonic} {masked}".strip()
239
+
240
+
241
+ @dataclass(frozen=True)
242
+ class _Sweep:
243
+ """Outcome of one bounded linear sweep (internal to this module).
244
+
245
+ Attributes:
246
+ address: Requested start address of the sweep.
247
+ tokens: Normalized instruction tokens in decoding order.
248
+ consumed_bytes: Bytes the decoder consumed.
249
+ buffer_bytes: Bytes read from the file for the sweep.
250
+ truncated: ``True`` when the sweep stopped before the end of the
251
+ available bytes -- the instruction budget was reached, the section's
252
+ file-backed bytes ran out, or trailing bytes could not be decoded.
253
+ max_instructions: Instruction budget that was applied.
254
+ """
255
+
256
+ address: int
257
+ tokens: tuple[str, ...]
258
+ consumed_bytes: int
259
+ buffer_bytes: int
260
+ truncated: bool
261
+ max_instructions: int
262
+
263
+
264
+ def _validate_bounds(address: int, max_instructions: int) -> None:
265
+ """Reject an invalid address or instruction budget.
266
+
267
+ Raises:
268
+ AnalysisError: When ``address`` is not a non-negative integer or
269
+ ``max_instructions`` is outside
270
+ ``MIN_MAX_INSTRUCTIONS`` .. ``MAX_MAX_INSTRUCTIONS``.
271
+ """
272
+ if isinstance(address, bool) or not isinstance(address, int):
273
+ raise AnalysisError(f"address must be an integer, got {address!r}")
274
+ if address < 0:
275
+ raise AnalysisError(f"address must be non-negative, got {address}")
276
+ if isinstance(max_instructions, bool) or not isinstance(max_instructions, int):
277
+ raise AnalysisError(f"max_instructions must be an integer, got {max_instructions!r}")
278
+ if max_instructions < MIN_MAX_INSTRUCTIONS:
279
+ raise AnalysisError("max_instructions must be >= 1")
280
+ if max_instructions > MAX_MAX_INSTRUCTIONS:
281
+ raise AnalysisError(f"max_instructions too large (limit {MAX_MAX_INSTRUCTIONS})")
282
+
283
+
284
+ def _sweep(
285
+ path: str | Path,
286
+ info: BinaryInfo,
287
+ address: int,
288
+ max_instructions: int,
289
+ ) -> _Sweep:
290
+ """Disassemble and normalize one bounded linear sweep at ``address``.
291
+
292
+ This is the only place a region is decoded, so :func:`normalized_tokens`,
293
+ :func:`function_fingerprint` and :func:`function_similarity` can never
294
+ disagree about which instructions a region contains or about the bounds that
295
+ were applied. At most ``max_instructions * 16`` file bytes are read and the
296
+ bytes are never mapped or executed.
297
+
298
+ Args:
299
+ path: File the bytes are read from (read-only).
300
+ info: Loaded metadata used for address mapping and for choosing the
301
+ decoder.
302
+ address: Virtual address of the first instruction; must be inside an
303
+ executable section.
304
+ max_instructions: Instruction budget
305
+ (``MIN_MAX_INSTRUCTIONS`` .. ``MAX_MAX_INSTRUCTIONS``).
306
+
307
+ Returns:
308
+ The normalized tokens of the region together with the applied bounds.
309
+
310
+ Raises:
311
+ AnalysisError: When ``address`` or ``max_instructions`` is invalid, when
312
+ ``address`` is not inside an executable section, when the address has
313
+ no file bytes, when the architecture cannot be disassembled, or when
314
+ no instruction could be decoded.
315
+ """
316
+ _validate_bounds(address, max_instructions)
317
+
318
+ section = info.section_for_va(address)
319
+ if section is None or not section.is_executable:
320
+ raise AnalysisError(f"address 0x{address:x} is not inside an executable section")
321
+
322
+ code = read_code(path, info, address, max_instructions * _MAX_INSTRUCTION_BYTES)
323
+ try:
324
+ disassembler = Disassembler(info.architecture, info.bitness, info.endianness)
325
+ result = disassembler.disassemble(
326
+ code,
327
+ base_address=address,
328
+ max_instructions=max_instructions,
329
+ )
330
+ except DisassemblyError as exc:
331
+ raise AnalysisError(f"cannot disassemble 0x{address:x}: {exc}") from exc
332
+
333
+ instructions = tuple(result.instructions)
334
+ if not instructions:
335
+ raise AnalysisError(f"no instruction decoded at 0x{address:x}")
336
+
337
+ tokens = tuple(normalize_instruction(instruction) for instruction in instructions)
338
+ truncated = (
339
+ bool(result.truncated)
340
+ or len(tokens) >= max_instructions
341
+ or result.consumed_bytes < len(code)
342
+ )
343
+ return _Sweep(
344
+ address=address,
345
+ tokens=tokens,
346
+ consumed_bytes=result.consumed_bytes,
347
+ buffer_bytes=len(code),
348
+ truncated=truncated,
349
+ max_instructions=max_instructions,
350
+ )
351
+
352
+
353
+ def normalized_tokens(
354
+ path: str | Path,
355
+ info: BinaryInfo,
356
+ address: int,
357
+ max_instructions: int = DEFAULT_MAX_INSTRUCTIONS,
358
+ ) -> tuple[str, ...]:
359
+ """Return the normalized instruction tokens of the region at ``address``.
360
+
361
+ The tokens are exactly the strings :func:`function_fingerprint` hashes, so
362
+ ``sha256("\\n".join(normalized_tokens(...)))`` reproduces the digest. They are
363
+ reusable on their own for multiset comparisons (see
364
+ :func:`function_similarity`) or for reporting which instructions two
365
+ regions have in common.
366
+
367
+ Args:
368
+ path: File the bytes are read from (read-only; never executed).
369
+ info: Loaded metadata used for address mapping and for choosing the
370
+ decoder.
371
+ address: Virtual address of the first instruction; must be inside an
372
+ executable section.
373
+ max_instructions: Instruction budget
374
+ (``MIN_MAX_INSTRUCTIONS`` .. ``MAX_MAX_INSTRUCTIONS``).
375
+
376
+ Returns:
377
+ The tokens in decoding order -- never empty, because a region without a
378
+ decodable instruction raises instead of returning ``()``.
379
+
380
+ Raises:
381
+ AnalysisError: When ``address`` or ``max_instructions`` is invalid, when
382
+ ``address`` is not inside an executable section, when the address has
383
+ no file bytes, when the architecture cannot be disassembled, or when
384
+ no instruction could be decoded.
385
+ """
386
+ return _sweep(path, info, address, max_instructions).tokens
387
+
388
+
389
+ def function_fingerprint(
390
+ path: str | Path,
391
+ info: BinaryInfo,
392
+ address: int,
393
+ max_instructions: int = DEFAULT_MAX_INSTRUCTIONS,
394
+ ) -> FunctionFingerprint:
395
+ """Fingerprint the normalized instruction stream that starts at ``address``.
396
+
397
+ The sweep is a plain linear disassembly of the executable section's file
398
+ bytes starting at ``address``: it does not follow branches, so bytes that
399
+ belong to a neighbouring function are included when ``address`` is not a
400
+ real function entry, and a function longer than the budget is fingerprinted
401
+ from its prefix only (``truncated`` is then ``True``).
402
+
403
+ Args:
404
+ path: File the bytes are read from (read-only; never executed).
405
+ info: Loaded metadata used for address mapping and for choosing the
406
+ decoder.
407
+ address: Virtual address of the first instruction; must be inside an
408
+ executable section.
409
+ max_instructions: Instruction budget
410
+ (``MIN_MAX_INSTRUCTIONS`` .. ``MAX_MAX_INSTRUCTIONS``).
411
+
412
+ Returns:
413
+ The :class:`FunctionFingerprint` for the region.
414
+
415
+ Raises:
416
+ AnalysisError: When ``address`` or ``max_instructions`` is invalid, when
417
+ ``address`` is not inside an executable section, when the address
418
+ has no file bytes, when the architecture cannot be disassembled, or
419
+ when no instruction could be decoded.
420
+
421
+ Note:
422
+ An empty stream is an error rather than an empty digest: every
423
+ undecodable region would otherwise share one digest and appear to
424
+ "match" every other such region.
425
+ """
426
+ sweep = _sweep(path, info, address, max_instructions)
427
+ digest = hashlib.sha256("\n".join(sweep.tokens).encode("utf-8")).hexdigest()
428
+ truncated = sweep.truncated
429
+
430
+ evidence = Evidence(
431
+ source=_EVIDENCE_SOURCE,
432
+ method=_EVIDENCE_METHOD,
433
+ confidence=Confidence.MEDIUM,
434
+ address=address,
435
+ detail=(
436
+ f"sha256 over {len(sweep.tokens)} normalized instruction(s)"
437
+ + (" from a truncated sweep (prefix only)" if truncated else "")
438
+ + f"; {_CAVEAT}"
439
+ ),
440
+ extra={
441
+ "algorithm": DIGEST_ALGORITHM,
442
+ "max_instructions": max_instructions,
443
+ "instruction_count": len(sweep.tokens),
444
+ "consumed_bytes": sweep.consumed_bytes,
445
+ "truncated": truncated,
446
+ },
447
+ )
448
+ return FunctionFingerprint(
449
+ address=address,
450
+ digest=digest,
451
+ instruction_count=len(sweep.tokens),
452
+ truncated=truncated,
453
+ evidence=evidence,
454
+ )
455
+
456
+
457
+ def function_similarity(
458
+ path_a: str | Path,
459
+ info_a: BinaryInfo,
460
+ address_a: int,
461
+ path_b: str | Path,
462
+ info_b: BinaryInfo,
463
+ address_b: int,
464
+ max_instructions: int = DEFAULT_MAX_INSTRUCTIONS,
465
+ ) -> SimilarityResult:
466
+ """Compare two function regions as multisets of normalized instructions.
467
+
468
+ Both regions are swept with the same bounded linear sweep as
469
+ :func:`normalized_tokens` and their tokens are counted with
470
+ :class:`collections.Counter`::
471
+
472
+ shared = sum((left & right).values()) # sum(min), shared occurrences
473
+ union = sum((left | right).values()) # sum(max), union size
474
+ score = shared / union
475
+
476
+ Multiplicity matters, so ``nop; nop; ret`` is only half similar to
477
+ ``nop; ret``. Immediates, frame offsets and branch targets are masked before
478
+ counting, so a recompiled or relocated copy of a function usually scores
479
+ ``1.0``. The score describes *shape*, not semantics -- see
480
+ :class:`SimilarityResult`.
481
+
482
+ Args:
483
+ path_a: File of the left region (read-only; never executed).
484
+ info_a: Loaded metadata of ``path_a``.
485
+ address_a: Start address of the left region.
486
+ path_b: File of the right region (read-only; never executed).
487
+ info_b: Loaded metadata of ``path_b``.
488
+ address_b: Start address of the right region.
489
+ max_instructions: Per-side instruction budget
490
+ (``MIN_MAX_INSTRUCTIONS`` .. ``MAX_MAX_INSTRUCTIONS``).
491
+
492
+ Returns:
493
+ The :class:`SimilarityResult`; ``1.0`` when both token multisets are
494
+ identical and ``0.0`` when they share no token.
495
+
496
+ Raises:
497
+ AnalysisError: When either side cannot be swept (invalid address or
498
+ budget, address outside an executable section, no file bytes,
499
+ undecodable architecture), or when either side yields no tokens.
500
+
501
+ Note:
502
+ Every side is bound by ``max_instructions``, so a full comparison
503
+ decodes at most ``2 * max_instructions`` instructions and reads at most
504
+ ``2 * max_instructions * 16`` bytes. When a side was truncated, the
505
+ result carries a warning and the score compares prefixes only.
506
+ """
507
+ left = _sweep(path_a, info_a, address_a, max_instructions)
508
+ right = _sweep(path_b, info_b, address_b, max_instructions)
509
+ if not left.tokens or not right.tokens:
510
+ # Defensive: _sweep already refuses an empty region, but a score against
511
+ # an empty multiset would be a meaningless 0.0 rather than an error.
512
+ side = "left" if not left.tokens else "right"
513
+ raise AnalysisError(f"{side} region produced no instruction tokens to compare")
514
+
515
+ left_counts = Counter(left.tokens)
516
+ right_counts = Counter(right.tokens)
517
+ shared = sum((left_counts & right_counts).values())
518
+ union = sum((left_counts | right_counts).values())
519
+ score = shared / union if union else 0.0
520
+
521
+ warnings: list[str] = []
522
+ if left.truncated:
523
+ warnings.append(
524
+ f"left region 0x{address_a:x} was truncated to {len(left.tokens)} "
525
+ "instruction(s); the score compares prefixes"
526
+ )
527
+ if right.truncated:
528
+ warnings.append(
529
+ f"right region 0x{address_b:x} was truncated to {len(right.tokens)} "
530
+ "instruction(s); the score compares prefixes"
531
+ )
532
+
533
+ evidence = Evidence(
534
+ source=_EVIDENCE_SIMILARITY_SOURCE,
535
+ method=_EVIDENCE_SIMILARITY_METHOD,
536
+ confidence=Confidence.MEDIUM,
537
+ address=address_a,
538
+ detail=(
539
+ f"{shared} shared of {union} token occurrence(s) in the union "
540
+ f"(left {len(left.tokens)}, right {len(right.tokens)}): "
541
+ f"score {score:.{_SCORE_DECIMALS}f}; {_SIMILARITY_CAVEAT}"
542
+ ),
543
+ extra={
544
+ "left_address": f"0x{address_a:x}",
545
+ "right_address": f"0x{address_b:x}",
546
+ "shared": shared,
547
+ "left_total": len(left.tokens),
548
+ "right_total": len(right.tokens),
549
+ "union": union,
550
+ "max_instructions": max_instructions,
551
+ "left_truncated": left.truncated,
552
+ "right_truncated": right.truncated,
553
+ },
554
+ )
555
+ return SimilarityResult(
556
+ score=score,
557
+ shared=shared,
558
+ left_total=len(left.tokens),
559
+ right_total=len(right.tokens),
560
+ evidence=evidence,
561
+ warnings=tuple(warnings),
562
+ )