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,715 @@
1
+ import { HEADER_LEN } from "../const";
2
+ import { bytesToIpBE, ipToBytesBE } from "../shared";
3
+ import type {
4
+ QueryDescriptor,
5
+ QueryHitDescriptor,
6
+ ShareFile,
7
+ } from "../types";
8
+ import type {
9
+ DescriptorHeader,
10
+ QueryEncodeOptions,
11
+ QueryHitEncodeOptions,
12
+ QueryHitResult,
13
+ } from "./node_types";
14
+ import { DEFAULT_VENDOR_CODE } from "../const";
15
+ import { parseHttpHeaders } from "./handshake";
16
+ import { parseGgep, encodeGgep, type GgepItem } from "./ggep";
17
+ import {
18
+ bitprintUrnFromGgepHash,
19
+ firstSha1Urn,
20
+ normalizeUrnList,
21
+ sha1BufferFromUrn,
22
+ textUrnFromGgepUrn,
23
+ } from "./content_urn";
24
+ import { splitQuerySearch } from "./query_search";
25
+ import { sha1ToUrn } from "./qrp";
26
+
27
+ const MODERN_QUERY_FLAG_BITS = [
28
+ ["requesterFirewalled", 14],
29
+ ["wantsXml", 13],
30
+ ["leafGuidedDynamic", 12],
31
+ ["ggepHAllowed", 11],
32
+ ["outOfBand", 10],
33
+ ] as const;
34
+
35
+ function normalizedModernQueryMaxHits(
36
+ maxHits: number | undefined,
37
+ ): number {
38
+ return Math.max(0, Math.min(0x1ff, maxHits ?? 0));
39
+ }
40
+
41
+ function buildModernQueryFlags(options?: QueryEncodeOptions): number {
42
+ let flags = 0x8000;
43
+ for (const [key, bit] of MODERN_QUERY_FLAG_BITS) {
44
+ if (options?.[key]) flags |= 1 << bit;
45
+ }
46
+ flags |= normalizedModernQueryMaxHits(options?.maxHits);
47
+ return flags >>> 0;
48
+ }
49
+
50
+ function splitFsBlocks(buf: Buffer): Buffer[] {
51
+ if (!buf.length) return [];
52
+ const blocks: Buffer[] = [];
53
+ let start = 0;
54
+ for (let i = 0; i < buf.length; i++) {
55
+ if (buf[i] === 0x1c) {
56
+ blocks.push(buf.subarray(start, i));
57
+ start = i + 1;
58
+ }
59
+ }
60
+ blocks.push(buf.subarray(start));
61
+ return blocks.filter((x) => x.length > 0);
62
+ }
63
+
64
+ function splitTextAndGgepExtensions(rawExtensions: Buffer): {
65
+ textBlocks: Buffer[];
66
+ ggepItems: GgepItem[];
67
+ } {
68
+ const ggepStart = rawExtensions.indexOf(0xc3);
69
+ if (ggepStart === -1) {
70
+ return {
71
+ textBlocks: splitFsBlocks(rawExtensions),
72
+ ggepItems: [],
73
+ };
74
+ }
75
+ let ggepItems: GgepItem[] = [];
76
+ try {
77
+ ggepItems = parseGgep(rawExtensions.subarray(ggepStart));
78
+ } catch {
79
+ ggepItems = [];
80
+ }
81
+ return {
82
+ textBlocks: splitFsBlocks(rawExtensions.subarray(0, ggepStart)),
83
+ ggepItems,
84
+ };
85
+ }
86
+
87
+ function urnsFromGgepHash(data: Buffer): string[] {
88
+ const hashType = data[0];
89
+ if (hashType === 0x01 && data.length >= 21) {
90
+ return [sha1ToUrn(data.subarray(1, 21))];
91
+ }
92
+ if (hashType === 0x02 && data.length >= 21) {
93
+ const bitprint = bitprintUrnFromGgepHash(data);
94
+ if (bitprint) return normalizeUrnList([bitprint]);
95
+ return [sha1ToUrn(data.subarray(1, 21))];
96
+ }
97
+ return [];
98
+ }
99
+
100
+ function urnsFromGgepItems(items: GgepItem[]): string[] {
101
+ const rawUrns: string[] = [];
102
+ for (const item of items) {
103
+ if (item.id === "H") {
104
+ rawUrns.push(...urnsFromGgepHash(item.data));
105
+ continue;
106
+ }
107
+ if (item.id === "u") {
108
+ const textUrn = textUrnFromGgepUrn(item.data);
109
+ if (textUrn) rawUrns.push(textUrn);
110
+ }
111
+ }
112
+ return normalizeUrnList(rawUrns);
113
+ }
114
+
115
+ function parseQueryExtensions(rawExtensions: Buffer): {
116
+ urns: string[];
117
+ xmlBlocks: string[];
118
+ } {
119
+ const rawUrns: string[] = [];
120
+ const xmlBlocks: string[] = [];
121
+ const { textBlocks, ggepItems } =
122
+ splitTextAndGgepExtensions(rawExtensions);
123
+ for (const block of textBlocks) {
124
+ if (!block.length) continue;
125
+ const text = block.toString("utf8");
126
+ if (text.startsWith("urn:")) rawUrns.push(text);
127
+ else if (text.startsWith("<") || text.startsWith("{"))
128
+ xmlBlocks.push(text);
129
+ }
130
+ return {
131
+ urns: normalizeUrnList([...rawUrns, ...urnsFromGgepItems(ggepItems)]),
132
+ xmlBlocks,
133
+ };
134
+ }
135
+
136
+ function qhdFlagEnabled(
137
+ enabler: number,
138
+ setter: number,
139
+ bit: number,
140
+ ): boolean {
141
+ if (bit === 0) return !!(setter & 1) && !!(enabler & 1);
142
+ return !!(enabler & (1 << bit)) && !!(setter & (1 << bit));
143
+ }
144
+
145
+ function qhdFlagMeaningful(
146
+ enabler: number,
147
+ setter: number,
148
+ bit: number,
149
+ ): boolean {
150
+ if (bit === 0) return !!(setter & 1);
151
+ return !!(enabler & (1 << bit));
152
+ }
153
+
154
+ function buildQhdBlock(options: {
155
+ vendorCode?: string;
156
+ push: boolean;
157
+ busy?: boolean;
158
+ haveUploaded?: boolean;
159
+ measuredSpeed?: boolean;
160
+ ggep?: boolean;
161
+ privateArea?: Buffer;
162
+ }): Buffer {
163
+ const vendor = Buffer.alloc(4, 0);
164
+ Buffer.from(
165
+ (options.vendorCode || DEFAULT_VENDOR_CODE).slice(0, 4).padEnd(4, " "),
166
+ "ascii",
167
+ ).copy(vendor);
168
+ const openData = Buffer.alloc(2, 0);
169
+ if (options.ggep) {
170
+ openData[0] |= 1 << 5;
171
+ openData[1] |= 1 << 5;
172
+ }
173
+ openData[0] |= 1 << 2;
174
+ if (options.busy) openData[1] |= 1 << 2;
175
+ openData[0] |= 1 << 3;
176
+ if (options.haveUploaded) openData[1] |= 1 << 3;
177
+ openData[0] |= 1 << 4;
178
+ if (options.measuredSpeed) openData[1] |= 1 << 4;
179
+ openData[1] |= 1;
180
+ if (options.push) openData[0] |= 1;
181
+ const privateArea = options.privateArea || Buffer.alloc(0);
182
+ return Buffer.concat([
183
+ vendor,
184
+ Buffer.from([openData.length]),
185
+ openData,
186
+ privateArea,
187
+ ]);
188
+ }
189
+
190
+ function parseQueryHitQhd(
191
+ privateBlock: Buffer,
192
+ ): Partial<QueryHitDescriptor> {
193
+ if (privateBlock.length < 5) return {};
194
+ const vendorCode = privateBlock.subarray(0, 4).toString("ascii");
195
+ const openDataSize = privateBlock[4];
196
+ if (5 + openDataSize > privateBlock.length) return { vendorCode };
197
+ const openData = privateBlock.subarray(5, 5 + openDataSize);
198
+ const privateArea = privateBlock.subarray(5 + openDataSize);
199
+ const enabler = openData[0] || 0;
200
+ const setter = openData[1] || 0;
201
+ return {
202
+ vendorCode,
203
+ openDataSize,
204
+ flagGgep: readQhdFlag(enabler, setter, 5),
205
+ flagUploadSpeedMeasured: readQhdFlag(enabler, setter, 4),
206
+ flagHaveUploaded: readQhdFlag(enabler, setter, 3),
207
+ flagBusy: readQhdFlag(enabler, setter, 2),
208
+ flagPush: readQhdFlag(enabler, setter, 0),
209
+ qhdPrivateArea: privateArea,
210
+ };
211
+ }
212
+
213
+ function readQhdFlag(
214
+ enabler: number,
215
+ setter: number,
216
+ bit: number,
217
+ ): boolean | undefined {
218
+ return qhdFlagMeaningful(enabler, setter, bit)
219
+ ? qhdFlagEnabled(enabler, setter, bit)
220
+ : undefined;
221
+ }
222
+
223
+ function parseQueryHitExtension(rawExtension: Buffer): {
224
+ urns: string[];
225
+ metadata: string[];
226
+ } {
227
+ const rawUrns: string[] = [];
228
+ const metadata: string[] = [];
229
+ const { textBlocks, ggepItems } =
230
+ splitTextAndGgepExtensions(rawExtension);
231
+ for (const block of textBlocks) {
232
+ const text = block.toString("utf8");
233
+ if (text.startsWith("urn:")) rawUrns.push(text);
234
+ else if (text) metadata.push(text);
235
+ }
236
+ return {
237
+ urns: normalizeUrnList([...rawUrns, ...urnsFromGgepItems(ggepItems)]),
238
+ metadata,
239
+ };
240
+ }
241
+
242
+ function ggepSha1Item(sha1: Buffer | undefined): GgepItem | undefined {
243
+ return sha1
244
+ ? {
245
+ id: "H",
246
+ data: Buffer.concat([Buffer.from([0x01]), sha1]),
247
+ }
248
+ : undefined;
249
+ }
250
+
251
+ function ggepHashItemsFromUrns(
252
+ urns: string[],
253
+ enabled: boolean,
254
+ ): GgepItem[] {
255
+ if (!enabled) return [];
256
+ const sha1 = sha1BufferFromUrn(firstSha1Urn(urns) || "");
257
+ const item = ggepSha1Item(sha1);
258
+ return item ? [item] : [];
259
+ }
260
+
261
+ function ggepHashItemsForShare(
262
+ share: ShareFile,
263
+ textUrns: string[],
264
+ enabled: boolean,
265
+ ): GgepItem[] {
266
+ if (!enabled) return [];
267
+ const sha1 =
268
+ share.sha1 || sha1BufferFromUrn(firstSha1Urn(textUrns) || "");
269
+ const item = ggepSha1Item(sha1);
270
+ return item ? [item] : [];
271
+ }
272
+
273
+ function ggepBrowseHostItem(enabled: boolean): GgepItem[] {
274
+ return enabled ? [{ id: "BH", data: Buffer.alloc(0) }] : [];
275
+ }
276
+
277
+ function buildExtensionPayload(
278
+ textParts: Buffer[],
279
+ ggepItems: GgepItem[],
280
+ ): Buffer {
281
+ const ggep = ggepItems.length ? encodeGgep(ggepItems) : Buffer.alloc(0);
282
+ const blocks = [...textParts, ...(ggep.length ? [ggep] : [])];
283
+ if (!blocks.length) return Buffer.alloc(0);
284
+ return Buffer.concat(
285
+ blocks.flatMap((block, index) =>
286
+ index === 0 ? [block] : [Buffer.from([0x1c]), block],
287
+ ),
288
+ );
289
+ }
290
+
291
+ function queryHitFieldEnd(
292
+ payload: Buffer,
293
+ start: number,
294
+ endLimit: number,
295
+ label: string,
296
+ ): number {
297
+ const end = payload.indexOf(0x00, start);
298
+ if (end === -1 || end > endLimit)
299
+ throw new Error(`truncated query hit ${label}`);
300
+ return end;
301
+ }
302
+
303
+ function parseQueryHitResultAt(
304
+ payload: Buffer,
305
+ offset: number,
306
+ ): {
307
+ result: QueryHitResult;
308
+ nextOffset: number;
309
+ } {
310
+ const tailStart = payload.length - 16;
311
+ if (offset + 8 > tailStart)
312
+ throw new Error("truncated query hit result header");
313
+ const fileIndex = payload.readUInt32LE(offset);
314
+ const fileSize = payload.readUInt32LE(offset + 4);
315
+ const nameEnd = queryHitFieldEnd(
316
+ payload,
317
+ offset + 8,
318
+ tailStart,
319
+ "file name",
320
+ );
321
+ const fileName = payload.subarray(offset + 8, nameEnd).toString("utf8");
322
+ const extStart = nameEnd + 1;
323
+ const extEnd = queryHitFieldEnd(
324
+ payload,
325
+ extStart,
326
+ tailStart,
327
+ "extension block",
328
+ );
329
+ const rawExtension = payload.subarray(extStart, extEnd);
330
+ return {
331
+ result: {
332
+ fileIndex,
333
+ fileSize,
334
+ fileName,
335
+ ...parseQueryHitExtension(rawExtension),
336
+ rawExtension,
337
+ },
338
+ nextOffset: extEnd + 1,
339
+ };
340
+ }
341
+
342
+ function parseByteRangeSuffix(
343
+ endRaw: string,
344
+ size: number,
345
+ last: number,
346
+ ): { start: number; end: number; partial: boolean } | null {
347
+ const suffixLen = Number(endRaw);
348
+ if (!Number.isInteger(suffixLen) || suffixLen <= 0) return null;
349
+ const length = Math.min(suffixLen, size);
350
+ return { start: size - length, end: last, partial: length < size };
351
+ }
352
+
353
+ function explicitByteRangeEnd(
354
+ endRaw: string,
355
+ start: number,
356
+ size: number,
357
+ last: number,
358
+ ): number | undefined {
359
+ const end = endRaw ? Number(endRaw) : last;
360
+ if (!Number.isInteger(end) || end < start) return undefined;
361
+ if (size === 0) return -1;
362
+ return Math.min(end, last);
363
+ }
364
+
365
+ function parseByteRangeExplicit(
366
+ startRaw: string,
367
+ endRaw: string,
368
+ size: number,
369
+ last: number,
370
+ ): { start: number; end: number; partial: boolean } | null {
371
+ const start = Number(startRaw);
372
+ if (!Number.isInteger(start) || start < 0) return null;
373
+ if (size > 0 && start > last) return null;
374
+ const end = explicitByteRangeEnd(endRaw, start, size, last);
375
+ if (end == null) return null;
376
+ return { start, end, partial: size > 0 && (start > 0 || end < last) };
377
+ }
378
+
379
+ export function parseByteRange(
380
+ rangeHeader: string | undefined,
381
+ size: number,
382
+ ): { start: number; end: number; partial: boolean } | null {
383
+ const last = size > 0 ? size - 1 : -1;
384
+ if (!rangeHeader) return { start: 0, end: last, partial: false };
385
+ const m = /^bytes=(\d*)-(\d*)$/i.exec(rangeHeader.trim());
386
+ if (!m) return null;
387
+ const startRaw = m[1];
388
+ const endRaw = m[2];
389
+ if (!startRaw && !endRaw) return null;
390
+ if (!startRaw) return parseByteRangeSuffix(endRaw, size, last);
391
+ return parseByteRangeExplicit(startRaw, endRaw, size, last);
392
+ }
393
+
394
+ export function buildHeader(
395
+ descriptorId: Buffer,
396
+ payloadType: number,
397
+ ttl: number,
398
+ hops: number,
399
+ payload: Buffer,
400
+ ): Buffer {
401
+ const h = Buffer.alloc(HEADER_LEN);
402
+ descriptorId.copy(h, 0, 0, 16);
403
+ h[16] = payloadType & 0xff;
404
+ h[17] = ttl & 0xff;
405
+ h[18] = hops & 0xff;
406
+ h.writeUInt32LE(payload.length >>> 0, 19);
407
+ return Buffer.concat([h, payload]);
408
+ }
409
+
410
+ export function parseHeader(buf: Buffer): DescriptorHeader {
411
+ return {
412
+ descriptorId: buf.subarray(0, 16),
413
+ descriptorIdHex: buf.subarray(0, 16).toString("hex"),
414
+ payloadType: buf[16],
415
+ ttl: buf[17],
416
+ hops: buf[18],
417
+ payloadLength: buf.readUInt32LE(19),
418
+ };
419
+ }
420
+
421
+ export function encodePong(
422
+ port: number,
423
+ ip: string,
424
+ files: number,
425
+ kbytes: number,
426
+ ggep?: Buffer,
427
+ ): Buffer {
428
+ const b = Buffer.alloc(14 + (ggep?.length || 0));
429
+ b.writeUInt16LE(port & 0xffff, 0);
430
+ ipToBytesBE(ip).copy(b, 2);
431
+ b.writeUInt32LE(files >>> 0, 6);
432
+ b.writeUInt32LE(kbytes >>> 0, 10);
433
+ if (ggep?.length) ggep.copy(b, 14);
434
+ return b;
435
+ }
436
+
437
+ export function parsePong(payload: Buffer) {
438
+ if (payload.length < 14)
439
+ throw new Error(`invalid pong length ${payload.length}`);
440
+ return {
441
+ port: payload.readUInt16LE(0),
442
+ ip: bytesToIpBE(payload.subarray(2, 6)),
443
+ files: payload.readUInt32LE(6),
444
+ kbytes: payload.readUInt32LE(10),
445
+ ggep: payload.subarray(14),
446
+ };
447
+ }
448
+
449
+ export function encodeQuery(
450
+ search: string,
451
+ options: QueryEncodeOptions = {},
452
+ ): Buffer {
453
+ const s = Buffer.from(search, "utf8");
454
+ const urns = normalizeUrnList(options.urns || []);
455
+ const ext = buildExtensionPayload(
456
+ [
457
+ ...urns.map((urn) => Buffer.from(urn, "utf8")),
458
+ ...(options.xmlBlocks || []).map((xml) => Buffer.from(xml, "utf8")),
459
+ ],
460
+ [
461
+ ...ggepHashItemsFromUrns(urns, !!options.ggepHAllowed),
462
+ ...(options.ggepItems || []),
463
+ ],
464
+ );
465
+ const out = Buffer.alloc(2 + s.length + 1 + ext.length);
466
+ out.writeUInt16BE(buildModernQueryFlags(options), 0);
467
+ s.copy(out, 2);
468
+ out[2 + s.length] = 0;
469
+ if (ext.length) ext.copy(out, 3 + s.length);
470
+ return out;
471
+ }
472
+
473
+ export function parseQuery(payload: Buffer): QueryDescriptor {
474
+ if (payload.length < 3)
475
+ throw new Error(`invalid query length ${payload.length}`);
476
+ const flagsRaw = payload.readUInt16BE(0);
477
+ const nul = payload.indexOf(0x00, 2);
478
+ const end = nul === -1 ? payload.length : nul;
479
+ const rawSearch = payload.subarray(2, end).toString("utf8");
480
+ const rawExtensions =
481
+ nul === -1 ? Buffer.alloc(0) : payload.subarray(end + 1);
482
+ const { search, urns: inlineUrns } = splitQuerySearch(rawSearch);
483
+ const { urns: extensionUrns, xmlBlocks } =
484
+ parseQueryExtensions(rawExtensions);
485
+ const urns = normalizeUrnList([...inlineUrns, ...extensionUrns]);
486
+ const normalizedSearch =
487
+ urns.length > 0 && search === "\\" ? "" : search;
488
+ return {
489
+ search: normalizedSearch,
490
+ flagsRaw,
491
+ requesterFirewalled: !!(flagsRaw & (1 << 14)),
492
+ wantsXml: !!(flagsRaw & (1 << 13)),
493
+ leafGuidedDynamic: !!(flagsRaw & (1 << 12)),
494
+ ggepHAllowed: !!(flagsRaw & (1 << 11)),
495
+ outOfBand: !!(flagsRaw & (1 << 10)),
496
+ maxHits: flagsRaw & 0x1ff,
497
+ urns,
498
+ xmlBlocks,
499
+ rawExtensions,
500
+ };
501
+ }
502
+
503
+ export function encodeQueryHit(
504
+ port: number,
505
+ ip: string,
506
+ speedKBps: number,
507
+ results: ShareFile[],
508
+ serventId: Buffer,
509
+ options: QueryHitEncodeOptions = {},
510
+ ): Buffer {
511
+ const parts: Buffer[] = [];
512
+ const trailerGgepItems = [
513
+ ...ggepBrowseHostItem(!!options.browseHost),
514
+ ...(options.privateGgepItems || []),
515
+ ];
516
+ let ggepUsed = trailerGgepItems.length > 0;
517
+ parts.push(Buffer.from([results.length & 0xff]));
518
+ const head = Buffer.alloc(10);
519
+ head.writeUInt16LE(port & 0xffff, 0);
520
+ ipToBytesBE(ip).copy(head, 2);
521
+ head.writeUInt32LE(speedKBps >>> 0, 6);
522
+ parts.push(head);
523
+ for (const r of results) {
524
+ const name = Buffer.from(r.name, "utf8");
525
+ const item = Buffer.alloc(8);
526
+ item.writeUInt32LE(r.index >>> 0, 0);
527
+ item.writeUInt32LE(r.size >>> 0, 4);
528
+ const textUrns = normalizeUrnList(r.sha1Urn ? [r.sha1Urn] : []);
529
+ const ggepItems = ggepHashItemsForShare(
530
+ r,
531
+ textUrns,
532
+ !!options.ggepHashes,
533
+ );
534
+ if (ggepItems.length) ggepUsed = true;
535
+ const ext = buildExtensionPayload(
536
+ textUrns.map((urn) => Buffer.from(urn, "utf8")),
537
+ ggepItems,
538
+ );
539
+ parts.push(item, name, Buffer.from([0x00]), ext, Buffer.from([0x00]));
540
+ }
541
+ const trailerPrivateArea = trailerGgepItems.length
542
+ ? encodeGgep(trailerGgepItems)
543
+ : Buffer.alloc(0);
544
+ parts.push(
545
+ buildQhdBlock({
546
+ vendorCode: options.vendorCode,
547
+ push: !!options.push,
548
+ busy: options.busy,
549
+ haveUploaded: options.haveUploaded,
550
+ measuredSpeed: options.measuredSpeed,
551
+ ggep: ggepUsed,
552
+ privateArea: trailerPrivateArea,
553
+ }),
554
+ );
555
+ parts.push(serventId);
556
+ return Buffer.concat(parts);
557
+ }
558
+
559
+ export function parseQueryHit(payload: Buffer): QueryHitDescriptor {
560
+ if (payload.length < 27)
561
+ throw new Error(`invalid query hit length ${payload.length}`);
562
+ const hits = payload[0];
563
+ const port = payload.readUInt16LE(1);
564
+ const ip = bytesToIpBE(payload.subarray(3, 7));
565
+ const speedKBps = payload.readUInt32LE(7);
566
+ let off = 11;
567
+ const results: QueryHitDescriptor["results"] = [];
568
+ for (let i = 0; i < hits; i++) {
569
+ const parsed = parseQueryHitResultAt(payload, off);
570
+ off = parsed.nextOffset;
571
+ results.push(parsed.result);
572
+ }
573
+ const serventId = payload.subarray(payload.length - 16);
574
+ const qhdBlock = payload.subarray(off, payload.length - 16);
575
+ return {
576
+ hits,
577
+ port,
578
+ ip,
579
+ speedKBps,
580
+ results,
581
+ ...parseQueryHitQhd(qhdBlock),
582
+ serventId,
583
+ serventIdHex: serventId.toString("hex"),
584
+ };
585
+ }
586
+
587
+ export function encodePush(
588
+ serventId: Buffer,
589
+ fileIndex: number,
590
+ ip: string,
591
+ port: number,
592
+ ): Buffer {
593
+ const b = Buffer.alloc(26);
594
+ serventId.copy(b, 0, 0, 16);
595
+ b.writeUInt32LE(fileIndex >>> 0, 16);
596
+ ipToBytesBE(ip).copy(b, 20);
597
+ b.writeUInt16LE(port & 0xffff, 24);
598
+ return b;
599
+ }
600
+
601
+ export function parsePush(payload: Buffer) {
602
+ if (payload.length < 26)
603
+ throw new Error(`invalid push length ${payload.length}`);
604
+ return {
605
+ serventId: payload.subarray(0, 16),
606
+ serventIdHex: payload.subarray(0, 16).toString("hex"),
607
+ fileIndex: payload.readUInt32LE(16),
608
+ ip: bytesToIpBE(payload.subarray(20, 24)),
609
+ port: payload.readUInt16LE(24),
610
+ ggep: payload.subarray(26),
611
+ };
612
+ }
613
+
614
+ export function encodeBye(code: number, message: string): Buffer {
615
+ const msg = Buffer.from(message, "utf8");
616
+ const payload = Buffer.alloc(2 + msg.length + 1);
617
+ payload.writeUInt16LE(code & 0xffff, 0);
618
+ msg.copy(payload, 2);
619
+ return payload;
620
+ }
621
+
622
+ export function parseBye(payload: Buffer): {
623
+ code: number;
624
+ message: string;
625
+ } {
626
+ if (payload.length < 2)
627
+ throw new Error(`invalid bye length ${payload.length}`);
628
+ const nul = payload.indexOf(0x00, 2);
629
+ const end = nul === -1 ? payload.length : nul;
630
+ return {
631
+ code: payload.readUInt16LE(0),
632
+ message: payload.subarray(2, end).toString("utf8"),
633
+ };
634
+ }
635
+
636
+ export function parseRouteTableUpdate(payload: Buffer):
637
+ | { variant: "reset"; tableLength: number; infinity: number }
638
+ | {
639
+ variant: "patch";
640
+ seqNo: number;
641
+ seqSize: number;
642
+ compressor: number;
643
+ entryBits: number;
644
+ data: Buffer;
645
+ } {
646
+ if (payload.length < 1)
647
+ throw new Error("invalid route table update length");
648
+ if (payload[0] === 0x00) {
649
+ if (payload.length < 6) throw new Error("invalid qrp reset length");
650
+ return {
651
+ variant: "reset",
652
+ tableLength: payload.readUInt32LE(1),
653
+ infinity: payload[5],
654
+ };
655
+ }
656
+ if (payload[0] === 0x01) {
657
+ if (payload.length < 6) throw new Error("invalid qrp patch length");
658
+ return {
659
+ variant: "patch",
660
+ seqNo: payload[1],
661
+ seqSize: payload[2],
662
+ compressor: payload[3],
663
+ entryBits: payload[4],
664
+ data: payload.subarray(5),
665
+ };
666
+ }
667
+ throw new Error(`unsupported qrp variant ${payload[0]}`);
668
+ }
669
+
670
+ export function buildGetRequest(
671
+ fileIndex: number,
672
+ fileName: string,
673
+ start: number,
674
+ host?: string,
675
+ port?: number,
676
+ ): string {
677
+ const rawName = encodeURI(fileName).replace(/#/g, "%23");
678
+ const hostHeader = host && port ? `Host: ${host}:${port}\r\n` : "";
679
+ return `GET /get/${fileIndex}/${rawName} HTTP/1.1\r\nUser-Agent: Gnutella\r\n${hostHeader}Connection: Keep-Alive\r\nRange: bytes=${start}-\r\n\r\n`;
680
+ }
681
+
682
+ export function buildUriResRequest(
683
+ urn: string,
684
+ start: number,
685
+ host?: string,
686
+ port?: number,
687
+ ): string {
688
+ const hostHeader = host && port ? `Host: ${host}:${port}\r\n` : "";
689
+ return `GET /uri-res/N2R?${urn} HTTP/1.1\r\nUser-Agent: Gnutella\r\n${hostHeader}Connection: Keep-Alive\r\nRange: bytes=${start}-\r\n\r\n`;
690
+ }
691
+
692
+ export function parseHttpDownloadHeader(
693
+ head: string,
694
+ requestedStart: number,
695
+ ): { remaining: number; finalStart: number } {
696
+ const first = head.replace(/\r\n/g, "\n").split("\n", 1)[0];
697
+ const match = /^HTTP\/(\d+\.\d+)\s+(\d+)/i.exec(first);
698
+ if (!match) throw new Error("invalid HTTP response");
699
+ const status = Number(match[2]);
700
+ const headers = parseHttpHeaders(head);
701
+ const remaining = Number(headers["content-length"] || NaN);
702
+ if (!Number.isFinite(remaining) || remaining < 0)
703
+ throw new Error("missing Content-length");
704
+ if (status === 206) {
705
+ const rangeMatch = /^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i.exec(
706
+ headers["content-range"] || "",
707
+ );
708
+ return {
709
+ remaining,
710
+ finalStart: rangeMatch ? Number(rangeMatch[1]) : requestedStart,
711
+ };
712
+ }
713
+ if (status === 200) return { remaining, finalStart: 0 };
714
+ throw new Error(`unexpected HTTP status ${status}`);
715
+ }