"""Unit tests for focused analysis CLI commands. Covers: functions, disassemble, bytes, and decompile. Validates against: - VAL-STRUCT-011, 012, 013: Functions - 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 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, "cursor": None, "sort": "address", "target": None, "address": None, "length": None, "no_exclude_external": False, "no_exclude_thunks": False, } defaults.update(kwargs) class Args: pass args = Args() for k, v in defaults.items(): setattr(args, k, v) return args # --------------------------------------------------------------------------- # Test: Functions command # --------------------------------------------------------------------------- class TestFunctionsCommand: """Tests for the 'functions' command (VAL-STRUCT-011, 012, 013).""" def test_functions_basic(self, monkeypatch, project_ready): """VAL-STRUCT-011: Functions return name, address, size_bytes, confidence, name_source.""" from binary_analysis.cli.functions import execute_functions monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj") result = execute_functions(args) assert result["success"] is True items = result["data"]["items"] assert len(items) > 0 for fn in items: assert "name" in fn assert "address" in fn assert isinstance(fn["address"], dict) assert "space" in fn["address"] assert "offset" in fn["address"] assert "display" in fn["address"] assert "size_bytes" in fn assert isinstance(fn["size_bytes"], int) assert "confidence" in fn assert fn["confidence"] in ("HIGH", "MEDIUM", "LOW", "UNKNOWN") assert "name_source" in fn assert fn["name_source"] in ( "ORIGINAL", "IMPORTED", "DEBUG", "BACKEND_GENERATED", "USER_ANNOTATION", "AGENT_SUGGESTION", "UNKNOWN", ) # Pagination fields assert "total" in result["data"] assert "has_more" in result["data"] assert "next_cursor" in result["data"] def test_functions_exclude_external_and_thunks_by_default(self, monkeypatch, project_ready): """VAL-STRUCT-012: Excludes external/thunks by default; applied_filters shows both active.""" from binary_analysis.cli.functions import execute_functions monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj") result = execute_functions(args) assert "applied_filters" in result["data"] filters = result["data"]["applied_filters"] assert any(f["filter"] == "exclude_external" and f["active"] is True for f in filters) assert any(f["filter"] == "exclude_thunks" and f["active"] is True for f in filters) # No function should have name_source IMPORTED with external characteristics for fn in result["data"]["items"]: if fn.get("name_source") == "IMPORTED": # Imported functions that are also external would be excluded # If any slip through, they should not have is_external=True assert not fn.get("is_external", False) def test_functions_no_exclude_overrides(self, monkeypatch, project_ready): """VAL-STRUCT-013: --no-exclude-external --no-exclude-thunks shows both inactive, includes previously excluded functions.""" from binary_analysis.cli.functions import execute_functions monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) # First, get default (excluded) results args_default = _make_args(project="test-proj") result_default = execute_functions(args_default) default_count = result_default["data"]["total"] # Now with overrides args_all = _make_args( project="test-proj", no_exclude_external=True, no_exclude_thunks=True, ) result_all = execute_functions(args_all) # applied_filters should show both inactive filters_all = result_all["data"]["applied_filters"] assert any(f["filter"] == "exclude_external" and f["active"] is False for f in filters_all) assert any(f["filter"] == "exclude_thunks" and f["active"] is False for f in filters_all) # Total should be >= default (includes previously excluded functions) all_count = result_all["data"]["total"] assert all_count >= default_count def test_functions_pagination(self, monkeypatch, project_ready): """Functions cursor pagination works.""" from binary_analysis.cli.functions import execute_functions monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", limit=2) result = execute_functions(args) assert result["success"] is True assert len(result["data"]["items"]) <= 2 assert "has_more" in result["data"] assert "next_cursor" in result["data"] def test_functions_cursor_pagination_no_overlap(self, monkeypatch, project_ready): """Cursor from first page produces next page with no overlap.""" from binary_analysis.cli.functions import execute_functions monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args1 = _make_args(project="test-proj", limit=2) result1 = execute_functions(args1) cursor = result1["data"]["next_cursor"] items1 = result1["data"]["items"] if cursor: args2 = _make_args(project="test-proj", limit=2, cursor=cursor) result2 = execute_functions(args2) items2 = result2["data"]["items"] # No overlap between pages names1 = {fn["name"] for fn in items1} names2 = {fn["name"] for fn in items2} assert names1.isdisjoint(names2) def test_functions_cursor_mismatched_filters(self, monkeypatch, project_ready): """Cursor from one filter set cannot be used with a different filter set.""" from binary_analysis.cli.functions import execute_functions from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) # Get cursor with defaults args1 = _make_args(project="test-proj", limit=1) result1 = execute_functions(args1) cursor = result1["data"]["next_cursor"] if cursor: # Try with different filter args2 = _make_args( project="test-proj", limit=1, cursor=cursor, no_exclude_external=True, ) with pytest.raises(InvalidArgsError, match="filters"): execute_functions(args2) def test_functions_unanalyzed_project(self, monkeypatch, project_imported): """Unanalyzed project returns info diagnostic.""" from binary_analysis.cli.functions import execute_functions monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_imported), ) args = _make_args(project="test-proj") result = execute_functions(args) assert result["success"] is True diagnostics = result.get("diagnostics", []) assert any( d.get("severity") == "INFO" and "not been fully analyzed" in d.get("message", "") for d in diagnostics ) # --------------------------------------------------------------------------- # Test: Disassemble command # --------------------------------------------------------------------------- class TestDisassembleCommand: """Tests for the 'disassemble' command (VAL-FOCUS-006, 007, 008, 009, 010).""" def test_disassemble_by_function_selector(self, monkeypatch, project_ready): """VAL-FOCUS-006: Disassemble by function selector returns instructions.""" from binary_analysis.cli.functions import execute_disassemble monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", target="function:main") result = execute_disassemble(args) assert result["success"] is True instructions = result["data"]["instructions"] assert len(instructions) > 0 for inst in instructions: assert "mnemonic" in inst assert isinstance(inst["mnemonic"], str) assert "operands" in inst assert isinstance(inst["operands"], str) assert "bytes_hex" in inst assert isinstance(inst["bytes_hex"], str) assert "address" in inst assert isinstance(inst["address"], dict) assert "space" in inst["address"] assert "offset" in inst["address"] assert "display" in inst["address"] def test_disassemble_by_address_range(self, monkeypatch, project_ready): """VAL-FOCUS-007: Disassemble by explicit address range.""" from binary_analysis.cli.functions import execute_disassemble monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", target="0x401000..0x401200") result = execute_disassemble(args) assert result["success"] is True instructions = result["data"]["instructions"] assert len(instructions) > 0 # Check bounds: first instruction at or after start, last at or before end first_addr = int(instructions[0]["address"]["offset"], 16) last_addr = int(instructions[-1]["address"]["offset"], 16) assert first_addr >= 0x401000 assert last_addr <= 0x401200 # Verify range info in data assert result["data"]["start_address"]["offset"] == "0x401000" assert result["data"]["end_address"]["offset"] == "0x401200" def test_disassemble_no_target(self, monkeypatch, project_ready): """VAL-FOCUS-008: No range/selector → exit code 2.""" from binary_analysis.cli.functions import execute_disassemble from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", target=None) with pytest.raises(InvalidArgsError) as exc_info: execute_disassemble(args) assert "requires a bounded target" in str(exc_info.value) assert exc_info.value.exit_code == 2 def test_disassemble_function_not_found(self, monkeypatch, project_ready): """Nonexistent function returns exit code 9.""" from binary_analysis.cli.functions import execute_disassemble from binary_analysis.domain.errors import EntityNotFoundError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", target="function:nonexistent_func_xyz") with pytest.raises(EntityNotFoundError) as exc_info: execute_disassemble(args) assert exc_info.value.exit_code == 9 def test_disassemble_empty_function_name(self, monkeypatch, project_ready): """Empty function name after 'function:' prefix → error.""" from binary_analysis.cli.functions import execute_disassemble from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", target="function:") with pytest.raises(InvalidArgsError) as exc_info: execute_disassemble(args) assert exc_info.value.exit_code == 2 def test_disassemble_unmapped_range(self, monkeypatch, project_ready): """VAL-FOCUS-009: Unmapped range → exit code 9.""" from binary_analysis.cli.functions import execute_disassemble from binary_analysis.domain.errors import EntityNotFoundError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) # Configure unmapped range on the adapter import binary_analysis.cli.functions as func_mod original_get_adapter = func_mod._get_adapter_and_binary def patched_get_adapter(project_path, manifest): adapter, binary, proj_info = original_get_adapter(project_path, manifest) adapter.configure_unmapped_range(0x900000, 0x901000) adapter.configure_unmapped_range(0x900200, 0x900300) return adapter, binary, proj_info func_mod._get_adapter_and_binary = patched_get_adapter try: args = _make_args(project="test-proj", target="0x900000..0x900100") with pytest.raises(EntityNotFoundError) as exc_info: execute_disassemble(args) assert exc_info.value.exit_code == 9 finally: func_mod._get_adapter_and_binary = original_get_adapter def test_disassemble_partially_mapped_range(self, monkeypatch, project_ready): """VAL-FOCUS-010: Partially mapped range → partial=true with diagnostic.""" from binary_analysis.cli.functions import execute_disassemble monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) # The fake adapter generates instructions; if the range is large, # it will stop at 1000 instructions max. Use a range that is partially mapped # by requesting a large range where only a portion has valid instructions. args = _make_args(project="test-proj", target="0x401000..0x500000") result = execute_disassemble(args) assert result["success"] is True assert result["partial"] is True assert len(result["data"]["instructions"]) > 0 # Must have a diagnostic about partial mapping diagnostics = result.get("diagnostics", []) assert any( "partial" in d.get("category", "").lower() or "portion" in d.get("message", "").lower() or d.get("category") == "partial_mapping" for d in diagnostics ) def test_disassemble_invalid_range_format(self, monkeypatch, project_ready): """Malformed address range → error.""" from binary_analysis.cli.functions import execute_disassemble from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", target="not-a-valid-range") with pytest.raises(InvalidArgsError) as exc_info: execute_disassemble(args) assert exc_info.value.exit_code == 2 def test_disassemble_invalid_range_reversed(self, monkeypatch, project_ready): """Reversed address range (start > end) → error.""" from binary_analysis.cli.functions import execute_disassemble from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", target="0x401200..0x401000") with pytest.raises(InvalidArgsError) as exc_info: execute_disassemble(args) assert exc_info.value.exit_code == 2 def test_disassemble_target_field_present(self, monkeypatch, project_ready): """Result includes the target identifier.""" from binary_analysis.cli.functions import execute_disassemble monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", target="function:main") result = execute_disassemble(args) assert result["data"]["target"] == "function:main" assert "instruction_count" in result["data"] def test_disassemble_complete_range_not_partial(self, monkeypatch, project_ready): """A small range that is fully mapped returns partial=False.""" from binary_analysis.cli.functions import execute_disassemble monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) # Small range should be fully covered by generated instructions args = _make_args(project="test-proj", target="0x401000..0x401005") result = execute_disassemble(args) assert result["success"] is True # Small range might still be partial since instructions might overshoot # but for a very small range, the last instruction address may exceed end # --------------------------------------------------------------------------- # Test: Bytes command # --------------------------------------------------------------------------- class TestBytesCommand: """Tests for the 'bytes' command (VAL-FOCUS-011, 012, 013, 014).""" def test_bytes_basic(self, monkeypatch, project_ready): """VAL-FOCUS-011: Returns hex (2*length chars) and base64.""" from binary_analysis.cli.functions import execute_bytes monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", address="0x401000", length=16) result = execute_bytes(args) assert result["success"] is True assert "hex" in result["data"] assert "base64" in result["data"] assert "address" in result["data"] assert "length" in result["data"] # hex must be 2*length chars assert len(result["data"]["hex"]) == 2 * result["data"]["length"] # address must be canonical addr = result["data"]["address"] assert "space" in addr assert "offset" in addr assert "display" in addr # base64 must be valid and decodable import base64 decoded = base64.standard_b64decode(result["data"]["base64"]) assert len(decoded) == result["data"]["length"] # Verify hex matches decoded bytes assert decoded.hex() == result["data"]["hex"] def test_bytes_unmapped_address(self, monkeypatch, project_ready): """VAL-FOCUS-012: Unmapped address → exit code 9.""" from binary_analysis.cli.functions import execute_bytes from binary_analysis.domain.errors import EntityNotFoundError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) import binary_analysis.cli.functions as func_mod original_get_adapter = func_mod._get_adapter_and_binary def patched_get_adapter(project_path, manifest): adapter, binary, proj_info = original_get_adapter(project_path, manifest) adapter.configure_unmapped_range(0x900000, 0x901000) return adapter, binary, proj_info func_mod._get_adapter_and_binary = patched_get_adapter try: args = _make_args(project="test-proj", address="0x900000", length=16) with pytest.raises(EntityNotFoundError) as exc_info: execute_bytes(args) assert exc_info.value.exit_code == 9 finally: func_mod._get_adapter_and_binary = original_get_adapter def test_bytes_zero_length(self, monkeypatch, project_ready): """VAL-FOCUS-013: Zero-length request → exit code 2.""" from binary_analysis.cli.functions import execute_bytes from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", address="0x401000", length=0) with pytest.raises(InvalidArgsError) as exc_info: execute_bytes(args) assert exc_info.value.exit_code == 2 def test_bytes_negative_length(self, monkeypatch, project_ready): """Negative length → exit code 2.""" from binary_analysis.cli.functions import execute_bytes from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", address="0x401000", length=-1) with pytest.raises(InvalidArgsError) as exc_info: execute_bytes(args) assert exc_info.value.exit_code == 2 def test_bytes_truncation_at_boundary(self, monkeypatch, project_ready): """VAL-FOCUS-014: Truncation at segment boundary → partial=true with diagnostic.""" from binary_analysis.cli.functions import execute_bytes monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) import binary_analysis.cli.functions as func_mod original_get_adapter = func_mod._get_adapter_and_binary def patched_get_adapter(project_path, manifest): adapter, binary, proj_info = original_get_adapter(project_path, manifest) # Configure truncation: at address 0x401000, only 8 bytes available adapter.configure_truncation(0x401000, 8) return adapter, binary, proj_info func_mod._get_adapter_and_binary = patched_get_adapter try: args = _make_args(project="test-proj", address="0x401000", length=16) result = execute_bytes(args) assert result["success"] is True assert result["partial"] is True # Actual length < requested assert result["data"]["length"] < 16 assert result["data"]["requested_length"] == 16 # hex should be shorter than 2*16 assert len(result["data"]["hex"]) == 2 * result["data"]["length"] # Must have truncation diagnostic diagnostics = result.get("diagnostics", []) assert any( "truncat" in d.get("category", "").lower() or "truncat" in d.get("message", "").lower() for d in diagnostics ) finally: func_mod._get_adapter_and_binary = original_get_adapter def test_bytes_no_truncation(self, monkeypatch, project_ready): """No truncation when within segment → partial=false.""" from binary_analysis.cli.functions import execute_bytes monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", address="0x401000", length=4) result = execute_bytes(args) assert result["success"] is True assert result["partial"] is False assert result["data"]["length"] == 4 assert len(result["data"]["hex"]) == 8 def test_bytes_invalid_address_format(self, monkeypatch, project_ready): """Invalid address format → error.""" from binary_analysis.cli.functions import execute_bytes from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", address="not-an-address", length=16) with pytest.raises(InvalidArgsError): execute_bytes(args) def test_bytes_missing_address(self, monkeypatch, project_ready): """Missing address argument → error.""" from binary_analysis.cli.functions import execute_bytes from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", address=None, length=16) with pytest.raises(InvalidArgsError): execute_bytes(args) def test_bytes_missing_length(self, monkeypatch, project_ready): """Missing length argument → error.""" from binary_analysis.cli.functions import execute_bytes from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", address="0x401000", length=None) with pytest.raises(InvalidArgsError): execute_bytes(args) # --------------------------------------------------------------------------- # Test: Error handling # --------------------------------------------------------------------------- class TestErrorHandling: """Test error handling for focused analysis commands.""" def test_functions_binary_not_found(self, tmp_path): """Project with no binary returns error.""" import json as _json from datetime import datetime, timezone from binary_analysis.cli.functions import execute_functions from binary_analysis.domain.errors import BinaryNotFoundError project_dir = tmp_path / "empty-proj" project_dir.mkdir() manifest = { "id": "test-id", "name": "empty-proj", "state": "CREATED", "created_at": datetime.now(timezone.utc).isoformat(), "updated_at": datetime.now(timezone.utc).isoformat(), "workspace_version": "1", "binary_count": 0, "is_stale": False, } with open(project_dir / "project.json", "w") as f: _json.dump(manifest, f) import binary_analysis.cli.functions as func_mod original_resolve = func_mod._resolve_project_path func_mod._resolve_project_path = lambda _: str(project_dir) try: args = _make_args(project="empty-proj") with pytest.raises(BinaryNotFoundError): execute_functions(args) finally: func_mod._resolve_project_path = original_resolve def test_project_not_found(self): """Non-existent project returns error.""" from binary_analysis.cli.functions import execute_functions from binary_analysis.domain.errors import ProjectNotFoundError args = _make_args(project="nonexistent-12345") with pytest.raises(ProjectNotFoundError): execute_functions(args) # --------------------------------------------------------------------------- # Test: JSON format compliance # --------------------------------------------------------------------------- class TestJsonFormat: """Test JSON output format compliance for focused analysis commands.""" def test_functions_json_format(self, monkeypatch, project_ready): """Functions command produces valid paginated JSON.""" from binary_analysis.cli.functions import execute_functions monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj") result = execute_functions(args) # Core result fields assert "success" in result assert "partial" in result assert "warnings" in result assert "diagnostics" in result assert "data" in result # Data fields assert "items" in result["data"] assert "total" in result["data"] assert "has_more" in result["data"] assert "next_cursor" in result["data"] assert "applied_filters" in result["data"] assert isinstance(result["data"]["items"], list) assert isinstance(result["data"]["total"], int) assert isinstance(result["data"]["has_more"], bool) def test_disassemble_json_format(self, monkeypatch, project_ready): """Disassemble command produces valid JSON.""" from binary_analysis.cli.functions import execute_disassemble monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", target="function:main") result = execute_disassemble(args) assert "success" in result assert "partial" in result assert "warnings" in result assert "diagnostics" in result assert "data" in result data = result["data"] assert "instructions" in data assert "start_address" in data assert "end_address" in data assert "instruction_count" in data assert "target" in data assert isinstance(data["instructions"], list) assert isinstance(data["instruction_count"], int) def test_bytes_json_format(self, monkeypatch, project_ready): """Bytes command produces valid JSON.""" from binary_analysis.cli.functions import execute_bytes monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", address="0x401000", length=16) result = execute_bytes(args) assert "success" in result assert "partial" in result assert "warnings" in result assert "diagnostics" in result assert "data" in result data = result["data"] assert "hex" in data assert "base64" in data assert "address" in data assert "length" in data assert "requested_length" in data assert isinstance(data["hex"], str) assert isinstance(data["base64"], str) assert isinstance(data["length"], int) # --------------------------------------------------------------------------- # Test: Decompile command # --------------------------------------------------------------------------- class TestDecompileCommand: """Tests for the 'decompile' command (VAL-FOCUS-001, 002, 003, 004, 005, 032).""" def test_decompile_basic(self, monkeypatch, project_ready): """VAL-FOCUS-001: Decompile returns pseudocode, address_map, and diagnostics.""" from binary_analysis.cli.functions import execute_decompile monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="function:main") result = execute_decompile(args) assert result["success"] is True assert "data" in result data = result["data"] assert "pseudocode" in data assert isinstance(data["pseudocode"], str) assert len(data["pseudocode"]) > 0 assert "address_map" in data assert isinstance(data["address_map"], dict) assert "diagnostics" in data assert isinstance(data["diagnostics"], list) # Pseudocode must be labeled as reconstructed, not original source assert "reconstructed" in data["pseudocode"].lower() assert "original source" not in data["pseudocode"].lower() def test_decompile_shorthand_selector(self, monkeypatch, project_ready): """Decompile accepts shorthand selector without 'function:' prefix.""" from binary_analysis.cli.functions import execute_decompile monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="main") result = execute_decompile(args) assert result["success"] is True data = result["data"] assert "pseudocode" in data assert len(data["pseudocode"]) > 0 def test_decompile_address_map_structure(self, monkeypatch, project_ready): """VAL-FOCUS-001: Address map maps source line numbers to canonical address objects.""" from binary_analysis.cli.functions import execute_decompile monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="function:main") result = execute_decompile(args) address_map = result["data"]["address_map"] assert len(address_map) > 0 for line_key, addr_obj in address_map.items(): # line keys are strings representing integers assert isinstance(line_key, str) assert int(line_key) > 0 # address object is a canonical address assert isinstance(addr_obj, dict) assert "space" in addr_obj assert "offset" in addr_obj assert "display" in addr_obj def test_decompile_ambiguous_selector(self, monkeypatch, project_ready): """VAL-FOCUS-002: Ambiguous selector returns exit code 8 with candidate functions.""" from binary_analysis.cli.functions import execute_decompile from binary_analysis.domain.errors import AmbiguousSelectorError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) import binary_analysis.cli.functions as func_mod original_get_adapter = func_mod._get_adapter_and_binary def patched_get_adapter(project_path, manifest): adapter, binary, proj_info = original_get_adapter(project_path, manifest) # Add a duplicate-named function to create ambiguity from binary_analysis.domain.entities import Address, Function from binary_analysis.domain.enums import Confidence, FunctionNameSource dup_fn = Function( name="main", address=Address(space="ram", offset="0x402000", display="0x402000"), size_bytes=128, confidence=Confidence.HIGH, name_source=FunctionNameSource.ORIGINAL, is_external=False, is_thunk=False, ) # Configure override functions with duplicates override_fns = adapter._get_binary_fixture(binary).get("functions", []) override_fns = [*list(override_fns), dup_fn] adapter._override_functions[str(binary.id)] = override_fns return adapter, binary, proj_info func_mod._get_adapter_and_binary = patched_get_adapter try: args = _make_args(project="test-proj", selector="function:main") with pytest.raises(AmbiguousSelectorError) as exc_info: execute_decompile(args) assert exc_info.value.exit_code == 8 assert len(exc_info.value.candidates) > 1 # Verify candidate structure for candidate in exc_info.value.candidates: assert "name" in candidate assert "address" in candidate finally: func_mod._get_adapter_and_binary = original_get_adapter def test_decompile_multiple_selectors_rejected(self, monkeypatch, project_ready): """VAL-FOCUS-003: Multiple function selectors rejected with exit code 2.""" from binary_analysis.cli.functions import execute_decompile from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) # Simulate multiple selectors by passing a composite selector with comma # or try a wildcard pattern args = _make_args(project="test-proj", selector="function:main,function:check_password") with pytest.raises(InvalidArgsError) as exc_info: execute_decompile(args) assert exc_info.value.exit_code == 2 assert "single" in str(exc_info.value).lower() def test_decompile_wildcard_rejected(self, monkeypatch, project_ready): """VAL-FOCUS-003: Wildcard selector rejected with exit code 2.""" from binary_analysis.cli.functions import execute_decompile from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="function:*") with pytest.raises(InvalidArgsError) as exc_info: execute_decompile(args) assert exc_info.value.exit_code == 2 def test_decompile_range_rejected(self, monkeypatch, project_ready): """VAL-FOCUS-003: Address range selector rejected with exit code 2.""" from binary_analysis.cli.functions import execute_decompile from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="0x401000..0x401200") with pytest.raises(InvalidArgsError) as exc_info: execute_decompile(args) assert exc_info.value.exit_code == 2 assert "single" in str(exc_info.value).lower() or "function" in str(exc_info.value).lower() def test_decompile_no_selector(self, monkeypatch, project_ready): """VAL-FOCUS-003: No selector provided → exit code 2.""" from binary_analysis.cli.functions import execute_decompile from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector=None) with pytest.raises(InvalidArgsError) as exc_info: execute_decompile(args) assert exc_info.value.exit_code == 2 def test_decompile_entity_not_found(self, monkeypatch, project_ready): """VAL-FOCUS-004: Entity not found returns exit code 9, no pseudocode.""" from binary_analysis.cli.functions import execute_decompile from binary_analysis.domain.errors import EntityNotFoundError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="function:nonexistent_function_xyz") with pytest.raises(EntityNotFoundError) as exc_info: execute_decompile(args) assert exc_info.value.exit_code == 9 def test_decompile_timeout_partial_results(self, monkeypatch, project_ready): """VAL-FOCUS-005: Timeout returns partial results with exit code 12.""" from binary_analysis.cli.functions import execute_decompile from binary_analysis.domain.errors import OperationTimeoutError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) import binary_analysis.cli.functions as func_mod original_get_adapter = func_mod._get_adapter_and_binary def patched_get_adapter(project_path, manifest): adapter, binary, proj_info = original_get_adapter(project_path, manifest) # Make decompile slow (10 second delay) but timeout at 0.5s adapter.configure_slow_operation("decompile", 10.0) return adapter, binary, proj_info func_mod._get_adapter_and_binary = patched_get_adapter try: args = _make_args(project="test-proj", selector="function:main", timeout=1) with pytest.raises(OperationTimeoutError) as exc_info: execute_decompile(args) assert exc_info.value.exit_code == 12 finally: func_mod._get_adapter_and_binary = original_get_adapter def test_decompile_large_function_time_limit(self, monkeypatch, project_ready): """VAL-FOCUS-032: Large function decompilation respects time limit. Either completes within timeout with bounded output, or returns partial results with timeout. No crash or hang. """ from binary_analysis.cli.functions import execute_decompile monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) import binary_analysis.cli.functions as func_mod original_get_adapter = func_mod._get_adapter_and_binary def patched_get_adapter(project_path, manifest): adapter, binary, proj_info = original_get_adapter(project_path, manifest) # Simulate a function with many basic blocks (large function) # Use a moderate delay that should complete within timeout adapter.configure_slow_operation("decompile", 0.1) return adapter, binary, proj_info func_mod._get_adapter_and_binary = patched_get_adapter try: args = _make_args(project="test-proj", selector="function:main", timeout=10) result = execute_decompile(args) # Should complete within timeout - no crash or hang assert result["success"] is True assert "data" in result assert "pseudocode" in result["data"] assert len(result["data"]["pseudocode"]) > 0 finally: func_mod._get_adapter_and_binary = original_get_adapter def test_decompile_large_function_timeout_with_partial(self, monkeypatch, project_ready): """VAL-FOCUS-032: Large function that times out returns partial with exit 12.""" from binary_analysis.cli.functions import execute_decompile from binary_analysis.domain.errors import OperationTimeoutError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) import binary_analysis.cli.functions as func_mod original_get_adapter = func_mod._get_adapter_and_binary def patched_get_adapter(project_path, manifest): adapter, binary, proj_info = original_get_adapter(project_path, manifest) # Very slow decompile - should timeout adapter.configure_slow_operation("decompile", 10.0) return adapter, binary, proj_info func_mod._get_adapter_and_binary = patched_get_adapter try: args = _make_args(project="test-proj", selector="function:main", timeout=0.5) with pytest.raises(OperationTimeoutError) as exc_info: execute_decompile(args) assert exc_info.value.exit_code == 12 # Message should indicate timeout assert ( "timed out" in str(exc_info.value).lower() or "timeout" in str(exc_info.value).lower() ) finally: func_mod._get_adapter_and_binary = original_get_adapter def test_decompile_not_a_function_selector(self, monkeypatch, project_ready): """Non-function selectors like 'address:' are rejected with exit 2.""" from binary_analysis.cli.functions import execute_decompile from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="address:0x401000..0x401200") with pytest.raises(InvalidArgsError) as exc_info: execute_decompile(args) assert exc_info.value.exit_code == 2 def test_decompile_empty_function_name(self, monkeypatch, project_ready): """Empty function name after 'function:' prefix → error.""" from binary_analysis.cli.functions import execute_decompile from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="function:") with pytest.raises(InvalidArgsError) as exc_info: execute_decompile(args) assert exc_info.value.exit_code == 2 def test_decompile_backend_failure(self, monkeypatch, project_ready): """Backend failure during decompile → exit code 13.""" from binary_analysis.cli.functions import execute_decompile from binary_analysis.domain.errors import BackendFailureError monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) import binary_analysis.cli.functions as func_mod original_get_adapter = func_mod._get_adapter_and_binary def patched_get_adapter(project_path, manifest): adapter, binary, proj_info = original_get_adapter(project_path, manifest) adapter.configure_backend_failure("decompile", "Simulated decompile crash") return adapter, binary, proj_info func_mod._get_adapter_and_binary = patched_get_adapter try: args = _make_args(project="test-proj", selector="function:main") with pytest.raises(BackendFailureError) as exc_info: execute_decompile(args) assert exc_info.value.exit_code == 13 finally: func_mod._get_adapter_and_binary = original_get_adapter def test_decompile_json_format(self, monkeypatch, project_ready): """Decompile command produces valid JSON with all required fields.""" from binary_analysis.cli.functions import execute_decompile monkeypatch.setattr( "binary_analysis.cli.functions._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", selector="function:main") result = execute_decompile(args) assert "success" in result assert "partial" in result assert "warnings" in result assert "diagnostics" in result assert "data" in result data = result["data"] assert "pseudocode" in data assert "address_map" in data assert "diagnostics" in data assert "language" in data assert "function" in data assert isinstance(data["pseudocode"], str) assert isinstance(data["address_map"], dict) assert isinstance(data["diagnostics"], list) assert isinstance(data["language"], str)