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,549 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import zlib from "node:zlib";
4
+
5
+ import {
6
+ DEFAULT_QRP_ENTRY_BITS,
7
+ DEFAULT_QRP_INFINITY,
8
+ DEFAULT_QRP_TABLE_SIZE,
9
+ QRP_COMPRESSOR_DEFLATE,
10
+ QRP_COMPRESSOR_NONE,
11
+ QRP_HASH_MULTIPLIER,
12
+ } from "../const";
13
+ import type { QueryDescriptor, RemoteQrpState, ShareFile } from "../types";
14
+ import { base32Encode } from "./content_urn";
15
+
16
+ type QrpPatchMessage = {
17
+ seqNo: number;
18
+ seqSize: number;
19
+ compressor: number;
20
+ entryBits: number;
21
+ };
22
+
23
+ type QrpResetMessage = {
24
+ tableLength: number;
25
+ infinity: number;
26
+ };
27
+
28
+ const QRP_MIN_WORD_LENGTH = 3;
29
+ const QRP_MAX_CUT_CHARS = 5;
30
+
31
+ export function splitSearchTerms(input: string): string[] {
32
+ const ascii = input
33
+ .normalize("NFKD")
34
+ .replace(/[\u0300-\u036f]/g, "")
35
+ .toLowerCase();
36
+ return ascii.split(/[^a-z0-9]+/).filter(Boolean);
37
+ }
38
+
39
+ export function tokenizeKeywords(input: string): string[] {
40
+ return [...new Set(splitSearchTerms(input).filter((x) => x.length > 1))];
41
+ }
42
+
43
+ function qrpQueryTerms(input: string): string[] {
44
+ return [
45
+ ...new Set(
46
+ splitSearchTerms(input).filter(
47
+ (x) => x.length >= QRP_MIN_WORD_LENGTH,
48
+ ),
49
+ ),
50
+ ];
51
+ }
52
+
53
+ function qrpIndexTerms(input: string): string[] {
54
+ const out: string[] = [];
55
+ const seen = new Set<string>();
56
+ for (const term of qrpQueryTerms(input)) {
57
+ let candidate = term;
58
+ for (let trim = 0; trim <= QRP_MAX_CUT_CHARS; trim++) {
59
+ if (!seen.has(candidate)) {
60
+ seen.add(candidate);
61
+ out.push(candidate);
62
+ }
63
+ if (candidate.length <= QRP_MIN_WORD_LENGTH) break;
64
+ const next = candidate.slice(0, -1);
65
+ if (next.length <= QRP_MIN_WORD_LENGTH) break;
66
+ candidate = next;
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ function qrpWordHitThreshold(hit: number, word: number): boolean {
73
+ return word < 3 ? hit === word : Math.trunc((3 * hit) / word) >= 2;
74
+ }
75
+
76
+ function qrpTermsMatch(
77
+ terms: string[],
78
+ hasTerm: (term: string) => boolean,
79
+ ): boolean {
80
+ if (!terms.length) return true;
81
+ let hit = 0;
82
+ for (const term of terms) {
83
+ if (hasTerm(term)) hit++;
84
+ }
85
+ return qrpWordHitThreshold(hit, terms.length);
86
+ }
87
+
88
+ function encodeSignedPatchValue(delta: number, bits: number): number {
89
+ const signBit = 1 << (bits - 1);
90
+ const min = -signBit;
91
+ const max = signBit - 1;
92
+ if (delta < min || delta > max)
93
+ throw new Error(`QRP ${bits}-bit patch delta out of range ${delta}`);
94
+ return delta & ((1 << bits) - 1);
95
+ }
96
+
97
+ function applyPresencePatchValue(
98
+ current: number,
99
+ infinity: number,
100
+ encoded: number,
101
+ bits: number,
102
+ ): number {
103
+ if (encoded === 0) return current;
104
+ const signBit = 1 << (bits - 1);
105
+ return encoded & signBit ? 1 : infinity;
106
+ }
107
+
108
+ function flipPresencePatchValue(
109
+ current: number,
110
+ infinity: number,
111
+ ): number {
112
+ return current < infinity ? infinity : 1;
113
+ }
114
+
115
+ function qrpHashBytes(str: string): number[] {
116
+ const bytes: number[] = [];
117
+ for (const char of str) {
118
+ let codePoint = char.codePointAt(0) ?? 0;
119
+ if (codePoint >= 0x41 && codePoint <= 0x5a) codePoint += 0x20;
120
+ if (codePoint <= 0xffff) {
121
+ bytes.push(codePoint & 0xff);
122
+ continue;
123
+ }
124
+ const value = codePoint - 0x10000;
125
+ const high = 0xd800 + (value >> 10);
126
+ const low = 0xdc00 + (value & 0x3ff);
127
+ bytes.push(high & 0xff, low & 0xff);
128
+ }
129
+ return bytes;
130
+ }
131
+
132
+ function qrpHash(str: string, bits: number): number {
133
+ const bytes = qrpHashBytes(str);
134
+ let xor = 0;
135
+ for (let i = 0; i < bytes.length; i++) xor ^= bytes[i] << ((i & 3) * 8);
136
+ const prod = BigInt(xor >>> 0) * BigInt(QRP_HASH_MULTIPLIER >>> 0);
137
+ const mask = (1n << BigInt(bits)) - 1n;
138
+ return Number((prod >> BigInt(32 - bits)) & mask) >>> 0;
139
+ }
140
+
141
+ export function sha1ToUrn(sha1: Buffer): string {
142
+ return `urn:sha1:${base32Encode(sha1)}`;
143
+ }
144
+
145
+ export async function sha1File(abs: string): Promise<Buffer> {
146
+ return await new Promise<Buffer>((resolve, reject) => {
147
+ const hash = crypto.createHash("sha1");
148
+ const rs = fs.createReadStream(abs);
149
+ rs.on("data", (chunk) => hash.update(chunk));
150
+ rs.on("error", reject);
151
+ rs.on("end", () => resolve(hash.digest()));
152
+ });
153
+ }
154
+
155
+ export function initialRemoteQrpState(): RemoteQrpState {
156
+ return {
157
+ resetSeen: false,
158
+ tableSize: 0,
159
+ infinity: DEFAULT_QRP_INFINITY,
160
+ entryBits: DEFAULT_QRP_ENTRY_BITS,
161
+ table: null,
162
+ seqSize: 0,
163
+ compressor: QRP_COMPRESSOR_NONE,
164
+ parts: new Map<number, Buffer>(),
165
+ };
166
+ }
167
+
168
+ export function validateRemoteQrpPatchSequence(
169
+ state: RemoteQrpState,
170
+ msg: QrpPatchMessage,
171
+ ): string | undefined {
172
+ return (
173
+ validateQrpResetSeen(state) ??
174
+ validateQrpPatchBounds(msg) ??
175
+ validateQrpPatchOrder(state, msg) ??
176
+ validateQrpPatchCodec(msg) ??
177
+ validateQrpPatchStability(state, msg)
178
+ );
179
+ }
180
+
181
+ export function validateRemoteQrpReset(
182
+ msg: QrpResetMessage,
183
+ ): string | undefined {
184
+ if (!isPowerOfTwo(msg.tableLength))
185
+ return `Invalid QRP table length ${msg.tableLength}`;
186
+ if (msg.infinity < 1) return `Invalid QRP infinity ${msg.infinity}`;
187
+ return undefined;
188
+ }
189
+
190
+ function isPowerOfTwo(value: number): boolean {
191
+ return value > 0 && Number.isInteger(Math.log2(value));
192
+ }
193
+
194
+ function validateQrpResetSeen(state: RemoteQrpState): string | undefined {
195
+ if (!state.resetSeen) return "No QRP RESET received before PATCH";
196
+ return undefined;
197
+ }
198
+
199
+ function validateQrpPatchBounds(msg: QrpPatchMessage): string | undefined {
200
+ if (msg.seqSize < 1 || msg.seqNo < 1 || msg.seqNo > msg.seqSize)
201
+ return `Invalid QRP seq number ${msg.seqNo} of ${msg.seqSize}`;
202
+ return undefined;
203
+ }
204
+
205
+ function validateQrpPatchOrder(
206
+ state: RemoteQrpState,
207
+ msg: QrpPatchMessage,
208
+ ): string | undefined {
209
+ const expectedSeqNo = state.seqSize ? state.parts.size + 1 : 1;
210
+ if (msg.seqNo !== expectedSeqNo)
211
+ return `Invalid QRP seq number ${msg.seqNo} (expected ${expectedSeqNo})`;
212
+ if (state.seqSize && msg.seqSize !== state.seqSize)
213
+ return `Changed QRP seq size to ${msg.seqSize} at message #${msg.seqNo} (began with ${state.seqSize})`;
214
+ return undefined;
215
+ }
216
+
217
+ function validateQrpPatchCodec(msg: QrpPatchMessage): string | undefined {
218
+ if (
219
+ msg.compressor !== QRP_COMPRESSOR_NONE &&
220
+ msg.compressor !== QRP_COMPRESSOR_DEFLATE
221
+ )
222
+ return `Invalid QRP compressor ${msg.compressor}`;
223
+
224
+ if (![1, 2, 4, 8].includes(msg.entryBits))
225
+ return `Invalid QRP entry bits ${msg.entryBits}`;
226
+ return undefined;
227
+ }
228
+
229
+ function validateQrpPatchStability(
230
+ state: RemoteQrpState,
231
+ msg: QrpPatchMessage,
232
+ ): string | undefined {
233
+ if (state.seqSize && msg.entryBits !== state.entryBits)
234
+ return `Changed QRP patch entry bits to ${msg.entryBits} at message #${msg.seqNo} (began with ${state.entryBits})`;
235
+
236
+ if (state.seqSize && msg.compressor !== state.compressor)
237
+ return `Changed QRP compressor to ${msg.compressor} at message #${msg.seqNo} (began with ${state.compressor})`;
238
+
239
+ return undefined;
240
+ }
241
+
242
+ export class QrpTable {
243
+ tableSize: number;
244
+ infinity: number;
245
+ entryBits: number;
246
+ table: Uint8Array;
247
+
248
+ constructor(
249
+ tableSize = DEFAULT_QRP_TABLE_SIZE,
250
+ infinity = DEFAULT_QRP_INFINITY,
251
+ entryBits = DEFAULT_QRP_ENTRY_BITS,
252
+ ) {
253
+ this.tableSize = tableSize;
254
+ this.infinity = infinity;
255
+ this.entryBits = entryBits;
256
+ this.table = new Uint8Array(tableSize);
257
+ this.clear();
258
+ }
259
+
260
+ clear(): void {
261
+ this.table.fill(this.infinity);
262
+ }
263
+
264
+ rebuildFromShares(shares: ShareFile[]): void {
265
+ this.clear();
266
+ for (const share of shares) {
267
+ for (const kw of share.keywords) {
268
+ for (const term of qrpIndexTerms(kw))
269
+ this.table[this.hashKeyword(term)] = 1;
270
+ }
271
+ }
272
+ }
273
+
274
+ hashKeyword(keyword: string): number {
275
+ return qrpHash(keyword, Math.log2(this.tableSize));
276
+ }
277
+
278
+ matchesQuery(search: string): boolean {
279
+ const kws = qrpQueryTerms(search);
280
+ return qrpTermsMatch(
281
+ kws,
282
+ (kw) => this.table[this.hashKeyword(kw)] < this.infinity,
283
+ );
284
+ }
285
+
286
+ mergePresenceTable(table: Uint8Array, infinity: number): void {
287
+ const length = Math.min(this.table.length, table.length);
288
+ for (let i = 0; i < length; i++) {
289
+ if (table[i] < infinity) this.table[i] = 1;
290
+ }
291
+ }
292
+
293
+ mergeFromQrp(other: Pick<QrpTable, "table" | "infinity">): void {
294
+ this.mergePresenceTable(other.table, other.infinity);
295
+ }
296
+
297
+ mergeFromRemoteQrp(state: RemoteQrpState): void {
298
+ if (!state.table) return;
299
+ this.mergePresenceTable(state.table, state.infinity);
300
+ }
301
+
302
+ encodeReset(): Buffer {
303
+ const payload = Buffer.alloc(6);
304
+ payload[0] = 0x00;
305
+ payload.writeUInt32LE(this.tableSize, 1);
306
+ payload[5] = this.infinity;
307
+ return payload;
308
+ }
309
+
310
+ encodePatchChunks(maxChunkPayload: number): Buffer[] {
311
+ const packed = this.packTable();
312
+ const compressed = zlib.deflateSync(packed);
313
+ const chunks: Buffer[] = [];
314
+ const partSize = Math.max(256, maxChunkPayload - 5);
315
+ const parts: Buffer[] = [];
316
+ for (let off = 0; off < compressed.length; off += partSize)
317
+ parts.push(compressed.subarray(off, off + partSize));
318
+ for (let i = 0; i < parts.length; i++) {
319
+ const payload = Buffer.alloc(5 + parts[i].length);
320
+ payload[0] = 0x01;
321
+ payload[1] = i + 1;
322
+ payload[2] = parts.length;
323
+ payload[3] = QRP_COMPRESSOR_DEFLATE;
324
+ payload[4] = this.entryBits;
325
+ parts[i].copy(payload, 5);
326
+ chunks.push(payload);
327
+ }
328
+ return chunks;
329
+ }
330
+
331
+ packTable(): Buffer {
332
+ if (this.entryBits === 1) return this.packOneBitTable();
333
+ if (this.entryBits === 4) return this.packNibbleTable();
334
+ if (this.entryBits === 8) return this.packByteTable();
335
+ throw new Error(`unsupported QRP entry bits ${this.entryBits}`);
336
+ }
337
+
338
+ packOneBitTable(): Buffer {
339
+ const out = Buffer.alloc(Math.ceil(this.tableSize / 8));
340
+ for (let i = 0; i < this.tableSize; i++) {
341
+ const byteIdx = i >> 3;
342
+ const bit = 7 - (i & 7);
343
+ if (this.table[i] < this.infinity) out[byteIdx] |= 1 << bit;
344
+ }
345
+ return out;
346
+ }
347
+
348
+ packNibbleTable(): Buffer {
349
+ const out = Buffer.alloc(Math.ceil(this.tableSize / 2));
350
+ for (let i = 0; i < this.tableSize; i++) {
351
+ const delta =
352
+ this.table[i] < this.infinity ? this.table[i] - this.infinity : 0;
353
+ const nibble = encodeSignedPatchValue(delta, 4);
354
+ const byteIdx = i >> 1;
355
+ if ((i & 1) === 0)
356
+ out[byteIdx] = (out[byteIdx] & 0x0f) | (nibble << 4);
357
+ else out[byteIdx] = (out[byteIdx] & 0xf0) | nibble;
358
+ }
359
+ return out;
360
+ }
361
+
362
+ packByteTable(): Buffer {
363
+ const out = Buffer.alloc(this.tableSize);
364
+ for (let i = 0; i < this.tableSize; i++) {
365
+ const delta =
366
+ this.table[i] < this.infinity ? this.table[i] - this.infinity : 0;
367
+ out[i] = encodeSignedPatchValue(delta, 8);
368
+ }
369
+ return out;
370
+ }
371
+
372
+ static canApplyPatch(state: RemoteQrpState): boolean {
373
+ return (
374
+ state.resetSeen &&
375
+ state.parts.size === state.seqSize &&
376
+ state.seqSize > 0
377
+ );
378
+ }
379
+
380
+ static orderedPatchParts(state: RemoteQrpState): Buffer[] | undefined {
381
+ const rawParts: Buffer[] = [];
382
+ for (let i = 1; i <= state.seqSize; i++) {
383
+ const part = state.parts.get(i);
384
+ if (!part) return undefined;
385
+ rawParts.push(part);
386
+ }
387
+ return rawParts;
388
+ }
389
+
390
+ static createUnpackedTable(state: RemoteQrpState): Uint8Array {
391
+ const table = new Uint8Array(state.tableSize);
392
+ table.fill(state.infinity);
393
+ return table;
394
+ }
395
+
396
+ static mutablePatchTable(state: RemoteQrpState): Uint8Array {
397
+ return state.table?.slice() ?? QrpTable.createUnpackedTable(state);
398
+ }
399
+
400
+ static unpackOneBitTable(
401
+ state: RemoteQrpState,
402
+ packed: Buffer,
403
+ ): Uint8Array {
404
+ const table = QrpTable.mutablePatchTable(state);
405
+ for (let i = 0; i < state.tableSize; i++) {
406
+ const byteIdx = i >> 3;
407
+ const bit = 7 - (i & 7);
408
+ if (byteIdx < packed.length && packed[byteIdx] & (1 << bit))
409
+ table[i] = flipPresencePatchValue(table[i], state.infinity);
410
+ }
411
+ return table;
412
+ }
413
+
414
+ static unpackNibbleTable(
415
+ state: RemoteQrpState,
416
+ packed: Buffer,
417
+ ): Uint8Array {
418
+ const table = QrpTable.mutablePatchTable(state);
419
+ for (let i = 0; i < state.tableSize; i++) {
420
+ const byteIdx = i >> 1;
421
+ if (byteIdx >= packed.length) break;
422
+ const nibble =
423
+ (i & 1) === 0
424
+ ? (packed[byteIdx] >> 4) & 0x0f
425
+ : packed[byteIdx] & 0x0f;
426
+ table[i] = applyPresencePatchValue(
427
+ table[i],
428
+ state.infinity,
429
+ nibble,
430
+ 4,
431
+ );
432
+ }
433
+ return table;
434
+ }
435
+
436
+ static unpackByteTable(
437
+ state: RemoteQrpState,
438
+ packed: Buffer,
439
+ ): Uint8Array {
440
+ const table = QrpTable.mutablePatchTable(state);
441
+ for (let i = 0; i < state.tableSize; i++) {
442
+ if (i >= packed.length) break;
443
+ table[i] = applyPresencePatchValue(
444
+ table[i],
445
+ state.infinity,
446
+ packed[i],
447
+ 8,
448
+ );
449
+ }
450
+ return table;
451
+ }
452
+
453
+ static unpackRemoteTable(
454
+ state: RemoteQrpState,
455
+ packed: Buffer,
456
+ ): Uint8Array | undefined {
457
+ if (state.entryBits === 1)
458
+ return QrpTable.unpackOneBitTable(state, packed);
459
+ if (state.entryBits === 4)
460
+ return QrpTable.unpackNibbleTable(state, packed);
461
+ if (state.entryBits === 8)
462
+ return QrpTable.unpackByteTable(state, packed);
463
+ return undefined;
464
+ }
465
+
466
+ static expectedPackedPatchBytes(
467
+ state: RemoteQrpState,
468
+ ): number | undefined {
469
+ if (state.entryBits === 1) return Math.ceil(state.tableSize / 8);
470
+ if (state.entryBits === 4) return Math.ceil(state.tableSize / 2);
471
+ if (state.entryBits === 8) return state.tableSize;
472
+ return undefined;
473
+ }
474
+
475
+ static packedPatchCoverageError(
476
+ state: RemoteQrpState,
477
+ packed: Buffer,
478
+ ): string | undefined {
479
+ const expectedBytes = QrpTable.expectedPackedPatchBytes(state);
480
+ if (expectedBytes == null || packed.length >= expectedBytes)
481
+ return undefined;
482
+ const coveredSlots = Math.floor((packed.length * 8) / state.entryBits);
483
+ return `Incomplete ${state.entryBits}-bit QRP patch covered ${coveredSlots}/${state.tableSize} slots`;
484
+ }
485
+
486
+ static applyPatch(state: RemoteQrpState): string | undefined {
487
+ if (!QrpTable.canApplyPatch(state)) return undefined;
488
+ const rawParts = QrpTable.orderedPatchParts(state);
489
+ if (!rawParts) return undefined;
490
+ let packed = Buffer.concat(rawParts);
491
+ if (state.compressor === QRP_COMPRESSOR_DEFLATE)
492
+ packed = zlib.inflateSync(packed);
493
+ const coverageError = QrpTable.packedPatchCoverageError(state, packed);
494
+ if (coverageError) return coverageError;
495
+ const table = QrpTable.unpackRemoteTable(state, packed);
496
+ if (!table) return undefined;
497
+ state.table = table;
498
+ state.parts.clear();
499
+ state.seqSize = 0;
500
+ return undefined;
501
+ }
502
+
503
+ static matchesRemote(state: RemoteQrpState, search: string): boolean {
504
+ if (!state.table || !state.tableSize) return true;
505
+ const kws = qrpQueryTerms(search);
506
+ const bits = Math.log2(state.tableSize);
507
+ return qrpTermsMatch(
508
+ kws,
509
+ (kw) => state.table![qrpHash(kw, bits)] < state.infinity,
510
+ );
511
+ }
512
+ }
513
+
514
+ export function matchQuery(
515
+ q: QueryDescriptor,
516
+ share: Pick<ShareFile, "name" | "sha1Urn" | "keywords">,
517
+ ): boolean {
518
+ if (q.urns.length) {
519
+ if (!share.sha1Urn) return false;
520
+ const urnSet = new Set(q.urns.map((x) => x.toLowerCase()));
521
+ if (!urnSet.has(share.sha1Urn.toLowerCase())) return false;
522
+ }
523
+ const term = q.search.trim();
524
+ if (!term) return q.urns.length > 0;
525
+ const kws = tokenizeKeywords(term);
526
+ if (!kws.length)
527
+ return share.name.toLowerCase().includes(term.toLowerCase());
528
+ const shareKw = new Set(share.keywords);
529
+ return kws.every(
530
+ (kw) => shareKw.has(kw) || share.name.toLowerCase().includes(kw),
531
+ );
532
+ }
533
+
534
+ function canRouteQrpQuery(
535
+ q: Pick<QueryDescriptor, "search" | "urns">,
536
+ matchesSearch: (search: string) => boolean,
537
+ ): boolean {
538
+ if (q.urns.length) return true;
539
+ return matchesSearch(q.search);
540
+ }
541
+
542
+ export function canRouteRemoteQrpQuery(
543
+ state: RemoteQrpState,
544
+ q: Pick<QueryDescriptor, "search" | "urns">,
545
+ ): boolean {
546
+ return canRouteQrpQuery(q, (search) =>
547
+ QrpTable.matchesRemote(state, search),
548
+ );
549
+ }
@@ -0,0 +1,29 @@
1
+ import { normalizeUrnList } from "./content_urn";
2
+
3
+ type QuerySearchParts = {
4
+ search: string;
5
+ urns: string[];
6
+ };
7
+
8
+ export function splitQuerySearch(rawSearch: string): QuerySearchParts {
9
+ if (!rawSearch.trim()) {
10
+ return {
11
+ search: rawSearch,
12
+ urns: [],
13
+ };
14
+ }
15
+ const parts = rawSearch.trim().split(/\s+/).filter(Boolean);
16
+ const textParts: string[] = [];
17
+ const rawUrns: string[] = [];
18
+ for (const part of parts) {
19
+ if (!/^urn:[^\s]+$/i.test(part)) {
20
+ textParts.push(part);
21
+ continue;
22
+ }
23
+ rawUrns.push(part);
24
+ }
25
+ return {
26
+ search: textParts.join(" "),
27
+ urns: normalizeUrnList(rawUrns),
28
+ };
29
+ }
@@ -0,0 +1,131 @@
1
+ import fsp from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { ensureDir, fileExists } from "../shared";
5
+
6
+ const SHARE_INDEX_FILENAME = "share-index.json";
7
+ const SHARE_INDEX_VERSION = 1;
8
+ const shareIndexWriteQueues = new Map<string, Promise<void>>();
9
+
10
+ export type ShareIndexEntry = {
11
+ rel: string;
12
+ size: number;
13
+ mtimeMs: number;
14
+ sha1Hex?: string;
15
+ sha1Urn?: string;
16
+ };
17
+
18
+ type ShareIndexManifest = {
19
+ version?: unknown;
20
+ files?: unknown;
21
+ };
22
+
23
+ function shareIndexPath(dataDir: string): string {
24
+ return path.join(dataDir, SHARE_INDEX_FILENAME);
25
+ }
26
+
27
+ function shareIndexTmpPath(file: string): string {
28
+ return `${file}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
29
+ }
30
+
31
+ async function queueShareIndexWrite(
32
+ file: string,
33
+ task: () => Promise<void>,
34
+ ): Promise<void> {
35
+ const previous = shareIndexWriteQueues.get(file) || Promise.resolve();
36
+ const current = previous.catch(() => void 0).then(task);
37
+ shareIndexWriteQueues.set(file, current);
38
+ try {
39
+ await current;
40
+ } finally {
41
+ if (shareIndexWriteQueues.get(file) === current) {
42
+ shareIndexWriteQueues.delete(file);
43
+ }
44
+ }
45
+ }
46
+
47
+ function objectRecord(
48
+ value: unknown,
49
+ ): Record<string, unknown> | undefined {
50
+ if (!value || typeof value !== "object" || Array.isArray(value))
51
+ return undefined;
52
+ return value as Record<string, unknown>;
53
+ }
54
+
55
+ function nonEmptyString(value: unknown): string | undefined {
56
+ return typeof value === "string" && value.length > 0 ? value : undefined;
57
+ }
58
+
59
+ function nonNegativeNumber(value: unknown): number | undefined {
60
+ const num = Number(value);
61
+ return Number.isFinite(num) && num >= 0 ? num : undefined;
62
+ }
63
+
64
+ function validSha1Hex(value: unknown): string | undefined {
65
+ const text = nonEmptyString(value);
66
+ return text && /^[0-9a-f]{40}$/i.test(text)
67
+ ? text.toLowerCase()
68
+ : undefined;
69
+ }
70
+
71
+ function parseShareIndexEntry(
72
+ value: unknown,
73
+ ): ShareIndexEntry | undefined {
74
+ const record = objectRecord(value);
75
+ if (!record) return undefined;
76
+ const rel = nonEmptyString(record.rel);
77
+ const size = nonNegativeNumber(record.size);
78
+ const mtimeMs = nonNegativeNumber(record.mtimeMs);
79
+ if (!rel || size == null || mtimeMs == null) return undefined;
80
+ const sha1Hex = validSha1Hex(record.sha1Hex);
81
+ const sha1Urn = nonEmptyString(record.sha1Urn);
82
+ return {
83
+ rel,
84
+ size,
85
+ mtimeMs,
86
+ ...(sha1Hex ? { sha1Hex } : {}),
87
+ ...(sha1Urn ? { sha1Urn } : {}),
88
+ };
89
+ }
90
+
91
+ export async function loadShareIndex(
92
+ dataDir: string,
93
+ ): Promise<Map<string, ShareIndexEntry>> {
94
+ const file = shareIndexPath(dataDir);
95
+ if (!(await fileExists(file))) return new Map();
96
+ try {
97
+ const raw = await fsp.readFile(file, "utf8");
98
+ const parsed = JSON.parse(raw) as ShareIndexManifest;
99
+ if (parsed.version !== SHARE_INDEX_VERSION) return new Map();
100
+ if (!Array.isArray(parsed.files)) return new Map();
101
+ const entries = parsed.files
102
+ .map((value) => parseShareIndexEntry(value))
103
+ .filter((entry) => entry !== undefined);
104
+ return new Map(entries.map((entry) => [entry.rel, entry]));
105
+ } catch {
106
+ return new Map();
107
+ }
108
+ }
109
+
110
+ export async function writeShareIndex(
111
+ dataDir: string,
112
+ entries: Map<string, ShareIndexEntry>,
113
+ ): Promise<void> {
114
+ await ensureDir(dataDir);
115
+ const file = shareIndexPath(dataDir);
116
+ const manifest = {
117
+ version: SHARE_INDEX_VERSION,
118
+ files: [...entries.values()].sort((a, b) =>
119
+ a.rel.localeCompare(b.rel),
120
+ ),
121
+ };
122
+ await queueShareIndexWrite(file, async () => {
123
+ const tmp = shareIndexTmpPath(file);
124
+ try {
125
+ await fsp.writeFile(tmp, JSON.stringify(manifest, null, 2));
126
+ await fsp.rename(tmp, file);
127
+ } finally {
128
+ await fsp.unlink(tmp).catch(() => void 0);
129
+ }
130
+ });
131
+ }