diff --git a/specs/milestone-2.md b/specs/milestone-2.md new file mode 100644 index 0000000..0806a0b --- /dev/null +++ b/specs/milestone-2.md @@ -0,0 +1,171 @@ +# 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=` counts iterations per test (no time bound exists; bare `--fuzz` = forever + webui; `--fuzz=` 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=` 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. diff --git a/specs/research/zig-0.16-api-notes.md b/specs/research/zig-0.16-api-notes.md index d07cf35..0bce6c0 100644 --- a/specs/research/zig-0.16-api-notes.md +++ b/specs/research/zig-0.16-api-notes.md @@ -136,6 +136,26 @@ changed most of these APIs. - No MIME table, no etag: set `content-type`/`cache-control` manually via extra_headers. BodyWriter supports sendFile (contentLengthSendFile/chunkedSendFile). +## std.testing.fuzz (0.16) + +- `std.testing.fuzz(context, testOne, options)` where `testOne: fn(ctx, smith: *std.testing.Smith) anyerror!void` — + the input is a `*Smith` structured generator, NOT a raw `[]const u8` slice (pre-0.16 form is gone). +- `FuzzInputOptions{ .corpus: []const []const u8 }` seeds the corpus. +- Smith surface (lib/std/testing/Smith.zig): `valueWithHash`, `valueRangeAtMostWithHash`, + `bytesWithHash`, `sliceWithHash(buf, hash) u32` (fills buf, returns length), `eosWithHash`, etc. + For raw-bytes parsers: fill a buffer via `sliceWithHash` and feed the prefix to the parser. +- Plain `zig build test` replays each corpus entry once + one empty input (deterministic). + `--fuzz=` = iterations PER TEST (K/M/G suffixes); bare `--fuzz` = forever + webui; + no time bound exists; `--fuzz=` conflicts with `--webui`. +- Corpus entries are Smith byte streams (slice = u32-LE length + bytes), not raw inputs. +- STOCK 0.16.0 CANNOT RUN `--fuzz`: (A) fuzz-mode test_runner.zig:566 has a type error + (needs patched --zig-lib-dir); (B) the self-hosted x86_64 backend emits no coverage + PCs — set `.use_llvm = true` on the fuzz test artifact. +- `zig test` collects tests ONLY from the root module — moving files into a named module + silently drops their tests. A file belongs to exactly one module per compilation, and + imports cannot escape the module root directory. Hence: aggregator imports files + directly; separate artifacts import a module-root file (e.g. src/dns/dns.zig). + ## std.zon - Parse: `std.zon.parse.fromSlice(T, gpa, src_z, ?*Diagnostics, .{})` for pointer-free T