| 1 | /** |
| 2 | * Protocol-v0 push choreography (DESIGN §2.3, §3.6, §4) as pure functions. |
| 3 | * |
| 4 | * Request shape (`POST /git-receive-pack`): |
| 5 | * |
| 6 | * ``` |
| 7 | * *PKT-LINE("shallow" SP obj-id) ; shallow clients |
| 8 | * PKT-LINE(old-oid SP new-oid SP refname NUL cap-list LF) ; first command |
| 9 | * *PKT-LINE(old-oid SP new-oid SP refname LF) |
| 10 | * flush-pkt |
| 11 | * [raw PACK bytes] ; NOT pkt-line framed |
| 12 | * ``` |
| 13 | * |
| 14 | * The pack follows the flush as raw bytes and is present iff any |
| 15 | * create/update command exists (it may be an empty 0-object pack); it is |
| 16 | * absent for delete-only pushes. Clients push **thin packs** over HTTP by |
| 17 | * default — REF_DELTA bases resolve from the object store during ingest. |
| 18 | * |
| 19 | * The **empty-flush probe**: git sends a bare `0000` POST when the payload |
| 20 | * exceeds `http.postBuffer`; the server replies with an empty 200 so the |
| 21 | * client retries with the real body. |
| 22 | * |
| 23 | * The choreography splits so the Repo DO owns the transaction: |
| 24 | * {@link parseReceivePackRequest} → {@link ingestPushPack} (staging sink) → |
| 25 | * {@link checkConnectivity} → DO `transactionSync` CAS → |
| 26 | * {@link reportStatus}. |
| 27 | */ |
| 28 | import * as Effect from "effect/Effect"; |
| 29 | import { |
| 30 | concatBytes, |
| 31 | isOid, |
| 32 | ObjectParseError, |
| 33 | type ObjectType, |
| 34 | type Oid, |
| 35 | parseCommit, |
| 36 | parseTag, |
| 37 | parseTree, |
| 38 | treeEntryKind, |
| 39 | ZERO_OID, |
| 40 | } from "./ObjectCodec.ts"; |
| 41 | import { |
| 42 | bufferRandomAccess, |
| 43 | ingestPack, |
| 44 | type IngestSummary, |
| 45 | type PackIngestError, |
| 46 | type ResolvedEntry, |
| 47 | type ThinBaseSource, |
| 48 | } from "./PackParser.ts"; |
| 49 | import { |
| 50 | flushPkt, |
| 51 | PktLineError, |
| 52 | pktPayloadText, |
| 53 | pktText, |
| 54 | ProtocolError, |
| 55 | readPktLineAt, |
| 56 | } from "./Pkt.ts"; |
| 57 | import { sidebandFrames } from "./Sideband.ts"; |
| 58 | import { StoreError } from "./Store.ts"; |
| 59 | |
| 60 | // ───────────────────────────────────────────────────────────────────────────── |
| 61 | // Commands |
| 62 | // ───────────────────────────────────────────────────────────────────────────── |
| 63 | |
| 64 | /** |
| 65 | * One ref update command: `old-oid SP new-oid SP refname`. `old-oid` is the |
| 66 | * client's expectation and the CAS token; zero-id old ⇒ create, zero-id new |
| 67 | * ⇒ delete. |
| 68 | */ |
| 69 | export interface RefCommand { |
| 70 | readonly oldOid: Oid; |
| 71 | readonly newOid: Oid; |
| 72 | readonly ref: string; |
| 73 | } |
| 74 | |
| 75 | /** The kind of a ref command derived from its zero-id sides. */ |
| 76 | export type CommandKind = "create" | "update" | "delete"; |
| 77 | |
| 78 | /** |
| 79 | * Classifies a command: zero old-oid ⇒ `create`, zero new-oid ⇒ `delete`, |
| 80 | * else `update`. |
| 81 | */ |
| 82 | export const commandKind = (command: RefCommand): CommandKind => |
| 83 | command.oldOid === ZERO_OID |
| 84 | ? "create" |
| 85 | : command.newOid === ZERO_OID |
| 86 | ? "delete" |
| 87 | : "update"; |
| 88 | |
| 89 | /** |
| 90 | * A parsed `git-receive-pack` request. |
| 91 | */ |
| 92 | export interface ReceivePackRequest { |
| 93 | /** Ref update commands, in request order. */ |
| 94 | readonly commands: ReadonlyArray<RefCommand>; |
| 95 | /** Capabilities chosen by the client (after the NUL on the first command). */ |
| 96 | readonly capabilities: ReadonlySet<string>; |
| 97 | /** `shallow` lines from shallow clients (v1: parsed, not honored). */ |
| 98 | readonly shallow: ReadonlyArray<Oid>; |
| 99 | /** Byte offset of the raw pack in the body (position after the flush). */ |
| 100 | readonly packOffset: number; |
| 101 | /** Whether raw pack bytes follow the flush. */ |
| 102 | readonly hasPack: boolean; |
| 103 | /** `atomic` requested: all-or-nothing ref updates. */ |
| 104 | readonly atomic: boolean; |
| 105 | /** `side-band-64k` requested: the report must be muxed on band 1. */ |
| 106 | readonly sideband: boolean; |
| 107 | /** `report-status` or `report-status-v2` requested. */ |
| 108 | readonly reportStatus: boolean; |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * The outcome of parsing a receive-pack body: the empty-flush probe (reply |
| 113 | * with an empty 200) or a real request. |
| 114 | */ |
| 115 | export type ParsedReceivePack = |
| 116 | | { readonly _tag: "probe" } |
| 117 | | { readonly _tag: "request"; readonly request: ReceivePackRequest }; |
| 118 | |
| 119 | // control chars, space, DEL, and git's forbidden refname metacharacters |
| 120 | const REFNAME_FORBIDDEN = new RegExp("[\\x00-\\x20\\x7f\\\\~^:?*\\[]"); |
| 121 | |
| 122 | const validRefName = (name: string): boolean => |
| 123 | name.startsWith("refs/") && |
| 124 | name.length > "refs/".length && |
| 125 | !name.endsWith("/") && |
| 126 | !name.includes("..") && |
| 127 | !name.endsWith(".lock") && |
| 128 | !REFNAME_FORBIDDEN.test(name); |
| 129 | |
| 130 | const parseCommandLine = ( |
| 131 | line: string, |
| 132 | ): Effect.Effect<RefCommand, ProtocolError> => |
| 133 | Effect.suspend(() => { |
| 134 | const oldOid = line.slice(0, 40); |
| 135 | const newOid = line.slice(41, 81); |
| 136 | const ref = line.slice(82); |
| 137 | if ( |
| 138 | !isOid(oldOid) || |
| 139 | line[40] !== " " || |
| 140 | !isOid(newOid) || |
| 141 | line[81] !== " " || |
| 142 | ref.length === 0 |
| 143 | ) { |
| 144 | return Effect.fail( |
| 145 | new ProtocolError({ reason: `malformed command line: ${line}` }), |
| 146 | ); |
| 147 | } |
| 148 | if (!validRefName(ref)) { |
| 149 | return Effect.fail( |
| 150 | new ProtocolError({ reason: `invalid refname: ${ref}` }), |
| 151 | ); |
| 152 | } |
| 153 | if (oldOid === ZERO_OID && newOid === ZERO_OID) { |
| 154 | return Effect.fail( |
| 155 | new ProtocolError({ reason: `zero-to-zero command for ${ref}` }), |
| 156 | ); |
| 157 | } |
| 158 | return Effect.succeed({ oldOid, newOid, ref }); |
| 159 | }); |
| 160 | |
| 161 | /** |
| 162 | * Parses a `git-receive-pack` request body (already gunzipped): shallow |
| 163 | * lines, the command list (NUL-separated caps on the first command), the |
| 164 | * flush, and the position of the raw pack payload. A body containing no |
| 165 | * commands is the **empty-flush probe**. |
| 166 | */ |
| 167 | export const parseReceivePackRequest = Effect.fn(function* (body: Uint8Array) { |
| 168 | const commands: Array<RefCommand> = []; |
| 169 | const capabilities = new Set<string>(); |
| 170 | const shallow: Array<Oid> = []; |
| 171 | let pos = 0; |
| 172 | |
| 173 | for (;;) { |
| 174 | const result = readPktLineAt(body, pos); |
| 175 | if (result._tag === "incomplete") { |
| 176 | return yield* new PktLineError({ |
| 177 | reason: `truncated receive-pack request at offset ${pos}`, |
| 178 | }); |
| 179 | } |
| 180 | if (result._tag === "invalid") { |
| 181 | return yield* new PktLineError({ reason: result.reason }); |
| 182 | } |
| 183 | const pkt = result.pkt; |
| 184 | if (pkt._tag === "flush") { |
| 185 | pos = result.next; |
| 186 | break; |
| 187 | } |
| 188 | if (pkt._tag !== "data") { |
| 189 | return yield* new ProtocolError({ |
| 190 | reason: `unexpected ${pkt._tag} packet in receive-pack request`, |
| 191 | }); |
| 192 | } |
| 193 | const text = pktPayloadText(pkt.payload); |
| 194 | if (text.startsWith("shallow ")) { |
| 195 | const oid = text.slice(8); |
| 196 | if (!isOid(oid)) { |
| 197 | return yield* new ProtocolError({ |
| 198 | reason: `malformed shallow line: ${text}`, |
| 199 | }); |
| 200 | } |
| 201 | shallow.push(oid); |
| 202 | } else { |
| 203 | const nul = text.indexOf("\0"); |
| 204 | const line = nul === -1 ? text : text.slice(0, nul); |
| 205 | if (nul !== -1) { |
| 206 | for (const cap of text.slice(nul + 1).split(" ")) { |
| 207 | if (cap.length > 0) capabilities.add(cap); |
| 208 | } |
| 209 | } |
| 210 | commands.push(yield* parseCommandLine(line)); |
| 211 | } |
| 212 | pos = result.next; |
| 213 | } |
| 214 | |
| 215 | if (commands.length === 0) { |
| 216 | return { _tag: "probe" } as ParsedReceivePack; |
| 217 | } |
| 218 | |
| 219 | const request: ReceivePackRequest = { |
| 220 | commands, |
| 221 | capabilities, |
| 222 | shallow, |
| 223 | packOffset: pos, |
| 224 | hasPack: pos < body.length, |
| 225 | atomic: capabilities.has("atomic"), |
| 226 | sideband: capabilities.has("side-band-64k"), |
| 227 | reportStatus: |
| 228 | capabilities.has("report-status") || capabilities.has("report-status-v2"), |
| 229 | }; |
| 230 | return { _tag: "request", request } as ParsedReceivePack; |
| 231 | }); |
| 232 | |
| 233 | // ───────────────────────────────────────────────────────────────────────────── |
| 234 | // Ingest driver |
| 235 | // ───────────────────────────────────────────────────────────────────────────── |
| 236 | |
| 237 | /** |
| 238 | * Options for {@link ingestPushPack}. |
| 239 | */ |
| 240 | export interface IngestPushOptions<E, R> { |
| 241 | /** The full (gunzipped, buffered) request body. */ |
| 242 | readonly body: Uint8Array; |
| 243 | /** `request.packOffset` from {@link parseReceivePackRequest}. */ |
| 244 | readonly packOffset: number; |
| 245 | /** Live objects — resolves thin REF_DELTA bases. */ |
| 246 | readonly store: ThinBaseSource; |
| 247 | /** Receives each resolved entry (the DO stages it under the push id). */ |
| 248 | readonly sink: (entry: ResolvedEntry) => Effect.Effect<void, E, R>; |
| 249 | /** Per-object uncompressed cap; defaults to the parser's 64 MiB. */ |
| 250 | readonly maxObjectSize?: number | undefined; |
| 251 | } |
| 252 | |
| 253 | /** |
| 254 | * Drives {@link ingestPack} over the pack payload of a push body. A |
| 255 | * delete-only push (no pack bytes) yields an empty summary without touching |
| 256 | * the parser. |
| 257 | */ |
| 258 | export const ingestPushPack = <E, R>( |
| 259 | options: IngestPushOptions<E, R>, |
| 260 | ): Effect.Effect<IngestSummary, PackIngestError | E, R> => |
| 261 | options.packOffset >= options.body.length |
| 262 | ? Effect.succeed({ count: 0, oids: [] }) |
| 263 | : ingestPack({ |
| 264 | source: bufferRandomAccess(options.body.subarray(options.packOffset)), |
| 265 | store: options.store, |
| 266 | sink: options.sink, |
| 267 | maxObjectSize: options.maxObjectSize, |
| 268 | }); |
| 269 | |
| 270 | // ───────────────────────────────────────────────────────────────────────────── |
| 271 | // Connectivity check |
| 272 | // ───────────────────────────────────────────────────────────────────────────── |
| 273 | |
| 274 | /** |
| 275 | * A staged object's identity, as needed by the connectivity walk. |
| 276 | */ |
| 277 | export interface StagedObjectRef { |
| 278 | readonly oid: Oid; |
| 279 | readonly type: ObjectType; |
| 280 | } |
| 281 | |
| 282 | /** |
| 283 | * The membership oracle and content reader the connectivity check needs — |
| 284 | * implemented by the Repo DO over batched SQL. |
| 285 | */ |
| 286 | export interface ConnectivityDeps { |
| 287 | /** Reads a staged object's inflated content. */ |
| 288 | readonly readStagedContent: ( |
| 289 | oid: Oid, |
| 290 | ) => Effect.Effect<Uint8Array, StoreError>; |
| 291 | /** |
| 292 | * Filters oids down to those existing among **live** objects (batched, |
| 293 | * chunked `IN` lists; order not guaranteed). |
| 294 | */ |
| 295 | readonly filterLiveExisting: ( |
| 296 | oids: ReadonlyArray<Oid>, |
| 297 | ) => Effect.Effect<ReadonlyArray<Oid>, StoreError>; |
| 298 | } |
| 299 | |
| 300 | /** |
| 301 | * The full connectivity check (DESIGN §3.6 step 5): collects every oid |
| 302 | * referenced by staged commits (tree, parents), staged trees (entries — |
| 303 | * gitlinks excluded, they point outside the repo), staged tags (target), |
| 304 | * plus every command's non-zero `new-oid`; then verifies membership against |
| 305 | * staged ∪ live objects. Returns the missing oids (empty = connected). |
| 306 | * |
| 307 | * This is the difference between "corrupt client" and "corrupt repo": any |
| 308 | * miss fails the push with `unpack missing objects` + all-`ng`. |
| 309 | */ |
| 310 | export const checkConnectivity = Effect.fn(function* ( |
| 311 | staged: ReadonlyArray<StagedObjectRef>, |
| 312 | commandNewOids: ReadonlyArray<Oid>, |
| 313 | deps: ConnectivityDeps, |
| 314 | ) { |
| 315 | const stagedSet = new Set(staged.map((s) => s.oid)); |
| 316 | const referenced = new Set<Oid>(); |
| 317 | |
| 318 | for (const object of staged) { |
| 319 | switch (object.type) { |
| 320 | case 1: { |
| 321 | const content = yield* deps.readStagedContent(object.oid); |
| 322 | const commit = yield* parseCommit(content); |
| 323 | referenced.add(commit.tree); |
| 324 | for (const parent of commit.parents) referenced.add(parent); |
| 325 | break; |
| 326 | } |
| 327 | case 2: { |
| 328 | const content = yield* deps.readStagedContent(object.oid); |
| 329 | const entries = yield* parseTree(content); |
| 330 | for (const entry of entries) { |
| 331 | // gitlinks (mode 160000) reference commits in other repos — skip |
| 332 | if (treeEntryKind(entry.mode) !== "commit") { |
| 333 | referenced.add(entry.oid); |
| 334 | } |
| 335 | } |
| 336 | break; |
| 337 | } |
| 338 | case 4: { |
| 339 | const content = yield* deps.readStagedContent(object.oid); |
| 340 | const tag = yield* parseTag(content); |
| 341 | referenced.add(tag.object); |
| 342 | break; |
| 343 | } |
| 344 | default: |
| 345 | break; // blobs reference nothing |
| 346 | } |
| 347 | } |
| 348 | for (const oid of commandNewOids) { |
| 349 | if (oid !== ZERO_OID) referenced.add(oid); |
| 350 | } |
| 351 | |
| 352 | const candidates = [...referenced].filter((oid) => !stagedSet.has(oid)); |
| 353 | if (candidates.length === 0) return [] as ReadonlyArray<Oid>; |
| 354 | const live = new Set(yield* deps.filterLiveExisting(candidates)); |
| 355 | return candidates.filter((oid) => !live.has(oid)).sort(); |
| 356 | }); |
| 357 | |
| 358 | // ───────────────────────────────────────────────────────────────────────────── |
| 359 | // report-status |
| 360 | // ───────────────────────────────────────────────────────────────────────────── |
| 361 | |
| 362 | /** |
| 363 | * The per-ref outcome of the CAS transaction, produced by the Repo DO and |
| 364 | * rendered by {@link reportStatus}. |
| 365 | */ |
| 366 | export interface CommandResult { |
| 367 | readonly ref: string; |
| 368 | readonly ok: boolean; |
| 369 | /** Failure reason for `ng` lines (e.g. `fetch first`). */ |
| 370 | readonly reason?: string | undefined; |
| 371 | } |
| 372 | |
| 373 | /** Builds an `ok <ref>` result. */ |
| 374 | export const okResult = (ref: string): CommandResult => ({ ref, ok: true }); |
| 375 | |
| 376 | /** Builds an `ng <ref> <reason>` result. */ |
| 377 | export const ngResult = (ref: string, reason: string): CommandResult => ({ |
| 378 | ref, |
| 379 | ok: false, |
| 380 | reason, |
| 381 | }); |
| 382 | |
| 383 | /** |
| 384 | * Options for {@link reportStatus}. |
| 385 | */ |
| 386 | export interface ReportStatusOptions { |
| 387 | /** `"ok"` when the pack ingested cleanly, else the unpack error message. */ |
| 388 | readonly unpack: string; |
| 389 | /** Per-ref results in command order. */ |
| 390 | readonly results: ReadonlyArray<CommandResult>; |
| 391 | /** Wrap the report in side-band band 1 (mandatory when negotiated). */ |
| 392 | readonly sideband: boolean; |
| 393 | } |
| 394 | |
| 395 | /** |
| 396 | * Renders the `report-status` (and `report-status-v2` — implemented |
| 397 | * identically, with no `option` lines, which is valid) response: |
| 398 | * |
| 399 | * ``` |
| 400 | * unpack ok|<error> |
| 401 | * ok <ref> | ng <ref> <reason> (per command) |
| 402 | * flush |
| 403 | * ``` |
| 404 | * |
| 405 | * When the client negotiated `side-band-64k` the entire report (including |
| 406 | * its flush) is wrapped in band-1 frames followed by the outer flush — |
| 407 | * sending it unmuxed while side-band is active breaks the client. |
| 408 | */ |
| 409 | export const reportStatus = (options: ReportStatusOptions): Uint8Array => { |
| 410 | const inner = concatBytes([ |
| 411 | pktText(`unpack ${options.unpack}`), |
| 412 | ...options.results.map((result) => |
| 413 | pktText( |
| 414 | result.ok |
| 415 | ? `ok ${result.ref}` |
| 416 | : `ng ${result.ref} ${result.reason ?? "failed"}`, |
| 417 | ), |
| 418 | ), |
| 419 | flushPkt, |
| 420 | ]); |
| 421 | return options.sideband |
| 422 | ? concatBytes([...sidebandFrames(1, inner), flushPkt]) |
| 423 | : inner; |
| 424 | }; |
| 425 | |
| 426 | /** |
| 427 | * Renders the all-failed report used for whole-push rejections: pack too |
| 428 | * large, checksum mismatch, missing objects, read-only repo. `unpack` carries |
| 429 | * the reason and every command gets an `ng` with the same reason. |
| 430 | */ |
| 431 | export const failAllReport = ( |
| 432 | commands: ReadonlyArray<RefCommand>, |
| 433 | reason: string, |
| 434 | sideband: boolean, |
| 435 | ): Uint8Array => |
| 436 | reportStatus({ |
| 437 | unpack: reason, |
| 438 | results: commands.map((command) => ngResult(command.ref, reason)), |
| 439 | sideband, |
| 440 | }); |
| 441 | |
| 442 | /** Errors the parse phase of receive-pack can produce. */ |
| 443 | export type ReceivePackParseError = PktLineError | ProtocolError; |
| 444 | |
| 445 | /** Errors the connectivity check can produce. */ |
| 446 | export type ConnectivityError = StoreError | ObjectParseError; |