import os import httpx import logging from urllib.parse import quote import re 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}$" ) MAX_SOURCE_ARCHIVE_BYTES = int( os.environ.get("OPAX_MAX_SOURCE_ARCHIVE_BYTES", "104857600") ) 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 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) async def handle_get_file_content(p: dict, default_repo: str) -> dict: """Gets the raw content of a file from the Gitea repository.""" gitea_url = os.environ.get("GITEA_URL") if not gitea_url: raise ValueError("GITEA_URL environment variable is not set.") path = p.get("path") if not path: raise ValueError("Missing required parameter: 'path' for get_file_content") repo_id = p.get("repo", default_repo) ref = p.get("ref", "main") url = f"{gitea_url}/api/v1/repos/{repo_id}/raw/{path}?ref={ref}" async with httpx.AsyncClient(timeout=15) as c: r = await c.get(url, headers=_gitea_headers()) r.raise_for_status() return {"path": path, "content": r.text, "encoding": "text"} 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.") path = p.get("path") if path is None or path == "" or path == ".": normalized_path = "" else: if not isinstance(path, str): raise ValueError("Invalid repository path.") normalized_path = path if normalized_path.startswith('/') or \ '\\' in normalized_path or \ any(c in normalized_path for c in (':', '?', '#')): raise ValueError("Invalid repository path.") parts = normalized_path.split('/') if any(part in ('.', '..') for part in parts): raise ValueError("Invalid repository path.") if '' in parts and normalized_path != '': # check for empty segments raise ValueError("Invalid repository 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.") name = entry.get("name") entry_path = entry.get("path") 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 }