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,1168 @@
1
+ """Structural diffing of two loaded binaries.
2
+
3
+ :func:`diff_infos` compares two :class:`~codevanta.core.loader.BinaryInfo`
4
+ snapshots together with their discovered function candidates and reports what
5
+ was added, removed or changed. :func:`diff_binaries` is the convenience entry
6
+ point that loads both files first.
7
+
8
+ The comparison is deliberately *structural* by default: it only reads metadata
9
+ the loader already extracted (sections, imports, exports) plus the candidate list
10
+ produced by :func:`~codevanta.core.functions.discover_functions`. No target file
11
+ is executed and no instruction bytes are compared, so a ``changed`` entry means
12
+ "the observed metadata differs", never "the machine code differs". Every delta
13
+ carries that caveat in its evidence record at ``Confidence.MEDIUM``.
14
+
15
+ ``deep=True`` adds a *content* signal on top of that metadata, without changing
16
+ any structural rule. For every function that matches by address, the normalized
17
+ instruction stream of both sides is fingerprinted with
18
+ :func:`~codevanta.analysis.function_hash.function_fingerprint`; when the digests
19
+ differ the entry becomes ``changed`` (with both digests and a content note), and
20
+ when they match although the metadata changed, the note says so. Fingerprints are
21
+ shape based -- numeric literals are masked to ``#`` -- so an equal digest is not
22
+ byte equality and a different digest is not a semantic difference. Deep mode
23
+ still only reads file bytes, never executes them, and is bounded by
24
+ ``max_functions`` per side, :data:`DEEP_MAX_INSTRUCTIONS` instructions per
25
+ fingerprint, and :data:`MAX_DEEP_FINGERPRINTS` fingerprinted pairs per side;
26
+ every bound that cuts work short is reported through ``warnings``. Functions
27
+ matched by name after moving, and one-sided functions, are fingerprinted by
28
+ neither side of deep mode and keep their structural verdict.
29
+
30
+ ``semantic=True`` adds a *pairing* signal rather than a new structural rule.
31
+ After address and name matching, the candidates still unpaired on both sides are
32
+ scored with :func:`~codevanta.analysis.function_hash.function_similarity` -- the
33
+ multiset overlap of their normalized instruction shapes -- and the
34
+ highest-scoring pairs at or above ``threshold`` (default
35
+ :data:`DEFAULT_SEMANTIC_THRESHOLD`) are reported as one ``changed`` entry each,
36
+ carrying that ``similarity`` (rounded to four decimals). Leftovers that are not
37
+ paired keep their structural ``added``/``removed`` verdict, so semantic mode
38
+ never hides a one-sided function; it can only turn a remove-plus-add pair into
39
+ one change.
40
+ A score decodes both sides with one instruction set, so the pass first requires
41
+ both sides to target the same architecture: when the loader identified two
42
+ different ones (``BinaryInfo.architecture``, the token it folds ELF ``e_machine``
43
+ and PE ``Machine`` into), nothing is decoded or scored and a warning names both,
44
+ because shapes from different instruction sets are not comparable. Two identical
45
+ tokens behave exactly as described above, and an architecture the loader could
46
+ not identify (``"unknown"``) never triggers the guard: it means "not determined",
47
+ not "a different target".
48
+ Because every score decodes both regions, the pass is bounded twice: at most
49
+ :data:`MAX_SEMANTIC_PAIRS` candidate pairs are scored -- the pass is skipped,
50
+ with a warning, when the leftover cross product is larger -- and one score
51
+ decodes at most :data:`SEMANTIC_MAX_INSTRUCTIONS` instructions per side. A score
52
+ is a *shape* overlap, never proof that two functions are the same code.
53
+
54
+ Matching rules, in order:
55
+
56
+ 1. **By address.** Candidates that share a virtual address are the same
57
+ function; if their metadata (name, size) differs the entry is ``changed``.
58
+ Discovery provenance (``source``/``confidence``) is *not* compared, because
59
+ it describes how a candidate was found, not the binary itself.
60
+ 2. **By name.** Candidates left over on both sides are matched by name, so a
61
+ function that was relocated is reported as one ``changed`` entry (with the
62
+ old and new address) instead of one removal plus one addition.
63
+ 3. **By content similarity** (only with ``semantic=True``, and only when both
64
+ sides report the same identified architecture). The candidates still
65
+ left over on both sides are paired greedily, highest score first, while the
66
+ score is at least ``threshold`` and neither side has been used yet. Each pair
67
+ is reported as one ``changed`` entry carrying ``similarity``.
68
+ 4. **Everything left over** on the left is ``removed`` and on the right is
69
+ ``added``.
70
+
71
+ Sections are compared by name, imports by ``(library, name)`` and exports by
72
+ name. Consequently a section that changed size/flags at the same name, or an
73
+ export that kept its name but moved, is not a delta.
74
+
75
+ ``identical`` therefore means "no structural delta was observed". It does not
76
+ imply equal bytes: two different files that expose the same metadata compare as
77
+ identical. Sorting and truncation are deterministic (``max_functions``) so the
78
+ same inputs always produce the same output.
79
+ """
80
+
81
+ from __future__ import annotations
82
+
83
+ import math
84
+ from collections import Counter
85
+ from collections.abc import Mapping, Sequence
86
+ from dataclasses import dataclass
87
+ from pathlib import Path
88
+ from typing import Any, Literal
89
+
90
+ from codevanta.analysis.function_hash import (
91
+ DEFAULT_MAX_INSTRUCTIONS,
92
+ function_fingerprint,
93
+ normalized_tokens,
94
+ )
95
+ from codevanta.core.functions import FunctionCandidate, discover_functions
96
+ from codevanta.core.loader import BinaryInfo, BinaryLoader
97
+ from codevanta.core.report import AnalysisError
98
+ from codevanta.evidence.confidence import Confidence
99
+ from codevanta.evidence.models import Evidence
100
+
101
+ __all__ = [
102
+ "DEFAULT_MAX_FUNCTIONS",
103
+ "DEFAULT_SEMANTIC_THRESHOLD",
104
+ "DEEP_MAX_INSTRUCTIONS",
105
+ "MAX_DEEP_FINGERPRINTS",
106
+ "MAX_MAX_FUNCTIONS",
107
+ "MAX_SEMANTIC_PAIRS",
108
+ "MAX_SEMANTIC_THRESHOLD",
109
+ "MIN_MAX_FUNCTIONS",
110
+ "MIN_SEMANTIC_THRESHOLD",
111
+ "SEMANTIC_MAX_INSTRUCTIONS",
112
+ "BinaryDiff",
113
+ "FunctionDelta",
114
+ "diff_binaries",
115
+ "diff_infos",
116
+ ]
117
+
118
+ #: Default ceiling on how many function candidates per side are compared.
119
+ DEFAULT_MAX_FUNCTIONS = 5_000
120
+
121
+ #: Smallest accepted ``max_functions``; smaller requests are clamped up.
122
+ MIN_MAX_FUNCTIONS = 1
123
+
124
+ #: Largest accepted ``max_functions``; larger requests are clamped down.
125
+ MAX_MAX_FUNCTIONS = 20_000
126
+
127
+ #: Largest number of address-matched pairs fingerprinted per side in deep mode.
128
+ #: Deep mode decodes real instructions, so it needs its own, tighter bound than
129
+ #: :data:`DEFAULT_MAX_FUNCTIONS`; pairs above the ceiling keep their structural
130
+ #: verdict and are reported through ``warnings``.
131
+ MAX_DEEP_FINGERPRINTS = 1_000
132
+
133
+ #: Instruction ceiling for one deep-mode fingerprint. Equal to
134
+ #: :data:`~codevanta.analysis.function_hash.DEFAULT_MAX_INSTRUCTIONS`, so a
135
+ #: fingerprint taken during a diff matches one taken on its own.
136
+ DEEP_MAX_INSTRUCTIONS = DEFAULT_MAX_INSTRUCTIONS
137
+
138
+ #: Default minimum shape-similarity score for semantic pairing.
139
+ DEFAULT_SEMANTIC_THRESHOLD = 0.9
140
+
141
+ #: Smallest accepted ``threshold``; anything lower is rejected.
142
+ MIN_SEMANTIC_THRESHOLD = 0.0
143
+
144
+ #: Largest accepted ``threshold``; anything higher is rejected.
145
+ MAX_SEMANTIC_THRESHOLD = 1.0
146
+
147
+ #: Largest number of leftover function pairs scored in semantic mode. Scoring a
148
+ #: pair is scored from tokens decoded once per leftover, so the pass needs a bound
149
+ #: of its own: when the leftover cross product is larger, semantic pairing is
150
+ #: skipped (with a warning)
151
+ #: and every leftover keeps its structural ``added``/``removed`` verdict.
152
+ MAX_SEMANTIC_PAIRS = 4_096
153
+
154
+ #: Instruction ceiling per side for one semantic similarity score. Equal to
155
+ #: :data:`~codevanta.analysis.function_hash.DEFAULT_MAX_INSTRUCTIONS`, so a score
156
+ #: taken during a diff matches one taken on its own.
157
+ SEMANTIC_MAX_INSTRUCTIONS = DEFAULT_MAX_INSTRUCTIONS
158
+
159
+ #: Decimals kept in :attr:`FunctionDelta.similarity`, matching the rounding
160
+ #: ``SimilarityResult.to_dict()`` applies, so the field, the evidence note and the
161
+ #: JSON report cannot disagree about a score.
162
+ _SIMILARITY_DECIMALS = 4
163
+
164
+ #: ``"added"`` and ``"removed"`` are one-sided; ``"changed"`` covers a metadata
165
+ #: edit at the same address, a differing content fingerprint at the same address
166
+ #: (deep mode), a relocation matched by name, and a pair matched by shape
167
+ #: similarity (semantic mode).
168
+ ChangeKind = Literal["added", "removed", "changed"]
169
+
170
+ _EVIDENCE_SOURCE = "binary_diff.structural"
171
+ _EVIDENCE_METHOD = "metadata_compare"
172
+ _CAVEAT = (
173
+ "structural metadata only: no instruction bytes were read, compared or executed, "
174
+ "so this is not a semantic equivalence claim"
175
+ )
176
+ _CONTENT_CAVEAT = (
177
+ "structural metadata plus normalized instruction shapes: numeric literals were masked to "
178
+ "'#', so equal digests are not byte equality and different digests are not a semantic "
179
+ "difference; nothing was executed"
180
+ )
181
+ _SIMILARITY_CAVEAT = (
182
+ "structural metadata plus normalized instruction-shape similarity: numeric literals were "
183
+ "masked to '#', so a pairing is a shape overlap of decoded instructions and not proof that "
184
+ "two functions are the same code; nothing was executed"
185
+ )
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class FunctionDelta:
190
+ """One function-level difference between the two binaries.
191
+
192
+ Attributes:
193
+ address: Virtual address the delta is reported at. The right-hand
194
+ address when the candidate exists on the right, otherwise the
195
+ left-hand address.
196
+ name: Best-known name, taken from the right-hand side when it has one.
197
+ change: ``"added"``, ``"removed"`` or ``"changed"``. A function matched
198
+ by name after moving is reported as ``"changed"``.
199
+ left: Metadata snapshot of the left-hand candidate, or ``None`` for an
200
+ ``"added"`` entry.
201
+ right: Metadata snapshot of the right-hand candidate, or ``None`` for a
202
+ ``"removed"`` entry.
203
+ evidence: Why this delta is claimed, including the applicable caveat and
204
+ the specific reason (metadata edit, differing fingerprint, or
205
+ relocation).
206
+ left_digest: Normalized-content fingerprint of the left-hand function,
207
+ set only by deep mode and only when the pair was fingerprinted.
208
+ right_digest: Same for the right-hand function. ``left_digest`` and
209
+ ``right_digest`` are always both set or both ``None``.
210
+ similarity: Shape-similarity score (``0.0 .. 1.0``) of the pair, set only
211
+ by semantic mode and only for a pair that was matched by that score.
212
+ Rounded to four decimals; ``None`` on every structurally matched
213
+ delta, including a name-matched relocation, because those were not
214
+ scored.
215
+ """
216
+
217
+ address: int
218
+ name: str | None
219
+ change: ChangeKind
220
+ left: dict[str, Any] | None
221
+ right: dict[str, Any] | None
222
+ evidence: Evidence
223
+ left_digest: str | None = None
224
+ right_digest: str | None = None
225
+ similarity: float | None = None
226
+
227
+ def to_dict(self) -> dict[str, Any]:
228
+ data: dict[str, Any] = {"address": f"0x{self.address:x}", "change": self.change}
229
+ if self.name:
230
+ data["name"] = self.name
231
+ if self.left is not None:
232
+ data["left"] = dict(self.left)
233
+ if self.right is not None:
234
+ data["right"] = dict(self.right)
235
+ if self.left_digest is not None:
236
+ data["left_digest"] = self.left_digest
237
+ if self.right_digest is not None:
238
+ data["right_digest"] = self.right_digest
239
+ if self.similarity is not None:
240
+ data["similarity"] = self.similarity
241
+ data["evidence"] = self.evidence.to_dict()
242
+ return data
243
+
244
+
245
+ @dataclass(frozen=True)
246
+ class BinaryDiff:
247
+ """Structural difference between two binaries.
248
+
249
+ Attributes:
250
+ left_path: Path of the left-hand input as loaded.
251
+ right_path: Path of the right-hand input as loaded.
252
+ identical: ``True`` only when no function, section, import or export
253
+ delta was observed. Equal file hashes are neither required nor
254
+ implied.
255
+ functions_added: Candidates present only on the right.
256
+ functions_removed: Candidates present only on the left.
257
+ functions_changed: Candidates matched by address with different metadata
258
+ or -- in deep mode -- a different normalized content fingerprint,
259
+ matched by name after moving, or -- in semantic mode -- paired by
260
+ instruction-shape similarity.
261
+ sections_added: Section names present only on the right.
262
+ sections_removed: Section names present only on the left.
263
+ imports_added: Imports present only on the right, as ``"lib!name"`` when
264
+ a library is known and ``"name"`` otherwise.
265
+ imports_removed: Imports present only on the left, same formatting.
266
+ exports_added: Export names present only on the right.
267
+ exports_removed: Export names present only on the left.
268
+ evidence: Evidence describing the comparison as a whole, with the
269
+ applicable caveat (structural, or structural plus normalized
270
+ content in deep mode, or structural plus shape similarity in semantic
271
+ mode), the effective ``max_functions`` bound, and the deep-mode or
272
+ semantic-mode bounds when that mode ran.
273
+ warnings: Non-fatal problems, including truncation notices and a semantic
274
+ pass that was skipped (architecture mismatch or pair cap).
275
+ """
276
+
277
+ left_path: str
278
+ right_path: str
279
+ identical: bool
280
+ functions_added: tuple[FunctionDelta, ...]
281
+ functions_removed: tuple[FunctionDelta, ...]
282
+ functions_changed: tuple[FunctionDelta, ...]
283
+ sections_added: tuple[str, ...]
284
+ sections_removed: tuple[str, ...]
285
+ imports_added: tuple[str, ...]
286
+ imports_removed: tuple[str, ...]
287
+ exports_added: tuple[str, ...]
288
+ exports_removed: tuple[str, ...]
289
+ evidence: Evidence
290
+ warnings: tuple[str, ...] = ()
291
+
292
+ def to_dict(self) -> dict[str, Any]:
293
+ return {
294
+ "left_path": self.left_path,
295
+ "right_path": self.right_path,
296
+ "identical": self.identical,
297
+ "functions_added": [delta.to_dict() for delta in self.functions_added],
298
+ "functions_removed": [delta.to_dict() for delta in self.functions_removed],
299
+ "functions_changed": [delta.to_dict() for delta in self.functions_changed],
300
+ "sections_added": list(self.sections_added),
301
+ "sections_removed": list(self.sections_removed),
302
+ "imports_added": list(self.imports_added),
303
+ "imports_removed": list(self.imports_removed),
304
+ "exports_added": list(self.exports_added),
305
+ "exports_removed": list(self.exports_removed),
306
+ "evidence": self.evidence.to_dict(),
307
+ "warnings": list(self.warnings),
308
+ }
309
+
310
+
311
+ def _sort_key(candidate: FunctionCandidate) -> tuple[int, str]:
312
+ """Return a stable ordering key for a function candidate."""
313
+ return (candidate.address, candidate.name or "")
314
+
315
+
316
+ def _snapshot(candidate: FunctionCandidate | None) -> dict[str, Any] | None:
317
+ """Return a JSON-safe metadata snapshot of ``candidate``."""
318
+ if candidate is None:
319
+ return None
320
+ data: dict[str, Any] = {
321
+ "address": f"0x{candidate.address:x}",
322
+ "source": candidate.source,
323
+ "confidence": candidate.confidence.value,
324
+ }
325
+ if candidate.name:
326
+ data["name"] = candidate.name
327
+ if candidate.size is not None:
328
+ data["size"] = candidate.size
329
+ return data
330
+
331
+
332
+ def _metadata_differences(left: FunctionCandidate, right: FunctionCandidate) -> tuple[str, ...]:
333
+ """Return the metadata field names that differ between two candidates.
334
+
335
+ Only fields describing the function itself are compared. ``source``,
336
+ ``confidence`` and ``evidence`` describe how the candidate was discovered
337
+ and may legitimately differ between two loads of the same file.
338
+ """
339
+ differences: list[str] = []
340
+ if (left.name or None) != (right.name or None):
341
+ differences.append("name")
342
+ if left.size != right.size:
343
+ differences.append("size")
344
+ return tuple(differences)
345
+
346
+
347
+ def _clamp_limit(requested: int, warnings: list[str]) -> int:
348
+ """Clamp ``requested`` into ``[MIN_MAX_FUNCTIONS, MAX_MAX_FUNCTIONS]``.
349
+
350
+ Args:
351
+ requested: Caller-supplied ``max_functions`` value.
352
+ warnings: Warning sink; every adjustment is recorded here.
353
+
354
+ Returns:
355
+ The effective comparison limit.
356
+ """
357
+ if isinstance(requested, bool) or not isinstance(requested, int):
358
+ warnings.append(
359
+ f"max_functions={requested!r} is not an integer; using {DEFAULT_MAX_FUNCTIONS}"
360
+ )
361
+ return DEFAULT_MAX_FUNCTIONS
362
+ if requested < MIN_MAX_FUNCTIONS:
363
+ warnings.append(
364
+ f"max_functions={requested} is below the supported minimum; "
365
+ f"clamped to {MIN_MAX_FUNCTIONS}"
366
+ )
367
+ return MIN_MAX_FUNCTIONS
368
+ if requested > MAX_MAX_FUNCTIONS:
369
+ warnings.append(
370
+ f"max_functions={requested} exceeds the supported maximum; "
371
+ f"clamped to {MAX_MAX_FUNCTIONS}"
372
+ )
373
+ return MAX_MAX_FUNCTIONS
374
+ return requested
375
+
376
+
377
+ def _validate_threshold(threshold: float) -> float:
378
+ """Return ``threshold`` as a float, rejecting values outside ``0.0 .. 1.0``.
379
+
380
+ The threshold is validated in every mode, not only with ``semantic=True``, so
381
+ a caller never gets a silently ignored out-of-range value that would behave
382
+ differently the moment semantic matching is switched on.
383
+
384
+ Args:
385
+ threshold: Smallest similarity score that may pair two functions.
386
+
387
+ Returns:
388
+ The threshold as a ``float``.
389
+
390
+ Raises:
391
+ AnalysisError: When ``threshold`` is not a finite number in
392
+ ``MIN_SEMANTIC_THRESHOLD .. MAX_SEMANTIC_THRESHOLD``.
393
+ """
394
+ if isinstance(threshold, bool) or not isinstance(threshold, (int, float)):
395
+ raise AnalysisError(f"threshold must be a number, got {threshold!r}")
396
+ value = float(threshold)
397
+ if not math.isfinite(value) or not (MIN_SEMANTIC_THRESHOLD <= value <= MAX_SEMANTIC_THRESHOLD):
398
+ raise AnalysisError(
399
+ f"threshold must be within {MIN_SEMANTIC_THRESHOLD} .. "
400
+ f"{MAX_SEMANTIC_THRESHOLD}, got {threshold!r}"
401
+ )
402
+ return value
403
+
404
+
405
+ def _bounded(
406
+ functions: Sequence[FunctionCandidate],
407
+ limit: int,
408
+ side: str,
409
+ warnings: list[str],
410
+ ) -> list[FunctionCandidate]:
411
+ """Return ``functions`` sorted by address, truncated to ``limit`` entries.
412
+
413
+ Truncation keeps the lowest addresses so the result stays deterministic and
414
+ records a warning: the added/removed lists may then be incomplete.
415
+ """
416
+ ordered = sorted(functions, key=_sort_key)
417
+ if len(ordered) <= limit:
418
+ return ordered
419
+ warnings.append(
420
+ f"{side} side has {len(ordered)} function candidates, more than "
421
+ f"max_functions={limit}; only the lowest addresses were compared, so "
422
+ "added/removed lists may be incomplete"
423
+ )
424
+ return ordered[:limit]
425
+
426
+
427
+ def _first_by_address(functions: Sequence[FunctionCandidate]) -> dict[int, FunctionCandidate]:
428
+ """Index candidates by address, keeping the first one per address.
429
+
430
+ Candidates are expected to be unique per address (as produced by
431
+ :func:`~codevanta.core.functions.discover_functions`); if a duplicate slips
432
+ in, the lowest sorted occurrence wins so the output stays deterministic.
433
+ """
434
+ by_address: dict[int, FunctionCandidate] = {}
435
+ for candidate in sorted(functions, key=_sort_key):
436
+ by_address.setdefault(candidate.address, candidate)
437
+ return by_address
438
+
439
+
440
+ def _group_by_name(candidates: Sequence[FunctionCandidate]) -> dict[str, list[FunctionCandidate]]:
441
+ """Group candidates by name, dropping unnamed ones and preserving order."""
442
+ groups: dict[str, list[FunctionCandidate]] = {}
443
+ for candidate in candidates:
444
+ if not candidate.name:
445
+ continue
446
+ groups.setdefault(candidate.name, []).append(candidate)
447
+ return groups
448
+
449
+
450
+ def _delta(
451
+ change: ChangeKind,
452
+ address: int,
453
+ left: FunctionCandidate | None,
454
+ right: FunctionCandidate | None,
455
+ detail: str,
456
+ *,
457
+ caveat: str = _CAVEAT,
458
+ left_digest: str | None = None,
459
+ right_digest: str | None = None,
460
+ similarity: float | None = None,
461
+ ) -> FunctionDelta:
462
+ """Assemble one :class:`FunctionDelta` with its caveated evidence record."""
463
+ primary = right if right is not None else left
464
+ return FunctionDelta(
465
+ address=address,
466
+ name=primary.name if primary is not None else None,
467
+ change=change,
468
+ left=_snapshot(left),
469
+ right=_snapshot(right),
470
+ evidence=Evidence(
471
+ source=_EVIDENCE_SOURCE,
472
+ method=_EVIDENCE_METHOD,
473
+ confidence=Confidence.MEDIUM,
474
+ address=address,
475
+ detail=f"{caveat}; {detail}",
476
+ ),
477
+ left_digest=left_digest,
478
+ right_digest=right_digest,
479
+ similarity=similarity,
480
+ )
481
+
482
+
483
+ def _address_digests(
484
+ info: BinaryInfo,
485
+ candidates: Sequence[FunctionCandidate],
486
+ addresses: set[int],
487
+ side: str,
488
+ warnings: list[str],
489
+ ) -> dict[int, str]:
490
+ """Fingerprint ``candidates`` whose address is in ``addresses``.
491
+
492
+ Bounded by construction: ``candidates`` is already truncated to
493
+ ``max_functions`` and ``addresses`` is capped by the caller. Every failure is
494
+ counted rather than raised, so one unreadable function never loses the whole
495
+ comparison; the caller keeps the structural verdict for it.
496
+
497
+ Args:
498
+ info: Metadata of the side being fingerprinted.
499
+ candidates: Bounded candidates of that side.
500
+ addresses: Addresses that were matched on both sides.
501
+ side: ``"left"`` or ``"right"``, used in warnings.
502
+ warnings: Warning sink for truncation and failure summaries.
503
+
504
+ Returns:
505
+ Mapping of address to normalized-content digest, covering the candidates
506
+ that could be fingerprinted.
507
+ """
508
+ digests: dict[int, str] = {}
509
+ truncated = 0
510
+ first_error: str | None = None
511
+ failed = 0
512
+ for candidate in candidates:
513
+ if candidate.address not in addresses:
514
+ continue
515
+ try:
516
+ fingerprint = function_fingerprint(
517
+ info.path,
518
+ info,
519
+ candidate.address,
520
+ max_instructions=DEEP_MAX_INSTRUCTIONS,
521
+ )
522
+ except (AnalysisError, OSError) as exc:
523
+ failed += 1
524
+ if first_error is None:
525
+ first_error = str(exc)
526
+ continue
527
+ digests[candidate.address] = fingerprint.digest
528
+ if fingerprint.truncated:
529
+ truncated += 1
530
+ if truncated:
531
+ warnings.append(
532
+ f"{side} side: {truncated} fingerprint(s) stopped at "
533
+ f"DEEP_MAX_INSTRUCTIONS={DEEP_MAX_INSTRUCTIONS}; the content comparison covers "
534
+ "only the decoded prefix of those functions"
535
+ )
536
+ if failed:
537
+ warnings.append(
538
+ f"{side} side: {failed} function(s) could not be fingerprinted and were "
539
+ f"compared structurally only (first error: {first_error})"
540
+ )
541
+ return digests
542
+
543
+
544
+ #: Value :class:`~codevanta.core.loader.BinaryLoader` reports when it could not
545
+ #: map the file's machine type onto an architecture it knows. It means "not
546
+ #: determined", so it never counts as a *different* target architecture.
547
+ _UNKNOWN_ARCHITECTURE = "unknown"
548
+
549
+
550
+ def _comparable_architecture(info: BinaryInfo) -> str:
551
+ """Return the architecture of ``info`` in a form two sides can be compared by.
552
+
553
+ :attr:`~codevanta.core.loader.BinaryInfo.architecture` is the field for this
554
+ job: the loader folds the ELF ``e_machine`` and the PE ``Machine`` header into
555
+ the same small set of canonical tokens (``"x86"``, ``"x86_64"``, ``"arm"``,
556
+ ``"aarch64"``, ``"ia64"``, ``"mips"``), so two sides target the same
557
+ instruction set exactly when those tokens match. Case and surrounding
558
+ whitespace are folded too, so metadata assembled by hand compares by token as
559
+ well.
560
+
561
+ An unidentified architecture is reported as ``""``: the loader's ``"unknown"``
562
+ placeholder says "not determined", not "a different target", so it must never
563
+ be the reason a pairing pass is refused. An empty result therefore means "no
564
+ architecture claim" and is compatible with any other value.
565
+
566
+ Args:
567
+ info: Metadata of one side of the comparison.
568
+
569
+ Returns:
570
+ The lower-cased architecture token, or ``""`` when it is unknown.
571
+ """
572
+ architecture = str(info.architecture or "").strip().lower()
573
+ if not architecture or architecture == _UNKNOWN_ARCHITECTURE:
574
+ return ""
575
+ return architecture
576
+
577
+
578
+ @dataclass(frozen=True)
579
+ class _SemanticRequest:
580
+ """Inputs needed to pair leftover functions by shape similarity.
581
+
582
+ Attributes:
583
+ left_info: Metadata of the left-hand binary; its ``path`` is read for
584
+ the left-hand scores and its ``architecture`` gates the pass
585
+ (read-only, never executed).
586
+ right_info: Metadata of the right-hand binary, same contract.
587
+ threshold: Smallest score that may pair two candidates.
588
+ warnings: Warning sink shared with the enclosing diff, so every bound
589
+ that cuts the pass short reaches the caller.
590
+ """
591
+
592
+ left_info: BinaryInfo
593
+ right_info: BinaryInfo
594
+ threshold: float
595
+ warnings: list[str]
596
+
597
+
598
+ def _semantic_pairs(
599
+ left_leftovers: Sequence[FunctionCandidate],
600
+ right_leftovers: Sequence[FunctionCandidate],
601
+ request: _SemanticRequest,
602
+ ) -> list[tuple[FunctionCandidate, FunctionCandidate, float]]:
603
+ """Pair leftover candidates greedily by normalized instruction similarity.
604
+
605
+ Each leftover is decoded once with
606
+ :func:`~codevanta.analysis.function_hash.normalized_tokens` and its token
607
+ multiset is kept, so scoring the cross product costs one multiset overlap per
608
+ pair instead of a fresh decode per pair. Pairs are then considered from the
609
+ highest score down and taken while the score rounds to at least the threshold
610
+ and neither candidate has been paired already. A greedy pass keeps the result
611
+ deterministic and mutually exclusive: one candidate never appears in two
612
+ deltas.
613
+
614
+ Work is bounded before it starts: the pass refuses to run when the two sides
615
+ target different, identified architectures (see
616
+ :func:`_comparable_architecture`) -- one instruction set cannot describe the
617
+ other's shapes -- and when the leftover cross product does not fit in
618
+ :data:`MAX_SEMANTIC_PAIRS`; either way nothing is decoded or scored, and a
619
+ single warning explains the skip. Each decode is bounded by
620
+ :data:`SEMANTIC_MAX_INSTRUCTIONS` instructions.
621
+ A leftover that cannot be decoded (unreadable file, address outside an
622
+ executable section, undecodable architecture) is skipped rather than raised,
623
+ so one bad candidate never loses the whole comparison.
624
+
625
+ Args:
626
+ left_leftovers: Left-hand candidates no structural rule matched.
627
+ right_leftovers: Right-hand candidates no structural rule matched.
628
+ request: Bounds, metadata and warning sink for the pass.
629
+
630
+ Returns:
631
+ ``(left, right, score)`` triples in descending score order, each pair
632
+ reported at the right-hand candidate's address by the caller. ``score`` is
633
+ rounded to ``_SIMILARITY_DECIMALS`` decimals and the threshold is compared
634
+ at the same precision, so a reported score never falls below the
635
+ threshold that authorised it; the ordering uses the unrounded value. A
636
+ pair is never accepted at a rounded score of ``0.0``, so leftovers that
637
+ share no token stay ``added``/``removed`` even with ``threshold=0.0``.
638
+ """
639
+ if not left_leftovers or not right_leftovers:
640
+ return []
641
+ # Architecture gate first: a cross-architecture score would compare shapes
642
+ # from two instruction sets, so it is noise dressed up as a likeness. Checking
643
+ # it before anything else also keeps the report to one explanation -- the
644
+ # warning below -- instead of stacking a pair-cap notice on top of it.
645
+ left_architecture = _comparable_architecture(request.left_info)
646
+ right_architecture = _comparable_architecture(request.right_info)
647
+ if (
648
+ left_architecture
649
+ and right_architecture
650
+ and left_architecture != right_architecture
651
+ ):
652
+ request.warnings.append(
653
+ "semantic matching skipped: the two sides target different architectures "
654
+ f"(left {left_architecture}, right {right_architecture}), so their instruction "
655
+ "shapes are not comparable; every leftover keeps its added/removed verdict"
656
+ )
657
+ return []
658
+ left_sorted = sorted(left_leftovers, key=_sort_key)
659
+ right_sorted = sorted(right_leftovers, key=_sort_key)
660
+ total_pairs = len(left_sorted) * len(right_sorted)
661
+ if total_pairs > MAX_SEMANTIC_PAIRS:
662
+ request.warnings.append(
663
+ f"semantic matching skipped: {len(left_sorted)} left and {len(right_sorted)} "
664
+ f"right leftover function(s) need {total_pairs} pair comparison(s), more than "
665
+ f"MAX_SEMANTIC_PAIRS={MAX_SEMANTIC_PAIRS}; every leftover keeps its "
666
+ "added/removed verdict"
667
+ )
668
+ return []
669
+
670
+ left_counts: dict[int, Counter[str]] = {}
671
+ right_counts: dict[int, Counter[str]] = {}
672
+ failed = 0
673
+ first_error: str | None = None
674
+ for candidate in left_sorted:
675
+ try:
676
+ left_counts[candidate.address] = Counter(
677
+ normalized_tokens(
678
+ request.left_info.path,
679
+ request.left_info,
680
+ candidate.address,
681
+ SEMANTIC_MAX_INSTRUCTIONS,
682
+ )
683
+ )
684
+ except (AnalysisError, OSError) as exc:
685
+ failed += 1
686
+ if first_error is None:
687
+ first_error = str(exc)
688
+ for candidate in right_sorted:
689
+ try:
690
+ right_counts[candidate.address] = Counter(
691
+ normalized_tokens(
692
+ request.right_info.path,
693
+ request.right_info,
694
+ candidate.address,
695
+ SEMANTIC_MAX_INSTRUCTIONS,
696
+ )
697
+ )
698
+ except (AnalysisError, OSError) as exc:
699
+ failed += 1
700
+ if first_error is None:
701
+ first_error = str(exc)
702
+
703
+ if failed:
704
+ request.warnings.append(
705
+ f"semantic matching: {failed} function(s) could not be scored and were left "
706
+ f"unmatched (first error: {first_error})"
707
+ )
708
+
709
+ # One decode per leftover: the cross product only overlaps the kept multisets,
710
+ # so the pass is quadratic in comparisons but linear in decoding.
711
+ truncated_sides = sum(
712
+ 1
713
+ for counts in (*left_counts.values(), *right_counts.values())
714
+ if sum(counts.values()) >= SEMANTIC_MAX_INSTRUCTIONS
715
+ )
716
+ if truncated_sides:
717
+ request.warnings.append(
718
+ f"semantic matching: {truncated_sides} function(s) were decoded only up to "
719
+ f"SEMANTIC_MAX_INSTRUCTIONS={SEMANTIC_MAX_INSTRUCTIONS} instructions, so their "
720
+ "scores compare prefixes"
721
+ )
722
+
723
+ scored: list[tuple[float, FunctionCandidate, FunctionCandidate]] = []
724
+ for left_candidate in left_sorted:
725
+ left_count = left_counts.get(left_candidate.address)
726
+ if left_count is None:
727
+ continue
728
+ for right_candidate in right_sorted:
729
+ right_count = right_counts.get(right_candidate.address)
730
+ if right_count is None:
731
+ continue
732
+ union = sum((left_count | right_count).values())
733
+ if not union:
734
+ # Defensive: a decoded region always carries tokens, but a union of
735
+ # zero would turn every score into 0/0.
736
+ continue
737
+ shared = sum((left_count & right_count).values())
738
+ scored.append((shared / union, left_candidate, right_candidate))
739
+
740
+ scored.sort(key=lambda item: (-item[0], item[1].address, item[2].address))
741
+ rounded_threshold = round(request.threshold, _SIMILARITY_DECIMALS)
742
+ used_left: set[int] = set()
743
+ used_right: set[int] = set()
744
+ pairs: list[tuple[FunctionCandidate, FunctionCandidate, float]] = []
745
+ for score, left_candidate, right_candidate in scored:
746
+ rounded_score = round(score, _SIMILARITY_DECIMALS)
747
+ if rounded_score <= 0.0 or rounded_score < rounded_threshold:
748
+ # Scores are sorted descending, so no later pair can qualify either.
749
+ # A rounded zero means the pair shares no token: pairing it would claim
750
+ # a likeness the score denies.
751
+ break
752
+ if left_candidate.address in used_left or right_candidate.address in used_right:
753
+ continue
754
+ used_left.add(left_candidate.address)
755
+ used_right.add(right_candidate.address)
756
+ pairs.append((left_candidate, right_candidate, rounded_score))
757
+ return pairs
758
+
759
+
760
+ def _diff_functions(
761
+ left: Sequence[FunctionCandidate],
762
+ right: Sequence[FunctionCandidate],
763
+ left_digests: Mapping[int, str] | None = None,
764
+ right_digests: Mapping[int, str] | None = None,
765
+ semantic: _SemanticRequest | None = None,
766
+ ) -> tuple[tuple[FunctionDelta, ...], tuple[FunctionDelta, ...], tuple[FunctionDelta, ...]]:
767
+ """Diff two bounded candidate lists.
768
+
769
+ Args:
770
+ left: Bounded, sorted left-hand candidates.
771
+ right: Bounded, sorted right-hand candidates.
772
+ left_digests: Optional address-to-digest map for the left side (deep
773
+ mode). Addresses missing from the map keep their structural verdict.
774
+ right_digests: Same for the right side.
775
+ semantic: Optional request enabling the similarity pass over candidates
776
+ that address and name matching left unpaired. ``None`` keeps the
777
+ structural verdict for those candidates.
778
+
779
+ Returns:
780
+ ``(added, removed, changed)`` tuples, each sorted by address.
781
+ """
782
+ left_by_address = _first_by_address(left)
783
+ right_by_address = _first_by_address(right)
784
+ left_digests = left_digests or {}
785
+ right_digests = right_digests or {}
786
+
787
+ added: list[FunctionDelta] = []
788
+ removed: list[FunctionDelta] = []
789
+ changed: list[FunctionDelta] = []
790
+
791
+ # Pass 1: same address means the same function; compare its metadata and, in
792
+ # deep mode, its normalized content.
793
+ for address in sorted(set(left_by_address) & set(right_by_address)):
794
+ left_candidate = left_by_address[address]
795
+ right_candidate = right_by_address[address]
796
+ differences = _metadata_differences(left_candidate, right_candidate)
797
+ left_digest = left_digests.get(address)
798
+ right_digest = right_digests.get(address)
799
+ if left_digest is not None and right_digest is not None:
800
+ delta_left_digest, delta_right_digest = left_digest, right_digest
801
+ else:
802
+ # Attach a digest pair only when both sides were fingerprinted, so a
803
+ # delta never carries a misleading half pair.
804
+ delta_left_digest, delta_right_digest = None, None
805
+ content_changed = (
806
+ delta_left_digest is not None
807
+ and delta_right_digest is not None
808
+ and delta_left_digest != delta_right_digest
809
+ )
810
+ if not differences and not content_changed:
811
+ continue
812
+
813
+ notes: list[str] = []
814
+ if differences:
815
+ notes.append(
816
+ f"metadata changed at the same address 0x{address:x}: {', '.join(differences)}"
817
+ )
818
+ if content_changed:
819
+ notes.append(
820
+ f"content changed at 0x{address:x}: normalized fingerprints differ "
821
+ f"(left {left_digest}, right {right_digest})"
822
+ )
823
+ elif differences and left_digest is not None and right_digest is not None:
824
+ notes.append(
825
+ "normalized fingerprints match, so this is a metadata-only change"
826
+ )
827
+ changed.append(
828
+ _delta(
829
+ "changed",
830
+ address,
831
+ left_candidate,
832
+ right_candidate,
833
+ "; ".join(notes),
834
+ caveat=_CONTENT_CAVEAT if delta_left_digest is not None else _CAVEAT,
835
+ left_digest=delta_left_digest,
836
+ right_digest=delta_right_digest,
837
+ )
838
+ )
839
+
840
+ # Pass 2: pair up the leftovers by name so a move is one change, not two.
841
+ left_unmatched = [
842
+ candidate
843
+ for address, candidate in left_by_address.items()
844
+ if address not in right_by_address
845
+ ]
846
+ right_unmatched = [
847
+ candidate
848
+ for address, candidate in right_by_address.items()
849
+ if address not in left_by_address
850
+ ]
851
+ right_by_name = _group_by_name(sorted(right_unmatched, key=_sort_key))
852
+
853
+ still_left: list[FunctionCandidate] = []
854
+ matched_right: set[int] = set()
855
+ for candidate in sorted(left_unmatched, key=_sort_key):
856
+ queue = right_by_name.get(candidate.name) if candidate.name else None
857
+ if not queue:
858
+ still_left.append(candidate)
859
+ continue
860
+ partner = queue.pop(0)
861
+ matched_right.add(partner.address)
862
+ changed.append(
863
+ _delta(
864
+ "changed",
865
+ partner.address,
866
+ candidate,
867
+ partner,
868
+ f"matched by name {candidate.name!r} but moved from "
869
+ f"0x{candidate.address:x} to 0x{partner.address:x}",
870
+ )
871
+ )
872
+
873
+ # Pass 3 (semantic mode only): pair what is left by shape similarity. Only
874
+ # candidates no structural rule matched are eligible, so the pass can turn a
875
+ # remove-plus-add pair into one change but never overrides an address or name
876
+ # match.
877
+ if semantic is not None:
878
+ right_leftover = [
879
+ candidate
880
+ for candidate in sorted(right_unmatched, key=_sort_key)
881
+ if candidate.address not in matched_right
882
+ ]
883
+ pairs = _semantic_pairs(still_left, right_leftover, semantic)
884
+ if pairs:
885
+ paired_left = {candidate.address for candidate, _partner, _score in pairs}
886
+ matched_right.update(partner.address for _candidate, partner, _score in pairs)
887
+ still_left = [
888
+ candidate for candidate in still_left if candidate.address not in paired_left
889
+ ]
890
+ for left_candidate, right_candidate, score in pairs:
891
+ changed.append(
892
+ _delta(
893
+ "changed",
894
+ right_candidate.address,
895
+ left_candidate,
896
+ right_candidate,
897
+ f"paired by instruction-shape similarity {score:.4f} >= "
898
+ f"threshold {semantic.threshold:.4f}, moving from "
899
+ f"0x{left_candidate.address:x} to 0x{right_candidate.address:x}; "
900
+ "the score compares normalized shapes, it is not proof that the two "
901
+ "functions are the same code",
902
+ caveat=_SIMILARITY_CAVEAT,
903
+ similarity=score,
904
+ )
905
+ )
906
+
907
+ # Pass 4: whatever is left is one-sided.
908
+ for candidate in still_left:
909
+ removed.append(
910
+ _delta(
911
+ "removed",
912
+ candidate.address,
913
+ candidate,
914
+ None,
915
+ f"present only in the left binary at 0x{candidate.address:x}",
916
+ )
917
+ )
918
+ for candidate in sorted(right_unmatched, key=_sort_key):
919
+ if candidate.address in matched_right:
920
+ continue
921
+ added.append(
922
+ _delta(
923
+ "added",
924
+ candidate.address,
925
+ None,
926
+ candidate,
927
+ f"present only in the right binary at 0x{candidate.address:x}",
928
+ )
929
+ )
930
+
931
+ changed.sort(key=lambda delta: delta.address)
932
+ removed.sort(key=lambda delta: delta.address)
933
+ added.sort(key=lambda delta: delta.address)
934
+ return tuple(added), tuple(removed), tuple(changed)
935
+
936
+
937
+ def _section_names(info: BinaryInfo) -> set[str]:
938
+ """Return the set of section names exposed by ``info``."""
939
+ return {section.name for section in info.sections}
940
+
941
+
942
+ def _import_keys(info: BinaryInfo) -> set[str]:
943
+ """Return imports as ``"library!name"`` keys, or ``"name"`` without a library.
944
+
945
+ The library is part of the key because ``CreateFileA`` from ``kernel32.dll``
946
+ and from another module are different imports.
947
+ """
948
+ keys: set[str] = set()
949
+ for item in info.imports:
950
+ if not item.name:
951
+ continue
952
+ keys.add(f"{item.library}!{item.name}" if item.library else item.name)
953
+ return keys
954
+
955
+
956
+ def _export_names(info: BinaryInfo) -> set[str]:
957
+ """Return the set of export names exposed by ``info``."""
958
+ return {export.name for export in info.exports if export.name}
959
+
960
+
961
+ def diff_infos(
962
+ left_info: BinaryInfo,
963
+ right_info: BinaryInfo,
964
+ left_functions: Sequence[FunctionCandidate],
965
+ right_functions: Sequence[FunctionCandidate],
966
+ max_functions: int = DEFAULT_MAX_FUNCTIONS,
967
+ deep: bool = False,
968
+ semantic: bool = False,
969
+ threshold: float = DEFAULT_SEMANTIC_THRESHOLD,
970
+ ) -> BinaryDiff:
971
+ """Compare two loaded binaries and their function candidates.
972
+
973
+ Structural and pure by default: with ``deep=False`` and ``semantic=False``
974
+ nothing is read from disk or executed, and the input sequences are never
975
+ mutated. With ``deep=True`` the function bytes of both sides are read (never
976
+ executed) to fingerprint the address-matched functions; with ``semantic=True``
977
+ they are read to score the functions that address and name matching left
978
+ unpaired. See the module docstring for what each mode adds and what it does
979
+ not.
980
+
981
+ Args:
982
+ left_info: Metadata of the left-hand (baseline) binary.
983
+ right_info: Metadata of the right-hand (candidate) binary.
984
+ left_functions: Function candidates discovered in ``left_info``.
985
+ right_functions: Function candidates discovered in ``right_info``.
986
+ max_functions: Per-side ceiling on compared candidates. Values outside
987
+ ``[MIN_MAX_FUNCTIONS, MAX_MAX_FUNCTIONS]`` are clamped and recorded
988
+ in ``warnings``; candidates above the ceiling are dropped (lowest
989
+ addresses are kept) with a truncation warning.
990
+ deep: When true, also fingerprint address-matched functions and report a
991
+ differing normalized fingerprint as ``"changed"`` with both digests
992
+ and a content note.
993
+ semantic: When true, also pair the candidates that address and name
994
+ matching left unpaired when their normalized instruction shapes score
995
+ at least ``threshold``. A pair becomes one ``"changed"`` delta
996
+ carrying ``similarity``; every candidate that is not paired keeps its
997
+ ``"added"``/``"removed"`` verdict. The pass is skipped, with a warning
998
+ naming both, when the two sides report different architectures, since
999
+ shapes from different instruction sets are not comparable.
1000
+ threshold: Smallest similarity score that may pair two leftovers, in
1001
+ ``[MIN_SEMANTIC_THRESHOLD, MAX_SEMANTIC_THRESHOLD]``. Validated in
1002
+ every mode, so an out-of-range value raises even with
1003
+ ``semantic=False``.
1004
+
1005
+ Returns:
1006
+ A :class:`BinaryDiff` whose ``identical`` flag is ``True`` only when no
1007
+ function, section, import or export delta was observed.
1008
+
1009
+ Raises:
1010
+ AnalysisError: When ``threshold`` is not a finite number within
1011
+ ``0.0 .. 1.0``.
1012
+ """
1013
+ effective_threshold = _validate_threshold(threshold)
1014
+ warnings: list[str] = []
1015
+ limit = _clamp_limit(max_functions, warnings)
1016
+ left_bounded = _bounded(left_functions, limit, "left", warnings)
1017
+ right_bounded = _bounded(right_functions, limit, "right", warnings)
1018
+
1019
+ left_digests: dict[int, str] = {}
1020
+ right_digests: dict[int, str] = {}
1021
+ if deep:
1022
+ matched = {candidate.address for candidate in left_bounded} & {
1023
+ candidate.address for candidate in right_bounded
1024
+ }
1025
+ if len(matched) > MAX_DEEP_FINGERPRINTS:
1026
+ warnings.append(
1027
+ f"{len(matched)} address-matched function(s) exceed "
1028
+ f"MAX_DEEP_FINGERPRINTS={MAX_DEEP_FINGERPRINTS}; only the "
1029
+ f"{MAX_DEEP_FINGERPRINTS} lowest addresses were fingerprinted and the rest "
1030
+ "were compared structurally"
1031
+ )
1032
+ matched = set(sorted(matched)[:MAX_DEEP_FINGERPRINTS])
1033
+ left_digests = _address_digests(left_info, left_bounded, matched, "left", warnings)
1034
+ right_digests = _address_digests(right_info, right_bounded, matched, "right", warnings)
1035
+
1036
+ functions_added, functions_removed, functions_changed = _diff_functions(
1037
+ left_bounded,
1038
+ right_bounded,
1039
+ left_digests,
1040
+ right_digests,
1041
+ _SemanticRequest(
1042
+ left_info=left_info,
1043
+ right_info=right_info,
1044
+ threshold=effective_threshold,
1045
+ warnings=warnings,
1046
+ )
1047
+ if semantic
1048
+ else None,
1049
+ )
1050
+
1051
+ left_sections = _section_names(left_info)
1052
+ right_sections = _section_names(right_info)
1053
+ sections_added = tuple(sorted(right_sections - left_sections))
1054
+ sections_removed = tuple(sorted(left_sections - right_sections))
1055
+
1056
+ left_imports = _import_keys(left_info)
1057
+ right_imports = _import_keys(right_info)
1058
+ imports_added = tuple(sorted(right_imports - left_imports))
1059
+ imports_removed = tuple(sorted(left_imports - right_imports))
1060
+
1061
+ left_exports = _export_names(left_info)
1062
+ right_exports = _export_names(right_info)
1063
+ exports_added = tuple(sorted(right_exports - left_exports))
1064
+ exports_removed = tuple(sorted(left_exports - right_exports))
1065
+
1066
+ identical = not any(
1067
+ (
1068
+ functions_added,
1069
+ functions_removed,
1070
+ functions_changed,
1071
+ sections_added,
1072
+ sections_removed,
1073
+ imports_added,
1074
+ imports_removed,
1075
+ exports_added,
1076
+ exports_removed,
1077
+ )
1078
+ )
1079
+
1080
+ extra: dict[str, Any] = {"max_functions": limit}
1081
+ caveat = _CAVEAT
1082
+ if deep:
1083
+ extra["deep"] = True
1084
+ extra["deep_instructions"] = DEEP_MAX_INSTRUCTIONS
1085
+ extra["max_deep_fingerprints"] = MAX_DEEP_FINGERPRINTS
1086
+ caveat = _CONTENT_CAVEAT
1087
+ if semantic:
1088
+ extra["semantic"] = True
1089
+ extra["semantic_threshold"] = effective_threshold
1090
+ extra["semantic_instructions"] = SEMANTIC_MAX_INSTRUCTIONS
1091
+ extra["max_semantic_pairs"] = MAX_SEMANTIC_PAIRS
1092
+ # Both modes read instruction shapes, so the record must keep saying that
1093
+ # the content comparison ran instead of dropping its caveat.
1094
+ caveat = f"{_CONTENT_CAVEAT}; {_SIMILARITY_CAVEAT}" if deep else _SIMILARITY_CAVEAT
1095
+ return BinaryDiff(
1096
+ left_path=str(left_info.path),
1097
+ right_path=str(right_info.path),
1098
+ identical=identical,
1099
+ functions_added=functions_added,
1100
+ functions_removed=functions_removed,
1101
+ functions_changed=functions_changed,
1102
+ sections_added=sections_added,
1103
+ sections_removed=sections_removed,
1104
+ imports_added=imports_added,
1105
+ imports_removed=imports_removed,
1106
+ exports_added=exports_added,
1107
+ exports_removed=exports_removed,
1108
+ evidence=Evidence(
1109
+ source=_EVIDENCE_SOURCE,
1110
+ method=_EVIDENCE_METHOD,
1111
+ confidence=Confidence.MEDIUM,
1112
+ detail=caveat,
1113
+ extra=extra,
1114
+ ),
1115
+ warnings=tuple(warnings),
1116
+ )
1117
+
1118
+
1119
+ def diff_binaries(
1120
+ left_path: str | Path,
1121
+ right_path: str | Path,
1122
+ max_functions: int = DEFAULT_MAX_FUNCTIONS,
1123
+ deep: bool = False,
1124
+ semantic: bool = False,
1125
+ threshold: float = DEFAULT_SEMANTIC_THRESHOLD,
1126
+ ) -> BinaryDiff:
1127
+ """Load two binaries with LIEF and return their structural difference.
1128
+
1129
+ Both files are parsed only; nothing is mapped or executed. Loading errors
1130
+ propagate as :class:`~codevanta.core.loader.BinaryLoadError`.
1131
+
1132
+ Args:
1133
+ left_path: Path of the left-hand (baseline) binary.
1134
+ right_path: Path of the right-hand (candidate) binary.
1135
+ max_functions: Per-side ceiling on compared function candidates; see
1136
+ :func:`diff_infos`.
1137
+ deep: When true, also compare the normalized content of address-matched
1138
+ functions (their bytes are read, never executed); see
1139
+ :func:`diff_infos`.
1140
+ semantic: When true, also pair the functions that address and name
1141
+ matching left unpaired when their normalized instruction shapes score
1142
+ at least ``threshold``; see :func:`diff_infos`.
1143
+ threshold: Smallest similarity score that may pair two leftovers, in
1144
+ ``0.0 .. 1.0``; see :func:`diff_infos`.
1145
+
1146
+ Returns:
1147
+ The :class:`BinaryDiff` produced by :func:`diff_infos`.
1148
+
1149
+ Raises:
1150
+ AnalysisError: When ``threshold`` is not a finite number within
1151
+ ``0.0 .. 1.0``.
1152
+ """
1153
+ # Validate before parsing: a bad threshold must not cost two full loads, and a
1154
+ # missing or unreadable file must not mask the threshold error.
1155
+ effective_threshold = _validate_threshold(threshold)
1156
+ loader = BinaryLoader()
1157
+ left_info = loader.load(left_path)
1158
+ right_info = loader.load(right_path)
1159
+ return diff_infos(
1160
+ left_info,
1161
+ right_info,
1162
+ discover_functions(left_info),
1163
+ discover_functions(right_info),
1164
+ max_functions=max_functions,
1165
+ deep=deep,
1166
+ semantic=semantic,
1167
+ threshold=effective_threshold,
1168
+ )