Skip to content
File

Blob: src/worker/git/core/protocol.ts

typescript131 lines
1import type { CacheContext } from "@/worker/cache";
2 
3import { pktLine, flushPkt, concatChunks, decodePktLines } from "./pktline";
4import { asBodyInit } from "@/worker/common/webtypes";
5import { getRepoStub } from "@/worker/common/stub";
6import { responseCacheControl } from "@/worker/cache/policy";
7import { 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 */
19export 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 */
102export 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}