"""Unit tests for cross-reference and call graph CLI commands. Covers: xrefs, callers, callees, and callgraph. Validates against: - 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 json import sys import tempfile from pathlib import Path import pytest _skill_dir = Path(__file__).resolve().parents[2] sys.path.insert(0, str(_skill_dir / "scripts")) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def temp_workspace(): """Create a temporary workspace directory for testing.""" with tempfile.TemporaryDirectory() as tmpdir: workspace_root = Path(tmpdir) yield workspace_root @pytest.fixture def project_imported(temp_workspace): """Create a project with an imported binary.""" import uuid from datetime import datetime, timezone project_id = str(uuid.uuid4()) binary_id = str(uuid.uuid4()) project_dir = temp_workspace / "test-proj" project_dir.mkdir(parents=True, exist_ok=True) manifest = { "id": project_id, "name": "test-proj", "state": "IMPORTED", "created_at": datetime.now(timezone.utc).isoformat(), "updated_at": datetime.now(timezone.utc).isoformat(), "workspace_version": "1", "binary_count": 1, "is_stale": False, "current_binary": { "id": binary_id, "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "path": "/tmp/test.bin", "format": "PE", "import_mode": "copy", "size_bytes": 16384, "architecture": "x86", }, } binaries_dir = project_dir / "binaries" binaries_dir.mkdir(exist_ok=True) with open(binaries_dir / f"{binary_id}.json", "w") as f: json.dump(manifest["current_binary"], f) with open(project_dir / "project.json", "w") as f: json.dump(manifest, f) return project_dir @pytest.fixture def project_ready(project_imported): """Create a project in READY (analyzed) state.""" project_dir = project_imported with open(project_dir / "project.json") as f: manifest = json.load(f) manifest["state"] = "READY" with open(project_dir / "project.json", "w") as f: json.dump(manifest, f) return project_dir # --------------------------------------------------------------------------- # Helper: build args # --------------------------------------------------------------------------- def _make_args(**kwargs): """Create a mock argparse.Namespace.""" defaults = { "json": True, "quiet": False, "limit": None, "timeout": 300, "project": "test-proj", "selector": None, "depth": 3, "command": "", } defaults.update(kwargs) class Args: pass args = Args() for k, v in defaults.items(): setattr(args, k, v) return args # --------------------------------------------------------------------------- # Test: Xrefs command # --------------------------------------------------------------------------- class TestXrefsCommand: """Tests for the 'xrefs' command (VAL-FOCUS-015, 016, 017).""" def test_xrefs_returns_references_with_kind_and_confidence(self, monkeypatch, project_ready): """VAL-FOCUS-015: Xrefs returns references with from, to (address objects), kind (ReferenceKind), and confidence; provenance present.""" from binary_analysis.cli.references import execute_xrefs monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="function:main") result = execute_xrefs(args) assert result["success"] is True references = result["data"]["references"] assert isinstance(references, list) for ref in references: assert "from" in ref, "Each reference must have a 'from' address" assert isinstance(ref["from"], dict) assert "space" in ref["from"] assert "offset" in ref["from"] assert "display" in ref["from"] assert "to" in ref, "Each reference must have a 'to' address" assert isinstance(ref["to"], dict) assert "space" in ref["to"] assert "offset" in ref["to"] assert "display" in ref["to"] assert "kind" in ref, "Each reference must have a 'kind'" assert ref["kind"] in ( "CALL", "JUMP", "READ", "WRITE", "DATA", "IMPORT", "EXPORT", "INDIRECT", "UNKNOWN", ) assert "confidence" in ref, "Each reference must have 'confidence'" assert ref["confidence"] in ("HIGH", "MEDIUM", "LOW", "UNKNOWN") def test_xrefs_on_leaf_function_returns_empty(self, monkeypatch, project_ready): """VAL-FOCUS-016: Xrefs on entity with zero references returns exit 0 with empty array, no error diagnostics.""" from binary_analysis.cli.references import execute_xrefs monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) # Patch the adapter's get_xrefs to return empty for this specific function import binary_analysis.cli.references as refs_mod original_get_adapter = refs_mod._get_adapter_and_binary def patched_get_adapter(project_path, manifest): adapter, binary, proj_info = original_get_adapter(project_path, manifest) # Override get_xrefs to return empty list for print_message original_get_xrefs = adapter.get_xrefs def mock_get_xrefs(binary_entity, address): if address.offset == "0x401400": return [] return original_get_xrefs(binary_entity, address) adapter.get_xrefs = mock_get_xrefs return adapter, binary, proj_info refs_mod._get_adapter_and_binary = patched_get_adapter try: args = _make_args(project="test-proj", selector="print_message") result = execute_xrefs(args) assert result["success"] is True references = result["data"]["references"] assert references == [] # No error diagnostics error_diags = [d for d in result.get("diagnostics", []) if d.get("severity") == "ERROR"] assert len(error_diags) == 0 finally: refs_mod._get_adapter_and_binary = original_get_adapter def test_xrefs_nonexistent_entity(self, monkeypatch, project_ready): """VAL-FOCUS-017: Xrefs on nonexistent entity returns exit code 9.""" from binary_analysis.cli.references import execute_xrefs from binary_analysis.domain.errors import EntityNotFoundError monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="function:nonexistent_func_xyz") with pytest.raises(EntityNotFoundError) as exc_info: execute_xrefs(args) assert exc_info.value.exit_code == 9 def test_xrefs_on_address(self, monkeypatch, project_ready): """Xrefs accepts an address selector.""" from binary_analysis.cli.references import execute_xrefs monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="0x401000") result = execute_xrefs(args) assert result["success"] is True assert isinstance(result["data"]["references"], list) def test_xrefs_invalid_address_format(self, monkeypatch, project_ready): """Xrefs on invalid selector that isn't a function name → EntityNotFoundError.""" from binary_analysis.cli.references import execute_xrefs from binary_analysis.domain.errors import EntityNotFoundError monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="not_a_valid_thing") with pytest.raises(EntityNotFoundError) as exc_info: execute_xrefs(args) assert exc_info.value.exit_code == 9 # --------------------------------------------------------------------------- # Test: Callers command # --------------------------------------------------------------------------- class TestCallersCommand: """Tests for the 'callers' command (VAL-FOCUS-018, 019).""" def test_callers_returns_function_objects(self, monkeypatch, project_ready): """VAL-FOCUS-018: Callers returns array of function objects (name/symbol, address) calling the target; depth/node limits disclosed.""" from binary_analysis.cli.references import execute_callers monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="function:check_password") result = execute_callers(args) assert result["success"] is True callers = result["data"]["callers"] assert isinstance(callers, list) for caller in callers: assert "name" in caller assert "address" in caller assert isinstance(caller["address"], dict) assert "space" in caller["address"] assert "offset" in caller["address"] # Limits disclosed in data assert "max_depth" in result["data"] assert "max_nodes" in result["data"] def test_callers_on_leaf_function_returns_empty(self, monkeypatch, project_ready): """VAL-FOCUS-019: Callers on leaf function returns exit 0 with empty array.""" from binary_analysis.cli.references import execute_callers monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) # main is the first function and has no callers in our fixture args = _make_args(project="test-proj", selector="function:main") result = execute_callers(args) assert result["success"] is True callers = result["data"]["callers"] assert callers == [] def test_callers_nonexistent_function(self, monkeypatch, project_ready): """Callers on nonexistent function → exit code 9.""" from binary_analysis.cli.references import execute_callers from binary_analysis.domain.errors import EntityNotFoundError monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="function:nonexistent_func_xyz") with pytest.raises(EntityNotFoundError) as exc_info: execute_callers(args) assert exc_info.value.exit_code == 9 def test_callers_no_selector(self, monkeypatch, project_ready): """Callers without selector → exit code 2.""" from binary_analysis.cli.references import execute_callers from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector=None) with pytest.raises(InvalidArgsError) as exc_info: execute_callers(args) assert exc_info.value.exit_code == 2 # --------------------------------------------------------------------------- # Test: Callees command # --------------------------------------------------------------------------- class TestCalleesCommand: """Tests for the 'callees' command (VAL-FOCUS-020, 021).""" def test_callees_returns_function_objects(self, monkeypatch, project_ready): """VAL-FOCUS-020: Callees returns array of function objects called by target; depth/node limits disclosed.""" from binary_analysis.cli.references import execute_callees monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="function:main") result = execute_callees(args) assert result["success"] is True callees = result["data"]["callees"] assert isinstance(callees, list) for callee in callees: assert "name" in callee assert "address" in callee assert isinstance(callee["address"], dict) assert "space" in callee["address"] assert "offset" in callee["address"] # Limits disclosed in data assert "max_depth" in result["data"] assert "max_nodes" in result["data"] def test_callees_on_terminal_function_returns_empty(self, monkeypatch, project_ready): """VAL-FOCUS-021: Callees on terminal function returns exit 0 with empty array.""" from binary_analysis.cli.references import execute_callees monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) # print_message is the last internal function; it has printf as # a callee in the simple chain model. Use printf which has no callees. args = _make_args(project="test-proj", selector="printf") result = execute_callees(args) assert result["success"] is True callees = result["data"]["callees"] assert callees == [] def test_callees_nonexistent_function(self, monkeypatch, project_ready): """Callees on nonexistent function → exit code 9.""" from binary_analysis.cli.references import execute_callees from binary_analysis.domain.errors import EntityNotFoundError monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="function:nonexistent_func_xyz") with pytest.raises(EntityNotFoundError) as exc_info: execute_callees(args) assert exc_info.value.exit_code == 9 # --------------------------------------------------------------------------- # Test: Callgraph command # --------------------------------------------------------------------------- class TestCallgraphCommand: """Tests for the 'callgraph' command (VAL-FOCUS-022, 023, 024, 031).""" def test_callgraph_returns_graph_with_nodes_and_edges(self, monkeypatch, project_ready): """VAL-FOCUS-022: Callgraph returns graph with nodes (functions) and edges (call relationships); root is target; depth disclosed.""" from binary_analysis.cli.references import execute_callgraph monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="function:main", depth=3) result = execute_callgraph(args) assert result["success"] is True graph = result["data"]["graph"] assert "nodes" in graph assert "edges" in graph assert graph["root_address"] is not None # Root node is the target function root_addr = graph["root_address"] assert root_addr["offset"] == "0x401000" # Depth disclosed assert "max_depth" in graph assert graph["max_depth"] == 3 # Nodes are function objects for node in graph["nodes"]: assert "name" in node assert "address" in node assert isinstance(node["address"], dict) assert "depth" in node, "Each node must have a depth level" # Edges have from/to for edge in graph["edges"]: assert "from" in edge assert "to" in edge assert "kind" in edge def test_callgraph_default_depth_is_3(self, monkeypatch, project_ready): """Callgraph default depth is bounded (max 3).""" from binary_analysis.cli.references import execute_callgraph monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="function:main", depth=3) result = execute_callgraph(args) assert result["success"] is True graph = result["data"]["graph"] assert graph["max_depth"] <= 3 # All nodes are at depth 0, 1, 2, or 3 for node in graph["nodes"]: assert 0 <= node["depth"] <= 3 def test_callgraph_explicit_depth_2(self, monkeypatch, project_ready): """VAL-FOCUS-023: Callgraph --depth 2 limits graph to exactly 2 levels; applied depth disclosed.""" from binary_analysis.cli.references import execute_callgraph monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="function:main", depth=2) result = execute_callgraph(args) assert result["success"] is True graph = result["data"]["graph"] assert graph["max_depth"] == 2, "Applied depth must be disclosed as 2" # No nodes at depth 3 or greater for node in graph["nodes"]: assert node["depth"] <= 2, f"Node at depth {node['depth']} exceeds max_depth=2" def test_callgraph_depth_zero_fails(self, monkeypatch, project_ready): """VAL-FOCUS-024: Callgraph --depth 0 fails with exit code 2, 'depth must be a positive integer'.""" from binary_analysis.cli.references import execute_callgraph from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="function:main", depth=0) with pytest.raises(InvalidArgsError) as exc_info: execute_callgraph(args) assert exc_info.value.exit_code == 2 assert "positive" in str(exc_info.value).lower() def test_callgraph_depth_negative_fails(self, monkeypatch, project_ready): """VAL-FOCUS-024: Callgraph --depth -1 fails with exit code 2, 'depth must be a positive integer'.""" from binary_analysis.cli.references import execute_callgraph from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="function:main", depth=-1) with pytest.raises(InvalidArgsError) as exc_info: execute_callgraph(args) assert exc_info.value.exit_code == 2 assert "positive" in str(exc_info.value).lower() def test_callgraph_breadth_limit_enforced(self, monkeypatch, project_ready): """VAL-FOCUS-031: Callgraph breadth limits: bounded node count with truncation diagnostic when exceeded.""" from binary_analysis.cli.references import execute_callgraph monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) # Configure adapter to have many callers at depth 1 import binary_analysis.cli.references as refs_mod original_get_adapter = refs_mod._get_adapter_and_binary def patched_get_adapter(project_path, manifest): from uuid import UUID, uuid4 from binary_analysis.adapters.fake import FakeAdapter from binary_analysis.domain.entities import ( Address, Binary, Function, ) from binary_analysis.domain.enums import Confidence, FunctionNameSource 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()) current_binary = manifest.get("current_binary", {}) binary_id_str = current_binary.get("id", str(uuid4())) binary_entity = Binary( id=UUID(binary_id_str), 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"), ) adapter._binaries[binary_id_str] = { "binary": binary_entity, "fixture_name": "pe-default", } # Create 200 functions at depth 1 to trigger breadth limit functions = [] for i in range(200): addr = Address( space="ram", offset=f"0x{0x500000 + i * 64:x}", display=f"0x{0x500000 + i * 64:x}", ) functions.append( Function( name=f"leaf_func_{i}", address=addr, size_bytes=32, confidence=Confidence.HIGH, name_source=FunctionNameSource.ORIGINAL, ) ) # Override functions for this binary adapter._override_functions[binary_id_str] = [ Function( name="root_func", address=Address(space="ram", offset="0x401000", display="0x401000"), size_bytes=128, confidence=Confidence.HIGH, name_source=FunctionNameSource.ORIGINAL, ), *functions, ] # Set max callgraph breadth limit to 50 for testing adapter._callgraph_max_breadth = 50 # Override get_callgraph to return a breadth-limited graph with 200 nodes from binary_analysis.domain.entities import CallGraph def breadth_callgraph(binary, function, max_depth=3): """Return a callgraph with 200 nodes to test breadth limits.""" nodes = [ { "name": "root_func", "address": Address( space="ram", offset="0x401000", display="0x401000" ).to_dict(), "depth": 0, } ] edges = [] for i in range(200): addr = Address( space="ram", offset=f"0x{0x500000 + i * 64:x}", display=f"0x{0x500000 + i * 64:x}", ) nodes.append( { "name": f"leaf_func_{i}", "address": addr.to_dict(), "depth": 1, } ) edges.append( { "from": nodes[0]["address"], "to": addr.to_dict(), "kind": "CALL", } ) return CallGraph( root_address=Address(space="ram", offset="0x401000", display="0x401000"), nodes=nodes, edges=edges, max_depth=max_depth, total_nodes=len(nodes), total_edges=len(edges), truncated=False, ) adapter.get_callgraph = breadth_callgraph project_info = { "id": manifest.get("id", ""), "name": manifest.get("name", ""), "state": manifest.get("state", ""), } return adapter, binary_entity, project_info refs_mod._get_adapter_and_binary = patched_get_adapter try: args = _make_args(project="test-proj", selector="function:root_func", depth=1) result = execute_callgraph(args) assert result["success"] is True graph = result["data"]["graph"] # Node count must be bounded assert graph["total_nodes"] <= 52 # root + limit (50) + possible extra # Must have a truncation diagnostic diagnostics = result.get("diagnostics", []) assert any( "trunc" in d.get("category", "").lower() or "trunc" in d.get("message", "").lower() or "limit" in d.get("message", "").lower() or graph.get("truncated", False) for d in diagnostics ), "Must have truncation diagnostic when breadth limit exceeded" finally: refs_mod._get_adapter_and_binary = original_get_adapter def test_callgraph_nonexistent_function(self, monkeypatch, project_ready): """Callgraph on nonexistent function → exit code 9.""" from binary_analysis.cli.references import execute_callgraph from binary_analysis.domain.errors import EntityNotFoundError monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="function:nonexistent_func_xyz") with pytest.raises(EntityNotFoundError) as exc_info: execute_callgraph(args) assert exc_info.value.exit_code == 9 def test_callgraph_no_selector(self, monkeypatch, project_ready): """Callgraph without selector → exit code 2.""" from binary_analysis.cli.references import execute_callgraph from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.references._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector=None) with pytest.raises(InvalidArgsError) as exc_info: execute_callgraph(args) assert exc_info.value.exit_code == 2