Skip to main content
Glama
conftest.py15.6 kB
"""pytest configuration and shared fixtures""" import pytest import logging import sys import json import requests from pathlib import Path # Add parent directory and test-infrastructure to path # From tests/e2e/conftest.py, go up to project root, then add test-infrastructure project_root = Path(__file__).parent.parent.parent sys.path.insert(0, str(project_root)) sys.path.insert(0, str(project_root / "test-infrastructure")) # Import from local utils (in test-infrastructure/utils/) from utils.ghidra_runner import GhidraRunner from utils.mcp_client import MCPClient logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) # Default binary name used when no marker is specified DEFAULT_BINARY = "test_simple" # Global reference to ghidra server URL (set by ghidra_server fixture) _ghidra_url = None # ==================== Program Management Helpers ==================== def _get_ghidra_url(): """Get the Ghidra server URL.""" global _ghidra_url if _ghidra_url is None: _ghidra_url = "http://127.0.0.1:8080" return _ghidra_url def list_programs(): """List all programs in the current Ghidra project. Returns: dict with 'programs' list and 'current' program name """ url = f"{_get_ghidra_url()}/program/list" response = requests.get(url, timeout=30) return response.json() def get_current_program(): """Get the currently active program. Returns: dict with 'program_name' and 'loaded' status """ url = f"{_get_ghidra_url()}/program/current" response = requests.get(url, timeout=30) return response.json() def switch_program(program_name: str): """Switch to a different program. Args: program_name: Name of the program to switch to Returns: dict with 'success' status and 'message' """ url = f"{_get_ghidra_url()}/program/switch" response = requests.post(url, data={"program_name": program_name}, timeout=60) return response.json() def import_binary(file_path: str): """Import a binary file into the Ghidra project. Args: file_path: Absolute path to the binary file Returns: dict with 'success' status, 'program_name', and 'message' """ url = f"{_get_ghidra_url()}/program/import" response = requests.post(url, data={"file_path": file_path}, timeout=120) return response.json() def ensure_binary_loaded(binary_name: str) -> str: """Ensure a binary is switched to as the current program. All binaries are pre-imported at Ghidra startup, so this function just switches to the requested binary if it's not already current. Args: binary_name: Name of the binary (e.g., 'test_simple', 'test_cpp') Returns: The program name after loading Raises: RuntimeError: If the binary cannot be switched to """ # Check current program current = get_current_program() if current.get("program_name") == binary_name: logging.info(f"Binary '{binary_name}' is already the current program") return binary_name # Check if the binary is available in the project programs = list_programs() program_names = [p["name"] for p in programs.get("programs", [])] if binary_name not in program_names: available = ", ".join(program_names) if program_names else "none" raise RuntimeError( f"Binary '{binary_name}' not found in project. " f"Available programs: {available}. " f"Make sure the binary exists in test-infrastructure/fixtures/binaries/" ) # Switch to the binary logging.info(f"Switching to binary '{binary_name}'") result = switch_program(binary_name) if not result.get("success"): raise RuntimeError(f"Failed to switch to program: {result.get('error', 'Unknown error')}") return binary_name def pytest_configure(config): """Register custom markers.""" config.addinivalue_line( "markers", "binary(name): mark test to run with specific binary (e.g., @pytest.mark.binary('test_simple'))" ) def pytest_collection_modifyitems(config, items): """Sort tests by their binary marker to minimize binary reloading. Tests are grouped by their binary marker value, with unmarked tests (using default binary) sorted together. """ def get_binary_name(item): marker = item.get_closest_marker("binary") if marker and marker.args: return marker.args[0] return DEFAULT_BINARY # Sort items by binary name to group tests using the same binary together items.sort(key=lambda item: get_binary_name(item)) def get_binary_path(binary_name: str) -> Path: """Get the full path to a binary in the test fixtures directory. Args: binary_name: Name of the binary (e.g., 'test_simple', 'test_cpp') Returns: Path to the binary file Raises: FileNotFoundError: If the binary doesn't exist """ binaries_dir = project_root / "test-infrastructure" / "fixtures" / "binaries" binary_path = binaries_dir / binary_name if not binary_path.exists(): available = [f.name for f in binaries_dir.iterdir() if f.is_file()] if binaries_dir.exists() else [] raise FileNotFoundError( f"Binary '{binary_name}' not found at {binary_path}. " f"Available binaries: {available}" ) return binary_path def get_test_binary_name(request) -> str: """Get the binary name for a test from its marker or default. Args: request: pytest request fixture Returns: Name of the binary to use for the test """ marker = request.node.get_closest_marker("binary") if marker and marker.args: return marker.args[0] return DEFAULT_BINARY def pytest_addoption(parser): """Add custom command line options""" # Use try-except to handle duplicate option registration when multiple conftest.py files exist options = [ ("--ghidra-dir", {"action": "store", "default": "/opt/ghidra", "help": "Path to Ghidra installation directory"}), ("--no-xvfb", {"action": "store_true", "default": False, "help": "Don't use Xvfb (for local testing with display)"}), ("--keep-project", {"action": "store_true", "default": False, "help": "Don't delete test project after tests"}), ("--verbose-ghidra", {"action": "store_true", "default": False, "help": "Enable verbose Ghidra output"}), ("--isolated", {"action": "store_true", "default": True, "help": "Use isolated Ghidra user directory (default: True, prevents interference with desktop Ghidra)"}), ("--no-isolated", {"action": "store_true", "default": False, "help": "Don't use isolated directory (use your actual ~/.ghidra)"}), ] for opt_name, opt_kwargs in options: try: parser.addoption(opt_name, **opt_kwargs) except ValueError: # Option already added by another conftest.py pass @pytest.fixture(scope="session") def ghidra_dir(request): """Get Ghidra installation directory""" return request.config.getoption("--ghidra-dir") @pytest.fixture(scope="session") def use_xvfb(request): """Whether to use Xvfb""" return not request.config.getoption("--no-xvfb") @pytest.fixture(scope="session") def keep_project(request): """Whether to keep test project""" return request.config.getoption("--keep-project") @pytest.fixture(scope="session") def verbose_ghidra(request): """Whether to enable verbose Ghidra output""" return request.config.getoption("--verbose-ghidra") @pytest.fixture(scope="session") def use_isolated(request): """Whether to use isolated Ghidra user directory""" if request.config.getoption("--no-isolated"): return False return True # Default to isolated mode @pytest.fixture(scope="session") def test_binary(): """Path to default test binary (test_simple). For tests that require a specific binary, use the @pytest.mark.binary('binary_name') marker and the `program` fixture instead. """ try: return str(get_binary_path(DEFAULT_BINARY)) except FileNotFoundError as e: pytest.skip(f"Default test binary not found: {e}. Run: test-infrastructure/fixtures/build_test_binary.sh") @pytest.fixture(scope="session") def all_test_binaries(): """Get paths to all test binaries in the fixtures directory. Returns a list of paths to all binaries that should be imported at startup. """ binaries_dir = project_root / "test-infrastructure" / "fixtures" / "binaries" if not binaries_dir.exists(): return [] binaries = [] for f in binaries_dir.iterdir(): if f.is_file() and not f.name.startswith('.'): binaries.append(str(f)) logging.info(f"Found {len(binaries)} test binaries: {[Path(b).name for b in binaries]}") return binaries @pytest.fixture(scope="session") def plugin_path(): """Path to plugin""" project_root = Path(__file__).parent.parent.parent # Try multiple locations search_paths = [ project_root / "test-infrastructure" / "fixtures" / "plugin", project_root / "target", # Maven output directory project_root / "dist", project_root / "build", ] for search_dir in search_paths: if search_dir.exists(): # Look for ZIP files for plugin_file in search_dir.glob("*.zip"): if "GhidraMCP" in plugin_file.name or "ghidra" in plugin_file.name.lower(): return str(plugin_file) pytest.skip("Plugin not found. Please build the plugin first with: mvn clean package") @pytest.fixture(scope="session") def ghidra_server(ghidra_dir, test_binary, all_test_binaries, plugin_path, use_xvfb, keep_project, verbose_ghidra, use_isolated): """Start Ghidra server for entire test session""" import tempfile import shutil project_dir = tempfile.mkdtemp(prefix="ghidra_test_project_") # Create isolated user directory if requested (default) isolated_dir = None if use_isolated: isolated_dir = tempfile.mkdtemp(prefix="ghidra_test_home_") logging.info(f"Using isolated Ghidra user directory: {isolated_dir}") else: logging.warning("NOT using isolated mode - tests will use your real ~/.ghidra directory!") # Get additional binaries (all binaries except the primary one) additional_binaries = [b for b in all_test_binaries if b != test_binary] if additional_binaries: logging.info(f"Will import {len(additional_binaries)} additional binaries: {[Path(b).name for b in additional_binaries]}") runner = GhidraRunner( ghidra_install_dir=ghidra_dir, test_project_dir=project_dir, test_binary=test_binary, plugin_path=plugin_path, http_port=8080, # Use plugin's default port (configured in plugin options) use_xvfb=use_xvfb, verbose=verbose_ghidra, isolated_user_dir=isolated_dir, additional_binaries=additional_binaries ) runner.start(timeout=30) # Set the global Ghidra URL for program management helpers global _ghidra_url _ghidra_url = f"http://127.0.0.1:{runner.http_port}" yield runner runner.stop() if not keep_project: runner.cleanup_project() # Clean up isolated directory if use_isolated and isolated_dir and Path(isolated_dir).exists(): if keep_project: logging.info(f"Keeping isolated directory: {isolated_dir}") else: logging.info(f"Cleaning up isolated directory: {isolated_dir}") shutil.rmtree(isolated_dir) @pytest.fixture(scope="session") def mcp_client(ghidra_server): """Start MCP bridge server for entire test session""" project_root = Path(__file__).parent.parent.parent mcp_script = project_root / "bridge_mcp_ghidra.py" if not mcp_script.exists(): pytest.skip(f"MCP script not found: {mcp_script}") # Use the dynamically allocated port from ghidra_server ghidra_url = f"http://127.0.0.1:{ghidra_server.http_port}/" logging.info(f"MCP client connecting to Ghidra at {ghidra_url}") client = MCPClient( mcp_script_path=str(mcp_script), ghidra_server=ghidra_url, timeout=60, verbose=False ) client.start() yield client client.stop() @pytest.fixture def mcp_tools(mcp_client): """List of available MCP tools""" return mcp_client.list_tools() # Track the currently loaded binary for the session _current_binary = None def _switch_to_binary(request, binary_name: str) -> str: """Internal helper to switch to a binary. Returns the loaded binary name or raises an exception. """ global _current_binary if _current_binary == binary_name: return _current_binary logging.info(f"Switching to binary '{binary_name}' (current: '{_current_binary}')") try: loaded_name = ensure_binary_loaded(binary_name) _current_binary = loaded_name logging.info(f"Successfully switched to binary '{loaded_name}'") return _current_binary except Exception as e: pytest.fail(f"Failed to switch to binary '{binary_name}': {e}") @pytest.fixture(autouse=True) def auto_switch_binary(request, ghidra_server): """Automatically switch to the correct binary for each test. This autouse fixture ensures that the correct binary is loaded based on the @pytest.mark.binary marker (or default binary if no marker). Tests are sorted by binary marker to minimize switching. """ requested_binary = get_test_binary_name(request) _switch_to_binary(request, requested_binary) yield @pytest.fixture def program(request, ghidra_server): """Get the program name for the current test. Reads the @pytest.mark.binary('name') marker to determine which binary the test expects. If no marker is present, uses the default binary. Note: Binary switching is handled automatically by auto_switch_binary. This fixture just returns the current binary name. Usage: @pytest.mark.binary("test_simple") def test_something(program): # program == "test_simple" pass @pytest.mark.binary("test_cpp") def test_cpp_feature(program): # program == "test_cpp" pass def test_default(program): # program == DEFAULT_BINARY (test_simple) pass Returns: The binary name that is loaded for this test. """ global _current_binary return _current_binary @pytest.fixture(scope="session") def clean_undo_stack(ghidra_server): """Ensure undo stack is clear at test session start""" from bridge_mcp_ghidra import clear_undo clear_undo() yield @pytest.fixture(autouse=True) def restore_program_state(clean_undo_stack): """Automatically restore program state after each test by undoing all changes""" from bridge_mcp_ghidra import can_undo, undo yield # Run the test # After test: undo all changes undo_count = 0 max_undos = 100 # Safety limit to prevent infinite loops while can_undo() and undo_count < max_undos: undo() undo_count += 1 if undo_count > 0: logging.info(f"Restored program state by undoing {undo_count} transaction(s)") if undo_count >= max_undos: logging.warning(f"Hit max undo limit ({max_undos}), program state may not be fully restored")

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/HK47196/GhidraMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server