"""Unit tests for git operations API.

Tests the git_operations.py and git_api.py modules covering:
- Command allowlist validation
- Flag validation (global and per-command)
- Config key validation (-c options)
- Path validation and traversal prevention
- Environment sanitization
- HMAC authentication
- Nonce replay protection
- Rate limiting
- Response handling (truncation, size limits)
- Remote subcommand validation
"""

import base64
import json
import os
import subprocess
import tempfile
import time
from unittest.mock import MagicMock, patch

import pytest

# mitmproxy mocks and sys.path setup handled by conftest.py

# These tests require flask (test-proxy extra); skip if not installed
pytest.importorskip("flask", reason="requires test-proxy extras (flask)")

# Import the modules under test
from git_operations import (
    MAX_ARGS_COUNT,
    MAX_RESPONSE_SIZE,
    MAX_STDIN_SIZE,
    GitExecRequest,
    build_clean_env,
    check_push_protected_branches,
    execute_git,
    validate_command,
    validate_clone_args,
    validate_path,
    validate_path_args,
    validate_request,
)

import git_api
from git_api import (
    CLOCK_WINDOW_SECONDS,
    MAX_REQUEST_BODY,
    NonceStore,
    RateLimiter,
    SecretStore,
    compute_signature,
    create_git_api,
)


# ---------------------------------------------------------------------------
# Test Classes
# ---------------------------------------------------------------------------


class TestCommandAllowlist:
    """Tests for command allowlist validation."""

    def test_allowed_commands_pass_through(self):
        """Test that all allowlisted commands pass validation."""
        allowed_cmds = ["status", "add", "commit", "push", "pull", "fetch", "diff", "log", "clone"]

        for cmd in allowed_cmds:
            result = validate_command([cmd])
            assert result is None, f"Command {cmd} should be allowed but got: {result}"

    def test_non_allowlisted_commands_rejected(self):
        """Test that non-allowlisted commands are rejected (e.g., reset, rm)."""
        blocked_cmds = ["reset", "rm", "gc", "fsck", "filter-branch", "reflog"]

        for cmd in blocked_cmds:
            result = validate_command([cmd])
            assert result is not None, f"Command {cmd} should be blocked"
            assert "not allowed" in result.reason.lower()


class TestFlagValidation:
    """Tests for flag validation."""

    def test_git_dir_flag_blocked(self):
        """Test that --git-dir flag is blocked."""
        result = validate_command(["--git-dir=/tmp/evil", "status"])
        assert result is not None
        assert "blocked flag" in result.reason.lower()
        assert "--git-dir" in result.reason

    def test_force_flag_on_push_blocked(self):
        """Test that --force on push is blocked."""
        result = validate_command(["push", "--force", "origin", "main"])
        assert result is not None
        assert "blocked flag" in result.reason.lower()
        assert "push" in result.reason.lower()

    def test_interactive_rebase_blocked(self):
        """Test that interactive rebase is blocked."""
        result = validate_command(["rebase", "-i", "HEAD~3"])
        assert result is not None
        assert "blocked flag" in result.reason.lower()
        assert "rebase" in result.reason.lower()


class TestConfigKeyValidation:
    """Tests for -c config key validation."""

    def test_config_core_hooks_path_blocked(self):
        """Test that -c core.hooksPath=evil is blocked (never-allow)."""
        result = validate_command(["-c", "core.hooksPath=/tmp/evil", "status"])
        assert result is not None
        assert "blocked config key" in result.reason.lower()

    def test_config_alias_blocked(self):
        """Test that -c alias.st=!evil is blocked (never-allow)."""
        result = validate_command(["-c", "alias.st=!evil", "status"])
        assert result is not None
        assert "blocked config key" in result.reason.lower()

    def test_config_ssh_command_blocked(self):
        """Test that -c core.sshCommand=evil is blocked (never-allow)."""
        result = validate_command(["-c", "core.sshCommand=evil", "status"])
        assert result is not None
        assert "blocked config key" in result.reason.lower()

    def test_config_credential_helper_blocked(self):
        """Test that -c credential.helper=evil is blocked (never-allow)."""
        result = validate_command(["-c", "credential.helper=evil", "status"])
        assert result is not None
        assert "blocked config key" in result.reason.lower()

    def test_config_user_name_allowed(self):
        """Test that -c user.name=Test is allowed (permitted prefix)."""
        result = validate_command(["-c", "user.name=Test User", "status"])
        assert result is None

    def test_config_color_ui_allowed(self):
        """Test that -c color.ui=auto is allowed (permitted prefix)."""
        result = validate_command(["-c", "color.ui=auto", "status"])
        assert result is None


class TestCloneValidation:
    """Tests for clone validation and allowlist enforcement."""

    def test_clone_allows_marketplace_https(self):
        """Marketplace clone should be allowed via HTTPS."""
        metadata = {"repo": "foundry-works/foundry-sandbox"}
        extra, error = validate_clone_args(
            ["clone", "https://github.com/anthropics/claude-plugins-official.git"],
            metadata,
        )
        assert error is None
        assert extra is not None

    def test_clone_blocks_unknown_repo(self):
        """Clone of non-allowlisted repo should be blocked."""
        metadata = {"repo": "foundry-works/foundry-sandbox"}
        extra, error = validate_clone_args(
            ["clone", "https://github.com/example/unknown.git"],
            metadata,
        )
        assert error is not None
        assert "not authorized" in error.reason.lower()

    def test_clone_blocks_non_https(self):
        """Only HTTPS GitHub URLs are allowed for clone."""
        metadata = {"repo": "foundry-works/foundry-sandbox"}
        extra, error = validate_clone_args(
            ["clone", "git@github.com:anthropics/claude-plugins-official.git"],
            metadata,
        )
        assert error is not None
        assert "https://github.com" in error.reason


class TestPathValidation:
    """Tests for path validation and traversal prevention."""

    def test_path_traversal_blocked(self):
        """Test that path traversal (..) is blocked."""
        with tempfile.TemporaryDirectory() as tmpdir:
            result_path, error = validate_path("../../../etc", tmpdir)
            assert error is not None
            assert "traversal" in error.reason.lower()

    def test_symlink_escape_blocked(self):
        """Test that symlink in cwd subpath does not escape repo root."""
        with tempfile.TemporaryDirectory() as tmpdir:
            # Create a directory structure
            subdir = os.path.join(tmpdir, "subdir")
            os.makedirs(subdir)

            # Create a symlink pointing outside repo root
            evil_link = os.path.join(subdir, "evil")
            os.symlink("/etc", evil_link)

            # Try to use the symlink path
            result_path, error = validate_path("subdir/evil", tmpdir)

            # Should be blocked because realpath resolves to /etc
            assert error is not None
            assert "outside" in error.reason.lower()

    def test_path_args_allows_extra_root(self):
        """Test that extra allowed roots permit absolute paths."""
        with tempfile.TemporaryDirectory() as tmpdir:
            extra_root = os.path.join(tmpdir, "extra")
            os.makedirs(extra_root, exist_ok=True)
            dest = os.path.join(extra_root, "plugin")
            error = validate_path_args(
                ["clone", "https://github.com/anthropics/claude-plugins-official.git", dest],
                tmpdir,
                extra_allowed_roots=[extra_root],
            )
            assert error is None


class TestRequestValidation:
    """Tests for request body validation."""

    def test_decoded_stdin_over_1mb_rejected(self):
        """Test that decoded stdin over 1MB is rejected."""
        large_data = b"x" * (MAX_STDIN_SIZE + 1)
        encoded = base64.b64encode(large_data).decode("ascii")

        raw = {
            "args": ["status"],
            "stdin_b64": encoded,
        }

        request, error = validate_request(raw)
        assert error is not None
        assert "exceeds limit" in error.reason.lower()

    def test_max_args_count_enforced(self):
        """Test that max args count is enforced."""
        raw = {
            "args": ["arg"] * (MAX_ARGS_COUNT + 1),
        }

        request, error = validate_request(raw)
        assert error is not None
        assert "too many arguments" in error.reason.lower()


class TestEnvironmentSanitization:
    """Tests for environment sanitization."""

    def test_git_config_parameters_cleared(self):
        """Test that GIT_CONFIG_PARAMETERS env var is cleared."""
        os.environ["GIT_CONFIG_PARAMETERS"] = "'alias.st=!evil'"
        try:
            clean_env = build_clean_env()
            assert "GIT_CONFIG_PARAMETERS" not in clean_env
        finally:
            os.environ.pop("GIT_CONFIG_PARAMETERS", None)

    def test_git_dir_env_cleared(self):
        """Test that GIT_DIR env var is cleared."""
        os.environ["GIT_DIR"] = "/tmp/evil"
        try:
            clean_env = build_clean_env()
            assert "GIT_DIR" not in clean_env
        finally:
            os.environ.pop("GIT_DIR", None)

    def test_git_ssh_command_env_cleared(self):
        """Test that GIT_SSH_COMMAND env var is cleared."""
        os.environ["GIT_SSH_COMMAND"] = "evil"
        try:
            clean_env = build_clean_env()
            assert "GIT_SSH_COMMAND" not in clean_env
        finally:
            os.environ.pop("GIT_SSH_COMMAND", None)


class TestRemoteSubcommands:
    """Tests for git remote subcommand validation."""

    def test_git_remote_add_blocked(self):
        """Test that git remote add is blocked."""
        result = validate_command(["remote", "add", "origin", "https://evil.com/repo.git"])
        assert result is not None
        assert "not allowed" in result.reason.lower()

    def test_git_remote_set_url_blocked(self):
        """Test that git remote set-url is blocked."""
        result = validate_command(["remote", "set-url", "origin", "https://evil.com/repo.git"])
        assert result is not None
        assert "not allowed" in result.reason.lower()

    def test_git_remote_dash_v_allowed(self):
        """Test that git remote -v is allowed."""
        result = validate_command(["remote", "-v"])
        assert result is None


class TestHMACAuthentication:
    """Tests for HMAC authentication."""

    def test_unauthenticated_request_returns_401(self):
        """Test that unauthenticated request returns 401."""
        app = create_git_api()
        client = app.test_client()

        # Request without auth headers
        response = client.post(
            "/git/exec",
            data=json.dumps({"args": ["status"]}),
            content_type="application/json",
        )

        assert response.status_code == 401

    def test_spoofed_sandbox_id_returns_401(self):
        """Test that spoofed sandbox ID without valid HMAC returns 401."""
        # Create a custom secret store with a known secret
        secrets = SecretStore()
        with tempfile.TemporaryDirectory() as tmpdir:
            secrets._path = tmpdir
            secret_file = os.path.join(tmpdir, "real-sandbox")
            with open(secret_file, "wb") as f:
                f.write(b"secret123")

            app = create_git_api(secret_store=secrets)
            client = app.test_client()

            # Try to use a different sandbox ID with wrong signature
            body = json.dumps({"args": ["status"]}).encode("utf-8")
            timestamp = str(time.time())
            nonce = "unique-nonce"

            # Compute signature for wrong sandbox
            wrong_sig = compute_signature(
                "POST", "/git/exec", body, timestamp, nonce, b"wrong-secret"
            )

            response = client.post(
                "/git/exec",
                data=body,
                headers={
                    "X-Sandbox-Id": "real-sandbox",
                    "X-Request-Signature": wrong_sig,
                    "X-Request-Timestamp": timestamp,
                    "X-Request-Nonce": nonce,
                },
            )

            assert response.status_code == 401

    def test_replayed_request_returns_401(self):
        """Test that replayed request (reused nonce) returns 401."""
        secrets = SecretStore()
        nonces = NonceStore()

        with tempfile.TemporaryDirectory() as tmpdir:
            secrets._path = tmpdir
            secret_file = os.path.join(tmpdir, "test-sandbox")
            with open(secret_file, "wb") as f:
                f.write(b"secret123")

            app = create_git_api(secret_store=secrets, nonce_store=nonces)
            client = app.test_client()

            # First request
            body = json.dumps({"args": ["status"]}).encode("utf-8")
            timestamp = str(time.time())
            nonce = "unique-nonce-123"

            sig = compute_signature(
                "POST", "/git/exec", body, timestamp, nonce, b"secret123"
            )

            response1 = client.post(
                "/git/exec",
                data=body,
                headers={
                    "X-Sandbox-Id": "test-sandbox",
                    "X-Request-Signature": sig,
                    "X-Request-Timestamp": timestamp,
                    "X-Request-Nonce": nonce,
                },
            )

            # May fail due to no repo_root, but should get past nonce check
            # If it's 401, it must be for a different reason (not nonce)
            assert response1.status_code != 401 or b"nonce" not in response1.data.lower()

            # Replay the same request with same nonce
            response2 = client.post(
                "/git/exec",
                data=body,
                headers={
                    "X-Sandbox-Id": "test-sandbox",
                    "X-Request-Signature": sig,
                    "X-Request-Timestamp": timestamp,
                    "X-Request-Nonce": nonce,  # Same nonce!
                },
            )

            assert response2.status_code == 401
            assert b"replayed" in response2.data.lower() or b"duplicate" in response2.data.lower()


class TestRateLimiting:
    """Tests for rate limiting."""

    def test_rate_limiting_keyed_on_authenticated_identity(self):
        """Test that rate limiting returns 429 keyed on authenticated identity."""
        limiter = RateLimiter(burst=2, sustained=1, global_ceiling=1000)

        # Consume tokens for sandbox1
        allowed, retry = limiter.check_sandbox_rate("sandbox1")
        assert allowed is True

        allowed, retry = limiter.check_sandbox_rate("sandbox1")
        assert allowed is True

        # Third request should be denied
        allowed, retry = limiter.check_sandbox_rate("sandbox1")
        assert allowed is False
        assert retry > 0

        # But sandbox2 should still have tokens
        allowed, retry = limiter.check_sandbox_rate("sandbox2")
        assert allowed is True

    def test_global_rate_ceiling_enforced(self):
        """Test that global rate ceiling is enforced."""
        limiter = RateLimiter(burst=1000, sustained=1000, global_ceiling=5)

        # Make 5 requests
        for i in range(5):
            allowed, retry = limiter.check_global_rate()
            assert allowed is True, f"Request {i+1} should be allowed"

        # 6th request should be denied
        allowed, retry = limiter.check_global_rate()
        assert allowed is False
        assert retry > 0


class TestResponseHandling:
    """Tests for response handling."""

    def test_response_truncation_at_10mb(self):
        """Test that response is truncated at 10MB."""
        with tempfile.TemporaryDirectory() as tmpdir:
            # Mock subprocess.run to return large stdout
            large_output = b"x" * (MAX_RESPONSE_SIZE + 1000)
            metadata = {"sandbox_branch": "test-branch"}

            with patch("git_operations.subprocess.run") as mock_run:
                mock_run.return_value = MagicMock(
                    returncode=0,
                    stdout=large_output,
                    stderr=b"",
                )

                request = GitExecRequest(args=["log"])
                response, error = execute_git(request, tmpdir, metadata=metadata)

                assert error is None
                assert response.truncated is True
                # Output should be truncated
                assert len(response.stdout) + len(response.stdout_b64 or "") <= MAX_RESPONSE_SIZE

    def test_server_side_repo_root_used(self):
        """Test that server-side repo root derivation ignores client cwd."""
        with tempfile.TemporaryDirectory() as tmpdir:
            # Create a git repo
            subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True, check=False)
            metadata = {"sandbox_branch": "test-branch"}

            # Client requests cwd of "evil/path"
            request = GitExecRequest(args=["status"], cwd="evil/path")

            with patch("git_operations.subprocess.run") as mock_run:
                mock_run.return_value = MagicMock(
                    returncode=0,
                    stdout=b"On branch main",
                    stderr=b"",
                )

                response, error = execute_git(request, tmpdir, metadata=metadata)

                # Should call subprocess with resolved cwd (not client's cwd)
                called_cwd = mock_run.call_args[1]["cwd"]
                # Should be within repo root (use realpath for macOS /var → /private/var)
                assert called_cwd.startswith(os.path.realpath(tmpdir))


class TestGitApiMetadataLookup:
    """Tests for metadata lookup behavior in git API."""

    def test_metadata_lookup_falls_back_to_client_ip(self):
        """When sandbox_id is not a registry container_id, lookup falls back to source IP."""
        secrets = SecretStore()

        with tempfile.TemporaryDirectory() as tmpdir:
            secrets._path = tmpdir
            secret_file = os.path.join(tmpdir, "sandbox-123")
            with open(secret_file, "wb") as f:
                f.write(b"secret123")

            registry_instance = MagicMock()
            registry_instance.get_by_container_id.return_value = None
            registry_instance.get_by_ip.return_value = MagicMock(
                metadata={"repo_root": "/git-workspace/from-ip", "allow_pr": True}
            )

            mock_git_response = MagicMock()
            mock_git_response.to_dict.return_value = {
                "exit_code": 0,
                "stdout": "",
                "stderr": "",
                "truncated": False,
            }

            with patch("registry.ContainerRegistry", return_value=registry_instance):
                with patch(
                    "git_operations.execute_git",
                    return_value=(mock_git_response, None),
                ) as mock_execute:
                    app = create_git_api(secret_store=secrets)
                    client = app.test_client()

                    body = json.dumps({"args": ["status"], "cwd": "."}).encode("utf-8")
                    timestamp = str(time.time())
                    nonce = "nonce-meta-ip"
                    sig = compute_signature(
                        "POST",
                        "/git/exec",
                        body,
                        timestamp,
                        nonce,
                        b"secret123",
                    )

                    response = client.post(
                        "/git/exec",
                        data=body,
                        headers={
                            "X-Sandbox-Id": "sandbox-123",
                            "X-Request-Signature": sig,
                            "X-Request-Timestamp": timestamp,
                            "X-Request-Nonce": nonce,
                        },
                        environ_base={"REMOTE_ADDR": "172.20.0.9"},
                    )

                    assert response.status_code == 200
                    registry_instance.get_by_container_id.assert_called_with(
                        "sandbox-123"
                    )
                    registry_instance.get_by_ip.assert_called_with("172.20.0.9")

                    _, repo_root_arg, metadata_arg = mock_execute.call_args[0]
                    assert repo_root_arg == "/git-workspace/from-ip"
                    assert metadata_arg == {
                        "repo_root": "/git-workspace/from-ip",
                        "allow_pr": True,
                    }


class TestProtectedBranchPushValidation:
    """Tests for protected-branch enforcement in push argument parsing."""

    def test_push_without_refspec_is_rejected(self):
        """Implicit push targets must be rejected to prevent bypasses."""
        err = check_push_protected_branches(["origin"], "/tmp", metadata=None)
        assert err is not None
        assert "explicit refspecs" in err.reason.lower()

    def test_push_all_is_rejected(self):
        """--all push mode is too broad for deterministic policy checks."""
        err = check_push_protected_branches(["--all", "origin"], "/tmp", metadata=None)
        assert err is not None
        assert "--all" in err.reason

    def test_push_mirror_is_rejected(self):
        """--mirror push mode is too broad for deterministic policy checks."""
        err = check_push_protected_branches(
            ["--mirror", "origin"], "/tmp", metadata=None
        )
        assert err is not None
        assert "--mirror" in err.reason

    def test_push_main_refspec_is_rejected(self):
        """Explicit push to protected branch should be blocked."""
        err = check_push_protected_branches(["origin", "main"], "/tmp", metadata=None)
        assert err is not None
        assert "protected branch" in err.reason.lower()

    def test_push_non_protected_branch_is_allowed(self):
        """Explicit push to non-protected branch should be allowed."""
        err = check_push_protected_branches(
            ["origin", "feature/test"], "/tmp", metadata=None
        )
        assert err is None

    def test_push_wildcard_refspec_is_rejected(self):
        """Wildcard refspecs are rejected to prevent broad push bypasses."""
        err = check_push_protected_branches(
            ["origin", "refs/heads/*:refs/heads/*"], "/tmp", metadata=None
        )
        assert err is not None
        assert "wildcard" in err.reason.lower()

    def test_execute_git_expands_implicit_push_with_sandbox_branch(self):
        """Implicit push (no refspec) should be auto-expanded with sandbox branch.

        The proxy auto-appends the sandbox branch so that downstream
        validation (branch isolation, protected branches) can enforce policy.
        """
        metadata = {"sandbox_branch": "test-branch"}
        request = GitExecRequest(args=["push", "origin"])
        with patch("git_operations.subprocess.run") as mock_run, \
             patch("git_operations.check_push_file_restrictions", return_value=None):
            mock_run.return_value = MagicMock(
                returncode=0, stdout=b"", stderr=b""
            )
            response, err = execute_git(request, "/tmp", metadata=metadata)
            # Should succeed (not blocked) — the bare push is expanded
            assert err is None
            assert response is not None
            # Verify subprocess was called with the expanded args
            call_args = mock_run.call_args[0][0]
            # The command list includes git binary + injected -c flags + translated args
            assert "push" in call_args
            assert "origin" in call_args
            assert "test-branch" in call_args

    def test_execute_git_blocks_wildcard_refspec_before_subprocess(self):
        """Wildcard refspec push should be denied before running git subprocess.

        May be caught by branch isolation (disallowed ref) or by push policy
        (wildcard refspec).  Either way, subprocess must not be called.
        """
        metadata = {"sandbox_branch": "test-branch"}
        request = GitExecRequest(args=["push", "origin", "refs/heads/*:refs/heads/*"])
        with patch("git_operations.subprocess.run") as mock_run:
            response, err = execute_git(request, "/tmp", metadata=metadata)
            assert response is None
            assert err is not None
            assert "not allowed" in err.reason.lower() or "wildcard" in err.reason.lower()
            mock_run.assert_not_called()

    def test_push_tags_only_is_allowed(self):
        """Push with only --tags and a remote should be allowed (no branch refspecs)."""
        err = check_push_protected_branches(
            ["origin", "--tags"], "/tmp", metadata=None
        )
        assert err is None

    def test_push_delete_protected_branch_is_rejected(self):
        """Deleting a protected branch via --delete should be blocked."""
        err = check_push_protected_branches(
            ["--delete", "origin", "main"], "/tmp", metadata=None
        )
        assert err is not None
        assert "protected branch" in err.reason.lower()

    def test_push_delete_non_protected_branch_is_allowed(self):
        """Deleting a non-protected branch via --delete should be allowed."""
        err = check_push_protected_branches(
            ["--delete", "origin", "feature/old"], "/tmp", metadata=None
        )
        assert err is None

    def test_push_with_double_dash_treats_remaining_as_positional(self):
        """Arguments after -- should be treated as positional (remote + refspec)."""
        err = check_push_protected_branches(
            ["--", "origin", "feature/test"], "/tmp", metadata=None
        )
        assert err is None


class TestRequestSizeLimits:
    """Tests for request size limits."""

    def test_max_request_body_size_enforced(self):
        """Test that max request body size is enforced."""
        secrets = SecretStore()

        with tempfile.TemporaryDirectory() as tmpdir:
            secrets._path = tmpdir
            secret_file = os.path.join(tmpdir, "test-sandbox")
            with open(secret_file, "wb") as f:
                f.write(b"secret123")

            app = create_git_api(secret_store=secrets)
            client = app.test_client()

            # Create oversized body
            large_body = b"x" * (MAX_REQUEST_BODY + 1)

            timestamp = str(time.time())
            nonce = "nonce-123"
            sig = compute_signature(
                "POST", "/git/exec", large_body, timestamp, nonce, b"secret123"
            )

            response = client.post(
                "/git/exec",
                data=large_body,
                headers={
                    "X-Sandbox-Id": "test-sandbox",
                    "X-Request-Signature": sig,
                    "X-Request-Timestamp": timestamp,
                    "X-Request-Nonce": nonce,
                },
            )

            assert response.status_code == 413


# ---------------------------------------------------------------------------
# Additional Edge Case Tests
# ---------------------------------------------------------------------------


class TestEdgeCases:
    """Additional edge case tests."""

    def test_empty_command_rejected(self):
        """Test that empty command list is rejected."""
        result = validate_command([])
        assert result is not None
        assert "empty" in result.reason.lower()

    def test_config_wildcard_pattern_matching(self):
        """Test that wildcard config patterns like remote.*.proxy are blocked."""
        result = validate_command(["-c", "remote.origin.proxy=http://evil.com", "status"])
        assert result is not None
        assert "blocked config key" in result.reason.lower()

    def test_path_validation_with_absolute_path(self):
        """Test that absolute paths are validated against repo root."""
        with tempfile.TemporaryDirectory() as tmpdir:
            # Try to use absolute path outside repo
            result_path, error = validate_path("/etc/passwd", tmpdir)
            assert error is not None
            assert "outside" in error.reason.lower()

    def test_path_validation_maps_workspace_absolute_path(self):
        """Absolute /workspace paths are translated to repo_root for compatibility."""
        with tempfile.TemporaryDirectory() as tmpdir:
            result_path, error = validate_path("/workspace/src", tmpdir)
            assert error is None
            assert result_path == os.path.realpath(os.path.join(tmpdir, "src"))

    def test_nonce_store_ttl_expiration(self):
        """Test that expired nonces are cleaned up."""
        _now = [1000.0]
        with patch.object(git_api.time, "time", side_effect=lambda: _now[0]):
            nonces = NonceStore(ttl=1, max_per_sandbox=100)

            # Store a nonce
            assert nonces.check_and_store("sandbox1", "nonce1") is True

            # Advance past TTL expiration
            _now[0] += 1.1

            # Same nonce should be allowed again after expiration
            assert nonces.check_and_store("sandbox1", "nonce1") is True

    def test_nonce_store_lru_eviction(self):
        """Test that LRU eviction works when cache is full."""
        nonces = NonceStore(ttl=3600, max_per_sandbox=3)

        # Fill the cache
        assert nonces.check_and_store("sandbox1", "nonce1") is True
        assert nonces.check_and_store("sandbox1", "nonce2") is True
        assert nonces.check_and_store("sandbox1", "nonce3") is True

        # Add one more - should evict oldest
        assert nonces.check_and_store("sandbox1", "nonce4") is True

        # nonce1 should be evicted, so we can use it again
        assert nonces.check_and_store("sandbox1", "nonce1") is True

    def test_token_bucket_refill(self):
        """Test that token bucket refills over time."""
        _now = [1000.0]
        with patch.object(git_api.time, "time", side_effect=lambda: _now[0]):
            limiter = RateLimiter(burst=1, sustained=60, global_ceiling=1000)  # 1 token/sec

            # Consume the only token
            allowed, retry = limiter.check_sandbox_rate("sandbox1")
            assert allowed is True

            # Immediate retry should fail
            allowed, retry = limiter.check_sandbox_rate("sandbox1")
            assert allowed is False

            # Advance past refill interval
            _now[0] += 1.1

            # Should have a new token
            allowed, retry = limiter.check_sandbox_rate("sandbox1")
            assert allowed is True

    def test_clock_window_validation(self):
        """Test that requests outside clock window are rejected."""
        secrets = SecretStore()

        with tempfile.TemporaryDirectory() as tmpdir:
            secrets._path = tmpdir
            secret_file = os.path.join(tmpdir, "test-sandbox")
            with open(secret_file, "wb") as f:
                f.write(b"secret123")

            app = create_git_api(secret_store=secrets)
            client = app.test_client()

            body = json.dumps({"args": ["status"]}).encode("utf-8")
            # Use timestamp from the past (outside window)
            old_timestamp = str(time.time() - CLOCK_WINDOW_SECONDS - 10)
            nonce = "nonce-old"

            sig = compute_signature(
                "POST", "/git/exec", body, old_timestamp, nonce, b"secret123"
            )

            response = client.post(
                "/git/exec",
                data=body,
                headers={
                    "X-Sandbox-Id": "test-sandbox",
                    "X-Request-Signature": sig,
                    "X-Request-Timestamp": old_timestamp,
                    "X-Request-Nonce": nonce,
                },
            )

            assert response.status_code == 401
            assert b"clock window" in response.data.lower() or b"timestamp" in response.data.lower()

    def test_health_endpoint(self):
        """Test that health endpoint returns 200."""
        app = create_git_api()
        client = app.test_client()

        response = client.get("/health")
        assert response.status_code == 200
        assert b"ok" in response.data.lower()

    def test_invalid_json_body_returns_400(self):
        """Test that invalid JSON body returns 400."""
        secrets = SecretStore()

        with tempfile.TemporaryDirectory() as tmpdir:
            secrets._path = tmpdir
            secret_file = os.path.join(tmpdir, "test-sandbox")
            with open(secret_file, "wb") as f:
                f.write(b"secret123")

            app = create_git_api(secret_store=secrets)
            client = app.test_client()

            body = b"not-json"
            timestamp = str(time.time())
            nonce = "nonce-123"

            sig = compute_signature(
                "POST", "/git/exec", body, timestamp, nonce, b"secret123"
            )

            response = client.post(
                "/git/exec",
                data=body,
                headers={
                    "X-Sandbox-Id": "test-sandbox",
                    "X-Request-Signature": sig,
                    "X-Request-Timestamp": timestamp,
                    "X-Request-Nonce": nonce,
                },
            )

            assert response.status_code == 400


# ---------------------------------------------------------------------------
# Branch Isolation Tests
# ---------------------------------------------------------------------------


class TestBranchIsolationValidator:
    """Tests for validate_branch_isolation() input validation."""

    SANDBOX_BRANCH = "sandbox-abc123"
    META = {"sandbox_branch": "sandbox-abc123"}

    def test_no_metadata_passes(self):
        """No metadata means isolation not enforced."""
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(["checkout", "evil-branch"], None)
        assert err is None

    def test_empty_metadata_fails_closed(self):
        """Metadata without sandbox_branch fails closed."""
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(["checkout", "evil-branch"], {})
        assert err is not None
        assert "missing sandbox_branch" in err.reason

    def test_checkout_own_branch_allowed(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(["checkout", self.SANDBOX_BRANCH], self.META)
        assert err is None

    def test_checkout_main_allowed(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(["checkout", "main"], self.META)
        assert err is None

    def test_checkout_other_sandbox_blocked(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(["checkout", "sandbox-other999"], self.META)
        assert err is not None
        assert "branch isolation" in err.reason.lower()

    def test_switch_other_sandbox_blocked(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(["switch", "sandbox-other999"], self.META)
        assert err is not None

    def test_log_other_branch_blocked(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(["log", "sandbox-other999"], self.META)
        assert err is not None

    def test_log_own_branch_allowed(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(["log", self.SANDBOX_BRANCH], self.META)
        assert err is None

    def test_log_head_allowed(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(["log", "HEAD~3"], self.META)
        assert err is None

    def test_log_all_blocked(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(["log", "--all"], self.META)
        assert err is not None
        assert "--all" in err.reason

    def test_fetch_other_branch_blocked(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(
            ["fetch", "origin", "sandbox-other999"], self.META
        )
        assert err is not None

    def test_fetch_own_branch_allowed(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(
            ["fetch", "origin", self.SANDBOX_BRANCH], self.META
        )
        assert err is None

    def test_fetch_main_allowed(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(["fetch", "origin", "main"], self.META)
        assert err is None

    def test_cherry_pick_fetch_head_blocked(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(["cherry-pick", "FETCH_HEAD"], self.META)
        assert err is not None

    def test_branch_delete_other_blocked(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(
            ["branch", "-d", "sandbox-other999"], self.META
        )
        assert err is not None
        assert "cannot delete" in err.reason.lower()

    def test_branch_delete_own_allowed(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(
            ["branch", "-d", self.SANDBOX_BRANCH], self.META
        )
        assert err is None

    def test_checkout_create_branch_with_bad_startpoint_blocked(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(
            ["checkout", "-b", "new-branch", "sandbox-other999"], self.META
        )
        assert err is not None

    def test_worktree_add_other_branch_blocked(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(
            ["worktree", "add", "../path", "sandbox-other999"], self.META
        )
        assert err is not None

    def test_bisect_start_other_branch_blocked(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(
            ["bisect", "start", "sandbox-other999", "main"], self.META
        )
        assert err is not None

    def test_rev_parse_other_branch_blocked(self):
        """rev-parse is a ref-reading cmd, so other branches are blocked."""
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(["rev-parse", "sandbox-other999"], self.META)
        assert err is not None
        assert "branch isolation" in err.reason.lower()

    def test_for_each_ref_passes_through(self):
        """Ref enum commands are handled by output filtering, not input validation."""
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(
            ["for-each-ref", "refs/heads/"], self.META
        )
        assert err is None

    def test_branch_listing_passes_through(self):
        """Branch listing is handled by output filtering, not input validation."""
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(["branch", "-a"], self.META)
        assert err is None

    def test_log_path_after_double_dash_allowed(self):
        """Paths after -- should not be checked as refs."""
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(
            ["log", self.SANDBOX_BRANCH, "--", "src/file.py"], self.META
        )
        assert err is None

    def test_sha_hash_allowed(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(
            ["show", "abcdef1234567890"], self.META
        )
        assert err is None

    def test_tag_ref_allowed(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(
            ["checkout", "refs/tags/v1.0"], self.META
        )
        assert err is None

    def test_release_branch_allowed(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(
            ["checkout", "release/1.0"], self.META
        )
        assert err is None

    def test_reflog_other_branch_blocked(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(
            ["reflog", "show", "sandbox-other999"], self.META
        )
        assert err is not None

    def test_notes_ref_other_branch_blocked(self):
        from branch_isolation import validate_branch_isolation
        err = validate_branch_isolation(
            ["notes", "--ref=sandbox-other999", "list"], self.META
        )
        assert err is not None


class TestFilterBranchOutput:
    """Tests for _filter_branch_output()."""

    SANDBOX = "sandbox-abc123"

    def test_plain_branch_listing(self):
        from branch_isolation import _filter_branch_output
        output = (
            "* sandbox-abc123\n"
            "  main\n"
            "  sandbox-other999\n"
            "  develop\n"
        )
        result = _filter_branch_output(output, self.SANDBOX)
        assert "sandbox-abc123" in result
        assert "main" in result
        assert "develop" in result
        assert "sandbox-other999" not in result

    def test_current_branch_indicator_preserved(self):
        from branch_isolation import _filter_branch_output
        output = "* sandbox-abc123\n  main\n"
        result = _filter_branch_output(output, self.SANDBOX)
        assert result.startswith("* sandbox-abc123")

    def test_verbose_branch_listing(self):
        from branch_isolation import _filter_branch_output
        output = (
            "* sandbox-abc123 abc1234 commit message\n"
            "  main              def5678 another commit\n"
            "  sandbox-other999  111aaaa bad branch\n"
        )
        result = _filter_branch_output(output, self.SANDBOX)
        assert "sandbox-abc123" in result
        assert "main" in result
        assert "sandbox-other999" not in result

    def test_remote_branch_listing(self):
        from branch_isolation import _filter_branch_output
        output = (
            "* sandbox-abc123\n"
            "  main\n"
            "  remotes/origin/sandbox-abc123\n"
            "  remotes/origin/main\n"
            "  remotes/origin/sandbox-other999\n"
            "  remotes/origin/HEAD -> origin/main\n"
        )
        result = _filter_branch_output(output, self.SANDBOX)
        assert "remotes/origin/sandbox-abc123" in result
        assert "remotes/origin/main" in result
        assert "remotes/origin/sandbox-other999" not in result
        assert "remotes/origin/HEAD -> origin/main" in result

    def test_empty_output(self):
        from branch_isolation import _filter_branch_output
        assert _filter_branch_output("", self.SANDBOX) == ""

    def test_unrecognized_format_dropped(self):
        """Unrecognized format lines are dropped (fail-closed)."""
        from branch_isolation import _filter_branch_output
        output = "Some unrecognized line\n* main\n"
        result = _filter_branch_output(output, self.SANDBOX)
        assert "Some unrecognized line" not in result
        assert "main" in result

    def test_well_known_prefix_branch_kept(self):
        from branch_isolation import _filter_branch_output
        output = "  release/1.0\n  hotfix/urgent\n  sandbox-other999\n"
        result = _filter_branch_output(output, self.SANDBOX)
        assert "release/1.0" in result
        assert "hotfix/urgent" in result
        assert "sandbox-other999" not in result


class TestFilterRefEnumOutput:
    """Tests for _filter_ref_enum_output()."""

    SANDBOX = "sandbox-abc123"

    def test_for_each_ref_output(self):
        from branch_isolation import _filter_ref_enum_output
        output = (
            "abc1234 commit\trefs/heads/sandbox-abc123\n"
            "def5678 commit\trefs/heads/main\n"
            "111aaaa commit\trefs/heads/sandbox-other999\n"
            "222bbbb commit\trefs/tags/v1.0\n"
        )
        result = _filter_ref_enum_output(output, self.SANDBOX)
        assert "sandbox-abc123" in result
        assert "main" in result
        assert "sandbox-other999" not in result
        assert "refs/tags/v1.0" in result

    def test_show_ref_output(self):
        from branch_isolation import _filter_ref_enum_output
        output = (
            "abc1234 refs/heads/main\n"
            "def5678 refs/heads/sandbox-other999\n"
        )
        result = _filter_ref_enum_output(output, self.SANDBOX)
        assert "main" in result
        assert "sandbox-other999" not in result

    def test_ls_remote_output(self):
        from branch_isolation import _filter_ref_enum_output
        output = (
            "abc1234\trefs/heads/main\n"
            "def5678\trefs/heads/sandbox-other999\n"
            "111aaaa\trefs/tags/v1.0\n"
        )
        result = _filter_ref_enum_output(output, self.SANDBOX)
        assert "main" in result
        assert "sandbox-other999" not in result
        assert "refs/tags/v1.0" in result

    def test_custom_format_short_refname(self):
        from branch_isolation import _filter_ref_enum_output
        output = (
            "main\n"
            "sandbox-other999\n"
            "sandbox-abc123\n"
        )
        result = _filter_ref_enum_output(output, self.SANDBOX)
        assert "main" in result
        assert "sandbox-abc123" in result
        assert "sandbox-other999" not in result

    def test_empty_output(self):
        from branch_isolation import _filter_ref_enum_output
        assert _filter_ref_enum_output("", self.SANDBOX) == ""

    def test_remote_refs_filtered(self):
        from branch_isolation import _filter_ref_enum_output
        output = (
            "abc1234\trefs/remotes/origin/main\n"
            "def5678\trefs/remotes/origin/sandbox-other999\n"
        )
        result = _filter_ref_enum_output(output, self.SANDBOX)
        assert "main" in result
        assert "sandbox-other999" not in result


class TestFilterLogDecorations:
    """Tests for log decoration filtering functions."""

    SANDBOX = "sandbox-abc123"

    def test_sha_anchored_decorations(self):
        from branch_isolation import _filter_log_decorations
        output = (
            "abc1234 (HEAD -> sandbox-abc123, origin/main, origin/sandbox-other999) commit msg\n"
            "def5678 (tag: v1.0, main) another commit\n"
        )
        result = _filter_log_decorations(output, self.SANDBOX)
        assert "sandbox-abc123" in result
        assert "origin/main" in result
        assert "sandbox-other999" not in result
        assert "tag: v1.0" in result
        assert "main" in result

    def test_empty_decorations_stripped(self):
        from branch_isolation import _filter_log_decorations
        # All decoration refs removed — should not have empty parens
        output = "abc1234 (origin/sandbox-other999) commit msg\n"
        result = _filter_log_decorations(output, self.SANDBOX)
        assert "()" not in result
        assert "sandbox-other999" not in result
        assert "abc1234" in result

    def test_head_always_kept(self):
        from branch_isolation import _filter_log_decorations
        output = "abc1234 (HEAD) commit msg\n"
        result = _filter_log_decorations(output, self.SANDBOX)
        assert "HEAD" in result

    def test_non_decoration_lines_preserved(self):
        from branch_isolation import _filter_log_decorations
        output = "abc1234 regular commit message\ndef5678 another message\n"
        result = _filter_log_decorations(output, self.SANDBOX)
        assert result == output

    def test_custom_d_format(self):
        from branch_isolation import _filter_custom_format_decorations
        output = " (HEAD -> sandbox-abc123, origin/sandbox-other999)\n"
        result = _filter_custom_format_decorations(output, self.SANDBOX)
        assert "sandbox-abc123" in result
        assert "sandbox-other999" not in result

    def test_log_has_custom_decoration_format(self):
        from branch_isolation import _log_has_custom_decoration_format
        assert _log_has_custom_decoration_format(["--format=%H %d"]) is True
        assert _log_has_custom_decoration_format(["--format=%H %D"]) is True
        assert _log_has_custom_decoration_format(["--format=%H %s"]) is False
        assert _log_has_custom_decoration_format(["--pretty=format:%d"]) is True

    def test_log_has_source_flag(self):
        from branch_isolation import _log_has_source_flag
        assert _log_has_source_flag(["--source", "--oneline"]) is True
        assert _log_has_source_flag(["--oneline"]) is False

    def test_source_ref_redaction(self):
        from branch_isolation import _filter_log_source_refs
        output = (
            "abc1234\trefs/heads/main\tcommit msg\n"
            "def5678\trefs/heads/sandbox-other999\tanother msg\n"
            "111aaaa\trefs/heads/sandbox-abc123\tmy msg\n"
        )
        result = _filter_log_source_refs(output, self.SANDBOX)
        assert "refs/heads/main" in result
        assert "refs/heads/sandbox-abc123" in result
        assert "refs/heads/sandbox-other999" not in result
        assert "refs/heads/[redacted]" in result


class TestFilterRefListingDispatch:
    """Tests for filter_ref_listing_output() dispatch."""

    SANDBOX = "sandbox-abc123"

    def test_dispatches_to_branch_filter(self):
        from git_operations import filter_ref_listing_output
        output = "* sandbox-abc123\n  sandbox-other999\n"
        result = filter_ref_listing_output(output, ["branch"], self.SANDBOX)
        assert "sandbox-abc123" in result
        assert "sandbox-other999" not in result

    def test_dispatches_to_ref_enum_filter(self):
        from git_operations import filter_ref_listing_output
        output = "abc1234 refs/heads/sandbox-other999\n"
        result = filter_ref_listing_output(output, ["for-each-ref"], self.SANDBOX)
        assert "sandbox-other999" not in result

    def test_dispatches_to_log_filter(self):
        from git_operations import filter_ref_listing_output
        output = "abc1234 (origin/sandbox-other999) msg\n"
        result = filter_ref_listing_output(output, ["log"], self.SANDBOX)
        assert "sandbox-other999" not in result

    def test_empty_output_passthrough(self):
        from git_operations import filter_ref_listing_output
        assert filter_ref_listing_output("", ["branch"], self.SANDBOX) == ""

    def test_no_sandbox_branch_passthrough(self):
        from git_operations import filter_ref_listing_output
        output = "* sandbox-other999\n"
        assert filter_ref_listing_output(output, ["branch"], "") == output

    def test_non_filtered_command_passthrough(self):
        from git_operations import filter_ref_listing_output
        output = "some output\n"
        assert filter_ref_listing_output(output, ["status"], self.SANDBOX) == output

    def test_log_with_source_flag(self):
        from git_operations import filter_ref_listing_output
        output = "abc1234\trefs/heads/sandbox-other999\tmsg\n"
        result = filter_ref_listing_output(
            output, ["log", "--source"], self.SANDBOX
        )
        assert "refs/heads/[redacted]" in result

    def test_log_with_custom_format(self):
        from git_operations import filter_ref_listing_output
        output = " (HEAD -> sandbox-abc123, origin/sandbox-other999)\n"
        result = filter_ref_listing_output(
            output, ["log", "--format=%H %d"], self.SANDBOX
        )
        assert "sandbox-abc123" in result
        assert "sandbox-other999" not in result


class TestOutputFilteringInExecuteGit:
    """Tests that output filtering is wired into execute_git()."""

    SANDBOX_BRANCH = "sandbox-abc123"
    META = {"sandbox_branch": "sandbox-abc123"}

    def test_branch_output_filtered_in_execute_git(self):
        """git branch output should have other sandbox branches removed."""
        with tempfile.TemporaryDirectory() as tmpdir:
            branch_output = (
                b"* sandbox-abc123\n"
                b"  main\n"
                b"  sandbox-other999\n"
            )
            with patch("git_operations.subprocess.run") as mock_run:
                mock_run.return_value = MagicMock(
                    returncode=0,
                    stdout=branch_output,
                    stderr=b"",
                )
                request = GitExecRequest(args=["branch"])
                response, error = execute_git(request, tmpdir, metadata=self.META)

                assert error is None
                assert "sandbox-abc123" in response.stdout
                assert "main" in response.stdout
                assert "sandbox-other999" not in response.stdout

    def test_log_decorations_filtered_in_execute_git(self):
        """git log decorations should have other sandbox branches removed."""
        with tempfile.TemporaryDirectory() as tmpdir:
            log_output = (
                b"abc1234 (HEAD -> sandbox-abc123, origin/sandbox-other999) msg\n"
            )
            with patch("git_operations.subprocess.run") as mock_run:
                mock_run.return_value = MagicMock(
                    returncode=0,
                    stdout=log_output,
                    stderr=b"",
                )
                request = GitExecRequest(args=["log"])
                response, error = execute_git(request, tmpdir, metadata=self.META)

                assert error is None
                assert "sandbox-abc123" in response.stdout
                assert "sandbox-other999" not in response.stdout

    def test_no_filtering_without_sandbox_branch(self):
        """Without sandbox_branch in metadata, output should not be filtered.

        Note: execute_git now requires sandbox_branch, so this actually
        returns an error. Testing the fail-closed behavior.
        """
        with tempfile.TemporaryDirectory() as tmpdir:
            request = GitExecRequest(args=["branch"])
            response, error = execute_git(request, tmpdir, metadata={})
            assert error is not None
            assert "sandbox branch identity missing" in error.reason.lower()


# ---------------------------------------------------------------------------
# Fetch Locking Tests
# ---------------------------------------------------------------------------


class TestResolveBareRepoPath:
    """Tests for resolve_bare_repo_path()."""

    def test_dot_git_directory(self):
        """When .git is a directory (normal repo), return normalized .git path."""
        from git_operations import resolve_bare_repo_path
        with tempfile.TemporaryDirectory() as tmpdir:
            dot_git = os.path.join(tmpdir, ".git")
            os.makedirs(dot_git)
            result = resolve_bare_repo_path(tmpdir)
            assert result == os.path.realpath(dot_git)

    def test_dot_git_directory_with_commondir(self):
        """When .git dir has a commondir file, follow it."""
        from git_operations import resolve_bare_repo_path
        with tempfile.TemporaryDirectory() as tmpdir:
            bare = os.path.join(tmpdir, "bare.git")
            os.makedirs(bare)
            dot_git = os.path.join(tmpdir, "work", ".git")
            os.makedirs(dot_git)
            with open(os.path.join(dot_git, "commondir"), "w") as f:
                f.write(bare)
            result = resolve_bare_repo_path(os.path.join(tmpdir, "work"))
            assert result == os.path.realpath(bare)

    def test_dot_git_file_worktree(self):
        """When .git is a file with gitdir pointer, follow the chain."""
        from git_operations import resolve_bare_repo_path
        with tempfile.TemporaryDirectory() as tmpdir:
            # Set up bare repo
            bare = os.path.join(tmpdir, "bare.git")
            os.makedirs(bare)

            # Set up worktree gitdir
            wt_gitdir = os.path.join(bare, "worktrees", "wt1")
            os.makedirs(wt_gitdir)
            with open(os.path.join(wt_gitdir, "commondir"), "w") as f:
                f.write("../..")  # relative to wt_gitdir -> bare.git

            # Set up worktree
            wt = os.path.join(tmpdir, "wt1")
            os.makedirs(wt)
            with open(os.path.join(wt, ".git"), "w") as f:
                f.write(f"gitdir: {wt_gitdir}")

            result = resolve_bare_repo_path(wt)
            assert result == os.path.realpath(bare)

    def test_no_dot_git(self):
        """Returns None when .git does not exist."""
        from git_operations import resolve_bare_repo_path
        with tempfile.TemporaryDirectory() as tmpdir:
            result = resolve_bare_repo_path(tmpdir)
            assert result is None

    def test_invalid_dot_git_file_content(self):
        """Returns None when .git file has unexpected content."""
        from git_operations import resolve_bare_repo_path
        with tempfile.TemporaryDirectory() as tmpdir:
            with open(os.path.join(tmpdir, ".git"), "w") as f:
                f.write("garbage content")
            result = resolve_bare_repo_path(tmpdir)
            assert result is None

    def test_relative_commondir(self):
        """Handles relative commondir path correctly."""
        from git_operations import resolve_bare_repo_path
        with tempfile.TemporaryDirectory() as tmpdir:
            bare = os.path.join(tmpdir, "repos", "bare.git")
            os.makedirs(bare)
            dot_git = os.path.join(tmpdir, ".git")
            os.makedirs(dot_git)
            # Relative path from .git to bare
            rel = os.path.relpath(bare, dot_git)
            with open(os.path.join(dot_git, "commondir"), "w") as f:
                f.write(rel)
            result = resolve_bare_repo_path(tmpdir)
            assert result == os.path.realpath(bare)


class TestFetchLock:
    """Tests for _fetch_lock() context manager."""

    def test_lock_acquired_and_released(self):
        """Basic lock acquire and release."""
        from git_operations import _fetch_lock, _FETCH_LOCK_FILENAME
        with tempfile.TemporaryDirectory() as tmpdir:
            with _fetch_lock(tmpdir, timeout=5.0):
                lock_path = os.path.join(tmpdir, _FETCH_LOCK_FILENAME)
                assert os.path.exists(lock_path)
            # After context exit, lock should be released (fd closed)

    def test_lock_timeout(self):
        """TimeoutError raised when lock cannot be acquired."""
        from git_operations import _fetch_lock, _FETCH_LOCK_FILENAME
        with tempfile.TemporaryDirectory() as tmpdir:
            lock_path = os.path.join(tmpdir, _FETCH_LOCK_FILENAME)
            # Hold an exclusive lock from outside
            fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644)
            try:
                import fcntl
                fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
                # Now try to acquire via _fetch_lock — should timeout
                with pytest.raises(TimeoutError):
                    with _fetch_lock(tmpdir, timeout=1.5):
                        pass
            finally:
                fcntl.flock(fd, fcntl.LOCK_UN)
                os.close(fd)

    def test_fd_closed_on_exception(self):
        """File descriptor is closed even on exception inside context."""
        from git_operations import _fetch_lock
        with tempfile.TemporaryDirectory() as tmpdir:
            with pytest.raises(ValueError):
                with _fetch_lock(tmpdir, timeout=5.0):
                    raise ValueError("test error")
            # Should not leave stale lock — verify by acquiring again
            with _fetch_lock(tmpdir, timeout=2.0):
                pass  # Should succeed if fd was properly closed


class TestFetchLockingInExecuteGit:
    """Tests that fetch locking is wired into execute_git()."""

    META = {"sandbox_branch": "test-branch"}

    def test_fetch_denied_without_bare_repo(self):
        """Fetch should be denied when bare repo cannot be resolved."""
        with tempfile.TemporaryDirectory() as tmpdir:
            # No .git dir — bare repo unresolvable
            request = GitExecRequest(args=["fetch", "origin", "main"])
            response, error = execute_git(request, tmpdir, metadata=self.META)
            assert response is None
            assert error is not None
            assert "bare repo" in error.reason.lower()

    def test_fetch_allowed_with_break_glass(self):
        """FOUNDRY_ALLOW_UNLOCKED_FETCH=1 allows fetch without lock."""
        with tempfile.TemporaryDirectory() as tmpdir:
            with patch.dict(os.environ, {"FOUNDRY_ALLOW_UNLOCKED_FETCH": "1"}):
                with patch("git_operations.subprocess.run") as mock_run:
                    mock_run.return_value = MagicMock(
                        returncode=0,
                        stdout=b"",
                        stderr=b"",
                    )
                    request = GitExecRequest(args=["fetch", "origin", "main"])
                    response, error = execute_git(request, tmpdir, metadata=self.META)
                    assert error is None
                    mock_run.assert_called_once()

    def test_fetch_with_lock_when_bare_repo_exists(self):
        """Fetch acquires lock when bare repo is resolvable."""
        with tempfile.TemporaryDirectory() as tmpdir:
            # Create .git directory to make bare repo resolvable
            os.makedirs(os.path.join(tmpdir, ".git"))
            with patch("git_operations.subprocess.run") as mock_run:
                mock_run.return_value = MagicMock(
                    returncode=0,
                    stdout=b"",
                    stderr=b"",
                )
                request = GitExecRequest(args=["fetch", "origin", "main"])
                response, error = execute_git(request, tmpdir, metadata=self.META)
                assert error is None
                mock_run.assert_called_once()

    def test_non_fetch_commands_not_locked(self):
        """Non-fetch commands should not require fetch locking."""
        with tempfile.TemporaryDirectory() as tmpdir:
            # No .git — would fail for fetch, but should work for status
            with patch("git_operations.subprocess.run") as mock_run:
                mock_run.return_value = MagicMock(
                    returncode=0,
                    stdout=b"On branch main",
                    stderr=b"",
                )
                request = GitExecRequest(args=["status"])
                response, error = execute_git(request, tmpdir, metadata=self.META)
                assert error is None
                assert response is not None


# ---------------------------------------------------------------------------
# S5: Default Branch Detection Helpers
# ---------------------------------------------------------------------------


class TestDetectDefaultBranch:
    """Tests for _detect_default_branch()."""

    def test_detects_main_branch(self):
        from git_operations import _detect_default_branch
        with tempfile.TemporaryDirectory() as tmpdir:
            # Create a fake bare repo with HEAD pointing to main
            os.makedirs(tmpdir, exist_ok=True)
            with patch("git_operations.subprocess.run") as mock_run:
                mock_run.return_value = MagicMock(
                    returncode=0,
                    stdout=b"refs/heads/main\n",
                )
                result = _detect_default_branch(tmpdir)
                assert result == "main"

    def test_detects_master_branch(self):
        from git_operations import _detect_default_branch
        with patch("git_operations.subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(
                returncode=0,
                stdout=b"refs/heads/master\n",
            )
            result = _detect_default_branch("/fake")
            assert result == "master"

    def test_returns_none_on_failure(self):
        from git_operations import _detect_default_branch
        with patch("git_operations.subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(
                returncode=1,
                stdout=b"",
            )
            result = _detect_default_branch("/fake")
            assert result is None

    def test_returns_none_on_detached_head(self):
        from git_operations import _detect_default_branch
        with patch("git_operations.subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(
                returncode=0,
                stdout=b"HEAD\n",  # Not refs/heads/...
            )
            result = _detect_default_branch("/fake")
            assert result is None

    def test_returns_none_on_timeout(self):
        from git_operations import _detect_default_branch
        import subprocess as sp
        with patch("git_operations.subprocess.run", side_effect=sp.TimeoutExpired("git", 10)):
            result = _detect_default_branch("/fake")
            assert result is None


class TestInjectDefaultBranchProtection:
    """Tests for _inject_default_branch_protection()."""

    def test_adds_pattern_to_empty_metadata(self):
        from git_operations import _inject_default_branch_protection
        meta = {}
        result = _inject_default_branch_protection(meta, "main")
        assert result["git"]["protected_branches"]["patterns"] == ["refs/heads/main"]
        # Original metadata must not be mutated
        assert "git" not in meta

    def test_adds_pattern_to_existing_patterns(self):
        from git_operations import _inject_default_branch_protection
        meta = {"git": {"protected_branches": {"patterns": ["refs/heads/production"]}}}
        result = _inject_default_branch_protection(meta, "main")
        assert "refs/heads/main" in result["git"]["protected_branches"]["patterns"]
        assert "refs/heads/production" in result["git"]["protected_branches"]["patterns"]

    def test_no_duplicate_pattern(self):
        from git_operations import _inject_default_branch_protection
        meta = {"git": {"protected_branches": {"patterns": ["refs/heads/main"]}}}
        result = _inject_default_branch_protection(meta, "main")
        assert result["git"]["protected_branches"]["patterns"].count("refs/heads/main") == 1

    def test_does_not_mutate_caller_metadata(self):
        from git_operations import _inject_default_branch_protection
        original_patterns = ["refs/heads/production"]
        meta = {"git": {"protected_branches": {"patterns": original_patterns}}}
        _inject_default_branch_protection(meta, "main")
        # Original must be unchanged
        assert meta["git"]["protected_branches"]["patterns"] == ["refs/heads/production"]


# ---------------------------------------------------------------------------
# S8: Blocked flag after subcommand
# ---------------------------------------------------------------------------


class TestBlockedFlagAfterSubcommand:
    """Test that global blocked flags are caught even after the subcommand."""

    def test_git_dir_after_subcommand_blocked(self):
        """--git-dir appearing after the subcommand should be caught."""
        result = validate_command(["status", "--git-dir=/etc"])
        assert result is not None
        assert "blocked flag" in result.reason.lower()
        assert "--git-dir" in result.reason

    def test_work_tree_after_subcommand_blocked(self):
        result = validate_command(["log", "--work-tree=/tmp/evil"])
        assert result is not None
        assert "blocked flag" in result.reason.lower()

    def test_exec_after_subcommand_blocked(self):
        result = validate_command(["fetch", "--exec=evil"])
        assert result is not None
        assert "blocked flag" in result.reason.lower()


# ---------------------------------------------------------------------------
# S9: Proxy-side hook hardening
# ---------------------------------------------------------------------------


class TestProxySideHookFlags:
    """Tests that proxy-side hook/fsmonitor hardening works correctly.

    The proxy injects ``-c core.hooksPath=/dev/null`` and
    ``-c core.fsmonitor=false`` into every ``execute_git`` invocation so that
    git hooks and fsmonitor cannot be used as an escape vector.  The
    corresponding keys are in CONFIG_NEVER_ALLOW so clients cannot override
    them via ``-c`` flags.
    """

    def test_proxy_side_hooks_disabled(self):
        """execute_git() must inject -c core.hooksPath=/dev/null and -c core.fsmonitor=false."""
        with tempfile.TemporaryDirectory() as tmpdir:
            with patch("git_operations.subprocess.run") as mock_run:
                mock_run.return_value = MagicMock(
                    returncode=0,
                    stdout=b"On branch main",
                    stderr=b"",
                )
                request = GitExecRequest(args=["status"])
                metadata = {"sandbox_branch": "test-branch"}
                response, error = execute_git(request, tmpdir, metadata=metadata)

                assert error is None
                # Inspect the command list passed to subprocess.run
                cmd = mock_run.call_args[0][0]

                # Verify -c core.hooksPath=/dev/null is present
                assert "-c" in cmd
                hooks_idx = cmd.index("core.hooksPath=/dev/null")
                assert cmd[hooks_idx - 1] == "-c", (
                    "core.hooksPath=/dev/null must be preceded by -c"
                )

                # Verify -c core.fsmonitor=false is present
                fsmon_idx = cmd.index("core.fsmonitor=false")
                assert cmd[fsmon_idx - 1] == "-c", (
                    "core.fsmonitor=false must be preceded by -c"
                )

    def test_client_cannot_override_hooks_path(self):
        """Client-supplied -c core.hooksPath=... must be rejected by CONFIG_NEVER_ALLOW."""
        # Test -c form
        result = validate_command(["-c", "core.hooksPath=/tmp/evil", "status"])
        assert result is not None
        assert "blocked config key" in result.reason.lower()

        # Test --config form (git alias for -c).
        # The parser does not special-case --config, so it will not be
        # recognised as a config flag; instead the value becomes the
        # "subcommand" and is rejected via the allowlist.  Either way the
        # command is denied.
        result_cfg = validate_command(["--config", "core.hooksPath=/tmp/evil", "status"])
        assert result_cfg is not None
        # Rejected — the exact reason may differ from the -c path
        assert "not allowed" in result_cfg.reason.lower() or "blocked" in result_cfg.reason.lower()

    def test_client_cannot_override_fsmonitor(self):
        """Client-supplied -c core.fsmonitor=... must be rejected by CONFIG_NEVER_ALLOW."""
        result = validate_command(["-c", "core.fsmonitor=/tmp/evil", "status"])
        assert result is not None
        assert "blocked config key" in result.reason.lower()


if __name__ == "__main__":
    pytest.main([__file__, "-v"])
