feat(opax-mcp): add validated Gitea source archive helpers
This commit is contained in:
parent
395aad5d70
commit
b802a29482
|
|
@ -2,13 +2,149 @@ import os
|
||||||
import httpx
|
import httpx
|
||||||
import logging
|
import logging
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
import re
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def _gitea_headers() -> dict:
|
||||||
"""Constructs headers for Gitea API requests."""
|
"""Constructs headers for Gitea API requests."""
|
||||||
return {"Authorization": f"token {os.environ.get('GITEA_TOKEN')}", "Accept": "application/json"}
|
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:
|
async def handle_get_file_content(p: dict, default_repo: str) -> dict:
|
||||||
"""Gets the raw content of a file from the Gitea repository."""
|
"""Gets the raw content of a file from the Gitea repository."""
|
||||||
gitea_url = os.environ.get("GITEA_URL")
|
gitea_url = os.environ.get("GITEA_URL")
|
||||||
|
|
|
||||||
137
opax-mcp/test_gitea_helpers.py
Normal file
137
opax-mcp/test_gitea_helpers.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(REPO_ROOT / "opax-mcp"))
|
||||||
|
|
||||||
|
import gitea_handler
|
||||||
|
|
||||||
|
def make_stream_context(response):
|
||||||
|
context = MagicMock()
|
||||||
|
context.__aenter__ = AsyncMock(return_value=response)
|
||||||
|
context.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
return context
|
||||||
|
|
||||||
|
class TestGiteaHelpers(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def test_validate_branch_name_valid(self):
|
||||||
|
self.assertEqual(gitea_handler._validate_branch_name("main"), "main")
|
||||||
|
self.assertEqual(gitea_handler._validate_branch_name("feat/opax-domain-decouple"), "feat/opax-domain-decouple")
|
||||||
|
|
||||||
|
def test_validate_branch_name_invalid(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
gitea_handler._validate_branch_name("../../etc/passwd")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
gitea_handler._validate_branch_name("main@{bad}")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
gitea_handler._validate_branch_name("feat//bad")
|
||||||
|
|
||||||
|
def test_validate_commit_sha_valid(self):
|
||||||
|
self.assertEqual(gitea_handler._validate_commit_sha("395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c9"), "395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c9")
|
||||||
|
|
||||||
|
def test_validate_commit_sha_invalid(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
gitea_handler._validate_commit_sha("main")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
gitea_handler._validate_commit_sha("395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c") # 39 chars
|
||||||
|
|
||||||
|
def test_validate_gitea_url_https_required(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
gitea_handler._validate_gitea_url("http://example.com")
|
||||||
|
|
||||||
|
@patch("gitea_handler.httpx.AsyncClient")
|
||||||
|
async def test_resolve_branch_to_commit_sha(self, mock_client):
|
||||||
|
mock_response = unittest.mock.Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {"commit": {"id": "395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c9"}}
|
||||||
|
mock_client.return_value.__aenter__.return_value.get.return_value = mock_response
|
||||||
|
|
||||||
|
sha = await gitea_handler.resolve_branch_to_commit_sha("main", "chris/OSVauco", "https://gitea.example.com")
|
||||||
|
self.assertEqual(sha, "395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c9")
|
||||||
|
|
||||||
|
@patch("gitea_handler.httpx.AsyncClient")
|
||||||
|
async def test_download_repo_archive_uses_sha(self, mock_client):
|
||||||
|
response = MagicMock()
|
||||||
|
response.headers = {}
|
||||||
|
response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
async def aiter_bytes():
|
||||||
|
yield b"archive-data"
|
||||||
|
|
||||||
|
response.aiter_bytes = aiter_bytes
|
||||||
|
|
||||||
|
stream_context = make_stream_context(response)
|
||||||
|
client = mock_client.return_value.__aenter__.return_value
|
||||||
|
client.stream = MagicMock(return_value=stream_context)
|
||||||
|
|
||||||
|
archive = await gitea_handler.download_repo_archive(
|
||||||
|
"395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c9",
|
||||||
|
"chris/OSVauco",
|
||||||
|
"https://gitea.example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(archive, b"archive-data")
|
||||||
|
|
||||||
|
client.stream.assert_called_once_with(
|
||||||
|
"GET",
|
||||||
|
(
|
||||||
|
"https://gitea.example.com/api/v1/repos/"
|
||||||
|
"chris/OSVauco/archive/"
|
||||||
|
"395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c9.tar.gz"
|
||||||
|
),
|
||||||
|
headers=unittest.mock.ANY,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("gitea_handler.httpx.AsyncClient")
|
||||||
|
async def test_download_repo_archive_size_limit(self, mock_client):
|
||||||
|
response = MagicMock()
|
||||||
|
response.headers = {
|
||||||
|
"content-length": str(
|
||||||
|
gitea_handler.MAX_SOURCE_ARCHIVE_BYTES + 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
stream_context = make_stream_context(response)
|
||||||
|
client = mock_client.return_value.__aenter__.return_value
|
||||||
|
client.stream = MagicMock(return_value=stream_context)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ValueError,
|
||||||
|
"Source archive exceeds allowed size",
|
||||||
|
):
|
||||||
|
await gitea_handler.download_repo_archive(
|
||||||
|
"395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c9",
|
||||||
|
"chris/OSVauco",
|
||||||
|
"https://gitea.example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("gitea_handler.httpx.AsyncClient")
|
||||||
|
async def test_download_repo_archive_no_redirects(self, mock_client):
|
||||||
|
response = MagicMock()
|
||||||
|
response.headers = {}
|
||||||
|
response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
async def aiter_bytes():
|
||||||
|
yield b""
|
||||||
|
|
||||||
|
response.aiter_bytes = aiter_bytes
|
||||||
|
|
||||||
|
stream_context = make_stream_context(response)
|
||||||
|
client = mock_client.return_value.__aenter__.return_value
|
||||||
|
client.stream = MagicMock(return_value=stream_context)
|
||||||
|
|
||||||
|
await gitea_handler.download_repo_archive(
|
||||||
|
"395aad5d70d1d30ccd4c3f84e3dfb56e38a2c2c9",
|
||||||
|
"chris/OSVauco",
|
||||||
|
"https://gitea.example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
mock_client.call_args.kwargs["follow_redirects"]
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Reference in New Issue
Block a user