File
Blob: src/worker/routes/git.ts
| 1 | import type { HeadInfo, Ref } from "@/worker/git"; |
| 2 | import type { CacheContext } from "@/worker/cache/cache"; |
| 3 | |
| 4 | import { |
| 5 | capabilityAdvertisement, |
| 6 | parseV2Command, |
| 7 | pktLine, |
| 8 | flushPkt, |
| 9 | concatChunks, |
| 10 | getHeadAndRefs, |
| 11 | } from "@/worker/git"; |
| 12 | import { loadPeeledTagTargets } from "@/worker/git/object-store"; |
| 13 | import { handleFetchV2Streaming } from "@/worker/git/operations/uploadStream"; |
| 14 | import { handleStreamingReceivePackPOST } from "@/worker/git/receive/streamReceivePack"; |
| 15 | import { asBodyInit, gunzip } from "@/worker/common"; |
| 16 | import { buildCacheKeyFrom, cacheOrLoadJSONForRequest } from "@/worker/cache"; |
| 17 | import { markRequestPrivate, responseCacheControl } from "@/worker/cache/policy"; |
| 18 | import { isValidOwnerRepo } from "@/shared/web"; |
| 19 | import { resolveRepositoryRoute, type RepositoryRoute } from "@/worker/repositories/route"; |
| 20 | import { |
| 21 | authenticateGitRequest, |
| 22 | getBasicCredentials, |
| 23 | scheduleTouchPatLastUsedAt, |
| 24 | type GitAuthResult, |
| 25 | } from "@/worker/auth/gitAuth"; |
| 26 | import type { Db } from "@/worker/db/d1/client"; |
| 27 | import type { Logger } from "@/worker/common/logger"; |
| 28 | import { touchRepositoryUpdatedAt } from "@/worker/db/d1/dal/repositories"; |
| 29 | import { workerExecutionContext, type AppContext, type AppRouter } from "./hono"; |
| 30 | |
| 31 | type GitService = "git-upload-pack" | "git-receive-pack"; |
| 32 | type PatTouchOp = "read" | "write"; |
| 33 | |
| 34 | // Realm string emitted on Basic auth challenges so git CLI prompts the user |
| 35 | // with a recognisable label. |
| 36 | const GIT_BASIC_REALM = 'Basic realm="git", charset="UTF-8"'; |
| 37 | |
| 38 | function basicChallenge(): Response { |
| 39 | return new Response("Authentication required\n", { |
| 40 | status: 401, |
| 41 | headers: { |
| 42 | "Content-Type": "text/plain; charset=utf-8", |
| 43 | "WWW-Authenticate": GIT_BASIC_REALM, |
| 44 | "Cache-Control": "no-store", |
| 45 | }, |
| 46 | }); |
| 47 | } |
| 48 | |
| 49 | function forbidden(message = "Forbidden\n"): Response { |
| 50 | return new Response(message, { |
| 51 | status: 403, |
| 52 | headers: { |
| 53 | "Content-Type": "text/plain; charset=utf-8", |
| 54 | "Cache-Control": "no-store", |
| 55 | }, |
| 56 | }); |
| 57 | } |
| 58 | |
| 59 | function gitNotFound(): Response { |
| 60 | return new Response("Not found\n", { |
| 61 | status: 404, |
| 62 | headers: { |
| 63 | "Content-Type": "text/plain; charset=utf-8", |
| 64 | "Cache-Control": "no-store", |
| 65 | }, |
| 66 | }); |
| 67 | } |
| 68 | |
| 69 | async function decodeUploadPackBody(request: Request): Promise<Uint8Array | Response> { |
| 70 | const rawBody = new Uint8Array(await request.arrayBuffer()); |
| 71 | const contentEncoding = (request.headers.get("Content-Encoding") || "").trim().toLowerCase(); |
| 72 | |
| 73 | if (!contentEncoding || contentEncoding === "identity") { |
| 74 | return rawBody; |
| 75 | } |
| 76 | |
| 77 | if (contentEncoding !== "gzip") { |
| 78 | return new Response(`Unsupported Content-Encoding: ${contentEncoding}\n`, { |
| 79 | status: 415, |
| 80 | headers: { "Content-Type": "text/plain; charset=utf-8" }, |
| 81 | }); |
| 82 | } |
| 83 | |
| 84 | try { |
| 85 | return await gunzip(rawBody); |
| 86 | } catch { |
| 87 | return new Response("Invalid gzip request body\n", { |
| 88 | status: 400, |
| 89 | headers: { "Content-Type": "text/plain; charset=utf-8" }, |
| 90 | }); |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * Handles Git upload-pack (fetch) POST requests. |
| 96 | * Supports both protocol v2 and legacy protocol based on Git-Protocol header. |
| 97 | */ |
| 98 | async function handleUploadPackPOST( |
| 99 | env: Env, |
| 100 | route: RepositoryRoute, |
| 101 | request: Request, |
| 102 | cacheCtx: CacheContext |
| 103 | ) { |
| 104 | const decodedBody = await decodeUploadPackBody(request); |
| 105 | if (decodedBody instanceof Response) return decodedBody; |
| 106 | const body = decodedBody; |
| 107 | const gitProto = request.headers.get("Git-Protocol") || ""; |
| 108 | const { command } = parseV2Command(body); |
| 109 | // Accept either explicit v2 header or a v2-formatted body (contains command=...) |
| 110 | if (!/version=2/.test(gitProto) && !command) { |
| 111 | return new Response("Expected Git protocol v2 (set Git-Protocol: version=2)\n", { |
| 112 | status: 400, |
| 113 | }); |
| 114 | } |
| 115 | |
| 116 | if (command === "ls-refs") { |
| 117 | const loader = async (): Promise<{ head: HeadInfo | undefined; refs: Ref[] } | null> => { |
| 118 | try { |
| 119 | const result = await getHeadAndRefs(env, route.doName, cacheCtx); |
| 120 | return { head: result.head, refs: result.refs }; |
| 121 | } catch { |
| 122 | return null; |
| 123 | } |
| 124 | }; |
| 125 | const cacheKeyRefs = buildCacheKeyFrom(request, "/_cache/refs", { repo: route.doName }); |
| 126 | const refsData = await cacheOrLoadJSONForRequest<{ head: HeadInfo | undefined; refs: Ref[] }>( |
| 127 | cacheCtx, |
| 128 | cacheKeyRefs, |
| 129 | loader, |
| 130 | 60 |
| 131 | ); |
| 132 | const { head, refs } = refsData || { refs: [] }; |
| 133 | |
| 134 | // Parse ls-refs arguments (reuse already-read body to avoid double-read of the stream) |
| 135 | const { args } = parseV2Command(body); |
| 136 | const refPrefixes: string[] = []; |
| 137 | let wantPeel = false; |
| 138 | for (const a of args) { |
| 139 | if (a === "peel") wantPeel = true; |
| 140 | else if (a.startsWith("ref-prefix ")) refPrefixes.push(a.slice("ref-prefix ".length)); |
| 141 | } |
| 142 | |
| 143 | let filteredRefs = refs; |
| 144 | if (refPrefixes.length > 0) { |
| 145 | filteredRefs = refs.filter((r) => refPrefixes.some((p) => r.name.startsWith(p))); |
| 146 | } |
| 147 | |
| 148 | let peeledByRef = new Map<string, string>(); |
| 149 | if (wantPeel) { |
| 150 | try { |
| 151 | const tagRefs = filteredRefs.filter((r) => r.name.startsWith("refs/tags/")); |
| 152 | if (tagRefs.length > 0) { |
| 153 | peeledByRef = await loadPeeledTagTargets(env, route.doName, tagRefs, cacheCtx); |
| 154 | } |
| 155 | } catch {} |
| 156 | } |
| 157 | |
| 158 | const chunks: Uint8Array[] = []; |
| 159 | if (head && head.target) { |
| 160 | const t = |
| 161 | filteredRefs.find((r) => r.name === head.target) || |
| 162 | refs.find((r) => r.name === head.target); |
| 163 | const headOid = head.oid ?? t?.oid; |
| 164 | const headLineAttrs: string[] = []; |
| 165 | headLineAttrs.push(`symref-target:${head.target}`); |
| 166 | if (headOid) { |
| 167 | const base = [`${headOid} HEAD`, ...headLineAttrs].join(" "); |
| 168 | chunks.push(pktLine(base + "\n")); |
| 169 | } else { |
| 170 | const base = ["unborn HEAD", ...headLineAttrs].join(" "); |
| 171 | chunks.push(pktLine(base + "\n")); |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | for (const r of filteredRefs) { |
| 176 | const attrs: string[] = []; |
| 177 | if (wantPeel) { |
| 178 | const peeled = peeledByRef.get(r.name); |
| 179 | if (peeled) attrs.push(`peeled:${peeled}`); |
| 180 | } |
| 181 | const line = |
| 182 | attrs.length > 0 ? `${r.oid} ${r.name} ${attrs.join(" ")}` : `${r.oid} ${r.name}`; |
| 183 | chunks.push(pktLine(line + "\n")); |
| 184 | } |
| 185 | chunks.push(flushPkt()); |
| 186 | return new Response(asBodyInit(concatChunks(chunks)), { |
| 187 | status: 200, |
| 188 | headers: { |
| 189 | "Content-Type": "application/x-git-upload-pack-result", |
| 190 | "Cache-Control": responseCacheControl(cacheCtx), |
| 191 | }, |
| 192 | }); |
| 193 | } |
| 194 | |
| 195 | if (command === "fetch") { |
| 196 | return handleFetchV2Streaming(env, route.doName, body, request.signal, cacheCtx); |
| 197 | } |
| 198 | |
| 199 | return new Response("Unsupported command or malformed request\n", { status: 400 }); |
| 200 | } |
| 201 | |
| 202 | async function handleReceivePackPOST( |
| 203 | env: Env, |
| 204 | route: RepositoryRoute, |
| 205 | request: Request, |
| 206 | ctx: ExecutionContext, |
| 207 | db: Db, |
| 208 | log: Logger |
| 209 | ) { |
| 210 | return await handleStreamingReceivePackPOST(env, route.doName, request, ctx, { |
| 211 | onRepoStateChanged: async ({ changed }) => { |
| 212 | if (!changed) return; |
| 213 | try { |
| 214 | await touchRepositoryUpdatedAt(db, route.repositoryId, Date.now()); |
| 215 | log.debug("receive:repo-updated-at-touched", { repositoryId: route.repositoryId }); |
| 216 | } catch (error) { |
| 217 | log.warn("receive:repo-updated-at-failed", { |
| 218 | repositoryId: route.repositoryId, |
| 219 | error: String(error), |
| 220 | }); |
| 221 | } |
| 222 | }, |
| 223 | }); |
| 224 | } |
| 225 | |
| 226 | // Validate URL slug shape before any DB/DO/R2 work. Mirrors `repoKey` validity. |
| 227 | function validateRouteSlugs(owner: string, repo: string): boolean { |
| 228 | return isValidOwnerRepo(owner) && isValidOwnerRepo(repo); |
| 229 | } |
| 230 | |
| 231 | function normalizeGitRouteRepoSlug(repo: string): string { |
| 232 | // Git clients append Smart HTTP endpoints to the clone URL. Accept |
| 233 | // clone-style `/repo.git/...` URLs while resolving storage against the |
| 234 | // canonical repository slug. |
| 235 | return repo.endsWith(".git") ? repo.slice(0, -".git".length) : repo; |
| 236 | } |
| 237 | |
| 238 | function gitRequestAllowsD1Fallback(request: Request): boolean { |
| 239 | const credentials = getBasicCredentials(request); |
| 240 | return credentials !== null && credentials.password.length > 0; |
| 241 | } |
| 242 | |
| 243 | async function resolveGitRoute( |
| 244 | c: AppContext, |
| 245 | owner: string, |
| 246 | repo: string |
| 247 | ): Promise<RepositoryRoute | null> { |
| 248 | return await resolveRepositoryRoute(c.env, owner, repo, { |
| 249 | mode: gitRequestAllowsD1Fallback(c.req.raw) ? "allow-d1-fallback" : "route-cache-only", |
| 250 | db: c.var.db, |
| 251 | log: c.var.logFor({ service: "RepoRoute" }), |
| 252 | }); |
| 253 | } |
| 254 | |
| 255 | type ResolveGitRouteResult = |
| 256 | | { kind: "ok"; route: RepositoryRoute } |
| 257 | | { kind: "response"; response: Response }; |
| 258 | |
| 259 | async function resolveGitRouteForRequest( |
| 260 | c: AppContext, |
| 261 | owner: string, |
| 262 | repo: string, |
| 263 | service: GitService, |
| 264 | isDiscovery: boolean |
| 265 | ): Promise<ResolveGitRouteResult> { |
| 266 | const route = await resolveGitRoute(c, owner, repo); |
| 267 | if (route) return { kind: "ok", route }; |
| 268 | |
| 269 | // Git sends the first receive-pack discovery request before it has |
| 270 | // credentials. If the route cache has no candidate yet (or the repo is |
| 271 | // private and intentionally absent from ROUTES), challenge so the client |
| 272 | // can retry with Basic/PAT. A request that already carried a Basic |
| 273 | // password has used D1 fallback and remains a real 404 when unresolved. |
| 274 | if (service === "git-receive-pack" && !gitRequestAllowsD1Fallback(c.req.raw)) { |
| 275 | return { |
| 276 | kind: "response", |
| 277 | response: challengeUnresolvedPushRoute(c, owner, repo, isDiscovery), |
| 278 | }; |
| 279 | } |
| 280 | return { kind: "response", response: gitNotFound() }; |
| 281 | } |
| 282 | |
| 283 | function challengeUnresolvedPushRoute( |
| 284 | c: AppContext, |
| 285 | owner: string, |
| 286 | repo: string, |
| 287 | isDiscovery: boolean |
| 288 | ): Response { |
| 289 | const log = c.var.logFor({ service: "GitAcl" }); |
| 290 | log.info("git-acl:push-route-miss-401-challenge", { |
| 291 | owner, |
| 292 | repo, |
| 293 | discovery: isDiscovery, |
| 294 | }); |
| 295 | return basicChallenge(); |
| 296 | } |
| 297 | |
| 298 | function gateD1FallbackGitAuth( |
| 299 | c: AppContext, |
| 300 | route: RepositoryRoute, |
| 301 | auth: GitAuthResult |
| 302 | ): Response | null { |
| 303 | if (route.source !== "d1") return null; |
| 304 | if (auth.kind === "pat") return null; |
| 305 | const log = c.var.logFor({ service: "GitAcl", repoId: route.doName }); |
| 306 | if (auth.kind === "pat-rejected" && auth.reason === "grant-missing") { |
| 307 | log.info("git-acl:d1-fallback-grant-missing", { reason: auth.reason }); |
| 308 | return forbidden(); |
| 309 | } |
| 310 | log.info("git-acl:d1-fallback-unauthorized", { reason: auth.kind }); |
| 311 | return basicChallenge(); |
| 312 | } |
| 313 | |
| 314 | // Decide whether a Git read (info-refs upload-pack, git-upload-pack) is |
| 315 | // allowed for the resolved route given the authenticated principal. Returns |
| 316 | // `null` when allowed, otherwise the response to send. |
| 317 | function gateGitRead(c: AppContext, route: RepositoryRoute, auth: GitAuthResult): Response | null { |
| 318 | if (route.visibility === "public") return null; |
| 319 | const log = c.var.logFor({ service: "GitAcl", repoId: route.doName }); |
| 320 | switch (auth.kind) { |
| 321 | case "anonymous": |
| 322 | // Discovery hop on private upload-pack: 404 to avoid leaking existence. |
| 323 | log.info("git-acl:private-404", { reason: "anonymous-read" }); |
| 324 | return gitNotFound(); |
| 325 | case "missing-credentials": |
| 326 | log.info("git-acl:private-401-challenge", { reason: "missing-credentials" }); |
| 327 | return basicChallenge(); |
| 328 | case "pat-rejected": |
| 329 | // Any PAT failure on a read is reported as 401 so the client can retry |
| 330 | // with fresh creds, EXCEPT grant-missing which is 403 (the user has |
| 331 | // proven their identity but lacks access). |
| 332 | if (auth.reason === "grant-missing") { |
| 333 | log.info("git-acl:pat-rejected", { reason: auth.reason }); |
| 334 | return forbidden(); |
| 335 | } |
| 336 | log.info("git-acl:pat-rejected", { reason: auth.reason }); |
| 337 | return basicChallenge(); |
| 338 | case "pat": |
| 339 | return null; |
| 340 | } |
| 341 | } |
| 342 | |
| 343 | // Push (receive-pack) requires a PAT with `level === "push"` regardless of |
| 344 | // repo visibility. Public repos do NOT fall through to anonymous push: the |
| 345 | // resolved D1 row is the only authority, and it has no per-repo "anyone can |
| 346 | // push" toggle. Treat any non-PAT-push principal as an auth challenge or |
| 347 | // 403 by reason. |
| 348 | async function gateGitPush( |
| 349 | c: AppContext, |
| 350 | route: RepositoryRoute, |
| 351 | auth: GitAuthResult, |
| 352 | isDiscovery: boolean |
| 353 | ): Promise<Response | null> { |
| 354 | const log = c.var.logFor({ service: "GitAcl", repoId: route.doName }); |
| 355 | if (auth.kind === "pat") { |
| 356 | if (auth.verified.level !== "push") { |
| 357 | log.info("git-acl:push-pull-only", { patId: auth.verified.patId }); |
| 358 | return forbidden(); |
| 359 | } |
| 360 | return null; |
| 361 | } |
| 362 | if (auth.kind === "pat-rejected") { |
| 363 | if (auth.reason === "grant-missing") { |
| 364 | log.info("git-acl:pat-rejected", { reason: auth.reason }); |
| 365 | return forbidden(); |
| 366 | } |
| 367 | log.info("git-acl:pat-rejected", { reason: auth.reason }); |
| 368 | return basicChallenge(); |
| 369 | } |
| 370 | // anonymous | missing-credentials -> 401 challenge so the git client |
| 371 | // re-issues with Basic credentials. |
| 372 | log.info("git-acl:push-401-challenge", { |
| 373 | reason: auth.kind === "anonymous" ? "anonymous" : "missing-credentials", |
| 374 | discovery: isDiscovery, |
| 375 | visibility: route.visibility, |
| 376 | }); |
| 377 | return basicChallenge(); |
| 378 | } |
| 379 | |
| 380 | type GitAuthorizationResult = |
| 381 | | { kind: "ok"; cacheCtx: CacheContext } |
| 382 | | { kind: "response"; response: Response }; |
| 383 | |
| 384 | async function authorizeGitRouteForRequest( |
| 385 | c: AppContext, |
| 386 | route: RepositoryRoute, |
| 387 | service: GitService, |
| 388 | isDiscovery: boolean, |
| 389 | patTouchOp: PatTouchOp |
| 390 | ): Promise<GitAuthorizationResult> { |
| 391 | const cacheCtx = c.var.cacheCtx; |
| 392 | if (route.visibility === "private" || service === "git-receive-pack") { |
| 393 | markRequestPrivate(cacheCtx); |
| 394 | } |
| 395 | |
| 396 | const auth = await authenticateGitRequest(c.env, c.req.raw, route, { db: c.var.db }); |
| 397 | const fallbackBlocked = gateD1FallbackGitAuth(c, route, auth); |
| 398 | if (fallbackBlocked) return { kind: "response", response: fallbackBlocked }; |
| 399 | |
| 400 | const blocked = |
| 401 | service === "git-receive-pack" |
| 402 | ? await gateGitPush(c, route, auth, isDiscovery) |
| 403 | : gateGitRead(c, route, auth); |
| 404 | if (blocked) return { kind: "response", response: blocked }; |
| 405 | |
| 406 | if (auth.kind === "pat") { |
| 407 | // PAT `last_used_at` is a visibility signal for token management only; |
| 408 | // it is throttled for reads and always attempted for writes. |
| 409 | scheduleTouchPatLastUsedAt( |
| 410 | c.env, |
| 411 | workerExecutionContext(c), |
| 412 | auth.verified, |
| 413 | patTouchOp, |
| 414 | Date.now(), |
| 415 | { |
| 416 | db: c.var.db, |
| 417 | log: c.var.logFor({ service: "GitAuth" }), |
| 418 | } |
| 419 | ); |
| 420 | } |
| 421 | |
| 422 | return { kind: "ok", cacheCtx }; |
| 423 | } |
| 424 | |
| 425 | /** |
| 426 | * Registers Git Smart HTTP v2 routes on the router. |
| 427 | */ |
| 428 | export function registerGitRoutes(router: AppRouter) { |
| 429 | router.get(`/:owner/:repo/info/refs`, async (c) => { |
| 430 | const owner = c.req.param("owner"); |
| 431 | const repo = normalizeGitRouteRepoSlug(c.req.param("repo")); |
| 432 | if (!validateRouteSlugs(owner, repo)) return gitNotFound(); |
| 433 | const url = new URL(c.req.url); |
| 434 | const service = url.searchParams.get("service"); |
| 435 | if (service !== "git-upload-pack" && service !== "git-receive-pack") { |
| 436 | return new Response("Missing or unsupported service\n", { status: 400 }); |
| 437 | } |
| 438 | const resolved = await resolveGitRouteForRequest(c, owner, repo, service, true); |
| 439 | if (resolved.kind === "response") return resolved.response; |
| 440 | const route = resolved.route; |
| 441 | const authorized = await authorizeGitRouteForRequest(c, route, service, true, "read"); |
| 442 | if (authorized.kind === "response") return authorized.response; |
| 443 | const { cacheCtx } = authorized; |
| 444 | return await capabilityAdvertisement(c.env, service, route.doName, cacheCtx); |
| 445 | }); |
| 446 | |
| 447 | router.post(`/:owner/:repo/git-upload-pack`, async (c) => { |
| 448 | const owner = c.req.param("owner"); |
| 449 | const repo = normalizeGitRouteRepoSlug(c.req.param("repo")); |
| 450 | if (!validateRouteSlugs(owner, repo)) return gitNotFound(); |
| 451 | const resolved = await resolveGitRouteForRequest(c, owner, repo, "git-upload-pack", false); |
| 452 | if (resolved.kind === "response") return resolved.response; |
| 453 | const route = resolved.route; |
| 454 | const authorized = await authorizeGitRouteForRequest( |
| 455 | c, |
| 456 | route, |
| 457 | "git-upload-pack", |
| 458 | false, |
| 459 | "read" |
| 460 | ); |
| 461 | if (authorized.kind === "response") return authorized.response; |
| 462 | return handleUploadPackPOST(c.env, route, c.req.raw, authorized.cacheCtx); |
| 463 | }); |
| 464 | |
| 465 | router.post(`/:owner/:repo/git-receive-pack`, async (c) => { |
| 466 | const owner = c.req.param("owner"); |
| 467 | const repo = normalizeGitRouteRepoSlug(c.req.param("repo")); |
| 468 | if (!validateRouteSlugs(owner, repo)) return gitNotFound(); |
| 469 | const resolved = await resolveGitRouteForRequest(c, owner, repo, "git-receive-pack", false); |
| 470 | if (resolved.kind === "response") return resolved.response; |
| 471 | const route = resolved.route; |
| 472 | const authorized = await authorizeGitRouteForRequest( |
| 473 | c, |
| 474 | route, |
| 475 | "git-receive-pack", |
| 476 | false, |
| 477 | "write" |
| 478 | ); |
| 479 | if (authorized.kind === "response") return authorized.response; |
| 480 | return await handleReceivePackPOST( |
| 481 | c.env, |
| 482 | route, |
| 483 | c.req.raw, |
| 484 | workerExecutionContext(c), |
| 485 | c.var.db, |
| 486 | c.var.logFor({ service: "ReceiveAcl", repoId: route.doName }) |
| 487 | ); |
| 488 | }); |
| 489 | } |