BIND zone files for Zig 0.16: read one, edit it, write it back
  • Zig 94.8%
  • Nix 3.2%
  • Python 2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie fb35be9998
All checks were successful
test / test (push) Successful in 8m8s
test / docs (push) Successful in 6m17s
Fuzz more of the library, and say what the lexer refused
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
2026-10-02 20:50:25 -05:00
.forgejo/workflows Read and write BIND zone files 2026-09-22 19:35:13 -05:00
LICENSES Read and write BIND zone files 2026-09-22 19:35:13 -05:00
src Fuzz more of the library, and say what the lexer refused 2026-10-02 20:50:25 -05:00
tests Fuzz more of the library, and say what the lexer refused 2026-10-02 20:50:25 -05:00
tools Build with the Zig 0.17.0 release 2026-10-02 19:39:00 -05:00
.gitignore Read and write BIND zone files 2026-09-22 19:35:13 -05:00
build.zig Build with the Zig 0.17.0 release 2026-10-02 19:39:00 -05:00
build.zig.zon Build with the Zig 0.17.0 release 2026-10-02 19:39:00 -05:00
build.zig.zon.nix Build with the Zig 0.17.0 release 2026-10-02 19:39:00 -05:00
flake.lock Build with the Zig 0.17.0 release 2026-10-02 19:39:00 -05:00
flake.nix Build with the Zig 0.17.0 release 2026-10-02 19:39:00 -05:00
README.md Fuzz more of the library, and say what the lexer refused 2026-10-02 20:50:25 -05:00
REUSE.toml Read and write BIND zone files 2026-09-22 19:35:13 -05:00

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:

  • removeRecord removes 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. removeRrset is the wrong tool for a delta: it takes the whole RRset when one member of it changed.
  • ensureUnusedCapacity and adopt are 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; adopt then 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), $INCLUDE with its optional origin, and $GENERATE with its ${offset,width,radix} substitutions — including the nibble radix, which exists because ip6.arpa is 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 \., \\, \DDD escapes 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, ISDN and RT.
  • Manning, B., and R. Colella. 1994. DNS NSAP Resource Records. RFC 1706. https://www.rfc-editor.org/info/rfc1706. Where the 0x in an NSAP comes 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, KEY and SIG, which came before NSEC, DNSKEY and RRSIG.
  • 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 the soa's minimum means 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 hex syntax, 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/length of an APL.
  • 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, the YYYYMMDDHHmmSS timestamps, and the canonical order of §6 that sortCanonical implements.
  • 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 NSEC3 owner 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 find ignores 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, L64 and LP.
  • 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=value parameters, their ordering on the wire, and the alpn list 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/. $GENERATE is BIND's rather than anybody's standard, and the manual is where its ${offset,width,radix} is defined.