gnutella 1.0.0 → 1.1.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 (75) hide show
  1. package/CLI.md +1 -0
  2. package/gnutella.json.example +1 -0
  3. package/package.json +4 -3
  4. package/src/cli_shared.ts +32 -43
  5. package/src/const.ts +1 -9
  6. package/src/descriptor_routing/index.ts +17 -0
  7. package/src/descriptor_routing/pong_cache.ts +32 -0
  8. package/src/descriptor_routing/response_routes.ts +15 -0
  9. package/src/descriptor_routing/seen.ts +20 -0
  10. package/src/descriptor_routing/ttl.ts +37 -0
  11. package/src/descriptor_routing/types.ts +27 -0
  12. package/src/gwebcache/bootstrap.ts +21 -58
  13. package/src/gwebcache/types.ts +6 -10
  14. package/src/handshake_policy/admission.ts +17 -0
  15. package/src/handshake_policy/capabilities.ts +167 -0
  16. package/src/handshake_policy/headers.ts +157 -0
  17. package/src/handshake_policy/index.ts +21 -0
  18. package/src/handshake_policy/types.ts +36 -0
  19. package/src/peer_address.ts +68 -0
  20. package/src/peer_discovery/candidate_policy.ts +80 -0
  21. package/src/peer_discovery/index.ts +8 -0
  22. package/src/peer_discovery/types.ts +26 -0
  23. package/src/persistence/config_doc.ts +61 -0
  24. package/src/persistence/index.ts +14 -0
  25. package/src/persistence/peer_state.ts +113 -0
  26. package/src/persistence/types.ts +28 -0
  27. package/src/protocol/codec.ts +27 -67
  28. package/src/protocol/content_urn.ts +5 -1
  29. package/src/protocol/file_hash.ts +12 -0
  30. package/src/protocol/file_server.ts +1 -1
  31. package/src/protocol/ggep.ts +13 -8
  32. package/src/protocol/handshake.ts +18 -161
  33. package/src/protocol/http_download_reader.ts +9 -7
  34. package/src/protocol/magnet.ts +15 -13
  35. package/src/protocol/node.ts +1 -1
  36. package/src/protocol/node_handshake.ts +55 -113
  37. package/src/protocol/node_protocol_runtime.ts +69 -60
  38. package/src/protocol/node_qrp_runtime.ts +7 -6
  39. package/src/protocol/node_query_routing.ts +43 -132
  40. package/src/protocol/node_state.ts +2 -3
  41. package/src/protocol/node_topology.ts +38 -82
  42. package/src/protocol/node_transfer.ts +52 -35
  43. package/src/protocol/peer_state.ts +36 -207
  44. package/src/protocol/qrp.ts +1 -549
  45. package/src/protocol/query_matching.ts +22 -0
  46. package/src/protocol/share_index.ts +8 -70
  47. package/src/protocol/share_library.ts +30 -73
  48. package/src/query_routing/dynamic_query.ts +117 -0
  49. package/src/query_routing/index.ts +27 -0
  50. package/src/query_routing/qrp/constants.ts +9 -0
  51. package/src/query_routing/qrp/hash.ts +27 -0
  52. package/src/query_routing/qrp/patch_values.ts +29 -0
  53. package/src/query_routing/qrp/remote_state.ts +98 -0
  54. package/src/query_routing/qrp/routing.ts +46 -0
  55. package/src/query_routing/qrp/table.ts +319 -0
  56. package/src/query_routing/qrp/terms.ts +62 -0
  57. package/src/query_routing/qrp/types.ts +31 -0
  58. package/src/query_routing/qrp.ts +13 -0
  59. package/src/share_catalog/catalog.ts +108 -0
  60. package/src/share_catalog/index.ts +16 -0
  61. package/src/share_catalog/keywords.ts +15 -0
  62. package/src/share_catalog/manifest.ts +81 -0
  63. package/src/share_catalog/types.ts +43 -0
  64. package/src/shared.ts +9 -68
  65. package/src/topology/admission.ts +51 -0
  66. package/src/topology/classify.ts +19 -0
  67. package/src/topology/index.ts +17 -0
  68. package/src/topology/slots.ts +43 -0
  69. package/src/topology/types.ts +25 -0
  70. package/src/transfers/index.ts +13 -0
  71. package/src/transfers/planner.ts +52 -0
  72. package/src/transfers/ranges.ts +57 -0
  73. package/src/transfers/results.ts +45 -0
  74. package/src/transfers/types.ts +43 -0
  75. package/src/types.ts +43 -55
@@ -1,549 +1 @@
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
- }
1
+ export { initialRemoteQrpState, QrpTable } from "../query_routing/qrp";
@@ -0,0 +1,22 @@
1
+ import type { QueryDescriptor, ShareFile } from "../types";
2
+ import { tokenizeKeywords } from "../query_routing/qrp";
3
+
4
+ export function matchQuery(
5
+ q: QueryDescriptor,
6
+ share: Pick<ShareFile, "name" | "sha1Urn" | "keywords">,
7
+ ): boolean {
8
+ if (q.urns.length) {
9
+ if (!share.sha1Urn) return false;
10
+ const urnSet = new Set(q.urns.map((x) => x.toLowerCase()));
11
+ if (!urnSet.has(share.sha1Urn.toLowerCase())) return false;
12
+ }
13
+ const term = q.search.trim();
14
+ if (!term) return q.urns.length > 0;
15
+ const kws = tokenizeKeywords(term);
16
+ if (!kws.length)
17
+ return share.name.toLowerCase().includes(term.toLowerCase());
18
+ const shareKw = new Set(share.keywords);
19
+ return kws.every(
20
+ (kw) => shareKw.has(kw) || share.name.toLowerCase().includes(kw),
21
+ );
22
+ }
@@ -2,23 +2,16 @@ import fsp from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import { ensureDir, fileExists } from "../shared";
5
+ import {
6
+ parseShareCatalogManifest,
7
+ serializeShareCatalogManifest,
8
+ type ShareCatalogEntry,
9
+ } from "../share_catalog";
5
10
 
6
11
  const SHARE_INDEX_FILENAME = "share-index.json";
7
- const SHARE_INDEX_VERSION = 1;
8
12
  const shareIndexWriteQueues = new Map<string, Promise<void>>();
9
13
 
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
- };
14
+ export type ShareIndexEntry = ShareCatalogEntry;
22
15
 
23
16
  function shareIndexPath(dataDir: string): string {
24
17
  return path.join(dataDir, SHARE_INDEX_FILENAME);
@@ -44,50 +37,6 @@ async function queueShareIndexWrite(
44
37
  }
45
38
  }
46
39
 
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
40
  export async function loadShareIndex(
92
41
  dataDir: string,
93
42
  ): Promise<Map<string, ShareIndexEntry>> {
@@ -95,13 +44,7 @@ export async function loadShareIndex(
95
44
  if (!(await fileExists(file))) return new Map();
96
45
  try {
97
46
  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]));
47
+ return parseShareCatalogManifest(JSON.parse(raw));
105
48
  } catch {
106
49
  return new Map();
107
50
  }
@@ -113,12 +56,7 @@ export async function writeShareIndex(
113
56
  ): Promise<void> {
114
57
  await ensureDir(dataDir);
115
58
  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
- };
59
+ const manifest = serializeShareCatalogManifest(entries);
122
60
  await queueShareIndexWrite(file, async () => {
123
61
  const tmp = shareIndexTmpPath(file);
124
62
  try {