/ tests / cli / test_worktree_security.py
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)