Files

1302 lines
50 KiB
Python

"""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)