"""Cross-reference and call graph commands — xrefs, callers, callees, callgraph. All commands follow the standard JSON envelope pattern. Xrefs returns cross-references with from/to addresses, kind (ReferenceKind), and confidence. Callers lists functions that call the target. Callees lists functions called by the target. Callgraph builds a bounded graph rooted at a target function. Validation assertions covered: - VAL-FOCUS-015, 016, 017: Xrefs - VAL-FOCUS-018, 019: Callers - VAL-FOCUS-020, 021: Callees - VAL-FOCUS-022, 023, 024, 031: Callgraph """ from __future__ import annotations import argparse from typing import Any from uuid import UUID, uuid4 from binary_analysis.domain.entities import Address from binary_analysis.domain.errors import ( BackendFailureError, BinaryAnalysisError, BinaryNotFoundError, EntityNotFoundError, InvalidArgsError, 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, ) # --------------------------------------------------------------------------- # Breadth limits (for callgraph node bounding) # --------------------------------------------------------------------------- DEFAULT_MAX_CALLGRAPH_NODES = 100 DEFAULT_MAX_DEPTH = 3 MAX_DEPTH_LIMIT = 10 # --------------------------------------------------------------------------- # Project path resolution # --------------------------------------------------------------------------- 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 # --------------------------------------------------------------------------- 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 # --------------------------------------------------------------------------- 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 and resolution # --------------------------------------------------------------------------- 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, ) # --------------------------------------------------------------------------- # Subparser registration # --------------------------------------------------------------------------- def add_subparser(subparsers: Any) -> None: """Register reference query subcommands: xrefs, callers, callees, callgraph.""" # -- Xrefs -- xrefs_parser = subparsers.add_parser( "xrefs", help="List cross-references to/from an entity (function or address).", ) xrefs_parser.add_argument("--project", required=True, help="Project name or UUID.") xrefs_parser.add_argument( "selector", nargs="?", default=None, help=( "Entity selector: function: (e.g., 'function:main') or " "a hex address (e.g., '0x401000')." ), ) # -- Callers -- callers_parser = subparsers.add_parser( "callers", help="List functions that call the target function.", ) callers_parser.add_argument("--project", required=True, help="Project name or UUID.") callers_parser.add_argument( "selector", nargs="?", default=None, help="Function selector: function: (e.g., 'function:main') or shorthand name.", ) # -- Callees -- callees_parser = subparsers.add_parser( "callees", help="List functions called by the target function.", ) callees_parser.add_argument("--project", required=True, help="Project name or UUID.") callees_parser.add_argument( "selector", nargs="?", default=None, help="Function selector: function: (e.g., 'function:main') or shorthand name.", ) # -- Callgraph -- callgraph_parser = subparsers.add_parser( "callgraph", help="Build a bounded call graph rooted at a target function.", ) callgraph_parser.add_argument("--project", required=True, help="Project name or UUID.") callgraph_parser.add_argument( "selector", nargs="?", default=None, help="Function selector: function: (e.g., 'function:main') or shorthand name.", ) callgraph_parser.add_argument( "--depth", type=int, default=DEFAULT_MAX_DEPTH, help=f"Maximum call graph depth (positive integer, default: {DEFAULT_MAX_DEPTH}, max: {MAX_DEPTH_LIMIT}).", ) # --------------------------------------------------------------------------- # Command: xrefs # --------------------------------------------------------------------------- def execute_xrefs(args: argparse.Namespace) -> dict[str, Any]: """Execute the 'xrefs' command. VAL-FOCUS-015: Returns references with from, to (address objects), kind (ReferenceKind), confidence; provenance present. VAL-FOCUS-016: Empty references result is valid (exit 0, no error diagnostics). VAL-FOCUS-017: Entity not found returns exit code 9 (ENTITY_NOT_FOUND). """ project_name = args.project raw_selector: str | None = getattr(args, "selector", None) if not raw_selector: raise InvalidArgsError( "The 'xrefs' command requires an entity selector. " "Provide a function selector (e.g., 'function:main') or " "a hex address (e.g., '0x401000')." ) project_path = _resolve_project_path(project_name) manifest = load_manifest(project_path) adapter, binary_entity, _project_info = _get_adapter_and_binary(project_path, manifest) # Parse the selector parsed = parse_selector(raw_selector) # Determine the address to look up xrefs for if parsed.is_address: # Address selector: use parsed address directly try: addr = _parse_address(parsed.value) except InvalidArgsError as err: raise InvalidArgsError( f"Invalid entity selector for xrefs: {raw_selector!r}. " "Use a function selector (e.g., 'function:main') or " "a hex address (e.g., '0x401000')." ) from err else: # Function selector: resolve the function, then use its address 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 xrefs: {e}", original_error=str(e), ) from e selected_function = resolve_function(parsed, all_functions, require_unique=True) if selected_function.address is None: raise EntityNotFoundError("function", raw_selector) addr = selected_function.address # Retrieve cross-references try: references = adapter.get_xrefs(binary_entity, addr) except BinaryAnalysisError: raise except Exception as e: raise BackendFailureError( f"Failed to retrieve cross-references: {e}", original_error=str(e), ) from e # Convert to dicts ref_dicts = [] for ref in references: d = _entity_to_dict(ref) # Rename from_addr -> from, to_addr -> to for the JSON contract if "from_addr" in d: d["from"] = d.pop("from_addr") if "to_addr" in d: d["to"] = d.pop("to_addr") ref_dicts.append(d) diagnostics: list[dict[str, Any]] = [] manifest_state = manifest.get("state", "") if manifest_state and manifest_state != "READY": diagnostics.append( { "severity": "INFO", "message": ( "Project has not been fully analyzed. " "Cross-reference results may be incomplete. " "Run 'binary analyze --project ' for complete analysis." ), "category": "analysis_state", } ) data: dict[str, Any] = { "references": ref_dicts, "total": len(ref_dicts), "selector": raw_selector, "max_references": 1000, } return { "success": True, "partial": False, "warnings": [], "diagnostics": diagnostics, "data": data, } # --------------------------------------------------------------------------- # Command: callers # --------------------------------------------------------------------------- def execute_callers(args: argparse.Namespace) -> dict[str, Any]: """Execute the 'callers' command. VAL-FOCUS-018: Returns array of function objects (name/symbol, address) calling the target; depth/node limits disclosed. VAL-FOCUS-019: Leaf function returns exit 0 with empty array. """ project_name = args.project raw_selector: str | None = getattr(args, "selector", None) if not raw_selector: raise InvalidArgsError( "The 'callers' command requires a function selector. " "Provide a function selector (e.g., 'function:main') or " "a shorthand function name (e.g., 'main')." ) project_path = _resolve_project_path(project_name) manifest = load_manifest(project_path) adapter, binary_entity, _project_info = _get_adapter_and_binary(project_path, manifest) # Resolve the function parsed = parse_selector(raw_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 callers: {e}", original_error=str(e), ) from e selected_function = resolve_function(parsed, all_functions, require_unique=True) # Retrieve callers try: call_edges = adapter.get_callers(binary_entity, selected_function) except BinaryAnalysisError: raise except Exception as e: raise BackendFailureError( f"Failed to retrieve callers: {e}", original_error=str(e), ) from e # Convert CallEdge list to function objects caller_dicts = [] for edge in call_edges: caller = { "name": edge.from_name, "address": edge.from_address.to_dict() if edge.from_address else None, "kind": edge.kind, } caller_dicts.append(caller) diagnostics: list[dict[str, Any]] = [] manifest_state = manifest.get("state", "") if manifest_state and manifest_state != "READY": diagnostics.append( { "severity": "INFO", "message": ( "Project has not been fully analyzed. " "Caller results may be incomplete. " "Run 'binary analyze --project ' for complete analysis." ), "category": "analysis_state", } ) data: dict[str, Any] = { "callers": caller_dicts, "total": len(caller_dicts), "target": { "name": selected_function.name, "address": selected_function.address.to_dict() if selected_function.address else None, }, "max_depth": 1, "max_nodes": 1000, } return { "success": True, "partial": False, "warnings": [], "diagnostics": diagnostics, "data": data, } # --------------------------------------------------------------------------- # Command: callees # --------------------------------------------------------------------------- def execute_callees(args: argparse.Namespace) -> dict[str, Any]: """Execute the 'callees' command. VAL-FOCUS-020: Returns array of function objects called by target; depth/node limits disclosed. VAL-FOCUS-021: Terminal function returns exit 0 with empty array. """ project_name = args.project raw_selector: str | None = getattr(args, "selector", None) if not raw_selector: raise InvalidArgsError( "The 'callees' command requires a function selector. " "Provide a function selector (e.g., 'function:main') or " "a shorthand function name (e.g., 'main')." ) project_path = _resolve_project_path(project_name) manifest = load_manifest(project_path) adapter, binary_entity, _project_info = _get_adapter_and_binary(project_path, manifest) # Resolve the function parsed = parse_selector(raw_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 callees: {e}", original_error=str(e), ) from e selected_function = resolve_function(parsed, all_functions, require_unique=True) # Retrieve callees try: call_edges = adapter.get_callees(binary_entity, selected_function) except BinaryAnalysisError: raise except Exception as e: raise BackendFailureError( f"Failed to retrieve callees: {e}", original_error=str(e), ) from e # Convert CallEdge list to function objects callee_dicts = [] for edge in call_edges: callee = { "name": edge.to_name, "address": edge.to_address.to_dict() if edge.to_address else None, "kind": edge.kind, } callee_dicts.append(callee) diagnostics: list[dict[str, Any]] = [] manifest_state = manifest.get("state", "") if manifest_state and manifest_state != "READY": diagnostics.append( { "severity": "INFO", "message": ( "Project has not been fully analyzed. " "Callee results may be incomplete. " "Run 'binary analyze --project ' for complete analysis." ), "category": "analysis_state", } ) data: dict[str, Any] = { "callees": callee_dicts, "total": len(callee_dicts), "target": { "name": selected_function.name, "address": selected_function.address.to_dict() if selected_function.address else None, }, "max_depth": 1, "max_nodes": 1000, } return { "success": True, "partial": False, "warnings": [], "diagnostics": diagnostics, "data": data, } # --------------------------------------------------------------------------- # Command: callgraph # --------------------------------------------------------------------------- def execute_callgraph(args: argparse.Namespace) -> dict[str, Any]: """Execute the 'callgraph' command. VAL-FOCUS-022: Builds bounded graph rooted at target function with nodes and edges; root is target; depth disclosed. VAL-FOCUS-023: --depth 2 limits graph to exactly 2 levels; applied depth disclosed. VAL-FOCUS-024: --depth 0 or --depth -1 fails with exit code 2, 'depth must be a positive integer'. VAL-FOCUS-031: Breadth limits enforced with truncation diagnostic and bounded node count. """ project_name = args.project raw_selector: str | None = getattr(args, "selector", None) depth: int = getattr(args, "depth", DEFAULT_MAX_DEPTH) # VAL-FOCUS-024: Validate depth is a positive integer if depth <= 0: raise InvalidArgsError( f"Depth must be a positive integer, got {depth}. " "Provide a positive depth value (e.g., --depth 2) or use the default (3)." ) if depth > MAX_DEPTH_LIMIT: raise InvalidArgsError( f"Depth {depth} exceeds maximum allowed depth of {MAX_DEPTH_LIMIT}. " f"Use a depth value between 1 and {MAX_DEPTH_LIMIT}." ) if not raw_selector: raise InvalidArgsError( "The 'callgraph' command requires a function selector. " "Provide a function selector (e.g., 'function:main') or " "a shorthand function name (e.g., 'main')." ) project_path = _resolve_project_path(project_name) manifest = load_manifest(project_path) adapter, binary_entity, _project_info = _get_adapter_and_binary(project_path, manifest) # Resolve the function parsed = parse_selector(raw_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 callgraph: {e}", original_error=str(e), ) from e selected_function = resolve_function(parsed, all_functions, require_unique=True) # Build the call graph try: callgraph = adapter.get_callgraph(binary_entity, selected_function, max_depth=depth) except BinaryAnalysisError: raise except Exception as e: raise BackendFailureError( f"Failed to build call graph: {e}", original_error=str(e), ) from e # Apply breadth limits (VAL-FOCUS-031) max_nodes = getattr(adapter, "_callgraph_max_breadth", DEFAULT_MAX_CALLGRAPH_NODES) nodes = list(callgraph.nodes) edges = list(callgraph.edges) truncated = callgraph.truncated # Apply node count bounding if there are too many nodes if len(nodes) > max_nodes: truncated = True nodes = nodes[:max_nodes] # Remove edges that reference truncated nodes valid_addrs = set() for n in nodes: addr = n.get("address", {}) offset = addr.get("offset", "") valid_addrs.add(offset) edges = [ e for e in edges if e.get("from", {}).get("offset", "") in valid_addrs and e.get("to", {}).get("offset", "") in valid_addrs ] total_nodes = len(nodes) total_edges = len(edges) graph_data: dict[str, Any] = { "root_address": callgraph.root_address.to_dict() if callgraph.root_address else None, "nodes": nodes, "edges": edges, "max_depth": depth, "total_nodes": total_nodes, "total_edges": total_edges, "truncated": truncated, } diagnostics: list[dict[str, Any]] = [] if truncated: diagnostics.append( { "severity": "WARNING", "message": ( f"Call graph truncated: total nodes bounded to {max_nodes}. " f"The graph contains {total_nodes} nodes and {total_edges} edges " f"after applying breadth limits. Some call targets beyond the " f"limit may have been omitted." ), "category": "truncation", } ) manifest_state = manifest.get("state", "") if manifest_state and manifest_state != "READY": diagnostics.append( { "severity": "INFO", "message": ( "Project has not been fully analyzed. " "Call graph results may be incomplete. " "Run 'binary analyze --project ' for complete analysis." ), "category": "analysis_state", } ) data: dict[str, Any] = { "graph": graph_data, "target": { "name": selected_function.name, "address": selected_function.address.to_dict() if selected_function.address else None, }, "applied_depth": depth, } return { "success": True, "partial": truncated, "warnings": [], "diagnostics": diagnostics, "data": data, }