gnutella 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CLI.md +189 -0
  2. package/DEVELOPER.md +193 -0
  3. package/LICENSE +674 -0
  4. package/QUICKSTART.md +133 -0
  5. package/README.md +74 -0
  6. package/bin/gnutella.ts +15 -0
  7. package/gnutella.json.example +18 -0
  8. package/package.json +72 -0
  9. package/src/cli.ts +692 -0
  10. package/src/cli_shared.ts +359 -0
  11. package/src/const.ts +138 -0
  12. package/src/gwebcache/bootstrap.ts +491 -0
  13. package/src/gwebcache/response.ts +391 -0
  14. package/src/gwebcache/shared.ts +116 -0
  15. package/src/gwebcache/types.ts +187 -0
  16. package/src/gwebcache_client.ts +13 -0
  17. package/src/protocol/browse_host.ts +552 -0
  18. package/src/protocol/client_blocking.ts +29 -0
  19. package/src/protocol/codec.ts +715 -0
  20. package/src/protocol/content_urn.ts +170 -0
  21. package/src/protocol/core_utils.ts +43 -0
  22. package/src/protocol/file_server.ts +245 -0
  23. package/src/protocol/ggep.ts +168 -0
  24. package/src/protocol/handshake.ts +199 -0
  25. package/src/protocol/http_download_reader.ts +112 -0
  26. package/src/protocol/magnet.ts +176 -0
  27. package/src/protocol/node.ts +416 -0
  28. package/src/protocol/node_handshake.ts +992 -0
  29. package/src/protocol/node_lifecycle.ts +210 -0
  30. package/src/protocol/node_protocol_runtime.ts +949 -0
  31. package/src/protocol/node_qrp_runtime.ts +97 -0
  32. package/src/protocol/node_query_routing.ts +208 -0
  33. package/src/protocol/node_state.ts +745 -0
  34. package/src/protocol/node_tls.ts +257 -0
  35. package/src/protocol/node_topology.ts +141 -0
  36. package/src/protocol/node_transfer.ts +455 -0
  37. package/src/protocol/node_types.ts +106 -0
  38. package/src/protocol/peer_state.ts +675 -0
  39. package/src/protocol/qrp.ts +549 -0
  40. package/src/protocol/query_search.ts +29 -0
  41. package/src/protocol/share_index.ts +131 -0
  42. package/src/protocol/share_library.ts +246 -0
  43. package/src/protocol.ts +36 -0
  44. package/src/shared.ts +236 -0
  45. package/src/types.ts +452 -0
@@ -0,0 +1,170 @@
1
+ import { BASE32_ALPHABET } from "../const";
2
+
3
+ const BASE32_LOOKUP = new Map(
4
+ [...BASE32_ALPHABET].map((char, index) => [char, index]),
5
+ );
6
+
7
+ const SHA1_URN_PREFIX = "urn:sha1:";
8
+ const BITPRINT_URN_PREFIX = "urn:bitprint:";
9
+ const TREE_TIGER_URN_PREFIX = "urn:tree:tiger/:";
10
+
11
+ function normalizeSha1Base32(value: string): string | undefined {
12
+ const normalized = value.trim().toUpperCase();
13
+ return /^[A-Z2-7]{32}$/.test(normalized) ? normalized : undefined;
14
+ }
15
+
16
+ function normalizeTigerTreeBase32(value: string): string | undefined {
17
+ const normalized = value.trim().toUpperCase();
18
+ return /^[A-Z2-7]{39}$/.test(normalized) ? normalized : undefined;
19
+ }
20
+
21
+ function base32Decode(text: string): Buffer | undefined {
22
+ let bits = 0;
23
+ let value = 0;
24
+ const out: number[] = [];
25
+ for (const char of text.toUpperCase()) {
26
+ const digit = BASE32_LOOKUP.get(char);
27
+ if (digit == null) return undefined;
28
+ value = (value << 5) | digit;
29
+ bits += 5;
30
+ while (bits >= 8) {
31
+ out.push((value >>> (bits - 8)) & 0xff);
32
+ bits -= 8;
33
+ }
34
+ }
35
+ return Buffer.from(out);
36
+ }
37
+
38
+ export function base32Encode(data: Buffer): string {
39
+ let result = "";
40
+ let bits = 0;
41
+ let value = 0;
42
+ for (const byte of data) {
43
+ value = (value << 8) | byte;
44
+ bits += 8;
45
+ while (bits >= 5) {
46
+ result += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
47
+ bits -= 5;
48
+ }
49
+ }
50
+ if (bits > 0) result += BASE32_ALPHABET[(value << (5 - bits)) & 31];
51
+ return result;
52
+ }
53
+
54
+ function normalizeSha1Urn(raw: string): string | undefined {
55
+ const match = /^urn:sha1:([A-Z2-7]+)$/i.exec(raw.trim());
56
+ if (!match) return undefined;
57
+ const digest = normalizeSha1Base32(match[1]);
58
+ return digest ? `${SHA1_URN_PREFIX}${digest}` : undefined;
59
+ }
60
+
61
+ function normalizeBitprintUrn(raw: string): string | undefined {
62
+ const match = /^urn:bitprint:([A-Z2-7]+)\.([A-Z2-7]+)$/i.exec(
63
+ raw.trim(),
64
+ );
65
+ if (!match) return undefined;
66
+ const sha1 = normalizeSha1Base32(match[1]);
67
+ const tiger = normalizeTigerTreeBase32(match[2]);
68
+ if (!sha1 || !tiger) return undefined;
69
+ return `${BITPRINT_URN_PREFIX}${sha1}.${tiger}`;
70
+ }
71
+
72
+ function normalizeTreeTigerUrn(raw: string): string | undefined {
73
+ const match = /^urn:tree:tiger\/?:([A-Z2-7]+)$/i.exec(raw.trim());
74
+ if (!match) return undefined;
75
+ const tiger = normalizeTigerTreeBase32(match[1]);
76
+ return tiger ? `${TREE_TIGER_URN_PREFIX}${tiger}` : undefined;
77
+ }
78
+
79
+ export function sha1UrnFromUrn(raw: string): string | undefined {
80
+ const sha1 = normalizeSha1Urn(raw);
81
+ if (sha1) return sha1;
82
+ const bitprint = normalizeBitprintUrn(raw);
83
+ if (!bitprint) return undefined;
84
+ const dot = bitprint.indexOf(".");
85
+ return dot === -1
86
+ ? undefined
87
+ : `${SHA1_URN_PREFIX}${bitprint.slice(BITPRINT_URN_PREFIX.length, dot)}`;
88
+ }
89
+
90
+ export function sha1BufferFromUrn(raw: string): Buffer | undefined {
91
+ const sha1Urn = sha1UrnFromUrn(raw);
92
+ if (!sha1Urn) return undefined;
93
+ const digest = sha1Urn.slice(SHA1_URN_PREFIX.length);
94
+ const decoded = base32Decode(digest);
95
+ return decoded?.length === 20 ? decoded : undefined;
96
+ }
97
+
98
+ export function bitprintUrnFromHashes(
99
+ sha1: Buffer,
100
+ tigerTreeRoot: Buffer,
101
+ ): string {
102
+ return `${BITPRINT_URN_PREFIX}${base32Encode(sha1)}.${base32Encode(tigerTreeRoot)}`;
103
+ }
104
+
105
+ export function normalizeUrnList(rawUrns: Iterable<string>): string[] {
106
+ const out: string[] = [];
107
+ const seen = new Set<string>();
108
+ const add = (urn: string) => {
109
+ const key = urn.toLowerCase();
110
+ if (seen.has(key)) return;
111
+ seen.add(key);
112
+ out.push(urn);
113
+ };
114
+ for (const raw of rawUrns) {
115
+ const sha1 = normalizeSha1Urn(raw);
116
+ if (sha1) {
117
+ add(sha1);
118
+ continue;
119
+ }
120
+ const bitprint = normalizeBitprintUrn(raw);
121
+ if (bitprint) {
122
+ add(bitprint);
123
+ add(
124
+ `${SHA1_URN_PREFIX}${bitprint.slice(
125
+ BITPRINT_URN_PREFIX.length,
126
+ bitprint.indexOf("."),
127
+ )}`,
128
+ );
129
+ continue;
130
+ }
131
+ const tiger = normalizeTreeTigerUrn(raw);
132
+ if (tiger) {
133
+ add(tiger);
134
+ continue;
135
+ }
136
+ const trimmed = raw.trim();
137
+ if (trimmed.toLowerCase().startsWith("urn:")) add(trimmed);
138
+ }
139
+ return out;
140
+ }
141
+
142
+ export function firstSha1Urn(
143
+ rawUrns: Iterable<string>,
144
+ ): string | undefined {
145
+ for (const urn of rawUrns) {
146
+ const sha1 = sha1UrnFromUrn(urn);
147
+ if (sha1) return sha1;
148
+ }
149
+ }
150
+
151
+ export function textUrnFromGgepUrn(raw: Buffer): string | undefined {
152
+ const text = raw.toString("utf8").trim();
153
+ if (!text) return undefined;
154
+ if (text.toLowerCase().startsWith("urn:")) {
155
+ return normalizeUrnList([text])[0];
156
+ }
157
+ if (/^(sha1|bitprint|tree:tiger\/?):/i.test(text)) {
158
+ return normalizeUrnList([`urn:${text}`])[0];
159
+ }
160
+ }
161
+
162
+ export function bitprintUrnFromGgepHash(
163
+ payload: Buffer,
164
+ ): string | undefined {
165
+ if (payload.length < 1 + 20 + 24) return undefined;
166
+ return bitprintUrnFromHashes(
167
+ payload.subarray(1, 21),
168
+ payload.subarray(21, 45),
169
+ );
170
+ }
@@ -0,0 +1,43 @@
1
+ import crypto from "node:crypto";
2
+
3
+ import { TYPE } from "../const";
4
+
5
+ export function randomId16(): Buffer {
6
+ const id = crypto.randomBytes(16);
7
+ id[8] = 0xff;
8
+ id[15] = 0x00;
9
+ return id;
10
+ }
11
+
12
+ export function seenKey(
13
+ payloadType: number,
14
+ descriptorIdHex: string,
15
+ payload?: Buffer,
16
+ ): string {
17
+ const base = `${payloadType}:${descriptorIdHex}`;
18
+ if (
19
+ (payloadType === TYPE.PONG || payloadType === TYPE.QUERY_HIT) &&
20
+ payload
21
+ ) {
22
+ const digest = crypto.createHash("sha1").update(payload).digest("hex");
23
+ return `${base}:${digest}`;
24
+ }
25
+ return base;
26
+ }
27
+
28
+ export function fromHex16(hex: string): Buffer {
29
+ const clean = hex.trim().toLowerCase();
30
+ if (!/^[0-9a-f]{32}$/.test(clean))
31
+ throw new Error(`expected 32 hex chars, got ${hex}`);
32
+ const id = Buffer.from(clean, "hex");
33
+ id[8] = 0xff;
34
+ id[15] = 0x00;
35
+ return id;
36
+ }
37
+
38
+ export function rawHex16(hex: string): Buffer {
39
+ const clean = hex.trim().toLowerCase();
40
+ if (!/^[0-9a-f]{32}$/.test(clean))
41
+ throw new Error(`expected 32 hex chars, got ${hex}`);
42
+ return Buffer.from(clean, "hex");
43
+ }
@@ -0,0 +1,245 @@
1
+ import fs from "node:fs";
2
+ import fsp from "node:fs/promises";
3
+ import net from "node:net";
4
+
5
+ import { errMsg } from "../shared";
6
+ import type { ShareFile } from "../types";
7
+ import { parseByteRange } from "./codec";
8
+ import { hasToken, parseHttpHeaders, socketCanEnd } from "./handshake";
9
+ import {
10
+ handleBrowseHostGet,
11
+ isBrowseHostGetRequest,
12
+ } from "./browse_host";
13
+ import type { GnutellaServent } from "./node";
14
+ import type { ExistingGetRequest } from "./node_types";
15
+ import { sha1UrnFromUrn } from "./content_urn";
16
+
17
+ function toError(error: unknown): Error {
18
+ return error instanceof Error ? error : new Error(errMsg(error));
19
+ }
20
+
21
+ async function handleGetByFileIndex(
22
+ node: GnutellaServent,
23
+ socket: net.Socket,
24
+ head: string,
25
+ first: string,
26
+ ): Promise<boolean | undefined> {
27
+ const match =
28
+ /^(GET|HEAD)\s+\/get\/(\d+)\/(.+?)(?:\/)?\s+HTTP\/(\d+\.\d+)$/i.exec(
29
+ first,
30
+ );
31
+ if (!match) return undefined;
32
+ const fileIndex = Number(match[2]);
33
+ const share = node.sharesByIndex.get(fileIndex);
34
+ if (!share) {
35
+ socket.end("HTTP/1.0 404 Not Found\r\n\r\n");
36
+ return false;
37
+ }
38
+ return await node.handleExistingGet(socket, head, share.abs, share);
39
+ }
40
+
41
+ async function handleUriResGet(
42
+ node: GnutellaServent,
43
+ socket: net.Socket,
44
+ head: string,
45
+ first: string,
46
+ ): Promise<boolean | undefined> {
47
+ const match =
48
+ /^(GET|HEAD)\s+\/uri-res\/N2R\?([^\s]+)\s+HTTP\/(\d+\.\d+)$/i.exec(
49
+ first,
50
+ );
51
+ if (!match || !node.config().serveUriRes) return undefined;
52
+ const rawUrn = decodeURIComponent(match[2]);
53
+ const urn = (sha1UrnFromUrn(rawUrn) || rawUrn).toLowerCase();
54
+ const share = node.sharesByUrn.get(urn);
55
+ if (!share) {
56
+ socket.end("HTTP/1.0 404 Not Found\r\n\r\n");
57
+ return false;
58
+ }
59
+ return await node.handleExistingGet(socket, head, share.abs, share);
60
+ }
61
+
62
+ export async function handleIncomingGet(
63
+ node: GnutellaServent,
64
+ socket: net.Socket,
65
+ head: string,
66
+ _body: Buffer = Buffer.alloc(0),
67
+ ): Promise<boolean> {
68
+ if (isBrowseHostGetRequest(head)) {
69
+ return await handleBrowseHostGet(node, socket, head);
70
+ }
71
+ const first = head.replace(/\r\n/g, "\n").split("\n", 1)[0];
72
+ const byIndex = await handleGetByFileIndex(node, socket, head, first);
73
+ if (byIndex != null) return byIndex;
74
+ const byUrn = await handleUriResGet(node, socket, head, first);
75
+ if (byUrn != null) return byUrn;
76
+
77
+ socket.end("HTTP/1.0 400 Bad Request\r\n\r\n");
78
+ return false;
79
+ }
80
+
81
+ export function parseExistingGetRequest(
82
+ _node: GnutellaServent,
83
+ head: string,
84
+ ): ExistingGetRequest {
85
+ const first = head.replace(/\r\n/g, "\n").split("\n", 1)[0];
86
+ const method =
87
+ /^(GET|HEAD)\s+/i.exec(first)?.[1]?.toUpperCase() || "GET";
88
+ const httpVersion =
89
+ /^([A-Z]+)\s+\S+\s+HTTP\/(\d+\.\d+)$/i.exec(first)?.[2] || "1.0";
90
+ const headers = parseHttpHeaders(head);
91
+ return {
92
+ method,
93
+ responseVersion: httpVersion === "1.1" ? "HTTP/1.1" : "HTTP/1.0",
94
+ headers,
95
+ keepAlive: !hasToken(headers["connection"], "close"),
96
+ };
97
+ }
98
+
99
+ export function writeInvalidRangeResponse(
100
+ _node: GnutellaServent,
101
+ socket: net.Socket,
102
+ request: ExistingGetRequest,
103
+ size: number,
104
+ ): boolean {
105
+ socket.write(
106
+ [
107
+ `${request.responseVersion} 416 Range Not Satisfiable`,
108
+ "Server: Gnutella",
109
+ "Content-Type: application/binary",
110
+ "Content-Length: 0",
111
+ `Content-Range: bytes */${size}`,
112
+ `Connection: ${request.keepAlive ? "Keep-Alive" : "close"}`,
113
+ "",
114
+ "",
115
+ ].join("\r\n"),
116
+ );
117
+ if (!request.keepAlive && socketCanEnd(socket)) socket.end();
118
+ return request.keepAlive;
119
+ }
120
+
121
+ export function existingGetBodyLength(
122
+ _node: GnutellaServent,
123
+ range: { start: number; end: number },
124
+ ): number {
125
+ return range.end >= range.start ? range.end - range.start + 1 : 0;
126
+ }
127
+
128
+ export function buildExistingGetResponseHeaders(
129
+ _node: GnutellaServent,
130
+ request: ExistingGetRequest,
131
+ range: { start: number; end: number; partial: boolean },
132
+ size: number,
133
+ remaining: number,
134
+ share?: ShareFile,
135
+ ): string {
136
+ return [
137
+ range.partial
138
+ ? `${request.responseVersion} 206 Partial Content`
139
+ : `${request.responseVersion} 200 OK`,
140
+ "Server: Gnutella",
141
+ "Content-Type: application/binary",
142
+ `Content-Length: ${remaining}`,
143
+ ...(range.partial
144
+ ? [`Content-Range: bytes ${range.start}-${range.end}/${size}`]
145
+ : []),
146
+ ...(share?.sha1Urn
147
+ ? [
148
+ `X-Gnutella-Content-URN: ${share.sha1Urn}`,
149
+ `X-Content-URN: ${share.sha1Urn}`,
150
+ ]
151
+ : []),
152
+ `Connection: ${request.keepAlive ? "Keep-Alive" : "close"}`,
153
+ "",
154
+ "",
155
+ ].join("\r\n");
156
+ }
157
+
158
+ export function finishExistingGetResponse(
159
+ _node: GnutellaServent,
160
+ socket: net.Socket,
161
+ keepAlive: boolean,
162
+ ): boolean {
163
+ if (!keepAlive && socketCanEnd(socket)) socket.end();
164
+ return keepAlive;
165
+ }
166
+
167
+ export async function streamExistingGetBody(
168
+ _node: GnutellaServent,
169
+ socket: net.Socket,
170
+ absPath: string,
171
+ range: { start: number; end: number },
172
+ keepAlive: boolean,
173
+ ): Promise<void> {
174
+ await new Promise<void>((resolve, reject) => {
175
+ const rs = fs.createReadStream(absPath, {
176
+ start: range.start,
177
+ end: range.end,
178
+ });
179
+ let done = false;
180
+ const cleanup = () => {
181
+ rs.off("error", onError);
182
+ rs.off("end", onEnd);
183
+ socket.off("close", onClose);
184
+ socket.off("error", onSocketError);
185
+ };
186
+ const finish = () => {
187
+ if (done) return;
188
+ done = true;
189
+ cleanup();
190
+ resolve();
191
+ };
192
+ const fail = (error: unknown) => {
193
+ if (done) return;
194
+ done = true;
195
+ cleanup();
196
+ reject(toError(error));
197
+ };
198
+ const onError = (error: unknown) => fail(error);
199
+ const onSocketError = (error: unknown) => fail(error);
200
+ const onClose = () => finish();
201
+ const onEnd = () => {
202
+ if (!keepAlive && socketCanEnd(socket)) socket.end();
203
+ finish();
204
+ };
205
+ rs.on("error", onError);
206
+ rs.on("end", onEnd);
207
+ socket.once("close", onClose);
208
+ socket.once("error", onSocketError);
209
+ rs.pipe(socket, { end: false });
210
+ });
211
+ }
212
+
213
+ export async function handleExistingGet(
214
+ node: GnutellaServent,
215
+ socket: net.Socket,
216
+ head: string,
217
+ absPath: string,
218
+ share?: ShareFile,
219
+ ): Promise<boolean> {
220
+ const request = node.parseExistingGetRequest(head);
221
+ const stat = await fsp.stat(absPath);
222
+ const range = parseByteRange(request.headers["range"], stat.size);
223
+ if (!range)
224
+ return node.writeInvalidRangeResponse(socket, request, stat.size);
225
+ const remaining = node.existingGetBodyLength(range);
226
+ socket.write(
227
+ node.buildExistingGetResponseHeaders(
228
+ request,
229
+ range,
230
+ stat.size,
231
+ remaining,
232
+ share,
233
+ ),
234
+ );
235
+ if (request.method === "HEAD" || remaining === 0) {
236
+ return node.finishExistingGetResponse(socket, request.keepAlive);
237
+ }
238
+ await node.streamExistingGetBody(
239
+ socket,
240
+ absPath,
241
+ range,
242
+ request.keepAlive,
243
+ );
244
+ return request.keepAlive;
245
+ }
@@ -0,0 +1,168 @@
1
+ import zlib from "node:zlib";
2
+
3
+ const GGEP_MAGIC = 0xc3;
4
+ const GGEP_HDR_LAST = 0x80;
5
+ const GGEP_HDR_COBS = 0x40;
6
+ const GGEP_HDR_DEFLATE = 0x20;
7
+ const GGEP_HDR_RESERVED = 0x10;
8
+ const GGEP_HDR_IDLEN = 0x0f;
9
+ const GGEP_LEN_MORE = 0x80;
10
+ const GGEP_LEN_LAST = 0x40;
11
+ const GGEP_LEN_MASK = 0x3f;
12
+
13
+ export type GgepItem = {
14
+ id: string;
15
+ data: Buffer;
16
+ };
17
+
18
+ function hasZeroByte(data: Buffer): boolean {
19
+ return data.includes(0);
20
+ }
21
+
22
+ function cobsEncode(data: Buffer): Buffer {
23
+ const out = [0];
24
+ let codeIndex = 0;
25
+ let code = 1;
26
+ for (const byte of data) {
27
+ if (byte === 0) {
28
+ out[codeIndex] = code;
29
+ codeIndex = out.length;
30
+ out.push(0);
31
+ code = 1;
32
+ continue;
33
+ }
34
+ out.push(byte);
35
+ code++;
36
+ if (code === 0xff) {
37
+ out[codeIndex] = code;
38
+ codeIndex = out.length;
39
+ out.push(0);
40
+ code = 1;
41
+ }
42
+ }
43
+ out[codeIndex] = code;
44
+ return Buffer.from(out);
45
+ }
46
+
47
+ function cobsDecode(data: Buffer): Buffer {
48
+ const out: number[] = [];
49
+ let offset = 0;
50
+ while (offset < data.length) {
51
+ const code = data[offset++];
52
+ if (code === 0) throw new Error("invalid GGEP COBS block");
53
+ const next = offset + code - 1;
54
+ if (next > data.length + 1)
55
+ throw new Error("truncated GGEP COBS block");
56
+ for (; offset < next && offset < data.length; offset++) {
57
+ out.push(data[offset] as number);
58
+ }
59
+ if (code < 0xff && offset < data.length) out.push(0);
60
+ }
61
+ return Buffer.from(out);
62
+ }
63
+
64
+ function encodeLength(length: number): Buffer {
65
+ const bytes: number[] = [];
66
+ if (length & 0x3f000) {
67
+ bytes.push(((length >>> 12) & GGEP_LEN_MASK) | GGEP_LEN_MORE);
68
+ }
69
+ if (length & 0x0fc0) {
70
+ bytes.push(((length >>> 6) & GGEP_LEN_MASK) | GGEP_LEN_MORE);
71
+ }
72
+ bytes.push((length & GGEP_LEN_MASK) | GGEP_LEN_LAST);
73
+ return Buffer.from(bytes);
74
+ }
75
+
76
+ function decodeLength(
77
+ raw: Buffer,
78
+ start: number,
79
+ ): { length: number; nextOffset: number } {
80
+ let offset = start;
81
+ let length = 0;
82
+ for (let i = 0; i < 3; i++) {
83
+ const byte = raw[offset++];
84
+ if (byte == null || byte === 0) throw new Error("invalid GGEP length");
85
+ length = (length << 6) | (byte & GGEP_LEN_MASK);
86
+ const last = !!(byte & GGEP_LEN_LAST);
87
+ const more = !!(byte & GGEP_LEN_MORE);
88
+ if (last) {
89
+ if (more) throw new Error("invalid GGEP length flags");
90
+ return { length, nextOffset: offset };
91
+ }
92
+ if (!more) throw new Error("invalid GGEP length continuation");
93
+ }
94
+ throw new Error("GGEP length too long");
95
+ }
96
+
97
+ function maybeDeflateDecode(data: Buffer, flags: number): Buffer {
98
+ if (!(flags & GGEP_HDR_DEFLATE)) return data;
99
+ return zlib.inflateRawSync(data);
100
+ }
101
+
102
+ function parseGgepItem(
103
+ raw: Buffer,
104
+ start: number,
105
+ ): { item: GgepItem; nextOffset: number; last: boolean } {
106
+ let offset = start;
107
+ const flags = raw[offset++];
108
+ if (flags == null) throw new Error("truncated GGEP flags");
109
+ if ((flags & GGEP_HDR_RESERVED) !== 0)
110
+ throw new Error("invalid GGEP reserved bit");
111
+ const idLength = flags & GGEP_HDR_IDLEN;
112
+ if (!idLength) throw new Error("invalid GGEP id length");
113
+ if (offset + idLength > raw.length) throw new Error("truncated GGEP id");
114
+ const id = raw.subarray(offset, offset + idLength).toString("ascii");
115
+ offset += idLength;
116
+ const decodedLength = decodeLength(raw, offset);
117
+ offset = decodedLength.nextOffset;
118
+ if (offset + decodedLength.length > raw.length)
119
+ throw new Error("truncated GGEP payload");
120
+ let data = Buffer.from(
121
+ raw.subarray(offset, offset + decodedLength.length),
122
+ ) as Buffer;
123
+ offset += decodedLength.length;
124
+ if (flags & GGEP_HDR_COBS) data = cobsDecode(data) as Buffer;
125
+ data = maybeDeflateDecode(data, flags) as Buffer;
126
+ return {
127
+ item: { id, data },
128
+ nextOffset: offset,
129
+ last: !!(flags & GGEP_HDR_LAST),
130
+ };
131
+ }
132
+
133
+ export function encodeGgep(items: GgepItem[]): Buffer {
134
+ if (!items.length) return Buffer.alloc(0);
135
+ const parts: Buffer[] = [Buffer.from([GGEP_MAGIC])];
136
+ items.forEach((item, index) => {
137
+ const id = item.id.trim();
138
+ if (!id || id.length > 15)
139
+ throw new Error(`invalid GGEP id ${JSON.stringify(item.id)}`);
140
+ let flags = id.length;
141
+ let data = item.data;
142
+ if (hasZeroByte(data)) {
143
+ data = cobsEncode(data);
144
+ flags |= GGEP_HDR_COBS;
145
+ }
146
+ if (index === items.length - 1) flags |= GGEP_HDR_LAST;
147
+ parts.push(
148
+ Buffer.from([flags]),
149
+ Buffer.from(id, "ascii"),
150
+ encodeLength(data.length),
151
+ data,
152
+ );
153
+ });
154
+ return Buffer.concat(parts);
155
+ }
156
+
157
+ export function parseGgep(raw: Buffer): GgepItem[] {
158
+ if (!raw.length || raw[0] !== GGEP_MAGIC) return [];
159
+ const items: GgepItem[] = [];
160
+ let offset = 1;
161
+ while (offset < raw.length) {
162
+ const parsed = parseGgepItem(raw, offset);
163
+ items.push(parsed.item);
164
+ offset = parsed.nextOffset;
165
+ if (parsed.last) break;
166
+ }
167
+ return items;
168
+ }