"""Unit tests for structural query CLI commands. Covers: sections, entrypoints, imports, exports, symbols, strings. Validates against VAL-STRUCT-001 through VAL-STRUCT-016. """ from __future__ import annotations import json # Add the scripts directory to the path for imports 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: # Set up workspace as the temp dir 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 # --------------------------------------------------------------------------- # Import helpers # --------------------------------------------------------------------------- def _make_args(**kwargs): """Create a mock argparse.Namespace.""" defaults = { "json": True, "quiet": False, "limit": None, "timeout": 300, "cursor": None, "min_length": 4, "contains": None, "encoding": None, "sort": "address", } defaults.update(kwargs) class Args: pass args = Args() for k, v in defaults.items(): setattr(args, k, v) return args # --------------------------------------------------------------------------- # Test helper: cursor encoding/decoding # --------------------------------------------------------------------------- class TestCursorScoping: """Test cursor encode/decode and scope validation.""" def test_encode_decode_roundtrip(self): from binary_analysis.cli.structural import _decode_cursor, _encode_cursor data = {"c": "sections", "p": "proj-1", "fh": "abc", "s": "address", "o": 10} encoded = _encode_cursor(data) decoded = _decode_cursor(encoded) assert decoded == data def test_decode_invalid_cursor(self): from binary_analysis.cli.structural import _decode_cursor from binary_analysis.domain.errors import InvalidArgsError with pytest.raises(InvalidArgsError): _decode_cursor("not-valid-base64!!!") def test_validate_cursor_scope_match(self): import hashlib import json from binary_analysis.cli.structural import _validate_cursor_scope # Compute the actual filters hash for empty filters filters_hash = hashlib.md5(json.dumps({}, sort_keys=True).encode("utf-8")).hexdigest() cursor_data = {"c": "sections", "p": "proj-1", "fh": filters_hash, "s": None, "o": 10} offset = _validate_cursor_scope( cursor_data, "sections", "proj-1", filters=None, sort_key=None ) assert offset == 10 def test_validate_cursor_scope_mismatched_command(self): from binary_analysis.cli.structural import _validate_cursor_scope from binary_analysis.domain.errors import InvalidArgsError cursor_data = {"c": "entrypoints", "p": "proj-1", "fh": "abc", "s": None, "o": 10} with pytest.raises(InvalidArgsError, match="command"): _validate_cursor_scope(cursor_data, "sections", "proj-1", filters=None, sort_key=None) def test_validate_cursor_scope_mismatched_project(self): from binary_analysis.cli.structural import _validate_cursor_scope from binary_analysis.domain.errors import InvalidArgsError cursor_data = {"c": "sections", "p": "proj-2", "fh": "abc", "s": None, "o": 10} with pytest.raises(InvalidArgsError, match="project"): _validate_cursor_scope(cursor_data, "sections", "proj-1", filters=None, sort_key=None) def test_validate_cursor_scope_mismatched_filters(self): from binary_analysis.cli.structural import _validate_cursor_scope from binary_analysis.domain.errors import InvalidArgsError cursor_data = {"c": "strings", "p": "proj-1", "fh": "abc", "s": None, "o": 10} with pytest.raises(InvalidArgsError, match="filters"): _validate_cursor_scope( cursor_data, "strings", "proj-1", filters={"min_length": 10}, sort_key=None, # Different filter hash ) def test_make_cursor_scoped(self): from binary_analysis.cli.structural import _decode_cursor, _make_cursor cursor = _make_cursor("strings", "proj-1", 20, filters={"min_length": 10}) decoded = _decode_cursor(cursor) assert decoded["c"] == "strings" assert decoded["p"] == "proj-1" assert decoded["o"] == 20 # --------------------------------------------------------------------------- # Test: Sections # --------------------------------------------------------------------------- class TestSectionsCommand: """Tests for the 'sections' command (VAL-STRUCT-001, 002, 014).""" def test_sections_basic(self, monkeypatch, project_ready): """VAL-STRUCT-001: Sections return canonical objects with pagination.""" from binary_analysis.cli.structural import execute_sections monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj") result = execute_sections(args) assert result["success"] is True assert "items" in result["data"] assert "total" in result["data"] assert "has_more" in result["data"] assert "next_cursor" in result["data"] items = result["data"]["items"] assert len(items) > 0 # Each section must have required fields for section in items: assert "name" in section assert "address" in section assert "virtual_size" in section assert "raw_size" in section assert "flags" in section assert "entropy" in section # Address must be canonical addr = section["address"] assert isinstance(addr, dict) assert "space" in addr assert "offset" in addr assert "display" in addr # Flags must be list of strings assert isinstance(section["flags"], list) def test_sections_limit_2(self, monkeypatch, project_ready): """VAL-STRUCT-002: --limit 2 returns exactly 2 items with has_more=true.""" from binary_analysis.cli.structural import execute_sections monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", limit=2) result = execute_sections(args) assert result["success"] is True assert len(result["data"]["items"]) == 2 assert result["data"]["has_more"] is True assert result["data"]["next_cursor"] is not None def test_sections_pagination_cursor(self, monkeypatch, project_ready): """Cursor from first page produces next page with no overlap.""" from binary_analysis.cli.structural import execute_sections monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_ready), ) args1 = _make_args(project="test-proj", limit=2) result1 = execute_sections(args1) cursor = result1["data"]["next_cursor"] items1 = result1["data"]["items"] args2 = _make_args(project="test-proj", limit=2, cursor=cursor) result2 = execute_sections(args2) items2 = result2["data"]["items"] # No overlap between pages names1 = {s["name"] for s in items1} names2 = {s["name"] for s in items2} assert names1.isdisjoint(names2) def test_sections_cursor_mismatched(self, monkeypatch, project_ready): """Cursor from sections used with entrypoints returns error.""" from binary_analysis.cli.structural import ( _make_cursor, execute_entrypoints, ) from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_ready), ) with open(project_ready / "project.json") as f: manifest = json.load(f) # Create a sections cursor and try with entrypoints sections_cursor = _make_cursor("sections", manifest["id"], 5) args = _make_args(project="test-proj", cursor=sections_cursor) with pytest.raises(InvalidArgsError, match="command"): execute_entrypoints(args) def test_sections_unanalyzed_project(self, monkeypatch, project_imported): """VAL-STRUCT-014: Unanalyzed project succeeds with info diagnostic.""" from binary_analysis.cli.structural import execute_sections monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_imported), ) args = _make_args(project="test-proj") result = execute_sections(args) assert result["success"] is True assert len(result["data"]["items"]) > 0 # Must have info-level diagnostic about incomplete analysis diagnostics = result.get("diagnostics", []) assert any( d.get("severity") == "INFO" and "not been fully analyzed" in d.get("message", "") for d in diagnostics ) # --------------------------------------------------------------------------- # Test: Entrypoints # --------------------------------------------------------------------------- class TestEntrypointsCommand: """Tests for the 'entrypoints' command (VAL-STRUCT-003).""" def test_entrypoints_basic(self, monkeypatch, project_ready): """VAL-STRUCT-003: Entrypoints with kind and confidence.""" from binary_analysis.cli.structural import execute_entrypoints monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj") result = execute_entrypoints(args) assert result["success"] is True items = result["data"]["items"] assert len(items) > 0 for ep in items: assert "address" in ep assert "kind" in ep assert ep["kind"] in ("program", "library", "boot", "firmware", "unknown") assert "confidence" in ep assert ep["confidence"] in ("HIGH", "MEDIUM", "LOW", "UNKNOWN") assert "name" in ep def test_entrypoints_pagination(self, monkeypatch, project_ready): """Entrypoints pagination works.""" from binary_analysis.cli.structural import execute_entrypoints monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", limit=1) result = execute_entrypoints(args) assert result["success"] is True assert "total" in result["data"] assert "has_more" in result["data"] assert "next_cursor" in result["data"] # --------------------------------------------------------------------------- # Test: Imports # --------------------------------------------------------------------------- class TestImportsCommand: """Tests for the 'imports' command (VAL-STRUCT-004).""" def test_imports_basic(self, monkeypatch, project_ready): """VAL-STRUCT-004: Imports with module, symbol, resolution, ordinal.""" from binary_analysis.cli.structural import execute_imports monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj") result = execute_imports(args) assert result["success"] is True items = result["data"]["items"] assert len(items) > 0 for imp in items: assert "module" in imp assert "symbol" in imp assert "address" in imp assert "resolution" in imp assert imp["resolution"] in ("RESOLVED", "PARTIAL", "UNRESOLVED") assert "ordinal" in imp # --------------------------------------------------------------------------- # Test: Exports # --------------------------------------------------------------------------- class TestExportsCommand: """Tests for the 'exports' command (VAL-STRUCT-005).""" def test_exports_basic(self, monkeypatch, project_ready): """VAL-STRUCT-005: Exports with name, address, ordinal, forwarder, kind.""" from binary_analysis.cli.structural import execute_exports monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj") result = execute_exports(args) assert result["success"] is True items = result["data"]["items"] assert len(items) > 0 for exp in items: assert "name" in exp assert "address" in exp assert "ordinal" in exp assert "forwarder" in exp # may be None assert "kind" in exp assert exp["kind"] in ("function", "data") # --------------------------------------------------------------------------- # Test: Symbols # --------------------------------------------------------------------------- class TestSymbolsCommand: """Tests for the 'symbols' command (VAL-STRUCT-006).""" def test_symbols_basic(self, monkeypatch, project_ready): """VAL-STRUCT-006: Symbols with name, address, source, scope.""" from binary_analysis.cli.structural import execute_symbols monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj") result = execute_symbols(args) assert result["success"] is True items = result["data"]["items"] assert len(items) > 0 source_values = { "ORIGINAL", "IMPORTED", "DEBUG", "BACKEND_GENERATED", "USER_ANNOTATION", "AGENT_SUGGESTION", "UNKNOWN", } scope_values = {"global", "local", "unknown"} for sym in items: assert "name" in sym assert "address" in sym assert "source" in sym assert sym["source"] in source_values assert "scope" in sym assert sym["scope"] in scope_values def test_symbols_imported_cross_linking(self, monkeypatch, project_ready): """IMPORTED symbols are cross-linked to imports table.""" from binary_analysis.cli.structural import execute_symbols monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj") result = execute_symbols(args) imported_symbols = [s for s in result["data"]["items"] if s.get("source") == "IMPORTED"] # At least some symbols should be imported # (from FakeAdapter fixtures, there are symbols with IMPORTED source) if imported_symbols: for sym in imported_symbols: # Should have cross-link to import info if found if "import" in sym: imp_info = sym["import"] assert "module" in imp_info assert "symbol" in imp_info assert "resolution" in imp_info # --------------------------------------------------------------------------- # Test: Strings # --------------------------------------------------------------------------- class TestStringsCommand: """Tests for the 'strings' command (VAL-STRUCT-007, 008, 009, 010, 016).""" def test_strings_basic(self, monkeypatch, project_ready): """VAL-STRUCT-007: Strings with text, encoding, address, length.""" from binary_analysis.cli.structural import execute_strings monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj") result = execute_strings(args) assert result["success"] is True items = result["data"]["items"] assert len(items) > 0 for s in items: assert "text" in s assert "encoding" in s assert s["encoding"] in ("ASCII", "UTF-8", "UTF-16") assert "address" in s assert "length" in s assert isinstance(s["length"], int) def test_strings_min_length(self, monkeypatch, project_ready): """VAL-STRUCT-008: --min-length excludes short strings.""" from binary_analysis.cli.structural import execute_strings monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_ready), ) # Get all strings first args_all = _make_args(project="test-proj", min_length=1) result_all = execute_strings(args_all) # Now filter with min-length=10 args_filtered = _make_args(project="test-proj", min_length=10) result_filtered = execute_strings(args_filtered) # Every item in filtered result must have length >= 10 for s in result_filtered["data"]["items"]: assert s["length"] >= 10 # Total count must be <= unfiltered count assert result_filtered["data"]["total"] <= result_all["data"]["total"] # Must report applied_filters assert "applied_filters" in result_filtered["data"] filters = result_filtered["data"]["applied_filters"] assert any(f["filter"] == "min_length" for f in filters) def test_strings_contains(self, monkeypatch, project_ready): """VAL-STRUCT-009: --contains returns substring matches.""" from binary_analysis.cli.structural import execute_strings monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", contains="Error") result = execute_strings(args) # Every item must contain the substring for s in result["data"]["items"]: assert "Error" in s["text"] # Must report applied_filters assert "applied_filters" in result["data"] filters = result["data"]["applied_filters"] assert any(f["filter"] == "contains" and f["value"] == "Error" for f in filters) def test_strings_combined_filters(self, monkeypatch, project_ready): """VAL-STRUCT-010: Combined --contains + --min-length apply both.""" from binary_analysis.cli.structural import execute_strings monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", contains="GetProc", min_length=5) result = execute_strings(args) # Every item must satisfy both filters for s in result["data"]["items"]: assert "GetProc" in s["text"] assert s["length"] >= 5 # Both filters in applied_filters assert "applied_filters" in result["data"] filters = result["data"]["applied_filters"] assert any(f["filter"] == "contains" for f in filters) assert any(f["filter"] == "min_length" for f in filters) def test_strings_unicode_preservation(self, monkeypatch, project_ready): """VAL-STRUCT-016: Unicode strings preserved in valid JSON output. We monkeypatch the FakeAdapter.get_strings to include Unicode strings and verify the JSON output is reparsable by python3 -m json.tool. """ import binary_analysis.cli.structural as struct_mod from binary_analysis.cli.structural import execute_strings from binary_analysis.domain.entities import Address, String # Monkeypatch project resolution monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_ready), ) original_get_adapter = struct_mod._get_adapter_and_binary def _patched_get_adapter_and_binary(project_path, manifest): adapter, binary, proj_info = original_get_adapter(project_path, manifest) # Monkeypatch get_strings to return unicode test strings unicode_strings = [ String( text="你好世界", encoding="UTF-8", address=Address(space="ram", offset="0x500000", display="0x500000"), length=8, ), String( text="😀🎉💻", encoding="UTF-8", address=Address(space="ram", offset="0x500008", display="0x500008"), length=6, ), String( text="שָׁלוֹם", encoding="UTF-8", address=Address(space="ram", offset="0x500010", display="0x500010"), length=5, ), String( text='He said "hello"', encoding="ASCII", address=Address(space="ram", offset="0x500018", display="0x500018"), length=15, ), String( text="C:\\path\\to\\file", encoding="ASCII", address=Address(space="ram", offset="0x500028", display="0x500028"), length=16, ), ] # Override get_strings on the adapter instance def patched_get_strings(binary, min_length=4, contains=None, encoding_filter=None): result = [] for s in unicode_strings: if s.length < min_length: continue if contains is not None and contains not in s.text: continue if encoding_filter is not None and s.encoding != encoding_filter: continue result.append(s) return result adapter.get_strings = patched_get_strings return adapter, binary, proj_info struct_mod._get_adapter_and_binary = _patched_get_adapter_and_binary try: args = _make_args(project="test-proj", min_length=1) result = execute_strings(args) assert result["success"] is True # Convert to JSON and verify it's reparsable json_str = json.dumps(result, ensure_ascii=False) parsed = json.loads(json_str) # Verify JSON validates with json.tool import subprocess proc = subprocess.run( ["python3", "-m", "json.tool"], input=json_str, capture_output=True, text=True, ) assert proc.returncode == 0, f"JSON validation failed: {proc.stderr}" # Check that unicode strings survived the roundtrip items = parsed["data"]["items"] texts = {s["text"] for s in items} assert "你好世界" in texts assert "😀🎉💻" in texts assert "שָׁלוֹם" in texts assert 'He said "hello"' in texts assert "C:\\path\\to\\file" in texts finally: struct_mod._get_adapter_and_binary = original_get_adapter def test_strings_pagination_cursor_scope_filters(self, monkeypatch, project_ready): """VAL-STRUCT-015: Cursor from different filter set returns error.""" from binary_analysis.cli.structural import execute_strings from binary_analysis.domain.errors import InvalidArgsError monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_ready), ) # Get cursor with min_length=4 (default) args1 = _make_args(project="test-proj", limit=1, min_length=4) result1 = execute_strings(args1) cursor = result1["data"]["next_cursor"] # Try using cursor with min_length=10 (different filter) args2 = _make_args(project="test-proj", limit=1, min_length=10, cursor=cursor) with pytest.raises(InvalidArgsError, match="filters"): execute_strings(args2) def test_strings_contains_no_match(self, monkeypatch, project_ready): """--contains with no matches returns empty result.""" from binary_analysis.cli.structural import execute_strings monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_ready), ) args = _make_args(project="test-proj", contains="ZZZZNOMATCHZZZZ") result = execute_strings(args) assert result["success"] is True assert result["data"]["total"] == 0 assert result["data"]["items"] == [] # --------------------------------------------------------------------------- # Test: Binary not found # --------------------------------------------------------------------------- class TestErrorHandling: """Test error handling for structural commands.""" def test_binary_not_found(self, tmp_path): """Project with no binary returns appropriate error.""" import json as _json from datetime import datetime, timezone from binary_analysis.cli.structural import execute_sections 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.structural as struct_mod original_resolve = struct_mod._resolve_project_path struct_mod._resolve_project_path = lambda _: str(project_dir) try: args = _make_args(project="empty-proj") with pytest.raises(BinaryNotFoundError): execute_sections(args) finally: struct_mod._resolve_project_path = original_resolve def test_project_not_found(self): """Non-existent project returns error.""" from binary_analysis.cli.structural import execute_sections from binary_analysis.domain.errors import ProjectNotFoundError args = _make_args(project="nonexistent-12345") with pytest.raises(ProjectNotFoundError): execute_sections(args) # --------------------------------------------------------------------------- # Test: JSON format compliance # --------------------------------------------------------------------------- class TestJsonFormat: """Test JSON output format compliance.""" def test_json_envelope_has_required_fields(self, monkeypatch, project_ready): """All structural commands return valid JSON with standard envelope.""" from binary_analysis.cli.structural import ( execute_entrypoints, execute_exports, execute_imports, execute_sections, execute_strings, execute_symbols, ) monkeypatch.setattr( "binary_analysis.cli.structural._resolve_project_path", lambda _: str(project_ready), ) commands = { "sections": execute_sections, "entrypoints": execute_entrypoints, "imports": execute_imports, "exports": execute_exports, "symbols": execute_symbols, "strings": execute_strings, } for _cmd_name, cmd_fn in commands.items(): args = _make_args(project="test-proj", limit=2) result = cmd_fn(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 pagination fields assert "items" in result["data"] assert "total" in result["data"] assert "has_more" in result["data"] assert "next_cursor" in result["data"] # Items should be lists assert isinstance(result["data"]["items"], list) assert isinstance(result["data"]["total"], int) assert isinstance(result["data"]["has_more"], bool)