"""Structural query commands — sections, entrypoints, imports, exports, symbols, and strings. All commands follow the standard JSON envelope pattern and return paginated results with cursor-based pagination. Cursors are scoped to command + project + filters + sort. """ from __future__ import annotations import argparse import base64 import hashlib import json from typing import Any from uuid import UUID, uuid4 from binary_analysis.cli.helpers import ( clamp_page_size, make_warning, ) from binary_analysis.domain.errors import ( BackendFailureError, BinaryAnalysisError, BinaryNotFoundError, InvalidArgsError, ProjectNotFoundError, ) from binary_analysis.projects.manifest import load_manifest from binary_analysis.projects.workspace import ( get_project_path, list_workspaces, workspace_exists, ) # --------------------------------------------------------------------------- # Project path resolution (shared with binary_ops) # --------------------------------------------------------------------------- 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) # --------------------------------------------------------------------------- # Cursor helper — scoped to command + project + filters + sort # --------------------------------------------------------------------------- def _encode_cursor(data: dict[str, Any]) -> str: """Encode a cursor dict as a base64-encoded JSON string.""" json_bytes = json.dumps(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. Raises InvalidArgsError if the cursor is malformed. """ 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 _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. The cursor encodes the command, project, filters hash, sort key, and offset so that cursors from different queries are rejected. """ filters_hash = hashlib.md5( json.dumps(filters or {}, sort_keys=True).encode("utf-8") ).hexdigest() return _encode_cursor( { "c": command, "p": project_id, "fh": filters_hash, "s": sort_key, "o": offset, } ) 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 a cursor matches the current query scope and return offset. Raises InvalidArgsError if the cursor is for a different command, project, filter set, or sort. """ 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 structural query subcommands.""" # -- Sections -- sections_parser = subparsers.add_parser( "sections", help="List canonical sections in the binary." ) sections_parser.add_argument("--project", required=True, help="Project name or UUID.") sections_parser.add_argument( "--cursor", default=None, help="Pagination cursor from previous response (next_cursor)." ) sections_parser.add_argument("--sort", default="address", help="Sort field (default: address).") # -- Entrypoints -- entrypoints_parser = subparsers.add_parser( "entrypoints", help="List entry points with confidence scoring." ) entrypoints_parser.add_argument("--project", required=True, help="Project name or UUID.") entrypoints_parser.add_argument( "--cursor", default=None, help="Pagination cursor from previous response (next_cursor)." ) # -- Imports -- imports_parser = subparsers.add_parser( "imports", help="List imported symbols with resolution status." ) imports_parser.add_argument("--project", required=True, help="Project name or UUID.") imports_parser.add_argument( "--cursor", default=None, help="Pagination cursor from previous response (next_cursor)." ) # -- Exports -- exports_parser = subparsers.add_parser("exports", help="List exported symbols.") exports_parser.add_argument("--project", required=True, help="Project name or UUID.") exports_parser.add_argument( "--cursor", default=None, help="Pagination cursor from previous response (next_cursor)." ) # -- Symbols -- symbols_parser = subparsers.add_parser("symbols", help="List symbols with source and scope.") symbols_parser.add_argument("--project", required=True, help="Project name or UUID.") symbols_parser.add_argument( "--cursor", default=None, help="Pagination cursor from previous response (next_cursor)." ) # -- Strings -- strings_parser = subparsers.add_parser( "strings", help="List decoded strings with encoding, address, and length." ) strings_parser.add_argument("--project", required=True, help="Project name or UUID.") strings_parser.add_argument( "--min-length", type=int, default=4, help="Minimum string length to return (default: 4).", ) strings_parser.add_argument( "--contains", default=None, help="Case-sensitive substring filter.", ) strings_parser.add_argument( "--encoding", default=None, choices=["ASCII", "UTF-8", "UTF-16"], help="Filter by string encoding.", ) strings_parser.add_argument( "--cursor", default=None, help="Pagination cursor from previous response (next_cursor)." ) # --------------------------------------------------------------------------- # Shared helpers for structural commands # --------------------------------------------------------------------------- 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"), ) # Map the binary to the appropriate fixture based on its format. # This is needed so the adapter knows which fixture to use for this binary. 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 def _entity_to_dict(entity: Any) -> dict[str, Any]: """Convert a domain entity to a JSON-serializable dict. Handles addresses (Address -> dict), UUIDs (UUID -> str), enums (Enum -> str), and None values. """ 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) # Skip binary_id — internal linking field, not part of canonical output if f.name == "binary_id": continue # Skip content_hash for sections unless present 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 def _build_structural_result( items: list[dict[str, Any]], total: int, offset: int, limit: int, command: str, project_id: str, filters: dict[str, Any] | None = None, sort_key: str | None = None, applied_filters: list[dict[str, Any]] | None = None, diagnostics_extra: list[dict[str, Any]] | None = None, warnings_extra: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """Build a paginated structural query result. The response uses next_cursor (not next_page_token) as per the validation contract naming convention. """ 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, ) data: dict[str, Any] = { "items": items, "total": total, "has_more": has_more, "next_cursor": next_cursor, } if applied_filters: data["applied_filters"] = applied_filters result: dict[str, Any] = { "success": True, "partial": False, "warnings": list(warnings_extra or []), "diagnostics": list(diagnostics_extra or []), "data": data, } return result # --------------------------------------------------------------------------- # Command execution # --------------------------------------------------------------------------- def execute_sections(args: argparse.Namespace) -> dict[str, Any]: """Execute the 'sections' command. Returns canonical section objects with pagination. """ 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") command = "sections" 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) # Get all sections try: sections = adapter.get_sections(binary_entity) except BinaryAnalysisError: raise except Exception as e: raise BackendFailureError(f"Failed to retrieve sections: {e}", original_error=str(e)) from e # Convert to dicts and sort items = [_entity_to_dict(s) for s in sections] # 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) ) total = len(items) offset = 0 # Decode cursor if present if cursor_str: cursor_data = _decode_cursor(cursor_str) offset = _validate_cursor_scope( cursor_data, command, project_id, filters=None, sort_key=sort_key, ) # Apply pagination page_items = items[offset : offset + limit] # Add info diagnostics for unanalyzed projects diagnostics: list[dict[str, Any]] = [] 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 ' for complete analysis." ), "category": "analysis_state", } ) return _build_structural_result( items=page_items, total=total, offset=offset, limit=limit, command=command, project_id=project_id, sort_key=sort_key, diagnostics_extra=diagnostics, warnings_extra=( [make_warning(clamp_warning, severity="WARNING", category="pagination")] if clamp_warning else None ), ) def execute_entrypoints(args: argparse.Namespace) -> dict[str, Any]: """Execute the 'entrypoints' command. Returns entry point objects with kind and confidence. """ project_name = args.project limit, clamp_warning = clamp_page_size(getattr(args, "limit", None)) cursor_str: str | None = getattr(args, "cursor", None) command = "entrypoints" 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: entrypoints = adapter.get_entrypoints(binary_entity) except BinaryAnalysisError: raise except Exception as e: raise BackendFailureError( f"Failed to retrieve entrypoints: {e}", original_error=str(e) ) from e items = [_entity_to_dict(ep) for ep in entrypoints] items.sort( key=lambda x: int((x.get("address") or {}).get("offset", "0x0").lstrip("0x") or "0", 16) ) total = len(items) offset = 0 if cursor_str: cursor_data = _decode_cursor(cursor_str) offset = _validate_cursor_scope( cursor_data, command, project_id, filters=None, sort_key=None, ) page_items = items[offset : offset + limit] diagnostics: list[dict[str, Any]] = [] 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 ' for complete analysis." ), "category": "analysis_state", } ) return _build_structural_result( items=page_items, total=total, offset=offset, limit=limit, command=command, project_id=project_id, diagnostics_extra=diagnostics, warnings_extra=( [make_warning(clamp_warning, severity="WARNING", category="pagination")] if clamp_warning else None ), ) def execute_imports(args: argparse.Namespace) -> dict[str, Any]: """Execute the 'imports' command. Returns imported symbols with module, symbol, address, resolution, ordinal. """ project_name = args.project limit, clamp_warning = clamp_page_size(getattr(args, "limit", None)) cursor_str: str | None = getattr(args, "cursor", None) command = "imports" 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: imports = adapter.get_imports(binary_entity) except BinaryAnalysisError: raise except Exception as e: raise BackendFailureError(f"Failed to retrieve imports: {e}", original_error=str(e)) from e items = [_entity_to_dict(imp) for imp in imports] items.sort( key=lambda x: int((x.get("address") or {}).get("offset", "0x0").lstrip("0x") or "0", 16) ) total = len(items) offset = 0 if cursor_str: cursor_data = _decode_cursor(cursor_str) offset = _validate_cursor_scope( cursor_data, command, project_id, filters=None, sort_key=None, ) page_items = items[offset : offset + limit] diagnostics: list[dict[str, Any]] = [] 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 ' for complete analysis." ), "category": "analysis_state", } ) return _build_structural_result( items=page_items, total=total, offset=offset, limit=limit, command=command, project_id=project_id, diagnostics_extra=diagnostics, warnings_extra=( [make_warning(clamp_warning, severity="WARNING", category="pagination")] if clamp_warning else None ), ) def execute_exports(args: argparse.Namespace) -> dict[str, Any]: """Execute the 'exports' command. Returns exported symbols with name, address, ordinal, forwarder, kind. """ project_name = args.project limit, clamp_warning = clamp_page_size(getattr(args, "limit", None)) cursor_str: str | None = getattr(args, "cursor", None) command = "exports" 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: exports = adapter.get_exports(binary_entity) except BinaryAnalysisError: raise except Exception as e: raise BackendFailureError(f"Failed to retrieve exports: {e}", original_error=str(e)) from e items = [_entity_to_dict(exp) for exp in exports] items.sort( key=lambda x: int((x.get("address") or {}).get("offset", "0x0").lstrip("0x") or "0", 16) ) total = len(items) offset = 0 if cursor_str: cursor_data = _decode_cursor(cursor_str) offset = _validate_cursor_scope( cursor_data, command, project_id, filters=None, sort_key=None, ) page_items = items[offset : offset + limit] diagnostics: list[dict[str, Any]] = [] 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 ' for complete analysis." ), "category": "analysis_state", } ) return _build_structural_result( items=page_items, total=total, offset=offset, limit=limit, command=command, project_id=project_id, diagnostics_extra=diagnostics, warnings_extra=( [make_warning(clamp_warning, severity="WARNING", category="pagination")] if clamp_warning else None ), ) def execute_symbols(args: argparse.Namespace) -> dict[str, Any]: """Execute the 'symbols' command. Returns symbols with name, address, source, scope. IMPORTED symbols are cross-linked to imports table. """ project_name = args.project limit, clamp_warning = clamp_page_size(getattr(args, "limit", None)) cursor_str: str | None = getattr(args, "cursor", None) command = "symbols" 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) # Get both symbols and imports for cross-linking try: symbols = adapter.get_symbols(binary_entity) imports = adapter.get_imports(binary_entity) except BinaryAnalysisError: raise except Exception as e: raise BackendFailureError(f"Failed to retrieve symbols: {e}", original_error=str(e)) from e # Build import lookup by address for cross-linking import_by_addr: dict[str, dict[str, Any]] = {} for imp in imports: if imp.address is not None: addr_key = imp.address.offset import_by_addr[addr_key] = { "module": imp.module, "symbol": imp.symbol, "resolution": str(imp.resolution.value), } # Convert symbols to dicts with cross-linking items = [] for sym in symbols: sym_dict = _entity_to_dict(sym) # Cross-link IMPORTED symbols to imports table if str(sym.source.value) == "IMPORTED" and sym.address is not None: imp_info = import_by_addr.get(sym.address.offset) if imp_info: sym_dict["import"] = imp_info else: # Try matching by name for imp in imports: if imp.symbol == sym.name: sym_dict["import"] = { "module": imp.module, "symbol": imp.symbol, "resolution": str(imp.resolution.value), } break items.append(sym_dict) items.sort( key=lambda x: int((x.get("address") or {}).get("offset", "0x0").lstrip("0x") or "0", 16) ) total = len(items) offset = 0 if cursor_str: cursor_data = _decode_cursor(cursor_str) offset = _validate_cursor_scope( cursor_data, command, project_id, filters=None, sort_key=None, ) page_items = items[offset : offset + limit] diagnostics: list[dict[str, Any]] = [] 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 ' for complete analysis." ), "category": "analysis_state", } ) return _build_structural_result( items=page_items, total=total, offset=offset, limit=limit, command=command, project_id=project_id, diagnostics_extra=diagnostics, warnings_extra=( [make_warning(clamp_warning, severity="WARNING", category="pagination")] if clamp_warning else None ), ) def execute_strings(args: argparse.Namespace) -> dict[str, Any]: """Execute the 'strings' command. Returns decoded strings with text, encoding, address, length. Supports --min-length, --contains, --encoding filters. Combined filters work together and are reported in applied_filters. """ project_name = args.project limit, clamp_warning = clamp_page_size(getattr(args, "limit", None)) cursor_str: str | None = getattr(args, "cursor", None) min_length: int = getattr(args, "min_length", 4) contains: str | None = getattr(args, "contains", None) encoding_filter: str | None = getattr(args, "encoding", None) command = "strings" 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) # Build filters dict for cursor scoping filters: dict[str, Any] = {} if min_length != 4: # Only track non-default filters["min_length"] = min_length if contains is not None: filters["contains"] = contains if encoding_filter is not None: filters["encoding"] = encoding_filter # Build applied_filters for response applied_filters: list[dict[str, Any]] = [] if min_length != 4 or min_length == 4: applied_filters.append({"filter": "min_length", "value": min_length}) if contains is not None: applied_filters.append({"filter": "contains", "value": contains}) if encoding_filter is not None: applied_filters.append({"filter": "encoding", "value": encoding_filter}) try: strings = adapter.get_strings( binary_entity, min_length=min_length, contains=contains, encoding_filter=encoding_filter, ) except BinaryAnalysisError: raise except Exception as e: raise BackendFailureError(f"Failed to retrieve strings: {e}", original_error=str(e)) from e items = [_entity_to_dict(s) for s in strings] # Sort by address for deterministic pagination items.sort( key=lambda x: int((x.get("address") or {}).get("offset", "0x0").lstrip("0x") or "0", 16) ) total = len(items) offset = 0 if cursor_str: cursor_data = _decode_cursor(cursor_str) offset = _validate_cursor_scope( cursor_data, command, project_id, filters=filters, sort_key=None, ) page_items = items[offset : offset + limit] diagnostics: list[dict[str, Any]] = [] 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 ' for complete analysis." ), "category": "analysis_state", } ) return _build_structural_result( items=page_items, total=total, offset=offset, limit=limit, command=command, project_id=project_id, filters=filters if filters else None, applied_filters=applied_filters, diagnostics_extra=diagnostics, warnings_extra=( [make_warning(clamp_warning, severity="WARNING", category="pagination")] if clamp_warning else None ), )