"""Tests for the workspace directory structure management (projects/workspace.py). Validates that: - Workspace root discovery with env var and default fallback. - Project workspace creation produces all required subdirectories. - Workspace removal deletes everything recursively. - Workspace existence checks and listing work correctly. - Subdirectory path resolution returns correct paths. - Project name validation rejects invalid characters. """ 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 pathlib import Path import pytest from binary_analysis.projects.workspace import ( create_workspace, get_project_path, get_workspace_root, get_workspace_subdirs, list_workspaces, remove_workspace, validate_project_name, workspace_exists, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @pytest.fixture def temp_workspace_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Fixture: redirect workspace root to a temp directory.""" monkeypatch.setenv("BINARY_WORKSPACE_ROOT", str(tmp_path)) return tmp_path # --------------------------------------------------------------------------- # Workspace root # --------------------------------------------------------------------------- class TestWorkspaceRoot: """Tests for get_workspace_root and env var resolution.""" def test_env_var_resolution(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """BINARY_WORKSPACE_ROOT env var takes precedence.""" custom = tmp_path / "custom-workspaces" monkeypatch.setenv("BINARY_WORKSPACE_ROOT", str(custom)) root = get_workspace_root() assert root == custom.resolve() def test_default_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: """Without env var, falls back to XDG default.""" monkeypatch.delenv("BINARY_WORKSPACE_ROOT", raising=False) root = get_workspace_root() assert ".local/share/binary-analysis/workspaces" in str(root) # --------------------------------------------------------------------------- # Project workspace creation # --------------------------------------------------------------------------- class TestCreateWorkspace: """Tests for create_workspace.""" def test_creates_all_subdirectories(self, temp_workspace_root: Path) -> None: """Creating a workspace produces all required subdirectories.""" project_dir = create_workspace("my-project") assert project_dir.exists() assert (project_dir / "binaries").is_dir() assert (project_dir / "samples").is_dir() assert (project_dir / "audit").is_dir() assert (project_dir / "reports").is_dir() assert (project_dir / "exports").is_dir() assert (project_dir / "cache").is_dir() assert (project_dir / "backend" / "ghidra").is_dir() def test_creates_project_root_directory(self, temp_workspace_root: Path) -> None: """The project root directory exists after creation.""" create_workspace("test-proj") assert (temp_workspace_root / "test-proj").is_dir() def test_different_project_names(self, temp_workspace_root: Path) -> None: """Multiple projects can be created in the same root.""" create_workspace("project-a") create_workspace("project-b") assert workspace_exists("project-a") assert workspace_exists("project-b") assert (temp_workspace_root / "project-a") != (temp_workspace_root / "project-b") def test_rejects_duplicate_names(self, temp_workspace_root: Path) -> None: """Creating a project with an existing name raises FileExistsError.""" create_workspace("my-project") with pytest.raises(FileExistsError, match="already exists"): create_workspace("my-project") def test_get_project_path(self, temp_workspace_root: Path) -> None: """get_project_path returns the correct path.""" path = get_project_path("my-project") assert path == temp_workspace_root / "my-project" # --------------------------------------------------------------------------- # Workspace removal # --------------------------------------------------------------------------- class TestRemoveWorkspace: """Tests for remove_workspace.""" def test_removes_directory_and_contents(self, temp_workspace_root: Path) -> None: """Removing a workspace deletes the entire directory tree.""" create_workspace("to-remove") # Create some files inside (temp_workspace_root / "to-remove" / "project.json").write_text("{}") (temp_workspace_root / "to-remove" / "audit" / "events.jsonl").write_text("line1\n") assert workspace_exists("to-remove") remove_workspace("to-remove") assert not workspace_exists("to-remove") def test_nonexistent_project_raises(self, temp_workspace_root: Path) -> None: """Removing a nonexistent project raises FileNotFoundError.""" with pytest.raises(FileNotFoundError, match="not found"): remove_workspace("nonexistent") # --------------------------------------------------------------------------- # Workspace existence and listing # --------------------------------------------------------------------------- class TestWorkspaceExists: """Tests for workspace_exists.""" def test_exists_after_creation(self, temp_workspace_root: Path) -> None: """Workspace exists after creation.""" assert not workspace_exists("my-project") create_workspace("my-project") assert workspace_exists("my-project") def test_not_exists_after_removal(self, temp_workspace_root: Path) -> None: """Workspace does not exist after removal.""" create_workspace("my-project") remove_workspace("my-project") assert not workspace_exists("my-project") class TestListWorkspaces: """Tests for list_workspaces.""" def test_empty_workspace_root(self, temp_workspace_root: Path) -> None: """Empty workspace root returns empty list.""" assert list_workspaces() == [] def test_lists_created_projects(self, temp_workspace_root: Path) -> None: """Lists all created project names sorted.""" create_workspace("zzz") create_workspace("aaa") assert list_workspaces() == ["aaa", "zzz"] def test_skips_dot_directories(self, temp_workspace_root: Path) -> None: """Dot-directories are excluded from listings.""" create_workspace("my-project") (temp_workspace_root / ".hidden").mkdir(exist_ok=True) projects = list_workspaces() assert "my-project" in projects assert ".hidden" not in projects def test_skips_files(self, temp_workspace_root: Path) -> None: """Regular files are excluded from listings.""" create_workspace("my-project") (temp_workspace_root / "not-a-dir.txt").write_text("hello") projects = list_workspaces() assert "my-project" in projects assert "not-a-dir.txt" not in projects # --------------------------------------------------------------------------- # Subdirectory resolution # --------------------------------------------------------------------------- class TestGetWorkspaceSubdirs: """Tests for get_workspace_subdirs.""" def test_all_subdirs_present(self, temp_workspace_root: Path) -> None: """All standard subdirectories are returned.""" create_workspace("my-project") subdirs = get_workspace_subdirs("my-project") expected_keys = { "root", "binaries", "samples", "audit", "reports", "exports", "cache", "backend_ghidra", } assert set(subdirs.keys()) == expected_keys for path in subdirs.values(): assert path.exists() def test_nonexistent_project_raises(self, temp_workspace_root: Path) -> None: """Subdir lookup on nonexistent project raises FileNotFoundError.""" with pytest.raises(FileNotFoundError, match="not found"): get_workspace_subdirs("nonexistent") # --------------------------------------------------------------------------- # Project name validation # --------------------------------------------------------------------------- class TestValidateProjectName: """Tests for validate_project_name.""" def test_valid_names(self) -> None: """Various valid project names are accepted.""" valid_names = [ "my-project", "project_123", "a", "my_analysis", "test-project-v2", "123project", ] for name in valid_names: assert validate_project_name(name) == name def test_empty_name_raises(self) -> None: """Empty or whitespace-only names are rejected.""" with pytest.raises(ValueError, match="must not be empty"): validate_project_name("") with pytest.raises(ValueError, match="must not be empty"): validate_project_name(" ") def test_null_bytes_raises(self) -> None: """Names containing null bytes are rejected.""" with pytest.raises(ValueError, match="null bytes"): validate_project_name("bad\x00name") def test_path_separators_raises(self) -> None: """Names containing path separators are rejected.""" for sep in ["/", "\\"]: with pytest.raises(ValueError, match="path separators"): validate_project_name(f"evil{sep}name") def test_dot_prefix_raises(self) -> None: """Names starting with a dot are rejected.""" with pytest.raises(ValueError, match="dot"): validate_project_name(".hidden") def test_dot_and_dotdot_raises(self) -> None: """The names . and .. are rejected.""" with pytest.raises(ValueError, match="Invalid project name"): validate_project_name(".") with pytest.raises(ValueError, match="Invalid project name"): validate_project_name("..") def test_invalid_characters_raises(self) -> None: """Names with special characters are rejected.""" invalid_names = [ "my project", # space "proj$", # dollar sign "proj@test", # at sign "proj!", # exclamation "proj#", # hash "proj%", # percent ] for name in invalid_names: with pytest.raises(ValueError, match="invalid characters"): validate_project_name(name) def test_absolute_path_rejected(self, temp_workspace_root: Path) -> None: """Absolute paths as project names are rejected.""" with pytest.raises(ValueError, match="path separators"): validate_project_name("/etc/passwd") def test_dotdot_traversal_rejected(self) -> None: """Directory traversal via .. is rejected.""" with pytest.raises(ValueError, match="path separators"): validate_project_name("../escape")