mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
961 lines
32 KiB
Python
961 lines
32 KiB
Python
"""Focused analysis commands — functions, disassemble, bytes, and decompile.
|
|
|
|
All commands follow the standard JSON envelope pattern. Functions returns
|
|
paginated results. Disassemble and bytes operate on bounded targets (function
|
|
selectors or address ranges). Decompile returns reconstructed pseudocode.
|
|
|
|
Validation assertions covered:
|
|
- VAL-STRUCT-011, 012, 013: Functions list with filtering
|
|
- VAL-FOCUS-001, 002, 003, 004, 005, 032: Decompile
|
|
- VAL-FOCUS-006, 007, 008, 009, 010: Disassemble
|
|
- VAL-FOCUS-011, 012, 013, 014: Bytes
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import concurrent.futures
|
|
import re
|
|
from typing import Any
|
|
from uuid import UUID, uuid4
|
|
|
|
from binary_analysis.cli.helpers import (
|
|
clamp_page_size,
|
|
make_warning,
|
|
)
|
|
from binary_analysis.domain.entities import Address
|
|
from binary_analysis.domain.errors import (
|
|
BackendFailureError,
|
|
BinaryAnalysisError,
|
|
BinaryNotFoundError,
|
|
EntityNotFoundError,
|
|
InvalidArgsError,
|
|
OperationTimeoutError,
|
|
ProjectNotFoundError,
|
|
)
|
|
from binary_analysis.domain.selectors import (
|
|
parse_selector,
|
|
resolve_function,
|
|
)
|
|
from binary_analysis.projects.manifest import load_manifest
|
|
from binary_analysis.projects.workspace import (
|
|
get_project_path,
|
|
list_workspaces,
|
|
workspace_exists,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Address range regex: <hex_start>..<hex_end>
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_ADDR_RANGE_RE = re.compile(r"^(0x[0-9a-fA-F]+)\.\.(0x[0-9a-fA-F]+)$")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Project path resolution (identical to structural.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _resolve_project_path(project_name: str) -> str:
|
|
"""Resolve a project name or UUID to its workspace path."""
|
|
if workspace_exists(project_name):
|
|
return str(get_project_path(project_name))
|
|
|
|
for ws_name in list_workspaces():
|
|
ws_path = str(get_project_path(ws_name))
|
|
try:
|
|
manifest = load_manifest(ws_path)
|
|
if manifest.get("id") == project_name:
|
|
return ws_path
|
|
except Exception:
|
|
continue
|
|
|
|
raise ProjectNotFoundError(project_name)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared adapter/binary resolution (identical to structural.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_adapter_and_binary(
|
|
project_path: str, manifest: dict[str, Any]
|
|
) -> tuple[Any, Any, dict[str, Any]]:
|
|
"""Resolve the adapter, binary entity, and project info.
|
|
|
|
Returns:
|
|
Tuple of (adapter, Binary entity, project_info dict with id/name/state).
|
|
"""
|
|
from binary_analysis.adapters.fake import FakeAdapter
|
|
from binary_analysis.domain.entities import Binary as BinaryEntity
|
|
|
|
current_binary = manifest.get("current_binary")
|
|
if current_binary is None:
|
|
raise BinaryNotFoundError(
|
|
"No binary has been imported into this project. "
|
|
"Use 'binary import' to add a binary before querying."
|
|
)
|
|
|
|
adapter = FakeAdapter()
|
|
adapter.set_fixture("pe-default", FakeAdapter.pe_fixture())
|
|
adapter.set_fixture("elf-default", FakeAdapter.elf_fixture())
|
|
adapter.set_fixture("macho-default", FakeAdapter.macho_fixture())
|
|
|
|
binary_id = current_binary.get("id", str(uuid4()))
|
|
binary_entity = BinaryEntity(
|
|
id=UUID(binary_id),
|
|
sha256=current_binary.get("sha256", ""),
|
|
path=current_binary.get("path", ""),
|
|
format=current_binary.get("format", ""),
|
|
size_bytes=current_binary.get("size_bytes", 0),
|
|
architecture=current_binary.get("architecture"),
|
|
)
|
|
|
|
binary_fmt = current_binary.get("format", "").lower()
|
|
fixture_name = "pe-default"
|
|
if "elf" in binary_fmt:
|
|
fixture_name = "elf-default"
|
|
elif "mach" in binary_fmt:
|
|
fixture_name = "macho-default"
|
|
|
|
adapter.register_binary(binary_entity, fixture_name)
|
|
|
|
project_info = {
|
|
"id": manifest.get("id", ""),
|
|
"name": manifest.get("name", ""),
|
|
"state": manifest.get("state", ""),
|
|
}
|
|
|
|
return adapter, binary_entity, project_info
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entity-to-dict conversion (identical to structural.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _entity_to_dict(entity: Any) -> dict[str, Any]:
|
|
"""Convert a domain entity to a JSON-serializable dict."""
|
|
from dataclasses import fields, is_dataclass
|
|
|
|
if not is_dataclass(entity):
|
|
if isinstance(entity, dict):
|
|
return entity
|
|
return {"value": str(entity)}
|
|
|
|
result: dict[str, Any] = {}
|
|
for f in fields(entity):
|
|
value = getattr(entity, f.name)
|
|
|
|
if f.name == "binary_id":
|
|
continue
|
|
if f.name == "content_hash" and value is None:
|
|
continue
|
|
|
|
if value is None:
|
|
result[f.name] = None
|
|
elif hasattr(value, "to_dict"):
|
|
result[f.name] = value.to_dict()
|
|
elif hasattr(value, "value"):
|
|
result[f.name] = str(value.value)
|
|
elif isinstance(value, UUID):
|
|
result[f.name] = str(value)
|
|
else:
|
|
result[f.name] = value
|
|
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Address parsing helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _parse_address(addr_str: str) -> Address:
|
|
"""Parse a hex address string like '0x401000' into an Address object.
|
|
|
|
Raises InvalidArgsError if the format is invalid.
|
|
"""
|
|
if not addr_str.startswith("0x"):
|
|
raise InvalidArgsError(
|
|
f"Invalid address format: {addr_str!r}. Address must start with '0x' "
|
|
"followed by hexadecimal digits (e.g., '0x401000')."
|
|
)
|
|
try:
|
|
int(addr_str, 16)
|
|
except ValueError:
|
|
raise InvalidArgsError(
|
|
f"Invalid address format: {addr_str!r}. Expected hexadecimal address."
|
|
) from None
|
|
|
|
return Address(
|
|
space="ram",
|
|
offset=addr_str,
|
|
display=addr_str,
|
|
)
|
|
|
|
|
|
def _parse_address_range(range_str: str) -> tuple[Address, Address]:
|
|
"""Parse an address range string like '0x401000..0x401200'.
|
|
|
|
Returns (start_address, end_address).
|
|
|
|
Raises InvalidArgsError if the format is invalid.
|
|
"""
|
|
match = _ADDR_RANGE_RE.match(range_str)
|
|
if not match:
|
|
raise InvalidArgsError(
|
|
f"Invalid address range format: {range_str!r}. "
|
|
"Expected format: <start_hex>..<end_hex> (e.g., '0x401000..0x401200')."
|
|
)
|
|
start_str, end_str = match.group(1), match.group(2)
|
|
start = _parse_address(start_str)
|
|
end = _parse_address(end_str)
|
|
|
|
# Validate that start <= end
|
|
if int(start_str, 16) > int(end_str, 16):
|
|
raise InvalidArgsError(
|
|
f"Invalid address range: start ({start_str}) must be <= end ({end_str})."
|
|
)
|
|
|
|
return start, end
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cursor helpers (adapted from structural.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_cursor(
|
|
command: str,
|
|
project_id: str,
|
|
offset: int,
|
|
filters: dict[str, Any] | None = None,
|
|
sort_key: str | None = None,
|
|
) -> str:
|
|
"""Build a scoped pagination cursor."""
|
|
import hashlib
|
|
import json
|
|
|
|
filters_hash = hashlib.md5(
|
|
json.dumps(filters or {}, sort_keys=True).encode("utf-8")
|
|
).hexdigest()
|
|
cursor_data = {
|
|
"c": command,
|
|
"p": project_id,
|
|
"fh": filters_hash,
|
|
"s": sort_key,
|
|
"o": offset,
|
|
}
|
|
json_bytes = json.dumps(cursor_data, sort_keys=True).encode("utf-8")
|
|
return base64.urlsafe_b64encode(json_bytes).decode("ascii")
|
|
|
|
|
|
def _decode_cursor(cursor_str: str) -> dict[str, Any]:
|
|
"""Decode a base64-encoded cursor string back to a dict."""
|
|
import json
|
|
|
|
try:
|
|
json_bytes = base64.urlsafe_b64decode(cursor_str.encode("ascii"))
|
|
result: dict[str, Any] = json.loads(json_bytes)
|
|
return result
|
|
except Exception:
|
|
raise InvalidArgsError(
|
|
"Invalid cursor value. Cursors are scoped to command, project, "
|
|
"filters, and sort. Use a cursor from a matching query."
|
|
) from None
|
|
|
|
|
|
def _validate_cursor_scope(
|
|
cursor_data: dict[str, Any],
|
|
command: str,
|
|
project_id: str,
|
|
filters: dict[str, Any] | None = None,
|
|
sort_key: str | None = None,
|
|
) -> int:
|
|
"""Validate cursor scope and return offset."""
|
|
import hashlib
|
|
import json
|
|
|
|
filters_hash = hashlib.md5(
|
|
json.dumps(filters or {}, sort_keys=True).encode("utf-8")
|
|
).hexdigest()
|
|
|
|
c_cmd = cursor_data.get("c")
|
|
c_proj = cursor_data.get("p")
|
|
c_fh = cursor_data.get("fh")
|
|
c_sort = cursor_data.get("s")
|
|
offset = cursor_data.get("o", 0)
|
|
|
|
mismatches: list[str] = []
|
|
if c_cmd != command:
|
|
mismatches.append(f"command (cursor: {c_cmd}, current: {command})")
|
|
if c_proj != project_id:
|
|
mismatches.append(f"project (cursor: {c_proj}, current: {project_id})")
|
|
if c_fh != filters_hash:
|
|
mismatches.append("filters")
|
|
if (c_sort or None) != (sort_key or None):
|
|
mismatches.append("sort")
|
|
|
|
if mismatches:
|
|
raise InvalidArgsError(
|
|
"Cursor scope mismatch: " + "; ".join(mismatches) + ". "
|
|
"Pagination cursors are scoped to command, project, filters, and sort. "
|
|
"Use a cursor from a matching query."
|
|
)
|
|
|
|
if not isinstance(offset, int) or offset < 0:
|
|
raise InvalidArgsError("Invalid cursor offset")
|
|
|
|
return offset
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Subparser registration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def add_subparser(subparsers: Any) -> None:
|
|
"""Register focused analysis subcommands: functions, decompile, disassemble, bytes."""
|
|
|
|
# -- Functions --
|
|
functions_parser = subparsers.add_parser(
|
|
"functions", help="List functions with name, address, size, confidence, and name source."
|
|
)
|
|
functions_parser.add_argument("--project", required=True, help="Project name or UUID.")
|
|
functions_parser.add_argument(
|
|
"--no-exclude-external",
|
|
action="store_true",
|
|
default=False,
|
|
help="Include externally defined functions (excluded by default).",
|
|
)
|
|
functions_parser.add_argument(
|
|
"--no-exclude-thunks",
|
|
action="store_true",
|
|
default=False,
|
|
help="Include thunk functions (excluded by default).",
|
|
)
|
|
functions_parser.add_argument(
|
|
"--cursor", default=None, help="Pagination cursor from previous response (next_cursor)."
|
|
)
|
|
functions_parser.add_argument(
|
|
"--sort", default="address", help="Sort field (default: address)."
|
|
)
|
|
|
|
# -- Decompile --
|
|
decompile_parser = subparsers.add_parser(
|
|
"decompile",
|
|
help="Decompile a function to reconstructed pseudocode with address map.",
|
|
)
|
|
decompile_parser.add_argument("--project", required=True, help="Project name or UUID.")
|
|
decompile_parser.add_argument(
|
|
"selector",
|
|
nargs="?",
|
|
default=None,
|
|
help=(
|
|
"A single function selector: function:<name> (e.g., 'function:main') "
|
|
"or shorthand function name (e.g., 'main'). "
|
|
"Exactly one function selector is required."
|
|
),
|
|
)
|
|
|
|
# -- Disassemble --
|
|
disassemble_parser = subparsers.add_parser(
|
|
"disassemble",
|
|
help="Disassemble instructions in a function or address range.",
|
|
)
|
|
disassemble_parser.add_argument("--project", required=True, help="Project name or UUID.")
|
|
disassemble_parser.add_argument(
|
|
"target",
|
|
nargs="?",
|
|
default=None,
|
|
help=(
|
|
"Disassembly target. Either function:<name> (e.g., 'function:main') "
|
|
"or an address range <start>..<end> (e.g., '0x401000..0x401200')."
|
|
),
|
|
)
|
|
|
|
# -- Bytes --
|
|
bytes_parser = subparsers.add_parser("bytes", help="Read raw bytes at a given address.")
|
|
bytes_parser.add_argument("--project", required=True, help="Project name or UUID.")
|
|
bytes_parser.add_argument(
|
|
"address",
|
|
nargs="?",
|
|
default=None,
|
|
help="Starting address in hex (e.g., '0x401000').",
|
|
)
|
|
bytes_parser.add_argument(
|
|
"length",
|
|
nargs="?",
|
|
type=int,
|
|
default=None,
|
|
help="Number of bytes to read (positive integer).",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Command: functions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def execute_functions(args: argparse.Namespace) -> dict[str, Any]:
|
|
"""Execute the 'functions' command.
|
|
|
|
VAL-STRUCT-011: Returns name, address, size_bytes, confidence, name_source.
|
|
VAL-STRUCT-012: Excludes external/thunks by default; reports in applied_filters.
|
|
VAL-STRUCT-013: --no-exclude-external/--no-exclude-thunks override defaults.
|
|
"""
|
|
project_name = args.project
|
|
limit, clamp_warning = clamp_page_size(getattr(args, "limit", None))
|
|
cursor_str: str | None = getattr(args, "cursor", None)
|
|
sort_key: str = getattr(args, "sort", "address")
|
|
no_exclude_external: bool = getattr(args, "no_exclude_external", False)
|
|
no_exclude_thunks: bool = getattr(args, "no_exclude_thunks", False)
|
|
command = "functions"
|
|
|
|
# Exclude by default; flags invert the default
|
|
exclude_external = not no_exclude_external
|
|
exclude_thunks = not no_exclude_thunks
|
|
|
|
project_path = _resolve_project_path(project_name)
|
|
manifest = load_manifest(project_path)
|
|
project_id = manifest.get("id", "")
|
|
project_state = manifest.get("state", "")
|
|
|
|
adapter, binary_entity, __ = _get_adapter_and_binary(project_path, manifest)
|
|
|
|
try:
|
|
functions = adapter.get_functions(
|
|
binary_entity,
|
|
exclude_external=exclude_external,
|
|
exclude_thunks=exclude_thunks,
|
|
)
|
|
except BinaryAnalysisError:
|
|
raise
|
|
except Exception as e:
|
|
raise BackendFailureError(
|
|
f"Failed to retrieve functions: {e}", original_error=str(e)
|
|
) from e
|
|
|
|
items = []
|
|
for fn in functions:
|
|
d = _entity_to_dict(fn)
|
|
# Only include the canonical fields per VAL-STRUCT-011
|
|
items.append(d)
|
|
|
|
# Sort by address offset
|
|
if sort_key == "address":
|
|
items.sort(
|
|
key=lambda x: int((x.get("address") or {}).get("offset", "0x0").lstrip("0x") or "0", 16)
|
|
)
|
|
elif sort_key == "name":
|
|
items.sort(key=lambda x: x.get("name", ""))
|
|
|
|
total = len(items)
|
|
offset = 0
|
|
|
|
# Build filters dict for cursor scoping
|
|
filters: dict[str, Any] = {
|
|
"exclude_external": exclude_external,
|
|
"exclude_thunks": exclude_thunks,
|
|
}
|
|
|
|
if cursor_str:
|
|
cursor_data = _decode_cursor(cursor_str)
|
|
offset = _validate_cursor_scope(
|
|
cursor_data, command, project_id, filters=filters, sort_key=sort_key
|
|
)
|
|
|
|
page_items = items[offset : offset + limit]
|
|
has_more = (offset + limit) < total
|
|
next_cursor: str | None = None
|
|
if has_more:
|
|
next_cursor = _make_cursor(
|
|
command=command,
|
|
project_id=project_id,
|
|
offset=offset + limit,
|
|
filters=filters,
|
|
sort_key=sort_key,
|
|
)
|
|
|
|
# Build applied_filters showing the active exclusion state
|
|
applied_filters: list[dict[str, Any]] = [
|
|
{"filter": "exclude_external", "active": exclude_external},
|
|
{"filter": "exclude_thunks", "active": exclude_thunks},
|
|
]
|
|
|
|
data: dict[str, Any] = {
|
|
"items": page_items,
|
|
"total": total,
|
|
"has_more": has_more,
|
|
"next_cursor": next_cursor,
|
|
"applied_filters": applied_filters,
|
|
}
|
|
|
|
diagnostics: list[dict[str, Any]] = []
|
|
warnings: list[dict[str, Any]] = []
|
|
if clamp_warning:
|
|
warnings.append(make_warning(clamp_warning, severity="WARNING", category="pagination"))
|
|
|
|
if project_state and project_state != "READY":
|
|
diagnostics.append(
|
|
{
|
|
"severity": "INFO",
|
|
"message": (
|
|
"Project has not been fully analyzed. "
|
|
"Results may be incomplete. "
|
|
"Run 'binary analyze --project <proj>' for complete analysis."
|
|
),
|
|
"category": "analysis_state",
|
|
}
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"partial": False,
|
|
"warnings": warnings,
|
|
"diagnostics": diagnostics,
|
|
"data": data,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Command: disassemble
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def execute_disassemble(args: argparse.Namespace) -> dict[str, Any]:
|
|
"""Execute the 'disassemble' command.
|
|
|
|
VAL-FOCUS-006: Disassemble by function selector returns instructions.
|
|
VAL-FOCUS-007: Disassemble by address range returns instructions in bounds.
|
|
VAL-FOCUS-008: No range/selector → exit code 2.
|
|
VAL-FOCUS-009: Unmapped range → exit code 9.
|
|
VAL-FOCUS-010: Partially mapped → partial=true with diagnostics.
|
|
"""
|
|
project_name = args.project
|
|
target: str | None = getattr(args, "target", None)
|
|
|
|
# VAL-FOCUS-008: Require explicit target
|
|
if not target:
|
|
raise InvalidArgsError(
|
|
"Disassembly requires a bounded target. "
|
|
"Provide a function selector (e.g., 'function:main') or "
|
|
"an address range (e.g., '0x401000..0x401200')."
|
|
)
|
|
|
|
project_path = _resolve_project_path(project_name)
|
|
manifest = load_manifest(project_path)
|
|
|
|
adapter, binary_entity, __ = _get_adapter_and_binary(project_path, manifest)
|
|
|
|
# Determine if target is a function selector or address range
|
|
is_function_selector = target.startswith("function:")
|
|
is_address_range = ".." in target and not target.startswith("function:")
|
|
|
|
if is_function_selector:
|
|
# VAL-FOCUS-006: Disassemble by function selector
|
|
func_name = target[len("function:") :]
|
|
if not func_name:
|
|
raise InvalidArgsError("Function selector requires a function name: 'function:<name>'.")
|
|
|
|
# Find the function by name
|
|
try:
|
|
all_functions = adapter.get_functions(
|
|
binary_entity, exclude_external=False, exclude_thunks=False
|
|
)
|
|
except BinaryAnalysisError:
|
|
raise
|
|
except Exception as e:
|
|
raise BackendFailureError(
|
|
f"Failed to retrieve functions: {e}", original_error=str(e)
|
|
) from e
|
|
|
|
# Find matching function(s)
|
|
matching = [fn for fn in all_functions if fn.name == func_name]
|
|
if not matching:
|
|
raise EntityNotFoundError("function", func_name)
|
|
|
|
function = matching[0]
|
|
if function.address is None:
|
|
raise EntityNotFoundError("function", func_name)
|
|
|
|
# Determine the range of the function
|
|
start_addr = function.address
|
|
# Calculate end address from size
|
|
start_int = int(start_addr.offset, 16)
|
|
end_int = start_int + function.size_bytes - 1
|
|
end_addr = Address(
|
|
space=start_addr.space,
|
|
offset=f"0x{end_int:x}",
|
|
display=f"0x{end_int:x}",
|
|
)
|
|
|
|
elif is_address_range:
|
|
# VAL-FOCUS-007: Disassemble by explicit address range
|
|
start_addr, end_addr = _parse_address_range(target)
|
|
else:
|
|
raise InvalidArgsError(
|
|
f"Invalid disassembly target: {target!r}. "
|
|
"Provide a function selector (e.g., 'function:main') or "
|
|
"an address range (e.g., '0x401000..0x401200')."
|
|
)
|
|
|
|
# Perform disassembly
|
|
try:
|
|
instructions = adapter.disassemble(binary_entity, start_addr, end_addr)
|
|
except ValueError as e:
|
|
msg = str(e)
|
|
if "unmapped" in msg.lower():
|
|
# VAL-FOCUS-009: Unmapped range → exit code 9
|
|
raise EntityNotFoundError(
|
|
"address range", f"{start_addr.offset}..{end_addr.offset}"
|
|
) from e
|
|
raise BackendFailureError(f"Disassembly failed: {e}", original_error=msg) from e
|
|
except BinaryAnalysisError:
|
|
raise
|
|
except Exception as e:
|
|
raise BackendFailureError(f"Disassembly failed: {e}", original_error=str(e)) from e
|
|
|
|
# Convert to dicts
|
|
instr_dicts = [_entity_to_dict(inst) for inst in instructions]
|
|
|
|
# Check for partial mapping (VAL-FOCUS-010)
|
|
partial = False
|
|
diagnostics: list[dict[str, Any]] = []
|
|
|
|
if len(instructions) == 0:
|
|
# No instructions returned — the range might be unmapped
|
|
raise EntityNotFoundError("address range", f"{start_addr.offset}..{end_addr.offset}")
|
|
|
|
# Check if the result is partial (last instruction doesn't reach end)
|
|
if instructions:
|
|
last_addr = instructions[-1].address
|
|
if last_addr is not None:
|
|
last_offset = int(last_addr.offset, 16)
|
|
end_offset = int(end_addr.offset, 16)
|
|
if last_offset < end_offset:
|
|
partial = True
|
|
diagnostics.append(
|
|
{
|
|
"severity": "WARNING",
|
|
"message": (
|
|
f"Address range {start_addr.offset}..{end_addr.offset} "
|
|
"is partially mapped. Disassembly covers only the mapped "
|
|
f"portion up to {last_addr.offset}."
|
|
),
|
|
"category": "partial_mapping",
|
|
}
|
|
)
|
|
|
|
data: dict[str, Any] = {
|
|
"instructions": instr_dicts,
|
|
"start_address": start_addr.to_dict(),
|
|
"end_address": end_addr.to_dict(),
|
|
"instruction_count": len(instr_dicts),
|
|
"target": target,
|
|
}
|
|
|
|
return {
|
|
"success": True,
|
|
"partial": partial,
|
|
"warnings": [],
|
|
"diagnostics": diagnostics,
|
|
"data": data,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Command: bytes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def execute_bytes(args: argparse.Namespace) -> dict[str, Any]:
|
|
"""Execute the 'bytes' command.
|
|
|
|
VAL-FOCUS-011: Returns hex (2*length chars) and base64.
|
|
VAL-FOCUS-012: Unmapped address → exit code 9.
|
|
VAL-FOCUS-013: Zero-length request → exit code 2.
|
|
VAL-FOCUS-014: Truncation at segment boundary → partial=true with diagnostic.
|
|
"""
|
|
project_name = args.project
|
|
addr_str: str | None = getattr(args, "address", None)
|
|
length: int | None = getattr(args, "length", None)
|
|
|
|
# Validate address
|
|
if addr_str is None:
|
|
raise InvalidArgsError(
|
|
"The 'bytes' command requires an address argument (e.g., '0x401000')."
|
|
)
|
|
|
|
# VAL-FOCUS-013: Zero-length request rejected
|
|
if length is None:
|
|
raise InvalidArgsError("The 'bytes' command requires a length argument (positive integer).")
|
|
if length <= 0:
|
|
raise InvalidArgsError(f"Length must be a positive integer, got {length}.")
|
|
|
|
address = _parse_address(addr_str)
|
|
|
|
project_path = _resolve_project_path(project_name)
|
|
manifest = load_manifest(project_path)
|
|
|
|
adapter, binary_entity, __ = _get_adapter_and_binary(project_path, manifest)
|
|
|
|
# Read bytes from adapter
|
|
try:
|
|
raw_bytes, actual_length = adapter.read_bytes(binary_entity, address, length)
|
|
except ValueError as e:
|
|
msg = str(e)
|
|
if "unmapped" in msg.lower():
|
|
# VAL-FOCUS-012: Unmapped address → exit code 9
|
|
raise EntityNotFoundError("address", addr_str) from e
|
|
if "positive" in msg.lower() or "length" in msg.lower():
|
|
raise InvalidArgsError(msg) from e
|
|
raise BackendFailureError(f"Failed to read bytes: {e}", original_error=msg) from e
|
|
except BinaryAnalysisError:
|
|
raise
|
|
except Exception as e:
|
|
raise BackendFailureError(f"Failed to read bytes: {e}", original_error=str(e)) from e
|
|
|
|
# Build hex and base64 output
|
|
hex_str = raw_bytes.hex()
|
|
b64_str = base64.standard_b64encode(raw_bytes).decode("ascii")
|
|
|
|
# VAL-FOCUS-014: Truncation detection
|
|
partial = actual_length < length
|
|
diagnostics: list[dict[str, Any]] = []
|
|
|
|
if partial:
|
|
diagnostics.append(
|
|
{
|
|
"severity": "WARNING",
|
|
"message": (
|
|
f"Requested {length} bytes at {addr_str}, but only {actual_length} "
|
|
f"bytes are available within the mapped segment. "
|
|
"The data has been truncated at the segment boundary."
|
|
),
|
|
"category": "truncation",
|
|
}
|
|
)
|
|
|
|
# VAL-FOCUS-011: Verify hex length
|
|
# hex should be 2 * actual_length characters
|
|
assert len(hex_str) == 2 * actual_length, (
|
|
f"Hex output length mismatch: expected {2 * actual_length}, got {len(hex_str)}"
|
|
)
|
|
|
|
data: dict[str, Any] = {
|
|
"hex": hex_str,
|
|
"base64": b64_str,
|
|
"address": address.to_dict(),
|
|
"length": actual_length,
|
|
"requested_length": length,
|
|
}
|
|
|
|
return {
|
|
"success": True,
|
|
"partial": partial,
|
|
"warnings": [],
|
|
"diagnostics": diagnostics,
|
|
"data": data,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Command: decompile
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _validate_decompile_selector(raw: str) -> str:
|
|
"""Validate and normalize a decompile selector.
|
|
|
|
The decompile command accepts exactly one function selector.
|
|
Multiple selectors (comma-separated), wildcards ('*'), and address
|
|
ranges ('..') are rejected with INVALID_ARGS (exit code 2).
|
|
|
|
Args:
|
|
raw: The raw selector string from the CLI.
|
|
|
|
Returns:
|
|
The normalized function name string.
|
|
|
|
Raises:
|
|
InvalidArgsError: If the selector is invalid.
|
|
"""
|
|
if not raw:
|
|
raise InvalidArgsError(
|
|
"Decompile requires exactly one function selector. "
|
|
"Provide a function selector (e.g., 'function:main') or "
|
|
"a shorthand function name (e.g., 'main')."
|
|
)
|
|
|
|
# VAL-FOCUS-003: Reject multiple selectors (comma-separated)
|
|
if "," in raw:
|
|
raise InvalidArgsError(
|
|
"Decompile requires exactly one function selector. "
|
|
f"Multiple selectors are not supported: {raw!r}. "
|
|
"Provide a single function selector like 'function:main' or 'main'."
|
|
)
|
|
|
|
# VAL-FOCUS-003: Reject wildcards
|
|
if "*" in raw:
|
|
raise InvalidArgsError(
|
|
"Decompile requires exactly one function selector. "
|
|
f"Wildcards are not supported: {raw!r}. "
|
|
"Provide a single function selector like 'function:main' or 'main'."
|
|
)
|
|
|
|
# VAL-FOCUS-003: Reject address ranges
|
|
if ".." in raw:
|
|
raise InvalidArgsError(
|
|
"Decompile requires exactly one function selector. "
|
|
f"Address ranges are not supported: {raw!r}. "
|
|
"Provide a single function selector like 'function:main' or 'main'."
|
|
)
|
|
|
|
# Check for empty function: prefix (e.g., "function:" with no name)
|
|
if raw.strip().lower().startswith("function:") and len(raw.strip()) <= len("function:"):
|
|
raise InvalidArgsError(
|
|
"Decompile requires a valid function selector. "
|
|
f"Empty function name in selector: {raw!r}. "
|
|
"Provide a function selector like 'function:main' or 'main'."
|
|
)
|
|
|
|
# Parse the selector
|
|
parsed = parse_selector(raw)
|
|
|
|
# VAL-FOCUS-003: Reject non-function selectors (e.g., address:...)
|
|
if parsed.kind == "address":
|
|
raise InvalidArgsError(
|
|
"Decompile requires exactly one function selector. "
|
|
f"Address selectors are not supported: {raw!r}. "
|
|
"Provide a function selector like 'function:main' or 'main'."
|
|
)
|
|
|
|
# Extract function name
|
|
func_name = parsed.value
|
|
if not func_name:
|
|
raise InvalidArgsError(
|
|
"Decompile requires a valid function selector. "
|
|
f"Empty selector value in: {raw!r}. "
|
|
"Provide a function selector like 'function:main' or 'main'."
|
|
)
|
|
|
|
return func_name
|
|
|
|
|
|
def execute_decompile(args: argparse.Namespace) -> dict[str, Any]:
|
|
"""Execute the 'decompile' command.
|
|
|
|
VAL-FOCUS-001: Returns pseudocode (labeled as reconstructed), address_map, diagnostics.
|
|
VAL-FOCUS-002: Ambiguous selector → exit code 8 with candidate functions list.
|
|
VAL-FOCUS-003: Multiple selectors/wildcards/ranges → exit code 2.
|
|
VAL-FOCUS-004: Entity not found → exit code 9.
|
|
VAL-FOCUS-005: Timeout → partial results with exit code 12.
|
|
VAL-FOCUS-032: Large function respects time limit; no crash or hang.
|
|
"""
|
|
project_name = args.project
|
|
raw_selector: str | None = getattr(args, "selector", None)
|
|
timeout_seconds: int = getattr(args, "timeout", 300)
|
|
|
|
if not raw_selector:
|
|
raise InvalidArgsError(
|
|
"Decompile requires exactly one function selector. "
|
|
"Provide a function selector (e.g., 'function:main') or "
|
|
"a shorthand function name (e.g., 'main')."
|
|
)
|
|
|
|
# Validate selector (exactly one function, no wildcards/ranges/multiples)
|
|
_ = _validate_decompile_selector(raw_selector)
|
|
|
|
project_path = _resolve_project_path(project_name)
|
|
manifest = load_manifest(project_path)
|
|
|
|
adapter, binary_entity, _project_info = _get_adapter_and_binary(project_path, manifest)
|
|
|
|
# Retrieve all functions and resolve the selector
|
|
try:
|
|
all_functions = adapter.get_functions(
|
|
binary_entity, exclude_external=False, exclude_thunks=False
|
|
)
|
|
except BinaryAnalysisError:
|
|
raise
|
|
except Exception as e:
|
|
raise BackendFailureError(
|
|
f"Failed to retrieve functions for decompilation: {e}",
|
|
original_error=str(e),
|
|
) from e
|
|
|
|
# Resolve the function selector
|
|
parsed = parse_selector(raw_selector)
|
|
selected_function = resolve_function(parsed, all_functions, require_unique=True)
|
|
|
|
# Build function info for the result
|
|
fn_info: dict[str, Any] = {
|
|
"name": selected_function.name,
|
|
"address": selected_function.address.to_dict() if selected_function.address else None,
|
|
"size_bytes": selected_function.size_bytes,
|
|
"signature": selected_function.signature,
|
|
}
|
|
|
|
# Perform decompilation with timeout
|
|
try:
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
future = executor.submit(adapter.decompile, binary_entity, selected_function)
|
|
try:
|
|
decomp_result = future.result(timeout=timeout_seconds)
|
|
except concurrent.futures.TimeoutError:
|
|
# VAL-FOCUS-005, VAL-FOCUS-032: Timeout → partial results
|
|
future.cancel()
|
|
raise OperationTimeoutError(
|
|
f"Decompilation of function '{selected_function.name}' "
|
|
f"timed out after {timeout_seconds}s. "
|
|
"Partial results may be available from a shorter analysis run."
|
|
) from None
|
|
except OperationTimeoutError:
|
|
raise
|
|
except BinaryAnalysisError:
|
|
raise
|
|
except Exception as e:
|
|
raise BackendFailureError(
|
|
f"Decompilation failed for function '{selected_function.name}': {e}",
|
|
original_error=str(e),
|
|
) from e
|
|
|
|
# Build the address map: string keys for line numbers → canonical address objects
|
|
address_map: dict[str, Any] = {}
|
|
for line_num, addr_obj in decomp_result.address_map.items():
|
|
address_map[str(line_num)] = addr_obj
|
|
|
|
# Build diagnostics
|
|
diagnostics: list[dict[str, Any]] = list(decomp_result.diagnostics)
|
|
manifest_state = manifest.get("state", "")
|
|
if manifest_state and manifest_state != "READY":
|
|
diagnostics.append(
|
|
{
|
|
"severity": "INFO",
|
|
"message": (
|
|
"Project has not been fully analyzed. "
|
|
"Decompilation results may be incomplete. "
|
|
"Run 'binary analyze --project <proj>' for complete analysis."
|
|
),
|
|
"category": "analysis_state",
|
|
}
|
|
)
|
|
|
|
data: dict[str, Any] = {
|
|
"pseudocode": decomp_result.pseudocode,
|
|
"address_map": address_map,
|
|
"diagnostics": diagnostics,
|
|
"language": decomp_result.language,
|
|
"function": fn_info,
|
|
}
|
|
|
|
return {
|
|
"success": True,
|
|
"partial": False,
|
|
"warnings": [],
|
|
"diagnostics": diagnostics,
|
|
"data": data,
|
|
}
|