Viewing files at commit 39bc21f7f67eCommit detailsView latest

scripts/oakbench/gitserver.py 213 lines · 8.0 KB · python
1
"""Stdlib smart-HTTP git server: ThreadingHTTPServer CGI-execing `git http-backend`.
2
3
This is the git side of the netshape lane's "both subjects served from local
4
disk through the same shaped pipe" promise — and unlike the shaped pipe it
5
runs everywhere, including the macOS dev host, so it is tested for real
6
(clone and push over HTTP, not mocks).
7
8
Contract:
9
- GET/POST are forwarded to `git http-backend` with the CGI environment it
10
  documents: GIT_PROJECT_ROOT, GIT_HTTP_EXPORT_ALL=1, PATH_INFO,
11
  QUERY_STRING, REQUEST_METHOD, CONTENT_TYPE, CONTENT_LENGTH, request body on
12
  stdin. REMOTE_USER is explicitly unset: this server is anonymous, pushes
13
  are allowed only when the served repo sets http.receivepack=true.
14
- Protocol v2 passes through: the client's Git-Protocol header is forwarded
15
  as HTTP_GIT_PROTOCOL (http-backend hands it to upload-pack as
16
  GIT_PROTOCOL). Observed header values are recorded on the server object so
17
  tests can assert v2 actually traversed the server.
18
- request_log records "METHOD /path?query" for every request, so lanes and
19
  tests can assert the smart paths (/info/refs?service=...) were exercised.
20
"""
21
22
from __future__ import annotations
23
24
import os
25
import subprocess
26
import threading
27
import urllib.parse
28
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
29
from pathlib import Path
30
from typing import Optional
31
32
33
class _GitHTTPRequestHandler(BaseHTTPRequestHandler):
34
    protocol_version = "HTTP/1.1"
35
    server_version = "oakbench-gitserver/1"
36
37
    def do_GET(self) -> None:  # noqa: N802 - BaseHTTPRequestHandler API
38
        self._backend()
39
40
    def do_POST(self) -> None:  # noqa: N802 - BaseHTTPRequestHandler API
41
        self._backend()
42
43
    def log_message(self, format: str, *args) -> None:  # noqa: A002 - stdlib signature
44
        # request_log is the observable record; stderr noise is not.
45
        pass
46
47
    def _read_body(self) -> bytes:
48
        if self.command != "POST":
49
            return b""
50
        transfer_encoding = (self.headers.get("Transfer-Encoding") or "").lower()
51
        if "chunked" in transfer_encoding:
52
            # git uses chunked uploads past http.postBuffer; decode so a plain
53
            # CONTENT_LENGTH body reaches http-backend.
54
            chunks: list[bytes] = []
55
            while True:
56
                size_line = self.rfile.readline().strip()
57
                size = int(size_line.split(b";", 1)[0], 16)
58
                if size == 0:
59
                    self.rfile.readline()  # trailing CRLF after last chunk
60
                    break
61
                chunks.append(self.rfile.read(size))
62
                self.rfile.read(2)  # CRLF after each chunk
63
            return b"".join(chunks)
64
        length = int(self.headers.get("Content-Length") or 0)
65
        return self.rfile.read(length) if length > 0 else b""
66
67
    def _backend(self) -> None:
68
        owner: "GitHTTPServer" = self.server.owner  # type: ignore[attr-defined]
69
        path, _, query = self.path.partition("?")
70
        owner._record_request(f"{self.command} {self.path}")
71
        git_protocol = self.headers.get("Git-Protocol")
72
        if git_protocol:
73
            owner._record_protocol(git_protocol)
74
75
        body = self._read_body()
76
        env = os.environ.copy()
77
        env.pop("REMOTE_USER", None)  # anonymous server; never an auth identity
78
        env.update(
79
            {
80
                "GIT_PROJECT_ROOT": str(owner.root),
81
                "GIT_HTTP_EXPORT_ALL": "1",
82
                "PATH_INFO": urllib.parse.unquote(path),
83
                "QUERY_STRING": query,
84
                "REQUEST_METHOD": self.command,
85
                "REMOTE_ADDR": self.client_address[0],
86
            }
87
        )
88
        if self.command == "POST":
89
            env["CONTENT_LENGTH"] = str(len(body))
90
        content_type = self.headers.get("Content-Type")
91
        if content_type:
92
            env["CONTENT_TYPE"] = content_type
93
        content_encoding = self.headers.get("Content-Encoding")
94
        if content_encoding:
95
            # http-backend inflates gzip request bodies itself.
96
            env["HTTP_CONTENT_ENCODING"] = content_encoding
97
        if git_protocol:
98
            env["HTTP_GIT_PROTOCOL"] = git_protocol  # protocol v2 passthrough
99
100
        try:
101
            proc = subprocess.run(
102
                [str(owner.git_bin), "http-backend"],
103
                input=body,
104
                env=env,
105
                stdout=subprocess.PIPE,
106
                stderr=subprocess.PIPE,
107
            )
108
        except OSError as exc:
109
            self._send_error_response(500, f"git http-backend failed to start: {exc}")
110
            return
111
        head, sep, payload = proc.stdout.partition(b"\r\n\r\n")
112
        if not sep:
113
            head, sep, payload = proc.stdout.partition(b"\n\n")
114
        if not sep:
115
            detail = proc.stderr.decode("utf-8", "replace")[-2000:]
116
            self._send_error_response(500, f"malformed CGI response from git http-backend: {detail}")
117
            return
118
119
        status = 200
120
        headers: list[tuple[str, str]] = []
121
        for line in head.decode("iso-8859-1").splitlines():
122
            if ":" not in line:
123
                continue
124
            key, value = line.split(":", 1)
125
            key, value = key.strip(), value.strip()
126
            if key.lower() == "status":
127
                status = int(value.split(None, 1)[0])
128
            else:
129
                headers.append((key, value))
130
131
        self.send_response(status)
132
        for key, value in headers:
133
            self.send_header(key, value)
134
        self.send_header("Content-Length", str(len(payload)))
135
        self.end_headers()
136
        self.wfile.write(payload)
137
138
    def _send_error_response(self, status: int, message: str) -> None:
139
        body = message.encode("utf-8", "replace")
140
        self.send_response(status)
141
        self.send_header("Content-Type", "text/plain; charset=utf-8")
142
        self.send_header("Content-Length", str(len(body)))
143
        self.end_headers()
144
        self.wfile.write(body)
145
146
147
class GitHTTPServer:
148
    """Serve every bare repo under `root` over smart HTTP at /<name>.git.
149
150
    port=0 binds an ephemeral port (read it back from `url`). Pushes work only
151
    on repos configured with http.receivepack=true; that is the served repo's
152
    choice, not this server's.
153
    """
154
155
    def __init__(self, root: Path, port: int = 0, *, host: str = "127.0.0.1", git_bin: str = "git") -> None:
156
        self.root = Path(root)
157
        self.host = host
158
        self.port = port
159
        self.git_bin = git_bin
160
        self.request_log: list[str] = []
161
        self.git_protocol_headers: list[str] = []
162
        self._lock = threading.Lock()
163
        self._httpd: Optional[ThreadingHTTPServer] = None
164
        self._thread: Optional[threading.Thread] = None
165
166
    def _record_request(self, entry: str) -> None:
167
        with self._lock:
168
            self.request_log.append(entry)
169
170
    def _record_protocol(self, value: str) -> None:
171
        with self._lock:
172
            self.git_protocol_headers.append(value)
173
174
    @property
175
    def server_port(self) -> int:
176
        if self._httpd is None:
177
            raise RuntimeError("server is not started")
178
        return int(self._httpd.server_address[1])
179
180
    @property
181
    def url(self) -> str:
182
        return f"http://{self.host}:{self.server_port}"
183
184
    def start(self) -> "GitHTTPServer":
185
        if self._httpd is not None:
186
            return self
187
        httpd = ThreadingHTTPServer((self.host, self.port), _GitHTTPRequestHandler)
188
        httpd.owner = self  # type: ignore[attr-defined]
189
        httpd.daemon_threads = True
190
        self._httpd = httpd
191
        self._thread = threading.Thread(
192
            target=httpd.serve_forever,
193
            name="oakbench-gitserver",
194
            daemon=True,
195
        )
196
        self._thread.start()
197
        return self
198
199
    def stop(self) -> None:
200
        if self._httpd is None:
201
            return
202
        self._httpd.shutdown()
203
        self._httpd.server_close()
204
        if self._thread is not None:
205
            self._thread.join(timeout=5.0)
206
        self._httpd = None
207
        self._thread = None
208
209
    def __enter__(self) -> "GitHTTPServer":
210
        return self.start()
211
212
    def __exit__(self, exc_type, exc, tb) -> None:  # type: ignore[no-untyped-def]
213
        self.stop()