175 lines · 4.7 KB
1 /**
2 * Git pkt-line format encoding/decoding.
3 * See: https://git-scm.com/docs/protocol-common#_pkt_line_format
4 *
5 * Each pkt-line is: 4-hex-digit length prefix + payload.
6 * Length includes the 4 prefix bytes. Special lines:
7 * "0000" = flush packet
8 * "0001" = delimiter packet
9 * "0002" = response-end packet
10 */
11
12 const FLUSH_PKT = '0000';
13 const DELIM_PKT = '0001';
14 const RESPONSE_END_PKT = '0002';
15
16 const encoder = new TextEncoder();
17 const decoder = new TextDecoder();
18
19 /** Encode a string as a pkt-line (includes trailing newline if not present). */
20 export function pktLineEncode(data: string): Uint8Array {
21 const payload = encoder.encode(data);
22 const len = payload.length + 4;
23 const hex = len.toString(16).padStart(4, '0');
24 const result = new Uint8Array(len);
25 result.set(encoder.encode(hex), 0);
26 result.set(payload, 4);
27 return result;
28 }
29
30 /** Encode raw bytes as a pkt-line. */
31 export function pktLineEncodeBytes(data: Uint8Array): Uint8Array {
32 const len = data.length + 4;
33 const hex = len.toString(16).padStart(4, '0');
34 const result = new Uint8Array(len);
35 result.set(encoder.encode(hex), 0);
36 result.set(data, 4);
37 return result;
38 }
39
40 /** Return a flush packet ("0000"). */
41 export function pktFlush(): Uint8Array {
42 return encoder.encode(FLUSH_PKT);
43 }
44
45 /** Return a delimiter packet ("0001"). */
46 export function pktDelim(): Uint8Array {
47 return encoder.encode(DELIM_PKT);
48 }
49
50 /** Concatenate multiple pkt-line segments into a single Uint8Array. */
51 export function pktConcat(...parts: Uint8Array[]): Uint8Array {
52 const totalLen = parts.reduce((sum, p) => sum + p.length, 0);
53 const result = new Uint8Array(totalLen);
54 let offset = 0;
55 for (const part of parts) {
56 result.set(part, offset);
57 offset += part.length;
58 }
59 return result;
60 }
61
62 /** Parsed pkt-line: either a data line, flush, delimiter, or response-end. */
63 export type PktLine =
64 | { type: 'data'; data: Uint8Array }
65 | { type: 'flush' }
66 | { type: 'delim' }
67 | { type: 'response-end' };
68
69 /**
70 * Parse all pkt-lines from a buffer.
71 * Returns the parsed lines and any remaining bytes that weren't a complete line.
72 */
73 export function pktLineParse(buf: Uint8Array): { lines: PktLine[]; remaining: Uint8Array } {
74 const lines: PktLine[] = [];
75 let offset = 0;
76
77 while (offset + 4 <= buf.length) {
78 const hexStr = decoder.decode(buf.slice(offset, offset + 4));
79 const len = parseInt(hexStr, 16);
80
81 if (isNaN(len)) {
82 break;
83 }
84
85 if (len === 0) {
86 lines.push({ type: 'flush' });
87 offset += 4;
88 continue;
89 }
90
91 if (len === 1) {
92 lines.push({ type: 'delim' });
93 offset += 4;
94 continue;
95 }
96
97 if (len === 2) {
98 lines.push({ type: 'response-end' });
99 offset += 4;
100 continue;
101 }
102
103 if (offset + len > buf.length) {
104 // Incomplete packet
105 break;
106 }
107
108 const data = buf.slice(offset + 4, offset + len);
109 lines.push({ type: 'data', data });
110 offset += len;
111 }
112
113 return { lines, remaining: buf.slice(offset) };
114 }
115
116 /** Parse a pkt-line data payload as a UTF-8 string, trimming trailing newline. */
117 export function pktLineDataToString(line: PktLine): string | null {
118 if (line.type !== 'data') return null;
119 let str = decoder.decode(line.data);
120 if (str.endsWith('\n')) str = str.slice(0, -1);
121 return str;
122 }
123
124 /**
125 * Build a service advertisement response (used by info/refs).
126 * Format: pkt-line "# service={service}\n" + flush + ref lines + flush
127 */
128 export function buildServiceAdvertisement(
129 service: string,
130 refs: Array<{ sha: string; name: string }>,
131 capabilities: string[]
132 ): Uint8Array {
133 const parts: Uint8Array[] = [];
134
135 // Service announcement
136 parts.push(pktLineEncode(`# service=${service}\n`));
137 parts.push(pktFlush());
138
139 if (refs.length === 0) {
140 // Empty repo: advertise capabilities on a zero-id line
141 const zeroSha = '0'.repeat(40);
142 const capsStr = capabilities.join(' ');
143 parts.push(pktLineEncode(`${zeroSha} capabilities^{}\0${capsStr}\n`));
144 } else {
145 for (let i = 0; i < refs.length; i++) {
146 const ref = refs[i];
147 if (i === 0) {
148 // First ref includes capabilities after NUL
149 const capsStr = capabilities.join(' ');
150 parts.push(pktLineEncode(`${ref.sha} ${ref.name}\0${capsStr}\n`));
151 } else {
152 parts.push(pktLineEncode(`${ref.sha} ${ref.name}\n`));
153 }
154 }
155 }
156
157 parts.push(pktFlush());
158 return pktConcat(...parts);
159 }
160
161 /** Side-band-64k channel IDs. */
162 export const SIDE_BAND = {
163 DATA: 1,
164 PROGRESS: 2,
165 ERROR: 3,
166 } as const;
167
168 /** Wrap data in a side-band-64k pkt-line. */
169 export function pktSideBandEncode(channel: number, data: Uint8Array): Uint8Array {
170 const payload = new Uint8Array(1 + data.length);
171 payload[0] = channel;
172 payload.set(data, 1);
173 return pktLineEncodeBytes(payload);
174 }
175