1/** Streaming smart-HTTP decoding and report-status encoding. No authorization policy. */
2import * as Effect from "effect/Effect";
3import * as Fiber from "effect/Fiber";
4import * as HttpServerRequest from "effect/http/HttpServerRequest";
5import * as HttpServerResponse from "effect/http/HttpServerResponse";
6import { concatBytes } from "../Protocol/ObjectCodec.ts";
7import { errPkt, flushPkt, pktText } from "../Protocol/Pkt.ts";
8import { sidebandFrames } from "../Protocol/Sideband.ts";
9import { StoreError } from "../Protocol/Store.ts";
10import { incomingStates, type PushInput } from "../Push.ts";
11import {
12 parseReceivePackRequest,
13 type CommitPushResult,
14} from "../RepoObject.ts";
15import { feedBody, HEAD_BYTES } from "../Store/IncomingBody.ts";
16import { makeStreamingSource } from "../Store/StreamingSource.ts";
17
18export { ReceivePack as endpoint } from "../Api/Protocol.ts";
19
20export interface Push {
21 readonly _tag: "Push";
22 readonly input: PushInput;
23 readonly updates: PushInput["updates"];
24 readonly capabilities: ReadonlySet<string>;
25}
26
27const headers = {
28 "cache-control": "no-cache",
29 "content-type": "application/x-git-receive-pack-result",
30};
31const reply = (bytes: Uint8Array) =>
32 HttpServerResponse.uint8Array(bytes, { headers });
33export const probeResponse = () =>
34 HttpServerResponse.empty({ status: 200, headers });
35
36/** Decode only the bounded command header. The body remains streaming until preparePush. */
37export const decode = (request: HttpServerRequest.HttpServerRequest) =>
38 Effect.gen(function* () {
39 const web = yield* HttpServerRequest.toWeb(request).pipe(
40 Effect.mapError(
41 (error) =>
42 new StoreError({ reason: `incoming body: ${error.message}` }),
43 ),
44 );
45 const feeder = makeStreamingSource();
46 const gzip = /\bgzip\b/i.test(request.headers["content-encoding"] ?? "");
47 const body =
48 gzip && web.body !== null
49 ? web.body.pipeThrough(new DecompressionStream("gzip"))
50 : web.body;
51 const receiving = yield* Effect.forkScoped(
52 Effect.result(feedBody(body, feeder)),
53 );
54 const state = {
55 feeder,
56 receiving,
57 packStart: 0,
58 declaredBytes: undefined as number | undefined,
59 active: true,
60 claimed: false,
61 };
62 yield* Effect.addFinalizer(() =>
63 Effect.gen(function* () {
64 state.active = false;
65 feeder.fail(new StoreError({ reason: "push scope closed" }));
66 yield* Fiber.interrupt(receiving);
67 }),
68 );
69 const head = yield* feeder.source.read(0, HEAD_BYTES);
70 if (!gzip && head[0] === 0x1f && head[1] === 0x8b) {
71 return yield* new StoreError({
72 reason: "gzip-encoded push without content-encoding",
73 });
74 }
75 const parsed = yield* parseReceivePackRequest(head);
76 if (parsed.probe) return { _tag: "Probe" } as const;
77 const updates = Object.freeze(
78 parsed.commands.map((command) => Object.freeze({ ...command })),
79 );
80 const input: PushInput = Object.freeze({
81 updates,
82 atomic: parsed.capabilities.has("atomic"),
83 });
84 state.packStart = parsed.packStart;
85 const length = Number.parseInt(request.headers["content-length"] ?? "", 10);
86 state.declaredBytes = Number.isNaN(length) ? undefined : length;
87 incomingStates.set(input, state);
88 return {
89 _tag: "Push",
90 input,
91 updates,
92 capabilities: parsed.capabilities,
93 } satisfies Push;
94 });
95
96/** Encode the negotiated Git report; policy rejection is a Git result, not JSON. */
97export const response = (push: Push, result: CommitPushResult) => {
98 if (
99 !push.capabilities.has("report-status") &&
100 !push.capabilities.has("report-status-v2")
101 )
102 return probeResponse();
103 const lines = [
104 pktText(`unpack ${result.unpack}`),
105 ...result.results.map((ref) =>
106 pktText(
107 ref.ok ? `ok ${ref.ref}` : `ng ${ref.ref} ${ref.reason ?? "failed"}`,
108 ),
109 ),
110 flushPkt,
111 ];
112 const report = concatBytes(lines);
113 return reply(
114 push.capabilities.has("side-band-64k")
115 ? concatBytes([...sidebandFrames(1, report), flushPkt])
116 : report,
117 );
118};
119
120/** Refuse the whole push without ingesting objects or moving any refs. */
121export const reject = (push: Push, reason: string) =>
122 response(push, {
123 unpack: "ok",
124 results: push.updates.map((update) => ({
125 ref: update.ref,
126 ok: false,
127 reason: reason.replace(/[\r\n\0]/g, " "),
128 })),
129 });
130
131/** Encode malformed protocol input or a failed unpack operation. */
132export const failure = (reason: string) =>
133 reply(errPkt(reason.replace(/[\r\n\0]/g, " ")));