import os import httpx import logging from urllib.parse import quote, unquote import re import base64 import json import binascii from typing import List, Literal, Callable, Awaitable, Any, Dict from pydantic import BaseModel, Field, ValidationError # --- Gitea Change Operation Models & Logic --- MAX_GITEA_PATCH_OPERATIONS = 50 MAX_GITEA_PATCH_INPUT_BYTES = 131_072 class ExactReplacementOperation(BaseModel, extra="forbid"): """A single, deterministic replacement operation.""" type: Literal["replace_once"] find: str = Field(min_length=1) replace: str def apply_gitea_patch_operations( original_content: str, operations: List[ExactReplacementOperation], ) -> str: """ Applies a sequence of deterministic patch operations to a string. Ensures that each `find` pattern matches exactly once in the content as it evolves through the sequence of operations. """ if not isinstance(original_content, str): raise TypeError("original_content must be a string.") if not operations: raise ValueError("Change operations cannot be empty.") if len(operations) > MAX_GITEA_PATCH_OPERATIONS: raise ValueError( f"Too many patch operations: {len(operations)} > " f"{MAX_GITEA_PATCH_OPERATIONS}." ) total_patch_bytes = sum( len(operation.find.encode("utf-8")) + len(operation.replace.encode("utf-8")) for operation in operations ) if total_patch_bytes > MAX_GITEA_PATCH_INPUT_BYTES: raise ValueError( f"Patch input exceeds {MAX_GITEA_PATCH_INPUT_BYTES} bytes." ) content = original_content for i, op in enumerate(operations): if op.type != "replace_once": raise ValueError(f"Unsupported operation type at index {i}: {op.type}") if not op.find: raise ValueError(f"Operation at index {i} has an empty 'find' value.") match_count = content.count(op.find) if match_count == 0: raise ValueError( f"Operation at index {i} failed: The 'find' string was not found." ) if match_count > 1: raise ValueError( f"Operation at index {i} failed: The 'find' string matched {match_count} times (expected 1)." ) content = content.replace(op.find, op.replace, 1) if content == original_content: raise ValueError("The applied operations resulted in no net change to the file content.") return content async def orchestrate_gitea_patch( params: Dict[str, Any], read_file_func: Callable[ [Dict[str, Any], str], Awaitable[Dict[str, Any]], ], write_file_func: Callable[ [str, Dict[str, Any]], Awaitable[Dict[str, Any]], ], ) -> Dict[str, Any]: """ Orchestrates a safe, server-side patch workflow against Gitea. This pure helper contains the core logic and is dependency-injected for testability. """ if not isinstance(params, dict): raise ValueError("patch_gitea_file input must be an object.") allowed_keys = {"repo", "branch", "path", "commit_message", "operations"} unsupported_keys = set(params) - allowed_keys if unsupported_keys: raise ValueError( f"Unsupported patch_gitea_file input fields: {sorted(unsupported_keys)}" ) # 1. Extract and validate all inputs before any network access. repo = params.get("repo") branch = params.get("branch") path = params.get("path") commit_message = params.get("commit_message") operations_data = params.get("operations") validate_repo_for_write(repo) validate_branch_for_write(branch) validate_path_for_write(path) if not isinstance(commit_message, str) or not commit_message.strip(): raise ValueError("commit_message must be a non-empty string.") if not isinstance(operations_data, list) or not operations_data: raise ValueError("operations must be a non-empty list.") try: parsed_operations = [ExactReplacementOperation(**op) for op in operations_data] except (TypeError, ValidationError) as e: raise ValueError(f"Invalid operation object provided: {e}") from e # 2. Read current file state from Gitea. file_state = await read_file_func( {"repo": repo, "path": path, "ref": branch}, repo, ) original_content = file_state["content"] file_sha = file_state["file_sha"] # 3. Apply the patch operations to the fetched content. try: patched_content = apply_gitea_patch_operations( original_content, parsed_operations, ) except ValueError as e: raise ValueError(f"Failed to apply patch: {e}") from e # 4. Write the update to Gitea. encoded_patched_content = base64.b64encode(patched_content.encode("utf-8")).decode("ascii") put_body = { "branch": branch, "message": commit_message, "content": encoded_patched_content, "sha": file_sha, } return await write_file_func( f"/repos/{repo}/contents/{path}", put_body, ) logger = logging.getLogger(__name__) _SHA_RE = re.compile(r"^[0-9a-f]{40}$") _REPO_ID_RE = re.compile( r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}/" r"[A-Za-z0-9][A-Za-z0-9._-]{0,99}$" ) _BRANCH_RE = re.compile( r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$" ) _READ_BRANCH_REF_RE = re.compile( r"^[A-Za-z0-9][A-Za-z0-9._-]*(?:/[A-Za-z0-9][A-Za-z0-9._-]*)*$" ) MAX_SOURCE_ARCHIVE_BYTES = int( os.environ.get("OPAX_MAX_SOURCE_ARCHIVE_BYTES", "104857600") ) ALLOWED_GITEA_NAMESPACE = "chris" CATALOG_PAGE_SIZE = 50 CATALOG_MAX_PAGES = 2 CATALOG_MAX_RESULTS = 100 MAX_READ_FILE_BYTES = 1_048_576 MAX_GET_FILE_RESPONSE_BYTES = 1_500_000 def _gitea_headers() -> dict: """Constructs headers for Gitea API requests.""" return {"Authorization": f"token {os.environ.get('GITEA_TOKEN')}", "Accept": "application/json"} def _validate_repo_id(repo_id: str) -> str: if not isinstance(repo_id, str) or not _REPO_ID_RE.fullmatch(repo_id): raise ValueError("Invalid configured Gitea repository ID") return repo_id def _validate_gitea_url(gitea_url: str) -> str: if not isinstance(gitea_url, str): raise ValueError("GITEA_URL environment variable is not set") normalized = gitea_url.rstrip("/") if not normalized.startswith("https://"): raise ValueError("GITEA_URL must use HTTPS") return normalized def _validate_branch_name(branch_name: str) -> str: if not isinstance(branch_name, str): raise ValueError("Branch name must be a string") if not _BRANCH_RE.fullmatch(branch_name): raise ValueError("Invalid branch name") if ( branch_name.startswith((".", "/")) or branch_name.endswith((".", "/")) or ".." in branch_name or "//" in branch_name or "@{" in branch_name or branch_name.endswith(".lock") ): raise ValueError("Invalid branch name") return branch_name def _validate_commit_sha(commit_sha: str) -> str: if not isinstance(commit_sha, str) or not _SHA_RE.fullmatch(commit_sha): raise ValueError("Invalid commit SHA") return commit_sha def _validate_safe_path(path: str) -> str: """Validates a repository-relative path using strict POSIX semantics.""" if not isinstance(path, str): raise ValueError("PATH_NOT_ALLOWED") decoded_path = unquote(path) for candidate in (path, decoded_path): if not candidate.strip() or "\0" in candidate or "\\" in candidate: raise ValueError("PATH_NOT_ALLOWED") if candidate.startswith("/"): raise ValueError("PATH_NOT_ALLOWED") components = candidate.split("/") if any(component in ("", ".", "..") for component in components): raise ValueError("PATH_NOT_ALLOWED") decoded_components = decoded_path.split("/") filename = decoded_components[-1] if ".git" in decoded_components: raise ValueError("SECRET_PATH_DENIED") if filename == ".env" or ( filename.startswith(".env.") and filename != ".env.example" ): raise ValueError("SECRET_PATH_DENIED") denied_basenames = { ".netrc", ".npmrc", ".pypirc", "id_rsa", "id_ed25519", "credentials", "credentials.json", "service_account.json", "service-account.json", "private_key", "private_key.json", "token", "token.json", } if filename in denied_basenames: raise ValueError("SECRET_PATH_DENIED") denied_components = { "secrets", "credentials", "service_accounts", "service-accounts", } if any(component in denied_components for component in decoded_components): raise ValueError("SECRET_PATH_DENIED") denied_suffixes = { ".pem", ".key", ".p12", ".pfx", ".jks", ".keystore", ".kubeconfig", ".crt", ".cer", ".der", } if any(filename.endswith(suffix) for suffix in denied_suffixes): raise ValueError("SECRET_PATH_DENIED") return path MAX_PUSH_CONTENT_BYTES = 1_048_576 # 1 MB def validate_repo_for_write(repo_id: str) -> str: """Validates that a repo is in the allowed namespace for write operations.""" if not isinstance(repo_id, str) or not _REPO_ID_RE.fullmatch(repo_id): raise ValueError("Invalid repository format. Must be 'owner/repo'.") if not repo_id.startswith(f"{ALLOWED_GITEA_NAMESPACE}/"): raise ValueError(f"Write operations are only allowed in the '{ALLOWED_GITEA_NAMESPACE}' namespace.") return repo_id def validate_branch_for_write(branch: str) -> str: """Validates that a branch name is safe for write operations.""" if not branch or not isinstance(branch, str): raise ValueError("Branch name cannot be empty.") if branch.lower() in ["main", "master"]: raise ValueError(f"Direct writes to protected branch '{branch}' are not allowed.") return _validate_branch_name(branch) def validate_path_for_write(path: str) -> str: """Validates a file path for write operations with segment-based checks.""" if not path or not isinstance(path, str): raise ValueError("Path cannot be empty.") if "\\" in path or "\x00" in path or "//" in path: raise ValueError("Path contains invalid characters.") if path.startswith('/') or '..' in path.split('/'): raise ValueError("Path must be relative and cannot contain traversal elements.") path_segments = path.lower().split('/') filename = path_segments[-1] if '.git' in path_segments: raise ValueError("Changes within a '.git' directory are not allowed.") if filename == '.env' or filename.startswith('.env.'): raise ValueError("Path targets a '.env' file, which is not allowed.") sensitive_basenames = ["credentials", "service_account", "private_key", "id_rsa", "id_ed25519", "secret", "token"] if filename in sensitive_basenames or any(filename.endswith(ext) for ext in ['.pem', '.key']): raise ValueError(f"Path targets a sensitive basename or extension.") return path def _validate_read_branch_ref(ref: str) -> str: """Validate a safe branch name used only for read resolution.""" if not isinstance(ref, str) or not ref: raise ValueError("Invalid git reference.") if any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in ref): raise ValueError("Invalid git reference.") if ( ref.upper() == "HEAD" or ref.startswith("refs/") or ref.startswith("/") or ref.endswith("/") or "//" in ref or "\\" in ref ): raise ValueError("Invalid git reference.") components = ref.split("/") if any(component in (".", "..") for component in components): raise ValueError("Invalid git reference.") if not _READ_BRANCH_REF_RE.fullmatch(ref): raise ValueError("Invalid git reference.") return ref async def resolve_branch_to_commit_sha( branch_name: str, repo_id: str, gitea_url: str, ) -> str: """Resolve an allowed branch name to one immutable 40-character SHA.""" validated_branch = _validate_branch_name(branch_name) validated_repo = _validate_repo_id(repo_id) validated_url = _validate_gitea_url(gitea_url) encoded_branch = quote(validated_branch, safe="") url = ( f"{validated_url}/api/v1/repos/" f"{validated_repo}/branches/{encoded_branch}" ) async with httpx.AsyncClient( timeout=httpx.Timeout(15.0), follow_redirects=False, ) as client: response = await client.get( url, headers=_gitea_headers(), ) response.raise_for_status() commit_sha = response.json().get("commit", {}).get("id") return _validate_commit_sha(commit_sha) async def download_repo_archive( commit_sha: str, repo_id: str, gitea_url: str, ) -> bytes: """Download a bounded source archive for an already resolved SHA.""" validated_sha = _validate_commit_sha(commit_sha) validated_repo = _validate_repo_id(repo_id) validated_url = _validate_gitea_url(gitea_url) url = ( f"{validated_url}/api/v1/repos/" f"{validated_repo}/archive/{validated_sha}.tar.gz" ) async with httpx.AsyncClient( timeout=httpx.Timeout(connect=15.0, read=60.0, write=15.0, pool=15.0), follow_redirects=False, ) as client: async with client.stream( "GET", url, headers=_gitea_headers(), ) as response: response.raise_for_status() content_length = response.headers.get("content-length") if ( content_length is not None and int(content_length) > MAX_SOURCE_ARCHIVE_BYTES ): raise ValueError("Source archive exceeds allowed size") chunks = [] total_bytes = 0 async for chunk in response.aiter_bytes(): total_bytes += len(chunk) if total_bytes > MAX_SOURCE_ARCHIVE_BYTES: raise ValueError("Source archive exceeds allowed size") chunks.append(chunk) return b"".join(chunks) def _normalize_repository_item(item: dict) -> dict | None: """Safely extracts and transforms a single repository item from the Gitea API response.""" if not isinstance(item, dict): return None full_name = item.get("full_name") name = item.get("name") if not all(isinstance(val, str) and val for val in [full_name, name]): return None if not full_name.startswith(f"{ALLOWED_GITEA_NAMESPACE}/"): return None return { "name": name, "full_name": full_name, "default_branch": item.get("default_branch"), "updated_at": item.get("updated_at"), "archived": item.get("archived", False), } async def list_allowed_namespace_repositories() -> dict: """ Fetches a read-only, metadata-only catalog of Gitea repositories from a fixed, approved namespace. """ gitea_url = _validate_gitea_url(os.environ.get("GITEA_URL")) all_repos = [] seen_repos = set() try: async with httpx.AsyncClient(timeout=15.0, follow_redirects=False) as client: for page in range(1, CATALOG_MAX_PAGES + 1): if len(all_repos) >= CATALOG_MAX_RESULTS: break url = ( f"{gitea_url}/api/v1/users/{ALLOWED_GITEA_NAMESPACE}/repos" f"?limit={CATALOG_PAGE_SIZE}&page={page}" ) response = await client.get(url, headers=_gitea_headers()) response.raise_for_status() page_items = response.json() if not isinstance(page_items, list) or not page_items: break for item in page_items: normalized = _normalize_repository_item(item) if normalized and normalized["full_name"] not in seen_repos: seen_repos.add(normalized["full_name"]) all_repos.append(normalized) except (httpx.HTTPError, json.JSONDecodeError) as e: logger.error(f"Gitea repository catalog failed: {e}") raise ValueError("Repository catalog unavailable.") return {"repositories": all_repos[:CATALOG_MAX_RESULTS]} async def handle_get_file_content(p: dict, server_repo_id: str) -> dict: """ Gets the raw content of a file from the Gitea repository after strict validation. Uses the JSON/base64 Contents API with bounded reads. """ gitea_url = _validate_gitea_url(os.environ.get("GITEA_URL")) validated_server_repo = _validate_repo_id(server_repo_id) caller_repo = p.get("repo") if caller_repo is not None and caller_repo != validated_server_repo: raise ValueError("Repository file request is not allowed.") path = _validate_safe_path(p.get("path")) requested_ref = p.get("ref") if not isinstance(requested_ref, str) or not requested_ref: raise ValueError("Invalid git reference.") if _SHA_RE.fullmatch(requested_ref.lower()): resolved_commit_sha = _validate_commit_sha(requested_ref.lower()) else: validated_branch_ref = _validate_read_branch_ref(requested_ref) try: resolved_commit_sha = await resolve_branch_to_commit_sha( branch_name=validated_branch_ref, repo_id=validated_server_repo, gitea_url=gitea_url, ) except httpx.HTTPStatusError as exc: if exc.response.status_code == 404: raise ValueError( "Unknown or inaccessible branch reference." ) from exc logger.warning( "Gitea branch resolution failed", extra={"status_code": exc.response.status_code}, ) raise ValueError("Repository file is unavailable.") from exc except Exception: logger.error("Gitea branch resolution failed unexpectedly") raise ValueError("Repository file is unavailable.") resolved_commit_sha = _validate_commit_sha(resolved_commit_sha) url = f"{gitea_url}/api/v1/repos/{validated_server_repo}/contents/{quote(path, safe='')}?ref={resolved_commit_sha}" try: async with httpx.AsyncClient(timeout=15) as c: async with c.stream("GET", url, headers=_gitea_headers()) as response: response.raise_for_status() content_length = -1 content_length_str = response.headers.get("content-length") if content_length_str: try: content_length = int(content_length_str) except (ValueError, TypeError): content_length = -1 if content_length >= 0 and content_length > MAX_GET_FILE_RESPONSE_BYTES: raise ValueError("Repository file exceeds the allowed size.") body_bytes = bytearray() async for chunk in response.aiter_bytes(): if len(body_bytes) + len(chunk) > MAX_GET_FILE_RESPONSE_BYTES: raise ValueError("Repository file exceeds the allowed size.") body_bytes.extend(chunk) data = json.loads(body_bytes) except (httpx.HTTPError, json.JSONDecodeError): raise ValueError("Repository file is unavailable.") if not isinstance(data, dict) or "content" not in data: raise ValueError("Repository file is unavailable.") size = data.get("size") if isinstance(size, int) and not isinstance(size, bool) and size >= 0: if size > MAX_READ_FILE_BYTES: raise ValueError("Repository file exceeds the allowed size.") try: decoded_content = base64.b64decode(data["content"], validate=True) except (TypeError, ValueError, binascii.Error): raise ValueError("Repository file content is not readable text.") if len(decoded_content) > MAX_READ_FILE_BYTES: raise ValueError("Repository file exceeds the allowed size.") try: text_content = decoded_content.decode('utf-8') except UnicodeDecodeError: raise ValueError("Repository file content is not readable text.") if '\0' in text_content: raise ValueError("Repository file content is not readable text.") file_sha = data.get("sha") if not isinstance(file_sha, str) or not file_sha: raise ValueError("Repository file is unavailable: upstream response missing file SHA.") return { "repo": validated_server_repo, "path": path, "requested_ref": requested_ref, "resolved_commit_sha": resolved_commit_sha, "file_sha": _validate_commit_sha(file_sha), "content": text_content, "encoding": "utf-8", } async def handle_list_repo_files(p: dict, default_repo: str) -> dict: """Lists files and directories in a given path in the Gitea repository.""" gitea_url = os.environ.get("GITEA_URL") if not gitea_url: raise ValueError("GITEA_URL environment variable is not set.") if not isinstance(p, dict): raise ValueError("Invalid list_repo_files input.") if set(p.keys()) - {"path"}: raise ValueError("Unsupported list_repo_files input field.") requested_path = p.get("path") if requested_path in (None, ""): normalized_path = "" else: normalized_path = _validate_safe_path(requested_path) repo_id = default_repo ref = "main" safe_path = quote(normalized_path, safe='/') url = f"{gitea_url.rstrip('/')}/api/v1/repos/{repo_id}/contents/{safe_path}" async with httpx.AsyncClient(timeout=15) as c: r = await c.get(url, headers=_gitea_headers(), params={"ref": ref}) r.raise_for_status() response_data = r.json() if isinstance(response_data, dict): raise ValueError("Path is a file, not a directory. Use get_file_content instead.") if not isinstance(response_data, list): raise ValueError("Invalid upstream response.") result_files = [] for entry in response_data: if not isinstance(entry, dict): raise ValueError("Invalid upstream response.") entry_path = entry.get("path") try: _validate_safe_path(entry_path) except ValueError as e: if str(e) in ("PATH_NOT_ALLOWED", "SECRET_PATH_DENIED"): continue raise name = entry.get("name") entry_type = entry.get("type") if not all([name, entry_path, entry_type]): raise ValueError("Invalid upstream response.") result_entry = { "name": name, "path": entry_path, "type": entry_type } if "sha" in entry and entry["sha"] is not None: result_entry["sha"] = entry["sha"] if "size" in entry and entry["size"] is not None: result_entry["size"] = entry["size"] result_files.append(result_entry) return { "path": normalized_path, "files": result_files }