@twinsearth/w2m-dsh-plugin 0.5.5 → 0.5.6

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.
@@ -0,0 +1,1062 @@
1
+ /**
2
+ * Multicast DNS wire codec 鈥?a bounded, hand-written DNS message encoder and decoder.
3
+ *
4
+ * # THE RULE
5
+ *
6
+ * **Discovery is a hint, never an authorisation.** A discovered device is a *candidate to ask*. It
7
+ * never skips, weakens or short-circuits any existing gate 鈥?the device token, the per-machine
8
+ * allow-list, the git anchors, the verdict model. Nothing in this file, in `src/agent/mdns.mjs`, or in
9
+ * `src/plugin/mdns-discover.mjs` may treat "we saw it announce itself" as "we may run there". If a
10
+ * future edit finds itself writing code where a discovered device is trusted *because it was
11
+ * discovered*, stop: that is a defect, not an optimisation. mDNS carries no authentication of any
12
+ * kind, so a record here is an unverified claim by whoever sent it, and every consumer must treat it
13
+ * as such.
14
+ *
15
+ * # Clean room
16
+ *
17
+ * Written from the published rules 鈥?RFC 1035 搂4.1 (message format, name compression), RFC 1035
18
+ * 搂2.3.4 (the 255-octet name limit), RFC 1035 搂3.1/搂3.2.2 (record and type definitions), RFC 2782
19
+ * (SRV), RFC 6763 (DNS-SD record layout, the TXT record's key=value convention), RFC 6762 搂18
20
+ * (mDNS message format) and RFC 6762 搂10 (TTL values, goodbye packets). No third-party
21
+ * implementation was consulted line by line and none was copied: this project is MIT and stays free of
22
+ * third-party notice entanglement, and deriving from the specification is also what lets us *not*
23
+ * reproduce the defects a studied implementation had.
24
+ *
25
+ * Two behaviours are deliberately not reproduced anywhere in this module, because they are how a
26
+ * discovery layer goes stale:
27
+ *
28
+ * * **first-writer-wins / keep-first-non-zero-port.** There is no such rule here. A record is
29
+ * identified by `(name, type, rdata)` and a later copy *replaces* an earlier one, at any TTL
30
+ * including zero: see `createRecordCache` in `src/agent/mdns.mjs`. A device that moves during a
31
+ * scan must be seen at its new address, not at the address it had when the scan began.
32
+ * * **a name as identity.** Nothing here decides identity at all; the codec only reports what the
33
+ * wire said, including the exact `name` bytes as written. Identity is decided in
34
+ * `src/plugin/mdns-discover.mjs`, by a `machine_id` the relay assigned 鈥?never by a matching
35
+ * service or host name.
36
+ *
37
+ * # What is implemented, and what is not
38
+ *
39
+ * Implemented: header, question section, and the answer/authority/additional sections for PTR, SRV,
40
+ * TXT, A and AAAA. Name compression on **decode** (pointers are followed, with hardening below) and on
41
+ * **encode** (a per-message suffix dictionary writes a pointer to an earlier occurrence).
42
+ *
43
+ * Not implemented, on purpose: NSEC/negative responses, OPT/EDNS0, DNSSEC, SVCB/HTTPS, and every
44
+ * record type this layer does not read. An unknown record type decodes into `{type, rdata}` and is
45
+ * ignored by the consumers rather than guessed at.
46
+ *
47
+ * # The hardening contract
48
+ *
49
+ * `decodeMessage` **never throws and never loops forever**, whatever bytes it is handed. Every read is
50
+ * bounds-checked against the buffer, and every structural limit is a refusal with a stable code:
51
+ *
52
+ * | guard | limit | refusal |
53
+ * | ---------------------------- | ----- | -------------------- |
54
+ * | label length octet | 63 | `MDNS_LABEL_TOO_LONG`|
55
+ * | unsupported label kind (01/10)| 鈥? | `MDNS_LABEL_TYPE` |
56
+ * | expanded name length | 255 | `MDNS_NAME_TOO_LONG` |
57
+ * | compression pointer target | must be strictly earlier in the message | `MDNS_POINTER_LOOP` |
58
+ * | compression pointer hops | 128 | `MDNS_POINTER_HOPS` |
59
+ * | records per message | 256 | `MDNS_RECORD_COUNT` |
60
+ * | message size | 9000 | `MDNS_MESSAGE_TOO_LARGE` |
61
+ * | any read past the end | 鈥? | `MDNS_TRUNCATED` |
62
+ *
63
+ * The two pointer guards are both load-bearing, and neither is decoration:
64
+ *
65
+ * * RFC 1035 搂4.1.4 says a pointer refers to "a prior occurrence" of the name, i.e. to an offset
66
+ * *earlier* in the message. A pointer that does not point strictly backwards is the only way to
67
+ * build a cycle, so it is refused as a loop. This alone guarantees termination.
68
+ * * The hop counter (128, the conventional guard) is the bound on *work*, not just on loops: a
69
+ * hostile packet can chain 200 legal, strictly-backwards pointers into one name, which terminates
70
+ * but costs more than any honest message needs.
71
+ *
72
+ * The last line of defence is a `try/catch` around the whole decoder: a bug in the code below becomes
73
+ * `MDNS_INTERNAL`, a refusal, not an exception escaping into a socket handler.
74
+ */
75
+
76
+ import { Buffer } from 'node:buffer';
77
+
78
+ // ---------------------------------------------------------------------------------------------
79
+ // Constants
80
+ // ---------------------------------------------------------------------------------------------
81
+
82
+ /** DNS CLASS IN (RFC 1035 搂3.2.4). */
83
+ export const DNS_CLASS_IN = 1;
84
+
85
+ /** DNS CLASS ANY (`*`), which mDNS queries use to ask for every type of a name (RFC 6762 搂5). */
86
+ export const DNS_CLASS_ANY = 255;
87
+
88
+ /** Record types this layer reads. RFC 1035 搂3.2.2, RFC 1035 搂3.2.3 (AAAA is RFC 3596), RFC 2782. */
89
+ export const RECORD_TYPE = Object.freeze({
90
+ A: 1,
91
+ PTR: 12,
92
+ TXT: 16,
93
+ AAAA: 28,
94
+ SRV: 33,
95
+ ANY: 255,
96
+ });
97
+
98
+ /** The same codes backwards, for diagnostics. An unknown code renders as `TYPE<n>`. */
99
+ const TYPE_NAME = Object.freeze({
100
+ 1: 'A',
101
+ 12: 'PTR',
102
+ 16: 'TXT',
103
+ 28: 'AAAA',
104
+ 33: 'SRV',
105
+ 255: 'ANY',
106
+ });
107
+
108
+ /**
109
+ * The most significant bit of the *rrclass* field in a response record.
110
+ *
111
+ * RFC 6762 搂10.2: "this is not a record class 0x8001" 鈥?the bit means "the rest of this RRset is
112
+ * replaced", and it must be masked off before the class is compared. It is the *same bit value* as
113
+ * {@link UNICAST_RESPONSE} in a question, but a different field with a different meaning; the two
114
+ * constants are separate so a future edit cannot conflate them.
115
+ */
116
+ export const CACHE_FLUSH = 0x8000;
117
+
118
+ /** The most significant bit of the *qclass* field in a question (RFC 6762 搂5.4, "QU" questions). */
119
+ export const UNICAST_RESPONSE = 0x8000;
120
+
121
+ /** RFC 1035 搂2.3.4: a label is at most 63 octets, which is why the top two bits can be flags. */
122
+ export const MAX_LABEL_BYTES = 63;
123
+
124
+ /** RFC 1035 搂2.3.4: a name (length octets, label octets and the terminating zero) is at most 255. */
125
+ export const MAX_NAME_BYTES = 255;
126
+
127
+ /**
128
+ * Largest message this codec will parse or produce.
129
+ *
130
+ * RFC 6762 搂17 asks that multicast messages fit in 1500 bytes "whenever possible" and caps them at
131
+ * 9000 (`MAX_MESSAGE_BYTES` here). The cap is also a bound on parser work, which is the reason it is
132
+ * enforced on *decode* as well: a datagram larger than this is refused before a single name is read.
133
+ */
134
+ export const MAX_MESSAGE_BYTES = 9000;
135
+
136
+ /** The conventional bound on compression-pointer dereferences for one name. */
137
+ export const MAX_POINTER_HOPS = 128;
138
+
139
+ /** Upper bound on records in one message. A 9000-byte message cannot honestly hold more than a few. */
140
+ export const MAX_RECORDS = 256;
141
+
142
+ /** Upper bound on the encoder's suffix dictionary, so a huge message cannot grow it without limit. */
143
+ export const MAX_COMPRESSION_ENTRIES = 512;
144
+
145
+ /**
146
+ * The two top bits of a length octet (RFC 1035 搂4.1.4): `00` is a label length, `11` is a compression
147
+ * pointer, `01` and `10` are reserved. The mask is one octet wide 鈥?the 16-bit form, `0xc000`, is what
148
+ * the *writer* ORs into a pointer, and mixing the two up makes every pointer unreadable.
149
+ */
150
+ const LABEL_KIND_MASK = 0xc0;
151
+
152
+ /** The 16-bit form the writer emits: `11` in the top two bits of the pointer word. */
153
+ const POINTER_WORD_MASK = 0xc000;
154
+
155
+ /**
156
+ * RFC 6762 搂6.7: a response to a query from a source port other than 5353 is a *legacy* unicast
157
+ * response, and its TTLs "SHOULD NOT be greater than ten seconds" 鈥?the resolver that asked has no
158
+ * cache-coherency protocol and would keep the record long after it stopped being true.
159
+ */
160
+ export const LEGACY_UNICAST_TTL = 10;
161
+
162
+ /** 14 bits of pointer target, i.e. the largest offset a pointer can name. */
163
+ const MAX_POINTER_TARGET = 0x3fff;
164
+
165
+ /** Header size: ID, flags, and four section counts (RFC 1035 搂4.1.1). */
166
+ const HEADER_BYTES = 12;
167
+
168
+ /** Flag bits of the second header word (RFC 1035 搂4.1.1). */
169
+ export const FLAG = Object.freeze({
170
+ QR: 0x8000,
171
+ AA: 0x0400,
172
+ TC: 0x0200,
173
+ RD: 0x0100,
174
+ RA: 0x0080,
175
+ });
176
+
177
+ /**
178
+ * Every refusal this decoder can report, as a stable code.
179
+ *
180
+ * Codes are values, not exceptions: a caller can log "the LAN sent us 3 packets we refused, the last
181
+ * one because a compression pointer did not point backwards" without a stack trace and without a
182
+ * `try/catch` around a socket handler.
183
+ */
184
+ export const REFUSAL = Object.freeze({
185
+ NOT_A_MESSAGE: 'MDNS_NOT_A_MESSAGE',
186
+ MESSAGE_TOO_LARGE: 'MDNS_MESSAGE_TOO_LARGE',
187
+ TRUNCATED: 'MDNS_TRUNCATED',
188
+ LABEL_TOO_LONG: 'MDNS_LABEL_TOO_LONG',
189
+ LABEL_TYPE: 'MDNS_LABEL_TYPE',
190
+ NAME_TOO_LONG: 'MDNS_NAME_TOO_LONG',
191
+ POINTER_LOOP: 'MDNS_POINTER_LOOP',
192
+ POINTER_HOPS: 'MDNS_POINTER_HOPS',
193
+ RECORD_COUNT: 'MDNS_RECORD_COUNT',
194
+ RDATA_LENGTH: 'MDNS_RDATA_LENGTH',
195
+ INTERNAL: 'MDNS_INTERNAL',
196
+ });
197
+
198
+ /**
199
+ * An invalid *argument* to an encoder.
200
+ *
201
+ * The decoder never throws 鈥?it refuses. The encoder does, because its input is our own data: writing
202
+ * a packet we know to be malformed would put a lie on the wire where nobody is left to catch it.
203
+ */
204
+ export class MdnsWireError extends Error {
205
+ /**
206
+ * @param {string} code Stable code, e.g. `MDNS_LABEL_TOO_LONG`.
207
+ * @param {string} message What was wrong and, when known, which value.
208
+ */
209
+ constructor(code, message) {
210
+ super(`${code}: ${message}`);
211
+ this.name = 'MdnsWireError';
212
+ this.code = code;
213
+ }
214
+ }
215
+
216
+ /** A refusal that the caller can branch on. Never thrown; always returned. */
217
+ function refuse(reason, detail = undefined) {
218
+ return detail === undefined ? { ok: false, reason } : { ok: false, reason, detail };
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------------------------
222
+ // Name and address helpers
223
+ // ---------------------------------------------------------------------------------------------
224
+
225
+ /**
226
+ * Lower-case a name and drop one trailing dot, for case-insensitive comparison.
227
+ *
228
+ * DNS names are case-insensitive (RFC 1035 搂2.3.3) but the wire case is preserved everywhere else in
229
+ * this module, because a DNS-SD *instance* name is a human-readable label and "Mac Studio" is not
230
+ * "mac studio" to the person who reads it.
231
+ *
232
+ * @param {unknown} name
233
+ * @returns {string}
234
+ */
235
+ export function normalizeName(name) {
236
+ const text = typeof name === 'string' ? name : String(name ?? '');
237
+ const trimmed = text.endsWith('.') ? text.slice(0, -1) : text;
238
+ return trimmed.toLowerCase();
239
+ }
240
+
241
+ /**
242
+ * Are these two names the same name, ignoring case and one trailing dot?
243
+ *
244
+ * @param {unknown} a
245
+ * @param {unknown} b
246
+ * @returns {boolean}
247
+ */
248
+ export function nameEquals(a, b) {
249
+ return normalizeName(a) === normalizeName(b);
250
+ }
251
+
252
+ /** @param {number} type @returns {string} */
253
+ export function typeName(type) {
254
+ return TYPE_NAME[type] ?? `TYPE${Number.isInteger(type) ? type : '?'}`;
255
+ }
256
+
257
+ /**
258
+ * Split a dotted name into labels, refusing anything that cannot be encoded.
259
+ *
260
+ * Escape sequences (`\.`) are **not** parsed: a label containing a literal dot cannot be expressed by
261
+ * this codec. See the module header of `src/agent/mdns.mjs` for why that is acceptable here and what
262
+ * it costs.
263
+ *
264
+ * @param {unknown} name
265
+ * @param {string} [what] What is being encoded, for the error message.
266
+ * @returns {string[]} Labels, without the root label.
267
+ */
268
+ export function splitName(name, what = 'name') {
269
+ if (typeof name !== 'string' || name.trim() === '') {
270
+ throw new MdnsWireError(
271
+ 'MDNS_NAME_EMPTY',
272
+ `${what} must be a non-empty string, got ${JSON.stringify(name)}`,
273
+ );
274
+ }
275
+ const labels = (name.endsWith('.') ? name.slice(0, -1) : name).split('.');
276
+ let encoded = 1; // the terminating zero octet
277
+ for (const label of labels) {
278
+ if (label === '') {
279
+ throw new MdnsWireError('MDNS_NAME_EMPTY_LABEL', `${what} ${JSON.stringify(name)} has an empty label`);
280
+ }
281
+ const bytes = Buffer.byteLength(label, 'utf8');
282
+ if (bytes > MAX_LABEL_BYTES) {
283
+ throw new MdnsWireError(
284
+ REFUSAL.LABEL_TOO_LONG,
285
+ `${what} ${JSON.stringify(name)} has a ${bytes}-octet label; RFC 1035 搂2.3.4 allows ${MAX_LABEL_BYTES}`,
286
+ );
287
+ }
288
+ encoded += bytes + 1;
289
+ }
290
+ if (encoded > MAX_NAME_BYTES) {
291
+ throw new MdnsWireError(
292
+ REFUSAL.NAME_TOO_LONG,
293
+ `${what} ${JSON.stringify(name)} encodes to ${encoded} octets; RFC 1035 搂2.3.4 allows ${MAX_NAME_BYTES}`,
294
+ );
295
+ }
296
+ return labels;
297
+ }
298
+
299
+ /**
300
+ * `a.b.c.` 鈫?encoded name, with no compression (a standalone helper for tests and for RDATA that must
301
+ * not be compressed).
302
+ *
303
+ * @param {string} name
304
+ * @param {{what?: string}} [options]
305
+ * @returns {Buffer}
306
+ */
307
+ export function encodeName(name, options = {}) {
308
+ const labels = splitName(name, options.what);
309
+ const parts = [];
310
+ for (const label of labels) {
311
+ const bytes = Buffer.from(label, 'utf8');
312
+ parts.push(Buffer.from([bytes.length]), bytes);
313
+ }
314
+ parts.push(Buffer.from([0]));
315
+ return Buffer.concat(parts);
316
+ }
317
+
318
+ /** `192.168.0.1` 鈫?4 octets. Strict: four decimal octets, each 0-255, no shorthand. */
319
+ export function parseIpv4(text) {
320
+ if (typeof text !== 'string')
321
+ throw new MdnsWireError('MDNS_A_INVALID', `IPv4 address must be a string, got ${typeof text}`);
322
+ const parts = text.trim().split('.');
323
+ if (parts.length !== 4)
324
+ throw new MdnsWireError('MDNS_A_INVALID', `${JSON.stringify(text)} is not four octets`);
325
+ const bytes = Buffer.alloc(4);
326
+ for (let i = 0; i < 4; i += 1) {
327
+ if (!/^\d{1,3}$/.test(parts[i]))
328
+ throw new MdnsWireError('MDNS_A_INVALID', `${JSON.stringify(text)} has a non-numeric octet`);
329
+ const value = Number(parts[i]);
330
+ if (value > 255)
331
+ throw new MdnsWireError('MDNS_A_INVALID', `${JSON.stringify(text)} has an octet above 255`);
332
+ bytes[i] = value;
333
+ }
334
+ return bytes;
335
+ }
336
+
337
+ /** 4 octets 鈫?dotted quad. */
338
+ export function formatIpv4(bytes) {
339
+ return `${bytes[0]}.${bytes[1]}.${bytes[2]}.${bytes[3]}`;
340
+ }
341
+
342
+ /**
343
+ * `2001:db8::1` 鈫?16 octets, with `::` expansion and an embedded IPv4 tail.
344
+ *
345
+ * Hand-written rather than borrowed: `node:net` validates an IPv6 literal but does not hand back its
346
+ * bytes, and the only other source of bytes would be a dependency this project does not take.
347
+ *
348
+ * @param {string} text
349
+ * @returns {Buffer}
350
+ */
351
+ export function parseIpv6(text) {
352
+ if (typeof text !== 'string' || text.trim() === '') {
353
+ throw new MdnsWireError(
354
+ 'MDNS_AAAA_INVALID',
355
+ `IPv6 address must be a non-empty string, got ${JSON.stringify(text)}`,
356
+ );
357
+ }
358
+ let value = text.trim().toLowerCase();
359
+ if (value.includes('%'))
360
+ throw new MdnsWireError('MDNS_AAAA_INVALID', `${text} carries a zone index, which is link-local only`);
361
+ if (value.includes('.')) {
362
+ // An embedded IPv4 tail (RFC 4291 搂2.2): convert it to two groups and re-parse.
363
+ const cut = value.lastIndexOf(':');
364
+ if (cut === -1)
365
+ throw new MdnsWireError('MDNS_AAAA_INVALID', `${text} has an IPv4 tail but no IPv6 prefix`);
366
+ const v4 = parseIpv4(value.slice(cut + 1));
367
+ value = `${value.slice(0, cut)}:${((v4[0] << 8) | v4[1]).toString(16)}:${((v4[2] << 8) | v4[3]).toString(16)}`;
368
+ }
369
+ const halves = value.split('::');
370
+ if (halves.length > 2) throw new MdnsWireError('MDNS_AAAA_INVALID', `${text} has more than one "::"`);
371
+ const head = halves[0] === '' ? [] : halves[0].split(':');
372
+ const tail = halves.length === 2 ? (halves[1] === '' ? [] : halves[1].split(':')) : [];
373
+ const groups = [];
374
+ const push = (list) => {
375
+ for (const group of list) {
376
+ if (!/^[0-9a-f]{1,4}$/.test(group))
377
+ throw new MdnsWireError('MDNS_AAAA_INVALID', `${text} has a bad group ${JSON.stringify(group)}`);
378
+ groups.push(Number.parseInt(group, 16));
379
+ }
380
+ };
381
+ push(head);
382
+ const headCount = groups.length;
383
+ push(tail);
384
+ if (halves.length === 2) {
385
+ const missing = 8 - groups.length;
386
+ if (missing < 1)
387
+ throw new MdnsWireError('MDNS_AAAA_INVALID', `${text} uses "::" but already has eight groups`);
388
+ groups.splice(headCount, 0, ...new Array(missing).fill(0));
389
+ }
390
+ if (groups.length !== 8)
391
+ throw new MdnsWireError('MDNS_AAAA_INVALID', `${text} has ${groups.length} groups, not eight`);
392
+ const bytes = Buffer.alloc(16);
393
+ groups.forEach((group, index) => bytes.writeUInt16BE(group, index * 2));
394
+ return bytes;
395
+ }
396
+
397
+ /**
398
+ * 16 octets 鈫?text, with the longest run of zero groups collapsed to `::` (RFC 5952 搂4.2).
399
+ *
400
+ * @param {Buffer} bytes
401
+ * @returns {string}
402
+ */
403
+ export function formatIpv6(bytes) {
404
+ const groups = [];
405
+ for (let i = 0; i < 16; i += 2) groups.push(bytes.readUInt16BE(i));
406
+ let bestStart = -1;
407
+ let bestLength = 0;
408
+ let start = -1;
409
+ for (let i = 0; i <= 8; i += 1) {
410
+ const isZero = i < 8 && groups[i] === 0;
411
+ if (isZero && start === -1) start = i;
412
+ if (!isZero && start !== -1) {
413
+ const length = i - start;
414
+ if (length > bestLength) {
415
+ bestStart = start;
416
+ bestLength = length;
417
+ }
418
+ start = -1;
419
+ }
420
+ }
421
+ if (bestLength < 2) return groups.map((group) => group.toString(16)).join(':');
422
+ const head = groups
423
+ .slice(0, bestStart)
424
+ .map((group) => group.toString(16))
425
+ .join(':');
426
+ const tail = groups
427
+ .slice(bestStart + bestLength)
428
+ .map((group) => group.toString(16))
429
+ .join(':');
430
+ return `${head}::${tail}`;
431
+ }
432
+
433
+ /** Does this IPv4 address look like something worth offering as a peer endpoint? */
434
+ export function isUsableUnicastIpv4(address) {
435
+ const octets = String(address).split('.').map(Number);
436
+ if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255))
437
+ return false;
438
+ if (octets[0] === 0 || octets[0] === 127 || octets[0] >= 224) return false; // this-network, loopback, multicast/reserved
439
+ if (octets[0] === 255 && octets[1] === 255 && octets[2] === 255 && octets[3] === 255) return false;
440
+ return true;
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------------------------
444
+ // Decoding
445
+ // ---------------------------------------------------------------------------------------------
446
+
447
+ /**
448
+ * Read one (possibly compressed) name, with every guard the header documents.
449
+ *
450
+ * @param {Buffer} buffer Whole message; pointers are absolute offsets into it.
451
+ * @param {number} start Offset of the first length octet.
452
+ * @returns {{ok: true, name: string, next: number, hops: number, bytes: number}|{ok: false, reason: string, detail?: object}}
453
+ * `hops` is the number of compression pointers followed, which the caller folds into its counters.
454
+ */
455
+ function readName(buffer, start) {
456
+ const labels = [];
457
+ let position = start;
458
+ /** Offset just past the name in the *original* stream, which a pointer fixes once and for all. */
459
+ let next = null;
460
+ let hops = 0;
461
+ let expanded = 1; // the terminating zero octet counts towards the 255 (RFC 1035 搂2.3.4)
462
+
463
+ for (;;) {
464
+ if (position >= buffer.length) return refuse(REFUSAL.TRUNCATED, { at: position, reading: 'name' });
465
+ const lengthByte = buffer[position];
466
+
467
+ if ((lengthByte & LABEL_KIND_MASK) === LABEL_KIND_MASK) {
468
+ if (position + 2 > buffer.length)
469
+ return refuse(REFUSAL.TRUNCATED, { at: position, reading: 'pointer' });
470
+ const target = ((lengthByte & 0x3f) << 8) | buffer[position + 1];
471
+ if (next === null) next = position + 2;
472
+ // The hop budget is checked before the backwards rule so that a *deep chain* of legal pointers
473
+ // is refused as a budget overrun rather than being mistaken for a loop.
474
+ if (hops >= MAX_POINTER_HOPS)
475
+ return refuse(REFUSAL.POINTER_HOPS, { hops, target, limit: MAX_POINTER_HOPS });
476
+ // RFC 1035 搂4.1.4: a pointer names a *prior* occurrence. A pointer that does not go strictly
477
+ // backwards is the only way to build a cycle, so it is refused as one. This is what makes
478
+ // termination a property of the format rather than of the hop counter.
479
+ if (target >= position) return refuse(REFUSAL.POINTER_LOOP, { target, position });
480
+ hops += 1;
481
+ position = target;
482
+ continue;
483
+ }
484
+
485
+ // `01` and `10` are reserved label kinds (RFC 1035 搂4.1.4 defines only `00` and `11`). A packet
486
+ // using them is not something to guess at.
487
+ if ((lengthByte & LABEL_KIND_MASK) !== 0)
488
+ return refuse(REFUSAL.LABEL_TYPE, { octet: lengthByte, at: position });
489
+ // Belt and braces: with the top two bits clear, `lengthByte` cannot exceed 63, so this branch is
490
+ // unreachable today. It stays because the 63-octet label limit is a rule this decoder claims to
491
+ // enforce, and a future edit that relaxes the mask above must not be able to relax this silently.
492
+ if (lengthByte > MAX_LABEL_BYTES)
493
+ return refuse(REFUSAL.LABEL_TOO_LONG, { labelBytes: lengthByte, at: position });
494
+ if (lengthByte === 0) {
495
+ if (next === null) next = position + 1;
496
+ return { ok: true, name: labels.join('.'), next, hops, bytes: expanded };
497
+ }
498
+ if (position + 1 + lengthByte > buffer.length) {
499
+ return refuse(REFUSAL.TRUNCATED, { at: position, labelBytes: lengthByte, reading: 'label' });
500
+ }
501
+ expanded += lengthByte + 1;
502
+ if (expanded > MAX_NAME_BYTES) {
503
+ return refuse(REFUSAL.NAME_TOO_LONG, { bytes: expanded, limit: MAX_NAME_BYTES, at: position });
504
+ }
505
+ labels.push(buffer.toString('utf8', position + 1, position + 1 + lengthByte));
506
+ position += 1 + lengthByte;
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Decode the RDATA of one record, by type.
512
+ *
513
+ * The caller has already checked that `rdata` is exactly `rdlength` octets and inside the buffer, so
514
+ * every read here is bounded by construction; a *length* that does not match the type is still a
515
+ * refusal, because a record we cannot read exactly is a record we must not report.
516
+ *
517
+ * @param {Buffer} buffer Whole message, for names inside RDATA.
518
+ * @param {number} rdataStart
519
+ * @param {number} rdataEnd
520
+ * @param {number} type
521
+ * @returns {{ok: true, value: object}|{ok: false, reason: string, detail?: object}}
522
+ */
523
+ function readRdata(buffer, rdataStart, rdataEnd, type) {
524
+ const length = rdataEnd - rdataStart;
525
+ if (type === RECORD_TYPE.A) {
526
+ if (length !== 4) return refuse(REFUSAL.RDATA_LENGTH, { type: 'A', length, expected: 4 });
527
+ const bytes = buffer.subarray(rdataStart, rdataEnd);
528
+ return { ok: true, value: { address: formatIpv4(bytes) } };
529
+ }
530
+ if (type === RECORD_TYPE.AAAA) {
531
+ if (length !== 16) return refuse(REFUSAL.RDATA_LENGTH, { type: 'AAAA', length, expected: 16 });
532
+ return { ok: true, value: { address: formatIpv6(buffer.subarray(rdataStart, rdataEnd)) } };
533
+ }
534
+ if (type === RECORD_TYPE.PTR) {
535
+ if (length === 0) return refuse(REFUSAL.RDATA_LENGTH, { type: 'PTR', length, expected: 'a name' });
536
+ const name = readName(buffer, rdataStart);
537
+ if (!name.ok) return name;
538
+ // RFC 1035 搂4.1.4 permits PTR RDATA to be compressed, and mDNS implementations do it, so a
539
+ // pointer is followed here (leniently) even though the encoder writes this name uncompressed.
540
+ return { ok: true, value: { target: name.name } };
541
+ }
542
+ if (type === RECORD_TYPE.SRV) {
543
+ if (length < 6) return refuse(REFUSAL.RDATA_LENGTH, { type: 'SRV', length, expected: 'at least 6' });
544
+ const target = readName(buffer, rdataStart + 6);
545
+ if (!target.ok) return target;
546
+ if (target.next > rdataEnd)
547
+ return refuse(REFUSAL.RDATA_LENGTH, { type: 'SRV', length, detail: 'target name runs past the rdata' });
548
+ return {
549
+ ok: true,
550
+ value: {
551
+ priority: buffer.readUInt16BE(rdataStart),
552
+ weight: buffer.readUInt16BE(rdataStart + 2),
553
+ port: buffer.readUInt16BE(rdataStart + 4),
554
+ target: target.name,
555
+ },
556
+ };
557
+ }
558
+ if (type === RECORD_TYPE.TXT) {
559
+ const strings = [];
560
+ let position = rdataStart;
561
+ while (position < rdataEnd) {
562
+ const stringLength = buffer[position];
563
+ position += 1;
564
+ if (position + stringLength > rdataEnd)
565
+ return refuse(REFUSAL.RDATA_LENGTH, { type: 'TXT', length, at: position });
566
+ strings.push(buffer.toString('utf8', position, position + stringLength));
567
+ position += stringLength;
568
+ }
569
+ return { ok: true, value: { txt: strings } };
570
+ }
571
+ return { ok: true, value: {} };
572
+ }
573
+
574
+ /**
575
+ * Read one resource record.
576
+ *
577
+ * @param {Buffer} buffer
578
+ * @param {number} offset Offset of the record's owner name.
579
+ * @param {string} section `answer` | `authority` | `additional`.
580
+ * @param {{stats: object}} context
581
+ * @returns {{ok: true, record: object, next: number}|{ok: false, reason: string, detail?: object}}
582
+ */
583
+ function readRecord(buffer, offset, section, context) {
584
+ const name = readName(buffer, offset);
585
+ if (!name.ok) return name;
586
+ const fields = name.next;
587
+ // NAME + TYPE(2) + CLASS(2) + TTL(4) + RDLENGTH(2) = the 10 octets every record must have.
588
+ if (fields + 10 > buffer.length)
589
+ return refuse(REFUSAL.TRUNCATED, { at: fields, reading: `${section} record header` });
590
+ const type = buffer.readUInt16BE(fields);
591
+ const rrclass = buffer.readUInt16BE(fields + 2);
592
+ const ttl = buffer.readUInt32BE(fields + 4);
593
+ const rdlength = buffer.readUInt16BE(fields + 8);
594
+ const rdataStart = fields + 10;
595
+ const rdataEnd = rdataStart + rdlength;
596
+ if (rdataEnd > buffer.length) {
597
+ return refuse(REFUSAL.TRUNCATED, { at: rdataStart, rdlength, available: buffer.length - rdataStart });
598
+ }
599
+
600
+ const value = readRdata(buffer, rdataStart, rdataEnd, type);
601
+ if (!value.ok) return value;
602
+
603
+ context.stats.records_read += 1;
604
+ context.stats.names_read += 1;
605
+ if (name.hops > context.stats.pointer_hops_max) context.stats.pointer_hops_max = name.hops;
606
+ return {
607
+ ok: true,
608
+ next: rdataEnd,
609
+ record: {
610
+ section,
611
+ name: name.name,
612
+ type,
613
+ type_name: typeName(type),
614
+ // RFC 6762 搂10.2: the cache-flush bit is not part of the class and must be masked off.
615
+ class: rrclass & 0x7fff,
616
+ cache_flush: (rrclass & CACHE_FLUSH) !== 0,
617
+ ttl,
618
+ // TTL 0 is a goodbye (RFC 6762 搂10.1), and it is a *value*, not an error: the consumers drop
619
+ // the record when they see it.
620
+ goodbye: ttl === 0,
621
+ rdata: Buffer.from(buffer.subarray(rdataStart, rdataEnd)),
622
+ offset,
623
+ ...value.value,
624
+ },
625
+ };
626
+ }
627
+
628
+ /**
629
+ * Decode one DNS/mDNS message.
630
+ *
631
+ * Never throws. A malformed, hostile or truncated packet is a refusal with a stable code.
632
+ *
633
+ * @param {unknown} buffer
634
+ * @returns {{ok: true, message: object}|{ok: false, reason: string, detail?: object}}
635
+ */
636
+ export function decodeMessage(buffer) {
637
+ try {
638
+ if (!Buffer.isBuffer(buffer)) return refuse(REFUSAL.NOT_A_MESSAGE, { got: typeof buffer });
639
+ if (buffer.length < HEADER_BYTES)
640
+ return refuse(REFUSAL.TRUNCATED, { bytes: buffer.length, reading: 'header' });
641
+ if (buffer.length > MAX_MESSAGE_BYTES)
642
+ return refuse(REFUSAL.MESSAGE_TOO_LARGE, { bytes: buffer.length, limit: MAX_MESSAGE_BYTES });
643
+
644
+ const id = buffer.readUInt16BE(0);
645
+ const flagsWord = buffer.readUInt16BE(2);
646
+ const counts = {
647
+ questions: buffer.readUInt16BE(4),
648
+ answers: buffer.readUInt16BE(6),
649
+ authorities: buffer.readUInt16BE(8),
650
+ additionals: buffer.readUInt16BE(10),
651
+ };
652
+ const total = counts.questions + counts.answers + counts.authorities + counts.additionals;
653
+ if (total > MAX_RECORDS) return refuse(REFUSAL.RECORD_COUNT, { total, limit: MAX_RECORDS });
654
+
655
+ const context = { stats: { names_read: 0, records_read: 0, pointer_hops_max: 0 } };
656
+ let offset = HEADER_BYTES;
657
+ const questions = [];
658
+
659
+ for (let index = 0; index < counts.questions; index += 1) {
660
+ const name = readName(buffer, offset);
661
+ if (!name.ok) return name;
662
+ offset = name.next;
663
+ if (offset + 4 > buffer.length) return refuse(REFUSAL.TRUNCATED, { at: offset, reading: 'question' });
664
+ const qclass = buffer.readUInt16BE(offset + 2);
665
+ questions.push({
666
+ name: name.name,
667
+ type: buffer.readUInt16BE(offset),
668
+ class: qclass & 0x7fff,
669
+ // RFC 6762 搂5.4: the top bit of the class asks for a unicast reply ("QU").
670
+ unicast_response: (qclass & UNICAST_RESPONSE) !== 0,
671
+ });
672
+ offset += 4;
673
+ context.stats.names_read += 1;
674
+ if (name.hops > context.stats.pointer_hops_max) context.stats.pointer_hops_max = name.hops;
675
+ }
676
+
677
+ const sections = [
678
+ ['answer', counts.answers],
679
+ ['authority', counts.authorities],
680
+ ['additional', counts.additionals],
681
+ ];
682
+ const records = [];
683
+ for (const [section, count] of sections) {
684
+ for (let index = 0; index < count; index += 1) {
685
+ const read = readRecord(buffer, offset, section, context);
686
+ if (!read.ok) return read;
687
+ offset = read.next;
688
+ records.push(read.record);
689
+ }
690
+ }
691
+
692
+ return {
693
+ ok: true,
694
+ message: {
695
+ id,
696
+ flags: {
697
+ qr: (flagsWord & FLAG.QR) !== 0,
698
+ opcode: (flagsWord >> 11) & 0x0f,
699
+ aa: (flagsWord & FLAG.AA) !== 0,
700
+ tc: (flagsWord & FLAG.TC) !== 0,
701
+ rd: (flagsWord & FLAG.RD) !== 0,
702
+ ra: (flagsWord & FLAG.RA) !== 0,
703
+ rcode: flagsWord & 0x0f,
704
+ word: flagsWord,
705
+ },
706
+ counts,
707
+ questions,
708
+ answers: records.filter((record) => record.section === 'answer'),
709
+ authorities: records.filter((record) => record.section === 'authority'),
710
+ additionals: records.filter((record) => record.section === 'additional'),
711
+ records,
712
+ // Bytes after the last record. Non-zero means the sender wrote something this parser does not
713
+ // account for; it is reported rather than hidden, and never treated as a record.
714
+ trailing_bytes: buffer.length - offset,
715
+ stats: context.stats,
716
+ },
717
+ };
718
+ } catch (error) {
719
+ // The whole point of this decoder is that nothing escapes it. A bug below becomes a refusal.
720
+ return refuse(REFUSAL.INTERNAL, { message: error instanceof Error ? error.message : String(error) });
721
+ }
722
+ }
723
+
724
+ /**
725
+ * {@link decodeMessage}, flattened to the message or `null`.
726
+ *
727
+ * @param {unknown} buffer
728
+ * @returns {object|null}
729
+ */
730
+ export function decodeMessageOrNull(buffer) {
731
+ const decoded = decodeMessage(buffer);
732
+ return decoded.ok ? decoded.message : null;
733
+ }
734
+
735
+ // ---------------------------------------------------------------------------------------------
736
+ // Encoding
737
+ // ---------------------------------------------------------------------------------------------
738
+
739
+ /**
740
+ * A message writer with a per-message name-compression suffix dictionary.
741
+ *
742
+ * Offsets are recorded for every suffix written at an offset a pointer could name (`<= 0x3fff`), which
743
+ * is what makes the encoder compress the *owner names* and the PTR RDATA the way an mDNS peer expects
744
+ * to receive them.
745
+ *
746
+ * @returns {object}
747
+ */
748
+ function createWriter() {
749
+ const parts = [];
750
+ const suffixes = new Map();
751
+ let length = 0;
752
+
753
+ const push = (buffer) => {
754
+ parts.push(buffer);
755
+ length += buffer.length;
756
+ return buffer;
757
+ };
758
+
759
+ return {
760
+ get length() {
761
+ return length;
762
+ },
763
+ writeUint8: (value) => push(Buffer.from([value & 0xff])),
764
+ writeUint16: (value) => {
765
+ const buffer = Buffer.alloc(2);
766
+ buffer.writeUInt16BE(value & 0xffff, 0);
767
+ return push(buffer);
768
+ },
769
+ writeUint32: (value) => {
770
+ const buffer = Buffer.alloc(4);
771
+ buffer.writeUInt32BE(value >>> 0, 0);
772
+ return push(buffer);
773
+ },
774
+ writeBytes: (buffer) => push(Buffer.from(buffer)),
775
+
776
+ /**
777
+ * Write one name, compressing from the dictionary when asked.
778
+ *
779
+ * @param {string} name
780
+ * @param {{compress?: boolean, remember?: boolean, what?: string}} [options] `compress: false`
781
+ * writes every label (SRV RDATA must not be compressed); `remember: false` keeps the offsets out
782
+ * of the dictionary, so no later record can point *into* this name.
783
+ */
784
+ writeName(name, options = {}) {
785
+ const compress = options.compress !== false;
786
+ const remember = compress && options.remember !== false;
787
+ const labels = splitName(name, options.what);
788
+ for (let index = 0; index < labels.length; index += 1) {
789
+ const suffix = labels.slice(index).join('.').toLowerCase();
790
+ const known = compress ? suffixes.get(suffix) : undefined;
791
+ if (known !== undefined && known <= MAX_POINTER_TARGET) {
792
+ this.writeUint16(POINTER_WORD_MASK | known);
793
+ return;
794
+ }
795
+ if (remember && length <= MAX_POINTER_TARGET && suffixes.size < MAX_COMPRESSION_ENTRIES) {
796
+ suffixes.set(suffix, length);
797
+ }
798
+ const bytes = Buffer.from(labels[index], 'utf8');
799
+ push(Buffer.from([bytes.length]));
800
+ push(bytes);
801
+ }
802
+ push(Buffer.from([0]));
803
+ },
804
+
805
+ /** Write a length placeholder and hand back the buffer, so the caller can patch it. */
806
+ reserveUint16() {
807
+ return push(Buffer.alloc(2));
808
+ },
809
+
810
+ /** @param {Buffer} placeholder @param {number} value */
811
+ patchUint16(placeholder, value) {
812
+ placeholder.writeUInt16BE(value & 0xffff, 0);
813
+ },
814
+
815
+ toBuffer: () => Buffer.concat(parts, length),
816
+ };
817
+ }
818
+
819
+ /**
820
+ * Encode the RDATA of one record, either into the message or into a standalone buffer.
821
+ *
822
+ * Compression policy, and the reasoning behind it:
823
+ *
824
+ * * **PTR RDATA is compressed.** It is a name, RFC 1035 搂4.1.4 permits it, and every mDNS responder
825
+ * on a real LAN does it 鈥?a response that did not would be larger than the peers' own.
826
+ * * **SRV RDATA is not compressed.** RFC 1035 搂4.1.4 lists the types whose RDATA may be compressed
827
+ * and SRV (RFC 2782) is not among them, which is the conservative reading every interoperating
828
+ * implementation follows. The *decoder* still follows a pointer there, because refusing a packet a
829
+ * peer legitimately built is worse than accepting one written in a style we would not choose.
830
+ *
831
+ * Two shapes come back, and the difference is not cosmetic: a name inside RDATA can only be compressed
832
+ * against offsets that exist in the *message*, so PTR and SRV RDATA must be written straight into it
833
+ * (`{inline: true}`) and A/AAAA/TXT/raw RDATA are built separately and appended.
834
+ *
835
+ * @param {object} writer
836
+ * @param {object} record
837
+ * @returns {{inline: true}|{bytes: Buffer}}
838
+ */
839
+ function encodeRdata(writer, record) {
840
+ const type = record.type;
841
+ if (type === RECORD_TYPE.A) return { bytes: parseIpv4(record.address) };
842
+ if (type === RECORD_TYPE.AAAA) return { bytes: parseIpv6(record.address) };
843
+ if (type === RECORD_TYPE.PTR) {
844
+ writer.writeName(record.target, { what: 'PTR target' });
845
+ return { inline: true };
846
+ }
847
+ if (type === RECORD_TYPE.SRV) {
848
+ writer.writeUint16(record.priority ?? 0);
849
+ writer.writeUint16(record.weight ?? 0);
850
+ writer.writeUint16(record.port ?? 0);
851
+ writer.writeName(record.target, { compress: false, remember: false, what: 'SRV target' });
852
+ return { inline: true };
853
+ }
854
+ if (type === RECORD_TYPE.TXT) return { bytes: encodeTxt(record) };
855
+ if (Buffer.isBuffer(record.rdata)) return { bytes: record.rdata };
856
+ throw new MdnsWireError(
857
+ 'MDNS_RDATA_UNSUPPORTED',
858
+ `no encoder for ${typeName(type)} and no raw \`rdata\` was given`,
859
+ );
860
+ }
861
+
862
+ /**
863
+ * Encode a TXT RDATA: a sequence of length-prefixed character-strings, each at most 255 octets.
864
+ *
865
+ * An empty list is written as one zero-length string, which is the DNS-SD convention for "this service
866
+ * has no attributes but does have a TXT record" (RFC 6763 搂6.1).
867
+ *
868
+ * @param {object} record
869
+ * @returns {Buffer}
870
+ */
871
+ function encodeTxt(record) {
872
+ const strings = Array.isArray(record.txt) ? record.txt.map((entry) => String(entry)) : [];
873
+ const parts = [];
874
+ for (const text of strings) {
875
+ const bytes = Buffer.from(text, 'utf8');
876
+ if (bytes.length > 255)
877
+ throw new MdnsWireError(
878
+ 'MDNS_TXT_TOO_LONG',
879
+ `TXT string of ${bytes.length} octets; RFC 1035 搂3.3.14 allows 255`,
880
+ );
881
+ parts.push(Buffer.from([bytes.length]), bytes);
882
+ }
883
+ if (parts.length === 0) parts.push(Buffer.from([0]));
884
+ return Buffer.concat(parts);
885
+ }
886
+
887
+ /**
888
+ * Build the second header word from a flags object.
889
+ */
890
+ function encodeFlags(flags = {}) {
891
+ let word = 0;
892
+ if (flags.qr === true) word |= FLAG.QR;
893
+ if (flags.aa === true) word |= FLAG.AA;
894
+ if (flags.tc === true) word |= FLAG.TC;
895
+ if (flags.rd === true) word |= FLAG.RD;
896
+ if (flags.ra === true) word |= FLAG.RA;
897
+ const opcode = Number.isInteger(flags.opcode) ? flags.opcode : 0;
898
+ const rcode = Number.isInteger(flags.rcode) ? flags.rcode : 0;
899
+ return word | ((opcode & 0x0f) << 11) | (rcode & 0x0f);
900
+ }
901
+
902
+ /**
903
+ * Write one record into a message.
904
+ *
905
+ * @param {object} writer
906
+ * @param {object} record
907
+ * @param {string} section Section name, for diagnostics on a refusal.
908
+ * @returns {void}
909
+ */
910
+ function writeRecord(writer, record, section) {
911
+ const type = typeof record.type === 'number' ? record.type : RECORD_TYPE[String(record.type).toUpperCase()];
912
+ if (!Number.isInteger(type)) {
913
+ throw new MdnsWireError(
914
+ 'MDNS_TYPE_UNKNOWN',
915
+ `${section} record has no usable type: ${JSON.stringify(record.type)}`,
916
+ );
917
+ }
918
+ const rrclass = Number.isInteger(record.class) ? record.class : DNS_CLASS_IN;
919
+ const ttl = Number.isInteger(record.ttl) ? record.ttl : 0;
920
+ if (ttl < 0 || ttl > 0xffffffff)
921
+ throw new MdnsWireError('MDNS_TTL_RANGE', `ttl ${ttl} is outside 0..4294967295`);
922
+ writer.writeName(record.name, { what: `${section} record owner` });
923
+ writer.writeUint16(type);
924
+ writer.writeUint16(rrclass | (record.cache_flush === true || record.cacheFlush === true ? CACHE_FLUSH : 0));
925
+ writer.writeUint32(ttl);
926
+ // The length has to precede the RDATA, but a compressed name inside the RDATA needs the offsets of
927
+ // everything before it 鈥?so the RDATA is written into the message and the placeholder patched after.
928
+ const placeholder = writer.reserveUint16();
929
+ const before = writer.length;
930
+ const rdata = encodeRdata(writer, { ...record, type });
931
+ if (rdata.bytes !== undefined) writer.writeBytes(rdata.bytes);
932
+ const length = writer.length - before;
933
+ if (length > 0xffff)
934
+ throw new MdnsWireError('MDNS_RDATA_TOO_LONG', `${section} record rdata is ${length} octets`);
935
+ if (rdata.bytes !== undefined && rdata.bytes.length !== length) {
936
+ throw new MdnsWireError(
937
+ 'MDNS_RDATA_MISCOUNT',
938
+ `built ${rdata.bytes.length} octets of rdata but wrote ${length}`,
939
+ );
940
+ }
941
+ writer.patchUint16(placeholder, length);
942
+ }
943
+
944
+ /**
945
+ * Encode one DNS/mDNS message.
946
+ *
947
+ * @param {object} message `{id, flags, questions, answers, authorities, additionals}`.
948
+ * @returns {Buffer}
949
+ * @throws {MdnsWireError} When an argument cannot be encoded. Encoder input is our own data.
950
+ */
951
+ export function encodeMessage(message = {}) {
952
+ const writer = createWriter();
953
+ const questions = Array.isArray(message.questions) ? message.questions : [];
954
+ const sections = [
955
+ ['answer', Array.isArray(message.answers) ? message.answers : []],
956
+ ['authority', Array.isArray(message.authorities) ? message.authorities : []],
957
+ ['additional', Array.isArray(message.additionals) ? message.additionals : []],
958
+ ];
959
+ writer.writeUint16(Number.isInteger(message.id) ? message.id : 0);
960
+ writer.writeUint16(encodeFlags(message.flags));
961
+ writer.writeUint16(questions.length);
962
+ for (const [, records] of sections) writer.writeUint16(records.length);
963
+
964
+ for (const question of questions) {
965
+ writer.writeName(question.name, { what: 'question name' });
966
+ writer.writeUint16(question.type ?? RECORD_TYPE.ANY);
967
+ const qclass = Number.isInteger(question.class) ? question.class : DNS_CLASS_IN;
968
+ writer.writeUint16(qclass | (question.unicast_response === true ? UNICAST_RESPONSE : 0));
969
+ }
970
+ for (const [section, records] of sections) {
971
+ for (const record of records) writeRecord(writer, record, section);
972
+ }
973
+
974
+ const bytes = writer.toBuffer();
975
+ if (bytes.length > MAX_MESSAGE_BYTES) {
976
+ throw new MdnsWireError(
977
+ REFUSAL.MESSAGE_TOO_LARGE,
978
+ `${bytes.length} octets; the cap is ${MAX_MESSAGE_BYTES}`,
979
+ );
980
+ }
981
+ return bytes;
982
+ }
983
+
984
+ /**
985
+ * Build a service-discovery query.
986
+ *
987
+ * RFC 6762 搂5.1: a one-shot query MUST NOT be sent from source port 5353, because that port signals a
988
+ * fully compliant querier. The caller owns the socket and therefore the source port; this function
989
+ * only writes the message. RFC 6762 搂18.1: the ID of a multicast query is zero.
990
+ *
991
+ * @param {{serviceType: string, type?: number, unicastResponse?: boolean, id?: number}} options
992
+ * @returns {Buffer}
993
+ */
994
+ export function encodeQuery(options) {
995
+ const type = options.type ?? RECORD_TYPE.PTR;
996
+ return encodeMessage({
997
+ id: Number.isInteger(options.id) ? options.id : 0,
998
+ // 搂18.3/搂18.4/搂18.5/搂18.6: opcode 0, and the RD/RA/Z bits clear on transmission.
999
+ flags: { qr: false, opcode: 0, aa: false, tc: false, rd: false, ra: false, rcode: 0 },
1000
+ questions: [
1001
+ {
1002
+ name: options.serviceType,
1003
+ type,
1004
+ class: DNS_CLASS_IN,
1005
+ unicast_response: options.unicastResponse === true,
1006
+ },
1007
+ ],
1008
+ });
1009
+ }
1010
+
1011
+ /**
1012
+ * Build a response to a question, copying the question section.
1013
+ *
1014
+ * RFC 6762 搂6.7: a response to a query from a source port other than 5353 ("legacy") MUST repeat the
1015
+ * query ID and the question. `legacy` therefore carries those two behaviours and the 10-second TTL cap
1016
+ * (see {@link capLegacyTtl}).
1017
+ *
1018
+ * @param {object} options
1019
+ * @param {object} options.query The decoded query.
1020
+ * @param {object[]} options.answers
1021
+ * @param {object[]} [options.additionals]
1022
+ * @param {boolean} [options.legacy]
1023
+ * @returns {Buffer}
1024
+ */
1025
+ export function encodeResponse(options) {
1026
+ const { query, answers = [], additionals = [] } = options;
1027
+ const legacy = options.legacy === true;
1028
+ return encodeMessage({
1029
+ id: legacy && Number.isInteger(query?.id) ? query.id : 0,
1030
+ // 搂18.7: the AA bit is set on responses; there is no recursion and no error code in mDNS.
1031
+ flags: { qr: true, opcode: 0, aa: true, tc: false, rd: false, ra: false, rcode: 0 },
1032
+ questions:
1033
+ legacy && Array.isArray(query?.questions)
1034
+ ? query.questions.map((question) => ({ ...question, unicast_response: false }))
1035
+ : [],
1036
+ answers: answers.map((record) => withTtl(record, legacy)),
1037
+ // The additional section carries the records a peer needs next (SRV and TXT for a PTR answer, the
1038
+ // A record for an SRV answer). 搂10.2's cache-flush bit is *not* set for a legacy unicast response.
1039
+ additionals: additionals.map((record) => withTtl(record, legacy)),
1040
+ });
1041
+ }
1042
+
1043
+ /**
1044
+ * Apply RFC 6762 搂6.7's TTL cap and cache-flush rule to one record of a response.
1045
+ *
1046
+ * A legacy resolver is a plain DNS client with no cache-coherency protocol, so it must not be handed a
1047
+ * 75-minute TTL: "the resource record TTL given in a legacy unicast response SHOULD NOT be greater
1048
+ * than ten seconds". The cache-flush bit is meaningless there and MUST NOT be set.
1049
+ *
1050
+ * @param {object} record
1051
+ * @param {boolean} legacy
1052
+ * @returns {object}
1053
+ */
1054
+ export function capLegacyTtl(record, legacy) {
1055
+ return withTtl(record, legacy);
1056
+ }
1057
+
1058
+ /** @param {object} record @param {boolean} legacy @returns {object} */
1059
+ function withTtl(record, legacy) {
1060
+ if (!legacy) return record;
1061
+ return { ...record, ttl: Math.min(Number(record.ttl) || 0, LEGACY_UNICAST_TTL), cache_flush: false };
1062
+ }