"""Unit tests for the FakeAdapter — a fully controllable in-memory backend adapter. Tests cover: - Normal data returns for all structural query types - Fixture registration (PE, ELF, Mach-O) - Import failures (exit code 10) - Analysis crashes (exit code 11) - Backend failures (exit code 13) - Slow operations and timeout simulation - Unmapped addresses, partial mapping, and truncation - Configurable overrides and edge cases """ 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)) import time from typing import Any from uuid import uuid4 import pytest from binary_analysis.adapters.base import ( AnalysisProfile, AnalysisResult, BackendAdapter, BinaryMetadata, CallEdge, ConcurrencyMode, DecompilationResult, ) from binary_analysis.adapters.fake import FakeAdapter from binary_analysis.domain.entities import ( Address, Binary, CallGraph, Function, Instruction, Project, Reference, Section, String, ) from binary_analysis.domain.enums import ( Confidence, Endianness, FunctionNameSource, ImportResolution, ) from binary_analysis.domain.errors import ( AnalysisFailedError, BackendFailureError, ImportFailedError, ) # --------------------------------------------------------------------------- # Test fixtures # --------------------------------------------------------------------------- @pytest.fixture def adapter() -> FakeAdapter: """Return a fresh FakeAdapter with PE fixture registered.""" a = FakeAdapter() a.set_fixture("pe-default", FakeAdapter.pe_fixture()) a.set_fixture("elf-default", FakeAdapter.elf_fixture()) a.set_fixture("macho-default", FakeAdapter.macho_fixture()) return a @pytest.fixture def project() -> Project: """Return a test project.""" return Project( id=uuid4(), name="test-project", ) @pytest.fixture def binary(adapter: FakeAdapter, project: Project) -> Binary: """Return an imported binary from the adapter.""" return adapter.import_binary("test.exe", project) # --------------------------------------------------------------------------- # Interface compliance # --------------------------------------------------------------------------- class TestBackendAdapterInterface: """Verify that FakeAdapter implements all BackendAdapter abstract methods.""" def test_is_subclass_of_backend_adapter(self) -> None: assert issubclass(FakeAdapter, BackendAdapter) def test_concurrency_mode(self, adapter: FakeAdapter) -> None: assert adapter.concurrency == ConcurrencyMode.PROJECT_SERIALIZED def test_all_abstract_methods_implemented(self) -> None: """Verify FakeAdapter implements every abstract method.""" # Collect abstract methods from BackendAdapter abstract_names: set[str] = set() for name in dir(BackendAdapter): if name.startswith("_"): continue attr = getattr(BackendAdapter, name, None) if attr is None: continue if hasattr(attr, "__isabstractmethod__") and attr.__isabstractmethod__: abstract_names.add(name) # Ensure FakeAdapter has each abstract method and it is NOT abstract for method_name in sorted(abstract_names): assert hasattr(FakeAdapter, method_name), ( f"FakeAdapter missing abstract method: {method_name}" ) fake_attr = getattr(FakeAdapter, method_name) # Properties decorated with @property + @abstractmethod carry # __isabstractmethod__ on the property object; we just need to # check the FakeAdapter overrides it (no abstractmethod on the override) if hasattr(fake_attr, "fget"): # It's a property — verify it has a concrete getter assert fake_attr.fget is not None, ( f"FakeAdapter.{method_name} property has no getter" ) else: assert not hasattr(fake_attr, "__isabstractmethod__"), ( f"FakeAdapter.{method_name} is still abstract" ) # --------------------------------------------------------------------------- # Initialization and capabilities # --------------------------------------------------------------------------- class TestInitialization: """Tests for initialize and capabilities.""" def test_initialize(self, adapter: FakeAdapter) -> None: adapter.initialize() assert adapter._initialized is True def test_capabilities(self, adapter: FakeAdapter) -> None: caps = adapter.capabilities() assert caps["adapter"] == "fake" assert caps["adapter_version"] == "0.1.0" assert "PE" in caps["supported_formats"] assert "ELF" in caps["supported_formats"] assert "Mach-O" in caps["supported_formats"] assert caps["max_depth"] == 10 def test_available_profiles(self, adapter: FakeAdapter) -> None: profiles = adapter.available_profiles() assert len(profiles) == 3 names = {p.name for p in profiles} assert names == {"standard", "quick", "deep"} def test_validate_profile_valid(self, adapter: FakeAdapter) -> None: profile = adapter.validate_profile("quick") assert profile.name == "quick" def test_validate_profile_invalid(self, adapter: FakeAdapter) -> None: with pytest.raises(ValueError, match="Unknown analysis profile"): adapter.validate_profile("nonexistent") # --------------------------------------------------------------------------- # Import # --------------------------------------------------------------------------- class TestImport: """Tests for import_binary.""" def test_import_binary_returns_binary_entity( self, adapter: FakeAdapter, project: Project ) -> None: b = adapter.import_binary("test.exe", project) assert isinstance(b, Binary) assert b.format == "PE" assert b.architecture == "x86" assert b.endianness == Endianness.LITTLE assert b.sha256 != "" assert b.id is not None def test_import_binary_sha256_present(self, adapter: FakeAdapter, project: Project) -> None: """SHA-256 is computed client-side and present (VAL-IMP-003).""" b = adapter.import_binary("test.exe", project) assert len(b.sha256) == 64 assert all(c in "0123456789abcdef" for c in b.sha256) def test_import_failure_simulated(self, adapter: FakeAdapter, project: Project) -> None: """Simulate import failure (exit code 10).""" adapter.configure_import_failure("fail.exe", "Simulated import failure") with pytest.raises(ImportFailedError) as exc: adapter.import_binary("fail.exe", project) assert exc.value.exit_code == 10 assert "Simulated import failure" in str(exc.value) def test_import_failure_cleared_after_remove( self, adapter: FakeAdapter, project: Project ) -> None: """Clearing configuration removes import failure.""" adapter.configure_import_failure("fail.exe", "Import failed") adapter.clear_configuration() b = adapter.import_binary("fail.exe", project) assert isinstance(b, Binary) assert b.format is not None # --------------------------------------------------------------------------- # Analysis # --------------------------------------------------------------------------- class TestAnalyze: """Tests for analyze method.""" def test_analyze_returns_result(self, adapter: FakeAdapter, binary: Binary) -> None: profile = adapter.validate_profile("standard") result = adapter.analyze(binary, profile) assert isinstance(result, AnalysisResult) assert result.success is True assert result.partial is False def test_analyze_quick_profile(self, adapter: FakeAdapter, binary: Binary) -> None: profile = adapter.validate_profile("quick") result = adapter.analyze(binary, profile) assert "functions" in result.completed_analysers assert "sections" in result.completed_analysers def test_analyze_deep_profile(self, adapter: FakeAdapter, binary: Binary) -> None: profile = adapter.validate_profile("deep") result = adapter.analyze(binary, profile) assert len(result.completed_analysers) > 2 def test_analysis_failure_crash(self, adapter: FakeAdapter, binary: Binary) -> None: """Simulate analysis crash (exit code 11).""" adapter.configure_analysis_failure("Analysis engine crashed") profile = adapter.validate_profile("standard") with pytest.raises(AnalysisFailedError) as exc: adapter.analyze(binary, profile) assert exc.value.exit_code == 11 assert "Analysis engine crashed" in str(exc.value) # --------------------------------------------------------------------------- # Metadata # --------------------------------------------------------------------------- class TestMetadata: """Tests for get_metadata.""" def test_metadata_returns_canonical_fields(self, adapter: FakeAdapter, binary: Binary) -> None: meta = adapter.get_metadata(binary) assert isinstance(meta, BinaryMetadata) assert meta.format == "PE" assert meta.architecture == "x86" assert meta.endianness == "LITTLE" assert meta.size_bytes > 0 def test_metadata_has_entry_point(self, adapter: FakeAdapter, binary: Binary) -> None: meta = adapter.get_metadata(binary) assert meta.entry_point is not None assert meta.entry_point.offset == "0x401000" # --------------------------------------------------------------------------- # Structural queries # --------------------------------------------------------------------------- class TestStructuralQueries: """Tests for sections, entrypoints, imports, exports, symbols, strings.""" def test_get_sections_returns_list(self, adapter: FakeAdapter, binary: Binary) -> None: sections = adapter.get_sections(binary) assert isinstance(sections, list) assert len(sections) > 0 assert all(isinstance(s, Section) for s in sections) def test_get_sections_has_expected_fields(self, adapter: FakeAdapter, binary: Binary) -> None: sections = adapter.get_sections(binary) text = [s for s in sections if s.name == ".text"] assert len(text) == 1 assert text[0].flags is not None assert isinstance(text[0].entropy, float) def test_get_entrypoints(self, adapter: FakeAdapter, binary: Binary) -> None: eps = adapter.get_entrypoints(binary) assert len(eps) > 0 assert eps[0].kind == "program" assert eps[0].confidence == Confidence.HIGH def test_get_imports(self, adapter: FakeAdapter, binary: Binary) -> None: imports = adapter.get_imports(binary) assert len(imports) > 0 kernel_imports = [i for i in imports if i.module == "kernel32.dll"] assert len(kernel_imports) > 0 # Check resolution status assert kernel_imports[0].resolution == ImportResolution.RESOLVED def test_get_exports(self, adapter: FakeAdapter, binary: Binary) -> None: exports = adapter.get_exports(binary) assert len(exports) > 0 assert exports[0].name == "_start" assert exports[0].kind == "function" def test_get_symbols(self, adapter: FakeAdapter, binary: Binary) -> None: symbols = adapter.get_symbols(binary) assert len(symbols) > 0 # At least one symbol should be IMPORTED imported = [s for s in symbols if s.source == FunctionNameSource.IMPORTED] assert len(imported) > 0 def test_get_strings(self, adapter: FakeAdapter, binary: Binary) -> None: strings = adapter.get_strings(binary) assert len(strings) > 0 assert all(isinstance(s, String) for s in strings) def test_get_strings_min_length_filter(self, adapter: FakeAdapter, binary: Binary) -> None: strings = adapter.get_strings(binary, min_length=15) for s in strings: assert s.length >= 15 def test_get_strings_contains_filter(self, adapter: FakeAdapter, binary: Binary) -> None: strings = adapter.get_strings(binary, contains="Access") for s in strings: assert "Access" in s.text def test_get_strings_encoding_filter(self, adapter: FakeAdapter, binary: Binary) -> None: strings = adapter.get_strings(binary, encoding_filter="ASCII") for s in strings: assert s.encoding == "ASCII" def test_get_strings_combined_filters(self, adapter: FakeAdapter, binary: Binary) -> None: strings = adapter.get_strings(binary, min_length=8, contains="kernel32") for s in strings: assert s.length >= 8 assert "kernel32" in s.text # --------------------------------------------------------------------------- # Functions # --------------------------------------------------------------------------- class TestFunctions: """Tests for get_functions.""" def test_get_functions_returns_list(self, adapter: FakeAdapter, binary: Binary) -> None: funcs = adapter.get_functions(binary) assert len(funcs) > 0 assert all(isinstance(f, Function) for f in funcs) def test_get_functions_excludes_external_by_default( self, adapter: FakeAdapter, binary: Binary ) -> None: funcs = adapter.get_functions(binary) for f in funcs: assert not f.is_external def test_get_functions_includes_external_when_requested( self, adapter: FakeAdapter, binary: Binary ) -> None: funcs = adapter.get_functions(binary, exclude_external=False) externals = [f for f in funcs if f.is_external] assert len(externals) > 0 def test_get_functions_excludes_thunks_by_default( self, adapter: FakeAdapter, binary: Binary ) -> None: funcs = adapter.get_functions(binary) for f in funcs: assert not f.is_thunk def test_get_functions_has_expected_fields(self, adapter: FakeAdapter, binary: Binary) -> None: funcs = adapter.get_functions(binary) main_func = [f for f in funcs if f.name == "main"] assert len(main_func) == 1 assert main_func[0].size_bytes > 0 assert main_func[0].address is not None assert main_func[0].confidence == Confidence.HIGH assert main_func[0].name_source == FunctionNameSource.ORIGINAL # --------------------------------------------------------------------------- # Decompile # --------------------------------------------------------------------------- class TestDecompile: """Tests for decompile method.""" def test_decompile_returns_pseudocode(self, adapter: FakeAdapter, binary: Binary) -> None: funcs = adapter.get_functions(binary) main = next(f for f in funcs if f.name == "main") result = adapter.decompile(binary, main) assert isinstance(result, DecompilationResult) assert "Reconstructed pseudocode" in result.pseudocode assert "main" in result.pseudocode assert result.language == "c" def test_decompile_has_address_map(self, adapter: FakeAdapter, binary: Binary) -> None: funcs = adapter.get_functions(binary) main = next(f for f in funcs if f.name == "main") result = adapter.decompile(binary, main) assert len(result.address_map) > 0 # Address map should contain the function's address first_entry = next(iter(result.address_map.values())) assert "offset" in first_entry def test_decompile_labels_as_reconstructed(self, adapter: FakeAdapter, binary: Binary) -> None: """Output is labeled as reconstructed pseudocode, not original source (VAL-FOCUS-001).""" funcs = adapter.get_functions(binary) main = next(f for f in funcs if f.name == "main") result = adapter.decompile(binary, main) assert "Reconstructed pseudocode" in result.pseudocode assert "// Generated by FakeAdapter" in result.pseudocode # --------------------------------------------------------------------------- # Disassemble # --------------------------------------------------------------------------- class TestDisassemble: """Tests for disassemble method.""" def test_disassemble_returns_instructions(self, adapter: FakeAdapter, binary: Binary) -> None: start = Address(space="ram", offset="0x401000", display="0x401000") end = Address(space="ram", offset="0x401020", display="0x401020") instructions = adapter.disassemble(binary, start, end) assert len(instructions) > 0 assert all(isinstance(i, Instruction) for i in instructions) # Each instruction should have mnemonic and operands for inst in instructions: assert inst.mnemonic != "" assert inst.address is not None def test_disassemble_unmapped_range_raises(self, adapter: FakeAdapter, binary: Binary) -> None: """Unmapped address range raises ValueError (VAL-FOCUS-009).""" adapter.configure_unmapped_range(0x5000, 0x6000) start = Address(space="ram", offset="0x5000", display="0x5000") end = Address(space="ram", offset="0x5010", display="0x5010") with pytest.raises(ValueError, match="unmapped"): adapter.disassemble(binary, start, end) # --------------------------------------------------------------------------- # Read bytes # --------------------------------------------------------------------------- class TestReadBytes: """Tests for read_bytes method.""" def test_read_bytes_returns_data(self, adapter: FakeAdapter, binary: Binary) -> None: addr = Address(space="ram", offset="0x401000", display="0x401000") data, length = adapter.read_bytes(binary, addr, 16) assert isinstance(data, bytes) assert length == 16 def test_read_bytes_deterministic(self, adapter: FakeAdapter, binary: Binary) -> None: addr = Address(space="ram", offset="0x401000", display="0x401000") data1, _ = adapter.read_bytes(binary, addr, 8) data2, _ = adapter.read_bytes(binary, addr, 8) assert data1 == data2 def test_read_bytes_unmapped_raises(self, adapter: FakeAdapter, binary: Binary) -> None: """Unmapped address raises ValueError (VAL-FOCUS-012).""" adapter.configure_unmapped_range(0x5000, 0x6000) addr = Address(space="ram", offset="0x5000", display="0x5000") with pytest.raises(ValueError, match="unmapped"): adapter.read_bytes(binary, addr, 16) def test_read_bytes_zero_length_raises(self, adapter: FakeAdapter, binary: Binary) -> None: """Zero-length read raises ValueError (VAL-FOCUS-013).""" addr = Address(space="ram", offset="0x401000", display="0x401000") with pytest.raises(ValueError, match="positive"): adapter.read_bytes(binary, addr, 0) def test_read_bytes_truncation(self, adapter: FakeAdapter, binary: Binary) -> None: """Truncation at segment boundary returns partial data (VAL-FOCUS-014).""" adapter.configure_truncation(0x401000, 8) addr = Address(space="ram", offset="0x401000", display="0x401000") data, length = adapter.read_bytes(binary, addr, 16) assert length == 8 # Truncated to 8 assert len(data) == 8 # --------------------------------------------------------------------------- # Xrefs, callers, callees, callgraph # --------------------------------------------------------------------------- class TestReferences: """Tests for xrefs, callers, callees, callgraph.""" def test_get_xrefs(self, adapter: FakeAdapter, binary: Binary) -> None: addr = Address(space="ram", offset="0x401000", display="0x401000") refs = adapter.get_xrefs(binary, addr) assert isinstance(refs, list) if refs: assert isinstance(refs[0], Reference) assert refs[0].kind is not None def test_get_xrefs_empty_for_unknown_address( self, adapter: FakeAdapter, binary: Binary ) -> None: """Xrefs on unknown address returns empty list, not error (VAL-FOCUS-016).""" addr = Address(space="ram", offset="0x999999", display="0x999999") refs = adapter.get_xrefs(binary, addr) assert isinstance(refs, list) def test_get_callers(self, adapter: FakeAdapter, binary: Binary) -> None: funcs = adapter.get_functions(binary) check = next(f for f in funcs if f.name == "check_password") callers = adapter.get_callers(binary, check) assert isinstance(callers, list) if callers: assert isinstance(callers[0], CallEdge) def test_get_callees(self, adapter: FakeAdapter, binary: Binary) -> None: funcs = adapter.get_functions(binary) main = next(f for f in funcs if f.name == "main") callees = adapter.get_callees(binary, main) assert isinstance(callees, list) if callees: assert isinstance(callees[0], CallEdge) assert callees[0].from_name == "main" def test_get_callgraph(self, adapter: FakeAdapter, binary: Binary) -> None: funcs = adapter.get_functions(binary) main = next(f for f in funcs if f.name == "main") cg = adapter.get_callgraph(binary, main, max_depth=2) assert isinstance(cg, CallGraph) assert cg.max_depth == 2 def test_get_callgraph_with_depth(self, adapter: FakeAdapter, binary: Binary) -> None: funcs = adapter.get_functions(binary) main = next(f for f in funcs if f.name == "main") cg = adapter.get_callgraph(binary, main, max_depth=1) # All nodes should be at depth <= 1 for node in cg.nodes: assert node["depth"] <= 1 # --------------------------------------------------------------------------- # Fixtures — PE, ELF, Mach-O # --------------------------------------------------------------------------- class TestFixtures: """Tests for the built-in fixture helpers.""" def test_pe_fixture(self) -> None: fixture = FakeAdapter.pe_fixture() assert fixture["format"] == "PE" assert fixture["architecture"] == "x86" assert fixture["endianness"] == Endianness.LITTLE assert len(fixture["sections"]) == 3 assert len(fixture["functions"]) == 4 assert len(fixture["imports"]) == 5 assert len(fixture["exports"]) == 1 assert len(fixture["strings"]) == 5 def test_elf_fixture(self) -> None: fixture = FakeAdapter.elf_fixture() assert fixture["format"] == "ELF" assert fixture["architecture"] == "x86-64" assert fixture["endianness"] == Endianness.LITTLE assert len(fixture["sections"]) == 4 # .text, .rodata, .data, .bss assert len(fixture["functions"]) == 4 assert len(fixture["imports"]) == 5 assert len(fixture["exports"]) == 2 # main, compute_hash def test_macho_fixture(self) -> None: fixture = FakeAdapter.macho_fixture() assert fixture["format"] == "Mach-O" assert fixture["architecture"] == "arm64" assert fixture["endianness"] == Endianness.LITTLE assert ( len(fixture["sections"]) == 6 ) # __text, __cstring, __const, __data, __bss, __linkedit assert len(fixture["functions"]) == 3 assert len(fixture["imports"]) == 4 assert len(fixture["exports"]) == 2 def test_pe_fixture_sections_have_deterministic_addresses(self) -> None: fixture = FakeAdapter.pe_fixture() text_sec = next(s for s in fixture["sections"] if s.name == ".text") assert text_sec.address is not None assert text_sec.address.offset == "0x401000" assert text_sec.address.space == "ram" def test_pe_fixture_functions_have_known_addresses(self) -> None: fixture = FakeAdapter.pe_fixture() main_fn = next(f for f in fixture["functions"] if f.name == "main") assert main_fn.address is not None assert main_fn.address.offset == "0x401000" assert main_fn.size_bytes == 512 def test_elf_fixture_exports_are_deterministic(self) -> None: fixture = FakeAdapter.elf_fixture() exports = fixture["exports"] names = {e.name for e in exports} assert "main" in names assert "compute_hash" in names def test_macho_fixture_imports_are_deterministic(self) -> None: fixture = FakeAdapter.macho_fixture() imports = fixture["imports"] modules = {i.module for i in imports} assert "libSystem.B.dylib" in modules # --------------------------------------------------------------------------- # Failure simulation # --------------------------------------------------------------------------- class TestFailureSimulation: """Tests for all failure simulation modes.""" def test_import_failure_exit_code_10(self, adapter: FakeAdapter, project: Project) -> None: adapter.configure_import_failure("bad.exe", "Disk full during import") with pytest.raises(ImportFailedError) as exc: adapter.import_binary("bad.exe", project) assert exc.value.exit_code == 10 def test_analysis_crash_exit_code_11(self, adapter: FakeAdapter, binary: Binary) -> None: adapter.configure_analysis_failure("Segmentation fault in analyzer") profile = AnalysisProfile(name="standard", analysers=["functions"]) with pytest.raises(AnalysisFailedError) as exc: adapter.analyze(binary, profile) assert exc.value.exit_code == 11 def test_backend_failure_exit_code_13(self, adapter: FakeAdapter, binary: Binary) -> None: adapter.configure_backend_failure("get_functions", "JVM OOM error") with pytest.raises(BackendFailureError) as exc: adapter.get_functions(binary) assert exc.value.exit_code == 13 def test_backend_failure_on_structural_query( self, adapter: FakeAdapter, binary: Binary ) -> None: """Backend crash during structural query (VAL-IMP-018).""" adapter.configure_backend_failure("get_sections", "Ghidra saw a ghost") with pytest.raises(BackendFailureError) as exc: adapter.get_sections(binary) assert exc.value.exit_code == 13 def test_multiple_backend_failures(self, adapter: FakeAdapter, binary: Binary) -> None: adapter.configure_backend_failure("get_symbols", "Symbol lookup failed") adapter.configure_backend_failure("get_strings", "String extraction failed") with pytest.raises(BackendFailureError): adapter.get_symbols(binary) with pytest.raises(BackendFailureError): adapter.get_strings(binary) # --------------------------------------------------------------------------- # Slow operations # --------------------------------------------------------------------------- class TestSlowOperations: """Tests for slow operation simulation.""" def test_slow_import(self, adapter: FakeAdapter, project: Project) -> None: adapter.configure_slow_operation("import", 0.1) start = time.time() adapter.import_binary("test.exe", project) elapsed = time.time() - start assert elapsed >= 0.1 def test_slow_analyze(self, adapter: FakeAdapter, binary: Binary) -> None: adapter.configure_slow_operation("analyze", 0.1) profile = AnalysisProfile(name="quick", analysers=["functions"]) start = time.time() adapter.analyze(binary, profile) elapsed = time.time() - start assert elapsed >= 0.1 def test_slow_decompile(self, adapter: FakeAdapter, binary: Binary) -> None: adapter.configure_slow_operation("decompile", 0.1) funcs = adapter.get_functions(binary) main = next(f for f in funcs if f.name == "main") start = time.time() adapter.decompile(binary, main) elapsed = time.time() - start assert elapsed >= 0.1 # --------------------------------------------------------------------------- # Address mapping edge cases # --------------------------------------------------------------------------- class TestAddressMapping: """Tests for unmapped addresses, partial mapping, and truncation.""" def test_unmapped_address_range(self, adapter: FakeAdapter, binary: Binary) -> None: adapter.configure_unmapped_range(0x5000, 0x6000) addr = Address(space="ram", offset="0x5000", display="0x5000") with pytest.raises(ValueError, match="unmapped"): adapter.read_bytes(binary, addr, 16) def test_mapped_address_outside_unmapped_range( self, adapter: FakeAdapter, binary: Binary ) -> None: """Addresses outside the unmapped range should work normally.""" adapter.configure_unmapped_range(0x5000, 0x6000) addr = Address(space="ram", offset="0x401000", display="0x401000") _data, length = adapter.read_bytes(binary, addr, 16) assert length == 16 def test_truncation_at_segment_boundary(self, adapter: FakeAdapter, binary: Binary) -> None: """Truncation returns fewer bytes than requested (VAL-FOCUS-014).""" adapter.configure_truncation(0x401000, 4) addr = Address(space="ram", offset="0x401000", display="0x401000") data, actual = adapter.read_bytes(binary, addr, 16) assert actual == 4 assert len(data) == 4 def test_configuration_cleared(self, adapter: FakeAdapter, binary: Binary) -> None: adapter.configure_unmapped_range(0x5000, 0x6000) adapter.clear_configuration() addr = Address(space="ram", offset="0x5000", display="0x5000") # Should now work (not unmapped anymore) _data, length = adapter.read_bytes(binary, addr, 16) assert length == 16 # --------------------------------------------------------------------------- # Custom fixture registration # --------------------------------------------------------------------------- class TestCustomFixtures: """Tests for registering custom fixtures.""" def test_register_custom_fixture(self) -> None: adapter = FakeAdapter() custom = { "format": "PE", "architecture": "x86", "endianness": Endianness.LITTLE, "sections": [ Section(name=".custom", flags=["r", "w", "x"]), ], "entrypoints": [], "imports": [], "exports": [], "symbols": [], "strings": [], "functions": [ Function(name="custom_func", size_bytes=42), ], } adapter.set_fixture("custom", custom) assert "custom" in adapter._fixtures def test_custom_fixture_used_for_import(self, adapter: FakeAdapter, project: Project) -> None: """A custom fixture is used when importing a matching binary.""" adapter.configure_import_failure("my-special.exe", "Import failed for my-special") with pytest.raises(ImportFailedError): adapter.import_binary("my-special.exe", project) # --------------------------------------------------------------------------- # Deterministic behavior # --------------------------------------------------------------------------- class TestDeterministicBehavior: """Tests for deterministic output across repeated calls.""" def test_repeated_imports_same_sha256(self, adapter: FakeAdapter, project: Project) -> None: b1 = adapter.import_binary("test.exe", project) b2 = adapter.import_binary("test.exe", project) assert b1.sha256 == b2.sha256 def test_repeated_section_queries_same_result( self, adapter: FakeAdapter, binary: Binary ) -> None: s1 = adapter.get_sections(binary) s2 = adapter.get_sections(binary) assert len(s1) == len(s2) for i in range(len(s1)): assert s1[i].name == s2[i].name def test_repeated_function_queries_same_result( self, adapter: FakeAdapter, binary: Binary ) -> None: f1 = adapter.get_functions(binary) f2 = adapter.get_functions(binary) assert len(f1) == len(f2) for i in range(len(f1)): assert f1[i].name == f2[i].name assert f1[i].size_bytes == f2[i].size_bytes # --------------------------------------------------------------------------- # Partial results # --------------------------------------------------------------------------- class TestEnvironmentConfiguration: """Tests for BINARY_FAKE_* environment variable support in FakeAdapter.__init__. These env vars enable black-box CLI testing of failure and injection modes without modifying CLI command modules. """ def test_env_import_failure_triggers_import_failed_error( self, monkeypatch: Any, project: Project ) -> None: """BINARY_FAKE_IMPORT_FAILURE triggers ImportFailedError (exit 10).""" monkeypatch.setenv("BINARY_FAKE_IMPORT_FAILURE", "Simulated import error from env") adapter = FakeAdapter() adapter.set_fixture("pe-default", FakeAdapter.pe_fixture()) with pytest.raises(ImportFailedError) as exc: adapter.import_binary("anyfile.exe", project) assert exc.value.exit_code == 10 assert "Simulated import error from env" in str(exc.value) def test_env_analysis_failure_triggers_analysis_failed_error( self, monkeypatch: Any, binary: Binary ) -> None: """BINARY_FAKE_ANALYSIS_FAILURE triggers AnalysisFailedError (exit 11).""" monkeypatch.setenv("BINARY_FAKE_ANALYSIS_FAILURE", "Analysis crash from env") adapter = FakeAdapter() adapter.set_fixture("pe-default", FakeAdapter.pe_fixture()) # We need to import a binary first so the adapter knows about it b = adapter.import_binary("test.exe", Project(id=uuid4(), name="test-proj")) profile = AnalysisProfile(name="standard", analysers=["functions"]) with pytest.raises(AnalysisFailedError) as exc: adapter.analyze(b, profile) assert exc.value.exit_code == 11 assert "Analysis crash from env" in str(exc.value) def test_env_backend_failure_triggers_backend_failure_error( self, monkeypatch: Any, binary: Binary ) -> None: """BINARY_FAKE_BACKEND_FAILURE=method:msg triggers BackendFailureError (exit 13).""" monkeypatch.setenv("BINARY_FAKE_BACKEND_FAILURE", "get_functions:JVM OOM from env") adapter = FakeAdapter() adapter.set_fixture("pe-default", FakeAdapter.pe_fixture()) b = adapter.import_binary("test.exe", Project(id=uuid4(), name="test-proj")) with pytest.raises(BackendFailureError) as exc: adapter.get_functions(b) assert exc.value.exit_code == 13 assert "JVM OOM from env" in str(exc.value) def test_env_backend_failure_defaults_to_get_functions( self, monkeypatch: Any, binary: Binary ) -> None: """BINARY_FAKE_BACKEND_FAILURE without colon defaults to get_functions.""" monkeypatch.setenv("BINARY_FAKE_BACKEND_FAILURE", "Generic backend failure") adapter = FakeAdapter() adapter.set_fixture("pe-default", FakeAdapter.pe_fixture()) b = adapter.import_binary("test.exe", Project(id=uuid4(), name="test-proj")) with pytest.raises(BackendFailureError) as exc: adapter.get_functions(b) assert exc.value.exit_code == 13 assert "Generic backend failure" in str(exc.value) def test_env_slow_import_adds_delay(self, monkeypatch: Any, project: Project) -> None: """BINARY_FAKE_SLOW_IMPORT_MS adds configurable delay to import.""" monkeypatch.setenv("BINARY_FAKE_SLOW_IMPORT_MS", "100") adapter = FakeAdapter() adapter.set_fixture("pe-default", FakeAdapter.pe_fixture()) start = time.time() adapter.import_binary("test.exe", project) elapsed = time.time() - start assert elapsed >= 0.1, f"Expected >= 100ms delay, got {elapsed * 1000:.0f}ms" def test_env_slow_analyze_adds_delay(self, monkeypatch: Any, binary: Binary) -> None: """BINARY_FAKE_SLOW_ANALYZE_MS adds configurable delay to analyze.""" monkeypatch.setenv("BINARY_FAKE_SLOW_ANALYZE_MS", "100") adapter = FakeAdapter() adapter.set_fixture("pe-default", FakeAdapter.pe_fixture()) b = adapter.import_binary("test.exe", Project(id=uuid4(), name="test-proj")) profile = AnalysisProfile(name="quick", analysers=["functions"]) start = time.time() adapter.analyze(b, profile) elapsed = time.time() - start assert elapsed >= 0.1, f"Expected >= 100ms delay, got {elapsed * 1000:.0f}ms" def test_env_slow_decompile_adds_delay(self, monkeypatch: Any, binary: Binary) -> None: """BINARY_FAKE_SLOW_DECOMPILE_MS adds configurable delay to decompile.""" monkeypatch.setenv("BINARY_FAKE_SLOW_DECOMPILE_MS", "100") adapter = FakeAdapter() adapter.set_fixture("pe-default", FakeAdapter.pe_fixture()) b = adapter.import_binary("test.exe", Project(id=uuid4(), name="test-proj")) funcs = adapter.get_functions(b) main = next(f for f in funcs if f.name == "main") start = time.time() adapter.decompile(b, main) elapsed = time.time() - start assert elapsed >= 0.1, f"Expected >= 100ms delay, got {elapsed * 1000:.0f}ms" def test_env_unmapped_ranges_marks_addresses_as_unmapped( self, monkeypatch: Any, binary: Binary ) -> None: """BINARY_FAKE_UNMAPPED_RANGES marks address ranges as unmapped.""" monkeypatch.setenv("BINARY_FAKE_UNMAPPED_RANGES", "0x5000:0x6000,0x7000:0x7100") adapter = FakeAdapter() adapter.set_fixture("pe-default", FakeAdapter.pe_fixture()) b = adapter.import_binary("test.exe", Project(id=uuid4(), name="test-proj")) # Address 0x5000 should be unmapped addr1 = Address(space="ram", offset="0x5000", display="0x5000") with pytest.raises(ValueError, match="unmapped"): adapter.read_bytes(b, addr1, 16) # Address 0x7000 should also be unmapped addr2 = Address(space="ram", offset="0x7000", display="0x7000") with pytest.raises(ValueError, match="unmapped"): adapter.read_bytes(b, addr2, 16) # Address 0x401000 should still be mapped addr3 = Address(space="ram", offset="0x401000", display="0x401000") _data, length = adapter.read_bytes(b, addr3, 16) assert length == 16 def test_env_truncation_limits_bytes_at_specified_addresses( self, monkeypatch: Any, binary: Binary ) -> None: """BINARY_FAKE_TRUNCATION limits bytes at specified addresses.""" monkeypatch.setenv("BINARY_FAKE_TRUNCATION", "0x401000:8,0x402000:4") adapter = FakeAdapter() adapter.set_fixture("pe-default", FakeAdapter.pe_fixture()) b = adapter.import_binary("test.exe", Project(id=uuid4(), name="test-proj")) # Request 16 bytes at 0x401000, should get only 8 addr1 = Address(space="ram", offset="0x401000", display="0x401000") data1, length1 = adapter.read_bytes(b, addr1, 16) assert length1 == 8 assert len(data1) == 8 # Request 16 bytes at 0x402000, should get only 4 addr2 = Address(space="ram", offset="0x402000", display="0x402000") data2, length2 = adapter.read_bytes(b, addr2, 16) assert length2 == 4 assert len(data2) == 4 def test_env_empty_vars_do_not_affect_behavior( self, monkeypatch: Any, project: Project ) -> None: """Empty env vars produce a normal, fully functional adapter.""" monkeypatch.setenv("BINARY_FAKE_IMPORT_FAILURE", "") monkeypatch.setenv("BINARY_FAKE_ANALYSIS_FAILURE", "") adapter = FakeAdapter() adapter.set_fixture("pe-default", FakeAdapter.pe_fixture()) # Should import normally b = adapter.import_binary("test.exe", project) assert isinstance(b, Binary) assert b.format == "PE" def test_env_vars_work_without_modifying_cli_modules( self, monkeypatch: Any, project: Project ) -> None: """Env vars are read in FakeAdapter.__init__ only; CLI modules are untouched.""" monkeypatch.setenv("BINARY_FAKE_IMPORT_FAILURE", "Env import failure") monkeypatch.setenv("BINARY_FAKE_SLOW_ANALYZE_MS", "50") # Create adapter as CLI modules do (same pattern) adapter = FakeAdapter() adapter.set_fixture("pe-default", FakeAdapter.pe_fixture()) adapter.set_fixture("elf-default", FakeAdapter.elf_fixture()) adapter.set_fixture("macho-default", FakeAdapter.macho_fixture()) # Import should fail from env var with pytest.raises(ImportFailedError) as exc: adapter.import_binary("test.exe", project) assert exc.value.exit_code == 10 assert "Env import failure" in str(exc.value) def test_multiple_env_vars_combined(self, monkeypatch: Any, binary: Binary) -> None: """Multiple env vars combine correctly.""" monkeypatch.setenv("BINARY_FAKE_BACKEND_FAILURE", "get_sections:Backend crash") monkeypatch.setenv("BINARY_FAKE_SLOW_DECOMPILE_MS", "50") monkeypatch.setenv("BINARY_FAKE_UNMAPPED_RANGES", "0x9999:0x999a") adapter = FakeAdapter() adapter.set_fixture("pe-default", FakeAdapter.pe_fixture()) b = adapter.import_binary("test.exe", Project(id=uuid4(), name="test-proj")) # Backend failure on get_sections with pytest.raises(BackendFailureError) as exc: adapter.get_sections(b) assert exc.value.exit_code == 13 assert "Backend crash" in str(exc.value) # Unmapped range addr = Address(space="ram", offset="0x9999", display="0x9999") with pytest.raises(ValueError, match="unmapped"): adapter.read_bytes(b, addr, 1) class TestPartialResults: """Tests for partial analysis results.""" def test_analyze_partial_when_some_analyzers_unavailable( self, adapter: FakeAdapter, binary: Binary ) -> None: # Request an analyser that doesn't exist in the fixture profile = AnalysisProfile( name="custom", analysers=["functions", "nonexistent_analyzer"], ) result = adapter.analyze(binary, profile) assert result.partial is True assert len(result.completed_analysers) > 0 assert len(result.failed_analysers) > 0 assert len(result.diagnostics) > 0