342 lines
15 KiB
Python
342 lines
15 KiB
Python
"""Bounded patching and single-commit Git workspaces for Gitea changesets."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import os
|
|
import re
|
|
import tempfile
|
|
from collections.abc import Iterator, Mapping, Sequence
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from threading import Lock
|
|
from typing import Any, Protocol
|
|
|
|
from dulwich import porcelain
|
|
from dulwich.repo import Repo
|
|
|
|
from packages.connector_kit import GiteaError, RegisteredRepository
|
|
from packages.connector_kit.gitea import TokenProvider
|
|
|
|
_HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$")
|
|
_SHA = re.compile(r"^[0-9a-fA-F]{40,64}$")
|
|
_MAX_CHANGESET_BYTES = 1_000_000
|
|
_MAX_OPERATIONS = 50
|
|
_MAX_WORKSPACE_BYTES = 256 * 1024 * 1024
|
|
_DULWICH_IDENTITY_LOCK = Lock()
|
|
_DULWICH_IDENTITY = {"USER": "mcp-agent", "EMAIL": "mcp-agent@example.com"}
|
|
|
|
|
|
@contextmanager
|
|
def _dulwich_clone_identity() -> Iterator[None]:
|
|
"""Give Dulwich a bounded reflog identity without mutating process state concurrently."""
|
|
|
|
with _DULWICH_IDENTITY_LOCK:
|
|
previous = {name: os.environ.get(name) for name in _DULWICH_IDENTITY}
|
|
os.environ.update(_DULWICH_IDENTITY)
|
|
try:
|
|
yield
|
|
finally:
|
|
for name, value in previous.items():
|
|
if value is None:
|
|
os.environ.pop(name, None)
|
|
else:
|
|
os.environ[name] = value
|
|
|
|
|
|
def _hex_object_id(value: bytes) -> str:
|
|
try:
|
|
decoded = value.decode("ascii")
|
|
except UnicodeDecodeError:
|
|
return value.hex()
|
|
return decoded.casefold() if _SHA.fullmatch(decoded) else value.hex()
|
|
|
|
|
|
class ChangesetWorkspace(Protocol):
|
|
def apply(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
branch: str,
|
|
message: str,
|
|
operations: Sequence[Mapping[str, Any]],
|
|
) -> dict[str, Any]: ...
|
|
|
|
|
|
def apply_unified_patch(original: str, patch: str) -> str:
|
|
"""Apply a single-file unified diff with exact context matching."""
|
|
if not patch or len(patch.encode("utf-8")) > 300_000 or "\x00" in patch:
|
|
raise GiteaError("GITEA_PATCH_INVALID", "patch is outside the connector budget")
|
|
lines = patch.splitlines(keepends=True)
|
|
index = 0
|
|
while index < len(lines) and not lines[index].startswith("@@ "):
|
|
if lines[index].startswith(("GIT binary patch", "Binary files ")):
|
|
raise GiteaError("GITEA_PATCH_INVALID", "binary patches are not supported")
|
|
index += 1
|
|
if index == len(lines):
|
|
raise GiteaError("GITEA_PATCH_INVALID", "patch contains no unified diff hunk")
|
|
|
|
source = original.splitlines(keepends=True)
|
|
output: list[str] = []
|
|
source_index = 0
|
|
seen_hunk = False
|
|
while index < len(lines):
|
|
header = lines[index].rstrip("\r\n")
|
|
match = _HUNK.fullmatch(header)
|
|
if match is None:
|
|
raise GiteaError("GITEA_PATCH_INVALID", "patch contains an invalid hunk header")
|
|
seen_hunk = True
|
|
old_start = int(match.group(1))
|
|
old_count = int(match.group(2) or "1")
|
|
new_count = int(match.group(4) or "1")
|
|
target_index = max(old_start - 1, 0)
|
|
if target_index < source_index or target_index > len(source):
|
|
raise GiteaError("GITEA_PATCH_CONFLICT", "patch hunks overlap or exceed the file")
|
|
output.extend(source[source_index:target_index])
|
|
source_index = target_index
|
|
index += 1
|
|
consumed = 0
|
|
produced = 0
|
|
while index < len(lines) and not lines[index].startswith("@@ "):
|
|
line = lines[index]
|
|
if line.startswith("\\ No newline at end of file"):
|
|
index += 1
|
|
continue
|
|
if not line or line[0] not in {" ", "+", "-"}:
|
|
raise GiteaError("GITEA_PATCH_INVALID", "patch contains an invalid hunk line")
|
|
marker, value = line[0], line[1:]
|
|
if marker in {" ", "-"}:
|
|
if source_index >= len(source) or source[source_index] != value:
|
|
raise GiteaError("GITEA_PATCH_CONFLICT", "patch context does not match")
|
|
source_index += 1
|
|
consumed += 1
|
|
if marker in {" ", "+"}:
|
|
output.append(value)
|
|
produced += 1
|
|
index += 1
|
|
if consumed != old_count or produced != new_count:
|
|
raise GiteaError("GITEA_PATCH_INVALID", "patch hunk counts do not match")
|
|
if not seen_hunk:
|
|
raise GiteaError("GITEA_PATCH_INVALID", "patch contains no hunks")
|
|
output.extend(source[source_index:])
|
|
result = "".join(output)
|
|
if len(result.encode("utf-8")) > 200_000:
|
|
raise GiteaError("GITEA_INPUT_INVALID", "patched file exceeds the connector budget")
|
|
return result
|
|
|
|
|
|
def validate_operations(operations: Sequence[Mapping[str, Any]]) -> tuple[dict[str, Any], ...]:
|
|
if not 1 <= len(operations) <= _MAX_OPERATIONS:
|
|
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset operation count is invalid")
|
|
total = 0
|
|
normalized: list[dict[str, Any]] = []
|
|
touched: set[str] = set()
|
|
for raw in operations:
|
|
kind = str(raw.get("kind", ""))
|
|
if kind not in {"create", "update", "delete", "move", "patch"}:
|
|
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset operation kind is invalid")
|
|
path = str(raw.get("path", ""))
|
|
if not path or path in touched:
|
|
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset paths must be unique")
|
|
touched.add(path)
|
|
operation: dict[str, Any] = {"kind": kind, "path": path}
|
|
for field in ("content", "patch", "expectedSha", "destinationPath"):
|
|
if field in raw and raw[field] is not None:
|
|
operation[field] = str(raw[field])
|
|
if kind in {"update", "delete", "move", "patch"} and not _SHA.fullmatch(
|
|
operation.get("expectedSha", "")
|
|
):
|
|
raise GiteaError("GITEA_CHANGESET_INVALID", "expectedSha is required and invalid")
|
|
if kind in {"create", "update"} and "content" not in operation:
|
|
raise GiteaError("GITEA_CHANGESET_INVALID", "content is required")
|
|
if kind == "patch" and "patch" not in operation:
|
|
raise GiteaError("GITEA_CHANGESET_INVALID", "patch text is required")
|
|
if kind == "move" and not operation.get("destinationPath"):
|
|
raise GiteaError("GITEA_CHANGESET_INVALID", "destinationPath is required")
|
|
destination = operation.get("destinationPath")
|
|
if destination is not None:
|
|
if destination in touched:
|
|
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset paths must be unique")
|
|
touched.add(destination)
|
|
total += sum(len(str(value).encode("utf-8")) for value in operation.values())
|
|
normalized.append(operation)
|
|
if total > _MAX_CHANGESET_BYTES:
|
|
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset exceeds the connector budget")
|
|
return tuple(normalized)
|
|
|
|
|
|
class DulwichChangesetWorkspace:
|
|
"""Apply an already-validated changeset through a fixed, isolated Git workflow."""
|
|
|
|
def __init__(self, base_url: str, token_provider: TokenProvider, token_reference: str) -> None:
|
|
self._base_url = base_url.rstrip("/")
|
|
self._token_provider = token_provider
|
|
self._token_reference = token_reference
|
|
|
|
@staticmethod
|
|
def _relative(root: Path, value: str) -> Path:
|
|
if not value or value.startswith(("/", "\\")) or "\\" in value:
|
|
raise GiteaError("GITEA_PATH_INVALID", "repository file path is invalid")
|
|
parts = value.split("/")
|
|
if any(not part or part in {".", ".."} or "\x00" in part for part in parts):
|
|
raise GiteaError("GITEA_PATH_INVALID", "repository file path is invalid")
|
|
target = root.joinpath(*parts)
|
|
try:
|
|
target.resolve(strict=False).relative_to(root.resolve())
|
|
except ValueError as exc:
|
|
raise GiteaError("GITEA_PATH_INVALID", "repository file path is invalid") from exc
|
|
return target
|
|
|
|
@staticmethod
|
|
def _blob_sha(repo: Repo, relative: str) -> str | None:
|
|
try:
|
|
entry = repo.open_index()[relative.encode("utf-8")]
|
|
except KeyError:
|
|
return None
|
|
sha = getattr(entry, "sha", None)
|
|
return _hex_object_id(sha) if sha is not None else None
|
|
|
|
def apply(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
branch: str,
|
|
message: str,
|
|
operations: Sequence[Mapping[str, Any]],
|
|
) -> dict[str, Any]:
|
|
normalized = validate_operations(operations)
|
|
token = self._token_provider.get_token(self._token_reference)
|
|
remote = f"{self._base_url}/{repository.owner}/{repository.name}.git"
|
|
authenticated = remote.startswith(("https://", "http://"))
|
|
stderr = io.BytesIO()
|
|
with tempfile.TemporaryDirectory(prefix="itworx-gitea-") as temporary:
|
|
root = Path(temporary) / "repository"
|
|
repo: Repo | None = None
|
|
try:
|
|
with _dulwich_clone_identity():
|
|
if authenticated:
|
|
repo = porcelain.clone(
|
|
remote,
|
|
root,
|
|
branch=branch,
|
|
depth=1,
|
|
errstream=stderr,
|
|
username="oauth2",
|
|
password=token,
|
|
)
|
|
else:
|
|
repo = porcelain.clone(
|
|
remote, root, branch=branch, depth=1, errstream=stderr
|
|
)
|
|
workspace_bytes = sum(
|
|
entry.stat(follow_symlinks=False).st_size
|
|
for entry in root.rglob("*")
|
|
if entry.is_file() and not entry.is_symlink()
|
|
)
|
|
if workspace_bytes > _MAX_WORKSPACE_BYTES:
|
|
raise GiteaError(
|
|
"GITEA_CHANGESET_TOO_LARGE",
|
|
"repository exceeds the changeset workspace budget",
|
|
)
|
|
staged: list[str] = []
|
|
removed: list[str] = []
|
|
for operation in normalized:
|
|
kind = operation["kind"]
|
|
relative = operation["path"]
|
|
target = self._relative(root, relative)
|
|
actual_sha = self._blob_sha(repo, relative)
|
|
expected_sha = operation.get("expectedSha")
|
|
if expected_sha is not None and actual_sha != expected_sha.casefold():
|
|
raise GiteaError(
|
|
"GITEA_CHANGESET_CONFLICT", f"stale expectedSha for {relative}"
|
|
)
|
|
if kind == "create":
|
|
if actual_sha is not None or target.exists():
|
|
raise GiteaError(
|
|
"GITEA_CHANGESET_CONFLICT", f"file already exists: {relative}"
|
|
)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(operation["content"], encoding="utf-8")
|
|
staged.append(relative)
|
|
elif kind == "update":
|
|
if actual_sha is None:
|
|
raise GiteaError(
|
|
"GITEA_CHANGESET_CONFLICT", f"file does not exist: {relative}"
|
|
)
|
|
target.write_text(operation["content"], encoding="utf-8")
|
|
staged.append(relative)
|
|
elif kind == "patch":
|
|
if actual_sha is None:
|
|
raise GiteaError(
|
|
"GITEA_CHANGESET_CONFLICT", f"file does not exist: {relative}"
|
|
)
|
|
original = target.read_text(encoding="utf-8")
|
|
target.write_text(
|
|
apply_unified_patch(original, operation["patch"]), encoding="utf-8"
|
|
)
|
|
staged.append(relative)
|
|
elif kind == "delete":
|
|
if actual_sha is None:
|
|
raise GiteaError(
|
|
"GITEA_CHANGESET_CONFLICT", f"file does not exist: {relative}"
|
|
)
|
|
target.unlink()
|
|
removed.append(relative)
|
|
else:
|
|
destination = str(operation["destinationPath"])
|
|
destination_target = self._relative(root, destination)
|
|
if actual_sha is None or destination_target.exists():
|
|
raise GiteaError(
|
|
"GITEA_CHANGESET_CONFLICT", "move source/destination is invalid"
|
|
)
|
|
destination_target.parent.mkdir(parents=True, exist_ok=True)
|
|
os.replace(target, destination_target)
|
|
removed.append(relative)
|
|
staged.append(destination)
|
|
if removed:
|
|
porcelain.rm(repo, removed)
|
|
if staged:
|
|
porcelain.add(repo, staged)
|
|
status = porcelain.status(repo)
|
|
if not status.staged and not status.unstaged and not status.untracked:
|
|
raise GiteaError("GITEA_CHANGESET_EMPTY", "changeset produces no change")
|
|
commit_sha = _hex_object_id(
|
|
porcelain.commit(
|
|
repo,
|
|
message=message.encode("utf-8"),
|
|
author=b"ChatGPT MCP <mcp-agent@example.com>",
|
|
committer=b"ChatGPT MCP <mcp-agent@example.com>",
|
|
)
|
|
)
|
|
if authenticated:
|
|
porcelain.push(
|
|
repo,
|
|
remote,
|
|
refspecs=f"refs/heads/{branch}:refs/heads/{branch}",
|
|
force=False,
|
|
errstream=stderr,
|
|
username="oauth2",
|
|
password=token,
|
|
)
|
|
else:
|
|
porcelain.push(
|
|
repo,
|
|
remote,
|
|
refspecs=f"refs/heads/{branch}:refs/heads/{branch}",
|
|
force=False,
|
|
errstream=stderr,
|
|
)
|
|
except GiteaError:
|
|
raise
|
|
except Exception as exc:
|
|
raise GiteaError(
|
|
"GITEA_CHANGESET_FAILED", "bounded Git changeset could not be committed"
|
|
) from exc
|
|
finally:
|
|
if repo is not None:
|
|
repo.close()
|
|
return {
|
|
"commitSha": commit_sha,
|
|
"branch": branch,
|
|
"operationCount": len(normalized),
|
|
"atomic": True,
|
|
}
|