@sdxc/zone-file 0.0.0-pre.1

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/parse.js ADDED
@@ -0,0 +1,282 @@
1
+ import { failure, isFailure, success } from "@sdxc/result";
2
+ import { ZoneFileError } from "./errors.js";
3
+ import { lex } from "./lexer.js";
4
+ import { absoluteName, canonicalName, qualifyName } from "./names.js";
5
+ import { parseRecordData } from "./parse-record-data.js";
6
+ import { isTypedType } from "./record-types.js";
7
+ import { looksLikeTtl, readTtl } from "./ttl.js";
8
+ /** Measures input in the UTF-8 bytes `maxBytes` counts. */
9
+ const ENCODER = new TextEncoder();
10
+ /** @default 1 MiB, larger than any zone a DNS provider accepts for import. */
11
+ const DEFAULT_MAX_BYTES = 1024 * 1024;
12
+ /** How deep `$INCLUDE` may nest; the text passed to `parse` is depth 0. */
13
+ const MAX_INCLUDE_DEPTH = 8;
14
+ /** The RDATA fields of each typed type that hold a name, which a relative spelling qualifies. */
15
+ const NAME_FIELDS = {
16
+ CNAME: [0],
17
+ NS: [0],
18
+ PTR: [0],
19
+ DNAME: [0],
20
+ MX: [1],
21
+ SRV: [3],
22
+ SOA: [0, 1],
23
+ };
24
+ /** The class numbers with a mnemonic, so `CLASS1` and `IN` read as one class. */
25
+ const CLASS_NAMES = { "1": "IN", "2": "CS", "3": "CH", "4": "HS" };
26
+ /**
27
+ * Reads a zone file. The only failure is `too-large`; every unreadable entry is a
28
+ * `Rejection` beside the records that did parse, so one bad line never costs the rest, and
29
+ * a caller that wants all-or-nothing checks `rejected.length`.
30
+ *
31
+ * Records outside the origin, repeats, non-`IN` classes and every record type are all
32
+ * returned: which of them to keep is the caller's decision.
33
+ *
34
+ * @param text - The zone file's contents.
35
+ * @param options - The origin, and how to read TTLs, includes and relative names.
36
+ * @returns The records and rejections, or the size that passed `maxBytes`.
37
+ * @example parse("www 300 IN A 192.0.2.1", { origin: "example.com" }) // records: [{ name: "www.example.com", ttl: 300, … }]
38
+ */
39
+ export function parse(text, options) {
40
+ let reader = new ZoneReader(options);
41
+ let state = {
42
+ origin: canonicalName(options.origin) ?? options.origin.toLowerCase(),
43
+ defaultTtl: null,
44
+ lastTtl: null,
45
+ lastOwner: null,
46
+ lastClass: "IN",
47
+ };
48
+ let read = reader.read(text, null, state, 0);
49
+ if (isFailure(read))
50
+ return read;
51
+ return success({ origin: state.origin, records: reader.records, rejected: reader.rejected });
52
+ }
53
+ /** Reads one file's entries into shared record and rejection lists, recursing into includes. */
54
+ class ZoneReader {
55
+ records = [];
56
+ rejected = [];
57
+ #options;
58
+ #maxBytes;
59
+ #mode;
60
+ #bytes = 0;
61
+ /** @param options - The options `parse` was called with. */
62
+ constructor(options) {
63
+ this.#options = options;
64
+ this.#maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
65
+ this.#mode = options.relativeNames ?? "rfc1035";
66
+ }
67
+ /**
68
+ * Reads one file, mutating `state` as its directives and records do; an included file
69
+ * reads with a copy, so the origin, `$TTL` and owner revert after it.
70
+ *
71
+ * @param text - The file's contents.
72
+ * @param file - The included file's name; `null` for the text passed to `parse`.
73
+ * @param state - The state the file starts with.
74
+ * @param depth - How many `$INCLUDE`s led here.
75
+ */
76
+ read(text, file, state, depth) {
77
+ this.#bytes += ENCODER.encode(text).byteLength;
78
+ if (this.#bytes > this.#maxBytes)
79
+ return failure(new ZoneFileError(this.#bytes, this.#maxBytes));
80
+ for (let entry of lex(text)) {
81
+ let reject = (reason, message) => {
82
+ this.rejected.push({
83
+ file,
84
+ line: entry.line,
85
+ endLine: entry.endLine,
86
+ input: entry.input,
87
+ reason,
88
+ message,
89
+ });
90
+ };
91
+ if (entry.error !== null) {
92
+ reject("malformed", entry.error);
93
+ continue;
94
+ }
95
+ let first = entry.tokens[0];
96
+ if (!entry.blankOwner && first && !first.quoted && first.text.startsWith("$")) {
97
+ let included = this.#directive(entry, file, state, depth, reject);
98
+ if (isFailure(included))
99
+ return included;
100
+ continue;
101
+ }
102
+ this.#record(entry, file, state, reject);
103
+ }
104
+ return success(undefined);
105
+ }
106
+ /** Applies one `$` directive to `state`, reading an included file through the option. */
107
+ #directive(entry, file, state, depth, reject) {
108
+ let [directive, ...args] = entry.tokens;
109
+ let word = (directive?.text ?? "").toUpperCase();
110
+ switch (word) {
111
+ case "$ORIGIN": {
112
+ let origin = args.length === 1 ? this.#name(args[0], state.origin) : null;
113
+ if (origin === null)
114
+ reject("malformed", "$ORIGIN needs one domain name");
115
+ else
116
+ state.origin = origin;
117
+ break;
118
+ }
119
+ case "$TTL": {
120
+ let ttl = args.length === 1 && args[0] && !args[0].quoted ? readTtl(args[0].text) : null;
121
+ if (ttl === null)
122
+ reject("malformed", "$TTL needs one TTL of at most 2147483647 seconds");
123
+ else
124
+ state.defaultTtl = ttl;
125
+ break;
126
+ }
127
+ case "$INCLUDE": {
128
+ let [fileToken, originToken] = args;
129
+ let fileName = fileToken ? unquote(fileToken) : "";
130
+ let origin = originToken ? this.#name(originToken, state.origin) : state.origin;
131
+ if (args.length < 1 || args.length > 2 || fileName === "" || origin === null) {
132
+ reject("malformed", "$INCLUDE needs a file name and an optional origin");
133
+ break;
134
+ }
135
+ if (!this.#options.include) {
136
+ reject("include", "$INCLUDE needs the include option to read a file");
137
+ break;
138
+ }
139
+ if (depth >= MAX_INCLUDE_DEPTH) {
140
+ reject("include", `$INCLUDE nests deeper than ${MAX_INCLUDE_DEPTH} files`);
141
+ break;
142
+ }
143
+ let included = this.#options.include(fileName, origin);
144
+ if (included === null) {
145
+ reject("include", `The include option returned no text for ${JSON.stringify(fileName)}`);
146
+ break;
147
+ }
148
+ let read = this.read(included, fileName, { ...state, origin }, depth + 1);
149
+ if (isFailure(read))
150
+ return read;
151
+ break;
152
+ }
153
+ default:
154
+ reject("unsupported-directive", `${directive?.text ?? word} is not a supported directive`);
155
+ }
156
+ return success(undefined);
157
+ }
158
+ /** Reads one record entry, or rejects it with the reason it cannot be one. */
159
+ #record(entry, file, state, reject) {
160
+ let tokens = entry.tokens;
161
+ let cursor = 0;
162
+ let owner;
163
+ if (entry.blankOwner) {
164
+ owner = state.lastOwner;
165
+ if (owner === null) {
166
+ reject("missing-owner", "A blank owner needs an earlier record to take it from");
167
+ return;
168
+ }
169
+ }
170
+ else {
171
+ let ownerToken = tokens[0];
172
+ cursor = 1;
173
+ owner = ownerToken ? this.#name(ownerToken, state.origin) : null;
174
+ if (owner === null) {
175
+ reject("malformed", `Invalid owner name ${JSON.stringify(ownerToken?.text ?? "")}`);
176
+ return;
177
+ }
178
+ state.lastOwner = owner;
179
+ }
180
+ let ttl = null;
181
+ let recordClass = null;
182
+ let ttlRead = false;
183
+ while (cursor < tokens.length) {
184
+ let token = tokens[cursor];
185
+ if (!token || token.quoted)
186
+ break;
187
+ if (!ttlRead && looksLikeTtl(token.text)) {
188
+ ttl = readTtl(token.text);
189
+ if (ttl === null) {
190
+ reject("malformed", `TTL ${token.text} is over 2147483647 seconds`);
191
+ return;
192
+ }
193
+ ttlRead = true;
194
+ cursor += 1;
195
+ continue;
196
+ }
197
+ let classMatch = /^(IN|CH|HS|CS|CLASS(\d+))$/i.exec(token.text);
198
+ if (recordClass === null && classMatch) {
199
+ let number = classMatch[2];
200
+ recordClass =
201
+ number === undefined
202
+ ? token.text.toUpperCase()
203
+ : (CLASS_NAMES[String(Number(number))] ?? `CLASS${Number(number)}`);
204
+ cursor += 1;
205
+ continue;
206
+ }
207
+ break;
208
+ }
209
+ let typeToken = tokens[cursor];
210
+ if (!typeToken || typeToken.quoted || !/^[A-Za-z][A-Za-z0-9-]*$/.test(typeToken.text)) {
211
+ let found = typeToken ? `found ${JSON.stringify(typeToken.text)}` : "found nothing";
212
+ reject("malformed", `Expected a record type, ${found}`);
213
+ return;
214
+ }
215
+ let rdata = tokens.slice(cursor + 1);
216
+ if (rdata.length === 0) {
217
+ reject("malformed", `${typeToken.text.toUpperCase()} record has no data`);
218
+ return;
219
+ }
220
+ let data = this.#data(typeToken.text, rdata, state.origin);
221
+ if (typeof data === "string") {
222
+ reject("invalid-data", data);
223
+ return;
224
+ }
225
+ if (ttl !== null)
226
+ state.lastTtl = ttl;
227
+ let resolvedTtl = ttl ?? state.defaultTtl ?? state.lastTtl ?? this.#options.ttl ?? null;
228
+ if (resolvedTtl !== null)
229
+ state.lastTtl = resolvedTtl;
230
+ if (recordClass !== null)
231
+ state.lastClass = recordClass;
232
+ let fields = {
233
+ name: owner,
234
+ ttl: resolvedTtl,
235
+ class: recordClass ?? state.lastClass,
236
+ file,
237
+ line: entry.line,
238
+ endLine: entry.endLine,
239
+ comment: entry.comment,
240
+ };
241
+ this.records.push((isTypedType(data.type)
242
+ ? { ...fields, ...data }
243
+ : { ...fields, ...data, origin: state.origin }));
244
+ }
245
+ /**
246
+ * Reads a record's RDATA for its type: name fields qualified against the origin first,
247
+ * since `parseRecordData` has no origin to qualify them with. RFC 3597 generic data is
248
+ * read as is, its names being absolute on the wire.
249
+ *
250
+ * @returns The data, or the message for an `invalid-data` rejection.
251
+ */
252
+ #data(type, rdata, origin) {
253
+ let tokens = rdata.map((token) => token.text);
254
+ let generic = rdata[0]?.text === "\\#" && !rdata[0].quoted;
255
+ let upper = type.toUpperCase();
256
+ if (!generic) {
257
+ for (let index of NAME_FIELDS[upper] ?? []) {
258
+ let token = rdata[index];
259
+ if (!token)
260
+ continue;
261
+ let name = this.#name(token, origin);
262
+ if (name === null)
263
+ return `Invalid name ${JSON.stringify(token.text)} in ${upper} data`;
264
+ tokens[index] = absoluteName(name);
265
+ }
266
+ }
267
+ let parsed = parseRecordData(type, tokens.join(" "));
268
+ return isFailure(parsed) ? parsed.error.message : parsed.data;
269
+ }
270
+ /** Qualifies a name field, refusing a quoted one: quotes mark a character-string. */
271
+ #name(token, origin) {
272
+ if (!token || token.quoted)
273
+ return null;
274
+ return qualifyName(token.text, origin, this.#mode);
275
+ }
276
+ }
277
+ /** The text of a quoted field with `\X` escapes resolved, or a bare field as written. */
278
+ function unquote(token) {
279
+ if (!token.quoted)
280
+ return token.text;
281
+ return token.text.slice(1, -1).replace(/\\(.)/g, "$1");
282
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Maps record type mnemonics to their IANA codes and back, with RFC 3597's `TYPEnnn`
3
+ * spelling for any code without a mnemonic here, and names the types read into fields.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ import type * as ZoneFile from "./types.js";
9
+ /** The mnemonic for a type code, or `TYPEnnn` when it has none here. */
10
+ export declare function typeName(code: number): string;
11
+ /**
12
+ * The spelling of a type as a record's `type`: the uppercase mnemonic, and a known
13
+ * mnemonic for `TYPEnnn` when one exists.
14
+ */
15
+ export declare function canonicalType(type: string): string;
16
+ /** Whether a canonical type has typed fields, so its data carries no verbatim `data`. */
17
+ export declare function isTypedType(type: string): type is ZoneFile.TypedRecordType;
@@ -0,0 +1,65 @@
1
+ /** The IANA codes of the record types a caller is likely to ask for or see in an answer. */
2
+ const TYPE_CODES = {
3
+ A: 1,
4
+ NS: 2,
5
+ CNAME: 5,
6
+ SOA: 6,
7
+ PTR: 12,
8
+ HINFO: 13,
9
+ MX: 15,
10
+ TXT: 16,
11
+ AAAA: 28,
12
+ LOC: 29,
13
+ SRV: 33,
14
+ NAPTR: 35,
15
+ DNAME: 39,
16
+ DS: 43,
17
+ SSHFP: 44,
18
+ RRSIG: 46,
19
+ NSEC: 47,
20
+ DNSKEY: 48,
21
+ NSEC3: 50,
22
+ NSEC3PARAM: 51,
23
+ TLSA: 52,
24
+ SMIMEA: 53,
25
+ CDS: 59,
26
+ CDNSKEY: 60,
27
+ OPENPGPKEY: 61,
28
+ SVCB: 64,
29
+ HTTPS: 65,
30
+ URI: 256,
31
+ CAA: 257,
32
+ };
33
+ /** The types `parseRecordData` reads into typed fields. */
34
+ const TYPED_TYPES = new Set([
35
+ "A",
36
+ "AAAA",
37
+ "CNAME",
38
+ "NS",
39
+ "PTR",
40
+ "DNAME",
41
+ "MX",
42
+ "TXT",
43
+ "CAA",
44
+ "SRV",
45
+ "SOA",
46
+ ]);
47
+ /** The mnemonic for each code in {@link TYPE_CODES}. */
48
+ const TYPE_NAMES = new Map(Object.entries(TYPE_CODES).map(([name, code]) => [code, name]));
49
+ /** The mnemonic for a type code, or `TYPEnnn` when it has none here. */
50
+ export function typeName(code) {
51
+ return TYPE_NAMES.get(code) ?? `TYPE${code}`;
52
+ }
53
+ /**
54
+ * The spelling of a type as a record's `type`: the uppercase mnemonic, and a known
55
+ * mnemonic for `TYPEnnn` when one exists.
56
+ */
57
+ export function canonicalType(type) {
58
+ let upper = type.trim().toUpperCase();
59
+ let generic = /^TYPE(\d+)$/.exec(upper);
60
+ return generic ? typeName(Number(generic[1])) : upper;
61
+ }
62
+ /** Whether a canonical type has typed fields, so its data carries no verbatim `data`. */
63
+ export function isTypedType(type) {
64
+ return TYPED_TYPES.has(type);
65
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Writes records as an RFC 1035 master file, one tab-separated line per record, with names
3
+ * absolute by default or relative to the origin, so a zone built in code or read with
4
+ * `parse` prints to text any BIND-compatible tool reads back to the same records.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type * as ZoneFile from "./types.js";
10
+ /**
11
+ * Writes a zone. `$ORIGIN` and `$TTL` lead when the input names them, a record's TTL is
12
+ * left out when it equals `$TTL`, and an untyped record read under another origin gets an
13
+ * `$ORIGIN` line first so the relative names in its verbatim data keep their meaning.
14
+ *
15
+ * For any `zone` that `parse` produced, `parse(stringify(zone))` yields the same records —
16
+ * name, TTL, class, type, fields and comment; spacing, standalone comments and
17
+ * parentheses are not kept.
18
+ *
19
+ * @param zone - The origin, default TTL and records to write.
20
+ * @param options - `relative: true` writes names under the origin relative to it.
21
+ * @returns The zone file, ending in a newline.
22
+ * @example stringify({ origin: "example.com", records: [{ name: "www.example.com", ttl: 300, type: "A", address: "192.0.2.1" }] }, { relative: true }) // "$ORIGIN example.com.\nwww\t300\tIN\tA\t192.0.2.1\n"
23
+ */
24
+ export declare function stringify(zone: ZoneFile.ZoneInput, options?: ZoneFile.StringifyOptions): string;
@@ -0,0 +1,52 @@
1
+ import { printRecordData } from "./format-record-data.js";
2
+ import { absoluteName, canonicalName, relativeName } from "./names.js";
3
+ import { isTypedType } from "./record-types.js";
4
+ /**
5
+ * Writes a zone. `$ORIGIN` and `$TTL` lead when the input names them, a record's TTL is
6
+ * left out when it equals `$TTL`, and an untyped record read under another origin gets an
7
+ * `$ORIGIN` line first so the relative names in its verbatim data keep their meaning.
8
+ *
9
+ * For any `zone` that `parse` produced, `parse(stringify(zone))` yields the same records —
10
+ * name, TTL, class, type, fields and comment; spacing, standalone comments and
11
+ * parentheses are not kept.
12
+ *
13
+ * @param zone - The origin, default TTL and records to write.
14
+ * @param options - `relative: true` writes names under the origin relative to it.
15
+ * @returns The zone file, ending in a newline.
16
+ * @example stringify({ origin: "example.com", records: [{ name: "www.example.com", ttl: 300, type: "A", address: "192.0.2.1" }] }, { relative: true }) // "$ORIGIN example.com.\nwww\t300\tIN\tA\t192.0.2.1\n"
17
+ */
18
+ export function stringify(zone, options = {}) {
19
+ let lines = [];
20
+ let origin = zone.origin === undefined ? null : canonical(zone.origin);
21
+ let ttl = zone.ttl ?? null;
22
+ if (origin !== null)
23
+ lines.push(`$ORIGIN ${absoluteName(origin)}`);
24
+ if (ttl !== null)
25
+ lines.push(`$TTL ${ttl}`);
26
+ for (let record of zone.records) {
27
+ if (!isTypedType(record.type) && "origin" in record && record.origin !== undefined) {
28
+ let recordOrigin = canonical(record.origin);
29
+ if (recordOrigin !== origin) {
30
+ origin = recordOrigin;
31
+ lines.push(`$ORIGIN ${absoluteName(origin)}`);
32
+ }
33
+ }
34
+ let current = origin;
35
+ let printName = (name) => options.relative && current !== null
36
+ ? relativeName(canonical(name), current)
37
+ : absoluteName(canonical(name));
38
+ let fields = [printName(record.name)];
39
+ if (record.ttl != null && record.ttl !== ttl)
40
+ fields.push(String(record.ttl));
41
+ fields.push(record.class ?? "IN", record.type, printRecordData(record, printName));
42
+ let line = fields.join("\t");
43
+ if (record.comment)
44
+ line += ` ; ${record.comment.replace(/[\r\n]+/g, " ")}`;
45
+ lines.push(line);
46
+ }
47
+ return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
48
+ }
49
+ /** A name in canonical form, or as given when it is not a valid name. */
50
+ function canonical(name) {
51
+ return canonicalName(name) ?? name;
52
+ }
package/dist/ttl.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Reads a TTL field as seconds: a plain number, or BIND's unit form (`1h30m`, `2W`), which
3
+ * every BIND-compatible tool accepts in records and in `$TTL`, capped at RFC 2181's 2³¹−1.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ /**
9
+ * Whether a field is spelled like a TTL, so the record reader takes it as one even when its
10
+ * value is out of range, and reports that rather than reading it as a record type.
11
+ */
12
+ export declare function looksLikeTtl(field: string): boolean;
13
+ /**
14
+ * The seconds a TTL field stands for, or `null` when it is not one or passes 2³¹−1.
15
+ *
16
+ * @example readTtl("1h30m") // 5400
17
+ */
18
+ export declare function readTtl(field: string): number | null;
package/dist/ttl.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Reads a TTL field as seconds: a plain number, or BIND's unit form (`1h30m`, `2W`), which
3
+ * every BIND-compatible tool accepts in records and in `$TTL`, capped at RFC 2181's 2³¹−1.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ /** The largest TTL RFC 2181 section 8 allows. */
9
+ const MAX_TTL = 2 ** 31 - 1;
10
+ /** Seconds per BIND TTL unit. */
11
+ const UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86_400, w: 604_800 };
12
+ /**
13
+ * Whether a field is spelled like a TTL, so the record reader takes it as one even when its
14
+ * value is out of range, and reports that rather than reading it as a record type.
15
+ */
16
+ export function looksLikeTtl(field) {
17
+ return /^\d+$/.test(field) || /^(\d+[smhdw])+$/i.test(field);
18
+ }
19
+ /**
20
+ * The seconds a TTL field stands for, or `null` when it is not one or passes 2³¹−1.
21
+ *
22
+ * @example readTtl("1h30m") // 5400
23
+ */
24
+ export function readTtl(field) {
25
+ if (!looksLikeTtl(field))
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);
30
+ return seconds <= MAX_TTL ? seconds : null;
31
+ }