"""Request building for the Gitea executor: paths, query strings and bodies. Everything here is pure (no I/O) and raises :class:`ArgumentError` with a message that never contains the offending value, because a rejected value may itself be a credential a model pasted into the wrong argument. Path safety rules ----------------- Every value substituted into a path template (and every raw ``request()`` path) is rejected when it is empty, contains a ``..`` or ``.`` path segment, an empty segment, a backslash, ``?``, ``#``, ``%`` or a control character. After that check the value is percent-encoded. Parameters are quoted with ``safe=""`` so a ``/`` becomes ``%2F`` and can never open a new path segment, except the ``(path template, parameter)`` pairs in :data:`SLASH_ROUTES`. Those are routed by Gitea 28.1.0 through wildcard routes (file paths, branch, tag and ref names) and only resolve with real slashes, so they are quoted with ``safe="/"``. The decision is keyed on the route and not on the parameter name because the same name is a single segment elsewhere (``releases/tags/{tag}`` and ``editorconfig/{filepath}`` answer 404 for a raw slash and only accept ``%2F``). """ from __future__ import annotations import base64 import binascii import re import unicodedata from collections.abc import Mapping from dataclasses import dataclass, field from typing import Any from urllib.parse import quote from aegis_gitea_mcp.catalog.model import Operation, Parameter #: ``(path template, parameter name)`` pairs that Gitea 28.1.0 serves through a #: wildcard route, verified against a live instance: a raw ``/`` resolves and #: ``%2F`` would not. Everything else (including same-named parameters on #: single-segment routes such as ``releases/tags/{tag}`` and #: ``editorconfig/{filepath}``) is quoted with ``safe=""``. SLASH_ROUTES = frozenset( { ("/repos/{owner}/{repo}/branches/{branch}", "branch"), ("/repos/{owner}/{repo}/tags/{tag}", "tag"), ("/repos/{owner}/{repo}/contents/{filepath}", "filepath"), ("/repos/{owner}/{repo}/contents-ext/{filepath}", "filepath"), ("/repos/{owner}/{repo}/raw/{filepath}", "filepath"), ("/repos/{owner}/{repo}/media/{filepath}", "filepath"), ("/repos/{owner}/{repo}/compare/{basehead}", "basehead"), ("/repos/{owner}/{repo}/git/refs/{ref}", "ref"), ("/repos/{owner}/{repo}/commits/{ref}/status", "ref"), ("/repos/{owner}/{repo}/commits/{ref}/statuses", "ref"), ("/repos/{owner}/{repo}/pulls/{base}/{head}", "head"), } ) def allows_slash(template: str, name: str) -> bool: """Whether ``name`` in the path ``template`` is a wildcard that takes raw slashes.""" return (template, name) in SLASH_ROUTES #: Query parameter names that carry credentials or switch the acting user in #: Gitea. They are never forwarded: the identity and the ``sudo`` argument are #: the only sanctioned ways to set them. FORBIDDEN_QUERY_NAMES = frozenset({"token", "access_token", "sudo", "_sudo"}) #: Raw-request methods. RAW_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"}) _PLACEHOLDER = re.compile(r"\{([^{}]+)\}") _BAD_CHARS = frozenset("\\?#%") _BAD_CATEGORIES = frozenset({"Cc", "Cs", "Zl", "Zp"}) _SUDO_PATTERN = re.compile(r"^[A-Za-z0-9_.-]{1,255}$") _MAX_FILENAME_CHARS = 255 _MAX_CONTENT_TYPE_CHARS = 255 _BODY_DEPTH_LIMIT = 8 #: Body keys whose string values are treated as secrets for redaction. _SENSITIVE_BODY_KEYS = frozenset( { "password", "secret", "client_secret", "token", "access_token", "auth_password", "auth_token", "private_key", "authorization_header", "remote_password", "mirror_password", "mirror_token", "aws_secret_access_key", "aws_access_key_id", } ) _MIN_SENSITIVE_VALUE_CHARS = 4 #: Response fields whose non-empty string values are replaced with ``[REDACTED]`` #: in every result except those of credential-issuing operations (whose purpose #: is to return a credential). Gitea fills ``authorization_header`` on every #: webhook read, so a stored header would otherwise reach the model. Keep this #: next to :data:`_SENSITIVE_BODY_KEYS` so a new secret-bearing field is added #: in one place. SENSITIVE_RESPONSE_KEYS = _SENSITIVE_BODY_KEYS class ArgumentError(ValueError): """An argument cannot be turned into a safe request (message never echoes the value).""" class PayloadTooLargeError(ArgumentError): """A request payload is larger than the configured limit (maps to ``payload_too_large``).""" @dataclass(frozen=True) class RequestBody: """Body of an outgoing request; at most one of the fields is set. Attributes: json: Value to send as ``application/json`` (``has_json`` tells ``None`` apart). has_json: True when ``json`` is the body. content: Raw bytes for ``text/plain`` bodies. content_type: Content type for ``content`` bodies. files: Multipart parts as ``(field, (filename|None, bytes, content_type|None))``. """ json: Any = None has_json: bool = False content: bytes | None = None content_type: str | None = None files: list[tuple[str, tuple[str | None, bytes, str | None]]] = field(default_factory=list) def _has_bad_character(value: str) -> bool: return any( char in _BAD_CHARS or unicodedata.category(char) in _BAD_CATEGORIES for char in value ) def check_segments(value: str, label: str) -> None: """Reject values that could escape or reshape a URL path. Args: value: Candidate path value (a parameter value or a whole raw path). label: Name used in the error message. Raises: ArgumentError: The value is empty or unsafe. """ if not value: raise ArgumentError(f"{label} must not be empty") if _has_bad_character(value): raise ArgumentError( f"{label} contains a forbidden character (backslash, ?, #, % or control character)" ) for segment in value.split("/"): if segment in {"", ".", ".."}: raise ArgumentError(f"{label} contains an empty, '.' or '..' path segment") def _path_value(parameter: Parameter, arguments: Mapping[str, Any]) -> str: value = arguments.get(parameter.arg_name) if value is None: raise ArgumentError(f"missing path parameter '{parameter.arg_name}'") if isinstance(value, bool) or not isinstance(value, (str, int)): raise ArgumentError(f"path parameter '{parameter.arg_name}' must be a string or integer") return str(value) def render_path(operation: Operation, arguments: Mapping[str, Any]) -> tuple[str, str]: """Fill the operation's path template. Args: operation: Catalog operation. arguments: Tool arguments keyed by ``arg_name``. Returns: ``(encoded_path, display_path)``: the percent-encoded path to send and the readable path (raw values) for the result envelope. Raises: ArgumentError: A placeholder has no parameter, is missing, or is unsafe. """ by_name = {p.name: p for p in operation.parameters if p.location == "path"} display: dict[str, str] = {} def fill(match: re.Match[str]) -> str: name = match.group(1) parameter = by_name.get(name) if parameter is None: raise ArgumentError(f"path placeholder '{name}' has no matching parameter") value = _path_value(parameter, arguments) check_segments(value, f"path parameter '{parameter.arg_name}'") safe = "/" if allows_slash(operation.path, name) else "" display[name] = value return quote(value, safe=safe) encoded_path = _PLACEHOLDER.sub(fill, operation.path) display_path = _PLACEHOLDER.sub(lambda m: display[m.group(1)], operation.path) return encoded_path, display_path def render_raw_path(path: str) -> tuple[str, str]: """Validate and encode a raw ``/api/v1``-relative path. Returns: ``(encoded_path, display_path)``. Raises: ArgumentError: The path does not start with ``/`` or fails the segment checks. """ if not isinstance(path, str) or not path.startswith("/"): raise ArgumentError("path must start with '/' and be relative to /api/v1") check_segments(path[1:], "path") return quote(path, safe="/"), path def _scalar(value: Any, name: str) -> str: if isinstance(value, bool): return "true" if value else "false" if isinstance(value, (int, float)): return str(value) if isinstance(value, str): return value raise ArgumentError(f"query parameter '{name}' must be a string, number, boolean or list") def _query_pairs(name: str, value: Any, csv: bool) -> list[tuple[str, str]]: if name.lower() in FORBIDDEN_QUERY_NAMES: raise ArgumentError(f"query parameter '{name}' is reserved and cannot be set") if isinstance(value, (list, tuple)): items = [_scalar(item, name) for item in value] if not items: return [] if csv: return [(name, ",".join(items))] return [(name, item) for item in items] return [(name, _scalar(value, name))] def build_query(operation: Operation, arguments: Mapping[str, Any]) -> list[tuple[str, str]]: """Build the query string pairs from the operation's query parameters. Only provided (non-null) arguments are sent. Booleans become ``true`` or ``false``; arrays become repeated parameters unless the parameter schema declares ``collectionFormat``/``x-collection-format`` ``csv``. """ pairs: list[tuple[str, str]] = [] for parameter in operation.parameters: if parameter.location != "query": continue value = arguments.get(parameter.arg_name) if value is None: continue schema = parameter.schema csv = "csv" in (schema.get("collectionFormat"), schema.get("x-collection-format")) pairs.extend(_query_pairs(parameter.name, value, csv)) return pairs def build_raw_query(query: Mapping[str, Any] | None) -> list[tuple[str, str]]: """Build query pairs for a raw request (repeated parameters for lists).""" pairs: list[tuple[str, str]] = [] for name, value in (query or {}).items(): if value is None: continue pairs.extend(_query_pairs(str(name), value, csv=False)) return pairs def validate_sudo(sudo: Any) -> str | None: """Validate the optional ``sudo`` login (None when not given).""" if sudo is None: return None # fullmatch: ``$`` would also accept a trailing newline. if not isinstance(sudo, str) or not _SUDO_PATTERN.fullmatch(sudo): raise ArgumentError("sudo must be a Gitea username (letters, digits, '-', '_', '.')") return sudo def _single_line(value: str, label: str, limit: int) -> str: if not value or len(value) > limit or _has_control(value): raise ArgumentError(f"{label} must be a non-empty single-line string") return value def _has_control(value: str) -> bool: return any(unicodedata.category(char) in _BAD_CATEGORIES for char in value) def _decoded_size_bound(encoded: str) -> int: """Decoded byte count implied by a base64 string's length (before decoding it).""" padding = len(encoded) - len(encoded.rstrip("=")) return max(0, len(encoded) * 3 // 4 - min(padding, 2)) def _file_part( parameter: Parameter, value: Any, max_file_bytes: int | None ) -> tuple[str, tuple[str | None, bytes, str | None]]: label = f"file argument '{parameter.arg_name}'" if not isinstance(value, Mapping): raise ArgumentError(f"{label} must be an object with filename and content_base64") unknown = set(value) - {"filename", "content_base64", "content_type"} if unknown: raise ArgumentError(f"{label} has unexpected keys") filename = value.get("filename") encoded = value.get("content_base64") if not isinstance(filename, str) or not isinstance(encoded, str): raise ArgumentError(f"{label} needs string filename and content_base64") filename = _single_line(filename, f"{label} filename", _MAX_FILENAME_CHARS) if '"' in filename: raise ArgumentError(f"{label} filename must not contain a double quote") if max_file_bytes is not None and _decoded_size_bound(encoded) > max_file_bytes: raise PayloadTooLargeError(f"{label} is larger than the limit of {max_file_bytes} bytes") try: content = base64.b64decode(encoded, validate=True) except (binascii.Error, ValueError) as exc: raise ArgumentError(f"{label} content_base64 is not valid base64") from exc content_type = value.get("content_type") if content_type is not None: if not isinstance(content_type, str): raise ArgumentError(f"{label} content_type must be a string") content_type = _single_line(content_type, f"{label} content_type", _MAX_CONTENT_TYPE_CHARS) return parameter.name, (filename, content, content_type or "application/octet-stream") def build_body( operation: Operation, arguments: Mapping[str, Any], max_file_bytes: int | None = None ) -> RequestBody: """Build the request body from the operation's body and formData parameters. ``formData`` parameters produce a multipart body (files are decoded from base64 strictly). A ``body`` parameter is sent as JSON, or as a plain string when the operation only consumes ``text/plain``. Args: operation: Catalog operation. arguments: Tool arguments keyed by ``arg_name``. max_file_bytes: Largest decoded size accepted for one file argument; the base64 length is checked before anything is decoded. None disables it. Raises: PayloadTooLargeError: A file argument is larger than ``max_file_bytes``. ArgumentError: A file argument or text body is malformed. """ form_parts: list[tuple[str, tuple[str | None, bytes, str | None]]] = [] for parameter in operation.parameters: if parameter.location != "formData": continue value = arguments.get(parameter.arg_name) if value is None: continue if parameter.is_file: form_parts.append(_file_part(parameter, value, max_file_bytes)) else: form_parts.append( (parameter.name, (None, _scalar(value, parameter.arg_name).encode(), None)) ) if form_parts: return RequestBody(files=form_parts) for parameter in operation.parameters: if parameter.location != "body": continue value = arguments.get(parameter.arg_name) if value is None: continue if "text/plain" in operation.consumes and "application/json" not in operation.consumes: if not isinstance(value, str): raise ArgumentError(f"body of '{parameter.arg_name}' must be a string") return RequestBody(content=value.encode("utf-8"), content_type="text/plain") return RequestBody(json=value, has_json=True) return RequestBody() def _walk_sensitive(value: Any, found: list[str], depth: int) -> None: if depth > _BODY_DEPTH_LIMIT: return if isinstance(value, Mapping): for key, item in value.items(): if ( isinstance(key, str) and key.lower() in _SENSITIVE_BODY_KEYS and isinstance(item, str) and len(item) >= _MIN_SENSITIVE_VALUE_CHARS ): found.append(item) else: _walk_sensitive(item, found, depth + 1) elif isinstance(value, (list, tuple)): for item in value: _walk_sensitive(item, found, depth + 1) def sensitive_body_values(body: Any, *, secret_data: bool) -> list[str]: """Collect submitted values that must never be echoed back in a result. Args: body: JSON request body. secret_data: True for Action-secret writes, where ``body["data"]`` is the secret itself (redacted at any length). Returns: Values to redact exactly: ``password``, ``secret``, ``token`` style fields of at least 4 characters, plus the secret ``data`` value. """ found: list[str] = [] if secret_data and isinstance(body, Mapping): data = body.get("data") if isinstance(data, str) and data: found.append(data) _walk_sensitive(body, found, 0) return found