- Zig 94.8%
- Nix 3.2%
- Python 2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Hand each fuzz target's seeds to testing.fuzz as its corpus, in the encoding a Smith reads, so that Zig's fuzzer starts from files that already parse rather than from nothing. Without --fuzz the same corpus runs once, which replaces the loops that ran the seeds by hand. Add an includes target, which answers every $INCLUDE with a second fuzzed file and so reaches nesting, the depth limit, and the origin and time to live put back when an included file ends. Read whole files with a Diagnostics, and check that every refusal says why and is the same refusal without one. Add seeds for the branches no input had reached, and fail the build if a seed outgrows its target's buffer. Under the fuzz-run loop this takes the lines of src/ reached from 90.9% to 97.4%, and Zig's fuzzer covers about three times as much in the same number of runs. The diagnostics check found the lexer's refusals -- a quote or a parenthesis that never closed, a trailing backslash -- coming back with an empty message pointing at the entry before. They now say what was wrong and point at the token that began it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MNSqhxCJdEjUWVTECRrS1E |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| REUSE.toml | ||
zig-dns-zone
BIND zone files for Zig 0.17: read one, edit it, write it back.
This library turns the text of a zone file into records and records back into
text. It opens no files and no sockets — you hand it a std.Io.Reader and a
std.Io.Writer and it does the rest, which is what lets the same code read a
zone off disk, out of an HTTP response, or from a string in a test.
A record here is a dns.Record: the
record exactly as a DNS message carries it, owner name and all. That is the
whole trick of this library. Everything zig-dns
can do to a record read off a socket — decode it, print it, compare it, put it
in a message — it can do to one read out of a file, and the presentation
parsing here is the half of the format that library deliberately does not have.
The API documentation is generated from the doc comments, which are most of the explanation of why the master file format is the shape it is.
Where this lives
The repository lives in three places that carry the same history. The Forgejo instance at https://git.jcollie.dev/jeff/zig-dns-zone is the web-visible one:
$ git clone https://git.jcollie.dev/jeff/zig-dns-zone.git
it is mirrored on Tangled at https://tangled.org/jcollie.dev/zig-dns-zone, and it is also on the Radicle network, where the repository's identifier is
rad:z37xg8xcpm7qicNuTjPJt7spAC8fv
and rad clone rad:z37xg8xcpm7qicNuTjPJt7spAC8fv fetches it from any node that
seeds it. Any of the three is the whole project.
Adding it to a project
$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-dns-zone.git
and then, in build.zig:
const dns_zone = b.dependency("dns_zone", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("zone", dns_zone.module("zone"));
That is the whole of it: zone.dns and zone.z46 are the libraries
underneath, re-exported, so reaching dns.Record and z46.Ipv4Address needs
no second dependency. Declaring zig-dns yourself as well works and gives the
same types — Zig's build graph caches a dependency on its build root and its
options, so the same commit taken the same way is one module — but it is one
more pin to keep in step, and a pin that drifts is two types with the same
name.
main needs Zig 0.17.0. For Zig 0.16.0, use the zig-0.16 branch, which
holds the last of zig-dns-zone to build with it:
$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-dns-zone.git#zig-0.16
$ git clone -b zig-0.16 https://git.jcollie.dev/jeff/zig-dns-zone.git
That branch is kept for 0.16 users rather than developed; new work lands on
main only.
Quick start
Reading:
const zone = @import("zone");
const dns = @import("dns");
var z = try zone.Zone.parse(gpa, reader, .{ .origin = "example.com." });
defer z.deinit();
std.debug.print("serial {?d}, {d} records\n", .{ z.serial(), z.count() });
var it = z.find(dns.Name.literal("www.example.com"), .a);
while (it.next()) |record| {
std.debug.print("{f}\n", .{(try record.data()).a});
}
Editing, and writing it back:
_ = z.removeRrset(dns.Name.literal("www.example.com"), .a);
try z.addText("www 300 IN A 198.51.100.10", .{});
try z.addData(.{
.name = dns.Name.literal("www.example.com"),
.ttl = 300,
.data = .{ .aaaa = .{ .octets = ... } },
});
_ = try z.bumpSerial();
try z.write(writer, .{});
One record at a time, without holding the zone in memory:
var parser = try zone.Parser.init(gpa, reader, .{ .origin = "example.com." });
defer parser.deinit();
while (try parser.next()) |item| switch (item) {
.record => |record| try send(record),
.include => {},
}
And when something is wrong with the file, a Diagnostics says what and where:
var diagnostics: zone.Diagnostics = .{};
var z = zone.Zone.parse(gpa, reader, .{
.origin = "example.com.",
.file = "db.example.com",
.diagnostics = &diagnostics,
}) catch |err| {
// db.example.com:42:1: the address is not an IPv4 address: "192.0.2.256"
std.debug.print("{f}\n", .{diagnostics});
return err;
};
It allocates nothing to do that, because the thing being reported may well be that there was no memory.
What is in it
Zone |
an origin and its records, in the order they were written: find, add, remove, bump the serial, sort, write — and, for a zone being kept up to date from somewhere else, remove one record by what it says and adopt a copy somebody else made |
Parser |
one record at a time, with $ORIGIN, $TTL, $INCLUDE and $GENERATE |
Lexer |
the tokens — quoting, escapes, parentheses, comments, and the indentation that means "the same owner again" |
rdata_text |
presentation form to wire form, for every type that has one |
write |
records back out as a zone file, or in a canonical form to compare two zones with |
name |
@, the refusal to root a name with no origin, and writing one back out relative to the origin — the escapes and the relative-name rule are dns.Name's, shared with the resolver that needs them too |
Diagnostics |
the line, the column and a sentence, without allocating |
Every record type with a presentation syntax is read: every type zig-dns decodes, less the three that belong to a message rather than to a zone and the two that never had a syntax to begin with.
| addresses and names | A AAAA NS CNAME DNAME PTR SOA MX SRV NAPTR NSAP-PTR MD MF MB MG MR |
| text | TXT SPF HINFO RP MINFO NINFO AVC RESINFO WALLET X25 ISDN GPOS |
| service and policy | SVCB HTTPS CAA URI KX LP AFSDB RT PX |
| DNSSEC | DS CDS DLV TA DNSKEY CDNSKEY KEY RRSIG SIG NSEC NSEC3 NSEC3PARAM NXT |
| keys published for elsewhere | SSHFP TLSA SMIMEA CERT IPSECKEY OPENPGPKEY DHCID HIP AMTRELAY |
| zone maintenance | CSYNC ZONEMD DSYNC |
| identifiers and locators | NID L32 L64 EUI48 EUI64 APL LOC WKS A6 NSAP |
Anything else — a type nobody has a name for, a type whose syntax nobody has
agreed on — is written in the \# length hex form of RFC 3597 §5, which this
reads for every type, known or not. That is how a zone file carries a record
type the software reading it has never heard of, and it is how the output of
one name server is read by another.
OPT, TSIG and TKEY are refused: they belong to a message, are built and
stripped by the software that sends it, and a zone file has no business holding
one. So are ANY, AXFR, IXFR, MAILA and MAILB, which name a question
and not a record. NULL and UNSPEC hold anything at all and no
specification ever said how to write one down, so they are read in the generic
form and only in that.
Keeping a zone up to date from a transfer
Three of Zone's methods exist for one caller — a secondary applying an
incremental zone transfer (RFC 1995), where what arrives is a list of records to
remove and a list to add:
removeRecordremoves one record, matched on its owner, class, type and data and not on its TTL, which RFC 2181 §5.2 says is not part of what makes a record that record. The data is compared after re-encoding, because a record that arrived in a message may have compression pointers inside it and those bytes mean nothing here — comparing them as they arrived answers "not found" for a record that is plainly there, and the zone quietly keeps something the primary dropped.removeRrsetis the wrong tool for a delta: it takes the whole RRset when one member of it changed.ensureUnusedCapacityandadoptare how a set of changes is applied without a half-updated zone in the middle. Reserve first, and the one step that can fail happens before anything has been removed;adoptthen takes an image the caller already made, so a transfer that staged its changes does not copy them twice. RFC 1995 §2 is explicit that nothing may replace the older version until every difference has been processed, and that is what those two are for.
What it will not do
No I/O. The library reads from a std.Io.Reader and writes to a
std.Io.Writer, and that is the whole of its contact with the world. $INCLUDE
is therefore reported rather than followed: the parser hands back the file
name, the caller opens it however it likes — a directory, an archive, a
database — and hands the reader back with pushInclude. tools/zonecat.zig
does exactly that, against the directory the including file is in, and is the
example to copy.
No policy. That a zone has exactly one soa and that it sits at the apex;
that a name with a cname holds nothing else; that every delegation has glue;
that the records of an RRset share a time to live, as RFC 2181 §5.2 requires —
none of that is checked. BIND checks all of it while loading and this reads what
is written. A zone file this accepts is not necessarily one a name server will
serve.
No cryptography, and no signing. Every DNSSEC record type is read and
written, ZONEMD and NSEC3 and all, and nothing here computes a digest,
verifies a signature or builds a chain.
No name server. A Zone is an ordered list to read and edit, and find
walks it. For a zone of a few thousand records that is nothing; for a zone of a
few million the right data structure depends on what the program asks, which is
not something a library can guess.
Three things worth knowing
A record is its wire form. Each one is a dns.Record over bytes this
library owns, laid out exactly as a message carries it: owner name, type,
class, time to live, length, data. Each is its own allocation, so a record
keeps working while the zone grows around it, while another is removed, and
while the list is sorted. Putting one in a message is a copy rather than a
conversion:
for (z.records()) |record| try builder.record(.answer, .{
.name = record.name,
.class = record.class,
.ttl = record.ttl,
.data = try record.data(),
});
What was read is checked by being read back. The presentation parser writes
the record data field by field, and then dns decodes those bytes again before
the record is handed over. That is what catches everything which is the right
shape and the wrong meaning: a svcb whose mandatory names a parameter it
does not carry, a \# whose bytes are not what its type needs, a record whose
length and type disagree. It is one pass over bytes that are already in cache,
and Parser.Options.validate turns it off for anyone who has measured that it
matters.
Writing is a canonical form. A zone written out and read back gives the same
records, and writing that gives the same text — so the output of this library
is something two zones can be compared in. write.Options.canonical goes
further: one record per line, every name in full, every field present, sorted
into the canonical order of RFC 4034 §6, which is the form diff can say
something about.
$ zonecat --canonical --sort db.example.com > before
$ zonecat --canonical --sort db.example.com.new | diff before -
The syntax it reads
Everything RFC 1035 §5.1 defines, and what BIND added afterwards:
$ORIGIN,$TTL(RFC 2308),$INCLUDEwith its optional origin, and$GENERATEwith its${offset,width,radix}substitutions — including the nibble radix, which exists becauseip6.arpais written one hexadecimal digit at a time.- An entry beginning with whitespace takes the owner of the entry above it, which is the only place in the format where indentation means something.
- A time to live written with units:
1h30m,2w,86400. - The class and the time to live in either order, and either of them left out.
- Parentheses that hide the newlines inside them, comments that run to the end
of a line, quoted strings that hold spaces and semicolons, and
\.,\\,\DDDescapes anywhere at all. @for the origin, and relative names under it.
A file with no $TTL and no default from the caller falls back to the soa's
own minimum, as RFC 2308 §4 describes and BIND does — and a record before the
soa with no time to live of its own is an error rather than a guess.
zonecat
The library's own command line, which is also what the differential check drives:
$ zonecat --origin example.com. db.example.com # tidied and re-laid out
$ zonecat --canonical --sort db.example.com # one record per line, sorted
$ zonecat --count db.example.com # how many records
$ zonecat --include db.example.com # follow $INCLUDE
$ named-compilezone -o - example.com db | zonecat --canonical --origin example.com. -
Building
zig build test runs everything; zig build check compiles the parts no test
runs. zig-dns does the record types and
z46 the addresses; those two are the only
dependencies.
$ zig build test --summary all
$ zig build docs && zig build docs-serve # http://127.0.0.1:8000/
Under Nix, nix develop -c zig build test uses the toolchain in flake.nix
rather than whatever is on the path: the official Zig 0.17.0 release binary,
packaged by zig-overlay.
Testing
Beyond the unit tests beside each file, tests/zones/ holds whole zone files —
a forward zone with most of the record types in it, a reverse zone written with
$GENERATE, a file of nothing but the awkward corners of the syntax, one
record of every type that has a syntax, and the types BIND has dropped. Each is
read, written, read again and compared: the records have to survive, and the
second file written has to equal the third.
Checking it against BIND
Every test above is this library agreeing with itself, which cannot catch the
reader and the writer being wrong the same way: a type whose fields are encoded
in the wrong order is decoded back in the wrong order and round-trips
perfectly. zig build differential puts the same zone files to
named-compilezone and compares what the two of them made of each:
$ nix develop -c zig build differential
2.0.192.in-addr.arpa.zone: 216 records, agreed
everything.test.zone: 60 records, agreed
example.com.zone: 37 records, agreed
legacy.test.zone: not compared -- BIND has removed A6 and NXT, ...
oddities.test.zone: 24 records, agreed, after giving 2 record(s) the time to
live of the rrset they are in, as BIND does and this does not
Every zone reads the same way here as it does in BIND.
The comparison is of records rather than of text: BIND's output is read back by
this library and written out in the same canonical form, which removes every
difference that is only a matter of layout. The two differences that remain are
written down in tests/differential.py — the RFC 2181 time to live rule, which
BIND applies while reading and this does not, and the record types BIND cannot
read at all.
nix flake check runs the same comparison as a build, with the Zig
dependencies coming from build.zig.zon.nix rather than from the network.
Regenerate that file with nix develop -c zon2nix --17 --nix=build.zig.zon.nix build.zig.zon whenever a dependency moves.
Fuzzing
tests/fuzz.zig holds the properties that must survive any input at all: that
reading terminates, stays inside its buffer, leaks nothing, that whatever was
accepted can be written and read back as the same records, and that whatever
was refused comes with a diagnostic saying why — the same refusal, with the
same error, as without one. There are five targets: whole zone files, single
entries, names, the lexer on its own, and a file whose every $INCLUDE is
answered with a second fuzzed file, which is what reaches nesting and the
depth limit. Zig's own fuzzer runs them with coverage feedback, starting from
the seeds beside each target rather than from nothing:
$ zig build fuzz --fuzz # until interrupted
$ zig build fuzz --fuzz=1M # a bounded run
$ zig build fuzz --fuzz -Dfuzz-filter=names # one target
The fuzz test binary is always compiled with LLVM, because the self-hosted backend Debug uses by default emits no coverage instrumentation and the fuzzer would have nothing to steer by.
zig build fuzz-run is a loop of our own over the same targets, with a corpus
in place of coverage feedback. It does a fixed amount of work from a seed that
can be given again, which is what CI runs, and replays a single input:
$ zig build fuzz-run # a minute of each target
$ zig build fuzz-run -- --seconds 300 --target zonefile
$ zig build fuzz-run -- --input fuzz-findings/x.bin --target entry
It has already earned its keep twice. A caa whose tag held a space was read,
written back, and read again as a different record, because the tag is the
one field written without quotes — RFC 8659 says a tag is letters and digits,
so now so does this. And a family of records turned out to be legal on the wire
and impossible to write down: a field that is empty in the middle of a record
disappears, and the field after it moves up into its place. Those are refused
now (rdata_text.representable lists them), so that a zone this library reads
is a zone it can write.
Giving it diagnostics to check found a third: a quote that never closed, a parenthesis that never did, and the other things the lexer refuses came back with an empty diagnostic pointing at the entry before. They now say what was wrong and point at the token that began it.
License
MIT. The project follows the REUSE standard; reuse lint checks it.
References cited
The specifications this library implements, in the order a zone file meets them.
- Mockapetris, P. 1987. Domain Names — Implementation and Specification. RFC 1035. Internet Engineering Task Force. https://www.rfc-editor.org/info/rfc1035. The master file format itself: §5.1 is the grammar, §3.3 the record types it defined, §2.3.4 the limits on a name.
- Everhart, C., L. Mamakos, R. Ullmann, and P. Mockapetris. 1990. New DNS RR
Definitions. RFC 1183. https://www.rfc-editor.org/info/rfc1183.
AFSDB,RP,X25,ISDNandRT. - Manning, B., and R. Colella. 1994. DNS NSAP Resource Records. RFC 1706.
https://www.rfc-editor.org/info/rfc1706. Where the
0xin anNSAPcomes from. - Davis, C., P. Vixie, T. Goodwin, and I. Dickinson. 1996. A Means for
Expressing Location Information in the Domain Name System. RFC 1876.
https://www.rfc-editor.org/info/rfc1876.
LOC, and the mantissa-and-exponent byte that makes a kilometre and a centimetre fit in the same field. - Eastlake, D. 1999. Domain Name System Security Extensions. RFC 2535.
https://www.rfc-editor.org/info/rfc2535.
NXT,KEYandSIG, which came beforeNSEC,DNSKEYandRRSIG. - Elz, R., and R. Bush. 1997. Clarifications to the DNS Specification. RFC 2181. https://www.rfc-editor.org/info/rfc2181. §5.2 is the time to live rule this library does not apply.
- Andrews, M. 1998. Negative Caching of DNS Queries (DNS NCACHE). RFC 2308.
https://www.rfc-editor.org/info/rfc2308.
$TTL, and what thesoa'sminimummeans now. - Gulbrandsen, A., P. Vixie, and L. Esibov. 2000. A DNS RR for Specifying the Location of Services (DNS SRV). RFC 2782. https://www.rfc-editor.org/info/rfc2782.
- Crawford, M., and C. Huitema. 2000. DNS Extensions to Support IPv6 Address
Aggregation and Renumbering. RFC 2874. https://www.rfc-editor.org/info/rfc2874.
A6, which RFC 6563 made historic. - Gustafsson, A. 2003. Handling of Unknown DNS Resource Record (RR) Types. RFC
3597. https://www.rfc-editor.org/info/rfc3597. The
\# length hexsyntax, and the rule that names in a new type are never compressed. - Mealling, M. 2002. Dynamic Delegation Discovery System (DDDS) Part Three: The
Domain Name System (DNS) Database. RFC 3403. https://www.rfc-editor.org/info/rfc3403.
NAPTR. - Koch, P. 2001. A DNS RR Type for Lists of Address Prefixes (APL RR). RFC
3123. https://www.rfc-editor.org/info/rfc3123. The
[!]family:address/lengthof anAPL. - Richardson, M. 2005. A Method for Storing IPsec Keying Material in DNS. RFC
4025. https://www.rfc-editor.org/info/rfc4025.
IPSECKEY, and the gateway written as a lone dot. - Arends, R., R. Austein, M. Larson, D. Massey, and S. Rose. 2005. Resource
Records for the DNS Security Extensions. RFC 4034.
https://www.rfc-editor.org/info/rfc4034.
DNSKEY,RRSIG,NSEC,DS, the type bitmap, theYYYYMMDDHHmmSStimestamps, and the canonical order of §6 thatsortCanonicalimplements. - Josefsson, S. 2006. The Base16, Base32, and Base64 Data Encodings. RFC 4648.
https://www.rfc-editor.org/info/rfc4648. §7 is the extended hex alphabet an
NSEC3owner name is written in, chosen so that it sorts the way the hash does. - Eastlake, D. 2006. Domain Name System (DNS) Case Insensitivity
Clarification. RFC 4343. https://www.rfc-editor.org/info/rfc4343. Why
findignores case and the wire does not. - Laurie, B., G. Sisson, R. Arends, and D. Blacka. 2008. DNS Security (DNSSEC)
Hashed Authenticated Denial of Existence. RFC 5155.
https://www.rfc-editor.org/info/rfc5155.
NSEC3, its salt and its base32 owner names. - Rose, S., and W. Wijngaards. 2012. DNAME Redirection in the DNS. RFC 6672. https://www.rfc-editor.org/info/rfc6672.
- Atkinson, R., and S. Bhatti. 2012. DNS Resource Records for the Identifier-
Locator Network Protocol (ILNP). RFC 6742. https://www.rfc-editor.org/info/rfc6742.
NID,L32,L64andLP. - Faltstrom, P., and O. Kolkman. 2013. A Uniform Resource Identifier (URI) DNS Resource Record. RFC 7553. https://www.rfc-editor.org/info/rfc7553.
- Laganier, J. 2016. Host Identity Protocol (HIP) Domain Name System (DNS)
Extension. RFC 8005. https://www.rfc-editor.org/info/rfc8005.
HIP, whose two lengths sit in front of data the parser has not read yet. - Hallam-Baker, P., R. Stradling, and J. Hoffman-Andrews. 2019. DNS Certification Authority Authorization (CAA) Resource Record. RFC 8659. https://www.rfc-editor.org/info/rfc8659. §4.1 is the rule about a tag being letters and digits that the fuzzer sent us back to read.
- Thaler, D. 2020. Automatic Multicast Tunneling (AMT) Relay Discovery Using
DNS. RFC 8777. https://www.rfc-editor.org/info/rfc8777.
AMTRELAY. - Schwartz, B., M. Bishop, and E. Nygren. 2023. Service Binding and Parameter
Specification via the DNS (SVCB and HTTPS Resource Records). RFC 9460.
https://www.rfc-editor.org/info/rfc9460. The
key=valueparameters, their ordering on the wire, and thealpnlist whose commas may be escaped. - Thomassen, P., and N. Wisiol. 2025. Generalized DNS Notifications. RFC 9859.
https://www.rfc-editor.org/info/rfc9859.
DSYNC. - Internet Systems Consortium. BIND 9 Administrator Reference Manual.
https://bind9.readthedocs.io/.
$GENERATEis BIND's rather than anybody's standard, and the manual is where its${offset,width,radix}is defined.