Files
nxdns/specs/milestone-2.md

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 or error.Malformed-family errors. No allocation during parse. Encode functions write through *std.Io.Writer or into caller-provided buffers — pick ONE convention in S1 (recommended: caller buffer + returned slice for fixed-size items, *std.Io.Writer for variable-size composition) and every later session follows it.
  • Every parse is bounds-checked; no @intCast/@truncate on 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 plus svcb = 64, any = 255. Same pattern for Class = 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 } with Flags = packed struct or 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 in len).
  • 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;
    • end is the offset after the name in the original location (first pointer or terminating zero);
    • errors: error{Truncated, LabelTooLong, NameTooLong, BadPointer, TooManyJumps}.
  • 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.zig all pass.
  • Every listed malformed case has a test.
  • zig fmt --check clean 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 } where RdataSpan = 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 just parseOpt.
  • Option iterator over the options span: OptionIterator yielding struct { 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 test passes per file; malformed cases covered; zig fmt --check clean.

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 distinguish error.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.zig passes; zig fmt --check clean.
  • 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):
    1. packet.parse on arbitrary bytes — must never panic/overflow; errors are fine. Property when parse succeeds: all iterators complete without error.
    2. name.parse on arbitrary bytes at arbitrary valid offsets — same property; additionally fromText(formatText(n)) round-trips when parse succeeds.
    3. decrementTtls on 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.zig or 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 --fuzz semantics 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 --check clean.

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 test green with all dns files wired into the aggregator.
  • zig build test -Dintegration and -Dintegration -Dlive remain 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 --check clean 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.