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,748 @@
1
+ """Plugin SDK: discover, load and run analysers without touching the core.
2
+
3
+ An analyser that ships outside the package must not require an edit inside it.
4
+ Three functions make that possible:
5
+
6
+ * :func:`discover` reads the ``codevanta.analyzers`` entry point group through
7
+ :mod:`importlib.metadata` and returns :class:`PluginInfo` records. It
8
+ **never imports** a plugin: discovery only touches distribution metadata, so
9
+ listing what is installed cannot execute third-party code.
10
+ * :func:`load` imports one plugin lazily, by name, and validates the object
11
+ before handing it back. A broken import or a wrong interface becomes an
12
+ :class:`~codevanta.core.report.AnalysisError` that names the plugin, so one
13
+ bad plugin cannot take the caller down with an arbitrary traceback.
14
+ * :func:`run_plugin` is the only place a plugin actually runs, and it is the
15
+ chokepoint for the two limits that matter: wall-clock time and payload size.
16
+
17
+ The contract is deliberately tiny -- one dataclass in, one mapping out::
18
+
19
+ class MyAnalyzer:
20
+ name = "my-analyzer"
21
+ version = "1.0"
22
+ description = "Counts something interesting."
23
+
24
+ def analyze(self, context: AnalysisContext) -> Mapping[str, Any]:
25
+ return {"count": len(context.functions)}
26
+
27
+ That object satisfies :class:`AnalyzerPlugin` structurally; it does not have to
28
+ import this module or subclass anything.
29
+
30
+ Safety limits
31
+ -------------
32
+
33
+ * **Time.** ``run_plugin`` measures the call with ``perf_counter`` and rejects
34
+ a run over ``max_seconds`` (default :data:`DEFAULT_MAX_SECONDS`, 30 s).
35
+ Python cannot kill a thread that is stuck inside third-party code, so the
36
+ budget is **checked on return, not enforced during the call**: a plugin that
37
+ never returns still hangs the caller. Run untrusted plugins in a subprocess
38
+ if that matters; for the pure-Python, read-only analysers this SDK targets,
39
+ a post-return check still stops a merely slow plugin from bloating a sweep.
40
+ * **Size.** The payload is reduced to canonical JSON and rejected above
41
+ ``max_bytes`` (default :data:`DEFAULT_MAX_BYTES`, 4 MiB). The cap is checked
42
+ *before* the time budget, so an oversized payload is always reported as
43
+ oversized rather than as a timeout. Serialising first also rejects payloads
44
+ that are not JSON-safe (``NaN``, arbitrary objects), which keeps every
45
+ :class:`PluginResult` exportable.
46
+ * **Shape.** Only a ``Mapping`` is accepted as a payload; anything else is an
47
+ :class:`AnalysisError` naming the plugin and the type it returned.
48
+
49
+ Built-in registry
50
+ -----------------
51
+
52
+ Analysers wired inside the package live in a process-local, lock-guarded dict
53
+ (:func:`register` / :func:`unregister`). :func:`load` consults it before entry
54
+ points, so an internal analyser is reachable by name without being installable,
55
+ and tests can register a throw-away analyser and drive it through the same code
56
+ path as an installed plugin. :func:`builtin_plugins` describes whatever is
57
+ registered; no plugin code runs at import time.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import json
63
+ import threading
64
+ import time
65
+ from collections.abc import Iterable, Mapping
66
+ from dataclasses import dataclass, field
67
+ from importlib import metadata
68
+ from pathlib import Path
69
+ from types import MappingProxyType
70
+ from typing import Any, Protocol, cast, runtime_checkable
71
+
72
+ from codevanta.core.functions import FunctionCandidate, discover_functions
73
+ from codevanta.core.loader import BinaryInfo
74
+ from codevanta.core.report import MAX_STRING_LIMIT, AnalysisError, load_binary
75
+ from codevanta.core.strings import StringExtractor
76
+ from codevanta.evidence.confidence import Confidence
77
+ from codevanta.evidence.models import Evidence
78
+
79
+ #: Entry point group scanned for analysers unless the caller says otherwise.
80
+ DEFAULT_ENTRY_POINT_GROUP = "codevanta.analyzers"
81
+
82
+ KIND_ANALYZER = "analyzer"
83
+ KIND_EXPORTER = "exporter"
84
+ KIND_PROVIDER = "provider"
85
+ #: The three plugin kinds this SDK knows how to describe.
86
+ PLUGIN_KINDS = frozenset({KIND_ANALYZER, KIND_EXPORTER, KIND_PROVIDER})
87
+
88
+ #: Wall-clock budget for one plugin run, in seconds.
89
+ DEFAULT_MAX_SECONDS = 30.0
90
+ #: Ceiling on the canonical-JSON size of one plugin payload (4 MiB).
91
+ DEFAULT_MAX_BYTES = 4 * 1024 * 1024
92
+ #: Strings the built-in metrics analyser reads per run.
93
+ DEFAULT_MAX_STRINGS = 5000
94
+
95
+ #: Version reported when a distribution does not declare one.
96
+ UNKNOWN_VERSION = "unknown"
97
+
98
+ #: Plugin kinds keyed by entry point group suffix.
99
+ _KIND_BY_GROUP_SUFFIX: dict[str, str] = {
100
+ "analyzer": KIND_ANALYZER,
101
+ "analyzers": KIND_ANALYZER,
102
+ "exporter": KIND_EXPORTER,
103
+ "exporters": KIND_EXPORTER,
104
+ "provider": KIND_PROVIDER,
105
+ "providers": KIND_PROVIDER,
106
+ }
107
+
108
+ @dataclass(frozen=True)
109
+ class PluginInfo:
110
+ """What is known about a plugin without importing it.
111
+
112
+ Attributes:
113
+ name: Entry point name / registry key; unique per group.
114
+ version: Declaring distribution's version, or ``"unknown"``.
115
+ kind: One of :data:`PLUGIN_KINDS`.
116
+ entry_point: The ``module:attribute`` reference as declared.
117
+ description: One-line summary from the distribution metadata, if any.
118
+
119
+ Raises:
120
+ ValueError: When the name is blank or the kind is not a known kind.
121
+ """
122
+
123
+ name: str
124
+ version: str
125
+ kind: str
126
+ entry_point: str
127
+ description: str = ""
128
+
129
+ def __post_init__(self) -> None:
130
+ name = self.name.strip()
131
+ if not name:
132
+ raise ValueError("plugin name must not be empty")
133
+ kind = self.kind.strip().lower()
134
+ if kind not in PLUGIN_KINDS:
135
+ raise ValueError(
136
+ f"plugin {name!r} has unknown kind {self.kind!r}; "
137
+ f"expected one of {sorted(PLUGIN_KINDS)}"
138
+ )
139
+ object.__setattr__(self, "name", name)
140
+ object.__setattr__(self, "kind", kind)
141
+ object.__setattr__(self, "version", self.version.strip() or UNKNOWN_VERSION)
142
+ object.__setattr__(self, "entry_point", self.entry_point.strip())
143
+ object.__setattr__(self, "description", self.description.strip())
144
+
145
+ def to_dict(self) -> dict[str, Any]:
146
+ """Return a JSON-safe description of the plugin."""
147
+ return {
148
+ "name": self.name,
149
+ "version": self.version,
150
+ "kind": self.kind,
151
+ "entry_point": self.entry_point,
152
+ "description": self.description,
153
+ }
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class AnalysisContext:
158
+ """Everything an analyser is given: the subject, and a budget.
159
+
160
+ Attributes:
161
+ path: The file under analysis (bytes, not a parsed copy).
162
+ info: Normalized binary metadata from the loader.
163
+ address: Focus address for address-scoped analysers; :func:`build_context`
164
+ defaults it to the entry point.
165
+ functions: Pre-discovered function candidates, so a plugin does not
166
+ repeat work the core already did.
167
+ budget: Plugin-specific integer limits (e.g. ``{"max_strings": 5000}``),
168
+ read-only for the plugin.
169
+ """
170
+
171
+ path: Path
172
+ info: BinaryInfo
173
+ address: int | None = None
174
+ functions: tuple[FunctionCandidate, ...] = ()
175
+ budget: Mapping[str, int] = field(default_factory=dict)
176
+
177
+ def __post_init__(self) -> None:
178
+ object.__setattr__(self, "path", Path(self.path))
179
+ object.__setattr__(self, "functions", tuple(self.functions))
180
+ object.__setattr__(self, "budget", MappingProxyType(dict(self.budget)))
181
+
182
+
183
+ @runtime_checkable
184
+ class AnalyzerPlugin(Protocol):
185
+ """Structural contract for an analyser plugin.
186
+
187
+ No base class is required: any object with these three attributes and an
188
+ ``analyze`` method works, which keeps plugins independent of CODEVANTA's
189
+ release cadence.
190
+ """
191
+
192
+ name: str
193
+ version: str
194
+ description: str
195
+
196
+ def analyze(self, context: AnalysisContext) -> Mapping[str, Any]:
197
+ """Analyse ``context`` and return a JSON-serialisable mapping."""
198
+ ...
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class PluginResult:
203
+ """The bounded outcome of one :func:`run_plugin` call.
204
+
205
+ Attributes:
206
+ plugin: Description of the plugin that produced the payload.
207
+ payload: The mapping the plugin returned (a shallow copy).
208
+ elapsed_ms: Measured run time in milliseconds.
209
+ warnings: Warnings lifted out of ``payload["warnings"]``.
210
+ evidence: Provenance for the run, including the payload size.
211
+ """
212
+
213
+ plugin: PluginInfo
214
+ payload: Mapping[str, Any]
215
+ elapsed_ms: float
216
+ warnings: tuple[str, ...] = ()
217
+ evidence: Evidence | None = None
218
+
219
+ def to_dict(self) -> dict[str, Any]:
220
+ """Return a JSON-safe representation, ready for a report or a bundle."""
221
+ data: dict[str, Any] = {
222
+ "plugin": self.plugin.to_dict(),
223
+ "payload": dict(self.payload),
224
+ "elapsed_ms": round(float(self.elapsed_ms), 3),
225
+ "warnings": list(self.warnings),
226
+ }
227
+ if self.evidence is not None:
228
+ data["evidence"] = self.evidence.to_dict()
229
+ return data
230
+
231
+
232
+ # ---- built-in registry ------------------------------------------------------
233
+
234
+ #: Process-local registry of plugins wired inside the package (and, in tests,
235
+ #: registered by hand). Guarded by ``_REGISTRY_LOCK``: built-ins are registered
236
+ #: once at import and read from any thread thereafter.
237
+ _REGISTRY: dict[str, AnalyzerPlugin] = {}
238
+ _REGISTRY_LOCK = threading.RLock()
239
+
240
+ #: Warnings produced by the most recent :func:`discover` call.
241
+ _DISCOVERY_WARNINGS: tuple[str, ...] = ()
242
+
243
+
244
+ def register(plugin: AnalyzerPlugin) -> PluginInfo:
245
+ """Register ``plugin`` under its own name and return its description.
246
+
247
+ Registering the same name twice replaces the previous object, which is how
248
+ an internal analyser gets swapped out during development.
249
+
250
+ Raises:
251
+ AnalysisError: When the object does not satisfy :class:`AnalyzerPlugin`
252
+ (no usable ``name``, no callable ``analyze``).
253
+ """
254
+ validated = _validate_plugin(plugin, source="plugin")
255
+ info = _plugin_info(validated)
256
+ with _REGISTRY_LOCK:
257
+ _REGISTRY[info.name] = validated
258
+ return info
259
+
260
+
261
+ def unregister(name: str) -> bool:
262
+ """Remove ``name`` from the registry; return whether it was there.
263
+
264
+ Idempotent on purpose, so a test can clean up in a ``finally`` block
265
+ without first checking what it managed to register.
266
+ """
267
+ key = (name or "").strip()
268
+ with _REGISTRY_LOCK:
269
+ return _REGISTRY.pop(key, None) is not None
270
+
271
+
272
+ def registry_snapshot() -> dict[str, AnalyzerPlugin]:
273
+ """Return a shallow copy of the registry, safe to iterate while mutating."""
274
+ with _REGISTRY_LOCK:
275
+ return dict(_REGISTRY)
276
+
277
+
278
+ def builtin_plugins() -> tuple[PluginInfo, ...]:
279
+ """Describe the plugins currently wired into this process, sorted by name.
280
+
281
+ This is the live registry, not a static list: a plugin registered by a
282
+ caller (a test, say) shows up here too, which is what makes the built-in
283
+ path testable end to end.
284
+ """
285
+ with _REGISTRY_LOCK:
286
+ plugins = list(_REGISTRY.values())
287
+ return tuple(sorted((_plugin_info(plugin) for plugin in plugins), key=lambda i: i.name))
288
+
289
+
290
+ def discovery_warnings() -> tuple[str, ...]:
291
+ """Return the warnings collected by the last :func:`discover` call."""
292
+ with _REGISTRY_LOCK:
293
+ return _DISCOVERY_WARNINGS
294
+
295
+
296
+ # ---- discovery and loading --------------------------------------------------
297
+
298
+
299
+ def discover(entry_point_group: str = DEFAULT_ENTRY_POINT_GROUP) -> tuple[PluginInfo, ...]:
300
+ """List the plugins declared in ``entry_point_group``, sorted by name.
301
+
302
+ Only distribution metadata is read -- no entry point is loaded -- so
303
+ enumerating what is installed never executes plugin code. Broken metadata
304
+ degrades the result instead of raising it: an entry point without a name or
305
+ without a target is skipped, an unreadable version falls back to
306
+ ``"unknown"``, a name declared twice is kept once, and each of those
307
+ records a warning retrievable through :func:`discovery_warnings`.
308
+
309
+ Args:
310
+ entry_point_group: Entry point group to scan.
311
+
312
+ Returns:
313
+ One :class:`PluginInfo` per declared plugin, sorted by name. An empty
314
+ tuple when nothing is installed for the group -- never an exception.
315
+ """
316
+ group = _group_name(entry_point_group)
317
+ warnings: list[str] = []
318
+ try:
319
+ declared = list(metadata.entry_points(group=group))
320
+ except Exception as exc: # noqa: BLE001 - distribution metadata is external data
321
+ _store_discovery_warnings(
322
+ (f"{group}: cannot read entry points ({exc}); no plugins discovered",)
323
+ )
324
+ return ()
325
+
326
+ found: dict[str, PluginInfo] = {}
327
+ for entry_point in sorted(declared, key=_ep_name):
328
+ label = _ep_name(entry_point) or "?"
329
+ try:
330
+ info, warning = _info_from_entry_point(entry_point, group)
331
+ except Exception as exc: # noqa: BLE001 - one bad entry point must not win
332
+ warnings.append(f"{group}: skipped entry point {label!r} ({exc})")
333
+ continue
334
+ if warning is not None:
335
+ warnings.append(warning)
336
+ if info.name in found:
337
+ warnings.append(
338
+ f"{group}: duplicate entry point {info.name!r} ignored "
339
+ f"({found[info.name].entry_point!r} already declared)"
340
+ )
341
+ continue
342
+ found[info.name] = info
343
+
344
+ _store_discovery_warnings(tuple(warnings))
345
+ return tuple(found[name] for name in sorted(found))
346
+
347
+
348
+ def load(
349
+ entry_point_group: str = DEFAULT_ENTRY_POINT_GROUP,
350
+ name: str = "",
351
+ ) -> AnalyzerPlugin:
352
+ """Import and return the plugin called ``name``, validating its interface.
353
+
354
+ The built-in registry is consulted first, so an internal analyser is
355
+ reachable by name whether or not anything is installed. Otherwise the
356
+ entry point is imported now -- lazily, and only for the one plugin asked
357
+ for.
358
+
359
+ Args:
360
+ entry_point_group: Entry point group to search.
361
+ name: Plugin name, as declared by the entry point or the registry.
362
+
363
+ Returns:
364
+ The plugin object, validated against :class:`AnalyzerPlugin`.
365
+
366
+ Raises:
367
+ AnalysisError: When the name is empty or unknown, when the import
368
+ fails, or when the imported object does not implement the
369
+ interface. The message always names the plugin.
370
+ """
371
+ key = (name or "").strip()
372
+ if not key:
373
+ raise AnalysisError("plugin name must not be empty")
374
+ group = _group_name(entry_point_group)
375
+
376
+ with _REGISTRY_LOCK:
377
+ builtin = _REGISTRY.get(key)
378
+ if builtin is not None:
379
+ return _validate_plugin(builtin, source=f"plugin {key!r} (built-in)")
380
+
381
+ try:
382
+ declared = [ep for ep in metadata.entry_points(group=group) if _ep_name(ep) == key]
383
+ except Exception as exc: # noqa: BLE001 - distribution metadata is external data
384
+ raise AnalysisError(f"cannot read entry points for {group!r}: {exc}") from exc
385
+
386
+ if not declared:
387
+ raise AnalysisError(f"no plugin {key!r} in entry point group {group!r}")
388
+ if len(declared) > 1:
389
+ raise AnalysisError(
390
+ f"plugin {key!r} is declared {len(declared)} times in {group!r}; "
391
+ "refusing to guess which one to load"
392
+ )
393
+
394
+ try:
395
+ loaded = declared[0].load()
396
+ except Exception as exc: # noqa: BLE001 - arbitrary import-time failure
397
+ raise AnalysisError(f"plugin {key!r} failed to import: {exc}") from exc
398
+ if isinstance(loaded, type) and not callable(getattr(loaded, "analyze", None)):
399
+ # An entry point can point at a class rather than an instance; say so
400
+ # instead of reporting a bare "no analyze()".
401
+ raise AnalysisError(
402
+ f"plugin {key!r} resolves to the class {loaded.__name__}; "
403
+ "point the entry point at an instance instead"
404
+ )
405
+ return _validate_plugin(loaded, source=f"plugin {key!r}")
406
+
407
+
408
+ # ---- running ----------------------------------------------------------------
409
+
410
+
411
+ def canonical_json(payload: Mapping[str, Any]) -> bytes:
412
+ """Serialise ``payload`` as canonical JSON bytes.
413
+
414
+ Canonical means ``sort_keys``, ``separators=(",", ":")``, ASCII-escaped and
415
+ NaN-free, matching the rest of the project, so two runs of the same plugin
416
+ on the same input serialise to identical bytes.
417
+
418
+ Raises:
419
+ AnalysisError: When the payload is not JSON-serialisable.
420
+ """
421
+ try:
422
+ text = json.dumps(
423
+ dict(payload),
424
+ sort_keys=True,
425
+ separators=(",", ":"),
426
+ ensure_ascii=True,
427
+ allow_nan=False,
428
+ )
429
+ except (TypeError, ValueError) as exc:
430
+ raise AnalysisError(f"payload is not JSON-serialisable: {exc}") from exc
431
+ return text.encode("utf-8")
432
+
433
+
434
+ def run_plugin(
435
+ plugin: AnalyzerPlugin,
436
+ context: AnalysisContext,
437
+ *,
438
+ max_seconds: float = DEFAULT_MAX_SECONDS,
439
+ max_bytes: int = DEFAULT_MAX_BYTES,
440
+ ) -> PluginResult:
441
+ """Run ``plugin`` against ``context`` under a time and size budget.
442
+
443
+ Checks are applied in this order: the plugin interface, the payload shape,
444
+ the canonical-JSON byte cap, then the time budget. The time budget is
445
+ measured with ``perf_counter`` and therefore checked *after* the call
446
+ returned -- the call is not interruptible (see the module docstring), so a
447
+ hung plugin still hangs the caller, and the byte cap is deliberately
448
+ enforced first so an oversized payload is never masked by a slow run.
449
+
450
+ Args:
451
+ plugin: The plugin object, not its name (:func:`load` resolves names).
452
+ context: Subject and budget handed to ``analyze``.
453
+ max_seconds: Wall-clock budget for the call, in seconds.
454
+ max_bytes: Ceiling on the canonical-JSON size of the payload.
455
+
456
+ Returns:
457
+ A :class:`PluginResult` carrying the payload, the measured time, any
458
+ warnings lifted out of ``payload["warnings"]`` and the run's evidence.
459
+
460
+ Raises:
461
+ AnalysisError: When the plugin is malformed, returns a non-mapping,
462
+ exceeds either budget, or when a budget argument is invalid.
463
+ """
464
+ if isinstance(plugin, str):
465
+ raise AnalysisError("run_plugin takes a plugin object, not a name; call load() first")
466
+ validated = _validate_plugin(plugin, source="plugin")
467
+ if not isinstance(context, AnalysisContext):
468
+ raise AnalysisError(f"run_plugin needs an AnalysisContext, got {type(context).__name__}")
469
+ seconds = _budget_seconds(max_seconds)
470
+ limit = _budget_bytes(max_bytes)
471
+
472
+ label = _plugin_label(validated)
473
+ started = time.perf_counter()
474
+ payload = validated.analyze(context)
475
+ elapsed_ms = (time.perf_counter() - started) * 1000.0
476
+
477
+ if not isinstance(payload, Mapping):
478
+ raise AnalysisError(
479
+ f"plugin {label} returned {type(payload).__name__}, expected a mapping"
480
+ )
481
+ try:
482
+ data = canonical_json(payload)
483
+ except AnalysisError as exc:
484
+ raise AnalysisError(f"plugin {label}: {exc}") from exc
485
+ if len(data) > limit:
486
+ raise AnalysisError(
487
+ f"plugin {label} produced {len(data)} bytes of JSON, over the {limit}-byte limit"
488
+ )
489
+ if elapsed_ms > seconds * 1000.0:
490
+ raise AnalysisError(
491
+ f"plugin {label} took {elapsed_ms:.1f} ms, over its {seconds:.3f} s budget "
492
+ "(measured after return; the call cannot be interrupted)"
493
+ )
494
+
495
+ info = _plugin_info(validated)
496
+ return PluginResult(
497
+ plugin=info,
498
+ payload=dict(payload),
499
+ elapsed_ms=round(elapsed_ms, 3),
500
+ warnings=_payload_warnings(payload),
501
+ evidence=Evidence(
502
+ source=f"plugin.{info.name}",
503
+ method="plugin.analyze",
504
+ confidence=Confidence.MEDIUM,
505
+ detail=(
506
+ f"ran to completion, {len(data)} bytes of JSON in {elapsed_ms:.1f} ms; "
507
+ "the harness vouches for the run, not for the plugin's method"
508
+ ),
509
+ extra={
510
+ "plugin": info.name,
511
+ "version": info.version,
512
+ "payload_bytes": len(data),
513
+ "elapsed_ms": round(elapsed_ms, 3),
514
+ },
515
+ ),
516
+ )
517
+
518
+
519
+ def build_context(
520
+ path: str | Path,
521
+ *,
522
+ address: int | None = None,
523
+ functions: Iterable[FunctionCandidate] | None = None,
524
+ budget: Mapping[str, int] | None = None,
525
+ ) -> AnalysisContext:
526
+ """Load ``path`` and assemble the :class:`AnalysisContext` for a run.
527
+
528
+ Convenience for callers (and tests) that want one obvious way from a file
529
+ to a runnable context: the binary is loaded, functions are discovered
530
+ unless supplied, and ``address`` falls back to the entry point.
531
+
532
+ Args:
533
+ path: Binary to analyse.
534
+ address: Focus address; defaults to the entry point (may stay ``None``).
535
+ functions: Pre-computed candidates, skipping discovery when given.
536
+ budget: Plugin-specific integer limits.
537
+
538
+ Raises:
539
+ BinaryLoadError: When the file cannot be loaded (propagated from the
540
+ loader, which already names the offending path).
541
+ """
542
+ binary_path = Path(path)
543
+ info = load_binary(binary_path)
544
+ discovered: tuple[FunctionCandidate, ...]
545
+ if functions is None:
546
+ discovered = tuple(discover_functions(info))
547
+ else:
548
+ discovered = tuple(functions)
549
+ return AnalysisContext(
550
+ path=binary_path,
551
+ info=info,
552
+ address=info.entry_point if address is None else address,
553
+ functions=discovered,
554
+ budget=dict(budget or {}),
555
+ )
556
+
557
+
558
+ class MetricsAnalyzer:
559
+ """Built-in analyser: counts functions and strings via the public API.
560
+
561
+ Deliberately unambitious. It exists to prove the plugin path end to end
562
+ (registry -> :func:`load` -> :func:`run_plugin` -> bounded JSON) with an
563
+ analyser whose numbers the core can independently check, and to show how a
564
+ plugin honours a budget from :class:`AnalysisContext`.
565
+ """
566
+
567
+ name = "metrics"
568
+ version = "1.0"
569
+ description = "Function and string counts from the public CODEVANTA analysis API."
570
+
571
+ def analyze(self, context: AnalysisContext) -> dict[str, Any]:
572
+ """Return counts plus the shape of the subject, bounded by the budget."""
573
+ max_strings = _budget_within(context.budget.get("max_strings", DEFAULT_MAX_STRINGS))
574
+ strings = StringExtractor().extract(context.path, context.info, limit=max_strings)
575
+ functions = context.functions
576
+ named = sum(1 for candidate in functions if candidate.name)
577
+ warnings: list[str] = []
578
+ if len(strings) >= max_strings:
579
+ warnings.append(
580
+ f"string scan stopped at the {max_strings}-string budget; "
581
+ "string_count is a lower bound"
582
+ )
583
+ entry_point = context.info.entry_point
584
+ return {
585
+ "format": context.info.format,
586
+ "architecture": context.info.architecture,
587
+ "bitness": context.info.bitness,
588
+ "endianness": context.info.endianness,
589
+ "entry_point": f"0x{entry_point:x}" if entry_point is not None else None,
590
+ "function_count": len(functions),
591
+ "named_function_count": named,
592
+ "string_count": len(strings),
593
+ "distinct_string_count": len({result.text for result in strings}),
594
+ "budget": {"max_strings": max_strings},
595
+ "warnings": warnings,
596
+ }
597
+
598
+
599
+ #: The built-in analyser instance, registered by ``_wire_builtins`` below.
600
+ METRICS_PLUGIN = MetricsAnalyzer()
601
+
602
+
603
+ # ---- internals --------------------------------------------------------------
604
+
605
+
606
+ def _group_name(entry_point_group: str) -> str:
607
+ """Return a validated entry point group name."""
608
+ group = (entry_point_group or "").strip()
609
+ if not group:
610
+ raise AnalysisError("entry point group must not be empty")
611
+ return group
612
+
613
+
614
+ def _kind_for_group(entry_point_group: str) -> str:
615
+ """Map an entry point group (``...analyzers``) to a plugin kind."""
616
+ suffix = _group_name(entry_point_group).rpartition(".")[2].lower()
617
+ return _KIND_BY_GROUP_SUFFIX.get(suffix, KIND_ANALYZER)
618
+
619
+
620
+ def _ep_name(entry_point: Any) -> str:
621
+ """Return an entry point's name, stripped, or ``""`` when it has none."""
622
+ return str(getattr(entry_point, "name", "") or "").strip()
623
+
624
+
625
+ def _plugin_label(plugin: object) -> str:
626
+ """A message-safe identifier for ``plugin``, using its name when it has one."""
627
+ name = getattr(plugin, "name", None)
628
+ if isinstance(name, str) and name.strip():
629
+ return repr(name.strip())
630
+ return f"<{type(plugin).__name__} at 0x{id(plugin):x}>"
631
+
632
+
633
+ def _validate_plugin(plugin: object, *, source: str) -> AnalyzerPlugin:
634
+ """Check the plugin contract, raising :class:`AnalysisError` naming it."""
635
+ label = _plugin_label(plugin)
636
+ name = getattr(plugin, "name", None)
637
+ if not isinstance(name, str) or not name.strip():
638
+ raise AnalysisError(f"{source} has no usable 'name' attribute (got {name!r})")
639
+ if not callable(getattr(plugin, "analyze", None)):
640
+ raise AnalysisError(f"{source} has no callable 'analyze' method (plugin {label})")
641
+ return cast(AnalyzerPlugin, plugin)
642
+
643
+
644
+ def _plugin_info(plugin: AnalyzerPlugin) -> PluginInfo:
645
+ """Describe a plugin object that already passed validation."""
646
+ version = getattr(plugin, "version", "") or ""
647
+ description = getattr(plugin, "description", "") or ""
648
+ return PluginInfo(
649
+ name=str(plugin.name),
650
+ version=str(version),
651
+ kind=KIND_ANALYZER,
652
+ entry_point=f"{type(plugin).__module__}:{type(plugin).__qualname__}",
653
+ description=str(description),
654
+ )
655
+
656
+
657
+ def _info_from_entry_point(entry_point: Any, group: str) -> tuple[PluginInfo, str | None]:
658
+ """Build :class:`PluginInfo` from one entry point without importing it.
659
+
660
+ Returns the description and, when the metadata was partially broken but
661
+ recoverable, a warning string instead of raising.
662
+ """
663
+ name = _ep_name(entry_point)
664
+ if not name:
665
+ raise ValueError("entry point declares no name")
666
+ value = str(getattr(entry_point, "value", "") or "").strip()
667
+ if not value:
668
+ raise ValueError(f"entry point {name!r} declares no importable target")
669
+
670
+ version, description, warning = UNKNOWN_VERSION, "", None
671
+ dist = getattr(entry_point, "dist", None)
672
+ if dist is not None:
673
+ try:
674
+ version = str(dist.version or UNKNOWN_VERSION)
675
+ except Exception as exc: # noqa: BLE001 - broken distribution metadata
676
+ warning = (
677
+ f"{group}: {name!r} has unreadable version metadata ({exc}); "
678
+ f"reporting {UNKNOWN_VERSION!r}"
679
+ )
680
+ try:
681
+ summary = dist.metadata["Summary"]
682
+ except Exception: # noqa: BLE001 - the summary is optional metadata
683
+ summary = None
684
+ if isinstance(summary, str):
685
+ description = summary
686
+
687
+ info = PluginInfo(
688
+ name=name,
689
+ version=version,
690
+ kind=_kind_for_group(group),
691
+ entry_point=value,
692
+ description=description,
693
+ )
694
+ return info, warning
695
+
696
+
697
+ def _store_discovery_warnings(warnings: tuple[str, ...]) -> None:
698
+ """Record the warnings from one discovery pass."""
699
+ global _DISCOVERY_WARNINGS
700
+ with _REGISTRY_LOCK:
701
+ _DISCOVERY_WARNINGS = warnings
702
+
703
+
704
+ def _payload_warnings(payload: Mapping[str, Any]) -> tuple[str, ...]:
705
+ """Lift string warnings out of ``payload["warnings"]``, ignoring the rest."""
706
+ raw = payload.get("warnings")
707
+ if isinstance(raw, (str, bytes)) or not isinstance(raw, Iterable):
708
+ return ()
709
+ return tuple(item for item in raw if isinstance(item, str))
710
+
711
+
712
+ def _budget_seconds(value: float) -> float:
713
+ """Validate ``max_seconds``: a positive, finite number."""
714
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
715
+ raise AnalysisError(f"max_seconds must be a number, got {value!r}")
716
+ seconds = float(value)
717
+ if not seconds > 0.0 or seconds == float("inf") or seconds != seconds:
718
+ raise AnalysisError(f"max_seconds must be finite and > 0, got {value!r}")
719
+ return seconds
720
+
721
+
722
+ def _budget_bytes(value: int) -> int:
723
+ """Validate ``max_bytes``: a positive integer."""
724
+ if isinstance(value, bool) or not isinstance(value, int):
725
+ raise AnalysisError(f"max_bytes must be an integer, got {value!r}")
726
+ if value < 1:
727
+ raise AnalysisError(f"max_bytes must be >= 1, got {value}")
728
+ return value
729
+
730
+
731
+ def _budget_within(value: Any) -> int:
732
+ """Clamp a plugin-side integer budget into ``1 .. MAX_STRING_LIMIT``.
733
+
734
+ A plugin budget comes from a caller, so it is treated as untrusted input:
735
+ a bad value falls back to the default rather than failing the analysis, and
736
+ a huge one is capped by the core's string limit.
737
+ """
738
+ if isinstance(value, bool) or not isinstance(value, int):
739
+ return DEFAULT_MAX_STRINGS
740
+ return max(1, min(value, MAX_STRING_LIMIT))
741
+
742
+
743
+ def _wire_builtins() -> None:
744
+ """Register the analysers that ship inside the package."""
745
+ register(METRICS_PLUGIN)
746
+
747
+
748
+ _wire_builtins()