A DNS resolver library for Zig that owns the DNS protocol and none of the I/O.
cocuyo builds queries, reads responses, and decides what to do next. It never opens a socket, starts a thread, sets a timer or allocates memory. Every function that would block returns a value naming the I/O it needs, and your program does it: a blocking socket, epoll, kqueue, io_uring, or any completion loop you already run.
It is written from the RFCs, as a replacement for c-ares. The name is the Colombian word for the firefly, and for a car's hazard lights.
Status: 0.5.0. The library is feature-complete against its plan. The engine over rotor is exported as
cocuyo_rotor, and carries DNS over TLS, over QUIC and over HTTPS, each with its TLS from colibri'stls. A question may ask for any record type. It needs Zig 0.16.0. Until 1.0, a minor version may change the API.
c-ares does two jobs: the DNS protocol, and owning the sockets it speaks it over. Embedding it means
connecting its event loop to yours through ARES_OPT_SOCK_STATE_CB and ares_process, and testing
it means a network.
cocuyo does the first job only. That choice gives you four things:
- It fits your loop. You send the bytes it hands you and give it the bytes you receive. The same library runs under a blocking socket and under io_uring, unchanged.
- Its memory is yours, sized once. You hand cocuyo its lookup table and its cache at init. It never allocates, so there is no allocator to pass and no out-of-memory path at run time.
- It is deterministic. Time and randomness are arguments. The same seed and the same clock replay the same lookup byte for byte, so the state machine is tested without a network.
- It checks itself in production. Assertions stay on in every build cocuyo offers. A malformed response is an error value, never a crash; a broken internal rule stops the program.
cocuyo sends DNS queries to the servers you give it. That is all it does. It is not your operating system's resolver, and it does not see what that resolver sees.
- macOS.
/etc/resolv.confis a partial, legacy view. The system resolves through per-interface configuration and through mDNSResponder, for.localnames and for split-DNS domains routed to a VPN. A program callinggetaddrinfosees all of that. cocuyo readingresolv.confsees none of it. - Linux with systemd-resolved.
/etc/resolv.confusually points at the 127.0.0.53 stub, which applies per-link routing, so cocuyo gets close to what other programs see. Whereresolv.confis a static list instead, per-link domains are invisible. - Everywhere. cocuyo reads the hosts file only when you parse it and hand it the table. It does not consult nsswitch, NIS, LDAP or mDNS. A name that resolves for every other program on the machine can fail here, by design.
Choose cocuyo when you want a resolver that is explicit, testable and the same on every host: a
server, a proxy, a container, an embedded system. Choose getaddrinfo on a thread when you need
exactly what the rest of the machine sees.
| Area | What cocuyo provides |
|---|---|
| Lookups | One question per lookup, for any record type: A, AAAA, PTR, CNAME, NS, SOA, MX, TXT, SRV, NAPTR, TLSA, SVCB, HTTPS, URI, CAA and more read into fields, and every other type, DNSSEC's among them, handed out whole with Kind.of(code) |
| Many lookups | Resolver, a bounded table of lookups that decides which lookup an incoming datagram belongs to |
getaddrinfo shape |
AddressLookup joins A and AAAA, the hosts file and the search list, and orders addresses by RFC 6724; NameLookup does the reverse |
| Transport | UDP with EDNS0 (RFC 6891) and its fallback, TCP on truncation or by choice (RFC 7766), with the length prefix handled for you |
| Encryption | In the engine: DNS over TLS (RFC 7858, strict as RFC 8310 asks), DNS over QUIC (RFC 9250) and DNS over HTTPS (RFC 8484) on HTTP/3, HTTP/2 or HTTP/1.1, a server known by its name, by SPKI pins, or by both |
| Robustness | Retries with a doubling timeout, rotation, and server failover that tracks failures per server |
| Configuration | resolv.conf, RES_OPTIONS and LOCALDOMAIN, and the hosts file, parsed from bytes you read |
| Cache | Optional, sized by you, with SIEVE eviction and RFC 2308 negative caching |
| Security | DNS cookies (RFC 7873), DNS-0x20 case randomisation, a source-port hint, and strict response matching |
The search-list walk has been checked on the wire against glibc 2.39, musl 1.2.5 and c-ares 1.34.8. cocuyo walks as glibc and c-ares do. The one difference is SERVFAIL: cocuyo stops and reports the failure, as c-ares does, where glibc moves on to the next search domain and hides it.
You own the loop. cocuyo tells you what to do next, and you tell it what happened. This is the
driving loop of examples/udp_blocking.zig, shortened. socket
stands for your own UDP socket, and clock for a monotonic clock in nanoseconds:
var servers = cocuyo.Servers.init(&config, seed);
var lookup = cocuyo.Lookup.init(&config, &servers, try cocuyo.Question.from_text(name, .a), seed);
var query: [cocuyo.constants.query_bytes_max]u8 = undefined;
while (true) {
switch (lookup.poll(clock.read(), &query)) {
.send_udp => |send| {
// From a port bound at send.local_port_hint, where the socket allows it.
try socket.send(send.server, send.message_bytes);
lookup.on_sent(clock.read());
},
.wait => |deadline_ns| {
// Receive until the deadline, and hand over whatever arrived.
if (try socket.receive_until(deadline_ns)) |datagram| {
_ = lookup.on_response(datagram.bytes, datagram.from, clock.read());
}
},
.connect_tcp, .send_tcp => {
// The answer was truncated: the same exchange over TCP.
},
.send_request => {
// A DoH or DoQ server: an HTTP or a QUIC client carries the bytes, as the engine does.
},
.done => |answer| return answer, // addresses, TTL, canonical name
.failed => |failure| return failure.err,
}
}For many lookups at once, Resolver does the same with one poll for the whole table, one
deadline to arm one timer, and one on_datagram call per datagram received.
cocuyo works with any loop, and it was built alongside one: rotor, a completion-based event loop for Zig by the same author. rotor runs on io_uring on Linux, falling back to epoll where io_uring is refused, as in a container under Docker's default security profile, and on kqueue on macOS.
-
One lookup over rotor.
examples/udp_rotor.zigdrives the same lookup as the blocking example, over rotor's loop. It shows the three things a completion loop changes: a send is queued and completes later, one receive delivers every datagram, and replies arrive in buffers the loop picks. cocuyo cannot tell the two examples apart.zig build example-udp-rotor -- example.com
-
Many lookups over rotor. The engine in
io/runs a wholeResolverover rotor: one UDP socket per server with source-port rotation, one reused TCP connection per server, one timer for every deadline, and the cache in front. It is tested on a deterministic twin of rotor, and end to end over rotor itself on macOS and Linux; the throughput comparison with c-ares below runs on it. It is exported as the modulecocuyo_rotor, whose typeResolverruns on a loop you own: your build binds itsrotorimport to your rotor, and every event your loop hands out goes to the engine'sapplyfirst (test/consumer/is such a package). -
One engine for each core. Each thread runs a loop and an engine of its own, and nothing is shared between them but a
Config, which is only read.examples/threads_rotor.zigruns two at once, and CI runs it under ThreadSanitizer as well.
rotor is optional. cocuyo does not depend on it: a project that depends on cocuyo fetches nothing else, and only this repository's examples and benchmarks fetch rotor.
The engine carries DNS over TLS, over QUIC and over HTTPS. Each lookup's policy, failover and cache stay as they are over UDP; what changes is the connection a query goes on.
- DNS over TLS (RFC 7858) goes over a TCP connection, with
chapulin's TLS 1.3, through
colibri's
tlsmodule, as a session behind an interface of cocuyo's. - DNS over QUIC (RFC 9250) goes over colibri's QUIC, in the module
cocuyo_quic, with chapulin's QUIC mode, through colibri'stls, as its TLS. - DNS over HTTPS (RFC 8484) goes through colibri's HTTP client, in the module
cocuyo_doh, which tries HTTP/3 over QUIC first, and HTTP/2 or HTTP/1.1 over TCP when QUIC is not answered in time. A DoH server is named by its URI template. - Every server is authenticated, strictly (RFC 8310): by the name its certificate must carry, by SPKI pins of its key, or by both. A server that cannot be authenticated is not asked.
- cocuyo's library depends on none of them. The engine speaks TLS through a session interface,
which this repository fills with chapulin's session in colibri's
tls; QUIC throughcocuyo_quic, whosequicimport a consumer that speaks DoQ binds to colibri's; and HTTPS throughcocuyo_doh, whoseclientandtlsimports a consumer that speaks DoH binds to colibri's.
They are checked every day against public resolvers: Google, Cloudflare and Quad9 over TLS, AdGuard and NextDNS over QUIC, Google and Cloudflare over HTTPS. Each resolves twice, the second time over a connection that offers the first one's ticket, and refuses a name its certificate does not carry and a root its chain does not end at. Over TLS and QUIC a server known by its key alone resolves, and a wrong pin is refused, and each is asked for AAAA, MX, TXT and HTTPS records as well as A. Every transport, plain DNS over UDP and TCP among them, also runs every day against AdGuard's dnsproxy on the loopback, an implementation written elsewhere, for addresses and for records of those four types and CNAME.
Add cocuyo to your package, pinned to a release:
zig fetch --save "git+https://github.com/c4milo/cocuyo?ref=v0.5.0"Import its module in build.zig. The library is one module, cocuyo, and fetches nothing else
when it is a dependency. The engine over rotor is a second, cocuyo_rotor, whose rotor import
your build binds to your rotor. Ask rotor for its release build as you ask cocuyo: a rotor asked
for no mode builds Debug, whatever mode your program builds in.
const cocuyo = b.dependency("cocuyo", .{ .target = target, .release = true });
exe.root_module.addImport("cocuyo", cocuyo.module("cocuyo"));
// Only for the engine, over a rotor your build already depends on.
const engine = cocuyo.module("cocuyo_rotor");
const rotor = b.dependency("rotor", .{ .target = target, .release = true });
engine.addImport("rotor", rotor.module("rotor"));
exe.root_module.addImport("cocuyo_rotor", engine);Then give it its memory and ask it for its first action. config, seed and now_ns are yours:
a Config from cocuyo.resolv_conf.parse or built by hand, a seed from a secure random source, and
a monotonic clock in nanoseconds.
const cocuyo = @import("cocuyo");
var slots: [64]cocuyo.Slot = @splat(.{});
var keys: [256]cocuyo.MatchKey = @splat(.{});
var entries: [1024]cocuyo.cache.Slot = @splat(cocuyo.cache.Slot.empty);
var index: [2048]cocuyo.cache.Key = @splat(.{});
var store = cocuyo.Cache.init(&entries, &index, seed, cocuyo.cache.constants.ttl_seconds_max_default);
var table = cocuyo.Resolver.init(&slots, &keys, &config, seed);
table.remember_with(cocuyo.remembered_by(&store)); // optional: the cache under every lookup
_ = try table.start(try cocuyo.Question.from_text("example.com.", .a));
var query: [cocuyo.constants.query_bytes_max]u8 = undefined;
const event = table.poll(now_ns, &query).?; // event.action is .send_udp: the bytes, and whereexamples/ holds complete programs that resolve real names against real servers: one
lookup over a blocking UDP socket, the same over rotor, and the
engine over plain DNS, DNS over TLS, over QUIC and over HTTPS. The
build and test table has the command for each.
The seed must come from a cryptographically secure random source, never from the clock. cocuyo draws the transaction id, the source-port hint and the DNS-0x20 case pattern from it. A guessable seed makes a guessable query, and a guessable query can be spoofed.
cocuyo parses unauthenticated input from the network. A response is considered only if all of these hold, checked in this order:
- Its length is between the 12-octet header and the largest message allowed.
- Its transaction id is the one sent.
- It came from the address and port the query went to.
- It is a response to a standard query.
- Its question is byte-identical to the one sent, letter case included.
- Its DNS cookie matches, when the query carried one (RFC 7873).
Only then is the answer read. Three sources of entropy defend each query against spoofing: a 16-bit transaction id, the random letter case of DNS-0x20, and a source port. cocuyo owns no socket, so it cannot bind a port; it suggests one. A caller that sends every query from one socket keeps the id and case entropy and loses the port entropy.
The parser checks every length against the end of the message before reading, never recurses, and never trusts a count field: a sender's claim of how many records follow is checked, not obeyed. A datagram that does not match never disturbs the lookup waiting for the real one.
Every check has a test, and every test is proved by breaking the check on purpose and confirming the
test fails. docs/mutations.md records each mutation and the test that caught
it.
Every claim above has a check behind it, and each check is itself broken on purpose to show it catches what it should.
- Proofs of the lookup. The state machine of design §5 is a Lean 4 model with proofs: a lookup
that has ended stays ended, under
use_tcpno event leads to a datagram, a server that refused never turns into a timeout, and no sequence of answers makes a lookup send forever. Every transition the model reaches is replayed against the Zig code.spec/README.mdlists the theorems and the axioms each one rests on. - A model of the engine. The engine's rules are a TLA+ model. TLC checks its invariants over every state of small configurations and rejects each mutant of the model, and 24,000 of TLC's walks are replayed against the engine on a deterministic twin of rotor.
- Mutation. Every check has a test that fails when the check is broken.
docs/mutations.mdrecords each mutation and what caught it. - Fuzzing. The codec's fuzz target writes a well-formed record of every type, whole or with one octet changed, and a record written whole must be read whole.
- Other implementations. Every record of 66 responses real servers sent is compared with dnslib's reading of it. The search-list walk was compared on the wire with glibc, musl and c-ares. Every transport runs each day against AdGuard's dnsproxy on the loopback, and the encrypted ones against public resolvers.
- Threads. Two engines on two threads of one image run under ThreadSanitizer in CI.
- Coverage. kcov runs every module's tests in CI, and the badge above is the share of the
library's and the engine's lines they run, test code left out (
zig build coverage).
Measured on 2026-09-22 on an Apple M1 Pro with 32 GiB under macOS 26.6.2, Zig 0.16.0, ReleaseSafe
with assertions on. docs/design.md §11 has the method and every row.
Per operation, cocuyo against c-ares 1.34.8's shipping build, in nanoseconds:
| Operation | cocuyo | c-ares |
|---|---|---|
Build a query for example.com with EDNS0 |
7.7 | 958.4 |
| Parse a response with one A record | 49.3 | 693.4 |
| Parse a CNAME and its A record | 185.1 | 1,084.1 |
| Parse 17 A records | 583.5 | 4,157.7 |
The difference is the record model, not the arithmetic: c-ares builds and frees heap objects per message, and cocuyo copies into memory you sized once. Against a network round trip of a millisecond, neither cost is one a user would notice.
End to end, both stacks resolve 20,000 distinct names against one responder on the loopback, cocuyo through its event-loop engine and c-ares through its own event thread:
| In flight | cocuyo lookups/s | c-ares lookups/s | cocuyo p99 | c-ares p99 |
|---|---|---|---|---|
| 1 | 45,199 | 36,304 | 78 µs | 42 µs |
| 16 | 110,194 | 87,306 | 409 µs | 272 µs |
| 128 | 122,089 | 82,554 | 1,724 µs | 2,038 µs |
cocuyo does 1.24 to 1.48 times the lookups per second, with a lower median latency in every row. c-ares has the better 99th percentile at one and sixteen in flight in this table. At one in flight that was the order of the runs: cocuyo ran first and paid for the process's first 20 milliseconds, the responder's thread and the cores coming up to speed. Warm, its p99 there is 57 to 64 microseconds, and the comparison now warms up before its first row; the table stands until a quiet machine measures it again (docs/design.md §11). The two also spend memory differently: c-ares allocates a heap object for each message and each record, and cocuyo holds only the memory its caller sized at init, so what it uses cannot grow with the load.
The cache, replayed over a real ISP's DNS log of 28 million questions: at its default of 1,024
entries, a cache per client answers 49% to 60% of questions without a packet. The range is the
TTLs, which the log does not carry and the replay assigns two ways. Either way the cache is at
most 2 points under the best any eviction policy could do with the same memory, which is why it
uses SIEVE and not something more complex. The replay and its source are in
bench/README.md.
cocuyo covers what c-ares does as a DNS client: every record type it parses, its configuration options, cookies, failover, the hosts file and a cache. What it does not cover, today:
- The platform's own configuration. c-ares also reads the macOS system configuration, the
Windows registry and Android's settings. cocuyo reads
resolv.confalone. - A ready-made event loop. c-ares ships one. cocuyo's engine runs on
rotor, a loop you own, and
cocuyo_rotorjoins it to yours. - A C interface. cocuyo is a Zig library. There is no C header.
- Windows. The library does no I/O of its own, so it depends on no platform; the engine runs on macOS and Linux.
- Binding sockets to a network device by name.
These are out of scope on purpose. Each has a place it would attach if that changes.
- DNSSEC validation. EDNS0 is in place, and a question may ask for DNSKEY, DS or RRSIG, whose records are handed out as read, so a validator can sit above the library.
- mDNS and zone transfers. Neither is a stub resolver's job.
- nsswitch, NIS and internationalised domain names.
zig build testThat is the gate every change passes: the lint rules, the module-graph check, a build of a package that depends on cocuyo, and every unit test. Other steps:
| Command | What it does |
|---|---|
zig build examples |
Build the examples into zig-out/bin |
zig build example-udp-blocking -- example.com |
Resolve a name over a blocking socket |
zig build example-udp-rotor -- example.com |
Resolve a name over rotor's event loop |
zig build example-threads-rotor |
Two engines on two threads, each on its own loop |
zig build example-cleartext-rotor -- <name> <server>[:<port>] [udp | tcp] |
Resolve over plain DNS through the engine |
zig build example-dot-rotor -- ... |
Resolve over DNS over TLS |
zig build example-doq-rotor -- ... |
Resolve over DNS over QUIC |
zig build example-doh-rotor -- ... |
Resolve over DNS over HTTPS |
tools/dot_live/run.sh, tools/doq_live/run.sh, tools/doh_live/run.sh |
The live checks against public resolvers |
tools/interop/run.sh |
Every transport against dnsproxy on the loopback |
tools/dnslib/run.sh |
The codec against dnslib's reading of real responses |
zig build spec, zig build tla |
The Lean proofs and every replay, and TLC over the engine model |
zig build bench |
The microbenchmarks and the cache replays |
zig build bench-cares |
The comparison with the installed c-ares |
zig build bench-log -- <dataset.csv> |
The cache over a real DNS log |
tools/search_order/run.sh |
The search-list walk against glibc, musl and c-ares |
docs/design.md is the full design: the module graph, the state machine, the
public API, the security rules, the named limits, every measurement with its machine and date, and
the decisions with the alternatives they beat. docs/mutations.md lists every
check and the test that proves it. The RFCs cocuyo is written from are in docs/rfcs/,
unmodified.
CONTRIBUTING.md states the bar a change meets and the workflow. Report a
vulnerability privately, as SECURITY.md says, never in a public issue.
docs/landscape.md surveys the other DNS resolver libraries, with sources, and
says where cocuyo is weaker than each.
cocuyo is licensed under the Apache License, Version 2.0.