| 1 | /** |
| 2 | * Protocol-v0 ref advertisement builders (DESIGN §2.3, §4). |
| 3 | * |
| 4 | * The smart-HTTP advertisement body is two pkt-line streams: |
| 5 | * |
| 6 | * ``` |
| 7 | * 001e# service=git-upload-pack\n |
| 8 | * 0000 |
| 9 | * <oid> HEAD\0<capabilities>\n ← first ref carries caps after a NUL |
| 10 | * <oid> refs/heads/main\n |
| 11 | * <oid> refs/tags/v1\n |
| 12 | * <peeled> refs/tags/v1^{}\n ← annotated tags peeled |
| 13 | * 0000 |
| 14 | * ``` |
| 15 | * |
| 16 | * Empty repo: a single `<zero-id> capabilities^{}\0<caps>` line. The response |
| 17 | * must carry `Content-Type: application/x-git-{service}-advertisement` and |
| 18 | * `Cache-Control: no-cache` (the client validates the first five bytes match |
| 19 | * `^[0-9a-f]{4}#`). |
| 20 | */ |
| 21 | import { concatBytes, utf8Encode, ZERO_OID } from "./ObjectCodec.ts"; |
| 22 | import { flushPkt, pktLine } from "./Pkt.ts"; |
| 23 | import type { RefRecord, RefsSnapshot } from "./Store.ts"; |
| 24 | |
| 25 | /** The agent string advertised by both services. */ |
| 26 | export const AGENT = "git-service/1"; |
| 27 | |
| 28 | /** The two smart-HTTP services. */ |
| 29 | export type GitService = "git-upload-pack" | "git-receive-pack"; |
| 30 | |
| 31 | /** |
| 32 | * The v0 upload-pack capability set (DESIGN §2.3): stateless |
| 33 | * `multi_ack_detailed` + `no-done` negotiation, side-band-64k muxing, shallow |
| 34 | * (`deepen <n>` only), and the default-branch symref. |
| 35 | */ |
| 36 | export const uploadPackCapabilities = (defaultBranch: string): string => |
| 37 | `multi_ack_detailed no-done side-band-64k shallow ofs-delta agent=${AGENT} symref=HEAD:refs/heads/${defaultBranch} object-format=sha1`; |
| 38 | |
| 39 | /** |
| 40 | * The v0 receive-pack capability set (DESIGN §2.3): per-ref report-status |
| 41 | * (v1 and v2 — v2 is implemented identically, with no `option` lines, which |
| 42 | * is valid), ref deletion, atomic pushes, and OFS_DELTA in pushed packs. |
| 43 | */ |
| 44 | export const receivePackCapabilities = (): string => |
| 45 | `report-status report-status-v2 delete-refs side-band-64k atomic ofs-delta object-format=sha1 agent=${AGENT}`; |
| 46 | |
| 47 | /** |
| 48 | * The `Content-Type` of a `GET info/refs?service=` advertisement response. |
| 49 | */ |
| 50 | export const advertisementContentType = (service: GitService): string => |
| 51 | `application/x-${service}-advertisement`; |
| 52 | |
| 53 | /** |
| 54 | * The `Content-Type` of a `POST /git-{upload,receive}-pack` result response. |
| 55 | */ |
| 56 | export const resultContentType = (service: GitService): string => |
| 57 | `application/x-${service}-result`; |
| 58 | |
| 59 | /** |
| 60 | * Builds the `# service=` prelude: one pkt-line plus a flush. Sent before the |
| 61 | * ref list on `info/refs` for both services. |
| 62 | */ |
| 63 | export const servicePrelude = (service: GitService): Uint8Array => |
| 64 | concatBytes([pktLine(utf8Encode(`# service=${service}\n`)), flushPkt]); |
| 65 | |
| 66 | /** One advertised `<oid> <name>` record (already flattened, incl. `^{}`). */ |
| 67 | interface AdvertisedRecord { |
| 68 | readonly oid: string; |
| 69 | readonly name: string; |
| 70 | } |
| 71 | |
| 72 | const flattenRefs = ( |
| 73 | refs: ReadonlyArray<RefRecord>, |
| 74 | ): Array<AdvertisedRecord> => { |
| 75 | const sorted = [...refs].sort((a, b) => |
| 76 | a.name < b.name ? -1 : a.name > b.name ? 1 : 0, |
| 77 | ); |
| 78 | const records: Array<AdvertisedRecord> = []; |
| 79 | for (const ref of sorted) { |
| 80 | records.push({ oid: ref.oid, name: ref.name }); |
| 81 | if (ref.peeled !== undefined) { |
| 82 | records.push({ oid: ref.peeled, name: `${ref.name}^{}` }); |
| 83 | } |
| 84 | } |
| 85 | return records; |
| 86 | }; |
| 87 | |
| 88 | /** |
| 89 | * Encodes the ref list section: first record carries the capability list |
| 90 | * after a NUL; an empty list becomes the zero-id `capabilities^{}` line; |
| 91 | * terminated by a flush. |
| 92 | */ |
| 93 | const refListSection = ( |
| 94 | records: ReadonlyArray<AdvertisedRecord>, |
| 95 | capabilities: string, |
| 96 | ): Uint8Array => { |
| 97 | const lines: Array<Uint8Array> = []; |
| 98 | if (records.length === 0) { |
| 99 | lines.push( |
| 100 | pktLine(utf8Encode(`${ZERO_OID} capabilities^{}\0${capabilities}\n`)), |
| 101 | ); |
| 102 | } else { |
| 103 | const first = records[0]!; |
| 104 | lines.push( |
| 105 | pktLine(utf8Encode(`${first.oid} ${first.name}\0${capabilities}\n`)), |
| 106 | ); |
| 107 | for (let i = 1; i < records.length; i++) { |
| 108 | const record = records[i]!; |
| 109 | lines.push(pktLine(utf8Encode(`${record.oid} ${record.name}\n`))); |
| 110 | } |
| 111 | } |
| 112 | lines.push(flushPkt); |
| 113 | return concatBytes(lines); |
| 114 | }; |
| 115 | |
| 116 | /** |
| 117 | * Builds the complete `info/refs?service=git-upload-pack` advertisement body: |
| 118 | * prelude, then `HEAD` first (its oid = the default branch tip, when that |
| 119 | * branch exists) with the capability line, then refs sorted by name with |
| 120 | * peeled `^{}` lines for annotated tags. An empty repo advertises the |
| 121 | * zero-id `capabilities^{}` form. |
| 122 | */ |
| 123 | export const uploadPackAdvertisement = (snapshot: RefsSnapshot): Uint8Array => { |
| 124 | const records = flattenRefs(snapshot.refs); |
| 125 | const head = snapshot.refs.find( |
| 126 | (ref) => ref.name === `refs/heads/${snapshot.defaultBranch}`, |
| 127 | ); |
| 128 | const withHead = |
| 129 | head === undefined |
| 130 | ? records |
| 131 | : [{ oid: head.oid, name: "HEAD" }, ...records]; |
| 132 | return concatBytes([ |
| 133 | servicePrelude("git-upload-pack"), |
| 134 | refListSection(withHead, uploadPackCapabilities(snapshot.defaultBranch)), |
| 135 | ]); |
| 136 | }; |
| 137 | |
| 138 | /** |
| 139 | * Builds the complete `info/refs?service=git-receive-pack` advertisement |
| 140 | * body. `HEAD` is not advertised (it is not pushable); refs are sorted with |
| 141 | * peeled lines; an empty repo advertises the zero-id `capabilities^{}` form |
| 142 | * (which is how the first-ever push learns the capability set). |
| 143 | */ |
| 144 | export const receivePackAdvertisement = (snapshot: RefsSnapshot): Uint8Array => |
| 145 | concatBytes([ |
| 146 | servicePrelude("git-receive-pack"), |
| 147 | refListSection(flattenRefs(snapshot.refs), receivePackCapabilities()), |
| 148 | ]); |
| 149 | |
| 150 | /** |
| 151 | * Builds the advertisement for either service. |
| 152 | */ |
| 153 | export const advertisement = ( |
| 154 | service: GitService, |
| 155 | snapshot: RefsSnapshot, |
| 156 | ): Uint8Array => |
| 157 | service === "git-upload-pack" |
| 158 | ? uploadPackAdvertisement(snapshot) |
| 159 | : receivePackAdvertisement(snapshot); |