Files
magnus919_agent-skills/binary-analysis/tests/unit/test_ghidra_adapter.py
T

613 lines
23 KiB
Python

"""Unit tests for the GhidraAdapter skeleton.
Tests cover:
- GhidraAdapter is a proper BackendAdapter subclass
- PROJECT_SERIALIZED concurrency declaration
- Capability detection reports available formats and profiles
- available_profiles() returns all three profiles
- validate_profile() accepts valid and rejects unknown profiles
- initialize() raises RuntimeError when PyGhidra not available
- Skeleton methods raise NotImplementedError with appropriate messages
- Error normalization maps Ghidra exceptions to canonical error types
- Bridge: PyGhidra availability detection
- Bridge: Ghidra version detection
- Bridge: JVM initialization guards
"""
from __future__ import annotations
import sys
from pathlib import Path
_scripts_dir = Path(__file__).resolve().parents[2] / "scripts"
if str(_scripts_dir) not in sys.path:
sys.path.insert(0, str(_scripts_dir))
from typing import ClassVar
import pytest
from binary_analysis.adapters.base import (
AnalysisProfile,
BackendAdapter,
ConcurrencyMode,
)
from binary_analysis.adapters.ghidra.adapter import GhidraAdapter
from binary_analysis.adapters.ghidra.bridge import (
ensure_initialized,
get_ghidra_version,
is_initialized,
is_pyghidra_available,
normalize_error,
)
from binary_analysis.domain.entities import (
Address,
Binary,
Function,
Project,
)
from binary_analysis.domain.enums import ExitCode
from binary_analysis.domain.errors import (
AnalysisFailedError,
BackendFailureError,
ImportFailedError,
OperationTimeoutError,
UnsupportedFormatError,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def adapter() -> GhidraAdapter:
"""Return a fresh GhidraAdapter instance."""
return GhidraAdapter()
# ---------------------------------------------------------------------------
# BackendAdapter interface compliance
# ---------------------------------------------------------------------------
class TestInterfaceCompliance:
"""Verify GhidraAdapter properly implements BackendAdapter."""
def test_subclass_of_backend_adapter(self, adapter: GhidraAdapter) -> None:
"""GhidraAdapter must subclass BackendAdapter."""
assert isinstance(adapter, BackendAdapter)
def test_concurrency_is_project_serialized(self, adapter: GhidraAdapter) -> None:
"""GhidraAdapter must declare PROJECT_SERIALIZED concurrency."""
assert adapter.concurrency == ConcurrencyMode.PROJECT_SERIALIZED
def test_has_all_required_methods(self, adapter: GhidraAdapter) -> None:
"""GhidraAdapter must have all BackendAdapter abstract methods.
Checks that all methods from the abstract interface exist and
are callable (raising NotImplementedError is acceptable for
skeleton methods).
"""
required_methods = [
"initialize",
"capabilities",
"available_profiles",
"validate_profile",
"import_binary",
"analyze",
"get_metadata",
"get_sections",
"get_entrypoints",
"get_imports",
"get_exports",
"get_symbols",
"get_strings",
"get_functions",
"decompile",
"disassemble",
"read_bytes",
"get_xrefs",
"get_callers",
"get_callees",
"get_callgraph",
]
for method_name in required_methods:
method = getattr(adapter, method_name, None)
assert method is not None, f"Missing method: {method_name}"
assert callable(method), f"Method not callable: {method_name}"
def test_concurrency_property_is_enum(self, adapter: GhidraAdapter) -> None:
"""concurrency property must return a ConcurrencyMode enum value."""
mode = adapter.concurrency
assert isinstance(mode, ConcurrencyMode)
assert mode.value == "PROJECT_SERIALIZED"
# ---------------------------------------------------------------------------
# Capability detection
# ---------------------------------------------------------------------------
class TestCapabilities:
"""Verify capability detection reports correct information."""
def test_capabilities_returns_expected_structure(self, adapter: GhidraAdapter) -> None:
"""capabilities() must have all required top-level keys."""
caps = adapter.capabilities()
required_keys = {
"backend",
"backend_version",
"adapter",
"adapter_version",
"concurrency",
"pyghidra_available",
"jvm_initialized",
"formats",
"architectures",
"profiles",
"limitations",
}
for key in required_keys:
assert key in caps, f"Missing capability key: {key}"
def test_capabilities_backend_is_ghidra(self, adapter: GhidraAdapter) -> None:
"""backend field must always be 'Ghidra'."""
caps = adapter.capabilities()
assert caps["backend"] == "Ghidra"
def test_capabilities_adapter_name(self, adapter: GhidraAdapter) -> None:
"""adapter field must be 'GhidraAdapter'."""
caps = adapter.capabilities()
assert caps["adapter"] == "GhidraAdapter"
def test_capabilities_concurrency(self, adapter: GhidraAdapter) -> None:
"""concurrency field must match PROJECT_SERIALIZED."""
caps = adapter.capabilities()
assert caps["concurrency"] == "PROJECT_SERIALIZED"
def test_capabilities_formats_is_list(self, adapter: GhidraAdapter) -> None:
"""formats must be a non-empty list of format strings."""
caps = adapter.capabilities()
assert isinstance(caps["formats"], list)
assert len(caps["formats"]) > 0
assert all(isinstance(f, str) for f in caps["formats"])
def test_capabilities_architectures_is_list(self, adapter: GhidraAdapter) -> None:
"""architectures must be a non-empty list of architecture strings."""
caps = adapter.capabilities()
assert isinstance(caps["architectures"], list)
assert len(caps["architectures"]) > 0
assert all(isinstance(a, str) for a in caps["architectures"])
def test_capabilities_profiles_is_list(self, adapter: GhidraAdapter) -> None:
"""profiles must be a non-empty list of profile dicts."""
caps = adapter.capabilities()
assert isinstance(caps["profiles"], list)
assert len(caps["profiles"]) > 0
for profile in caps["profiles"]:
assert "name" in profile
assert "description" in profile
assert "analyser_count" in profile
def test_capabilities_profiles_include_standard_quick_deep(
self, adapter: GhidraAdapter
) -> None:
"""profiles must include standard, quick, and deep."""
caps = adapter.capabilities()
profile_names = {p["name"] for p in caps["profiles"]}
assert "standard" in profile_names
assert "quick" in profile_names
assert "deep" in profile_names
def test_capabilities_limitations_is_list(self, adapter: GhidraAdapter) -> None:
"""limitations must be a list of strings."""
caps = adapter.capabilities()
assert isinstance(caps["limitations"], list)
assert len(caps["limitations"]) > 0
assert all(isinstance(item, str) for item in caps["limitations"])
def test_capabilities_pyghidra_available_is_bool(self, adapter: GhidraAdapter) -> None:
"""pyghidra_available must be a boolean."""
caps = adapter.capabilities()
assert isinstance(caps["pyghidra_available"], bool)
def test_capabilities_jvm_initialized_is_bool(self, adapter: GhidraAdapter) -> None:
"""jvm_initialized must be a boolean."""
caps = adapter.capabilities()
assert isinstance(caps["jvm_initialized"], bool)
# ---------------------------------------------------------------------------
# Available profiles
# ---------------------------------------------------------------------------
class TestAvailableProfiles:
"""Verify available_profiles() returns correct profiles."""
def test_available_profiles_returns_three(self, adapter: GhidraAdapter) -> None:
"""available_profiles() must return exactly 3 profiles."""
profiles = adapter.available_profiles()
assert len(profiles) == 3
def test_available_profiles_are_analysis_profiles(self, adapter: GhidraAdapter) -> None:
"""All returned profiles must be AnalysisProfile instances."""
profiles = adapter.available_profiles()
for p in profiles:
assert isinstance(p, AnalysisProfile)
def test_available_profiles_names(self, adapter: GhidraAdapter) -> None:
"""Profile names must be standard, quick, and deep."""
profiles = adapter.available_profiles()
names = {p.name for p in profiles}
assert names == {"standard", "quick", "deep"}
def test_available_profiles_have_analysers(self, adapter: GhidraAdapter) -> None:
"""Each profile must have a non-empty analysers list."""
profiles = adapter.available_profiles()
for p in profiles:
assert len(p.analysers) > 0, f"Profile {p.name} has no analysers"
def test_available_profiles_have_descriptions(self, adapter: GhidraAdapter) -> None:
"""Each profile must have a non-empty description."""
profiles = adapter.available_profiles()
for p in profiles:
assert p.description, f"Profile {p.name} has no description"
def test_available_profiles_standard_has_function_analysers(
self, adapter: GhidraAdapter
) -> None:
"""Standard profile must include function_start and function_id."""
for p in adapter.available_profiles():
if p.name == "standard":
assert "function_start" in p.analysers
assert "function_id" in p.analysers
break
# ---------------------------------------------------------------------------
# Profile validation
# ---------------------------------------------------------------------------
class TestProfileValidation:
"""Verify validate_profile() (inherited from BackendAdapter)."""
def test_validate_known_profile_returns_profile(self, adapter: GhidraAdapter) -> None:
"""validate_profile with a known name must return the AnalysisProfile."""
profile = adapter.validate_profile("standard")
assert isinstance(profile, AnalysisProfile)
assert profile.name == "standard"
def test_validate_unknown_profile_raises_valueerror(self, adapter: GhidraAdapter) -> None:
"""validate_profile with an unknown name must raise ValueError."""
with pytest.raises(ValueError, match="Unknown analysis profile"):
adapter.validate_profile("nonexistent")
def test_validate_profile_error_lists_available(self, adapter: GhidraAdapter) -> None:
"""validate_profile error message must list available profiles."""
with pytest.raises(ValueError) as excinfo:
adapter.validate_profile("bogus")
error_msg = str(excinfo.value)
assert "standard" in error_msg
assert "quick" in error_msg
assert "deep" in error_msg
def test_validate_all_three_profiles_pass(self, adapter: GhidraAdapter) -> None:
"""validate_profile must accept standard, quick, and deep."""
for name in ("standard", "quick", "deep"):
profile = adapter.validate_profile(name)
assert profile.name == name
# ---------------------------------------------------------------------------
# Initialization
# ---------------------------------------------------------------------------
class TestInitialize:
"""Verify initialize() behavior."""
def test_initialize_raises_when_pyghidra_unavailable(self, adapter: GhidraAdapter) -> None:
"""initialize() must raise RuntimeError when PyGhidra not installed."""
# We don't have PyGhidra in the default test environment.
if not is_pyghidra_available():
with pytest.raises(RuntimeError, match="PyGhidra is not available"):
adapter.initialize()
# ---------------------------------------------------------------------------
# Skeleton methods raise NotImplementedError
# ---------------------------------------------------------------------------
class TestSkeletonMethods:
"""Verify skeleton methods raise NotImplementedError."""
_SKELETON_METHODS: ClassVar = [
("import_binary", ("/fake/path", Project(name="test")), {}),
("analyze", (Binary(), AnalysisProfile(name="standard")), {}),
("get_metadata", (Binary(),), {}),
("get_sections", (Binary(),), {}),
("get_entrypoints", (Binary(),), {}),
("get_imports", (Binary(),), {}),
("get_exports", (Binary(),), {}),
("get_symbols", (Binary(),), {}),
("get_strings", (Binary(),), {}),
("get_functions", (Binary(),), {}),
("decompile", (Binary(), Function(name="test")), {}),
(
"disassemble",
(
Binary(),
Address(space="ram", offset="0x1000", display="0x1000"),
Address(space="ram", offset="0x2000", display="0x2000"),
),
{},
),
(
"read_bytes",
(Binary(), Address(space="ram", offset="0x1000", display="0x1000"), 16),
{},
),
("get_xrefs", (Binary(), Address(space="ram", offset="0x1000", display="0x1000")), {}),
("get_callers", (Binary(), Function(name="test")), {}),
("get_callees", (Binary(), Function(name="test")), {}),
("get_callgraph", (Binary(), Function(name="test")), {}),
]
@pytest.mark.parametrize(
"method_name,args,kwargs",
_SKELETON_METHODS,
)
def test_skeleton_method_raises_not_implemented(
self,
adapter: GhidraAdapter,
method_name: str,
args: tuple,
kwargs: dict,
) -> None:
"""Skeleton method must raise NotImplementedError."""
method = getattr(adapter, method_name)
with pytest.raises(NotImplementedError):
method(*args, **kwargs)
def test_import_binary_has_meaningful_message(self, adapter: GhidraAdapter) -> None:
"""NotImplementedError message must reference the deferred method."""
with pytest.raises(NotImplementedError, match="Ghidra binary import"):
adapter.import_binary("/test", Project(name="test"))
def test_decompile_has_meaningful_message(self, adapter: GhidraAdapter) -> None:
"""NotImplementedError message must reference the deferred method."""
with pytest.raises(NotImplementedError, match="Ghidra decompile"):
adapter.decompile(Binary(), Function(name="test"))
def test_disassemble_has_meaningful_message(self, adapter: GhidraAdapter) -> None:
"""NotImplementedError message must reference the deferred method."""
with pytest.raises(NotImplementedError, match="Ghidra disassembly"):
adapter.disassemble(
Binary(),
Address(space="ram", offset="0x1000", display="0x1000"),
Address(space="ram", offset="0x2000", display="0x2000"),
)
def test_get_callgraph_has_meaningful_message(self, adapter: GhidraAdapter) -> None:
"""NotImplementedError message must reference the deferred method."""
with pytest.raises(NotImplementedError, match="Ghidra callgraph"):
adapter.get_callgraph(Binary(), Function(name="test"))
# ---------------------------------------------------------------------------
# Bridge: Error normalization
# ---------------------------------------------------------------------------
class TestErrorNormalization:
"""Verify the bridge's error normalization maps Ghidra exceptions."""
def test_cancelled_exception_maps_to_timeout(self) -> None:
"""CancelledException must map to OperationTimeoutError."""
class CancelledException(Exception):
pass
err = normalize_error(CancelledException("User cancelled"))
assert isinstance(err, OperationTimeoutError)
assert err.exit_code == ExitCode.OPERATION_TIMEOUT
def test_timeout_exception_maps_to_timeout(self) -> None:
"""TimeoutException must map to OperationTimeoutError."""
class TimeoutException(Exception):
pass
err = normalize_error(TimeoutException("Timed out"))
assert isinstance(err, OperationTimeoutError)
assert err.exit_code == ExitCode.OPERATION_TIMEOUT
def test_unsupported_language_maps_to_unsupported_format(self) -> None:
"""UnsupportedLanguageException must map to UnsupportedFormatError."""
class UnsupportedLanguageException(Exception):
pass
err = normalize_error(UnsupportedLanguageException("Bad arch"))
assert isinstance(err, UnsupportedFormatError)
assert err.exit_code == ExitCode.UNSUPPORTED_FORMAT
def test_domain_file_exception_maps_to_import_failed(self) -> None:
"""DomainFileException must map to ImportFailedError."""
class DomainFileException(Exception):
pass
err = normalize_error(DomainFileException("Corrupt"))
assert isinstance(err, ImportFailedError)
assert err.exit_code == ExitCode.IMPORT_FAILED
def test_pe_exception_maps_to_import_failed(self) -> None:
"""PortableExecutableException must map to ImportFailedError."""
class PortableExecutableException(Exception):
pass
err = normalize_error(PortableExecutableException("Bad PE"))
assert isinstance(err, ImportFailedError)
assert err.exit_code == ExitCode.IMPORT_FAILED
def test_elf_exception_maps_to_import_failed(self) -> None:
"""ELFException must map to ImportFailedError."""
class ELFException(Exception):
pass
err = normalize_error(ELFException("Bad ELF"))
assert isinstance(err, ImportFailedError)
assert err.exit_code == ExitCode.IMPORT_FAILED
def test_mach_exception_maps_to_import_failed(self) -> None:
"""MachException must map to ImportFailedError."""
class MachException(Exception):
pass
err = normalize_error(MachException("Bad Mach-O"))
assert isinstance(err, ImportFailedError)
assert err.exit_code == ExitCode.IMPORT_FAILED
def test_assert_exception_maps_to_analysis_failed(self) -> None:
"""AssertException must map to AnalysisFailedError."""
class AssertException(Exception):
pass
err = normalize_error(AssertException("Assertion failed"))
assert isinstance(err, AnalysisFailedError)
assert err.exit_code == ExitCode.ANALYSIS_FAILED
def test_io_exception_maps_to_backend_failure(self) -> None:
"""IOException must map to BackendFailureError."""
class IOException(Exception):
pass
err = normalize_error(IOException("Disk error"))
assert isinstance(err, BackendFailureError)
assert err.exit_code == ExitCode.BACKEND_FAILURE
def test_runtime_exception_maps_to_backend_failure(self) -> None:
"""RuntimeException must map to BackendFailureError."""
class RuntimeException(Exception):
pass
err = normalize_error(RuntimeException("Unexpected"))
assert isinstance(err, BackendFailureError)
assert err.exit_code == ExitCode.BACKEND_FAILURE
def test_unknown_exception_maps_to_backend_failure(self) -> None:
"""Unrecognized exception must map to BackendFailureError (fallback)."""
class SomeObscureError(Exception):
pass
err = normalize_error(SomeObscureError("Mystery"))
assert isinstance(err, BackendFailureError)
assert err.exit_code == ExitCode.BACKEND_FAILURE
def test_backend_failure_preserves_original_error(self) -> None:
"""BackendFailureError must preserve the original error string."""
class IOException(Exception):
pass
err = normalize_error(IOException("Disk full"))
assert isinstance(err, BackendFailureError)
assert err.original_error == "Disk full"
def test_operation_timeout_message_includes_original(self) -> None:
"""OperationTimeoutError message must reference the original cause."""
class CancelledException(Exception):
pass
err = normalize_error(CancelledException("User hit cancel"))
assert isinstance(err, OperationTimeoutError)
assert "User hit cancel" in str(err)
# ---------------------------------------------------------------------------
# Bridge: PyGhidra availability
# ---------------------------------------------------------------------------
class TestPyGhidraAvailability:
"""Verify bridge PyGhidra availability detection."""
def test_is_pyghidra_available_returns_bool(self) -> None:
"""is_pyghidra_available() must return a boolean."""
result = is_pyghidra_available()
assert isinstance(result, bool)
def test_is_pyghidra_available_is_idempotent(self) -> None:
"""is_pyghidra_available() must return the same result on repeated calls."""
first = is_pyghidra_available()
second = is_pyghidra_available()
assert first == second
def test_is_initialized_returns_bool(self) -> None:
"""is_initialized() must return a boolean."""
result = is_initialized()
assert isinstance(result, bool)
def test_ensure_initialized_returns_bool(self) -> None:
"""ensure_initialized() must return a boolean (False when PyGhidra unavailable)."""
result = ensure_initialized()
assert isinstance(result, bool)
def test_get_ghidra_version_returns_string_or_none(self) -> None:
"""get_ghidra_version() must return a string or None."""
version = get_ghidra_version()
assert version is None or isinstance(version, str)
# ---------------------------------------------------------------------------
# Bridge: error normalization edge cases
# ---------------------------------------------------------------------------
class TestErrorNormalizationEdgeCases:
"""Verify edge cases in error normalization."""
def test_empty_error_message(self) -> None:
"""Normalizing an exception with no message must still produce an error."""
class CancelledException(Exception):
pass
err = normalize_error(CancelledException())
assert isinstance(err, OperationTimeoutError)
def test_nested_class_name_matching(self) -> None:
"""Exception names containing prefix substrings must match correctly."""
class GhidraCancelledException(Exception):
pass
err = normalize_error(GhidraCancelledException("Cancelled"))
assert isinstance(err, OperationTimeoutError)
def test_to_diagnostic_on_operation_timeout(self) -> None:
"""OperationTimeoutError.to_diagnostic() must include category and recoverable."""
err = OperationTimeoutError("Timed out")
diag = err.to_diagnostic()
assert diag["severity"] == "ERROR"
assert diag["category"] == "timeout"
assert diag["recoverable"] is True
def test_to_diagnostic_on_backend_failure(self) -> None:
"""BackendFailureError.to_diagnostic() must include backend_error."""
err = BackendFailureError("JVM crash", original_error="OOM")
diag = err.to_diagnostic()
assert diag["severity"] == "ERROR"
assert diag["backend_error"] == "OOM"