"""Security-focused integration tests for CLI worktree setup.""" import subprocess from pathlib import Path import pytest @pytest.fixture def git_repo(tmp_path): """Create a temporary git repo for testing real cli._setup_worktree behavior.""" repo = tmp_path / "test-repo" repo.mkdir() subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo, check=True, capture_output=True) subprocess.run(["git", "config", "user.name", "Test"], cwd=repo, check=True, capture_output=True) (repo / "README.md").write_text("# Test Repo\n") subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True) subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=repo, check=True, capture_output=True) return repo def _force_remove_worktree(info: dict | None) -> None: if not info: return subprocess.run( ["git", "worktree", "remove", info["path"], "--force"], cwd=info["repo_root"], capture_output=True, check=False, ) subprocess.run( ["git", "branch", "-D", info["branch"]], cwd=info["repo_root"], capture_output=True, check=False, ) class TestWorktreeIncludeSecurity: def test_rejects_parent_directory_file_traversal(self, git_repo): import cli as cli_mod outside_file = git_repo.parent / "sensitive.txt" outside_file.write_text("SENSITIVE DATA") (git_repo / ".worktreeinclude").write_text("../sensitive.txt\n") info = None try: info = cli_mod._setup_worktree(str(git_repo)) assert info is not None wt_path = Path(info["path"]) assert not (wt_path.parent / "sensitive.txt").exists() assert not (wt_path / "../sensitive.txt").resolve().exists() finally: _force_remove_worktree(info) def test_rejects_parent_directory_directory_traversal(self, git_repo): import cli as cli_mod outside_dir = git_repo.parent / "outside-dir" outside_dir.mkdir() (outside_dir / "secret.txt").write_text("SENSITIVE DIR DATA") (git_repo / ".worktreeinclude").write_text("../outside-dir\n") info = None try: info = cli_mod._setup_worktree(str(git_repo)) assert info is not None wt_path = Path(info["path"]) escaped_dir = wt_path.parent / "outside-dir" assert not escaped_dir.exists() assert not escaped_dir.is_symlink() finally: _force_remove_worktree(info) def test_rejects_symlink_that_resolves_outside_repo(self, git_repo): import cli as cli_mod outside_file = git_repo.parent / "linked-secret.txt" outside_file.write_text("LINKED SECRET") (git_repo / "leak.txt").symlink_to(outside_file) (git_repo / ".worktreeinclude").write_text("leak.txt\n") info = None try: info = cli_mod._setup_worktree(str(git_repo)) assert info is not None assert not (Path(info["path"]) / "leak.txt").exists() finally: _force_remove_worktree(info) def test_allows_valid_file_include(self, git_repo): import cli as cli_mod (git_repo / ".env").write_text("SECRET=***\n") (git_repo / ".worktreeinclude").write_text(".env\n") info = None try: info = cli_mod._setup_worktree(str(git_repo)) assert info is not None copied = Path(info["path"]) / ".env" assert copied.exists() assert copied.read_text() == "SECRET=***\n" finally: _force_remove_worktree(info) def test_allows_valid_directory_include(self, git_repo): import cli as cli_mod assets_dir = git_repo / ".venv" / "lib" assets_dir.mkdir(parents=True) (assets_dir / "marker.txt").write_text("venv marker") (git_repo / ".worktreeinclude").write_text(".venv\n") info = None try: info = cli_mod._setup_worktree(str(git_repo)) assert info is not None linked_dir = Path(info["path"]) / ".venv" assert linked_dir.is_symlink() assert (linked_dir / "lib" / "marker.txt").read_text() == "venv marker" finally: _force_remove_worktree(info) class TestWorktreeIncludeEncoding: """The include list and .gitignore are UTF-8 files; reading them with the locale default breaks Windows (cp1251/GBK mojibake or UnicodeDecodeError, swallowed at DEBUG so no include is copied), and a Notepad BOM glues to the first line on every platform.""" def test_bom_in_worktreeinclude_does_not_hide_first_entry(self, git_repo): import cli as cli_mod (git_repo / ".env").write_text("SECRET=***\n") # Notepad-style UTF-8 with BOM; the BOM must not become part of the # first entry's path. (git_repo / ".worktreeinclude").write_bytes(".env\n".encode("utf-8")) info = None try: info = cli_mod._setup_worktree(str(git_repo)) assert info is not None assert (Path(info["path"]) / ".env").exists() finally: _force_remove_worktree(info) def test_non_ascii_worktreeinclude_entry_copied(self, git_repo): import cli as cli_mod secret = git_repo / "секреты.env" secret.write_text("SECRET=***\n", encoding="utf-8") (git_repo / ".worktreeinclude").write_bytes( "# ключи агента\nсекреты.env\n".encode("utf-8") ) info = None try: info = cli_mod._setup_worktree(str(git_repo)) assert info is not None assert (Path(info["path"]) / "секреты.env").exists() finally: _force_remove_worktree(info) def test_bom_in_gitignore_does_not_duplicate_worktrees_entry(self, git_repo): import cli as cli_mod (git_repo / ".gitignore").write_bytes(".worktrees/\n".encode("utf-8")) info = None try: info = cli_mod._setup_worktree(str(git_repo)) assert info is not None lines = ( (git_repo / ".gitignore") .read_text(encoding="utf-8-sig") .splitlines() ) assert lines.count(".worktrees/") == 1, ( "BOM glued to the first line must not defeat the membership " f"check and duplicate the entry: {lines!r}" ) finally: _force_remove_worktree(info)