Files
magnus919_agent-skills/binary-analysis/scripts/binary_analysis/cli/helpers.py
T

261 lines
7.9 KiB
Python

"""Shared CLI helpers for pagination, warnings, diagnostics, and provenance.
These helpers are used across CLI modules without creating circular imports.
"""
from __future__ import annotations
import json
import platform as _platform
from typing import Any
from binary_analysis import __version__ as _cli_version
# ---------------------------------------------------------------------------
# Constants (matching main.py)
# ---------------------------------------------------------------------------
SCHEMA_VERSION = "1.0.0"
PAGE_SIZE_DEFAULT = 100
PAGE_SIZE_MAX = 1000
# ---------------------------------------------------------------------------
# Provenance helpers
# ---------------------------------------------------------------------------
def default_provenance() -> dict[str, Any]:
"""Return default provenance metadata (base 7 fields) for all commands.
Every response must include: cli_version, schema_version, adapter,
adapter_version, backend, backend_version, platform.
"""
return {
"cli_version": _cli_version,
"schema_version": SCHEMA_VERSION,
"adapter": "none",
"adapter_version": "0.1.0",
"backend": "none",
"backend_version": "0.1.0",
"platform": f"{_platform.system()}-{_platform.machine()}-python{_platform.python_version()}",
}
def enrich_provenance(
provenance: dict[str, Any] | None = None,
*,
project_id: str | None = None,
binary_id: str | None = None,
binary_sha256: str | None = None,
architecture: str | None = None,
analysis_profile: str | None = None,
) -> dict[str, Any]:
"""Enrich provenance with optional context fields.
Args:
provenance: Base provenance dict (uses default if None).
project_id: UUID of the project, added for project-context commands.
binary_id: UUID of the binary, added for binary-context commands.
binary_sha256: SHA-256 of the binary, added for binary-context commands.
architecture: Language/processor spec (e.g., "x86:LE:64:default").
analysis_profile: Profile name (e.g., "standard", "quick", "deep").
Returns:
The enriched provenance dict.
"""
if provenance is None:
provenance = default_provenance()
if project_id is not None:
provenance["project_id"] = project_id
if binary_id is not None:
provenance["binary_id"] = binary_id
if binary_sha256 is not None:
provenance["binary_sha256"] = binary_sha256
if architecture is not None:
provenance["architecture"] = architecture
if analysis_profile is not None:
provenance["analysis_profile"] = analysis_profile
return provenance
# ---------------------------------------------------------------------------
# Pagination helpers
# ---------------------------------------------------------------------------
def clamp_page_size(limit: int | None) -> tuple[int, str | None]:
"""Clamp a page size to the valid range [1, PAGE_SIZE_MAX].
None or values <= 0 default to PAGE_SIZE_DEFAULT.
Values above PAGE_SIZE_MAX are clamped to PAGE_SIZE_MAX.
Args:
limit: Requested page size, or None for default.
Returns:
Tuple of (clamped_page_size, warning_message_or_None).
The warning message is present only when clamping occurred.
"""
if limit is None or limit < 1:
return PAGE_SIZE_DEFAULT, None
if limit > PAGE_SIZE_MAX:
warning = (
f"Requested page size {limit} exceeds maximum {PAGE_SIZE_MAX}. "
f"Clamped to {PAGE_SIZE_MAX}."
)
return PAGE_SIZE_MAX, warning
return limit, None
def build_paginated_response(
items: list[dict[str, Any]],
total: int,
offset: int,
limit: int,
*,
cursor_encoder: Any | None = None,
) -> dict[str, Any]:
"""Build a paginated response with opaque next_page_token.
Args:
items: The sliced page of items.
total: Total number of items across all pages.
offset: Starting offset of this page within the total set.
limit: Page size used for this slice.
cursor_encoder: Optional callable(dict) -> str for cursor encoding.
Returns:
A dict with items, total, page_size, has_more, and next_page_token.
"""
import base64 as _b64
has_more = (offset + limit) < total
next_page_token: str | None = None
if has_more:
if cursor_encoder is not None:
next_page_token = cursor_encoder({"offset": offset + limit})
else:
cursor_data = json.dumps({"offset": offset + limit}).encode("utf-8")
next_page_token = _b64.b64encode(cursor_data).decode("ascii")
return {
"items": items,
"total": total,
"page_size": limit,
"has_more": has_more,
"next_page_token": next_page_token,
}
# ---------------------------------------------------------------------------
# Warning and diagnostics helpers
# ---------------------------------------------------------------------------
def make_warning(
message: str,
severity: str = "WARNING",
category: str = "general",
) -> dict[str, Any]:
"""Create a structured warning entry with severity, message, and category.
Warnings are structurally distinct from diagnostics. They appear in
the `warnings` array, not `diagnostics`.
Args:
message: Human-readable warning description.
severity: Severity from DiagnosticSeverity enum (INFO, WARNING, ERROR).
category: Classification domain (e.g., "pagination", "staleness", "truncation").
Returns:
A dict with severity, message, and category keys.
"""
return {
"severity": severity,
"message": message,
"category": category,
}
def make_diagnostic(
message: str,
severity: str = "ERROR",
category: str = "general",
*,
component: str | None = None,
remediation: str | None = None,
recoverable: bool | None = None,
) -> dict[str, Any]:
"""Create a structured diagnostic entry.
Args:
message: Human-readable diagnostic description.
severity: Severity from DiagnosticSeverity enum.
category: Classification domain.
component: Optional component name (e.g., "Java", "Ghidra").
remediation: Optional remediation hint.
recoverable: Whether retrying could resolve this.
Returns:
A dict with standard diagnostic fields; None-valued optional fields omitted.
"""
diag: dict[str, Any] = {
"severity": severity,
"message": message,
"category": category,
}
if component is not None:
diag["component"] = component
if remediation is not None:
diag["remediation"] = remediation
if recoverable is not None:
diag["recoverable"] = recoverable
return diag
def ensure_collection(data: Any) -> list[Any]:
"""Guarantee that a collection value is a list, never None.
Empty collection results must be [], never null and never absent.
Args:
data: A list or None.
Returns:
The original list, or an empty list if data is None.
"""
if data is None:
return []
if isinstance(data, list):
return data
return list(data)
def make_partial_success(
data: Any,
diagnostics: list[dict[str, Any]],
warnings: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build a partial success result envelope fragment.
Partial success means: success=false, partial=true, non-empty diagnostics,
and data containing whatever partial results are available.
Args:
data: The partial result payload.
diagnostics: Non-empty list of diagnostic entries.
warnings: Optional warning entries.
Returns:
A dict with success, partial, warnings, diagnostics, data keys.
"""
return {
"success": False,
"partial": True,
"warnings": warnings or [],
"diagnostics": diagnostics,
"data": data,
}