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 */
28import * as Effect from "effect/Effect";
29import {
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";
41import {
42 bufferRandomAccess,
43 ingestPack,
44 type IngestSummary,
45 type PackIngestError,
46 type ResolvedEntry,
47 type ThinBaseSource,
48} from "./PackParser.ts";
49import {
50 flushPkt,
51 PktLineError,
52 pktPayloadText,
53 pktText,
54 ProtocolError,
55 readPktLineAt,
56} from "./Pkt.ts";
57import { sidebandFrames } from "./Sideband.ts";
58import { 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 */
69export 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. */
76export type CommandKind = "create" | "update" | "delete";
77
78/**
79 * Classifies a command: zero old-oid ⇒ `create`, zero new-oid ⇒ `delete`,
80 * else `update`.
81 */
82export 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 */
92export 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 */
115export 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
120const REFNAME_FORBIDDEN = new RegExp("[\\x00-\\x20\\x7f\\\\~^:?*\\[]");
121
122const 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
130const 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 */
167export 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 */
240export 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 */
258export 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 */
277export 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 */
286export 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 */
310export 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 */
366export 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. */
374export const okResult = (ref: string): CommandResult => ({ ref, ok: true });
375
376/** Builds an `ng <ref> <reason>` result. */
377export const ngResult = (ref: string, reason: string): CommandResult => ({
378 ref,
379 ok: false,
380 reason,
381});
382
383/**
384 * Options for {@link reportStatus}.
385 */
386export 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 */
409export 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 */
431export 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. */
443export type ReceivePackParseError = PktLineError | ProtocolError;
444
445/** Errors the connectivity check can produce. */
446export type ConnectivityError = StoreError | ObjectParseError;