| 1 | /** Streaming smart-HTTP decoding and report-status encoding. No authorization policy. */ |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Fiber from "effect/Fiber"; |
| 4 | import * as HttpServerRequest from "effect/http/HttpServerRequest"; |
| 5 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 6 | import { concatBytes } from "../Protocol/ObjectCodec.ts"; |
| 7 | import { errPkt, flushPkt, pktText } from "../Protocol/Pkt.ts"; |
| 8 | import { sidebandFrames } from "../Protocol/Sideband.ts"; |
| 9 | import { StoreError } from "../Protocol/Store.ts"; |
| 10 | import { incomingStates, type PushInput } from "../Push.ts"; |
| 11 | import { |
| 12 | parseReceivePackRequest, |
| 13 | type CommitPushResult, |
| 14 | } from "../RepoObject.ts"; |
| 15 | import { feedBody, HEAD_BYTES } from "../Store/IncomingBody.ts"; |
| 16 | import { makeStreamingSource } from "../Store/StreamingSource.ts"; |
| 17 | |
| 18 | export { ReceivePack as endpoint } from "../Api/Protocol.ts"; |
| 19 | |
| 20 | export interface Push { |
| 21 | readonly _tag: "Push"; |
| 22 | readonly input: PushInput; |
| 23 | readonly updates: PushInput["updates"]; |
| 24 | readonly capabilities: ReadonlySet<string>; |
| 25 | } |
| 26 | |
| 27 | const headers = { |
| 28 | "cache-control": "no-cache", |
| 29 | "content-type": "application/x-git-receive-pack-result", |
| 30 | }; |
| 31 | const reply = (bytes: Uint8Array) => |
| 32 | HttpServerResponse.uint8Array(bytes, { headers }); |
| 33 | export const probeResponse = () => |
| 34 | HttpServerResponse.empty({ status: 200, headers }); |
| 35 | |
| 36 | /** Decode only the bounded command header. The body remains streaming until preparePush. */ |
| 37 | export 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. */ |
| 97 | export 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. */ |
| 121 | export 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. */ |
| 132 | export const failure = (reason: string) => |
| 133 | reply(errPkt(reason.replace(/[\r\n\0]/g, " "))); |