test_worktree_security.py
1 """Security-focused integration tests for CLI worktree setup.""" 2 3 import subprocess 4 from pathlib import Path 5 6 import pytest 7 8 9 @pytest.fixture 10 def git_repo(tmp_path): 11 """Create a temporary git repo for testing real cli._setup_worktree behavior.""" 12 repo = tmp_path / "test-repo" 13 repo.mkdir() 14 subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) 15 subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo, check=True, capture_output=True) 16 subprocess.run(["git", "config", "user.name", "Test"], cwd=repo, check=True, capture_output=True) 17 (repo / "README.md").write_text("# Test Repo\n") 18 subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True) 19 subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=repo, check=True, capture_output=True) 20 return repo 21 22 23 def _force_remove_worktree(info: dict | None) -> None: 24 if not info: 25 return 26 subprocess.run( 27 ["git", "worktree", "remove", info["path"], "--force"], 28 cwd=info["repo_root"], 29 capture_output=True, 30 check=False, 31 ) 32 subprocess.run( 33 ["git", "branch", "-D", info["branch"]], 34 cwd=info["repo_root"], 35 capture_output=True, 36 check=False, 37 ) 38 39 40 class TestWorktreeIncludeSecurity: 41 def test_rejects_parent_directory_file_traversal(self, git_repo): 42 import cli as cli_mod 43 44 outside_file = git_repo.parent / "sensitive.txt" 45 outside_file.write_text("SENSITIVE DATA") 46 (git_repo / ".worktreeinclude").write_text("../sensitive.txt\n") 47 48 info = None 49 try: 50 info = cli_mod._setup_worktree(str(git_repo)) 51 assert info is not None 52 53 wt_path = Path(info["path"]) 54 assert not (wt_path.parent / "sensitive.txt").exists() 55 assert not (wt_path / "../sensitive.txt").resolve().exists() 56 finally: 57 _force_remove_worktree(info) 58 59 def test_rejects_parent_directory_directory_traversal(self, git_repo): 60 import cli as cli_mod 61 62 outside_dir = git_repo.parent / "outside-dir" 63 outside_dir.mkdir() 64 (outside_dir / "secret.txt").write_text("SENSITIVE DIR DATA") 65 (git_repo / ".worktreeinclude").write_text("../outside-dir\n") 66 67 info = None 68 try: 69 info = cli_mod._setup_worktree(str(git_repo)) 70 assert info is not None 71 72 wt_path = Path(info["path"]) 73 escaped_dir = wt_path.parent / "outside-dir" 74 assert not escaped_dir.exists() 75 assert not escaped_dir.is_symlink() 76 finally: 77 _force_remove_worktree(info) 78 79 def test_rejects_symlink_that_resolves_outside_repo(self, git_repo): 80 import cli as cli_mod 81 82 outside_file = git_repo.parent / "linked-secret.txt" 83 outside_file.write_text("LINKED SECRET") 84 (git_repo / "leak.txt").symlink_to(outside_file) 85 (git_repo / ".worktreeinclude").write_text("leak.txt\n") 86 87 info = None 88 try: 89 info = cli_mod._setup_worktree(str(git_repo)) 90 assert info is not None 91 92 assert not (Path(info["path"]) / "leak.txt").exists() 93 finally: 94 _force_remove_worktree(info) 95 96 def test_allows_valid_file_include(self, git_repo): 97 import cli as cli_mod 98 99 (git_repo / ".env").write_text("SECRET=***\n") 100 (git_repo / ".worktreeinclude").write_text(".env\n") 101 102 info = None 103 try: 104 info = cli_mod._setup_worktree(str(git_repo)) 105 assert info is not None 106 107 copied = Path(info["path"]) / ".env" 108 assert copied.exists() 109 assert copied.read_text() == "SECRET=***\n" 110 finally: 111 _force_remove_worktree(info) 112 113 def test_allows_valid_directory_include(self, git_repo): 114 import cli as cli_mod 115 116 assets_dir = git_repo / ".venv" / "lib" 117 assets_dir.mkdir(parents=True) 118 (assets_dir / "marker.txt").write_text("venv marker") 119 (git_repo / ".worktreeinclude").write_text(".venv\n") 120 121 info = None 122 try: 123 info = cli_mod._setup_worktree(str(git_repo)) 124 assert info is not None 125 126 linked_dir = Path(info["path"]) / ".venv" 127 assert linked_dir.is_symlink() 128 assert (linked_dir / "lib" / "marker.txt").read_text() == "venv marker" 129 finally: 130 _force_remove_worktree(info)