| 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 | */ |
| 20 | import * as Arr from "effect/Array"; |
| 21 | import * as Cause from "effect/Cause"; |
| 22 | import * as Effect from "effect/Effect"; |
| 23 | import * as Pull from "effect/Pull"; |
| 24 | import * as Schema from "effect/Schema"; |
| 25 | import * as Stream from "effect/Stream"; |
| 26 | import { concatBytes, utf8Decode, utf8Encode } from "./ObjectCodec.ts"; |
| 27 | |
| 28 | /** |
| 29 | * Maximum pkt-line payload: 65520 total − 4 length bytes. |
| 30 | */ |
| 31 | export 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 | */ |
| 37 | export 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 | */ |
| 47 | export 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 | */ |
| 56 | export 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. */ |
| 63 | export const flush: PktLine = { _tag: "flush" }; |
| 64 | /** The delim packet singleton. */ |
| 65 | export const delim: PktLine = { _tag: "delim" }; |
| 66 | |
| 67 | /** The 4 raw bytes of a flush-pkt (`0000`). */ |
| 68 | export const flushPkt: Uint8Array = utf8Encode("0000"); |
| 69 | /** The 4 raw bytes of a delim-pkt (`0001`). */ |
| 70 | export const delimPkt: Uint8Array = utf8Encode("0001"); |
| 71 | |
| 72 | const HEX_CHARS = "0123456789abcdef"; |
| 73 | |
| 74 | const 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 | */ |
| 88 | export 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 | */ |
| 104 | export 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 | */ |
| 112 | export 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 | */ |
| 119 | export 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 | */ |
| 130 | export type PktReadResult = |
| 131 | | { readonly _tag: "pkt"; readonly pkt: PktLine; readonly next: number } |
| 132 | | { readonly _tag: "incomplete" } |
| 133 | | { readonly _tag: "invalid"; readonly reason: string }; |
| 134 | |
| 135 | const 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 | */ |
| 147 | export 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 | */ |
| 190 | export 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 | |
| 215 | const 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 | */ |
| 225 | export 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 | ); |