@sdxc/zone-file 2026.10.10 → 2026.10.25

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.
package/dist/index.js CHANGED
@@ -10,5 +10,5 @@ export { RecordDataError, ZoneFileError } from "./errors.js";
10
10
  export { formatRecordData } from "./format-record-data.js";
11
11
  export { parse } from "./parse.js";
12
12
  export { parseRecordData } from "./parse-record-data.js";
13
- export { canonicalType, typeName } from "./record-types.js";
13
+ export { canonicalType, typeCode, typeName } from "./record-types.js";
14
14
  export { stringify } from "./stringify.js";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Hex and base64 codecs for the binary fields of DNSSEC, DANE and SVCB records, which the
3
+ * presentation format writes as text a zone file may split across whitespace and lines.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ /** The octets a hex string spells, whitespace ignored; `null` for an odd or non-hex run, or none. */
9
+ export declare function decodeHex(text: string): Uint8Array | null;
10
+ /** Octets as lowercase hex, the spelling DS digests and TLSA data compare in. */
11
+ export declare function encodeHex(octets: Uint8Array): string;
12
+ /** The octets a base64 string spells, whitespace ignored; `null` for text that is not base64, or none. */
13
+ export declare function decodeBase64(text: string): Uint8Array | null;
14
+ /** Octets as padded base64, the spelling DNSKEY keys and SVCB `ech` print in. */
15
+ export declare function encodeBase64(octets: Uint8Array): string;
package/dist/octets.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Hex and base64 codecs for the binary fields of DNSSEC, DANE and SVCB records, which the
3
+ * presentation format writes as text a zone file may split across whitespace and lines.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ /** Hex text in pairs, with any whitespace between digits already removed. */
9
+ const HEX_PATTERN = /^(?:[0-9a-fA-F]{2})+$/;
10
+ /** Canonical base64: complete quads, padding only at the end. */
11
+ const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
12
+ /** The octets a hex string spells, whitespace ignored; `null` for an odd or non-hex run, or none. */
13
+ export function decodeHex(text) {
14
+ let hex = text.replace(/\s+/g, "");
15
+ if (!HEX_PATTERN.test(hex))
16
+ return null;
17
+ let octets = new Uint8Array(hex.length / 2);
18
+ for (let index = 0; index < octets.length; index++)
19
+ octets[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
20
+ return octets;
21
+ }
22
+ /** Octets as lowercase hex, the spelling DS digests and TLSA data compare in. */
23
+ export function encodeHex(octets) {
24
+ return Array.from(octets, (octet) => octet.toString(16).padStart(2, "0")).join("");
25
+ }
26
+ /** The octets a base64 string spells, whitespace ignored; `null` for text that is not base64, or none. */
27
+ export function decodeBase64(text) {
28
+ let base64 = text.replace(/\s+/g, "");
29
+ if (base64.length === 0 || !BASE64_PATTERN.test(base64))
30
+ return null;
31
+ let binary = atob(base64);
32
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
33
+ }
34
+ /** Octets as padded base64, the spelling DNSKEY keys and SVCB `ech` print in. */
35
+ export function encodeBase64(octets) {
36
+ return btoa(Array.from(octets, (octet) => String.fromCharCode(octet)).join(""));
37
+ }
@@ -1,14 +1,13 @@
1
1
  import { failure, isFailure, success } from "@sdxc/result";
2
+ import { canonicalIpv6, isIpv4, printIpv6 } from "./addresses.js";
2
3
  import { decodeOctets, readCharacterStrings } from "./character-strings.js";
4
+ import { keyTag, readAlgorithm } from "./dnssec.js";
3
5
  import { RecordDataError } from "./errors.js";
4
6
  import { canonicalName, printLabels } from "./names.js";
7
+ import { decodeBase64, decodeHex, encodeBase64, encodeHex } from "./octets.js";
5
8
  import { canonicalType } from "./record-types.js";
9
+ import { readSvcParams, readWireSvcParams } from "./svc-params.js";
6
10
  import { readTtl } from "./ttl.js";
7
- /**
8
- * A dotted-quad IPv4 address without leading zeros, which `inet_aton` would read as octal
9
- * and browsers as decimal, so an address carrying them has no single meaning.
10
- */
11
- const IPV4_PATTERN = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
12
11
  /**
13
12
  * Reads a record's data into its typed fields; untyped types come back as `{ type, data }`.
14
13
  * Names come back canonical (lowercased, escapes resolved, no trailing dot, the root as
@@ -39,19 +38,41 @@ function readNumber(field, max) {
39
38
  let number = Number(field);
40
39
  return number <= max ? number : null;
41
40
  }
42
- /** An IPv6 literal in RFC 5952 canonical form, or `null` when the text is not one. */
43
- function canonicalIpv6(value) {
44
- if (!value.includes(":"))
45
- return null;
46
- let url = URL.parse(`http://[${value}]/`);
47
- return url ? url.hostname.slice(1, -1) : null;
41
+ /**
42
+ * The fields of DNSKEY data from its wire octets, the key tag computed over them, so the
43
+ * presentation and generic spellings of one key agree on every field.
44
+ */
45
+ function dnskeyFields(rdata) {
46
+ let flags = ((rdata[0] ?? 0) << 8) | (rdata[1] ?? 0);
47
+ return {
48
+ flags,
49
+ zoneKey: (flags & 0x0100) !== 0,
50
+ secureEntryPoint: (flags & 0x0001) !== 0,
51
+ protocol: rdata[2] ?? 0,
52
+ algorithm: rdata[3] ?? 0,
53
+ publicKey: encodeBase64(rdata.subarray(4)),
54
+ keyTag: keyTag(rdata),
55
+ };
56
+ }
57
+ /** Reads the leading `count` whitespace-separated fields and the rest of the text after them. */
58
+ function splitLeading(value, count) {
59
+ let fields = [];
60
+ let rest = value;
61
+ for (let index = 0; index < count; index++) {
62
+ let match = /^(\S+)\s*/.exec(rest);
63
+ if (!match)
64
+ break;
65
+ fields.push(match[1] ?? "");
66
+ rest = rest.slice(match[0].length);
67
+ }
68
+ return [fields, rest];
48
69
  }
49
70
  /** Reads presentation-format data for the typed record types. */
50
71
  function parsePresentation(type, value) {
51
72
  let fields = value.split(/\s+/).filter((field) => field.length > 0);
52
73
  switch (type) {
53
74
  case "A":
54
- return IPV4_PATTERN.test(value) ? success({ type, address: value }) : invalid(type, value);
75
+ return isIpv4(value) ? success({ type, address: value }) : invalid(type, value);
55
76
  case "AAAA": {
56
77
  let address = canonicalIpv6(value);
57
78
  return address ? success({ type, address }) : invalid(type, value);
@@ -133,6 +154,50 @@ function parsePresentation(type, value) {
133
154
  minimum,
134
155
  });
135
156
  }
157
+ case "SVCB":
158
+ case "HTTPS": {
159
+ let [[priorityField, targetField], rest] = splitLeading(value, 2);
160
+ let priority = readNumber(priorityField, 0xffff);
161
+ let target = targetField === undefined ? null : canonicalName(targetField);
162
+ let params = readSvcParams(rest);
163
+ if (priority === null || target === null || params === null)
164
+ return invalid(type, value);
165
+ return success({ type, priority, target, params });
166
+ }
167
+ case "TLSA": {
168
+ let [leading, rest] = splitLeading(value, 3);
169
+ let [usage, selector, matchingType] = leading.map((field) => readNumber(field, 0xff));
170
+ let association = decodeHex(rest);
171
+ if (usage == null || selector == null || matchingType == null || !association)
172
+ return invalid(type, value);
173
+ let certificateAssociation = encodeHex(association);
174
+ return success({ type, usage, selector, matchingType, certificateAssociation });
175
+ }
176
+ case "DS":
177
+ case "CDS": {
178
+ let [[tagField, algorithmField, digestTypeField], rest] = splitLeading(value, 3);
179
+ let keyTag = readNumber(tagField, 0xffff);
180
+ let algorithm = readAlgorithm(algorithmField);
181
+ let digestType = readNumber(digestTypeField, 0xff);
182
+ let digest = decodeHex(rest);
183
+ if (keyTag === null || algorithm === null || digestType === null || !digest)
184
+ return invalid(type, value);
185
+ return success({ type, keyTag, algorithm, digestType, digest: encodeHex(digest) });
186
+ }
187
+ case "DNSKEY":
188
+ case "CDNSKEY": {
189
+ let [[flagsField, protocolField, algorithmField], rest] = splitLeading(value, 3);
190
+ let flags = readNumber(flagsField, 0xffff);
191
+ let protocol = readNumber(protocolField, 0xff);
192
+ let algorithm = readAlgorithm(algorithmField);
193
+ let key = decodeBase64(rest);
194
+ if (flags === null || protocol === null || algorithm === null || !key)
195
+ return invalid(type, value);
196
+ let rdata = new Uint8Array(4 + key.length);
197
+ rdata.set([flags >> 8, flags & 0xff, protocol, algorithm]);
198
+ rdata.set(key, 4);
199
+ return success({ type, ...dnskeyFields(rdata) });
200
+ }
136
201
  default:
137
202
  return success({ type, data: value });
138
203
  }
@@ -187,10 +252,7 @@ class WireReader {
187
252
  }
188
253
  case "AAAA": {
189
254
  let bytes = this.#bytes(16);
190
- if (!bytes)
191
- return null;
192
- let groups = Array.from({ length: 8 }, (_, index) => (((bytes[index * 2] ?? 0) << 8) | (bytes[index * 2 + 1] ?? 0)).toString(16));
193
- return { address: canonicalIpv6(groups.join(":")) ?? groups.join(":") };
255
+ return bytes ? { address: printIpv6(bytes) } : null;
194
256
  }
195
257
  case "CNAME":
196
258
  case "PTR":
@@ -250,6 +312,39 @@ class WireReader {
250
312
  let [serial, refresh, retry, expire, minimum] = numbers;
251
313
  return { primary, mailbox, serial, refresh, retry, expire, minimum };
252
314
  }
315
+ case "SVCB":
316
+ case "HTTPS": {
317
+ let priority = this.#uint(2);
318
+ let target = this.#name();
319
+ let params = readWireSvcParams(this.#rest());
320
+ if (priority === null || target === null || params === null)
321
+ return null;
322
+ return { priority, target, params };
323
+ }
324
+ case "TLSA": {
325
+ let [usage, selector, matchingType] = [this.#uint(1), this.#uint(1), this.#uint(1)];
326
+ let association = this.#rest();
327
+ if (usage === null || selector === null || matchingType === null)
328
+ return null;
329
+ if (association.length === 0)
330
+ return null;
331
+ return { usage, selector, matchingType, certificateAssociation: encodeHex(association) };
332
+ }
333
+ case "DS":
334
+ case "CDS": {
335
+ let [keyTag, algorithm, digestType] = [this.#uint(2), this.#uint(1), this.#uint(1)];
336
+ let digest = this.#rest();
337
+ if (keyTag === null || algorithm === null || digestType === null)
338
+ return null;
339
+ if (digest.length === 0)
340
+ return null;
341
+ return { keyTag, algorithm, digestType, digest: encodeHex(digest) };
342
+ }
343
+ case "DNSKEY":
344
+ case "CDNSKEY": {
345
+ let rdata = this.#rest();
346
+ return rdata.length > 4 ? dnskeyFields(rdata) : null;
347
+ }
253
348
  default:
254
349
  return "unknown";
255
350
  }
@@ -262,6 +357,10 @@ class WireReader {
262
357
  this.#offset += length;
263
358
  return bytes;
264
359
  }
360
+ /** Every octet left, which the types ending in a variable-length field read as that field. */
361
+ #rest() {
362
+ return this.#bytes(this.#octets.length - this.#offset) ?? new Uint8Array();
363
+ }
265
364
  /** A big-endian unsigned integer of `size` octets. */
266
365
  #uint(size) {
267
366
  let bytes = this.#bytes(size);
package/dist/parse.js CHANGED
@@ -20,6 +20,8 @@ const NAME_FIELDS = {
20
20
  MX: [1],
21
21
  SRV: [3],
22
22
  SOA: [0, 1],
23
+ SVCB: [1],
24
+ HTTPS: [1],
23
25
  };
24
26
  /** The class numbers with a mnemonic, so `CLASS1` and `IN` read as one class. */
25
27
  const CLASS_NAMES = { "1": "IN", "2": "CS", "3": "CH", "4": "HS" };
@@ -8,6 +8,14 @@
8
8
  import type * as ZoneFile from "./types.js";
9
9
  /** The mnemonic for a type code, or `TYPEnnn` when it has none here. */
10
10
  export declare function typeName(code: number): string;
11
+ /**
12
+ * The IANA code of a type, given as a mnemonic or `TYPEnnn`, so a caller building a query
13
+ * message can name the type the way a zone file does; `null` for a mnemonic it does not know.
14
+ *
15
+ * @example typeCode("HTTPS") // 65
16
+ * @example typeCode("TYPE65280") // 65280
17
+ */
18
+ export declare function typeCode(type: string): number | null;
11
19
  /**
12
20
  * The spelling of a type as a record's `type`: the uppercase mnemonic, and a known
13
21
  * mnemonic for `TYPEnnn` when one exists.
@@ -43,6 +43,13 @@ const TYPED_TYPES = new Set([
43
43
  "CAA",
44
44
  "SRV",
45
45
  "SOA",
46
+ "SVCB",
47
+ "HTTPS",
48
+ "TLSA",
49
+ "DS",
50
+ "CDS",
51
+ "DNSKEY",
52
+ "CDNSKEY",
46
53
  ]);
47
54
  /** The mnemonic for each code in {@link TYPE_CODES}. */
48
55
  const TYPE_NAMES = new Map(Object.entries(TYPE_CODES).map(([name, code]) => [code, name]));
@@ -50,6 +57,22 @@ const TYPE_NAMES = new Map(Object.entries(TYPE_CODES).map(([name, code]) => [cod
50
57
  export function typeName(code) {
51
58
  return TYPE_NAMES.get(code) ?? `TYPE${code}`;
52
59
  }
60
+ /**
61
+ * The IANA code of a type, given as a mnemonic or `TYPEnnn`, so a caller building a query
62
+ * message can name the type the way a zone file does; `null` for a mnemonic it does not know.
63
+ *
64
+ * @example typeCode("HTTPS") // 65
65
+ * @example typeCode("TYPE65280") // 65280
66
+ */
67
+ export function typeCode(type) {
68
+ let upper = type.trim().toUpperCase();
69
+ let generic = /^TYPE(\d{1,5})$/.exec(upper);
70
+ if (generic) {
71
+ let code = Number(generic[1]);
72
+ return code <= 0xffff ? code : null;
73
+ }
74
+ return TYPE_CODES[upper] ?? null;
75
+ }
53
76
  /**
54
77
  * The spelling of a type as a record's `type`: the uppercase mnemonic, and a known
55
78
  * mnemonic for `TYPEnnn` when one exists.
@@ -0,0 +1,24 @@
1
+ import type * as ZoneFile from "./types.js";
2
+ /**
3
+ * Reads the params of SVCB or HTTPS presentation data, the fields after the target.
4
+ * Fails on an unknown or repeated key and on a value its key does not accept.
5
+ *
6
+ * @param text - Zero or more `key[=value]` fields.
7
+ */
8
+ export declare function readSvcParams(text: string): ZoneFile.SvcParams | null;
9
+ /**
10
+ * Reads the params of SVCB or HTTPS wire data, the octets after the target. Keys must
11
+ * strictly increase, as RFC 9460 requires of the wire form.
12
+ *
13
+ * @param octets - The SvcParams octets, `key length value` repeated.
14
+ */
15
+ export declare function readWireSvcParams(octets: Uint8Array): ZoneFile.SvcParams | null;
16
+ /**
17
+ * Prints params in canonical presentation form, keys in ascending number order and values
18
+ * quoted wherever they are free text, so two spellings of one record print alike.
19
+ *
20
+ * @param params - The params to print.
21
+ * @param quote - Prints a character-string with its escapes.
22
+ * @returns The fields joined by spaces; empty for no params.
23
+ */
24
+ export declare function printSvcParams(params: ZoneFile.SvcParams, quote: (text: string) => string): string;
@@ -0,0 +1,338 @@
1
+ /**
2
+ * The SvcParams codec SVCB and HTTPS share (RFC 9460): presentation `key=value` lists and
3
+ * their wire form both read into one wire-keyed map first, so the two spellings validate
4
+ * by the same rules and come out as the same `SvcParams`.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { isFailure } from "@sdxc/result";
10
+ import { ipv4Octets, ipv6Octets, printIpv6 } from "./addresses.js";
11
+ import { decodeOctets, readCharacterStrings } from "./character-strings.js";
12
+ import { decodeBase64, encodeBase64 } from "./octets.js";
13
+ /** The registered SvcParamKeys by number; any other key is spelled `keyNNNNN`. */
14
+ const KEY_NAMES = {
15
+ 0: "mandatory",
16
+ 1: "alpn",
17
+ 2: "no-default-alpn",
18
+ 3: "port",
19
+ 4: "ipv4hint",
20
+ 5: "ech",
21
+ 6: "ipv6hint",
22
+ 7: "dohpath",
23
+ 8: "ohttp",
24
+ };
25
+ /** The number of each registered key name. */
26
+ const KEY_NUMBERS = new Map(Object.entries(KEY_NAMES).map(([number, name]) => [name, Number(number)]));
27
+ /** Encodes the text of a value into the octets it is on the wire. */
28
+ const ENCODER = new TextEncoder();
29
+ /** The presentation name of a key number. */
30
+ function keyName(key) {
31
+ return KEY_NAMES[key] ?? `key${key}`;
32
+ }
33
+ /** The number of a key name, registered or `keyNNNNN`; `null` for anything else and for the reserved 65535. */
34
+ function keyNumber(name) {
35
+ let generic = /^key(0|[1-9]\d{0,4})$/.exec(name);
36
+ let number = generic ? Number(generic[1]) : (KEY_NUMBERS.get(name) ?? null);
37
+ return number !== null && number < 0xffff ? number : null;
38
+ }
39
+ /**
40
+ * Splits presentation params into `key[=value]` fields on whitespace outside quotes,
41
+ * stepping over escaped characters so `\"` and `\ ` stay inside their field.
42
+ */
43
+ function splitFields(text) {
44
+ let fields = [];
45
+ let field = "";
46
+ let quoted = false;
47
+ for (let index = 0; index < text.length; index++) {
48
+ let char = text[index] ?? "";
49
+ if (char === "\\") {
50
+ field += text.slice(index, index + 2);
51
+ index += 1;
52
+ }
53
+ else if (char === '"') {
54
+ quoted = !quoted;
55
+ field += char;
56
+ }
57
+ else if (!quoted && /\s/.test(char)) {
58
+ if (field.length > 0)
59
+ fields.push(field);
60
+ field = "";
61
+ }
62
+ else {
63
+ field += char;
64
+ }
65
+ }
66
+ if (field.length > 0)
67
+ fields.push(field);
68
+ return fields;
69
+ }
70
+ /** A value's text with its quoting and escapes removed, `key=` as empty; `null` for more than one character-string. */
71
+ function readValue(raw) {
72
+ if (raw.length === 0)
73
+ return "";
74
+ let strings = readCharacterStrings(raw);
75
+ if (isFailure(strings) || strings.data.length !== 1)
76
+ return null;
77
+ return strings.data[0] ?? null;
78
+ }
79
+ /**
80
+ * Splits a value-list (RFC 9460 Appendix A.1) on its unescaped commas, `\,` and `\\`
81
+ * standing for a literal comma and backslash; `null` when any item is empty.
82
+ */
83
+ function splitValueList(value) {
84
+ let items = [];
85
+ let item = "";
86
+ for (let index = 0; index < value.length; index++) {
87
+ let char = value[index] ?? "";
88
+ if (char === "\\" && index + 1 < value.length) {
89
+ item += value[index + 1];
90
+ index += 1;
91
+ }
92
+ else if (char === ",") {
93
+ items.push(item);
94
+ item = "";
95
+ }
96
+ else {
97
+ item += char;
98
+ }
99
+ }
100
+ items.push(item);
101
+ return items.every((entry) => entry.length > 0) ? items : null;
102
+ }
103
+ /** Joins octet runs into one. */
104
+ function concat(parts) {
105
+ let octets = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
106
+ let offset = 0;
107
+ for (let part of parts) {
108
+ octets.set(part, offset);
109
+ offset += part.length;
110
+ }
111
+ return octets;
112
+ }
113
+ /** A 16-bit big-endian integer's two octets. */
114
+ function uint16(value) {
115
+ return Uint8Array.of(value >> 8, value & 0xff);
116
+ }
117
+ /** Reads every item of a value-list through `read`, `null` when the list or any item is invalid. */
118
+ function encodeList(value, read) {
119
+ let items = splitValueList(value);
120
+ if (!items)
121
+ return null;
122
+ let parts = items.map(read);
123
+ return parts.every((part) => part !== null) ? concat(parts) : null;
124
+ }
125
+ /** The wire octets of one key's presentation value; `value` is `null` when the field has no `=`. */
126
+ function encodeValue(key, value) {
127
+ switch (key) {
128
+ case 0: {
129
+ if (value === null)
130
+ return null;
131
+ let items = splitValueList(value);
132
+ let keys = items?.map(keyNumber);
133
+ if (!keys || keys.some((number) => number === null))
134
+ return null;
135
+ return concat(keys.sort((a, b) => a - b).map(uint16));
136
+ }
137
+ case 1:
138
+ return value === null
139
+ ? null
140
+ : encodeList(value, (item) => {
141
+ let octets = ENCODER.encode(item);
142
+ return octets.length <= 0xff ? concat([Uint8Array.of(octets.length), octets]) : null;
143
+ });
144
+ case 2:
145
+ case 8:
146
+ return value === null || value === "" ? new Uint8Array() : null;
147
+ case 3:
148
+ return value !== null && /^\d{1,5}$/.test(value) && Number(value) <= 0xffff
149
+ ? uint16(Number(value))
150
+ : null;
151
+ case 4:
152
+ return value === null ? null : encodeList(value, ipv4Octets);
153
+ case 5:
154
+ return value === null ? null : decodeBase64(value);
155
+ case 6:
156
+ return value === null ? null : encodeList(value, ipv6Octets);
157
+ default:
158
+ return ENCODER.encode(value ?? "");
159
+ }
160
+ }
161
+ /**
162
+ * Reads the params of SVCB or HTTPS presentation data, the fields after the target.
163
+ * Fails on an unknown or repeated key and on a value its key does not accept.
164
+ *
165
+ * @param text - Zero or more `key[=value]` fields.
166
+ */
167
+ export function readSvcParams(text) {
168
+ let values = new Map();
169
+ for (let field of splitFields(text)) {
170
+ let separator = field.indexOf("=");
171
+ let key = keyNumber(separator === -1 ? field : field.slice(0, separator));
172
+ if (key === null || values.has(key))
173
+ return null;
174
+ let value = separator === -1 ? null : readValue(field.slice(separator + 1));
175
+ if (separator !== -1 && value === null)
176
+ return null;
177
+ let octets = encodeValue(key, value);
178
+ if (!octets)
179
+ return null;
180
+ values.set(key, octets);
181
+ }
182
+ return decodeSvcParams(new Map([...values].sort(([a], [b]) => a - b)));
183
+ }
184
+ /**
185
+ * Reads the params of SVCB or HTTPS wire data, the octets after the target. Keys must
186
+ * strictly increase, as RFC 9460 requires of the wire form.
187
+ *
188
+ * @param octets - The SvcParams octets, `key length value` repeated.
189
+ */
190
+ export function readWireSvcParams(octets) {
191
+ let values = new Map();
192
+ let offset = 0;
193
+ let previous = -1;
194
+ while (offset < octets.length) {
195
+ if (offset + 4 > octets.length)
196
+ return null;
197
+ let key = ((octets[offset] ?? 0) << 8) | (octets[offset + 1] ?? 0);
198
+ let length = ((octets[offset + 2] ?? 0) << 8) | (octets[offset + 3] ?? 0);
199
+ offset += 4;
200
+ if (key <= previous || key === 0xffff || offset + length > octets.length)
201
+ return null;
202
+ values.set(key, octets.subarray(offset, offset + length));
203
+ offset += length;
204
+ previous = key;
205
+ }
206
+ return decodeSvcParams(values);
207
+ }
208
+ /** Splits octets into runs of `size`, `null` when they do not divide evenly or are empty. */
209
+ function chunks(octets, size) {
210
+ if (octets.length === 0 || octets.length % size !== 0)
211
+ return null;
212
+ return Array.from({ length: octets.length / size }, (_, index) => octets.subarray(index * size, index * size + size));
213
+ }
214
+ /** Reads length-prefixed ALPN ids, `null` when one is empty or overruns the value. */
215
+ function readAlpn(octets) {
216
+ let ids = [];
217
+ let offset = 0;
218
+ while (offset < octets.length) {
219
+ let length = octets[offset] ?? 0;
220
+ if (length === 0 || offset + 1 + length > octets.length)
221
+ return null;
222
+ ids.push(decodeOctets(octets.subarray(offset + 1, offset + 1 + length)));
223
+ offset += 1 + length;
224
+ }
225
+ return ids.length > 0 ? ids : null;
226
+ }
227
+ /**
228
+ * Turns wire values into `SvcParams`, enforcing what RFC 9460 asks of every key: values of
229
+ * the right shape, and a `mandatory` list of distinct keys that are all present and exclude
230
+ * `mandatory` itself.
231
+ */
232
+ function decodeSvcParams(values) {
233
+ let params = {};
234
+ for (let [key, value] of values) {
235
+ switch (key) {
236
+ case 0: {
237
+ let keys = chunks(value, 2)?.map((pair) => ((pair[0] ?? 0) << 8) | (pair[1] ?? 0));
238
+ if (!keys)
239
+ return null;
240
+ let increasing = keys.every((number, index) => index === 0 || number > (keys[index - 1] ?? 0));
241
+ if (!increasing || keys.some((number) => number === 0 || !values.has(number)))
242
+ return null;
243
+ params.mandatory = keys.map(keyName);
244
+ break;
245
+ }
246
+ case 1: {
247
+ let alpn = readAlpn(value);
248
+ if (!alpn)
249
+ return null;
250
+ params.alpn = alpn;
251
+ break;
252
+ }
253
+ case 2:
254
+ case 8:
255
+ if (value.length !== 0)
256
+ return null;
257
+ if (key === 2)
258
+ params.noDefaultAlpn = true;
259
+ else
260
+ params.ohttp = true;
261
+ break;
262
+ case 3:
263
+ if (value.length !== 2)
264
+ return null;
265
+ params.port = ((value[0] ?? 0) << 8) | (value[1] ?? 0);
266
+ break;
267
+ case 4: {
268
+ let addresses = chunks(value, 4);
269
+ if (!addresses)
270
+ return null;
271
+ params.ipv4Hint = addresses.map((address) => Array.from(address).join("."));
272
+ break;
273
+ }
274
+ case 5:
275
+ if (value.length === 0)
276
+ return null;
277
+ params.ech = encodeBase64(value);
278
+ break;
279
+ case 6: {
280
+ let addresses = chunks(value, 16);
281
+ if (!addresses)
282
+ return null;
283
+ params.ipv6Hint = addresses.map(printIpv6);
284
+ break;
285
+ }
286
+ case 7:
287
+ if (value.length === 0)
288
+ return null;
289
+ params.dohPath = decodeOctets(value);
290
+ break;
291
+ default:
292
+ params.other = { ...params.other, [key]: decodeOctets(value) };
293
+ }
294
+ }
295
+ return params;
296
+ }
297
+ /** Escapes a value-list item so its commas and backslashes read back as part of it. */
298
+ function escapeItem(item) {
299
+ return item.replace(/[\\,]/g, "\\$&");
300
+ }
301
+ /**
302
+ * Prints params in canonical presentation form, keys in ascending number order and values
303
+ * quoted wherever they are free text, so two spellings of one record print alike.
304
+ *
305
+ * @param params - The params to print.
306
+ * @param quote - Prints a character-string with its escapes.
307
+ * @returns The fields joined by spaces; empty for no params.
308
+ */
309
+ export function printSvcParams(params, quote) {
310
+ let fields = [];
311
+ if (params.mandatory) {
312
+ let keys = params.mandatory.map((name) => [keyNumber(name) ?? 0xffff, name]);
313
+ let sorted = keys.sort(([a], [b]) => a - b).map(([, name]) => name);
314
+ fields.push([0, `mandatory=${sorted.join(",")}`]);
315
+ }
316
+ if (params.alpn)
317
+ fields.push([1, `alpn=${quote(params.alpn.map(escapeItem).join(","))}`]);
318
+ if (params.noDefaultAlpn)
319
+ fields.push([2, "no-default-alpn"]);
320
+ if (params.port !== undefined)
321
+ fields.push([3, `port=${params.port}`]);
322
+ if (params.ipv4Hint)
323
+ fields.push([4, `ipv4hint=${params.ipv4Hint.join(",")}`]);
324
+ if (params.ech !== undefined)
325
+ fields.push([5, `ech=${params.ech}`]);
326
+ if (params.ipv6Hint)
327
+ fields.push([6, `ipv6hint=${params.ipv6Hint.join(",")}`]);
328
+ if (params.dohPath !== undefined)
329
+ fields.push([7, `dohpath=${quote(params.dohPath)}`]);
330
+ if (params.ohttp)
331
+ fields.push([8, "ohttp"]);
332
+ for (let [key, value] of Object.entries(params.other ?? {}))
333
+ fields.push([Number(key), `key${key}=${quote(value)}`]);
334
+ return fields
335
+ .sort(([a], [b]) => a - b)
336
+ .map(([, field]) => field)
337
+ .join(" ");
338
+ }