mirror your GitHub repos to tangled.org automatically
1/**
2 * Git pkt-line framing (protocol v0). A pkt-line is a 4-hex-digit length
3 * prefix (counting the 4 prefix bytes themselves) followed by that many bytes
4 * of payload. `0000` is the flush-pkt: a section delimiter carrying no
5 * payload. Lengths `0001`-`0003` are reserved and invalid in v0.
6 *
7 * See `Documentation/gitprotocol-common.txt` in git.git.
8 */
9
10const FLUSH = '0000'
11const MAX_DATA = 65516
12
13export const flushPkt: Buffer = Buffer.from(FLUSH, 'ascii')
14
15/**
16 * Frame a payload as a pkt-line. Accepts a string (encoded UTF-8) or raw
17 * bytes. Does NOT append a trailing newline; callers that want the
18 * conventional `\n` (command and capability lines) must include it.
19 */
20export function encodePktLine(data: string | Buffer): Buffer {
21 const payload = typeof data === 'string' ? Buffer.from(data, 'utf8') : data
22 if (payload.length > MAX_DATA) {
23 throw new RangeError(`pkt-line payload too large: ${payload.length} > ${MAX_DATA}`)
24 }
25 const len = payload.length + 4
26 const prefix = len.toString(16).padStart(4, '0')
27 return Buffer.concat([Buffer.from(prefix, 'ascii'), payload])
28}
29
30export type PktLine =
31 | { type: 'line', data: Buffer }
32 | { type: 'flush' }
33
34/**
35 * Incrementally decode pkt-lines from a byte source, then hand back whatever
36 * raw bytes follow the section we consumed.
37 *
38 * The git smart protocol switches from pkt-line framing to a raw packfile
39 * stream mid-response (after the NAK/ACK line on a fetch). A naive reader that
40 * buffers ahead would swallow the first chunk of the pack, so this reader
41 * tracks exactly how much it has consumed and exposes the remainder via
42 * `remaining()`.
43 */
44export class PktLineReader {
45 private buf: Buffer = Buffer.alloc(0)
46 private done = false
47 private readonly iter: AsyncIterator<Buffer>
48
49 constructor(source: AsyncIterable<Buffer | Uint8Array>) {
50 this.iter = (async function* normalise() {
51 for await (const chunk of source) {
52 yield Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
53 }
54 })()
55 }
56
57 /**
58 * Read the next pkt-line, or `null` at end of stream. A flush-pkt is
59 * returned as `{ type: 'flush' }` rather than ending iteration; the wire
60 * protocol uses multiple flush-delimited sections per stream.
61 */
62 async next(): Promise<PktLine | null> {
63 while (this.buf.length < 4) {
64 // eslint-disable-next-line no-await-in-loop -- each fill extends a shared buffer the next iteration inspects; the reads are inherently sequential
65 if (!(await this.fill())) {
66 if (this.buf.length === 0) return null
67 throw new Error('unexpected end of stream: truncated pkt-line length')
68 }
69 }
70
71 const len = Number.parseInt(this.buf.toString('ascii', 0, 4), 16)
72 if (Number.isNaN(len)) {
73 throw new Error(`invalid pkt-line length: ${JSON.stringify(this.buf.toString('ascii', 0, 4))}`)
74 }
75 if (len === 0) {
76 this.buf = this.buf.subarray(4)
77 return { type: 'flush' }
78 }
79 if (len < 4) {
80 throw new Error(`reserved pkt-line length ${len} is invalid in protocol v0`)
81 }
82
83 while (this.buf.length < len) {
84 // eslint-disable-next-line no-await-in-loop -- sequential read; see next()
85 if (!(await this.fill())) {
86 throw new Error(`unexpected end of stream: pkt-line wanted ${len} bytes, had ${this.buf.length}`)
87 }
88 }
89
90 const data = this.buf.subarray(4, len)
91 this.buf = this.buf.subarray(len)
92 return { type: 'line', data }
93 }
94
95 /**
96 * Read pkt-lines up to and including the next flush-pkt, returning the line
97 * payloads (flush excluded). Returns `null` if the stream ends before any
98 * line is read.
99 */
100 async readUntilFlush(): Promise<Buffer[] | null> {
101 const lines: Buffer[] = []
102 for (;;) {
103 // eslint-disable-next-line no-await-in-loop -- sequential read; see next()
104 const pkt = await this.next()
105 if (pkt === null) return lines.length > 0 ? lines : null
106 if (pkt.type === 'flush') return lines
107 lines.push(pkt.data)
108 }
109 }
110
111 /**
112 * The bytes already buffered past the last consumed pkt-line. Used to seed
113 * the raw packfile stream once negotiation framing ends.
114 */
115 buffered(): Buffer {
116 return this.buf
117 }
118
119 /**
120 * Yield the remainder of the source as a raw byte stream: first any bytes
121 * already buffered, then the rest of the underlying iterator verbatim. After
122 * calling this, do not call `next()` again.
123 */
124 async *remaining(): AsyncGenerator<Buffer> {
125 if (this.buf.length > 0) {
126 yield this.buf
127 this.buf = Buffer.alloc(0)
128 }
129 if (this.done) return
130 for (;;) {
131 // eslint-disable-next-line no-await-in-loop -- sequential drain of the source iterator
132 const { value, done } = await this.iter.next()
133 if (done) {
134 this.done = true
135 return
136 }
137 yield value
138 }
139 }
140
141 private async fill(): Promise<boolean> {
142 if (this.done) return false
143 const { value, done } = await this.iter.next()
144 if (done) {
145 this.done = true
146 return false
147 }
148 this.buf = this.buf.length === 0 ? value : Buffer.concat([this.buf, value])
149 return true
150 }
151}
152
153/** Decode a single line's payload as a UTF-8 string with any trailing `\n` removed. */
154export function lineToString(data: Buffer): string {
155 return data.length > 0 && data[data.length - 1] === 0x0A
156 ? data.toString('utf8', 0, data.length - 1)
157 : data.toString('utf8')
158}