Mercurial > hg-git-serve
comparison src/hgext3rd/hggit_serve/_http.py @ 12:f630d9904ea7
Reorganize project into multiple files.
| author | Paul Fisher <paul@pfish.zone> |
|---|---|
| date | Wed, 18 Feb 2026 16:17:05 -0500 |
| parents | src/hgext3rd/hggit_serve.py@ce204bcc4e04 |
| children | 00bdfac5416c |
comparison
equal
deleted
inserted
replaced
| 11:ce204bcc4e04 | 12:f630d9904ea7 |
|---|---|
| 1 from __future__ import annotations | |
| 2 | |
| 3 import email.parser | |
| 4 import email.policy | |
| 5 import re | |
| 6 import shutil | |
| 7 import subprocess | |
| 8 import typing as t | |
| 9 | |
| 10 from mercurial import extensions | |
| 11 from mercurial import wireprotoserver | |
| 12 from mercurial.thirdparty import attr | |
| 13 | |
| 14 from . import _export as xp | |
| 15 | |
| 16 if t.TYPE_CHECKING: | |
| 17 import mercurial.hgweb.hgweb_mod_inner as web_inner | |
| 18 import mercurial.hgweb.request as hgreq | |
| 19 import mercurial.interfaces.repository as hgrepo | |
| 20 import mercurial.ui as hgui | |
| 21 | |
| 22 PermissionCheck = t.Callable[ | |
| 23 [web_inner.requestcontext, hgreq.parsedrequest, bytes], | |
| 24 None, | |
| 25 ] | |
| 26 | |
| 27 | |
| 28 _CGI_VAR = re.compile(rb'[A-Z0-9_]+$') | |
| 29 """Environment variables that we need to pass to git-as-cgi.""" | |
| 30 | |
| 31 | |
| 32 def _build_git_environ( | |
| 33 req_ctx: web_inner.requestcontext, | |
| 34 request: hgreq.parsedrequest, | |
| 35 ) -> dict[bytes, bytes]: | |
| 36 """Builds the environment to be sent to Git to serve HTTP.""" | |
| 37 fixed = { | |
| 38 k: v | |
| 39 for (k, v) in request.rawenv.items() | |
| 40 if isinstance(v, bytes) and _CGI_VAR.match(k) | |
| 41 } | |
| 42 fixed.update( | |
| 43 { | |
| 44 b'GIT_HTTP_EXPORT_ALL': b'yes', | |
| 45 b'GIT_PROJECT_ROOT': req_ctx.repo.path, | |
| 46 b'PATH_INFO': b'/git/' + request.dispatchpath, | |
| 47 # Since Mercurial is taking care of authorization checking, | |
| 48 # we tell Git to always allow push. | |
| 49 b'GIT_CONFIG_COUNT': b'1', | |
| 50 b'GIT_CONFIG_KEY_0': b'http.receivepack', | |
| 51 b'GIT_CONFIG_VALUE_0': b'true', | |
| 52 } | |
| 53 ) | |
| 54 return fixed | |
| 55 | |
| 56 | |
| 57 def _parse_cgi_response( | |
| 58 output: t.IO[bytes], | |
| 59 ) -> tuple[bytes, dict[bytes, bytes], t.IO[bytes]]: | |
| 60 """Parses a CGI response into a status, headers, and everyhting else.""" | |
| 61 parser = email.parser.BytesFeedParser(policy=email.policy.HTTP) | |
| 62 while line := output.readline(): | |
| 63 if not line.rstrip(b'\r\n'): | |
| 64 # We've reached the end of the headers. | |
| 65 # Leave the rest in the output for later. | |
| 66 break | |
| 67 parser.feed(line) | |
| 68 msg = parser.close() | |
| 69 status = msg.get('Status', '200 OK I guess').encode('utf-8') | |
| 70 del msg['Status'] # this won't raise an exception | |
| 71 byte_headers = { | |
| 72 k.encode('utf-8'): v.encode('utf-8') for (k, v) in msg.items() | |
| 73 } | |
| 74 return status, byte_headers, output | |
| 75 | |
| 76 | |
| 77 def _git_service_permission(request: hgreq.parsedrequest) -> bytes | None: | |
| 78 """Figures out what Mercurial permission corresponds to a request from Git. | |
| 79 | |
| 80 If the request is a supported Git action, returns the permission it needs. | |
| 81 If the request is not a Git action, returns None. | |
| 82 """ | |
| 83 if perm := xp.SERVICE_PERMISSIONS.get(request.dispatchpath): | |
| 84 return perm | |
| 85 if request.dispatchpath != b'info/refs': | |
| 86 return None | |
| 87 qs = request.querystring | |
| 88 service = qs.removeprefix(b'service=') | |
| 89 if qs == service: | |
| 90 # Nothing was stripped. | |
| 91 return None | |
| 92 return xp.SERVICE_PERMISSIONS.get(service) | |
| 93 | |
| 94 | |
| 95 def _handle_git_protocol( | |
| 96 original: t.Callable[..., bool], | |
| 97 req_ctx: web_inner.requestcontext, | |
| 98 request: hgreq.parsedrequest, | |
| 99 response: hgreq.wsgiresponse, | |
| 100 check_permission: PermissionCheck, | |
| 101 ) -> bool: | |
| 102 """Intercepts requests from Git, if needed.""" | |
| 103 perm = _git_service_permission(request) | |
| 104 repo: hgrepo.IRepo = req_ctx.repo | |
| 105 if not perm or not xp.is_gitty(repo): | |
| 106 # We only handle Git requests to Gitty repos. | |
| 107 return original(req_ctx, request, response, check_permission) | |
| 108 | |
| 109 # Permission workaround: Mercurial requires POSTs for push, | |
| 110 # but the advertisement request from Git will be a GET. | |
| 111 # We just lie to Mercurial about what we're doing. | |
| 112 check_permission( | |
| 113 req_ctx, | |
| 114 ( | |
| 115 attr.evolve(req_ctx.req, method=b'POST') | |
| 116 if perm == xp.PUSH | |
| 117 else req_ctx.req | |
| 118 ), | |
| 119 perm, | |
| 120 ) | |
| 121 cgi_env = _build_git_environ(req_ctx, request) | |
| 122 http_backend = repo.ui.configlist( | |
| 123 b'hggit-serve', b'http-backend', default=(b'git', b'http-backend') | |
| 124 ) | |
| 125 call = subprocess.Popen( | |
| 126 http_backend, | |
| 127 close_fds=True, | |
| 128 stdin=subprocess.PIPE, | |
| 129 stdout=subprocess.PIPE, | |
| 130 stderr=subprocess.DEVNULL, | |
| 131 env=cgi_env, | |
| 132 text=False, | |
| 133 ) | |
| 134 assert call.stdout | |
| 135 assert call.stdin | |
| 136 # Git will not start writing output until stdin is fully closed. | |
| 137 with call.stdin: | |
| 138 # This is how we know if there's anything to read from bodyfh. | |
| 139 # If we try to read from bodyfh on a request with no content, | |
| 140 # it hangs forever. | |
| 141 if b'CONTENT_LENGTH' in request.rawenv: | |
| 142 shutil.copyfileobj(request.bodyfh, call.stdin) | |
| 143 | |
| 144 status, headers, rest = _parse_cgi_response(call.stdout) | |
| 145 response.status = status | |
| 146 for k, v in headers.items(): | |
| 147 response.headers[k] = v | |
| 148 | |
| 149 def write_the_rest() -> t.Iterator[bytes]: | |
| 150 with call, rest: | |
| 151 # if it's good enough for shutil it's good enough for me | |
| 152 # technically not in the docs but everybody it | |
| 153 bs = shutil.COPY_BUFSIZE # type: ignore[attr-defined] | |
| 154 while more := rest.read(bs): | |
| 155 yield more | |
| 156 if perm == xp.PUSH: | |
| 157 # If we pushed, we need to import any new refs back into Mercurial. | |
| 158 xp.importing_enter(repo) | |
| 159 try: | |
| 160 gh = repo.githandler | |
| 161 gh.import_git_objects( | |
| 162 b'git-push', remote_names=(), refs=gh.git.refs.as_dict() | |
| 163 ) | |
| 164 finally: | |
| 165 xp.importing_exit(repo) | |
| 166 | |
| 167 response.setbodygen(write_the_rest()) | |
| 168 response.sendresponse() | |
| 169 return True | |
| 170 | |
| 171 | |
| 172 # | |
| 173 # Interfacing with Mercurial | |
| 174 # | |
| 175 | |
| 176 | |
| 177 def uisetup(_: hgui.ui) -> None: | |
| 178 extensions.wrapfunction( | |
| 179 wireprotoserver, 'handlewsgirequest', _handle_git_protocol | |
| 180 ) |
