@sdxc/zone-file 2026.10.9 → 2026.10.13

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/README.md CHANGED
@@ -2,42 +2,71 @@
2
2
 
3
3
  Read and write RFC 1035 DNS zone files, with the record data codec for every typed record.
4
4
 
5
+ A zone file (a BIND "master file") is how DNS operators export, import, review and version a
6
+ zone. `parse` reads the whole grammar and returns the records it read beside every entry it could
7
+ not use; `stringify` writes records back, so `parse(stringify(zone))` gives the same records.
8
+
5
9
  ## Installation
6
10
 
7
- ```bash
11
+ ```sh
8
12
  npm add @sdxc/zone-file
9
13
  ```
10
14
 
11
- `parse` returns a [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result) value, which
12
- installs alongside this package.
13
-
14
- A zone file (a BIND "master file") is how DNS operators export, import, review and version a
15
- zone. `parse` reads the whole RFC 1035 grammar — `$ORIGIN`, `$TTL`, `$INCLUDE`, parentheses,
16
- blank owners, escapes, TTL units, any class and any type — and returns the records it read beside
17
- every entry it could not use, each with its line and a reason. `stringify` writes records back, so
18
- `parse(stringify(zone))` gives the same records.
15
+ `parse` and `parseRecordData` return a `Result` from
16
+ [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result), which installs alongside this
17
+ package.
19
18
 
20
19
  ## Usage
21
20
 
22
- ### Read A Zone File
21
+ ### Read a zone file
23
22
 
24
23
  ```typescript
25
- import * as ZoneFile from "@sdxc/zone-file";
26
- import { isSuccess } from "@sdxc/result";
24
+ import { formatRecordData, parse } from "@sdxc/zone-file";
25
+ import { isFailure } from "@sdxc/result";
26
+
27
+ let text = `$TTL 1h
28
+ @ IN SOA ns1 hostmaster ( 1 2h 1h 1w 5m )
29
+ IN NS ns1 ; primary
30
+ www IN A 192.0.2.1
31
+ `;
27
32
 
28
- let parsed = ZoneFile.parse(text, { origin: "example.com" });
29
- if (isSuccess(parsed)) {
30
- for (let record of parsed.data.records) console.log(record.name, record.ttl, record.type);
31
- for (let rejection of parsed.data.rejected) console.warn(rejection.line, rejection.message);
33
+ let parsed = parse(text, { origin: "example.com" });
34
+ if (isFailure(parsed)) throw parsed.error; // only for input over `maxBytes`
35
+
36
+ for (let record of parsed.data.records) {
37
+ console.log(record.name, record.ttl, record.type, formatRecordData(record));
32
38
  }
39
+ // example.com 3600 SOA ns1.example.com. hostmaster.example.com. 1 7200 3600 604800 300
40
+ // example.com 3600 NS ns1.example.com.
41
+ // www.example.com 3600 A 192.0.2.1
33
42
  ```
34
43
 
35
- ### Write One
44
+ Every record comes back with an absolute, lowercased `name`, its `ttl`, `class`, `type`, the
45
+ typed fields of its type (`address`, `host`, `serial`…) and where it was written (`file`, `line`,
46
+ `endLine`, `comment`).
47
+
48
+ ### Check what it could not read
49
+
50
+ An entry that does not parse becomes a rejection, and the rest of the file still reads.
36
51
 
37
52
  ```typescript
38
- import * as ZoneFile from "@sdxc/zone-file";
53
+ import { parse } from "@sdxc/zone-file";
54
+ import { unwrap } from "@sdxc/result";
55
+
56
+ let zone = unwrap(parse("bad IN A 999.1.1.1\nok IN A 192.0.2.1\n", { origin: "example.com" }));
39
57
 
40
- let text = ZoneFile.stringify(
58
+ zone.records.length; // 1
59
+ zone.rejected;
60
+ // [{ file: null, line: 1, endLine: 1, input: "bad IN A 999.1.1.1",
61
+ // reason: "invalid-data", message: 'Invalid A record data: "999.1.1.1"' }]
62
+ ```
63
+
64
+ ### Write a zone file
65
+
66
+ ```typescript
67
+ import { stringify } from "@sdxc/zone-file";
68
+
69
+ let text = stringify(
41
70
  {
42
71
  origin: "example.com",
43
72
  ttl: 3600,
@@ -54,169 +83,236 @@ let text = ZoneFile.stringify(
54
83
  // www 300 IN CNAME @
55
84
  ```
56
85
 
57
- ### Read Record Data From Anywhere
86
+ ### Read and print record data on its own
58
87
 
59
88
  ```typescript
60
- import { parseRecordData } from "@sdxc/zone-file";
89
+ import { formatRecordData, parseRecordData } from "@sdxc/zone-file";
61
90
 
62
91
  parseRecordData("MX", "10 Mail.Example.com.");
63
92
  // success({ type: "MX", preference: 10, exchange: "mail.example.com" })
93
+
94
+ formatRecordData({ type: "MX", preference: 10, exchange: "mail.example.com" });
95
+ // "10 mail.example.com."
64
96
  ```
65
97
 
66
98
  ## API
67
99
 
68
- ### `parse(text, options): Result<ZoneFile.Zone, ZoneFileError>`
100
+ ### `parse(text: string, options: ParseOptions): Result<Zone, ZoneFileError>`
69
101
 
70
- Reads a zone file into `{ origin, records, rejected }`. The only failure is a `ZoneFileError`
71
- for input past `maxBytes`; any other problem — a bad line, an unknown directive, RDATA that does
72
- not fit its type — is a `Rejection` beside the records that did parse, so a caller wanting
73
- all-or-nothing checks `rejected.length`.
102
+ Reads a zone file into `{ origin, records, rejected }`. It fails only with a `ZoneFileError` for
103
+ input over `maxBytes`; every other problem is a `Rejection` beside the records that did parse,
104
+ and a caller that wants all-or-nothing checks `rejected.length`. Every record is returned —
105
+ outside the origin, repeated, any class, any type — and the caller decides which to keep.
74
106
 
75
- | Option | Default | Meaning |
76
- | --------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- |
77
- | `origin` | Required | The initial `$ORIGIN`; `@` and relative names resolve against it until a `$ORIGIN` line changes it |
78
- | `ttl` | `null` | The TTL a record gets when neither it, a `$TTL` nor an earlier record states one |
79
- | `include` | None | `(fileName, origin) => string \| null`, the text of an `$INCLUDE`d file; without it every `$INCLUDE` is rejected |
80
- | `relativeNames` | `"rfc1035"` | `"origin-suffix"` also reads a dotless name that equals the origin or ends in it as absolute |
81
- | `maxBytes` | 1 MiB | The largest input in UTF-8 bytes, counted across included files |
107
+ | Option | Default | Meaning |
108
+ | --------------- | ----------- | ------------------------------------------------------------------------------------------------------------------ |
109
+ | `origin` | Required | The initial `$ORIGIN`; `@` and relative names resolve against it |
110
+ | `ttl` | `null` | The TTL a record gets when neither it, a `$TTL` nor an earlier record states one |
111
+ | `include` | None | `(fileName, origin) => string \| null` returns an `$INCLUDE`d file's text; without it every `$INCLUDE` is rejected |
112
+ | `relativeNames` | `"rfc1035"` | `"origin-suffix"` also reads a dotless name that equals the origin or ends in it as absolute |
113
+ | `maxBytes` | 1 MiB | The largest input in UTF-8 bytes, counted across included files |
82
114
 
83
- The grammar:
115
+ The grammar it reads:
84
116
 
85
117
  - `;` starts a comment outside quotes; a record's trailing comment is kept as `comment`.
86
- - `"…"` quotes a field, with `\"`, `\\` and `\DDD` escapes; a `;` or parenthesis inside is data.
87
- - `(` … `)` joins lines into one entry; `line` is where it starts and `endLine` where it ends.
88
- - `@` is the current origin, and a name without a trailing dot gets the origin appended, in owners
89
- and in the name fields of typed RDATA.
90
- - Names come back absolute, lowercased, without the trailing dot, `\DDD` and `\.` escapes resolved
91
- to one canonical spelling; the root is `"."`.
118
+ - `"…"` quotes a field, with `\"`, `\\` and `\DDD` escapes.
119
+ - `(` … `)` joins lines into one entry, from `line` to `endLine`.
120
+ - `@` is the current origin, and a name without a trailing dot gets the origin appended, in
121
+ owners and in the name fields of typed data. Names come back absolute, lowercased, without the
122
+ trailing dot; the root is `"."`.
92
123
  - A line starting with whitespace takes the previous record's owner.
93
124
  - TTL and class are optional, in either order. A TTL is seconds or BIND units (`1h30m`, `2W`), up
94
- to 2³¹−1. A record without one takes `$TTL`, else the previous record's TTL, else `options.ttl`.
95
- - `$ORIGIN`, `$TTL` and `$INCLUDE file [origin]` are read. An included file starts with the given
96
- or current origin, and the origin, `$TTL` and owner revert after it; includes nest 8 deep.
97
- `$GENERATE` is rejected.
125
+ to 2³¹−1; a record without one takes `$TTL`, else the previous record's TTL, else
126
+ `options.ttl`.
127
+ - `$ORIGIN`, `$TTL` and `$INCLUDE file [origin]` are read. An included file starts at the given
128
+ or current origin, the origin, `$TTL` and owner revert after it, and includes nest 8 deep.
98
129
  - Classes are `IN`, `CH`, `HS`, `CS` and `CLASSnnn`; types are any mnemonic or `TYPEnnn`, and RFC
99
130
  3597 generic data (`\# 4 C0000201`) is read for every type.
100
131
 
101
- Records outside the origin, a record listed twice, classes other than `IN` and every type are all
102
- returned: which of them to keep is the caller's decision.
103
-
104
- ### `stringify(zone, options?): string`
132
+ ### `stringify(zone: ZoneInput, options?: StringifyOptions): string`
105
133
 
106
- Writes `{ origin?, ttl?, records }` (a parsed `Zone` is one) as one tab-separated line per record.
107
- `$ORIGIN` and `$TTL` lead when the input names them, and a record's TTL is left out when it equals
108
- `$TTL`. `relative: true` writes names under the origin relative to it and the apex as `@`;
109
- otherwise every name is absolute. An untyped record read under another origin gets an `$ORIGIN`
110
- line first, so relative names in its verbatim data keep their meaning. Comments are written back
111
- after `;`; spacing, standalone comments and parentheses are not kept.
134
+ Writes `{ origin?, ttl?, records }` (a parsed `Zone` is one) as one tab-separated line per record,
135
+ ending in a newline. `$ORIGIN` and `$TTL` lead when the input names them, and a record's TTL is
136
+ left out when it equals `$TTL`. `relative: true` writes names under the origin relative to it and
137
+ the apex as `@`; otherwise every name is absolute. Comments are written back after `;`; spacing,
138
+ standalone comments and parentheses are not.
112
139
 
113
- ### `parseRecordData(type, data): Result<ZoneFile.RecordData<Type>, RecordDataError>`
140
+ ### `parseRecordData<Type extends RecordType>(type: Type, data: string): Result<RecordData<Type>, RecordDataError>`
114
141
 
115
- Reads RDATA in presentation format into typed fields. Typed types: `A` and `AAAA` (`address`, IPv6
116
- in RFC 5952 form), `CNAME`, `PTR` and `DNAME` (`target`), `NS` (`host`), `MX` (`preference`,
117
- `exchange`), `TXT` (`text`, `strings`), `CAA` (`flags`, `critical`, `tag`, `value`), `SRV`
118
- (`priority`, `weight`, `port`, `target`) and `SOA` (`primary`, `mailbox`, `serial`, `refresh`,
119
- `retry`, `expire`, `minimum`, the timers accepting TTL units). Any other type returns
120
- `{ type, data }`. RFC 3597 generic data is decoded for the typed types. Names are not qualified:
121
- relative names stay relative.
142
+ Reads RDATA in presentation format, or RFC 3597 generic form, into the typed fields of `A`,
143
+ `AAAA`, `CNAME`, `NS`, `PTR`, `DNAME`, `MX`, `TXT`, `CAA`, `SRV` and `SOA` (SOA timers accept
144
+ TTL units). Any other type returns `{ type, data }` as written. Names come back lowercased
145
+ without the trailing dot, and a relative name stays relative.
122
146
 
123
147
  ```typescript
124
148
  parseRecordData("TXT", '"v=DKIM1; p=AAA" "BBB"');
125
149
  // success({ type: "TXT", text: "v=DKIM1; p=AAABBB", strings: ["v=DKIM1; p=AAA", "BBB"] })
126
150
  ```
127
151
 
128
- ### `formatRecordData(data): string`
152
+ ### `formatRecordData(data: RecordData<RecordType>): string`
129
153
 
130
154
  Prints record data in canonical presentation format, the inverse of `parseRecordData`: names
131
- absolute with the trailing dot, TXT strings and the CAA value quoted with every octet outside
132
- printable ASCII as `\DDD`, the CAA tag lowercased, untyped data as is. Two spellings of one record —
133
- `0 ISSUE "ca.example"` and its generic form — print to one string.
155
+ absolute with the trailing dot, TXT strings and the CAA value quoted with non-printable octets as
156
+ `\DDD`, untyped data as is. Two spellings of one record, such as `0 ISSUE "ca.example"` and its
157
+ generic form, print to one string.
134
158
 
135
- ```typescript
136
- formatRecordData({ type: "MX", preference: 10, exchange: "mx.example.com" }); // "10 mx.example.com."
137
- ```
159
+ ### `canonicalType(type: string): string`
160
+
161
+ The uppercase mnemonic for a type, resolving `TYPEnnn` when it has one: `canonicalType("type16")`
162
+ is `"TXT"`.
138
163
 
139
- ### `canonicalType(type): string` / `typeName(code): string`
164
+ ### `typeName(code: number): string`
140
165
 
141
- `canonicalType("txt")` is `"TXT"` and `canonicalType("TYPE16")` is `"TXT"`; `typeName(16)` is
142
- `"TXT"`, and a code without a mnemonic is `"TYPEnnn"`.
166
+ The mnemonic for a type code, or `TYPEnnn` without one: `typeName(16)` is `"TXT"`.
143
167
 
144
- ### Errors
168
+ ### `ZoneFileError`
145
169
 
146
- | Class | When |
147
- | ----------------- | -------------------------------------------------------------------------------------- |
148
- | `ZoneFileError` | `code: "too-large"`: the input, with its includes, passed `maxBytes`; `bytes` holds it |
149
- | `RecordDataError` | `parseRecordData` on data that does not fit the type |
170
+ The failure `parse` returns when the input, with its includes, passes `maxBytes`. `code` is
171
+ `"too-large"` and `bytes` holds the size read.
150
172
 
151
- ### Rejection reasons
173
+ ### `RecordDataError`
152
174
 
153
- | `reason` | When |
154
- | ----------------------- | ----------------------------------------------------------------------------------------- |
155
- | `malformed` | An unterminated quote, unbalanced parentheses, a bad owner or TTL, a missing type or data |
156
- | `invalid-data` | RDATA that does not fit its type; `message` holds the codec's error |
157
- | `missing-owner` | A blank owner with no earlier record to take it from |
158
- | `include` | `$INCLUDE` without the `include` option, a file it returned `null` for, or nesting past 8 |
159
- | `unsupported-directive` | `$GENERATE` or any other `$` word |
175
+ The failure `parseRecordData` returns for data that does not fit its type.
160
176
 
161
- Each rejection carries `file` (`null` for the text passed to `parse`), `line`, `endLine`, `input`
162
- (the entry as written) and `message`.
177
+ ### Types
163
178
 
164
- ### `ZoneFile` namespace
179
+ Every type is exported top-level and under the `ZoneFile` namespace (`ZoneFile.Zone`).
165
180
 
166
- Types only: `Zone`, `Record`, `RecordFor<Type>`, `RecordFields`, `UntypedRecord`, `Rejection`,
167
- `RejectionReason`, `ParseOptions`, `ZoneInput`, `RecordInput`, `StringifyOptions`, `RecordType`,
168
- `TypedRecordType`, `RecordClass`, `RecordData<Type>`, and one data interface per typed type
169
- (`AData` through `SOAData`) plus `UnknownData`.
181
+ - `Zone`: `{ origin, records, rejected }`, where `origin` is the origin at the end of the file.
182
+ - `Record`: any record read from a file; `RecordFor<Type>` narrows it to one type, and
183
+ `RecordFields` is what every record carries beside its data (`name`, `ttl`, `class`, `file`,
184
+ `line`, `endLine`, `comment`).
185
+ - `UntypedRecord`: a record of a type without typed fields, with its `data` as written and the
186
+ `origin` its relative names need.
187
+ - `Rejection`: `{ file, line, endLine, input, reason, message }` for an entry that did not become
188
+ a record. `RejectionReason` is `"malformed"`, `"invalid-data"`, `"missing-owner"`, `"include"`
189
+ (no `include` option, a file it returned `null` for, or nesting past 8) or
190
+ `"unsupported-directive"` (`$GENERATE` or any other `$` word).
191
+ - `RecordData<Type>`: the typed fields of a type: `AData`, `AAAAData`, `CNAMEData`, `NSData`,
192
+ `PTRData`, `DNAMEData`, `MXData`, `TXTData`, `CAAData`, `SRVData`, `SOAData`, or `UnknownData`.
193
+ - `RecordType`, `TypedRecordType`, `RecordClass`: type and class names.
194
+ - `ParseOptions`, `StringifyOptions`: the options above.
195
+ - `ZoneInput`, `RecordInput`, `RecordInputFields`: what `stringify` accepts; a record needs only
196
+ `name`, its type and fields, with optional `ttl`, `class` (default `"IN"`) and `comment`.
170
197
 
171
- ## Pattern: Import Only What You Track
198
+ ## Pattern: Import Only The Records You Track
172
199
 
173
- `parse` returns everything; the policy is a filter.
200
+ `parse` returns everything, so an import policy is a filter. Exports from DNS providers often drop
201
+ the trailing dot on fully-qualified names; `relativeNames: "origin-suffix"` reads those as
202
+ absolute.
174
203
 
175
204
  ```typescript
176
- import * as ZoneFile from "@sdxc/zone-file";
205
+ import { parse } from "@sdxc/zone-file";
177
206
  import { isFailure } from "@sdxc/result";
178
207
 
179
- let parsed = ZoneFile.parse(text, { origin: domain, relativeNames: "origin-suffix" });
180
- if (isFailure(parsed)) throw new Error(parsed.error.message);
181
-
182
- let tracked = parsed.data.records.filter(
183
- (record) =>
184
- record.class === "IN" &&
185
- ["A", "AAAA", "MX", "TXT"].includes(record.type) &&
186
- (record.name === domain || record.name.endsWith(`.${domain}`)),
187
- );
208
+ function importZone(text: string, domain: string) {
209
+ let parsed = parse(text, { origin: domain, relativeNames: "origin-suffix" });
210
+ if (isFailure(parsed)) return { records: [], errors: [parsed.error.message] };
211
+
212
+ let records = parsed.data.records.filter(
213
+ (record) =>
214
+ record.class === "IN" &&
215
+ ["A", "AAAA", "MX", "TXT"].includes(record.type) &&
216
+ (record.name === domain || record.name.endsWith(`.${domain}`)),
217
+ );
218
+ let errors = parsed.data.rejected.map((entry) => `Line ${entry.line}: ${entry.message}`);
219
+ return { records, errors };
220
+ }
188
221
  ```
189
222
 
190
223
  ## Pattern: Normalize A Zone File For Review
191
224
 
192
- Reading and printing gives every zone one spelling — absolute names, canonical addresses, quoted
225
+ Reading and printing gives every zone one spelling — lowercased names, canonical addresses, quoted
193
226
  TXT — so two exports diff only where their records differ.
194
227
 
195
228
  ```typescript
196
- import * as ZoneFile from "@sdxc/zone-file";
229
+ import { parse, stringify } from "@sdxc/zone-file";
197
230
  import { unwrap } from "@sdxc/result";
198
231
 
199
232
  function normalize(text: string, origin: string): string {
200
- let zone = unwrap(ZoneFile.parse(text, { origin }));
201
- let records = [...zone.records].sort((a, b) => a.name.localeCompare(b.name));
202
- return ZoneFile.stringify({ origin, records }, { relative: true });
233
+ let zone = unwrap(parse(text, { origin }));
234
+ let records = [...zone.records].sort(
235
+ (a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type),
236
+ );
237
+ return stringify({ origin, records }, { relative: true });
238
+ }
239
+ ```
240
+
241
+ ## Pattern: Refuse A File With Any Bad Line
242
+
243
+ For an all-or-nothing import, treat any rejection as a failure and report each with its line.
244
+
245
+ ```typescript
246
+ import type { Result } from "@sdxc/result";
247
+ import type { Zone } from "@sdxc/zone-file";
248
+
249
+ import { parse } from "@sdxc/zone-file";
250
+ import { failure, isFailure, success } from "@sdxc/result";
251
+
252
+ function parseStrict(text: string, origin: string): Result<Zone, Error> {
253
+ let parsed = parse(text, { origin, maxBytes: 256 * 1024 });
254
+ if (isFailure(parsed)) return parsed;
255
+
256
+ let { rejected } = parsed.data;
257
+ if (rejected.length === 0) return success(parsed.data);
258
+
259
+ let lines = rejected.map((entry) => `Line ${entry.line} (${entry.reason}): ${entry.input}`);
260
+ return failure(new Error(lines.join("\n")));
203
261
  }
204
262
  ```
205
263
 
206
264
  ## Pattern: Read A Zone With Includes
207
265
 
266
+ The `include` callback supplies each `$INCLUDE`d file; records from it carry its name as `file`.
267
+
208
268
  ```typescript
209
269
  import { readFileSync } from "node:fs";
210
270
  import { join } from "node:path";
211
271
 
212
- import * as ZoneFile from "@sdxc/zone-file";
272
+ import { parse } from "@sdxc/zone-file";
213
273
 
214
- let parsed = ZoneFile.parse(readFileSync("zones/example.com.zone", "utf8"), {
274
+ let parsed = parse(readFileSync("zones/example.com.zone", "utf8"), {
215
275
  origin: "example.com",
276
+ ttl: 3600,
216
277
  include: (fileName) => readFileSync(join("zones", fileName), "utf8"),
217
278
  });
218
279
  ```
219
280
 
281
+ ## Pattern: Compare A Zone File With Live DNS
282
+
283
+ A resolver answers with a type code and RDATA that may be in generic form. Reading both sides with
284
+ `parseRecordData` and printing with `formatRecordData` gives one key per record, whichever way it
285
+ was spelled.
286
+
287
+ ```typescript
288
+ import { formatRecordData, parse, parseRecordData, typeName } from "@sdxc/zone-file";
289
+ import { isSuccess, unwrap } from "@sdxc/result";
290
+
291
+ interface Answer {
292
+ name: string;
293
+ type: number;
294
+ data: string;
295
+ }
296
+
297
+ async function missingFromDns(text: string, origin: string, name: string, type: string) {
298
+ let url = new URL("https://cloudflare-dns.com/dns-query");
299
+ url.searchParams.set("name", name);
300
+ url.searchParams.set("type", type);
301
+ let response = await fetch(url, { headers: { Accept: "application/dns-json" } });
302
+ let body = (await response.json()) as { Answer?: Answer[] };
303
+
304
+ let live = new Set<string>();
305
+ for (let answer of body.Answer ?? []) {
306
+ let data = parseRecordData(typeName(answer.type), answer.data);
307
+ if (isSuccess(data)) live.add(formatRecordData(data.data));
308
+ }
309
+
310
+ return unwrap(parse(text, { origin }))
311
+ .records.filter((record) => record.name === name && record.type === type)
312
+ .filter((record) => !live.has(formatRecordData(record)));
313
+ }
314
+ ```
315
+
220
316
  ## Versioning
221
317
 
222
318
  Releases are dated rather than semantic. A version is the UTC date it was published,
package/dist/names.js CHANGED
@@ -161,7 +161,12 @@ export function relativeName(name, origin) {
161
161
  * @example absoluteName("mx.example.com") // "mx.example.com."
162
162
  */
163
163
  export function absoluteName(name) {
164
- let trailing = /(\\*)\.$/.exec(name);
165
- let absolute = name === "." || (trailing !== null && (trailing[1] ?? "").length % 2 === 0);
166
- return absolute ? name : `${name}.`;
164
+ if (name === ".")
165
+ return name;
166
+ if (!name.endsWith("."))
167
+ return `${name}.`;
168
+ let backslashes = 0;
169
+ for (let index = name.length - 2; index >= 0 && name[index] === "\\"; index--)
170
+ backslashes++;
171
+ return backslashes % 2 === 0 ? name : `${name}.`;
167
172
  }
@@ -79,7 +79,7 @@ function parsePresentation(type, value) {
79
79
  return success({ type, text: strings.data.join(""), strings: strings.data });
80
80
  }
81
81
  case "CAA": {
82
- let match = /^(\d+)\s+([A-Za-z0-9]+)\s+(.+)$/.exec(value);
82
+ let match = /^(\d+)\s+([A-Za-z0-9]+)\s+(\S.*)$/.exec(value);
83
83
  let flags = readNumber(match?.[1], 0xff);
84
84
  if (!match || flags === null || match[2] === undefined || match[3] === undefined)
85
85
  return invalid(type, value);
@@ -137,9 +137,12 @@ function parsePresentation(type, value) {
137
137
  return success({ type, data: value });
138
138
  }
139
139
  }
140
- /** Decodes RFC 3597 generic data (`\# <length> <hex>`) and reads the octets for the type. */
140
+ /**
141
+ * Decodes RFC 3597 generic data (`\# <length> <hex>`) and reads the octets for the type. The
142
+ * length is the whole leading digit run, so the hex after it starts at whitespace or a letter.
143
+ */
141
144
  function parseGeneric(type, value) {
142
- let match = /^\\#\s+(\d+)((?:\s*[0-9a-fA-F]+)*)\s*$/.exec(value);
145
+ let match = /^\\#\s+(\d+)((?:[\sa-fA-F][\s0-9a-fA-F]*)?)$/.exec(value);
143
146
  if (!match)
144
147
  return invalid(type, value);
145
148
  let hex = (match[2] ?? "").replace(/\s+/g, "");
package/dist/ttl.js CHANGED
@@ -24,8 +24,23 @@ export function looksLikeTtl(field) {
24
24
  export function readTtl(field) {
25
25
  if (!looksLikeTtl(field))
26
26
  return null;
27
- let seconds = /^\d+$/.test(field)
28
- ? Number(field)
29
- : Array.from(field.matchAll(/(\d+)([smhdw])/gi)).reduce((total, [, amount, unit]) => total + Number(amount) * (UNIT_SECONDS[(unit ?? "s").toLowerCase()] ?? 1), 0);
27
+ let seconds = /^\d+$/.test(field) ? Number(field) : sumUnits(field);
30
28
  return seconds <= MAX_TTL ? seconds : null;
31
29
  }
30
+ /**
31
+ * Adds up a unit-form TTL already checked by `looksLikeTtl`, in one pass over the field so its
32
+ * cost stays linear in the field's length.
33
+ */
34
+ function sumUnits(field) {
35
+ let total = 0;
36
+ let amount = "";
37
+ for (let char of field) {
38
+ if (char >= "0" && char <= "9") {
39
+ amount += char;
40
+ continue;
41
+ }
42
+ total += Number(amount) * (UNIT_SECONDS[char.toLowerCase()] ?? 1);
43
+ amount = "";
44
+ }
45
+ return total;
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/zone-file",
3
- "version": "2026.10.9",
3
+ "version": "2026.10.13",
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
6
  "type": "module",
@@ -8,9 +8,9 @@
8
8
  ".": "./dist/index.js"
9
9
  },
10
10
  "dependencies": {
11
- "@sdxc/result": "2026.10.6"
11
+ "@sdxc/result": "2026.10.13"
12
12
  },
13
- "gitHead": "64fa732ed0a8ca8693a35aeef6345ae5be2534ef",
13
+ "gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
14
14
  "publishConfig": {
15
15
  "access": "public"
16
16
  },