mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-14 13:06:30 +03:00
fix: relocate binary analysis skill
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
"""Canonical domain model — entities, enums, schemas, errors, and selectors.
|
||||
|
||||
All public symbols are re-exported for convenient imports:
|
||||
from binary_analysis.domain import Address, Project, Function, ...
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from binary_analysis.domain.entities import (
|
||||
Address,
|
||||
AuditEvent,
|
||||
BasicBlock,
|
||||
Binary,
|
||||
CallGraph,
|
||||
Capability,
|
||||
Diagnostic,
|
||||
EntryPoint,
|
||||
Export,
|
||||
Function,
|
||||
Heuristic,
|
||||
Import,
|
||||
Inference,
|
||||
Instruction,
|
||||
Observation,
|
||||
Project,
|
||||
Reference,
|
||||
Report,
|
||||
Section,
|
||||
String,
|
||||
Symbol,
|
||||
Unknown,
|
||||
)
|
||||
from binary_analysis.domain.enums import (
|
||||
AuditResult,
|
||||
Confidence,
|
||||
DiagnosticSeverity,
|
||||
Endianness,
|
||||
ExitCode,
|
||||
FunctionNameSource,
|
||||
ImportResolution,
|
||||
ProjectState,
|
||||
ReferenceKind,
|
||||
ReportType,
|
||||
)
|
||||
from binary_analysis.domain.errors import (
|
||||
AmbiguousSelectorError,
|
||||
AnalysisFailedError,
|
||||
BackendFailureError,
|
||||
BinaryAnalysisError,
|
||||
BinaryNotFoundError,
|
||||
DependencyMissingError,
|
||||
EntityNotFoundError,
|
||||
ImportFailedError,
|
||||
InvalidArgsError,
|
||||
InvalidConfigError,
|
||||
OperationTimeoutError,
|
||||
ProjectNotFoundError,
|
||||
UnsupportedFormatError,
|
||||
error_type_for,
|
||||
fail,
|
||||
)
|
||||
from binary_analysis.domain.schemas import (
|
||||
canonical_address,
|
||||
deserialize_address,
|
||||
entity_to_dict,
|
||||
safe_json_dumps,
|
||||
serialize_address,
|
||||
serialize_enum,
|
||||
)
|
||||
from binary_analysis.domain.selectors import (
|
||||
ParsedSelector,
|
||||
ResolvedEntity,
|
||||
format_candidates,
|
||||
parse_selector,
|
||||
resolve_function,
|
||||
resolve_functions,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Address",
|
||||
"AmbiguousSelectorError",
|
||||
"AnalysisFailedError",
|
||||
"AuditEvent",
|
||||
"AuditResult",
|
||||
"BackendFailureError",
|
||||
"BasicBlock",
|
||||
"Binary",
|
||||
"BinaryAnalysisError",
|
||||
"BinaryNotFoundError",
|
||||
"CallGraph",
|
||||
"Capability",
|
||||
"Confidence",
|
||||
"DependencyMissingError",
|
||||
"Diagnostic",
|
||||
"DiagnosticSeverity",
|
||||
"Endianness",
|
||||
"EntityNotFoundError",
|
||||
"EntryPoint",
|
||||
"ExitCode",
|
||||
"Export",
|
||||
"Function",
|
||||
"FunctionNameSource",
|
||||
"Heuristic",
|
||||
"Import",
|
||||
"ImportFailedError",
|
||||
"ImportResolution",
|
||||
"Inference",
|
||||
"Instruction",
|
||||
"InvalidArgsError",
|
||||
"InvalidConfigError",
|
||||
"Observation",
|
||||
"OperationTimeoutError",
|
||||
"ParsedSelector",
|
||||
"Project",
|
||||
"ProjectNotFoundError",
|
||||
"ProjectState",
|
||||
"Reference",
|
||||
"ReferenceKind",
|
||||
"Report",
|
||||
"ReportType",
|
||||
"ResolvedEntity",
|
||||
"Section",
|
||||
"String",
|
||||
"Symbol",
|
||||
"Unknown",
|
||||
"UnsupportedFormatError",
|
||||
"canonical_address",
|
||||
"deserialize_address",
|
||||
"entity_to_dict",
|
||||
"error_type_for",
|
||||
"fail",
|
||||
"format_candidates",
|
||||
"parse_selector",
|
||||
"resolve_function",
|
||||
"resolve_functions",
|
||||
"safe_json_dumps",
|
||||
"serialize_address",
|
||||
"serialize_enum",
|
||||
]
|
||||
@@ -0,0 +1,440 @@
|
||||
"""Canonical domain entities as dataclasses.
|
||||
|
||||
All entities use typed fields with proper defaults. Every entity can be
|
||||
serialized to a JSON-compatible dict via asdict() or the schema helpers.
|
||||
Address objects use the canonical structured format with space, offset,
|
||||
display, and optional file_offset.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from binary_analysis.domain.enums import (
|
||||
AuditResult,
|
||||
Confidence,
|
||||
DiagnosticSeverity,
|
||||
Endianness,
|
||||
FunctionNameSource,
|
||||
ImportResolution,
|
||||
ProjectState,
|
||||
ReferenceKind,
|
||||
ReportType,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Canonical Address
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Address:
|
||||
"""Canonical structured address.
|
||||
|
||||
Attributes:
|
||||
space: Address space name (e.g., "ram", "register", "const").
|
||||
offset: Hex-prefixed offset string (e.g., "0x4018d0").
|
||||
display: Human-readable display form (e.g., "0x4018d0").
|
||||
file_offset: Optional byte offset within the file on disk.
|
||||
"""
|
||||
|
||||
space: str
|
||||
offset: str
|
||||
display: str
|
||||
file_offset: int | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate offset format."""
|
||||
if not self.offset.startswith("0x"):
|
||||
raise ValueError(f"Address offset must start with '0x', got: {self.offset!r}")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize to a canonical dict for JSON output."""
|
||||
result: dict[str, Any] = {
|
||||
"space": self.space,
|
||||
"offset": self.offset,
|
||||
"display": self.display,
|
||||
}
|
||||
if self.file_offset is not None:
|
||||
result["file_offset"] = self.file_offset
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> Address:
|
||||
"""Deserialize from a canonical dict."""
|
||||
return cls(
|
||||
space=data["space"],
|
||||
offset=data["offset"],
|
||||
display=data["display"],
|
||||
file_offset=data.get("file_offset"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain Entities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Project:
|
||||
"""Persistent analysis workspace.
|
||||
|
||||
Identity: UUID.
|
||||
"""
|
||||
|
||||
id: UUID = field(default_factory=uuid4)
|
||||
name: str = ""
|
||||
state: ProjectState = ProjectState.CREATED
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
workspace_version: str = "1"
|
||||
binary_count: int = 0
|
||||
is_stale: bool = False
|
||||
lock: dict[str, Any] | None = None
|
||||
description: str | None = None
|
||||
max_binary_size_bytes: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Binary:
|
||||
"""Imported artifact identified by SHA-256.
|
||||
|
||||
Identity: UUID + SHA-256.
|
||||
"""
|
||||
|
||||
id: UUID = field(default_factory=uuid4)
|
||||
sha256: str = ""
|
||||
path: str = ""
|
||||
format: str = ""
|
||||
import_mode: str = "copy"
|
||||
size_bytes: int = 0
|
||||
architecture: str | None = None
|
||||
endianness: Endianness | None = None
|
||||
entry_point: Address | None = None
|
||||
compiler: str | None = None
|
||||
source_language: str | None = None
|
||||
imported_at: str | None = None
|
||||
analyzed_at: str | None = None
|
||||
analysis_profile: str | None = None
|
||||
is_stale: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Section:
|
||||
"""Mapped code or data region within a binary.
|
||||
|
||||
Identity: Name + binary ID.
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
binary_id: UUID | None = None
|
||||
address: Address | None = None
|
||||
virtual_size: int = 0
|
||||
raw_size: int = 0
|
||||
flags: list[str] = field(default_factory=list)
|
||||
entropy: float | None = None
|
||||
content_hash: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntryPoint:
|
||||
"""Process, library, boot, or firmware entry point.
|
||||
|
||||
Identity: Address within binary.
|
||||
"""
|
||||
|
||||
address: Address | None = None
|
||||
kind: str = "unknown"
|
||||
confidence: Confidence = Confidence.UNKNOWN
|
||||
name: str | None = None
|
||||
binary_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Import:
|
||||
"""External dependency symbol.
|
||||
|
||||
Identity: Address within binary.
|
||||
"""
|
||||
|
||||
module: str = ""
|
||||
symbol: str = ""
|
||||
address: Address | None = None
|
||||
resolution: ImportResolution = ImportResolution.UNRESOLVED
|
||||
ordinal: int | None = None
|
||||
binary_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Export:
|
||||
"""Public symbol or forwarder.
|
||||
|
||||
Identity: Address or ordinal.
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
address: Address | None = None
|
||||
ordinal: int | None = None
|
||||
forwarder: str | None = None
|
||||
kind: str = "function"
|
||||
binary_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Symbol:
|
||||
"""Named entity with source and scope.
|
||||
|
||||
Identity: Address within binary.
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
address: Address | None = None
|
||||
source: FunctionNameSource = FunctionNameSource.UNKNOWN
|
||||
scope: str = "unknown"
|
||||
binary_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class String:
|
||||
"""Decoded string at a specific address.
|
||||
|
||||
Identity: Address + encoding + length.
|
||||
"""
|
||||
|
||||
text: str = ""
|
||||
encoding: str = "ASCII"
|
||||
address: Address | None = None
|
||||
length: int = 0
|
||||
binary_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Function:
|
||||
"""Callable code region.
|
||||
|
||||
Identity: Binary ID + entry address.
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
address: Address | None = None
|
||||
size_bytes: int = 0
|
||||
confidence: Confidence = Confidence.UNKNOWN
|
||||
name_source: FunctionNameSource = FunctionNameSource.UNKNOWN
|
||||
binary_id: UUID | None = None
|
||||
is_external: bool = False
|
||||
is_thunk: bool = False
|
||||
signature: str | None = None
|
||||
source_language: str | None = None
|
||||
basic_block_count: int | None = None
|
||||
instruction_count: int | None = None
|
||||
cyclomatic_complexity: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Instruction:
|
||||
"""Canonical machine instruction.
|
||||
|
||||
Identity: Address within function.
|
||||
"""
|
||||
|
||||
mnemonic: str = ""
|
||||
operands: str = ""
|
||||
bytes_hex: str = ""
|
||||
address: Address | None = None
|
||||
size_bytes: int = 0
|
||||
function_id: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BasicBlock:
|
||||
"""Control-flow node within a function.
|
||||
|
||||
Identity: Start address within function.
|
||||
"""
|
||||
|
||||
start_address: Address | None = None
|
||||
end_address: Address | None = None
|
||||
instruction_count: int = 0
|
||||
function_id: str | None = None
|
||||
is_entry: bool = False
|
||||
is_exit: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Reference:
|
||||
"""Directed call, jump, read, write, or data relation.
|
||||
|
||||
Identity: Address pair + kind.
|
||||
"""
|
||||
|
||||
from_addr: Address | None = None
|
||||
to_addr: Address | None = None
|
||||
kind: ReferenceKind = ReferenceKind.UNKNOWN
|
||||
confidence: Confidence = Confidence.UNKNOWN
|
||||
binary_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallGraph:
|
||||
"""Bounded call graph rooted at a function.
|
||||
|
||||
Identity: Derived from function references.
|
||||
"""
|
||||
|
||||
root_address: Address | None = None
|
||||
nodes: list[dict[str, Any]] = field(default_factory=list)
|
||||
edges: list[dict[str, Any]] = field(default_factory=list)
|
||||
max_depth: int = 3
|
||||
total_nodes: int = 0
|
||||
total_edges: int = 0
|
||||
truncated: bool = False
|
||||
binary_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Diagnostic:
|
||||
"""Warning or limitation from an analysis run.
|
||||
|
||||
Identity: Unique within analysis run.
|
||||
"""
|
||||
|
||||
severity: DiagnosticSeverity = DiagnosticSeverity.INFO
|
||||
category: str = ""
|
||||
message: str = ""
|
||||
component: str | None = None
|
||||
remediation: str | None = None
|
||||
recoverable: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class Capability:
|
||||
"""Rule-derived functional indicator.
|
||||
|
||||
Identity: Name within binary.
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
confidence: Confidence = Confidence.UNKNOWN
|
||||
evidence: list[dict[str, Any]] = field(default_factory=list)
|
||||
binary_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Observation:
|
||||
"""Direct deterministic fact from analysis.
|
||||
|
||||
Identity: Unique within analysis run.
|
||||
"""
|
||||
|
||||
category: str = ""
|
||||
description: str = ""
|
||||
source: str = ""
|
||||
address: Address | None = None
|
||||
evidence: Any | None = None
|
||||
binary_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Heuristic:
|
||||
"""Rule-derived interpretation with confidence.
|
||||
|
||||
Identity: Name within analysis run.
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
confidence: Confidence = Confidence.UNKNOWN
|
||||
rule_id: str | None = None
|
||||
evidence: list[dict[str, Any]] = field(default_factory=list)
|
||||
binary_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Inference:
|
||||
"""Agent-generated interpretation.
|
||||
|
||||
Identity: Unique within analysis run.
|
||||
"""
|
||||
|
||||
description: str = ""
|
||||
confidence: Confidence = Confidence.UNKNOWN
|
||||
basis: list[str] = field(default_factory=list)
|
||||
binary_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Unknown:
|
||||
"""Explicit unresolved question.
|
||||
|
||||
Identity: Address within binary.
|
||||
"""
|
||||
|
||||
address: Address | None = None
|
||||
question: str = ""
|
||||
category: str | None = None
|
||||
binary_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Report:
|
||||
"""Durable handoff artifact.
|
||||
|
||||
Identity: UUID.
|
||||
"""
|
||||
|
||||
id: UUID = field(default_factory=uuid4)
|
||||
report_type: ReportType = ReportType.TRIAGE
|
||||
project_id: UUID | None = None
|
||||
binary_id: UUID | None = None
|
||||
created_at: str = ""
|
||||
format: str = "json"
|
||||
summary: str | None = None
|
||||
sections: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditEvent:
|
||||
"""Append-only provenance event.
|
||||
|
||||
Identity: Timestamp sequence.
|
||||
"""
|
||||
|
||||
timestamp: str = ""
|
||||
event_type: str = ""
|
||||
result: AuditResult = AuditResult.SUCCESS
|
||||
project_id: UUID | None = None
|
||||
binary_id: UUID | None = None
|
||||
user: str | None = None
|
||||
details: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TriageResult:
|
||||
"""Aggregate result of a triage analysis.
|
||||
|
||||
Contains observations (facts), heuristics (interpretations),
|
||||
unknowns (open questions), and engine diagnostics.
|
||||
"""
|
||||
|
||||
observations: list[Observation] = field(default_factory=list)
|
||||
heuristics: list[Heuristic] = field(default_factory=list)
|
||||
unknowns: list[Unknown] = field(default_factory=list)
|
||||
engine_diagnostics: list[dict[str, Any]] = field(default_factory=list)
|
||||
partial: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiagnosticsResult:
|
||||
"""Cumulative diagnostics across project lifecycle.
|
||||
|
||||
Contains all persistent diagnostics from analysis, triage,
|
||||
and other commands, plus any current engine diagnostics.
|
||||
"""
|
||||
|
||||
diagnostics: list[dict[str, Any]] = field(default_factory=list)
|
||||
total: int = 0
|
||||
by_severity: dict[str, int] = field(
|
||||
default_factory=lambda: {"INFO": 0, "WARNING": 0, "ERROR": 0}
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Canonical enumerations for the binary analysis domain model.
|
||||
|
||||
All enums serialize as UPPER_CASE strings in JSON output. Integer ordinals and
|
||||
lowercase representations are never used.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ExitCode(int, Enum):
|
||||
"""Standard exit codes for the binary CLI.
|
||||
|
||||
Every error that terminates the CLI maps to one of these codes.
|
||||
"""
|
||||
|
||||
SUCCESS = 0
|
||||
GENERIC_ERROR = 1
|
||||
INVALID_ARGS = 2
|
||||
DEPENDENCY_MISSING = 3
|
||||
INVALID_CONFIG = 4
|
||||
UNSUPPORTED_FORMAT = 5
|
||||
PROJECT_NOT_FOUND = 6
|
||||
BINARY_NOT_FOUND = 7
|
||||
AMBIGUOUS_SELECTOR = 8
|
||||
ENTITY_NOT_FOUND = 9
|
||||
IMPORT_FAILED = 10
|
||||
ANALYSIS_FAILED = 11
|
||||
OPERATION_TIMEOUT = 12
|
||||
BACKEND_FAILURE = 13
|
||||
|
||||
|
||||
class ProjectState(str, Enum):
|
||||
"""Lifecycle states for a binary analysis project."""
|
||||
|
||||
CREATED = "CREATED"
|
||||
IMPORTED = "IMPORTED"
|
||||
ANALYZING = "ANALYZING"
|
||||
READY = "READY"
|
||||
STALE = "STALE"
|
||||
FAILED = "FAILED"
|
||||
|
||||
|
||||
class Confidence(str, Enum):
|
||||
"""Confidence levels for observations, heuristics, and inferences."""
|
||||
|
||||
HIGH = "HIGH"
|
||||
MEDIUM = "MEDIUM"
|
||||
LOW = "LOW"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class DiagnosticSeverity(str, Enum):
|
||||
"""Severity levels for diagnostic entries."""
|
||||
|
||||
INFO = "INFO"
|
||||
WARNING = "WARNING"
|
||||
ERROR = "ERROR"
|
||||
|
||||
|
||||
class ReferenceKind(str, Enum):
|
||||
"""Types of cross-references between entities."""
|
||||
|
||||
CALL = "CALL"
|
||||
JUMP = "JUMP"
|
||||
READ = "READ"
|
||||
WRITE = "WRITE"
|
||||
DATA = "DATA"
|
||||
IMPORT = "IMPORT"
|
||||
EXPORT = "EXPORT"
|
||||
INDIRECT = "INDIRECT"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class Endianness(str, Enum):
|
||||
"""Byte ordering of the target architecture."""
|
||||
|
||||
LITTLE = "LITTLE"
|
||||
BIG = "BIG"
|
||||
MIXED = "MIXED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class FunctionNameSource(str, Enum):
|
||||
"""Provenance of a function name."""
|
||||
|
||||
ORIGINAL = "ORIGINAL"
|
||||
IMPORTED = "IMPORTED"
|
||||
DEBUG = "DEBUG"
|
||||
BACKEND_GENERATED = "BACKEND_GENERATED"
|
||||
USER_ANNOTATION = "USER_ANNOTATION"
|
||||
AGENT_SUGGESTION = "AGENT_SUGGESTION"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class ImportResolution(str, Enum):
|
||||
"""Resolution status of an imported symbol."""
|
||||
|
||||
RESOLVED = "RESOLVED"
|
||||
PARTIAL = "PARTIAL"
|
||||
UNRESOLVED = "UNRESOLVED"
|
||||
|
||||
|
||||
class ReportType(str, Enum):
|
||||
"""Types of analysis reports that can be generated."""
|
||||
|
||||
TRIAGE = "TRIAGE"
|
||||
FOCUSED = "FOCUSED"
|
||||
PROJECT = "PROJECT"
|
||||
|
||||
|
||||
class AuditResult(str, Enum):
|
||||
"""Outcome of an audited operation."""
|
||||
|
||||
SUCCESS = "SUCCESS"
|
||||
PARTIAL = "PARTIAL"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
REFUSED = "REFUSED"
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Canonical error types and exit codes for the binary CLI.
|
||||
|
||||
Each error type maps to a specific exit code from ExitCode enum (0-13).
|
||||
The error hierarchy allows callers to catch specific error types while
|
||||
the base class provides a fallback for GENERIC_ERROR.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from binary_analysis.domain.enums import ExitCode
|
||||
|
||||
|
||||
class BinaryAnalysisError(Exception):
|
||||
"""Base exception for all binary analysis errors.
|
||||
|
||||
Every BinaryAnalysisError carries an exit code and can produce
|
||||
a JSON-serializable representation for the envelope's diagnostics.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, exit_code: ExitCode = ExitCode.GENERIC_ERROR) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.exit_code = exit_code
|
||||
|
||||
def to_diagnostic(self) -> dict[str, Any]:
|
||||
"""Return a diagnostic entry suitable for the envelope."""
|
||||
return {
|
||||
"severity": "ERROR",
|
||||
"message": self.message,
|
||||
}
|
||||
|
||||
|
||||
class InvalidArgsError(BinaryAnalysisError):
|
||||
"""Raised when CLI arguments are invalid. Exit code 2."""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message, ExitCode.INVALID_ARGS)
|
||||
|
||||
|
||||
class DependencyMissingError(BinaryAnalysisError):
|
||||
"""Raised when a required external dependency is missing. Exit code 3."""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message, ExitCode.DEPENDENCY_MISSING)
|
||||
|
||||
|
||||
class InvalidConfigError(BinaryAnalysisError):
|
||||
"""Raised when configuration is invalid or corrupted. Exit code 4."""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message, ExitCode.INVALID_CONFIG)
|
||||
|
||||
def to_diagnostic(self) -> dict[str, Any]:
|
||||
return {
|
||||
"severity": "ERROR",
|
||||
"message": self.message,
|
||||
"category": "config",
|
||||
}
|
||||
|
||||
|
||||
class UnsupportedFormatError(BinaryAnalysisError):
|
||||
"""Raised when the binary format is not supported. Exit code 5."""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message, ExitCode.UNSUPPORTED_FORMAT)
|
||||
|
||||
|
||||
class ProjectNotFoundError(BinaryAnalysisError):
|
||||
"""Raised when a project does not exist. Exit code 6."""
|
||||
|
||||
def __init__(self, project: str) -> None:
|
||||
super().__init__(f"Project not found: {project}", ExitCode.PROJECT_NOT_FOUND)
|
||||
|
||||
|
||||
class BinaryNotFoundError(BinaryAnalysisError):
|
||||
"""Raised when a binary is not found in a project. Exit code 7."""
|
||||
|
||||
def __init__(self, message: str = "No binary has been imported into this project") -> None:
|
||||
super().__init__(message, ExitCode.BINARY_NOT_FOUND)
|
||||
|
||||
|
||||
class AmbiguousSelectorError(BinaryAnalysisError):
|
||||
"""Raised when an entity selector matches multiple entities. Exit code 8."""
|
||||
|
||||
def __init__(self, message: str, candidates: list[dict[str, Any]] | None = None) -> None:
|
||||
super().__init__(message, ExitCode.AMBIGUOUS_SELECTOR)
|
||||
self.candidates = candidates or []
|
||||
|
||||
def to_diagnostic(self) -> dict[str, Any]:
|
||||
diag = super().to_diagnostic()
|
||||
if self.candidates:
|
||||
diag["candidates"] = self.candidates
|
||||
return diag
|
||||
|
||||
|
||||
class EntityNotFoundError(BinaryAnalysisError):
|
||||
"""Raised when a referenced entity does not exist. Exit code 9."""
|
||||
|
||||
def __init__(self, entity_type: str, selector: str) -> None:
|
||||
super().__init__(
|
||||
f"{entity_type} not found: {selector}",
|
||||
ExitCode.ENTITY_NOT_FOUND,
|
||||
)
|
||||
self.entity_type = entity_type
|
||||
self.selector = selector
|
||||
|
||||
|
||||
class ImportFailedError(BinaryAnalysisError):
|
||||
"""Raised when binary import fails. Exit code 10."""
|
||||
|
||||
def __init__(self, message: str, binary_path: str | None = None) -> None:
|
||||
super().__init__(message, ExitCode.IMPORT_FAILED)
|
||||
self.binary_path = binary_path
|
||||
|
||||
|
||||
class AnalysisFailedError(BinaryAnalysisError):
|
||||
"""Raised when analysis fails completely (not partial). Exit code 11."""
|
||||
|
||||
def __init__(self, message: str, project: str | None = None) -> None:
|
||||
super().__init__(message, ExitCode.ANALYSIS_FAILED)
|
||||
self.project = project
|
||||
|
||||
|
||||
class OperationTimeoutError(BinaryAnalysisError):
|
||||
"""Raised when an operation exceeds its timeout. Exit code 12."""
|
||||
|
||||
def __init__(self, message: str = "Operation timed out") -> None:
|
||||
super().__init__(message, ExitCode.OPERATION_TIMEOUT)
|
||||
|
||||
def to_diagnostic(self) -> dict[str, Any]:
|
||||
return {
|
||||
"severity": "ERROR",
|
||||
"message": self.message,
|
||||
"category": "timeout",
|
||||
"recoverable": True,
|
||||
}
|
||||
|
||||
|
||||
class BackendFailureError(BinaryAnalysisError):
|
||||
"""Raised when the backend encounters an internal failure. Exit code 13."""
|
||||
|
||||
def __init__(self, message: str, original_error: str | None = None) -> None:
|
||||
super().__init__(message, ExitCode.BACKEND_FAILURE)
|
||||
self.original_error = original_error
|
||||
|
||||
def to_diagnostic(self) -> dict[str, Any]:
|
||||
diag = super().to_diagnostic()
|
||||
if self.original_error:
|
||||
diag["backend_error"] = self.original_error
|
||||
return diag
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exit code to error type lookup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_EXIT_CODE_TO_ERROR: dict[ExitCode, type[BinaryAnalysisError]] = {
|
||||
ExitCode.SUCCESS: BinaryAnalysisError,
|
||||
ExitCode.GENERIC_ERROR: BinaryAnalysisError,
|
||||
ExitCode.INVALID_ARGS: InvalidArgsError,
|
||||
ExitCode.DEPENDENCY_MISSING: DependencyMissingError,
|
||||
ExitCode.INVALID_CONFIG: InvalidConfigError,
|
||||
ExitCode.UNSUPPORTED_FORMAT: UnsupportedFormatError,
|
||||
ExitCode.PROJECT_NOT_FOUND: ProjectNotFoundError,
|
||||
ExitCode.BINARY_NOT_FOUND: BinaryNotFoundError,
|
||||
ExitCode.AMBIGUOUS_SELECTOR: AmbiguousSelectorError,
|
||||
ExitCode.ENTITY_NOT_FOUND: EntityNotFoundError,
|
||||
ExitCode.IMPORT_FAILED: ImportFailedError,
|
||||
ExitCode.ANALYSIS_FAILED: AnalysisFailedError,
|
||||
ExitCode.OPERATION_TIMEOUT: OperationTimeoutError,
|
||||
ExitCode.BACKEND_FAILURE: BackendFailureError,
|
||||
}
|
||||
|
||||
|
||||
def error_type_for(code: ExitCode) -> type[BinaryAnalysisError]:
|
||||
"""Get the error class for a given exit code."""
|
||||
return _EXIT_CODE_TO_ERROR.get(code, BinaryAnalysisError)
|
||||
|
||||
|
||||
def fail(error: BinaryAnalysisError) -> None:
|
||||
"""Print the error to stderr and exit with the appropriate code."""
|
||||
print(f"Error: {error.message}", file=sys.stderr)
|
||||
sys.exit(error.exit_code)
|
||||
@@ -0,0 +1,453 @@
|
||||
"""JSON serialization helpers for the canonical domain model.
|
||||
|
||||
Key serialization rules:
|
||||
- Addresses: structured objects (space, offset, display, optional file_offset)
|
||||
- Sizes: integer bytes (JSON number), never strings
|
||||
- Unknown/null fields: serialize as JSON null, not "" or 0
|
||||
- Enum values: UPPER_CASE strings matching documented enum members
|
||||
- Entity objects: only canonical fields; no backend-specific keys
|
||||
- Strings: correct JSON escaping of embedded quotes, backslashes, control chars
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from binary_analysis.domain.entities import Address
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Address serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def serialize_address(addr: Address | None) -> dict[str, Any] | None:
|
||||
"""Serialize an Address to its canonical dict form, or null."""
|
||||
if addr is None:
|
||||
return None
|
||||
return addr.to_dict()
|
||||
|
||||
|
||||
def deserialize_address(data: dict[str, Any] | None) -> Address | None:
|
||||
"""Deserialize a canonical dict back to an Address, or null."""
|
||||
if data is None:
|
||||
return None
|
||||
return Address.from_dict(data)
|
||||
|
||||
|
||||
def canonical_address(
|
||||
space: str, offset: str, display: str | None = None, file_offset: int | None = None
|
||||
) -> Address:
|
||||
"""Factory for creating canonical addresses with validated format.
|
||||
|
||||
Args:
|
||||
space: Address space name (e.g., "ram", "register").
|
||||
offset: Hex-prefixed offset string (e.g., "0x401000").
|
||||
display: Display string. Defaults to offset if not provided.
|
||||
file_offset: Optional byte offset within the file.
|
||||
"""
|
||||
if not offset.startswith("0x"):
|
||||
offset = f"0x{offset}"
|
||||
if display is None:
|
||||
display = offset
|
||||
return Address(space=space, offset=offset, display=display, file_offset=file_offset)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enum serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def serialize_enum(value: Enum | None) -> str | None:
|
||||
"""Serialize an enum member to its UPPER_CASE string name, or null."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return value.upper()
|
||||
return str(value.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entity serialization (generic)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def entity_to_dict(
|
||||
entity: Any,
|
||||
canonical_fields: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Convert a dataclass entity to a dict using only canonical fields.
|
||||
|
||||
Args:
|
||||
entity: The dataclass entity to serialize.
|
||||
canonical_fields: Optional whitelist of field names to include.
|
||||
If not provided, all dataclass fields are serialized.
|
||||
|
||||
Returns:
|
||||
A dict with only canonical fields, with proper serialization:
|
||||
- Addresses become structured dicts or null
|
||||
- Enums become UPPER_CASE strings or null
|
||||
- UUIDs become strings
|
||||
- None values remain as null
|
||||
- Sizes remain as integers (never converted to strings)
|
||||
"""
|
||||
result: dict[str, Any] = {}
|
||||
fields_dict = {f.name: f for f in dataclasses.fields(entity)}
|
||||
|
||||
for field_name in fields_dict:
|
||||
# Skip non-canonical fields if a whitelist is provided
|
||||
if canonical_fields is not None and field_name not in canonical_fields:
|
||||
continue
|
||||
|
||||
value = getattr(entity, field_name)
|
||||
|
||||
# Serialize based on type
|
||||
serialized = _serialize_value(value)
|
||||
|
||||
# Only include optional fields if they have a non-None value,
|
||||
# to keep the JSON minimal
|
||||
result[field_name] = serialized
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _serialize_value(value: Any) -> Any:
|
||||
"""Serialize a single value to its JSON-compatible form.
|
||||
|
||||
Rules:
|
||||
- None → None (JSON null)
|
||||
- Address → structured dict or None
|
||||
- Enum → UPPER_CASE string or None
|
||||
- UUID → string
|
||||
- list → list of serialized values
|
||||
- dict → dict of serialized values
|
||||
- booleans → remain booleans
|
||||
- integers → remain integers (never strings)
|
||||
- floats → remain floats
|
||||
- strings → remain strings
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, Address):
|
||||
return value.to_dict()
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
if isinstance(value, list):
|
||||
return [_serialize_value(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: _serialize_value(v) for k, v in value.items()}
|
||||
# Primitives pass through as-is
|
||||
return value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Canonical field whitelists per entity type
|
||||
# These ensure no backend-specific keys leak into entity objects.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROJECT_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"id",
|
||||
"name",
|
||||
"state",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"workspace_version",
|
||||
"binary_count",
|
||||
"is_stale",
|
||||
"lock",
|
||||
"description",
|
||||
"max_binary_size_bytes",
|
||||
}
|
||||
)
|
||||
|
||||
BINARY_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"id",
|
||||
"sha256",
|
||||
"path",
|
||||
"format",
|
||||
"import_mode",
|
||||
"size_bytes",
|
||||
"architecture",
|
||||
"endianness",
|
||||
"entry_point",
|
||||
"compiler",
|
||||
"source_language",
|
||||
"imported_at",
|
||||
"analyzed_at",
|
||||
"analysis_profile",
|
||||
"is_stale",
|
||||
}
|
||||
)
|
||||
|
||||
SECTION_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"name",
|
||||
"binary_id",
|
||||
"address",
|
||||
"virtual_size",
|
||||
"raw_size",
|
||||
"flags",
|
||||
"entropy",
|
||||
"content_hash",
|
||||
}
|
||||
)
|
||||
|
||||
ENTRYPOINT_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"address",
|
||||
"kind",
|
||||
"confidence",
|
||||
"name",
|
||||
"binary_id",
|
||||
}
|
||||
)
|
||||
|
||||
IMPORT_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"module",
|
||||
"symbol",
|
||||
"address",
|
||||
"resolution",
|
||||
"ordinal",
|
||||
"binary_id",
|
||||
}
|
||||
)
|
||||
|
||||
EXPORT_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"name",
|
||||
"address",
|
||||
"ordinal",
|
||||
"forwarder",
|
||||
"kind",
|
||||
"binary_id",
|
||||
}
|
||||
)
|
||||
|
||||
SYMBOL_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"name",
|
||||
"address",
|
||||
"source",
|
||||
"scope",
|
||||
"binary_id",
|
||||
}
|
||||
)
|
||||
|
||||
STRING_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"text",
|
||||
"encoding",
|
||||
"address",
|
||||
"length",
|
||||
"binary_id",
|
||||
}
|
||||
)
|
||||
|
||||
FUNCTION_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"name",
|
||||
"address",
|
||||
"size_bytes",
|
||||
"confidence",
|
||||
"name_source",
|
||||
"binary_id",
|
||||
"is_external",
|
||||
"is_thunk",
|
||||
"signature",
|
||||
"source_language",
|
||||
"basic_block_count",
|
||||
"instruction_count",
|
||||
"cyclomatic_complexity",
|
||||
}
|
||||
)
|
||||
|
||||
INSTRUCTION_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"mnemonic",
|
||||
"operands",
|
||||
"bytes_hex",
|
||||
"address",
|
||||
"size_bytes",
|
||||
"function_id",
|
||||
}
|
||||
)
|
||||
|
||||
BASIC_BLOCK_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"start_address",
|
||||
"end_address",
|
||||
"instruction_count",
|
||||
"function_id",
|
||||
"is_entry",
|
||||
"is_exit",
|
||||
}
|
||||
)
|
||||
|
||||
REFERENCE_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"from_addr",
|
||||
"to_addr",
|
||||
"kind",
|
||||
"confidence",
|
||||
"binary_id",
|
||||
}
|
||||
)
|
||||
|
||||
CALLGRAPH_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"root_address",
|
||||
"nodes",
|
||||
"edges",
|
||||
"max_depth",
|
||||
"total_nodes",
|
||||
"total_edges",
|
||||
"truncated",
|
||||
"binary_id",
|
||||
}
|
||||
)
|
||||
|
||||
DIAGNOSTIC_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"severity",
|
||||
"category",
|
||||
"message",
|
||||
"component",
|
||||
"remediation",
|
||||
"recoverable",
|
||||
}
|
||||
)
|
||||
|
||||
CAPABILITY_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"name",
|
||||
"confidence",
|
||||
"evidence",
|
||||
"binary_id",
|
||||
}
|
||||
)
|
||||
|
||||
OBSERVATION_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"category",
|
||||
"description",
|
||||
"source",
|
||||
"address",
|
||||
"evidence",
|
||||
"binary_id",
|
||||
}
|
||||
)
|
||||
|
||||
HEURISTIC_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"name",
|
||||
"description",
|
||||
"confidence",
|
||||
"rule_id",
|
||||
"evidence",
|
||||
"binary_id",
|
||||
}
|
||||
)
|
||||
|
||||
INFERENCE_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"description",
|
||||
"confidence",
|
||||
"basis",
|
||||
"binary_id",
|
||||
}
|
||||
)
|
||||
|
||||
UNKNOWN_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"address",
|
||||
"question",
|
||||
"category",
|
||||
"binary_id",
|
||||
}
|
||||
)
|
||||
|
||||
REPORT_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"id",
|
||||
"report_type",
|
||||
"project_id",
|
||||
"binary_id",
|
||||
"created_at",
|
||||
"format",
|
||||
"summary",
|
||||
"sections",
|
||||
}
|
||||
)
|
||||
|
||||
AUDIT_EVENT_CANONICAL_FIELDS = frozenset(
|
||||
{
|
||||
"timestamp",
|
||||
"event_type",
|
||||
"result",
|
||||
"project_id",
|
||||
"binary_id",
|
||||
"user",
|
||||
"details",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON encoding with correct string escaping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def safe_json_dumps(obj: Any, indent: int = 2, ensure_ascii: bool = False) -> str:
|
||||
"""Serialize to JSON with correct escaping of embedded quotes, backslashes,
|
||||
and control characters.
|
||||
|
||||
Uses json.dumps with ensure_ascii=False (preserving Unicode) unless
|
||||
ensure_ascii is explicitly True. The standard library json module
|
||||
correctly escapes ", \\, and control characters by default, but we
|
||||
document the expected behavior here.
|
||||
|
||||
Args:
|
||||
obj: The object to serialize.
|
||||
indent: Indentation level (default 2 spaces).
|
||||
ensure_ascii: Whether to escape non-ASCII characters.
|
||||
|
||||
Returns:
|
||||
A valid JSON string.
|
||||
|
||||
Serialization rules enforced:
|
||||
- Double quotes in strings → \\"
|
||||
- Backslashes in strings → \\\\
|
||||
- Control characters → \\uXXXX
|
||||
- Unicode preserved by default (ensure_ascii=False)
|
||||
"""
|
||||
return json.dumps(obj, indent=indent, ensure_ascii=ensure_ascii)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serializable entity mixin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SerializableEntity:
|
||||
"""Mixin for entities that need JSON serialization.
|
||||
|
||||
Subclasses must implement to_dict() and can override _canonical_fields
|
||||
to restrict which fields are serialized.
|
||||
"""
|
||||
|
||||
_canonical_fields: frozenset[str] | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to a JSON-compatible dict."""
|
||||
if self._canonical_fields is not None:
|
||||
return entity_to_dict(self, set(self._canonical_fields))
|
||||
return entity_to_dict(self)
|
||||
|
||||
def to_json(self, indent: int = 2) -> str:
|
||||
"""Serialize to JSON string with correct escaping."""
|
||||
return safe_json_dumps(self.to_dict(), indent=indent)
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Entity selectors for resolving function, address, and entity references.
|
||||
|
||||
Selectors are human-readable strings that resolve to specific entities.
|
||||
Supported selector formats:
|
||||
- function:<name> — Resolve a function by name (exact or fuzzy match)
|
||||
- function:<address> — Resolve a function by address
|
||||
- address:<addr-range> — Resolve an address range (e.g., 0x1000..0x2000)
|
||||
- name:<entity-name> — Generic entity lookup by name
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from binary_analysis.domain.entities import Function
|
||||
from binary_analysis.domain.errors import AmbiguousSelectorError, EntityNotFoundError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Selector types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SelectorKind:
|
||||
"""Selector kind constants."""
|
||||
|
||||
FUNCTION = "function"
|
||||
ADDRESS = "address"
|
||||
NAME = "name"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parsed selector
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedSelector:
|
||||
"""Result of parsing an entity selector string.
|
||||
|
||||
Attributes:
|
||||
kind: The selector kind (function, address, name).
|
||||
value: The parsed selector value.
|
||||
raw: The original selector string.
|
||||
is_address: Whether the value represents an address.
|
||||
address_value: Parsed address offset (hex string without 0x prefix) if applicable.
|
||||
is_range: Whether the selector specifies a range.
|
||||
range_start: Start of range if is_range is True.
|
||||
range_end: End of range if is_range is True.
|
||||
"""
|
||||
|
||||
kind: str = ""
|
||||
value: str = ""
|
||||
raw: str = ""
|
||||
is_address: bool = False
|
||||
address_value: str | None = None
|
||||
is_range: bool = False
|
||||
range_start: str | None = None
|
||||
range_end: str | None = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.raw
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Selector parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ADDRESS_PATTERN = re.compile(r"^(0x)?[0-9a-fA-F]+$")
|
||||
_RANGE_PATTERN = re.compile(r"^(0x)?[0-9a-fA-F]+\.\.(0x)?[0-9a-fA-F]+$")
|
||||
_SELECTOR_PATTERN = re.compile(r"^(function|address|name):(.+)$", re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_selector(raw: str) -> ParsedSelector:
|
||||
"""Parse a selector string into its components.
|
||||
|
||||
Supported formats:
|
||||
"function:main" → function selector by name
|
||||
"function:0x401000" → function selector by address
|
||||
"address:0x1000..0x2000" → address range selector
|
||||
"name:entrypoint" → generic name selector
|
||||
"main" → implicit function selector (shorthand)
|
||||
"0x401000" → implicit address selector (shorthand)
|
||||
|
||||
Args:
|
||||
raw: The raw selector string.
|
||||
|
||||
Returns:
|
||||
A ParsedSelector with kind, value, and parsed components.
|
||||
"""
|
||||
result = ParsedSelector(raw=raw, kind=SelectorKind.NAME, value=raw)
|
||||
|
||||
# Try explicit selector format: kind:value
|
||||
match = _SELECTOR_PATTERN.match(raw)
|
||||
if match:
|
||||
kind = match.group(1).lower()
|
||||
value = match.group(2)
|
||||
|
||||
if kind == SelectorKind.FUNCTION:
|
||||
result.kind = SelectorKind.FUNCTION
|
||||
result.value = value
|
||||
elif kind == SelectorKind.ADDRESS:
|
||||
result.kind = SelectorKind.ADDRESS
|
||||
result.value = value
|
||||
else:
|
||||
result.kind = SelectorKind.NAME
|
||||
result.value = value
|
||||
else:
|
||||
# Implicit: check if it looks like an address
|
||||
if _ADDRESS_PATTERN.match(raw):
|
||||
result.kind = SelectorKind.ADDRESS
|
||||
result.value = raw
|
||||
else:
|
||||
result.kind = SelectorKind.FUNCTION
|
||||
result.value = raw
|
||||
|
||||
# Check if it's an address value
|
||||
if _ADDRESS_PATTERN.match(result.value):
|
||||
result.is_address = True
|
||||
addr = result.value
|
||||
if addr.startswith("0x") or addr.startswith("0X"):
|
||||
result.address_value = addr[2:].lower()
|
||||
else:
|
||||
result.address_value = addr.lower()
|
||||
|
||||
# Check if it's a range
|
||||
if _RANGE_PATTERN.match(result.value):
|
||||
result.is_range = True
|
||||
parts = result.value.split("..")
|
||||
result.range_start = parts[0]
|
||||
result.range_end = parts[1]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entity resolver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedEntity:
|
||||
"""Result of resolving a selector to one or more entities.
|
||||
|
||||
Attributes:
|
||||
selector: The parsed selector that was resolved.
|
||||
entity_type: The type of entity resolved (e.g., "Function", "Address").
|
||||
exact_match: The single entity if resolution was unambiguous.
|
||||
candidates: List of candidates if multiple matches were found.
|
||||
is_ambiguous: Whether resolution produced multiple candidates.
|
||||
"""
|
||||
|
||||
selector: ParsedSelector
|
||||
entity_type: str = ""
|
||||
exact_match: Any | None = None
|
||||
candidates: list[Any] = field(default_factory=list)
|
||||
is_ambiguous: bool = False
|
||||
|
||||
|
||||
def resolve_function(
|
||||
parsed: ParsedSelector,
|
||||
functions: list[Function],
|
||||
require_unique: bool = True,
|
||||
) -> Function:
|
||||
"""Resolve a function selector to a single Function entity.
|
||||
|
||||
Args:
|
||||
parsed: The parsed function selector.
|
||||
functions: List of functions to search.
|
||||
require_unique: If True, raise AmbiguousSelectorError when multiple
|
||||
functions match.
|
||||
|
||||
Returns:
|
||||
The matching Function entity.
|
||||
|
||||
Raises:
|
||||
EntityNotFoundError: If no function matches the selector.
|
||||
AmbiguousSelectorError: If multiple functions match and require_unique is True.
|
||||
"""
|
||||
if parsed.is_address:
|
||||
# Lookup by address
|
||||
addr_val = parsed.address_value or ""
|
||||
matches = [
|
||||
f
|
||||
for f in functions
|
||||
if f.address is not None and f.address.offset.lower() == f"0x{addr_val}"
|
||||
]
|
||||
if not matches:
|
||||
matches = [
|
||||
f
|
||||
for f in functions
|
||||
if f.address is not None and addr_val in f.address.offset.lower()
|
||||
]
|
||||
else:
|
||||
# Lookup by name
|
||||
search_name = parsed.value.lower()
|
||||
exact_matches = [f for f in functions if f.name.lower() == search_name]
|
||||
matches = exact_matches or [f for f in functions if search_name in f.name.lower()]
|
||||
|
||||
if not matches:
|
||||
raise EntityNotFoundError("Function", parsed.raw)
|
||||
|
||||
if len(matches) > 1 and require_unique:
|
||||
candidates_info = [
|
||||
{
|
||||
"name": f.name,
|
||||
"address": f.address.to_dict() if f.address else None,
|
||||
"size_bytes": f.size_bytes,
|
||||
}
|
||||
for f in matches
|
||||
]
|
||||
raise AmbiguousSelectorError(
|
||||
f"Function selector '{parsed.raw}' matches {len(matches)} functions",
|
||||
candidates=candidates_info,
|
||||
)
|
||||
|
||||
return matches[0]
|
||||
|
||||
|
||||
def resolve_functions(
|
||||
parsed: ParsedSelector,
|
||||
functions: list[Function],
|
||||
) -> list[Function]:
|
||||
"""Resolve a function selector to all matching Function entities.
|
||||
|
||||
Args:
|
||||
parsed: The parsed function selector.
|
||||
functions: List of functions to search.
|
||||
|
||||
Returns:
|
||||
List of matching Function entities (may be empty).
|
||||
"""
|
||||
if parsed.is_address:
|
||||
addr_val = parsed.address_value or ""
|
||||
matches = [
|
||||
f for f in functions if f.address is not None and addr_val in f.address.offset.lower()
|
||||
]
|
||||
else:
|
||||
search_name = parsed.value.lower()
|
||||
matches = [f for f in functions if search_name in f.name.lower()]
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
def format_candidates(candidates: list[dict[str, Any]]) -> str:
|
||||
"""Format candidate entities for display in ambiguity errors.
|
||||
|
||||
Args:
|
||||
candidates: List of candidate dicts with name, address, and optional info.
|
||||
|
||||
Returns:
|
||||
A human-readable string listing candidates.
|
||||
"""
|
||||
lines = ["Ambiguous selector matches multiple entities:"]
|
||||
for i, candidate in enumerate(candidates, start=1):
|
||||
name = candidate.get("name", "unknown")
|
||||
addr = candidate.get("address", {})
|
||||
if isinstance(addr, dict):
|
||||
display = addr.get("display", addr.get("offset", "?"))
|
||||
else:
|
||||
display = str(addr)
|
||||
lines.append(f" {i}. {name} @ {display}")
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user