1/**
2 * pkt-line framing (gitprotocol-common).
3 *
4 * Every protocol message except raw pack bytes is a stream of pkt-lines:
5 * 4 lowercase hex digits (total length INCLUDING the 4 length bytes) followed
6 * by the payload. Max total length 65520 ⇒ max payload 65516.
7 *
8 * Special packets whose length field is not a length:
9 *
10 * | bytes | meaning |
11 * |--------|--------------------------------------------|
12 * | `0000` | flush-pkt — end of message/request/response |
13 * | `0001` | delim-pkt — section separator (v2) |
14 * | `0002` | response-end-pkt (v2, stateless) |
15 *
16 * Readers MUST strip an optional trailing LF from text payloads; writers
17 * SHOULD append one. `ERR <msg>` payloads report in-band fatals once a 200
18 * has begun.
19 */
20import * as Arr from "effect/Array";
21import * as Cause from "effect/Cause";
22import * as Effect from "effect/Effect";
23import * as Pull from "effect/Pull";
24import * as Schema from "effect/Schema";
25import * as Stream from "effect/Stream";
26import { concatBytes, utf8Decode, utf8Encode } from "./ObjectCodec.ts";
27
28/**
29 * Maximum pkt-line payload: 65520 total − 4 length bytes.
30 */
31export const MAX_PKT_PAYLOAD = 65516;
32
33/**
34 * Error raised on malformed pkt-line framing (bad hex length, out-of-range
35 * length, truncated payload, oversize writer payload).
36 */
37export class PktLineError extends Schema.TaggedError<PktLineError>()(
38 "PktLineError",
39 { reason: Schema.String },
40) {}
41
42/**
43 * Error raised when a syntactically valid pkt-line stream contains a request
44 * the protocol grammar does not allow (bad `want`/`have`/command lines,
45 * unsupported arguments). Shared by `UploadPack` and `ReceivePack`.
46 */
47export class ProtocolError extends Schema.TaggedError<ProtocolError>()(
48 "ProtocolError",
49 { reason: Schema.String },
50) {}
51
52/**
53 * One decoded pkt-line: a data packet with its payload, or one of the three
54 * special packets.
55 */
56export type PktLine =
57 | { readonly _tag: "data"; readonly payload: Uint8Array }
58 | { readonly _tag: "flush" }
59 | { readonly _tag: "delim" }
60 | { readonly _tag: "responseEnd" };
61
62/** The flush packet singleton. */
63export const flush: PktLine = { _tag: "flush" };
64/** The delim packet singleton. */
65export const delim: PktLine = { _tag: "delim" };
66
67/** The 4 raw bytes of a flush-pkt (`0000`). */
68export const flushPkt: Uint8Array = utf8Encode("0000");
69/** The 4 raw bytes of a delim-pkt (`0001`). */
70export const delimPkt: Uint8Array = utf8Encode("0001");
71
72const HEX_CHARS = "0123456789abcdef";
73
74const encodeLength = (n: number): Uint8Array => {
75 const out = new Uint8Array(4);
76 out[0] = HEX_CHARS.charCodeAt((n >> 12) & 0xf);
77 out[1] = HEX_CHARS.charCodeAt((n >> 8) & 0xf);
78 out[2] = HEX_CHARS.charCodeAt((n >> 4) & 0xf);
79 out[3] = HEX_CHARS.charCodeAt(n & 0xf);
80 return out;
81};
82
83/**
84 * Encodes a data pkt-line around a binary payload (no LF appended). Throws
85 * {@link PktLineError} when the payload exceeds {@link MAX_PKT_PAYLOAD} —
86 * an internal invariant violation, since all writers cap their payloads.
87 */
88export const pktLine = (payload: Uint8Array): Uint8Array => {
89 if (payload.length > MAX_PKT_PAYLOAD) {
90 throw new PktLineError({
91 reason: `pkt-line payload ${payload.length} exceeds ${MAX_PKT_PAYLOAD}`,
92 });
93 }
94 const out = new Uint8Array(4 + payload.length);
95 out.set(encodeLength(payload.length + 4), 0);
96 out.set(payload, 4);
97 return out;
98};
99
100/**
101 * Encodes a text pkt-line, appending the conventional trailing LF when the
102 * text does not already end with one.
103 */
104export const pktText = (text: string): Uint8Array =>
105 pktLine(utf8Encode(text.endsWith("\n") ? text : `${text}\n`));
106
107/**
108 * Encodes an `ERR ` pkt-line. Usable anywhere in a response (including in
109 * place of an advertisement); the client prints `remote error: <message>`
110 * and aborts.
111 */
112export const errPkt = (message: string): Uint8Array =>
113 pktText(`ERR ${message}`);
114
115/**
116 * Decodes a data pkt-line payload as UTF-8 text with the optional trailing
117 * LF stripped (readers MUST strip it).
118 */
119export const pktPayloadText = (payload: Uint8Array): string => {
120 const trimmed =
121 payload.length > 0 && payload[payload.length - 1] === 0x0a
122 ? payload.subarray(0, payload.length - 1)
123 : payload;
124 return utf8Decode(trimmed);
125};
126
127/**
128 * Result of attempting to read one pkt-line at an offset.
129 */
130export type PktReadResult =
131 | { readonly _tag: "pkt"; readonly pkt: PktLine; readonly next: number }
132 | { readonly _tag: "incomplete" }
133 | { readonly _tag: "invalid"; readonly reason: string };
134
135const hexDigit = (byte: number): number => {
136 if (byte >= 0x30 && byte <= 0x39) return byte - 0x30;
137 if (byte >= 0x61 && byte <= 0x66) return byte - 0x61 + 10;
138 if (byte >= 0x41 && byte <= 0x46) return byte - 0x41 + 10;
139 return -1;
140};
141
142/**
143 * Attempts to read one pkt-line from `buf` at `offset` without copying.
144 * Returns `incomplete` when more bytes are needed, `invalid` on malformed
145 * framing, or the decoded packet and the offset of the next one.
146 */
147export const readPktLineAt = (
148 buf: Uint8Array,
149 offset: number,
150): PktReadResult => {
151 if (offset + 4 > buf.length) return { _tag: "incomplete" };
152 let n = 0;
153 for (let i = 0; i < 4; i++) {
154 const d = hexDigit(buf[offset + i]!);
155 if (d === -1) {
156 return {
157 _tag: "invalid",
158 reason: `invalid pkt-line length at offset ${offset}`,
159 };
160 }
161 n = (n << 4) | d;
162 }
163 if (n === 0) return { _tag: "pkt", pkt: flush, next: offset + 4 };
164 if (n === 1) return { _tag: "pkt", pkt: delim, next: offset + 4 };
165 if (n === 2) {
166 return { _tag: "pkt", pkt: { _tag: "responseEnd" }, next: offset + 4 };
167 }
168 if (n === 3) {
169 return { _tag: "invalid", reason: `invalid pkt-line length 0003` };
170 }
171 if (n > MAX_PKT_PAYLOAD + 4) {
172 return {
173 _tag: "invalid",
174 reason: `pkt-line length ${n} exceeds maximum 65520`,
175 };
176 }
177 if (offset + n > buf.length) return { _tag: "incomplete" };
178 return {
179 _tag: "pkt",
180 pkt: { _tag: "data", payload: buf.subarray(offset + 4, offset + n) },
181 next: offset + n,
182 };
183};
184
185/**
186 * Decodes an entire buffer (from `offset`) as consecutive pkt-lines. Fails
187 * with {@link PktLineError} when the buffer ends mid-packet or contains
188 * malformed framing.
189 */
190export const decodePktLines = (
191 buf: Uint8Array,
192 offset = 0,
193): Effect.Effect<ReadonlyArray<PktLine>, PktLineError> =>
194 Effect.suspend(() => {
195 const out: Array<PktLine> = [];
196 let pos = offset;
197 while (pos < buf.length) {
198 const r = readPktLineAt(buf, pos);
199 switch (r._tag) {
200 case "pkt":
201 out.push(r.pkt);
202 pos = r.next;
203 break;
204 case "incomplete":
205 return Effect.fail(
206 new PktLineError({ reason: `truncated pkt-line at offset ${pos}` }),
207 );
208 case "invalid":
209 return Effect.fail(new PktLineError({ reason: r.reason }));
210 }
211 }
212 return Effect.succeed(out);
213 });
214
215const EMPTY = new Uint8Array(0);
216
217/**
218 * Stream transform: decodes a byte stream into a stream of pkt-lines,
219 * buffering partial packets across chunk boundaries. Fails with
220 * {@link PktLineError} on malformed framing or a truncated trailing packet.
221 *
222 * Note: payloads are copied out of the internal buffer so they remain valid
223 * after the transform advances.
224 */
225export const decodePktStream = <E, R>(
226 input: Stream.Stream<Uint8Array, E, R>,
227): Stream.Stream<PktLine, E | PktLineError, R> =>
228 Stream.transformPull(input, (pull) =>
229 Effect.sync(() => {
230 let buffer: Uint8Array = EMPTY;
231 let upstreamDone = false;
232
233 const drain = (): Array<PktLine> | PktLineError => {
234 const out: Array<PktLine> = [];
235 let pos = 0;
236 for (;;) {
237 const r = readPktLineAt(buffer, pos);
238 if (r._tag === "incomplete") break;
239 if (r._tag === "invalid") {
240 return new PktLineError({ reason: r.reason });
241 }
242 // copy the payload: the backing buffer is replaced on refill
243 out.push(
244 r.pkt._tag === "data"
245 ? { _tag: "data", payload: Uint8Array.from(r.pkt.payload) }
246 : r.pkt,
247 );
248 pos = r.next;
249 }
250 buffer = pos === 0 ? buffer : buffer.subarray(pos);
251 return out;
252 };
253
254 const loop: Pull.Pull<
255 Arr.NonEmptyReadonlyArray<PktLine>,
256 E | PktLineError,
257 void
258 > = Effect.suspend(() => {
259 const drained = drain();
260 if (drained instanceof PktLineError) return Effect.fail(drained);
261 if (Arr.isReadonlyArrayNonEmpty(drained)) {
262 return Effect.succeed(drained);
263 }
264 if (upstreamDone) {
265 if (buffer.length > 0) {
266 return Effect.fail(
267 new PktLineError({
268 reason: "byte stream ended inside a pkt-line",
269 }),
270 );
271 }
272 return Cause.done();
273 }
274 return pull.pipe(
275 Effect.map((chunks) => {
276 buffer = concatBytes([buffer, ...chunks]);
277 }),
278 Pull.catchDone(() =>
279 Effect.sync(() => {
280 upstreamDone = true;
281 }),
282 ),
283 Effect.flatMap(() => loop),
284 );
285 });
286
287 return loop;
288 }),
289 );