@sdxc/zone-file 0.0.0-stage → 2026.10.9
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/LICENSE.md +21 -0
- package/README.md +247 -2
- package/dist/character-strings.d.ts +22 -0
- package/dist/character-strings.js +72 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +28 -0
- package/dist/format-record-data.d.ts +32 -0
- package/dist/format-record-data.js +84 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +14 -0
- package/dist/lexer.d.ts +37 -0
- package/dist/lexer.js +128 -0
- package/dist/names.d.ts +49 -0
- package/dist/names.js +167 -0
- package/dist/parse-record-data.d.ts +23 -0
- package/dist/parse-record-data.js +288 -0
- package/dist/parse.d.ts +25 -0
- package/dist/parse.js +282 -0
- package/dist/record-types.d.ts +17 -0
- package/dist/record-types.js +65 -0
- package/dist/stringify.d.ts +24 -0
- package/dist/stringify.js +52 -0
- package/dist/ttl.d.ts +18 -0
- package/dist/ttl.js +31 -0
- package/dist/types.d.ts +218 -0
- package/dist/types.js +8 -0
- package/package.json +30 -5
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergio Xalambrí
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,248 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sdxc/zone-file
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Read and write RFC 1035 DNS zone files, with the record data codec for every typed record.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm add @sdxc/zone-file
|
|
9
|
+
```
|
|
10
|
+
|
|
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.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
### Read A Zone File
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import * as ZoneFile from "@sdxc/zone-file";
|
|
26
|
+
import { isSuccess } from "@sdxc/result";
|
|
27
|
+
|
|
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);
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Write One
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
import * as ZoneFile from "@sdxc/zone-file";
|
|
39
|
+
|
|
40
|
+
let text = ZoneFile.stringify(
|
|
41
|
+
{
|
|
42
|
+
origin: "example.com",
|
|
43
|
+
ttl: 3600,
|
|
44
|
+
records: [
|
|
45
|
+
{ name: "example.com", type: "MX", preference: 10, exchange: "mail.example.com" },
|
|
46
|
+
{ name: "www.example.com", ttl: 300, type: "CNAME", target: "example.com" },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
{ relative: true },
|
|
50
|
+
);
|
|
51
|
+
// $ORIGIN example.com.
|
|
52
|
+
// $TTL 3600
|
|
53
|
+
// @ IN MX 10 mail
|
|
54
|
+
// www 300 IN CNAME @
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Read Record Data From Anywhere
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import { parseRecordData } from "@sdxc/zone-file";
|
|
61
|
+
|
|
62
|
+
parseRecordData("MX", "10 Mail.Example.com.");
|
|
63
|
+
// success({ type: "MX", preference: 10, exchange: "mail.example.com" })
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## API
|
|
67
|
+
|
|
68
|
+
### `parse(text, options): Result<ZoneFile.Zone, ZoneFileError>`
|
|
69
|
+
|
|
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`.
|
|
74
|
+
|
|
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 |
|
|
82
|
+
|
|
83
|
+
The grammar:
|
|
84
|
+
|
|
85
|
+
- `;` 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 `"."`.
|
|
92
|
+
- A line starting with whitespace takes the previous record's owner.
|
|
93
|
+
- 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.
|
|
98
|
+
- Classes are `IN`, `CH`, `HS`, `CS` and `CLASSnnn`; types are any mnemonic or `TYPEnnn`, and RFC
|
|
99
|
+
3597 generic data (`\# 4 C0000201`) is read for every type.
|
|
100
|
+
|
|
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`
|
|
105
|
+
|
|
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.
|
|
112
|
+
|
|
113
|
+
### `parseRecordData(type, data): Result<ZoneFile.RecordData<Type>, RecordDataError>`
|
|
114
|
+
|
|
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.
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
parseRecordData("TXT", '"v=DKIM1; p=AAA" "BBB"');
|
|
125
|
+
// success({ type: "TXT", text: "v=DKIM1; p=AAABBB", strings: ["v=DKIM1; p=AAA", "BBB"] })
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### `formatRecordData(data): string`
|
|
129
|
+
|
|
130
|
+
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.
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
formatRecordData({ type: "MX", preference: 10, exchange: "mx.example.com" }); // "10 mx.example.com."
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### `canonicalType(type): string` / `typeName(code): string`
|
|
140
|
+
|
|
141
|
+
`canonicalType("txt")` is `"TXT"` and `canonicalType("TYPE16")` is `"TXT"`; `typeName(16)` is
|
|
142
|
+
`"TXT"`, and a code without a mnemonic is `"TYPEnnn"`.
|
|
143
|
+
|
|
144
|
+
### Errors
|
|
145
|
+
|
|
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 |
|
|
150
|
+
|
|
151
|
+
### Rejection reasons
|
|
152
|
+
|
|
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 |
|
|
160
|
+
|
|
161
|
+
Each rejection carries `file` (`null` for the text passed to `parse`), `line`, `endLine`, `input`
|
|
162
|
+
(the entry as written) and `message`.
|
|
163
|
+
|
|
164
|
+
### `ZoneFile` namespace
|
|
165
|
+
|
|
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`.
|
|
170
|
+
|
|
171
|
+
## Pattern: Import Only What You Track
|
|
172
|
+
|
|
173
|
+
`parse` returns everything; the policy is a filter.
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
import * as ZoneFile from "@sdxc/zone-file";
|
|
177
|
+
import { isFailure } from "@sdxc/result";
|
|
178
|
+
|
|
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
|
+
);
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Pattern: Normalize A Zone File For Review
|
|
191
|
+
|
|
192
|
+
Reading and printing gives every zone one spelling — absolute names, canonical addresses, quoted
|
|
193
|
+
TXT — so two exports diff only where their records differ.
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
import * as ZoneFile from "@sdxc/zone-file";
|
|
197
|
+
import { unwrap } from "@sdxc/result";
|
|
198
|
+
|
|
199
|
+
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 });
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Pattern: Read A Zone With Includes
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
import { readFileSync } from "node:fs";
|
|
210
|
+
import { join } from "node:path";
|
|
211
|
+
|
|
212
|
+
import * as ZoneFile from "@sdxc/zone-file";
|
|
213
|
+
|
|
214
|
+
let parsed = ZoneFile.parse(readFileSync("zones/example.com.zone", "utf8"), {
|
|
215
|
+
origin: "example.com",
|
|
216
|
+
include: (fileName) => readFileSync(join("zones", fileName), "utf8"),
|
|
217
|
+
});
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Versioning
|
|
221
|
+
|
|
222
|
+
Releases are dated rather than semantic. A version is the UTC date it was published,
|
|
223
|
+
written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one
|
|
224
|
+
release goes out per day.
|
|
225
|
+
|
|
226
|
+
Those numbers say when, not what: a later date means a later release and carries no
|
|
227
|
+
compatibility promise. Any release may change or remove an export.
|
|
228
|
+
|
|
229
|
+
Depend on one exact date, and move it when you are ready to take the change:
|
|
230
|
+
|
|
231
|
+
```json
|
|
232
|
+
{
|
|
233
|
+
"dependencies": {
|
|
234
|
+
"@sdxc/zone-file": "2026.9.4"
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
A caret or tilde range reads the date as major, minor and patch, so it accepts every
|
|
240
|
+
later release in the same year. An exact version keeps the upgrade yours to schedule.
|
|
241
|
+
|
|
242
|
+
## License
|
|
243
|
+
|
|
244
|
+
MIT
|
|
245
|
+
|
|
246
|
+
## Author
|
|
247
|
+
|
|
248
|
+
[Sergio Xalambrí](https://sergiodxa.com)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the character-strings of TXT RDATA in presentation format (RFC 1035 section 5.1):
|
|
3
|
+
* quoted strings keep their spaces, bare words split on whitespace, and `\"`, `\\` and
|
|
4
|
+
* `\DDD` escapes are decoded, so a resolver's quoting never leaks into the text.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import { RecordDataError } from "./errors.js";
|
|
11
|
+
/**
|
|
12
|
+
* Splits TXT presentation data into its character-strings. Escapes are decoded to octets
|
|
13
|
+
* and each string is read as UTF-8, so text a resolver writes as `\195\169` comes back as
|
|
14
|
+
* `é`. Fails on an unterminated quote or a decimal escape above 255.
|
|
15
|
+
*
|
|
16
|
+
* @param data - One or more quoted or bare character-strings.
|
|
17
|
+
* @returns The strings in order, empty strings included.
|
|
18
|
+
* @example readCharacterStrings('"v=DKIM1; p=AAA" "BBB"') // success(["v=DKIM1; p=AAA", "BBB"])
|
|
19
|
+
*/
|
|
20
|
+
export declare function readCharacterStrings(data: string): Result<string[], RecordDataError>;
|
|
21
|
+
/** Decodes octets as UTF-8, the reading applied to every character-string. */
|
|
22
|
+
export declare function decodeOctets(octets: Uint8Array): string;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { failure, success } from "@sdxc/result";
|
|
2
|
+
import { RecordDataError } from "./errors.js";
|
|
3
|
+
/** Decodes a character-string's octets as UTF-8, replacing invalid sequences with U+FFFD. */
|
|
4
|
+
const DECODER = new TextDecoder();
|
|
5
|
+
/** Encodes literal characters into the octets they stand for. */
|
|
6
|
+
const ENCODER = new TextEncoder();
|
|
7
|
+
/** Whether a character separates character-strings. */
|
|
8
|
+
function isSpace(char) {
|
|
9
|
+
return char === " " || char === "\t" || char === "\n" || char === "\r";
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Splits TXT presentation data into its character-strings. Escapes are decoded to octets
|
|
13
|
+
* and each string is read as UTF-8, so text a resolver writes as `\195\169` comes back as
|
|
14
|
+
* `é`. Fails on an unterminated quote or a decimal escape above 255.
|
|
15
|
+
*
|
|
16
|
+
* @param data - One or more quoted or bare character-strings.
|
|
17
|
+
* @returns The strings in order, empty strings included.
|
|
18
|
+
* @example readCharacterStrings('"v=DKIM1; p=AAA" "BBB"') // success(["v=DKIM1; p=AAA", "BBB"])
|
|
19
|
+
*/
|
|
20
|
+
export function readCharacterStrings(data) {
|
|
21
|
+
let chars = Array.from(data);
|
|
22
|
+
let strings = [];
|
|
23
|
+
let index = 0;
|
|
24
|
+
while (index < chars.length) {
|
|
25
|
+
if (isSpace(chars[index])) {
|
|
26
|
+
index += 1;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
let quoted = chars[index] === '"';
|
|
30
|
+
if (quoted)
|
|
31
|
+
index += 1;
|
|
32
|
+
let octets = [];
|
|
33
|
+
let terminated = !quoted;
|
|
34
|
+
while (index < chars.length) {
|
|
35
|
+
let char = chars[index] ?? "";
|
|
36
|
+
if (quoted && char === '"') {
|
|
37
|
+
terminated = true;
|
|
38
|
+
index += 1;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
if (!quoted && isSpace(char))
|
|
42
|
+
break;
|
|
43
|
+
if (char === "\\") {
|
|
44
|
+
let digits = chars.slice(index + 1, index + 4).join("");
|
|
45
|
+
if (/^\d{3}$/.test(digits)) {
|
|
46
|
+
let octet = Number(digits);
|
|
47
|
+
if (octet > 255)
|
|
48
|
+
return failure(new RecordDataError(`Invalid escape \\${digits} in TXT data`));
|
|
49
|
+
octets.push(octet);
|
|
50
|
+
index += 4;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
let next = chars[index + 1];
|
|
54
|
+
if (next === undefined)
|
|
55
|
+
return failure(new RecordDataError("TXT data ends in a lone backslash"));
|
|
56
|
+
octets.push(...ENCODER.encode(next));
|
|
57
|
+
index += 2;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
octets.push(...ENCODER.encode(char));
|
|
61
|
+
index += 1;
|
|
62
|
+
}
|
|
63
|
+
if (!terminated)
|
|
64
|
+
return failure(new RecordDataError("TXT data has an unterminated quoted string"));
|
|
65
|
+
strings.push(DECODER.decode(new Uint8Array(octets)));
|
|
66
|
+
}
|
|
67
|
+
return success(strings);
|
|
68
|
+
}
|
|
69
|
+
/** Decodes octets as UTF-8, the reading applied to every character-string. */
|
|
70
|
+
export function decodeOctets(octets) {
|
|
71
|
+
return DECODER.decode(octets);
|
|
72
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two failures this package returns: RDATA that does not fit its type, and a zone file
|
|
3
|
+
* too large to read at all. Everything else wrong with a file is a line-level rejection
|
|
4
|
+
* beside the records that did parse.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/** RDATA that does not parse for its type. */
|
|
10
|
+
export declare class RecordDataError extends Error {
|
|
11
|
+
name: string;
|
|
12
|
+
}
|
|
13
|
+
/** A zone file that `parse` refuses whole; its `code` names why. */
|
|
14
|
+
export declare class ZoneFileError extends Error {
|
|
15
|
+
name: string;
|
|
16
|
+
/** The input, with its includes, passed `maxBytes`. */
|
|
17
|
+
readonly code = "too-large";
|
|
18
|
+
/** UTF-8 bytes read when the limit was passed, included files counted. */
|
|
19
|
+
readonly bytes: number;
|
|
20
|
+
/**
|
|
21
|
+
* @param bytes - The bytes read so far.
|
|
22
|
+
* @param maxBytes - The limit they passed.
|
|
23
|
+
*/
|
|
24
|
+
constructor(bytes: number, maxBytes: number);
|
|
25
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two failures this package returns: RDATA that does not fit its type, and a zone file
|
|
3
|
+
* too large to read at all. Everything else wrong with a file is a line-level rejection
|
|
4
|
+
* beside the records that did parse.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/** RDATA that does not parse for its type. */
|
|
10
|
+
export class RecordDataError extends Error {
|
|
11
|
+
name = "RecordDataError";
|
|
12
|
+
}
|
|
13
|
+
/** A zone file that `parse` refuses whole; its `code` names why. */
|
|
14
|
+
export class ZoneFileError extends Error {
|
|
15
|
+
name = "ZoneFileError";
|
|
16
|
+
/** The input, with its includes, passed `maxBytes`. */
|
|
17
|
+
code = "too-large";
|
|
18
|
+
/** UTF-8 bytes read when the limit was passed, included files counted. */
|
|
19
|
+
bytes;
|
|
20
|
+
/**
|
|
21
|
+
* @param bytes - The bytes read so far.
|
|
22
|
+
* @param maxBytes - The limit they passed.
|
|
23
|
+
*/
|
|
24
|
+
constructor(bytes, maxBytes) {
|
|
25
|
+
super(`The zone file is ${bytes} bytes, over the ${maxBytes} byte limit`);
|
|
26
|
+
this.bytes = bytes;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prints typed record data back to canonical presentation format, the inverse of
|
|
3
|
+
* `parseRecordData`, so two spellings of one record (a zone file's text and a resolver's
|
|
4
|
+
* RFC 3597 generic form) print to one string a diff can key on.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type * as ZoneFile from "./types.js";
|
|
10
|
+
/**
|
|
11
|
+
* Prints a record's data in canonical presentation format. Names print absolute with the
|
|
12
|
+
* trailing dot (the root stays `"."`), TXT and CAA values print quoted with `"` and `\`
|
|
13
|
+
* escaped and every octet outside printable ASCII as `\DDD`, and untyped data prints as is.
|
|
14
|
+
* `parseRecordData(data.type, formatRecordData(data))` gives back `data`.
|
|
15
|
+
*
|
|
16
|
+
* @param data - Record data, as `parseRecordData` produces it.
|
|
17
|
+
* @returns The RDATA in presentation format.
|
|
18
|
+
* @example formatRecordData({ type: "MX", preference: 10, exchange: "mx.example.com" }) // "10 mx.example.com."
|
|
19
|
+
* @example formatRecordData({ type: "CAA", flags: 0, critical: false, tag: "issue", value: "pki.goog" }) // '0 issue "pki.goog"'
|
|
20
|
+
*/
|
|
21
|
+
export declare function formatRecordData(data: ZoneFile.RecordData<ZoneFile.RecordType>): string;
|
|
22
|
+
/**
|
|
23
|
+
* Prints record data with every name written by `printName`, which lets a zone file write
|
|
24
|
+
* names relative to its origin while the data's other fields print as `formatRecordData`
|
|
25
|
+
* prints them.
|
|
26
|
+
*
|
|
27
|
+
* @param data - Typed or untyped record data.
|
|
28
|
+
* @param printName - Writes one canonical name as it appears in the output.
|
|
29
|
+
*/
|
|
30
|
+
export declare function printRecordData(data: {
|
|
31
|
+
type: string;
|
|
32
|
+
}, printName: (name: string) => string): string;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { absoluteName } from "./names.js";
|
|
2
|
+
/** Encodes a character-string into the octets `\DDD` escapes stand for. */
|
|
3
|
+
const ENCODER = new TextEncoder();
|
|
4
|
+
/**
|
|
5
|
+
* Prints a record's data in canonical presentation format. Names print absolute with the
|
|
6
|
+
* trailing dot (the root stays `"."`), TXT and CAA values print quoted with `"` and `\`
|
|
7
|
+
* escaped and every octet outside printable ASCII as `\DDD`, and untyped data prints as is.
|
|
8
|
+
* `parseRecordData(data.type, formatRecordData(data))` gives back `data`.
|
|
9
|
+
*
|
|
10
|
+
* @param data - Record data, as `parseRecordData` produces it.
|
|
11
|
+
* @returns The RDATA in presentation format.
|
|
12
|
+
* @example formatRecordData({ type: "MX", preference: 10, exchange: "mx.example.com" }) // "10 mx.example.com."
|
|
13
|
+
* @example formatRecordData({ type: "CAA", flags: 0, critical: false, tag: "issue", value: "pki.goog" }) // '0 issue "pki.goog"'
|
|
14
|
+
*/
|
|
15
|
+
export function formatRecordData(data) {
|
|
16
|
+
return printRecordData(data, absoluteName);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Prints record data with every name written by `printName`, which lets a zone file write
|
|
20
|
+
* names relative to its origin while the data's other fields print as `formatRecordData`
|
|
21
|
+
* prints them.
|
|
22
|
+
*
|
|
23
|
+
* @param data - Typed or untyped record data.
|
|
24
|
+
* @param printName - Writes one canonical name as it appears in the output.
|
|
25
|
+
*/
|
|
26
|
+
export function printRecordData(data, printName) {
|
|
27
|
+
switch (data.type) {
|
|
28
|
+
case "A":
|
|
29
|
+
case "AAAA":
|
|
30
|
+
return data.address;
|
|
31
|
+
case "CNAME":
|
|
32
|
+
case "PTR":
|
|
33
|
+
case "DNAME":
|
|
34
|
+
return printName(data.target);
|
|
35
|
+
case "NS":
|
|
36
|
+
return printName(data.host);
|
|
37
|
+
case "MX": {
|
|
38
|
+
let { preference, exchange } = data;
|
|
39
|
+
return `${preference} ${printName(exchange)}`;
|
|
40
|
+
}
|
|
41
|
+
case "TXT": {
|
|
42
|
+
let { strings } = data;
|
|
43
|
+
return strings.length === 0 ? "\\# 0" : strings.map(quote).join(" ");
|
|
44
|
+
}
|
|
45
|
+
case "CAA": {
|
|
46
|
+
let { flags, tag, value } = data;
|
|
47
|
+
return `${flags} ${tag.toLowerCase()} ${quote(value)}`;
|
|
48
|
+
}
|
|
49
|
+
case "SRV": {
|
|
50
|
+
let { priority, weight, port, target } = data;
|
|
51
|
+
return `${priority} ${weight} ${port} ${printName(target)}`;
|
|
52
|
+
}
|
|
53
|
+
case "SOA": {
|
|
54
|
+
let soa = data;
|
|
55
|
+
return [
|
|
56
|
+
printName(soa.primary),
|
|
57
|
+
printName(soa.mailbox),
|
|
58
|
+
soa.serial,
|
|
59
|
+
soa.refresh,
|
|
60
|
+
soa.retry,
|
|
61
|
+
soa.expire,
|
|
62
|
+
soa.minimum,
|
|
63
|
+
].join(" ");
|
|
64
|
+
}
|
|
65
|
+
default:
|
|
66
|
+
return String(data.data);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* One quoted character-string: `"` and `\` escaped, every octet outside printable ASCII
|
|
71
|
+
* written `\DDD`, so the text reads back to the same UTF-8 whatever the reader's encoding.
|
|
72
|
+
*/
|
|
73
|
+
function quote(text) {
|
|
74
|
+
let printed = "";
|
|
75
|
+
for (let octet of ENCODER.encode(text)) {
|
|
76
|
+
if (octet === 0x22 || octet === 0x5c)
|
|
77
|
+
printed += `\\${String.fromCharCode(octet)}`;
|
|
78
|
+
else if (octet >= 0x20 && octet <= 0x7e)
|
|
79
|
+
printed += String.fromCharCode(octet);
|
|
80
|
+
else
|
|
81
|
+
printed += `\\${String(octet).padStart(3, "0")}`;
|
|
82
|
+
}
|
|
83
|
+
return `"${printed}"`;
|
|
84
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads and writes RFC 1035 master files (BIND zone files), reporting every entry it cannot
|
|
3
|
+
* use beside the records it read, and owns the RDATA presentation codec, so record data from
|
|
4
|
+
* a zone file and from a resolver reads into the same typed fields.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
export type * from "./types.js";
|
|
10
|
+
export type * as ZoneFile from "./types.js";
|
|
11
|
+
export { RecordDataError, ZoneFileError } from "./errors.js";
|
|
12
|
+
export { formatRecordData } from "./format-record-data.js";
|
|
13
|
+
export { parse } from "./parse.js";
|
|
14
|
+
export { parseRecordData } from "./parse-record-data.js";
|
|
15
|
+
export { canonicalType, typeName } from "./record-types.js";
|
|
16
|
+
export { stringify } from "./stringify.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads and writes RFC 1035 master files (BIND zone files), reporting every entry it cannot
|
|
3
|
+
* use beside the records it read, and owns the RDATA presentation codec, so record data from
|
|
4
|
+
* a zone file and from a resolver reads into the same typed fields.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
export { RecordDataError, ZoneFileError } from "./errors.js";
|
|
10
|
+
export { formatRecordData } from "./format-record-data.js";
|
|
11
|
+
export { parse } from "./parse.js";
|
|
12
|
+
export { parseRecordData } from "./parse-record-data.js";
|
|
13
|
+
export { canonicalType, typeName } from "./record-types.js";
|
|
14
|
+
export { stringify } from "./stringify.js";
|
package/dist/lexer.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits master-file text into entries: one per line, or one per parenthesized group of
|
|
3
|
+
* lines, each a list of fields with its trailing comment. Quotes, escapes and parentheses
|
|
4
|
+
* are settled here, so the entry reader sees fields and never characters.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/** One field of an entry, as written: quotes and escapes kept for the field's own reader. */
|
|
10
|
+
export interface Token {
|
|
11
|
+
text: string;
|
|
12
|
+
quoted: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** One logical entry of a zone file: a directive, a record, or something unreadable. */
|
|
15
|
+
export interface Entry {
|
|
16
|
+
line: number;
|
|
17
|
+
endLine: number;
|
|
18
|
+
/** The first line starts with whitespace, which leaves the owner to the previous entry. */
|
|
19
|
+
blankOwner: boolean;
|
|
20
|
+
tokens: Token[];
|
|
21
|
+
/** The `;` comment outside parentheses, trimmed; `null` without one. */
|
|
22
|
+
comment: string | null;
|
|
23
|
+
/** Why the entry cannot be read; `null` when it lexed cleanly. */
|
|
24
|
+
error: string | null;
|
|
25
|
+
/** The entry as written, every line of it. */
|
|
26
|
+
input: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Splits text into entries. Blank and comment-only lines produce none; an entry whose
|
|
30
|
+
* parentheses never close runs to the end of the text and carries an error, as does one
|
|
31
|
+
* with a quote left open at the end of a line.
|
|
32
|
+
*
|
|
33
|
+
* @param text - The zone file's contents.
|
|
34
|
+
* @returns The entries in file order.
|
|
35
|
+
* @example lex("www IN A 192.0.2.1 ; web")[0]?.tokens.length // 4
|
|
36
|
+
*/
|
|
37
|
+
export declare function lex(text: string): Entry[];
|