view src/hgext3rd/hggit_serve/_http.py @ 17:a70f387ab3cd default tip

Fix formatting in documentation.
author Paul Fisher <paul@pfish.zone>
date Fri, 20 Feb 2026 21:12:40 -0500
parents 00bdfac5416c
children
line wrap: on
line source

from __future__ import annotations

import email.parser
import email.policy
import re
import shutil
import subprocess
import typing as t

from mercurial import extensions
from mercurial import wireprotoserver
from mercurial.thirdparty import attr

from . import _export as xp

if t.TYPE_CHECKING:
    import mercurial.hgweb.hgweb_mod_inner as web_inner
    import mercurial.hgweb.request as hgreq
    import mercurial.ui as hgui

    PermissionCheck = t.Callable[
        [web_inner.requestcontext, hgreq.parsedrequest, bytes],
        None,
    ]


_CGI_VAR = re.compile(rb'[A-Z0-9_]+$')
"""Environment variables that we need to pass to git-as-cgi."""


def _build_git_environ(
    req_ctx: web_inner.requestcontext,
    request: hgreq.parsedrequest,
) -> dict[bytes, bytes]:
    """Builds the environment to be sent to Git to serve HTTP."""
    fixed = {
        k: v
        for (k, v) in request.rawenv.items()
        if isinstance(v, bytes) and _CGI_VAR.match(k)
    }
    repo = req_ctx.repo
    assert xp.is_gitty(repo)
    fixed.update(
        {
            b'GIT_HTTP_EXPORT_ALL': b'yes',
            b'GIT_PROJECT_ROOT': repo.githandler.gitdir,
            b'PATH_INFO': b'/' + request.dispatchpath,
            # Since Mercurial is taking care of authorization checking,
            # we tell Git to always allow push.
            b'GIT_CONFIG_COUNT': b'1',
            b'GIT_CONFIG_KEY_0': b'http.receivepack',
            b'GIT_CONFIG_VALUE_0': b'true',
        }
    )
    return fixed


def _parse_cgi_response(
    output: t.IO[bytes],
) -> tuple[bytes, dict[bytes, bytes], t.IO[bytes]]:
    """Parses a CGI response into a status, headers, and everyhting else."""
    parser = email.parser.BytesFeedParser(policy=email.policy.HTTP)
    while line := output.readline():
        if not line.rstrip(b'\r\n'):
            # We've reached the end of the headers.
            # Leave the rest in the output for later.
            break
        parser.feed(line)
    msg = parser.close()
    status = msg.get('Status', '200 OK I guess').encode('utf-8')
    del msg['Status']  # this won't raise an exception
    byte_headers = {
        k.encode('utf-8'): v.encode('utf-8') for (k, v) in msg.items()
    }
    return status, byte_headers, output


def _git_service_permission(request: hgreq.parsedrequest) -> bytes | None:
    """Figures out what Mercurial permission corresponds to a request from Git.

    If the request is a supported Git action, returns the permission it needs.
    If the request is not a Git action, returns None.
    """
    if perm := xp.SERVICE_PERMISSIONS.get(request.dispatchpath):
        return perm
    if request.dispatchpath != b'info/refs':
        return None
    qs = request.querystring
    service = qs.removeprefix(b'service=')
    if qs == service:
        # Nothing was stripped.
        return None
    return xp.SERVICE_PERMISSIONS.get(service)


def _handle_git_protocol(
    original: t.Callable[..., bool],
    req_ctx: web_inner.requestcontext,
    request: hgreq.parsedrequest,
    response: hgreq.wsgiresponse,
    check_permission: PermissionCheck,
) -> bool:
    """Intercepts requests from Git, if needed."""
    perm = _git_service_permission(request)
    repo = req_ctx.repo
    if not perm or not xp.is_gitty(repo):
        # We only handle Git requests to Gitty repos.
        return original(req_ctx, request, response, check_permission)

    # Permission workaround: Mercurial requires POSTs for push,
    # but the advertisement request from Git will be a GET.
    # We just lie to Mercurial about what we're doing.
    check_permission(
        req_ctx,
        (
            attr.evolve(req_ctx.req, method=b'POST')
            if perm == xp.PUSH
            else req_ctx.req
        ),
        perm,
    )
    cgi_env = _build_git_environ(req_ctx, request)
    http_backend = repo.ui.configlist(
        b'hggit-serve', b'http-backend', default=(b'git', b'http-backend')
    )
    call = subprocess.Popen(
        http_backend,
        close_fds=True,
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
        env=cgi_env,
        text=False,
    )
    assert call.stdout
    assert call.stdin
    # Git will not start writing output until stdin is fully closed.
    with call.stdin:
        # This is how we know if there's anything to read from bodyfh.
        # If we try to read from bodyfh on a request with no content,
        # it hangs forever.
        if b'CONTENT_LENGTH' in request.rawenv:
            shutil.copyfileobj(request.bodyfh, call.stdin)

    status, headers, rest = _parse_cgi_response(call.stdout)
    response.status = status
    for k, v in headers.items():
        response.headers[k] = v

    def write_the_rest() -> t.Iterator[bytes]:
        with call, rest:
            # if it's good enough for shutil it's good enough for me
            # technically not in the docs but everybody it
            bs = shutil.COPY_BUFSIZE  # type: ignore[attr-defined]
            while more := rest.read(bs):
                yield more
        if perm == xp.PUSH:
            # If we pushed, we need to import any new refs back into Mercurial.
            xp.import_all(repo, b'git-http-push')

    response.setbodygen(write_the_rest())
    response.sendresponse()
    return True


#
# Interfacing with Mercurial
#


def uisetup(_: hgui.ui) -> None:
    extensions.wrapfunction(
        wireprotoserver, 'handlewsgirequest', _handle_git_protocol
    )