16 KiB
Milestone 2: DNS Core
Goal: pure DNS wire-format module (src/dns/) — parse, encode, iterate, mutate — with unit tests, malformed-input tests, and fuzz targets. Bytes in, bytes out: no std.Io, no sockets, no allocator in the parse path.
Read first: AGENTS.md, specs/research/zig-0.16-api-notes.md, specs/milestone-1.md (module conventions). RFC 1035 is the wire-format authority; RFC 6891 for EDNS(0); RFC 7871 for ECS option layout.
Sessions
Four sessions, sequential (each builds on the previous session's types):
S1 (types/header/name) → S2 (question/record/edns) → S3 (packet) → S4 (fuzz)
The orchestrator wires src/tests.zig imports and the fuzz build step after each session verifies.
As built (S1): the encode convention is LOCKED — fixed-size items into caller buffers (header.encode(h, out: *[12]u8)), variable-size through *std.Io.Writer (name.encode(n, w)); S3's ResponseBuilder wraps its buffer via std.Io.Writer.fixed and yields w.buffered(). name.parse returns a named Parsed { name, end }; end is always past the name at the original location (past the first pointer when compressed) — advance section walks by it unconditionally. Name.wire() returns the wire bytes. Reserved label types (bit patterns 01/10) surface as LabelTooLong. Pointer loops are impossible by construction (strictly-decreasing targets) — loops yield BadPointer; TooManyJumps fires on legal descending chains > 32.
As built (S2): extendedRcode takes types.Rcode (not u4). parseOpt validates the whole option list (error.BadOption) so an OptRecord from it always iterates cleanly; findOption(packet, opt, code) added. rdataCname accepts cname/ns/ptr. record.ParseError == name.ParseError (rdlength overrun = Truncated). record.encode/encodeOpt guard 16-bit length fields (RdataTooLong/OptionsTooLong). encodeOpt writes the root owner name itself and clears Z bits. RdataSpan.slice(packet) yields raw rdata; typed accessors need the whole packet. parseOpt rejects non-root owner / wrong type with error.NotOpt.
As built (S3, amended by review round 1): packet.ParseError = error{Truncated} || WalkError where WalkError = error{BadName, SectionOverrun, TrailingBytes, MultipleOptRecords} — Truncated means < 12 header bytes (silent drop); every WalkError member means FORMERR (name-level Truncated maps to SectionOverrun so the drop-vs-FORMERR branch is just error.Truncated vs else). parse consumes the buffer exactly (no trailing bytes) and permits at most one OPT per message (RFC 6891 §6.1.1, counted across all record sections); findOptRecord's last-OPT-wins walk matters only for hand-assembled Packets. decrementTtls returns ParseError!?u32 (null = no meaningful TTL, e.g. OPT-only); it validates fully BEFORE mutating, so malformed input leaves the buffer byte-identical — but a packet whose owner name compression-points into another record's TTL bytes can still fail mid-walk after aging changed those bytes; on ANY error the caller must discard the buffer (milestone 3's cache hit path must honor this — it ages private copies, so discard = drop the copy and treat as miss). A pointer-blind record-skip helper in record.zig would make that case atomic; deliberately not built until a consumer needs it. Iterators position lazily; constructors are infallible, errors surface from next(). firstQuestion/findOptRecord return plain ?T (validated-view invariant). addAnswer takes types.Class; ResponseBuilder asserts buf ≤ 65535 and no addAnswer after addOptEcho; setAuthoritative exists for later local records. NOTE for milestone 3+: ResponseBuilder has no addAuthority yet — negative-cache SOA synthesis needs a helper slotted between addAnswer and addOptEcho (section order).
Design invariants (all sessions)
- Parse functions take
[]const u8(+ offset where needed) and return typed values orerror.Malformed-family errors. No allocation during parse. Encode functions write through*std.Io.Writeror into caller-provided buffers — pick ONE convention in S1 (recommended: caller buffer + returned slice for fixed-size items,*std.Io.Writerfor variable-size composition) and every later session follows it. - Every parse is bounds-checked; no
@intCast/@truncateon attacker-controlled values without prior range checks. - All limits are named constants in
types.zig(max name length 255, max label 63, max compression jumps, max packet size handled). - Unit tests live in-file. Every parser gets malformed-input tests, not just happy-path.
Session S1: dns/types.zig, dns/header.zig, dns/name.zig
S1.1 types.zig
pub const Type = enum(u16) { a = 1, ns = 2, cname = 5, soa = 6, ptr = 12, mx = 15, txt = 16, aaaa = 28, srv = 33, opt = 41, https = 65, ... , _ }— non-exhaustive enum; include at least the listed values plussvcb = 64,any = 255. Same pattern forClass = enum(u16) { in = 1, ch = 3, hs = 4, any = 255, _ }.pub const Rcode = enum(u4) { no_error = 0, form_err = 1, serv_fail = 2, nx_domain = 3, not_imp = 4, refused = 5, ... , _ }(4-bit header field; extended RCODE handled in edns.zig).pub const Opcode = enum(u4) { query = 0, iquery = 1, status = 2, notify = 4, update = 5, _ }.- Limits:
max_name_len = 255,max_label_len = 63,max_compression_jumps = 32,max_udp_payload = 512(pre-EDNS default),header_len = 12.
S1.2 header.zig
pub const Header = struct { id: u16, flags: Flags, qdcount: u16, ancount: u16, nscount: u16, arcount: u16 }withFlags = packed structor explicit bit accessors for: QR, Opcode, AA, TC, RD, RA, Z (must parse, re-encode as-is), RCODE. Choose the representation that round-trips unknown Z bits faithfully.parse(bytes: []const u8) error{Truncated}!Header(first 12 bytes, big-endian),encode(h: Header, out: *[12]u8) void.- Test: round-trip all flag bits including Z; truncated input.
S1.3 name.zig
pub const Name = struct { bytes: [255]u8, len: u8 }— decoded, uncompressed wire form (length-prefixed labels, terminating zero counted inlen).parse(packet: []const u8, offset: usize) ParseError!struct { name: Name, end: usize }:- follows compression pointers; each pointer must target an offset strictly LOWER than the offset of the pointer byte itself (kills loops and forward references); additionally cap at
max_compression_jumps; endis the offset after the name in the original location (first pointer or terminating zero);- errors:
error{Truncated, LabelTooLong, NameTooLong, BadPointer, TooManyJumps}.
- follows compression pointers; each pointer must target an offset strictly LOWER than the offset of the pointer byte itself (kills loops and forward references); additionally cap at
encode(name: Name, w: *std.Io.Writer) !void— always uncompressed output (valid per RFC 1035; compression on encode is out of scope, document why in a comment: simplicity beats bytes at household scale).- Helpers:
fromText("example.com.") error{...}!Name(accepts with/without trailing dot; rejects empty labels except root, oversize labels/names);formatText(dots, no trailing dot except root);eqlIgnoreCase(a, b) bool(DNS names compare case-insensitively, RFC 1035 §2.3.3 — ASCII only);labelCount,isRoot. - Tests: round-trip; pointer chain decode (hand-built packet); pointer loop →
BadPointer/TooManyJumps; forward pointer →BadPointer; label 64 →LabelTooLong; total > 255 →NameTooLong; truncation mid-label; case-insensitive eql.
S1.4 Acceptance Criteria
zig test src/dns/types.zig,zig test src/dns/header.zig,zig test src/dns/name.zigall pass.- Every listed malformed case has a test.
zig fmt --checkclean on the three files.
Session S2: dns/question.zig, dns/record.zig, dns/edns.zig
S2.1 question.zig
pub const Question = struct { name: name.Name, qtype: types.Type, qclass: types.Class }.parse(packet: []const u8, offset: usize) !struct { question: Question, end: usize };encode(q, w) !void.
S2.2 record.zig
pub const Record = struct { name: name.Name, rtype: types.Type, class: u16, ttl: u32, rdata: RdataSpan }whereRdataSpan = struct { offset: usize, len: usize }— RDATA stays a span into the original packet (no copy; names inside RDATA may use compression pointers into the whole packet, so the span alone is not decodable out of context — document this).parse(packet, offset) !struct { record: Record, end: usize }— bounds-check rdlength against packet length.- Typed RDATA accessors (take the whole packet + the record):
rdataA(packet, rec) ![4]u8,rdataAaaa ![16]u8,rdataCname(packet, rec) !name.Name(decompresses),rdataSoaMinimumTtl(packet, rec) !u32(walks MNAME/RNAME — both possibly compressed — then reads the 5th fixed u32; needed for negative caching §PLAN 8). encode(rec: Record, rdata_bytes: []const u8, w) !void— writes name uncompressed + fixed fields + rdlength + raw rdata (encoding path is for synthesized records whose rdata contains no compression).- Tests: A/AAAA/CNAME/SOA parse from hand-built packets (with compressed names inside RDATA), rdlength overrun → error, SOA minimum extraction.
S2.3 edns.zig
pub const OptRecord = struct { udp_payload_size: u16, extended_rcode: u8, version: u8, do_bit: bool, options: RdataSpan }.parseOpt(packet, record.Record) !OptRecord(OPT reinterprets class=udp size, ttl=flags per RFC 6891);findOpt(packet, additional-section iterator) ?— S3 provides the iterator, so here justparseOpt.- Option iterator over the options span:
OptionIteratoryieldingstruct { code: u16, data: []const u8 }; ECS option code = 8;pub const Ecs = struct { family: u16, source_prefix: u8, scope_prefix: u8, address: []const u8 }+parseEcs(data) !Ecs. encodeOpt(opt: OptRecord, options_bytes: []const u8, w) !void— for building queries/responses with EDNS (DO-bit passthrough).- Extended RCODE composition helper:
extendedRcode(header_rcode: u4, opt: ?OptRecord) u12. - Tests: OPT parse round-trip, DO bit both states, ECS v4+v6 parse, malformed option length → error.
S2.4 Acceptance Criteria
zig testpasses per file; malformed cases covered;zig fmt --checkclean.
Session S3: dns/packet.zig
S3.1 API
pub const Packet = struct { bytes: []const u8, header: header.Header }— a validated view.parse(bytes: []const u8) !Packet— validates header + walks all four sections once to verify structural integrity (every name/record bounds-checked); stores nothing but the bytes + header. Errors distinguisherror.Truncated(severely truncated: less than a full header — the server will silently drop) from other malformed errors (server responds FORMERR) — this distinction is the PLAN §6.1 contract, document it here.- Section iterators:
questions(p) QuestionIterator,answers(p) RecordIterator,authorities(p),additionals(p)— lazy, re-walk on demand;firstQuestion(p) ?Question. findOptRecord(p) ?record.Record— last OPT in additionals per RFC 6891.- Mutation helpers on raw mutable buffers (for the cache hit path, PLAN §8):
setId(bytes: []u8, id: u16) void.decrementTtls(bytes: []u8, elapsed_seconds: u32) error{...}!void— walks answer/authority/additional records in place, saturating-subtracts elapsed from each TTL (not OPT records — their TTL field is flags). Returns the minimum resulting TTL so the caller can treat ≤0 as a miss.
- Response builder for synthesized replies (used later by blocking + local records; pure encoding, no policy):
pub const ResponseBuilder = struct { ... }writing into a caller buffer:init(buf, request_header, question)(copies id, RD; sets QR, sets RA),setRcode,addAnswer(name, type, class, ttl, rdata),addOptEcho(request opt, do_bit),finish() []u8(patches counts). Encoding only — which rcode/answer to use is the caller's policy.
- Tests: parse a real captured query + response (hand-encoded fixtures in-file); iterators yield expected records; structural validation rejects: count fields larger than actual records, record overruns, question name loops; setId; decrementTtls including saturation and OPT skip; ResponseBuilder round-trips through
parse.
S3.2 Acceptance Criteria
zig test src/dns/packet.zigpasses;zig fmt --checkclean.- A packet built by ResponseBuilder re-parses cleanly and its TTLs decrement correctly.
Session S4: fuzz targets
S4.1 tests/fuzz/dns_fuzz.zig
- Fuzz tests using
std.testing.fuzz(0.16 Smith API — see API notes; read lib/std/testing/Smith.zig and the build_runner fuzz flags before writing):packet.parseon arbitrary bytes — must never panic/overflow; errors are fine. Property when parse succeeds: all iterators complete without error.name.parseon arbitrary bytes at arbitrary valid offsets — same property; additionallyfromText(formatText(n))round-trips when parse succeeds.decrementTtlson mutated valid packets — never panics.
- Seed corpus: a handful of valid query/response byte strings (reuse S3's fixtures via a shared
tests/fuzz/corpus.zigor inline constants) plus known-nasty cases (pointer loop, deep pointer chain, rdlength overrun). - The fuzz file must also pass as a normal test (corpus replay under plain
zig build test). - Investigate and REPORT (do not guess): exact
zig build test --fuzzsemantics in 0.16 (time-bounded? forever?), so the orchestrator can wire a bounded CI smoke step.
S4.2 Acceptance Criteria
- Fuzz file passes under plain test run.
- A bounded local fuzz run (document the exact command) executes without crashes for ≥ 60 seconds.
zig fmt --checkclean.
Module Layout
src/dns/types.zig S1 enums + limits
src/dns/header.zig S1 12-byte header
src/dns/name.zig S1 labels, compression decode, text conversion
src/dns/question.zig S2
src/dns/record.zig S2 RR + typed RDATA accessors
src/dns/edns.zig S2 OPT, DO bit, ECS
src/dns/packet.zig S3 validated view, iterators, mutation, ResponseBuilder
tests/fuzz/dns_fuzz.zig S4
File Ownership
Sequential sessions; each owns only its listed files. Nobody edits build.zig/src/tests.zig (orchestrator wires: tests.zig imports per session; fuzz step after S4).
Acceptance Criteria (Milestone 2 Complete)
zig build testgreen with all dns files wired into the aggregator.zig build test -Dintegrationand-Dintegration -Dliveremain green (no regressions).- Bounded fuzz smoke runs clean locally; CI gets a bounded fuzz job only if runner semantics allow bounding (else fuzz stays local + documented).
As built (S4): plain zig build test replays the seed corpus deterministically (test_runner.zig:596) — that IS the CI fuzz smoke. --fuzz=<n> counts iterations per test (no time bound exists; bare --fuzz = forever + webui; --fuzz=<n> conflicts with --webui). Corpus entries are Smith byte streams (u32-LE length-prefixed slices), encoded by tests/fuzz/corpus.zig. Two upstream 0.16.0 defects block zig build test -Dfuzz --fuzz=<n> on stock installs: (A) fuzz-mode test_runner.zig:566 type mismatch (*builtin.StackTrace vs *const std.debug.StackTrace) — needs a patched --zig-lib-dir; (B) self-hosted x86_64 backend emits no sanitizer coverage PCs — needs .use_llvm = true, which -Dfuzz sets. Verified bounded run: --fuzz=6M, 18M runs, 70.6s, no findings. Wiring: src/dns/dns.zig module root + second test artifact fuzz importing dns as a named module (zig test collects tests only from the root module; files belong to one module per compilation). Known measured limit: coverage guidance did not reach a planted record-type-specific panic in 6M runs — the targets are a crash detector + corpus regression suite, not semantic-reach proof. The name round-trip property is conditional: wire labels may contain '.' bytes, which formatText/fromText cannot round-trip.
zig fmt --checkclean repo-wide; signed lowercase commits.
Anti-Requirements
- No sockets, no std.Io, no allocator in parse paths, no clocks.
- No blocking/filtering policy — ResponseBuilder is mechanism only.
- No name compression on encode.
- No DNSSEC record parsing (DO-bit passthrough only), no TSIG, no zone-transfer opcodes beyond enum values.
- No IDN/punycode handling (bytes pass through opaquely).
- Do not touch milestone-1 files.