@sdxc/zone-file 2026.10.13 → 2026.11.3

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,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
+ }
package/dist/types.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * @copyright Sergio Xalambrí 2026
8
8
  */
9
9
  /** The record types whose data is read into typed fields. */
10
- export type TypedRecordType = "A" | "AAAA" | "CNAME" | "NS" | "PTR" | "DNAME" | "MX" | "TXT" | "CAA" | "SRV" | "SOA";
10
+ export type TypedRecordType = "A" | "AAAA" | "CNAME" | "NS" | "PTR" | "DNAME" | "MX" | "TXT" | "CAA" | "SRV" | "SOA" | "SVCB" | "HTTPS" | "TLSA" | "DS" | "CDS" | "DNSKEY" | "CDNSKEY";
11
11
  /** A record type by mnemonic; the typed ones autocomplete, any other name is accepted. */
12
12
  export type RecordType = TypedRecordType | (string & {});
13
13
  /** A record class; `IN` is the internet, `CLASSnnn` spells one without a mnemonic. */
@@ -90,13 +90,102 @@ export interface SOAData {
90
90
  /** Bounds how long a negative answer may be cached (RFC 2308). */
91
91
  minimum: number;
92
92
  }
93
+ /**
94
+ * The parameters of an SVCB or HTTPS record (RFC 9460), one optional field per key. A key
95
+ * that is absent was not published; `true` marks a key published without a value.
96
+ */
97
+ export interface SvcParams {
98
+ /** Keys a client must understand to use the record, by presentation name (`alpn`, `key65000`). */
99
+ mandatory?: string[];
100
+ /** Protocol ids (`h2`, `h3`) in the publisher's order of preference. */
101
+ alpn?: string[];
102
+ /** The default ALPN (`http/1.1` for HTTPS) is unsupported; only `alpn` applies. */
103
+ noDefaultAlpn?: true;
104
+ port?: number;
105
+ /** IPv4 addresses a client may use before the target's A records resolve. */
106
+ ipv4Hint?: string[];
107
+ /** The ECHConfigList, base64-encoded as the presentation format writes it. */
108
+ ech?: string;
109
+ /** IPv6 addresses in RFC 5952 canonical form, as hints like `ipv4Hint`. */
110
+ ipv6Hint?: string[];
111
+ /** The DoH URI template's path (RFC 9461), relative to the target. */
112
+ dohPath?: string;
113
+ /** The service accepts Oblivious HTTP requests (RFC 9540). */
114
+ ohttp?: true;
115
+ /** Keys without a reading here, by key number, each value's octets read as UTF-8. */
116
+ other?: {
117
+ [key: number]: string;
118
+ };
119
+ }
120
+ /** A service binding (RFC 9460): where and how to reach the service at the owner. */
121
+ export interface SVCBData {
122
+ type: "SVCB";
123
+ /** `0` is AliasMode, which only names `target`; any other value orders ServiceMode records, lowest first. */
124
+ priority: number;
125
+ /** The service's host; `"."` means the owner itself in ServiceMode and no service in AliasMode. */
126
+ target: string;
127
+ params: SvcParams;
128
+ }
129
+ /** The SVCB record for HTTPS origins (RFC 9460), published at the origin's own name. */
130
+ export interface HTTPSData extends Omit<SVCBData, "type"> {
131
+ type: "HTTPS";
132
+ }
133
+ /** Binds a TLS certificate or public key to a service, DANE (RFC 6698, RFC 7671). */
134
+ export interface TLSAData {
135
+ type: "TLSA";
136
+ /** `0` PKIX-TA, `1` PKIX-EE, `2` DANE-TA, `3` DANE-EE. */
137
+ usage: number;
138
+ /** `0` matches the whole certificate, `1` its SubjectPublicKeyInfo. */
139
+ selector: number;
140
+ /** `0` the exact bytes, `1` their SHA-256, `2` their SHA-512. */
141
+ matchingType: number;
142
+ /** Lowercase hex. */
143
+ certificateAssociation: string;
144
+ }
145
+ /** A digest of the child zone's key-signing key, published in the parent (RFC 4034). */
146
+ export interface DSData {
147
+ type: "DS";
148
+ /** The key tag of the DNSKEY the digest is of, for picking it out of the child's key set. */
149
+ keyTag: number;
150
+ /** The DNSSEC algorithm number; `13` is ECDSA P-256 with SHA-256. */
151
+ algorithm: number;
152
+ /** `1` SHA-1, `2` SHA-256, `4` SHA-384. */
153
+ digestType: number;
154
+ /** Lowercase hex. */
155
+ digest: string;
156
+ }
157
+ /** The child's DS record as it asks the parent to publish it (RFC 7344); `0 0 0 00` asks for removal. */
158
+ export interface CDSData extends Omit<DSData, "type"> {
159
+ type: "CDS";
160
+ }
161
+ /** A zone's public signing key (RFC 4034). */
162
+ export interface DNSKEYData {
163
+ type: "DNSKEY";
164
+ /** The flags as published, so the record prints back unchanged. */
165
+ flags: number;
166
+ /** Bit 7 of `flags`: the key signs the zone's records; DNSSEC ignores a key without it. */
167
+ zoneKey: boolean;
168
+ /** Bit 15 of `flags`: the key is a key-signing key, the one a DS record points at. */
169
+ secureEntryPoint: boolean;
170
+ /** Always `3` in a valid key. */
171
+ protocol: number;
172
+ algorithm: number;
173
+ /** Base64, as the presentation format writes it. */
174
+ publicKey: string;
175
+ /** The RFC 4034 Appendix B tag a DS or RRSIG names this key by. */
176
+ keyTag: number;
177
+ }
178
+ /** The child's DNSKEY as it asks the parent to derive a DS from it (RFC 7344). */
179
+ export interface CDNSKEYData extends Omit<DNSKEYData, "type"> {
180
+ type: "CDNSKEY";
181
+ }
93
182
  /** Data of a type without a typed reading, kept as written. */
94
183
  export interface UnknownData {
95
184
  type: string;
96
185
  data: string;
97
186
  }
98
187
  /** A record's type-specific fields, what `parseRecordData` reads out of RDATA. */
99
- export type RecordData<Type extends RecordType> = Type extends "A" ? AData : Type extends "AAAA" ? AAAAData : Type extends "CNAME" ? CNAMEData : Type extends "NS" ? NSData : Type extends "PTR" ? PTRData : Type extends "DNAME" ? DNAMEData : Type extends "MX" ? MXData : Type extends "TXT" ? TXTData : Type extends "CAA" ? CAAData : Type extends "SRV" ? SRVData : Type extends "SOA" ? SOAData : UnknownData;
188
+ export type RecordData<Type extends RecordType> = Type extends "A" ? AData : Type extends "AAAA" ? AAAAData : Type extends "CNAME" ? CNAMEData : Type extends "NS" ? NSData : Type extends "PTR" ? PTRData : Type extends "DNAME" ? DNAMEData : Type extends "MX" ? MXData : Type extends "TXT" ? TXTData : Type extends "CAA" ? CAAData : Type extends "SRV" ? SRVData : Type extends "SOA" ? SOAData : Type extends "SVCB" ? SVCBData : Type extends "HTTPS" ? HTTPSData : Type extends "TLSA" ? TLSAData : Type extends "DS" ? DSData : Type extends "CDS" ? CDSData : Type extends "DNSKEY" ? DNSKEYData : Type extends "CDNSKEY" ? CDNSKEYData : UnknownData;
100
189
  /** What every record read from a file carries beside its data. */
101
190
  export interface RecordFields {
102
191
  /** Absolute, lowercased, no trailing dot; the root is `"."`. */
package/package.json CHANGED
@@ -1,16 +1,21 @@
1
1
  {
2
2
  "name": "@sdxc/zone-file",
3
- "version": "2026.10.13",
3
+ "version": "2026.11.3",
4
4
  "description": "Read and write RFC 1035 DNS zone files, with the record data codec for every typed record",
5
5
  "license": "MIT",
6
+ "bin": {
7
+ "sdxc-zone-file": "dist/cli.js"
8
+ },
6
9
  "type": "module",
7
10
  "exports": {
8
11
  ".": "./dist/index.js"
9
12
  },
10
13
  "dependencies": {
11
- "@sdxc/result": "2026.10.13"
14
+ "@sdxc/cli": "2026.11.3",
15
+ "@sdxc/result": "2026.10.13",
16
+ "remix": "3.1.0"
12
17
  },
13
- "gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
18
+ "gitHead": "d1dd4030f8df68b8a170940a3f790ea8654d3676",
14
19
  "publishConfig": {
15
20
  "access": "public"
16
21
  },
@@ -19,7 +24,7 @@
19
24
  "url": "git+https://github.com/sergiodxa/monorepo.git",
20
25
  "directory": "packages/zone-file"
21
26
  },
22
- "homepage": "https://sdxc.sergiodxa.com/api/zone-file",
27
+ "homepage": "https://sdxc.dev/api/zone-file",
23
28
  "author": {
24
29
  "name": "Sergio Xalambrí",
25
30
  "url": "https://sergiodxa.com"