File
Blob: src/worker/git/core/protocol.ts
| 1 | import type { CacheContext } from "@/worker/cache"; |
| 2 | |
| 3 | import { pktLine, flushPkt, concatChunks, decodePktLines } from "./pktline"; |
| 4 | import { asBodyInit } from "@/worker/common/webtypes"; |
| 5 | import { getRepoStub } from "@/worker/common/stub"; |
| 6 | import { responseCacheControl } from "@/worker/cache/policy"; |
| 7 | import { getLimiter } from "@/worker/git/operations/limits"; |
| 8 | |
| 9 | /** |
| 10 | * Generates a Git capability advertisement response. |
| 11 | * Lists all refs and capabilities for the requested service. |
| 12 | * @param env - Worker environment |
| 13 | * @param service - Git service (git-upload-pack or git-receive-pack) |
| 14 | * @param repoId - Repository identifier |
| 15 | * @param cacheCtx - Optional request CacheContext used to share the per-request |
| 16 | * subrequest limiter for the underlying DO RPC. |
| 17 | * @returns HTTP response with capability advertisement |
| 18 | */ |
| 19 | export async function capabilityAdvertisement( |
| 20 | env: Env, |
| 21 | service: "git-upload-pack" | "git-receive-pack", |
| 22 | repoId?: string, |
| 23 | cacheCtx?: CacheContext |
| 24 | ) { |
| 25 | if (service === "git-upload-pack") { |
| 26 | const chunks: Uint8Array[] = []; |
| 27 | chunks.push(pktLine("version 2\n")); |
| 28 | chunks.push(pktLine(`agent=git-on-cloudflare/0.1\n`)); |
| 29 | chunks.push(pktLine("ls-refs\n")); |
| 30 | // Advertise fetch and supported features |
| 31 | chunks.push(pktLine("fetch\n")); |
| 32 | // We stream pack data over sideband; advertise side-band-64k for client awareness |
| 33 | chunks.push(pktLine("side-band-64k\n")); |
| 34 | chunks.push(pktLine("ofs-delta\n")); |
| 35 | chunks.push(pktLine(`object-format=sha1\n`)); |
| 36 | chunks.push(flushPkt()); |
| 37 | return new Response(asBodyInit(concatChunks(chunks)), { |
| 38 | status: 200, |
| 39 | headers: { |
| 40 | "Content-Type": "application/x-git-upload-pack-advertisement", |
| 41 | "Cache-Control": responseCacheControl(cacheCtx), |
| 42 | }, |
| 43 | }); |
| 44 | } |
| 45 | |
| 46 | // git-receive-pack uses v0-style advertisement of refs with capabilities on first line. |
| 47 | // Query the repo DO for current refs so clients include the correct old OID, |
| 48 | // enabling non-fast-forward (force) pushes when desired. |
| 49 | let refs: { name: string; oid: string }[] = []; |
| 50 | if (repoId) { |
| 51 | try { |
| 52 | const stub = getRepoStub(env, repoId); |
| 53 | const limiter = getLimiter(cacheCtx); |
| 54 | const data = await limiter.run("do:caps-list-refs", () => stub.listRefs()); |
| 55 | if (Array.isArray(data)) refs = data as { name: string; oid: string }[]; |
| 56 | } catch {} |
| 57 | } |
| 58 | const caps = [ |
| 59 | "report-status", |
| 60 | "delete-refs", |
| 61 | "side-band-64k", |
| 62 | "quiet", |
| 63 | "atomic", |
| 64 | "ofs-delta", |
| 65 | `agent=git-on-cloudflare/0.1`, |
| 66 | ].join(" "); |
| 67 | |
| 68 | const lines: Uint8Array[] = []; |
| 69 | // Smart HTTP service prelude then flush |
| 70 | lines.push(pktLine(`# service=git-receive-pack\n`)); |
| 71 | lines.push(flushPkt()); |
| 72 | // Only advertise real refs (no HEAD entry). Put caps on the first ref line. |
| 73 | if (refs.length > 0) { |
| 74 | for (let i = 0; i < refs.length; i++) { |
| 75 | const r = refs[i]; |
| 76 | if (i === 0) lines.push(pktLine(`${r.oid} ${r.name}\0${caps}\n`)); |
| 77 | else lines.push(pktLine(`${r.oid} ${r.name}\n`)); |
| 78 | } |
| 79 | } else { |
| 80 | // Empty repo: advertise capabilities using the pseudo-ref 'capabilities^{}' |
| 81 | lines.push(pktLine(`0000000000000000000000000000000000000000 capabilities^{}\0${caps}\n`)); |
| 82 | } |
| 83 | lines.push(flushPkt()); |
| 84 | return new Response(asBodyInit(concatChunks(lines)), { |
| 85 | status: 200, |
| 86 | headers: { |
| 87 | "Content-Type": "application/x-git-receive-pack-advertisement", |
| 88 | // Receive-pack is credentialed and only reached after PAT auth; never |
| 89 | // allow shared caches to retain capability advertisements regardless |
| 90 | // of repo visibility. |
| 91 | "Cache-Control": responseCacheControl(cacheCtx, { mutating: true }), |
| 92 | }, |
| 93 | }); |
| 94 | } |
| 95 | |
| 96 | /** |
| 97 | * Parses a Git protocol v2 command from the request body. |
| 98 | * Extracts the command from pkt-line formatted data. |
| 99 | * @param body - Raw request body |
| 100 | * @returns Object containing the parsed command, if present |
| 101 | */ |
| 102 | export function parseV2Command(body: Uint8Array): { command: string; args: string[] } { |
| 103 | const items = decodePktLines(body); |
| 104 | let command = ""; |
| 105 | const args: string[] = []; |
| 106 | if (items.length > 0) { |
| 107 | let beforeDelim = true; |
| 108 | for (const it of items) { |
| 109 | if (it.type === "delim") { |
| 110 | beforeDelim = false; |
| 111 | continue; |
| 112 | } |
| 113 | if (it.type !== "line") continue; |
| 114 | const text = it.text.replace(/\r?\n$/, ""); |
| 115 | if (beforeDelim) { |
| 116 | if (text.startsWith("command=")) { |
| 117 | command = text.slice("command=".length); |
| 118 | } |
| 119 | } else { |
| 120 | args.push(text); |
| 121 | } |
| 122 | } |
| 123 | } |
| 124 | if (!command) { |
| 125 | const text = new TextDecoder().decode(body); |
| 126 | const m = text.match(/command=([a-z-]+)/); |
| 127 | command = m ? m[1] : ""; |
| 128 | } |
| 129 | return { command, args }; |
| 130 | } |