172 lines
16 KiB
Markdown
172 lines
16 KiB
Markdown
# 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.
|