95 KiB
Milestone 5: Filtering + Local DNS
Goal (PLAN §16 Phase 5): the rule matcher (exact / parent-walk / wildcard); blocklist parsers and the compiled file format; the fetcher and its scheduled update; the RCU snapshot swap; per-group safe-search; blocked-response synthesis; local records; forward zones. Exit: the §3.10 precedence table is validated by tests; local zone answers and conditional forwards work.
Read first: AGENTS.md (values), specs/research/zig-0.16-api-notes.md (verified stdlib facts —
pre-0.16 knowledge is stale and MUST NOT be used), specs/milestone-1.md, specs/milestone-2.md,
specs/milestone-3.md, specs/milestone-4.md (module conventions, the "As built" notes, and the
binding logging policy restated below). The Zig source of truth is /home/mokhtar/app/zig at tag
0.16.0. PLAN §2.2, §3.8–§3.10, §5, §6.2, §6.4, §6.5, §7, §11.2, §12, §18 are the scope authority.
What already exists (do not respecify, import it)
src/dns/*— pure wire format. Used here:name.Name,name.fromText,name.formatText,name.eqlIgnoreCase,types.Type,types.Class,types.Rcode,types.max_name_len,question.Question,header.Header,edns.OptRecord,packet.ResponseBuilder(init,setRcode,addAnswer,addOptEcho,finish). Do not write a second name parser and do not add anything tosrc/dns/.src/platform/address.zig—NetAddress.parse/format/key/eql,Prefix.parse/contains,matchLongest. Group assignment by client IP uses these; do not write a second IP parser.src/config/model.zig—Config,Group,Rule,RuleKind,RuleAction,BlocklistSource,GroupSource,LocalRecord,RecordType,ForwardZone,Client,ClientPrefix,Blocking,BlockResponse,BlocklistUpdate,updateIntervalSeconds.src/config/validate.zig—parseResolver(text) ResolverError!Resolver,Resolver,ResolverScheme. Its doc comment already names this phase as the importer.local/forward_zones.zigimports it; nobody writes a second resolver-URL parser.src/storage/repositories/*—listGroups/freeGroups,listGroupSources/freeGroupSources,listRules/freeRules,listBlocklistSources/freeBlocklistSources,listLocalRecords,listForwardZones,listClients,listClientPrefixes, and thecount*functions. Every list returnsstd.ArrayList(model.X)with heap-owned strings and a matchingfreeX. The repositories gain no new functions in this milestone except the two named in S8.1, which are additive and owned by S8.src/storage/db.zig—Db,Stmt,Tx,Error.db.zigtakes nostd.Io(milestone 4's documented exception); everything else that touches the filesystem takesio: std.Io.src/upstream/transport.zig—Client(theexchangeFnvtable),ExchangeError,PeerFault,LocalResource,mapLocal,group,validateResponse,max_message_len. The forward-zone client implements this interface; it does not invent a second one.build.zig—-Dintegration(hermetic, loopback/tmpdir only, PR-blocking) and-Dlive(leaves the machine, manual only) reach test files through@import("build_options"). A second test artifact carries the fuzz targets withdnsas a named module.
Sessions
S1 (parsers + wildcard) S2 (domain_set + compiler) S3 (safesearch + response)
S4 (local records + forward_zones) S6 (fetcher) S7 (forward_client) [all parallel, no deps]
| |
+---------+----------+
v
S5 (rules + matcher) [needs S1, S2]
v
S8 (manager) [needs S2, S5, S6]
v
S9 (integration + fuzz) [needs everything]
Six sessions start together. Every later session is written against this spec, not against the
previous session's source. The orchestrator — not any session — wires src/tests.zig imports and
every build.zig change. A session that needs a build change reports the exact change in its
completion report; the one this milestone needs is stated in S9.3.
Session verification protocol (read this before starting)
Unchanged from milestone 4, and it still binds:
zig test <file>does not work here: every file imports acrosssrc/subdirectories or links againstsqlite3. Each session verifies its own work withzig fmt --check <its files>andzig ast-check <its files>.zig ast-checkreports only syntax and AST-level errors; it does not type-check, so it cannot prove the code compiles.- The orchestrator wires the files into
src/tests.zigand runszig build test(andzig build test -Dintegrationfor S9). That run is the real gate. - Every session states in its completion report that its tests have not been executed, and lists the exact test names it wrote so the orchestrator can confirm they ran.
- No session edits
build.zig,build.zig.zon, orsrc/tests.zig. No session edits a milestone 1–4 file; a needed change there is reported, not made.
Design invariants (all sessions)
- The pure core stays pure.
filter/parsers.zig,parser_hosts.zig,parser_domains.zig,parser_abp.zig,wildcard.zig,domain_set.zig,compiler.zig,rules.zig,matcher.zig,safesearch.zig,response.zig,local/records.zigandlocal/forward_zones.zigtake nostd.Iovalue. They take bytes, structs, an allocator, and*std.Io.Reader/*std.Io.Writerinterfaces — which are notIoand carry no backend. Clocks, entropy, sockets and files arrive as parameters or do not arrive at all. std.Iolives in exactly three files:filter/fetcher.zig,filter/manager.zig,local/forward_client.zig. PLAN §5 marksfilter/"pure" and then listsfetcher.zigandcompiler.ziginside it; the split above keeps that promise where it matters — the compiler is pure over reader/writer interfaces, and only the fetcher and the manager touch the network and the filesystem.- Logging policy (binding, repo-wide, milestone 4 "As built"): a condition that is returned as a
typed error logs at
warnat most.erris reserved for failures the code swallows. The zig test runner fails any test that emitserrlogs. In this milestone the only legitimateerrsite is a background refresh task that has nowhere to return its failure — and even that logs atwarn, because the failure is recorded inSourceStatusand surfaced (S8.5). Nostd.log.errcall is written in this milestone. - Every failure mode is counted, not dropped (AGENTS.md). A skipped regex line, an unsupported
ABP modifier, an over-long line, a single-label name, an unparseable name, a failed download and a
checksum mismatch each increment a named counter that reaches
SourceStatusand (for the three columns that exist)blocklist_sources. - No regex, ever (PLAN §2.2). Regex lines are recognized, counted, skipped. No engine, no dependency, no "just a small subset".
- Nothing in this milestone allocates on the query path.
Snapshot.evaluateperforms no allocation, opens no file, and takes no lock beyond the reader lock its caller already holds. It is callable from astd.Iotask with a stack buffer and nothing else. - Determinism: a compiled list body is a pure function of the downloaded bytes and the format. See the determinism contract in §Determinism below; S2 and S9 both test it.
- Unit tests live in-file. Tests that touch the filesystem, loopback sockets or real HTTP live in
src/filter/filter_integration_test.zig, guarded byif (!build_options.integration) return error.SkipZigTest;. Tests that leave the machine are guarded bybuild_options.live. Mirrors milestones 1, 3 and 4.
Resolved PLAN ambiguities (read before writing code)
These were decided while writing this spec. They are the ruling for this milestone; the orchestrator carries any of them that change PLAN text back into PLAN.
- Blocklist domain entries match exactly; only rules parent-walk. PLAN §7.1 builds a candidate
chain and then consults rules (step 3), blocklist domains (step 4) and blocklist wildcards
(step 5). If blocklist domains were also matched against the whole chain, §3.10's separate
"blocklist wildcards" level would be unreachable and one
com-shaped line in a bad list would black-hole the internet. §3.9 ties parent-walk to the rule model ("parent-walk (implicit via candidate chain)"). Ruling: exact and wildcard rules are evaluated against every candidate in the chain; blocklist.listentries are matched against the query name only; blocklist.wildentries are matched against every proper parent of the query name (that is what*.x.ymeans). ABP||x.y^therefore emits both a.listentryx.yand a.wildentryx.y, which together give the "domain and all subdomains" semantics the syntax promises. *matches one or more labels. PLAN §3.9 gives both*.doubleclick.netandads.*.example.com. A one-label-only*would make the first pattern missa.b.doubleclick.net, which is not what an operator writing it means. One rule for both positions: each*label matches one or more labels. Bounded by the 128-label ceiling a 255-byte name implies, so the backtracking match cannot blow up.evaluatetakes no qtype. PLAN §7.1 keys evaluation on{domain, qtype, group_id}but no step in §7 or §3.10 reads the qtype. Ruling: the filtering decision is{domain, group}; the qtype travels with the query for logging and for response synthesis, not for matching. Adding an unused parameter would be generality nobody asked for (AGENTS.md).- Group assignment by client IP is in scope; auto-materialization is not. PLAN §7.2 belongs to
the filtering engine this phase builds, and the matching half is pure (
clientsexact match →client_prefixeslongest-prefix →default). The half that writes — inserting the unseen client row, updatinglast_seen, retention ofhand_edited = 0rows — needs a clock and a database write on the query path and belongs with the handler pipeline (Phase 7) and retention (Phase 6).Snapshot.groupForClientis built here; nothing inserts a row. - A plain UDP/TCP resolver client is in scope. PLAN Phase 5's exit criterion says conditional
forwards work, and §6.5 permits plain transports. No such client exists:
transport.Endpointknows onlyhttps://andtls://, by design. Ruling:src/local/forward_client.zigimplementstransport.Clientover plain UDP with TCP fallback, configured fromvalidate.Resolverrather than fromtransport.Endpoint, so no milestone-3 file is edited. Wiring it into the query path is Phase 7. - Compressed transfer encoding is out of scope.
std.http.Clientadvertises gzip and deflate by default (verified, Client.zig:831) andResponse.readerDecompressingwould be needed to read them. The fetcher overridesaccept_encodingto identity, exactly asdoh_client.zigdoes. A daily download of a few megabytes does not pay for a decompression buffer and a second failure surface. A source URL that serves a.gzbody (content, not encoding) fails the compile with a high invalid-line count and a visible error, rather than silently compiling to nothing.
Determinism
For a given input byte stream and format, compiler.compile must produce byte-identical .list and
.wild bodies on every run, on both target architectures. Concretely:
- Names are lowercased over ASCII only (
A–Z); a byte ≥ 0x80 makes the candidate invalid. - A single trailing dot is stripped; the name is stored without it.
- Entries are sorted ascending by
std.mem.order(u8, …)over the full name and deduplicated. - Every line ends with a single
\n, including the last. An empty body is zero bytes. - The body carries no header, no timestamp, no hostname, no counts. The manager writes the header (S8.3); the checksum covers the two bodies only.
- Sorting is by byte value, not by locale, not by label. Two runs of the same input in different input order (S9 shuffles a fixture) produce the same body.
The snapshot built from those files is deterministic in content but not in hash-table layout: the
open-addressing seed is per-snapshot randomness (S2.1). contains results do not depend on it, and
that is the property tests assert.
Memory budget (PLAN §18: < 100 MB with ~1M blocked domains)
The compiled in-memory structure is an exact-match set — a flat arena of length-prefixed
lowercase names plus an open-addressed table of u32 offsets into it. No Bloom filter, no
hash-only key set, no probabilistic structure: a false positive in a DNS sinkhole blocks a real
domain for a real household and is undebuggable from the outside. Every probe compares full bytes.
Sizing for one 1,000,000-entry source, mean name length 22 bytes:
| Part | Size |
|---|---|
arena (1 + len bytes per name) |
≈ 23 MB |
index: capacity 2^21 slots at load factor ≤ 0.75, 4 bytes per slot |
8 MiB |
| per-source struct overhead | < 1 KB |
| total, one 1M-domain source | ≈ 31 MB |
A typical household set (three lists, ~150k entries each, heavy overlap) is under 15 MB. Sets are
per source, shared between groups — a group holds indices into Snapshot.sources, so assigning
one source to four groups costs four u32s, not four copies.
Refresh peak, which is what the budget has to survive:
- Sources are compiled one at a time, each into its own arena, and that arena is freed before
the next source starts. Compile working set for a 1M-entry source: 23 MB of candidate bytes plus
4 MB of
u32offsets ≈ 27 MB. - The new snapshot is built only after every compile has finished, so compile arenas and the new snapshot never coexist.
- Peak is therefore
max(old + compile, old + new)≈ 62 MB for a 1M-domain configuration, leaving headroom under the 100 MB target for the servers, the pool and (Phase 6) the cache.
Hard caps, all of which produce a typed error and a counter rather than a slow death:
compiler.max_domains = 2_000_000 per source, fetcher.max_body_bytes = 64 * 1024 * 1024,
compiler.max_line_len = 4096, rules.max_wildcards_per_group = 4096.
Lookup cost, against PLAN §18's "blocklist lookup p95 < 1 ms": a 5-label query walks at most 5
candidates; per candidate the matcher does 2 rule-set probes plus one probe per assigned source
(.list for the full name, .wild for parents). With 5 sources that is under 40 Wyhash + memcmp
probes on structures that fit in L2 for typical list sizes. No allocation, no lock beyond the
caller's reader lock.
Verified 0.16.0 stdlib facts used by this milestone
Read from /home/mokhtar/app/zig at tag 0.16.0. Anything not listed here or in
specs/research/zig-0.16-api-notes.md must be re-verified against the source before use.
Locks — they are not in std.Thread
lib/std/Thread.zig declares no Mutex and no RwLock in 0.16.0. Both live under std.Io:
pub const RwLock = @import("Io/RwLock.zig"); // Io.zig:48
pub const init: RwLock = .{ ... }; // Io/RwLock.zig:15
pub fn lock(rl: *RwLock, io: Io) Io.Cancelable!void; // Io/RwLock.zig:51
pub fn lockUncancelable(rl: *RwLock, io: Io) void; // Io/RwLock.zig:42
pub fn unlock(rl: *RwLock, io: Io) void; // Io/RwLock.zig:70
pub fn lockShared(rl: *RwLock, io: Io) Io.Cancelable!void; // Io/RwLock.zig:115
pub fn lockSharedUncancelable(rl: *RwLock, io: Io) void; // Io/RwLock.zig:97
pub fn unlockShared(rl: *RwLock, io: Io) void; // Io/RwLock.zig:133
pub fn tryLockShared(rl: *RwLock, io: Io) bool; // Io/RwLock.zig:75
pub const Mutex = extern struct { ... }; // Io.zig:1587
Timers
pub fn sleep(io: Io, duration: Duration, clock: Clock) Cancelable!void; // Io.zig:2397
pub fn sleep(duration: Clock.Duration, io: Io) Cancelable!void; // Io.zig:900
pub fn fromSeconds(x: i64) Duration; // Io.zig:988
pub fn fromMilliseconds(x: i64) Duration; // Io.zig:984
pub fn now(clock: Clock, io: Io) Io.Timestamp; // Io.zig:778
pub fn toSeconds(t: Timestamp) i64; // Io.zig:943
There is no timer wheel and no periodic-callback API. A scheduled refresh is a task that sleeps and
loops; cancellation arrives as error.Canceled from the sleep (milestone 3 convention).
std.Io.Reader line iteration
pub fn takeDelimiter(r: *Reader, delimiter: u8) error{ ReadFailed, StreamTooLong }!?[]u8; // Reader.zig:895
pub fn discardDelimiterInclusive(r: *Reader, delimiter: u8) Error!usize; // Reader.zig:1037
pub fn discardRemaining(r: *Reader) ShortError!usize; // Reader.zig:270
takeDelimiter returns null at end of stream, excludes the delimiter, and treats end-of-stream as
a delimiter for a final unterminated line. error.StreamTooLong means the line did not fit the
reader's buffer and leaves the stream unmodified (doc comment, Reader.zig:885) — the compiler
must then discardDelimiterInclusive('\n') to step over that line and count it, or it will spin
forever on the same bytes. This is the single most likely infinite loop in this milestone.
std.http.Client — GET download flow
pub fn request(client: *Client, method, uri, options: RequestOptions) RequestError!Request;
pub fn sendBodiless(r: *Request) Writer.Error!void; // Client.zig:912
pub fn receiveHead(r: *Request, redirect_buffer: []u8) ReceiveHeadError!Response; // Client.zig:1133
pub fn reader(response: *Response, transfer_buffer: []u8) *Reader; // Client.zig:736
pub fn readerDecompressing(...) *Reader; // Client.zig:752 (NOT used, ambiguity 6)
redirect_behavior: Request.RedirectBehavior = @enumFromInt(3) // Client.zig:1654
pub const default_accept_encoding = { gzip, deflate, identity } // Client.zig:831
receiveHead follows redirects itself and needs the redirect_buffer to outlive Request.uri;
RFC 9110 recommends ≥ 8000 bytes (doc comment, Client.zig:1128). An empty buffer is legal only when
redirect_behavior = .not_allowed. Blocklist URLs redirect in practice, so the fetcher allows
redirects and supplies a real buffer. Response.reader returns compressed bytes if a compressed
encoding was negotiated — which is why the fetcher pins accept_encoding to identity.
Hashing and sorting
pub fn hash(seed: u64, input: []const u8) u64; // hash/wyhash.zig:178 → std.hash.Wyhash
pub const Sha256 = Sha2x32(iv256, 256); // crypto/sha2.zig:23
pub fn hash(b: []const u8, out: *[32]u8, options) void; // crypto/sha2.zig:108
pub fn bytesToHex(input: anytype, case: Case) [input.len * 2]u8; // fmt.zig:1156
pub fn sortUnstableContext(a: usize, b: usize, context: anytype) void; // mem.zig:648
Filesystem (unchanged from milestone 4, reproduced for the two files that need it)
pub fn createFileAtomic(dir: Dir, io: Io, sub_path: []const u8, options: CreateFileAtomicOptions)
CreateFileAtomicError!File.Atomic; // Dir.zig:1924
pub fn replace(af: *Atomic, io: Io) ReplaceError!void; // File/Atomic.zig:77
pub fn deinit(af: *Atomic, io: Io) void; // File/Atomic.zig:23
pub fn readFileAlloc(dir, io, sub_path, gpa, limit) ReadFileAllocError![]u8; // Dir.zig:1326
pub fn createDirPathStatus(dir, io, sub_path, permissions) ...!CreatePathStatus; // Dir.zig:851
pub fn deleteFile(dir, io, sub_path) DeleteFileError!void; // Dir.zig:1004
pub fn writer(file: File, io: Io, buffer: []u8) Writer; // File.zig:600, BY VALUE
Plain createDirPath hardcodes 0o777 (Dir.zig:843) and must not be used; the blocklist directory
is created with createDirPathStatus(io, path, .fromMode(0o700)).
Session S1: src/filter/parsers.zig, parser_hosts.zig, parser_domains.zig, parser_abp.zig, wildcard.zig
Pure, and std is the only import in all five files. No @import("../dns/..."), no config, no
allocator, no I/O. That constraint is not stylistic: parsers.zig is the root of the fuzz module
S9.3 adds, and a module root cannot import across its own directory boundary
(error.ImportOutsideModulePath, milestone 2). Domain validity is decided in S2's compiler
through dns.name.fromText; these files decide format.
S1.1 parsers.zig — the shared vocabulary
pub const hosts = @import("parser_hosts.zig");
pub const domains = @import("parser_domains.zig");
pub const abp = @import("parser_abp.zig");
pub const wildcard = @import("wildcard.zig");
pub const Format = enum { hosts, domains, abp };
pub const Kind = enum {
/// Nothing on the line, or only a comment.
ignore,
/// `text` holds one or more whitespace-separated candidate names.
domain,
/// `text` holds one candidate suffix; every proper subdomain of it matches.
wildcard,
/// A regex rule. Counted, skipped, never compiled (PLAN §2.2).
regex,
/// Syntactically a rule of this format, but one nxdns cannot honour:
/// an ABP modifier list, an exception rule, element hiding, a scheme anchor.
unsupported,
};
pub const Line = struct {
kind: Kind,
/// Borrowed from the caller's line. Not lowercased, not validated.
text: []const u8 = "",
/// `.wildcard` only. ABP `||x^` covers `x` itself as well as its subdomains,
/// so the compiler emits an additional `.list` entry when this is set.
covers_apex: bool = false,
};
/// Dispatches to the format's parser. The line must not contain '\n' or '\r';
/// the caller strips them.
pub fn parseLine(format: Format, line: []const u8) Line;
/// Picks a format from the first `sample_lines` lines that are not blank and
/// not comments: an ABP marker (`||`, `@@`, `##`, `$`) anywhere wins `.abp`;
/// otherwise a majority of lines whose first field parses as an IP literal
/// wins `.hosts`; otherwise `.domains`.
pub const sample_lines = 64;
pub fn detectFormat(sample: []const u8) Format;
detectFormat uses its own minimal IPv4/IPv6-literal recognizer over the first field (digits, dots,
colons and hex only) rather than NetAddress.parse — importing platform/ would break the module
constraint above, and a sniffing heuristic does not need a correct parser.
S1.2 parser_hosts.zig
pub fn parseLine(line: []const u8) parsers.Line;
- Truncate at the first
#. Trim ASCII whitespace. Empty →.ignore. - A line starting with
/→.regex(a regex smuggled into a hosts list). - Split on ASCII whitespace. If the first field looks like an IP literal and at least one field
follows, the remaining fields are the candidate names →
.domainwithtextspanning them. - If the first field does not look like an IP literal, the whole line is the candidate name(s) →
.domain. Real "hosts" lists are frequently bare domain lists with a hosts extension. - A leading
*.on any candidate is not stripped here;parsers.parseLineis per line, and the compiler splitstextand re-classifies each name (S2.3). This is the one place a.domainline can yield wildcard entries, and it is spelled out in S2.3 rather than duplicated here.
The sink address is not checked against a list of "blocking" IPs: a hosts-format blocklist that maps
to 127.0.0.1, 0.0.0.0 or :: is the same instruction to nxdns, and a source that maps to a real
address is still a source of names the operator asked to block.
S1.3 parser_domains.zig
pub fn parseLine(line: []const u8) parsers.Line;
One candidate per line. # and ! start a comment (both appear in the wild); truncate and trim.
A leading / → .regex. Everything else → .domain with the trimmed text. A line containing
whitespace after trimming → .unsupported (a domains-format list with two fields is a
mis-detected hosts file, and guessing is worse than counting).
S1.4 parser_abp.zig
pub fn parseLine(line: []const u8) parsers.Line;
| Input | Result |
|---|---|
! comment, [Adblock Plus 2.0], empty |
.ignore |
||example.com^ |
.wildcard, text = "example.com", covers_apex = true |
||example.com^ with a trailing $… modifier list |
.unsupported |
@@… (exception) |
.unsupported |
##…, #@#…, #?#… (element hiding) |
.unsupported |
|http://…, |https://… (scheme anchor) |
.unsupported |
/regex/ |
.regex |
example.com (bare) |
.domain |
anything containing *, ^ or | outside the forms above |
.unsupported |
^ is a separator token in ABP and only the trailing ^ (or a trailing ^ before a modifier) is
meaningful for a domain rule; anything else carrying ^ is .unsupported. Exception rules are
.unsupported rather than an allow entry: PLAN's allow surface is the rules table, and a list
that could quietly allow a domain across every group is a policy hole the operator did not open.
S1.5 wildcard.zig
pub const max_labels = 128;
pub const PatternError = error{
/// No label is exactly "*".
NoWildcard,
/// A label contains '*' but is not exactly "*". Partial-label globbing
/// (`ad*.example.com`) is out of scope: it is regex by another name, and
/// PLAN §3.9 defines the wildcard as a label pattern.
PartialWildcardLabel,
EmptyLabel,
LabelTooLong,
PatternTooLong,
TooManyLabels,
};
/// Syntax only. A valid pattern has at least one label that is exactly "*",
/// every other label is 1–63 bytes with no '*' inside it, and the whole
/// pattern is at most 253 bytes over at most `max_labels` labels.
pub fn validate(pattern: []const u8) PatternError!void;
/// `domain` is already normalized: lowercase, no trailing dot. `pattern` is
/// lowercase. Each "*" label matches ONE OR MORE labels (resolved ambiguity 2).
/// Allocation-free; the backtracking is bounded by `max_labels` on both sides.
pub fn matches(pattern: []const u8, domain: []const u8) bool;
Implementation shape: split both sides into label slices in two fixed [max_labels][]const u8
stack arrays, then run the classic two-pointer glob match with a single backtrack point (a *
consumes one label and may extend). No recursion, no allocation. A pattern with more than
max_labels labels cannot occur because validate rejects it and the matcher only holds validated
patterns; matches asserts the bound.
S1.6 Tests (in-file)
parsers.zig:
detectFormaton a hosts fixture, a domains fixture, an ABP fixture, and a file whose first 64 lines are all comments (→.domains).parseLinedispatches to the format's parser (one case each).
parser_hosts.zig:
0.0.0.0 ads.example.com→.domain,"ads.example.com".127.0.0.1 a.example.com b.example.com→.domainwhosetextspans both names.::1 ip6-localhost ip6-loopback→.domainwith both names (the compiler drops single-label names, S2.3 — this test asserts the parser does not silently swallow them).0.0.0.0 ads.example.com # tracker→ the comment is gone.# whole line, ``,→.ignore./ads\d+/→.regex.example.com(no IP) →.domain.
parser_domains.zig: bare name; ! comment; # comment; inline example.com # x; /re/ →
.regex; 0.0.0.0 example.com → .unsupported.
parser_abp.zig: one named test per row of the S1.4 table.
wildcard.zig:
validateaccepts*.doubleclick.netandads.*.example.com; one named test perPatternErrormember:example.com→NoWildcard,a*b.com→PartialWildcardLabel,a..b→EmptyLabel, a 64-byte label →LabelTooLong, a 300-byte pattern →PatternTooLong, a 200-label pattern →TooManyLabels.matches("*.doubleclick.net", "a.doubleclick.net")true;("*.doubleclick.net", "a.b.doubleclick.net")true (ambiguity 2);("*.doubleclick.net", "doubleclick.net")false;("ads.*.example.com", "ads.eu.example.com")true;("ads.*.example.com", "ads.eu.west.example.com")true;("ads.*.example.com", "ads.example.com")false;("*.example.com", "example.com.evil.net")false.- A pathological pattern
*.*.*.*.*.*.*.*.example.comagainst a 100-label domain terminates (assert it returns, which is the runnable form of "the backtracking is bounded").
S1.7 Acceptance criteria
zig fmt --checkandzig ast-checkclean on all five files.grep -n "@import" src/filter/parsers.zig src/filter/parser_*.zig src/filter/wildcard.zigshows onlystdand sibling files insrc/filter/.PatternErrorincludesPartialWildcardLabeland every member is produced by a named test.- No allocator, no
std.Iovalue, no clock appears in any of the five files. - Every bullet in S1.6 exists as a named test.
Session S2: src/filter/domain_set.zig, src/filter/compiler.zig
Pure: an allocator and reader/writer interfaces, no Io. compiler.zig may import
../dns/name.zig (it decides domain validity); domain_set.zig imports only std.
S2.1 domain_set.zig
pub const DomainSet = struct {
/// Length-prefixed lowercase names, back to back: [len: u8][bytes]…
arena: []const u8,
/// Open-addressed table of offsets into `arena`; `empty` marks a hole.
/// Length is always a power of two.
index: []const u32,
count: u32,
seed: u64,
pub const empty_slot: u32 = std.math.maxInt(u32);
pub const max_count: u32 = 4_000_000;
pub const max_arena_bytes: usize = 1 << 31;
pub const Error = error{ OutOfMemory, TooManyDomains, SetTooLarge, NotSorted, NotLowercase };
/// Builds from a compiled body: LF-separated, lowercase, sorted ascending,
/// deduplicated, every line 1–255 bytes. The sortedness is VERIFIED, not
/// assumed — a hand-edited or truncated file must fail loudly at load
/// (`error.NotSorted`) rather than produce a set that silently misses
/// entries. Verification is one comparison per line and costs nothing.
///
/// `seed` randomizes the hash. Query names are attacker-supplied, so a
/// fixed seed would make probe-chain flooding computable offline; the seed
/// arrives as a parameter so this file stays free of entropy sources.
pub fn build(gpa: std.mem.Allocator, body: []const u8, seed: u64) Error!DomainSet;
/// An empty set that owns nothing. `contains` on it is always false.
pub const empty: DomainSet;
pub fn deinit(self: *DomainSet, gpa: std.mem.Allocator) void;
/// `domain` must be normalized (lowercase, no trailing dot). Allocation-free.
pub fn contains(self: *const DomainSet, domain: []const u8) bool;
/// Bytes held, for the memory report in `Snapshot.memoryBytes`.
pub fn memoryBytes(self: *const DomainSet) usize;
};
Rules:
- Capacity is the smallest power of two ≥
count * 4 / 3, minimum 16. Linear probing, insertion in file order, no tombstones (the set is immutable afterbuild). - Hash is
std.hash.Wyhash.hash(self.seed, domain); the slot ishash & (index.len - 1). A probe compares the full stored bytes withstd.mem.eqlbefore reporting a hit. There is no path in which a hash collision produces a match. buildmakes exactly two allocations (arena, index) and frees both on any error.- A body of length 0 yields
empty. count > max_count→error.TooManyDomains; body longer thanmax_arena_bytes→error.SetTooLarge(the index storesu32offsets and must not silently truncate).
Tests (in-file):
- Build from a small sorted body;
containstrue for every member, false for four non-members including a prefix, a suffix, an uppercase spelling and the empty string. - A body that is not sorted →
error.NotSorted; a body with an uppercase byte →error.NotLowercase; a duplicate line →error.NotSorted(equal is not ascending, and a duplicate means the compiler broke its contract). - Two sets built from the same body with different seeds answer
containsidentically over a 50-name probe list — the property that matters, stated as a test. - 10,000 generated names round-trip;
memoryBytesis within 2× of the naivebody.lenbound. buildunderstd.testing.checkAllAllocationFailuresleaks nothing.empty.contains("x") == falseanddeinitonemptyis a no-op.
S2.2 compiler.zig — signatures
pub const max_domains: u32 = 2_000_000;
pub const max_line_len: usize = 4096;
pub const Counts = struct {
domains: u32 = 0,
wildcards: u32 = 0,
skipped_regex: u32 = 0,
skipped_unsupported: u32 = 0,
/// Not a valid domain name (`dns.name.fromText` rejected it, a non-ASCII
/// byte, or fewer than two labels).
invalid: u32 = 0,
/// Lines longer than `max_line_len`, skipped whole.
long_lines: u32 = 0,
/// Duplicates removed by the sort/unique pass.
duplicates: u32 = 0,
};
pub const Result = struct {
counts: Counts,
/// Lowercase hex sha256 over the `.list` body followed by the `.wild` body.
checksum: [64]u8,
};
pub const Error = error{ OutOfMemory, TooManyDomains, ReadFailed, WriteFailed };
/// Reads `r` to end of stream, writes the two compiled bodies. Nothing else is
/// written — headers belong to the caller (S8.3), so this function is a pure
/// function of (bytes, format) and is tested by comparing two runs.
pub fn compile(
gpa: std.mem.Allocator,
r: *std.Io.Reader,
format: parsers.Format,
list_w: *std.Io.Writer,
wild_w: *std.Io.Writer,
) Error!Result;
S2.3 compile algorithm
- Two
std.ArrayList(u8)arenas (list_bytes,wild_bytes) and twostd.ArrayList(u32)offset lists, all fromgpa. All four are freed before returning, on every path. - Loop with
r.takeDelimiter('\n'):null→ end.error.StreamTooLong→counts.long_lines += 1, thenr.discardDelimiterInclusive('\n')(toleratingerror.EndOfStreamas end of input) and continue. Not doing this discard is an infinite loop —takeDelimiterleaves the stream unmodified onStreamTooLong(verified, Reader.zig:885).- Strip a trailing
\r(CRLF files are common).
parsers.parseLine(format, line);.ignorecontinues,.regexand.unsupportedincrement their counters and continue.- For
.domain, splitline.texton ASCII whitespace and process each field; for.wildcard, processline.textas one field withcovers_apexremembered. - Per candidate field, in order:
a. A leading
*.makes it a wildcard candidate over the remainder; a*anywhere else makes itinvalid(blocklist entries are suffixes, not patterns — patterns belong to therulestable). b. Strip one trailing.. c. Reject any byte ≥ 0x80 or any ASCII control byte →invalid. d. Lowercase ASCII into a[types.max_name_len]u8stack buffer. e.dns.name.fromTextmust accept it → elseinvalid. f. Fewer than two labels →invalid. This is what keepslocalhost,local,broadcasthostand theip6-*names that every hosts list carries from black-holing the loopback names of every client on the LAN. It is the single highest-consequence rule in this file. g. Append tolist_bytes(domain) orwild_bytes(wildcard), recording the offset. A.wildcardwithcovers_apexappends to both. h. Either list exceedingmax_domainsentries →error.TooManyDomains. - Sort each offset list with
std.mem.sortUnstableContextcomparing the stored names bytewise, then write unique entries to the matching writer, each followed by\n, counting duplicates. - Hash both bodies as they are written (
std.crypto.hash.sha2.Sha256streaming:updateper emitted line,final,std.fmt.bytesToHex(digest, .lower)),.listfirst then.wild. - Return
Result.counts.domainsandcounts.wildcardsare the written, deduplicated counts — they are whatblocklist_sources.domain_countandwildcard_countstore, and what the UI shows.
S2.4 Tests (in-file, std.Io.Reader.fixed / std.Io.Writer.Allocating)
- Hosts fixture with sink IPs, comments, a duplicate,
localhost, an over-long line and a regex line: every counter has the expected value and the body is exactly the expected sorted text. - Domains fixture and ABP fixture likewise, the ABP one asserting
||x.com^producesx.comin both bodies. - Determinism: compile the same fixture twice → identical bodies and identical checksums.
- Order independence: compile a shuffled permutation of the same fixture → identical bodies.
- Uppercase input compiles to lowercase output; a trailing-dot input compiles without the dot.
- A candidate with a non-ASCII byte, a single-label candidate, and a
a*b.comcandidate each land incounts.invalidand appear in neither body. - An input of 3,000 lines with a 5,000-byte line in the middle:
long_lines == 1, the surrounding lines are all present, and the call terminates (theStreamTooLongregression test). - An empty input produces two empty bodies and the sha256 of the empty string.
compileundercheckAllAllocationFailuresleaks nothing.
S2.5 Acceptance criteria
zig fmt --checkandzig ast-checkclean on both files.- Neither file takes a
std.Iovalue, opens a file, or reads a clock. domain_set.zigimports onlystd.containscompares full bytes on every probe; no code path returns a hit from a hash comparison alone.- The
StreamTooLongdiscard is implemented and covered by the 3,000-line test. - The two-label minimum is implemented and covered by a
localhosttest. - Determinism and order-independence tests pass.
Session S3: src/filter/safesearch.zig, src/filter/response.zig
Pure. safesearch.zig imports std and ../dns/name.zig; response.zig imports std, ../dns/*
and ../config/model.zig.
S3.1 safesearch.zig
pub const Entry = struct { domain: []const u8, target: []const u8 };
/// PLAN §7.4. Sorted by `domain`, so the table is searchable and diffable, and
/// asserted sorted by a comptime block. Every target is a name the operator's
/// upstream can resolve; nxdns never hardcodes an address.
pub const table = [_]Entry{
.{ .domain = "bing.com", .target = "strict.bing.com" },
.{ .domain = "duckduckgo.com", .target = "safe.duckduckgo.com" },
.{ .domain = "google.com", .target = "forcesafesearch.google.com" },
.{ .domain = "pixabay.com", .target = "safesearch.pixabay.com" },
.{ .domain = "www.bing.com", .target = "strict.bing.com" },
.{ .domain = "www.duckduckgo.com", .target = "safe.duckduckgo.com" },
.{ .domain = "www.google.com", .target = "forcesafesearch.google.com" },
.{ .domain = "www.youtube.com", .target = "restrictmoderate.youtube.com" },
.{ .domain = "youtube.com", .target = "restrictmoderate.youtube.com" },
.{ .domain = "m.youtube.com", .target = "restrictmoderate.youtube.com" },
.{ .domain = "youtubei.googleapis.com", .target = "restrictmoderate.youtube.com" },
.{ .domain = "youtube.googleapis.com", .target = "restrictmoderate.youtube.com" },
.{ .domain = "www.youtube-nocookie.com", .target = "restrictmoderate.youtube.com" },
};
/// Exact match on a normalized name (lowercase, no trailing dot). Google's
/// country domains (`google.de`, …) are NOT enumerated: the list is unbounded,
/// it goes stale, and `forcesafesearch.google.com` is the documented target for
/// every one of them — an operator who needs a country domain adds a rule.
/// State that reasoning in the file.
pub fn lookup(domain: []const u8) ?[]const u8;
/// The rewritten question name for a matched query, as a `dns.name.Name`.
/// Applying it — sending the rewritten question upstream and prefixing the
/// answer with a CNAME from the original name to `target` — is the handler's
/// job (Phase 7). Nothing here builds a response.
pub fn rewrite(domain: []const u8) ?name.Name;
The table is asserted sorted and duplicate-free in a comptime block, and lookup is a binary
search. A test walks every entry and asserts dns.name.fromText accepts both sides.
S3.2 response.zig — blocked-response synthesis (PLAN §6.2)
pub const Options = struct {
mode: model.BlockResponse, // .zero | .nxdomain
ttl: u32, // blocking.ttl
};
pub const Error = packet.ResponseBuilder.Error;
/// Writes a blocked reply for `q` into `buf` and returns a prefix of it.
///
/// `.zero`: A → 0.0.0.0, AAAA → ::, every other qtype → NOERROR with no answer
/// (NODATA). Synthesizing an address for a qtype that does not carry one is
/// not possible, and NXDOMAIN for, say, an MX query would tell the client the
/// name does not exist while an A query says it does.
/// `.nxdomain`: RCODE = NXDOMAIN, no answer, for every qtype.
///
/// No SOA is placed in the authority section: nxdns is not authoritative for
/// the name and a synthesized SOA would give resolvers a negative-caching TTL
/// nxdns cannot honour. Document that in the file.
///
/// `request_opt` echoes EDNS exactly as `handler.zig` does: when the query
/// carried an OPT record, the reply carries one with the same payload size and
/// the DO bit passed through.
pub fn writeBlocked(
buf: []u8,
request: header.Header,
q: question.Question,
request_opt: ?edns.OptRecord,
do_bit: bool,
options: Options,
) Error![]u8;
Only the question's class IN is answered with addresses; a non-IN class takes the NODATA path.
Tests (in-file): for each of the two modes × {A, AAAA, MX, HTTPS} × {with OPT, without OPT}, parse
the produced message back with packet.parse and assert the rcode, ancount, arcount, the echoed
question, the rdata bytes and the TTL. One test asserts a buf too small returns
error.WriteFailed rather than truncating.
S3.3 Acceptance criteria
zig fmt --checkandzig ast-checkclean on both files.- The safe-search table is comptime-asserted sorted and duplicate-free.
- Neither file takes a
std.Iovalue or an allocator. - Every blocked-response case in S3.2 is a named test that re-parses the output.
Session S4: src/local/records.zig, src/local/forward_zones.zig
Pure. Both build immutable lookup structures from the model types the repositories already return.
S4.1 records.zig
pub const Value = union(enum) { a: [4]u8, aaaa: [16]u8, cname: name.Name };
pub const Record = struct {
/// Normalized owner name: lowercase, no trailing dot.
owner: []const u8,
value: Value,
ttl: u32,
};
pub const Error = error{ OutOfMemory, BadRecordValue, BadRecordName, TooManyRecords };
pub const max_records: usize = 10_000;
pub const Records = struct {
/// Arena-owned, sorted by (owner, rtype) so lookup is a binary search and
/// the answer order for one name is stable across restarts.
items: []const Record,
pub const empty: Records;
/// `gpa` owns the result; `deinit` frees it. Values are parsed here, once:
/// an `a` value through `NetAddress.parse` (must be `.ip4`), `aaaa` (`.ip6`),
/// a `cname` through `dns.name.fromText`. A bad value is an error, not a
/// skipped row — `validate.zig` already rejects these, so reaching one here
/// means the database was edited behind nxdns's back and silence would make
/// a record vanish with no signal.
pub fn build(gpa: std.mem.Allocator, rows: []const model.LocalRecord) Error!Records;
pub fn deinit(self: *Records, gpa: std.mem.Allocator) void;
/// All records for `domain` whose type matches `qtype`, plus any CNAME
/// (RFC 1034 §3.6.2: a CNAME answers every qtype). Empty slice = no local
/// record. Allocation-free.
pub fn lookup(self: *const Records, domain: []const u8, qtype: types.Type) []const Record;
/// True when the name has any local record of any type. The handler needs
/// this to answer NODATA instead of forwarding a name nxdns owns.
pub fn hasName(self: *const Records, domain: []const u8) bool;
};
/// Writes `records` as answers into a builder the caller has already
/// initialized with the request header and question. Mechanism only.
pub fn writeAnswers(
b: *packet.ResponseBuilder,
owner: name.Name,
records: []const Record,
) packet.ResponseBuilder.Error!void;
Local records are group-independent (PLAN §6.4) and are matched before filtering. Resolving a
CNAME target through the pipeline is Phase 7; writeAnswers emits the CNAME record and stops.
S4.2 forward_zones.zig
pub const Zone = struct {
/// Normalized: lowercase, no trailing dot.
zone: []const u8,
resolver: validate.Resolver,
};
pub const Error = error{ OutOfMemory, BadZone, BadResolver, TooManyZones };
pub const max_zones: usize = 1_000;
pub const Zones = struct {
/// Arena-owned, sorted by descending label count then by name, so the first
/// match found by a forward scan is the longest one.
items: []const Zone,
pub const empty: Zones;
pub fn build(gpa: std.mem.Allocator, rows: []const model.ForwardZone) Error!Zones;
pub fn deinit(self: *Zones, gpa: std.mem.Allocator) void;
/// Longest-suffix match on LABEL boundaries: `lan.home` matches `nas.lan.home`
/// and `lan.home`, and does NOT match `notlan.home`. `10.in-addr.arpa`
/// matches every reverse name under it. Allocation-free.
pub fn match(self: *const Zones, domain: []const u8) ?*const Zone;
};
build parses each resolver string with validate.parseResolver — the function whose doc comment
already names this file as its Phase 5 importer. There is no second resolver parser.
S4.3 Tests (in-file)
records.zig:
- Build from A, AAAA and CNAME rows;
lookup("nas.lan", .a)returns the A record;.aaaareturns the AAAA;.mxreturns nothing; with a CNAME present, every qtype returns the CNAME. - Two A records for one name both come back, in a stable order across two builds.
- Uppercase and trailing-dot owner names normalize to the same key.
- A bad
avalue ("::1"), a badaaaavalue, a bad CNAME target and an unparseable owner each produce their typed error. writeAnswersoutput re-parses with the expected ancount, types, TTLs and rdata.buildundercheckAllAllocationFailures.
forward_zones.zig:
lan.homematchesnas.lan.home,a.b.lan.homeandlan.home; does not matchnotlan.home,homeorlan.home.evil.net.- With both
homeandlan.homeconfigured,nas.lan.homematcheslan.home(longest wins). 10.in-addr.arpamatches5.4.3.10.in-addr.arpa.- A bad resolver URL →
error.BadResolver; a bad zone →error.BadZone. buildundercheckAllAllocationFailures.
S4.4 Acceptance criteria
zig fmt --checkandzig ast-checkclean on both files.- Neither file takes a
std.Iovalue or reads a clock. forward_zones.zigcallsvalidate.parseResolver; no second resolver parser exists (grep -rn "udp://" src/local/shows only test text).- Suffix matching is on label boundaries, proven by the
notlan.hometest. - Every bullet in S4.3 exists as a named test.
Session S5: src/filter/rules.zig, src/filter/matcher.zig
Pure, and the heart of the milestone. Depends on S1's wildcard.zig and S2's domain_set.zig.
S5.1 rules.zig — one group's explicit rules
pub const Error = error{ OutOfMemory, BadPattern, TooManyWildcards } || domain_set.DomainSet.Error;
pub const max_wildcards_per_group: usize = 4096;
pub const RuleSet = struct {
exact_allow: domain_set.DomainSet,
exact_block: domain_set.DomainSet,
/// Arena-owned, normalized, sorted for determinism. Scanned linearly:
/// these are operator-authored and few, and a linear scan over ≤ 4096
/// short patterns is far cheaper than any index that would have to be
/// rebuilt on every swap.
wildcard_allow: []const []const u8,
wildcard_block: []const []const u8,
pub const empty: RuleSet;
/// `rows` are this group's rules only (already filtered by the caller).
/// Patterns are normalized (lowercase, trailing dot stripped) and validated:
/// `.exact` through `dns.name.fromText`, `.wildcard` through
/// `wildcard.validate`. An invalid pattern is `error.BadPattern` — the
/// database went through `validate.zig`, so an invalid one here means the
/// rows were edited underneath nxdns.
pub fn build(gpa: std.mem.Allocator, rows: []const model.Rule, seed: u64) Error!RuleSet;
pub fn deinit(self: *RuleSet, gpa: std.mem.Allocator) void;
pub fn memoryBytes(self: *const RuleSet) usize;
};
build must be given only one group's rows; splitting listRules output by group is the caller's
job (S5.3's Snapshot.build), because only it holds the group table.
S5.2 matcher.zig — normalization, candidates, decision
pub const Reason = enum {
none,
rule_allow_exact,
rule_block_exact,
rule_allow_wildcard,
rule_block_wildcard,
blocklist_domain,
blocklist_wildcard,
};
pub const Decision = struct {
blocked: bool,
reason: Reason,
/// The candidate (or pattern) that decided it; borrowed from the caller's
/// normalized buffer or from the snapshot. "" when `reason == .none`.
matched: []const u8,
/// `.blocklist_*` only: index into `Snapshot.sources`, for the block reason
/// the query log (Phase 6) and the UI (Phase 8) will show.
source: ?u32 = null,
};
/// Lowercase ASCII, trailing dot stripped, written into `buf`. Returns a slice
/// of `buf`. The root name normalizes to "".
pub fn normalize(qname: name.Name, buf: *[types.max_name_len]u8) []const u8;
/// Full name, then each parent, ending at the last two-label suffix. The TLD
/// alone is NOT a candidate: a rule or list entry on `com` is a configuration
/// mistake that would take the whole internet with it, and refusing to walk
/// that far costs nothing real.
pub const Candidates = struct {
rest: []const u8,
pub fn init(domain: []const u8) Candidates;
pub fn next(self: *Candidates) ?[]const u8;
};
S5.3 matcher.zig — the snapshot
pub const SourceSets = struct {
/// Row id, so the manager can map a decision back to `blocklist_sources`.
id: i64,
/// Borrowed from the snapshot arena; the source's display name for the UI.
name: []const u8,
domains: domain_set.DomainSet,
wildcards: domain_set.DomainSet,
};
pub const Group = struct {
id: i64,
name: []const u8,
safe_search: bool,
rules: rules.RuleSet,
/// Indices into `Snapshot.sources`, ascending, deduplicated.
sources: []const u32,
};
pub const ClientEntry = struct { key: address.NetAddress.Key, group: u32 };
pub const PrefixEntry = struct { prefix: address.Prefix, group: u32, priority: i32 };
pub const Snapshot = struct {
arena: std.heap.ArenaAllocator,
groups: []Group,
sources: []SourceSets,
clients: []ClientEntry,
prefixes: []PrefixEntry,
/// Index into `groups` of the group named "default". Always valid:
/// `build` returns `error.MissingDefaultGroup` otherwise.
default_group: u32,
/// Monotonic, assigned by the manager. Logged on every swap so an operator
/// can tell which generation answered a query.
generation: u64,
pub const Input = struct {
groups: []const model.Group,
group_sources: []const model.GroupSource,
sources: []const model.BlocklistSource,
rules: []const model.Rule,
clients: []const model.Client,
prefixes: []const model.ClientPrefix,
/// One entry per `sources[i]`, in the same order: the compiled bodies
/// already read from disk with their headers stripped. An enabled source
/// whose bodies are absent is `error.MissingCompiledSource` — a silently
/// unenforced blocklist is exactly the failure PLAN §1.3 exists to
/// prevent.
compiled: []const Compiled,
seed: u64,
generation: u64,
};
pub const Compiled = struct { list_body: []const u8, wild_body: []const u8 };
pub const Error = error{
OutOfMemory, MissingDefaultGroup, UnknownGroup, UnknownSource,
MissingCompiledSource, BadClientIp, BadClientPrefix,
} || rules.Error;
/// Builds an immutable snapshot. Every string is copied into `arena`, so the
/// caller may free the repository lists immediately afterwards. Disabled
/// sources are skipped entirely — they cost no memory.
pub fn build(gpa: std.mem.Allocator, input: Input) Error!Snapshot;
pub fn deinit(self: *Snapshot) void;
/// PLAN §3.10 precedence, allow wins at equal specificity:
/// 1. exact/parent allow rules 2. exact/parent block rules
/// 3. wildcard allow rules 4. wildcard block rules
/// 5. blocklist domains 6. blocklist wildcards
/// `domain` is normalized (`normalize`). No allocation, no lock, no clock.
pub fn evaluate(self: *const Snapshot, group: u32, domain: []const u8) Decision;
/// PLAN §7.2 matching half: exact client row, else longest-prefix match
/// (ties broken by longer prefix then higher `priority`), else the default
/// group. Auto-materialization is Phase 7 (resolved ambiguity 4).
pub fn groupForClient(self: *const Snapshot, addr: address.NetAddress) u32;
pub fn groupIndexById(self: *const Snapshot, id: i64) ?u32;
pub fn groupIndexByName(self: *const Snapshot, name_text: []const u8) ?u32;
pub fn safeSearch(self: *const Snapshot, group: u32) bool;
pub fn memoryBytes(self: *const Snapshot) usize;
};
evaluate order is the specification, and it is level-by-level over the whole candidate chain,
not candidate-by-candidate over the levels: level 1 is checked against every candidate before level 2
is checked against any. That is what makes "an allow rule on the parent beats a block rule on the
child" true, which is the behaviour operators expect from an allow list.
Levels 5 and 6 iterate the group's sources in ascending index order and return the first hit, so the
reported source is stable for a given snapshot. Level 5 tests only the full name and level 6
tests only proper parents (resolved ambiguity 1).
S5.4 Tests (in-file)
Precedence — one named test per row, each asserting blocked, reason and matched:
| Configuration | Query | Expected |
|---|---|---|
block rule ads.example.com, nothing else |
ads.example.com |
blocked, rule_block_exact |
block rule example.com |
ads.example.com |
blocked, rule_block_exact, matched example.com (parent walk) |
block rule example.com, allow rule ads.example.com |
ads.example.com |
allowed, rule_allow_exact |
block rule ads.example.com, allow rule example.com |
ads.example.com |
allowed (allow level runs first over the whole chain) |
allow rule *.example.com, block rule ads.example.com |
ads.example.com |
blocked (exact block, level 2, beats wildcard allow, level 3) |
allow wildcard *.example.com, block wildcard *.example.com |
a.example.com |
allowed (tie → allow wins) |
list entry tracker.net |
tracker.net |
blocked, blocklist_domain |
list entry tracker.net |
sub.tracker.net |
allowed (ambiguity 1) |
wild entry tracker.net |
sub.tracker.net |
blocked, blocklist_wildcard |
wild entry tracker.net |
tracker.net |
allowed (a .wild entry covers proper subdomains only) |
allow rule sub.tracker.net, wild entry tracker.net |
sub.tracker.net |
allowed |
| nothing configured | example.com |
allowed, reason == .none |
| source assigned to group A only | same query in group B | allowed in B, blocked in A |
| disabled source | its entry | allowed (disabled sources are not loaded) |
Also:
normalizelowercases, strips one trailing dot, and returns""for the root.Candidatesovera.b.example.comyields exactlya.b.example.com,b.example.com,example.com— and notcom.groupForClient: exact IPv4 hit; exact IPv6 hit through the canonical key;/24prefix hit; overlapping/16and/24→ the/24wins; equal-length prefixes → higher priority wins; no match → default group.Snapshot.buildwithout adefaultgroup →error.MissingDefaultGroup; with agroup_sourcesrow naming an unknown source →error.UnknownSource; with an enabled source whosecompiledentry is absent →error.MissingCompiledSource.- Two snapshots built from the same input with different seeds produce identical decisions over a 40-query table.
Snapshot.buildundercheckAllAllocationFailuresleaks nothing.memoryByteson a snapshot with 10,000 synthetic domains stays under the S§Memory-budget bound (count * (avg_len + 1) * 2 + index), asserted as an inequality so it is a real regression guard.
S5.5 Acceptance criteria
zig fmt --checkandzig ast-checkclean on both files.- Neither file takes a
std.Iovalue, opens a file, or reads a clock. evaluateperforms no allocation (no allocator is reachable from its signature).- Every row of the S5.4 precedence table exists as a named test.
- The candidate chain stops before the TLD, proven by a test.
Snapshot.buildcopies every string into its arena; a test frees the input lists before callingevaluate.
Session S6: src/filter/fetcher.zig
The first of the three std.Io files. Downloads one source; knows nothing about parsing, files or
the database.
S6.1 Signatures
pub const max_body_bytes: usize = 64 * 1024 * 1024;
pub const redirect_buffer_len: usize = 8192; // RFC 9110 recommendation (Client.zig:1128)
pub const min_transfer_buf: usize = 16 * 1024;
pub const Error = error{
BadUrl, ConnectFailed, TlsFailed, SendFailed, ReceiveFailed, HttpStatus,
BodyTooLarge, Timeout, Canceled, OutOfMemory, SystemResources, Unexpected,
};
pub const Result = struct { bytes_read: u64, status: std.http.Status };
pub const Fetcher = struct {
http: *std.http.Client, // caller-owned, shared, pools connections
transfer_buf: []u8, // caller-owned, ≥ min_transfer_buf
redirect_buf: []u8, // caller-owned, ≥ redirect_buffer_len
/// GETs `url` and streams the body into `w`. Nothing is buffered whole:
/// a 64 MB list must not become a 64 MB allocation, and the caller is
/// writing into a temporary file anyway.
///
/// `budget` bounds the WHOLE exchange. `std.http.Client` has no per-request
/// deadline (verified), so the caller runs this under `io.concurrent` and
/// cancels the future — the same pattern `tls_client_integration_test.zig`
/// established. This function therefore takes no timeout parameter and
/// simply propagates `error.Canceled`; S8 owns the deadline.
pub fn fetch(self: *Fetcher, io: std.Io, url: []const u8, w: *std.Io.Writer) Error!Result;
};
Rules:
std.Uri.parsefailure →error.BadUrl. A scheme other thanhttp/https→error.BadUrl.accept_encodingis overridden to identity (resolved ambiguity 6), matchingdoh_client.zig.redirect_behaviorkeeps the stdlib default of 3;receiveHead(self.redirect_buf)follows them.error.TooManyHttpRedirectsmaps toerror.HttpStatus.- Status other than
.ok→error.HttpStatus. An error return carries noResult, so the fetcher stores the numeric status in alast_status: ?std.http.Statusfield, cleared at the start of eachfetchand set from every response head; the caller reads it aftererror.HttpStatus.content-typeis not checked: blocklists are served astext/plain,application/octet-stream,text/htmland worse, and the compiler's invalid-line counters are the honest signal about content. - The body is copied to
wintransfer_buf-sized chunks with a running total; exceedingmax_body_bytes→error.BodyTooLarge(and the caller discards its temporary file). - Error mapping reuses
transport.mapLocalfirst, then classifies by phase, exactly asdoh_client.zig'smapErrordoes. Do not invent a second classification vocabulary.
S6.2 Tests
In-file (no sockets): URL rejection table (ftp://x, x, https://), and a Fetcher value
constructed against an undefined std.http.Client proving the URL check precedes any client use
(the doh_client.zig pattern).
Everything real belongs to S9: a loopback std.http.Server serving a fixture, a redirect chain, a
404, and a body that exceeds a lowered cap.
S6.3 Acceptance criteria
zig fmt --checkandzig ast-checkclean.accept_encodingis pinned to identity and the reason is commented.- The body is streamed; no
allocRemaining, no whole-body allocation anywhere in the file. - No timeout parameter and no sleep: the deadline is the caller's (S8.4).
transport.mapLocalis used before any phase classification.
Session S7: src/local/forward_client.zig
Conditional forwarding's transport (resolved ambiguity 5). Implements transport.Client so the
Phase 7 handler treats a forward zone exactly like any other exchange.
S7.1 Signatures
pub const ForwardClient = struct {
resolver: validate.Resolver,
/// Caller-owned scratch for the TCP length-prefixed path.
frame_buf: []u8,
read_timeout: std.Io.Clock.Duration,
stats: Stats = .{},
pub const Stats = struct {
queries: u64 = 0,
udp_truncated: u64 = 0, // TC=1 → retried over TCP
failures: u64 = 0,
};
pub fn init(resolver: validate.Resolver, frame_buf: []u8, read_timeout: std.Io.Clock.Duration) ForwardClient;
pub fn client(self: *ForwardClient) transport.Client;
/// UDP: send, `receiveTimeout`, validate. TC=1 → retry over TCP with the
/// RFC 1035 §4.2.2 two-byte length prefix. `.tcp` resolvers skip straight
/// to the TCP path.
pub fn exchange(self: *ForwardClient, io: std.Io, query: []const u8, response_buf: []u8)
transport.ExchangeError![]u8;
};
Rules:
- Every response passes
transport.validateResponse(query, response)before it is returned — a forward zone points at LAN infrastructure, which is not a reason to trust its framing. - UDP uses
Socket.receiveTimeout(verified implemented on the POSIX Threaded backend, milestone 3). A datagram from an address other than the resolver's is discarded and counted, and the receive is retried within the remaining budget. - TCP has no read timeout in 0.16.0: run the exchange under
io.concurrentand cancel the loser against astd.Io.Clock.Durationsleep, exactly as milestone 3 does. Never setConnectOptions.timeout— the Threaded backend panics (Threaded.zig:12077). - Failures map through
transport.mapLocalfirst, then to the phase'sPeerFault. Every socket is closed on every path. - No health tracking and no backoff:
upstream/health.zigandpool.zigmodel the upstream pool, and a forward zone has exactly one designated resolver with no failover partner. Say so in a comment so its absence does not read as an oversight.
S7.2 Tests
In-file: the transport.Client vtable instantiation check (milestone 3's pattern) and the stats
struct defaults. Everything with a socket is S9's: a loopback UDP responder answering an A query, a
loopback responder setting TC=1 followed by a TCP responder returning the full answer, a silent
responder proving the timeout produces error.Timeout, and a responder answering with a mismatched
ID proving error.ResponseMismatch.
S7.3 Acceptance criteria
zig fmt --checkandzig ast-checkclean.ConnectOptions.timeoutappears nowhere.- Every response goes through
transport.validateResponse. - A datagram from a foreign source address is counted and discarded, not returned.
- The file implements
transport.Clientand defines no second client interface.
Session S8: src/filter/manager.zig
The BlocklistManager of PLAN §4: compiled-file layout, refresh, metadata, snapshot build and the
RCU swap. The only file in this milestone that touches both the database and the filesystem.
S8.1 The two repository additions this session owns
blocklist_sources carries counters and a checksum that milestone 4 deliberately excluded from the
config model (runtime facts, not configuration). Refresh has to write them, so sources_repo.zig
gains exactly two functions — additive, no signature of an existing function changes:
pub const SourceRow = struct {
id: i64,
url: []const u8,
name: []const u8,
enabled: bool,
last_updated: ?i64,
domain_count: i64,
wildcard_count: i64,
skipped_regex_count: i64,
checksum: ?[]const u8,
};
/// Every source with its row id and runtime columns. Strings are heap copies.
/// ORDER BY url (matching `listBlocklistSources`, milestone 4 §S4.2).
pub fn listSourceRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(SourceRow);
pub fn freeSourceRows(gpa: Allocator, items: []const SourceRow) void;
/// Writes the runtime columns for one source after a successful compile.
pub fn updateSourceStats(database: *db.Db, id: i64, stats: SourceStats) db.Error!void;
This is the one exception to "no session edits a milestone 1–4 file", and it is scoped to appending
two functions and their tests to sources_repo.zig. S8 owns that file for this milestone. No
schema change and no migration: every column already exists (PLAN §11.2).
S8.2 Compiled-file layout (PLAN §3.8, §3.13)
<data_dir>/blocklists/<source_id>.list
<data_dir>/blocklists/<source_id>.wild
Directory created with createDirPathStatus(io, path, .fromMode(0o700)); files written 0o600 through
createFileAtomic + replace (milestone 4's verified helper), so a crash mid-write can never leave
a half-list that would load as a valid, shorter blocklist. <source_id> is the blocklist_sources
row id, so a renamed URL keeps its files and a deleted source's files are removed by
pruneOrphans (below).
Header, written by the manager ahead of the body, every line prefixed # :
# nxdns blocklist
# url <url>
# format <hosts|domains|abp>
# fetched_at <unix seconds>
# domains <n>
# wildcards <n>
# skipped_regex <n>
# skipped_unsupported <n>
# invalid <n>
# sha256 <64 hex chars>
The loader strips every leading # line and hands the remainder to DomainSet.build. The sha256
covers the .list body followed by the .wild body — not the header, so the checksum is stable
across refetches of unchanged content while fetched_at moves.
S8.3 Signatures
pub const Paths = struct {
dir: std.Io.Dir, // <data_dir>
subdir: []const u8 = "blocklists",
};
pub const SourceStatus = struct {
id: i64,
url: []const u8, // borrowed from the manager's arena
state: enum { ok, never_fetched, fetch_failed, compile_failed, load_failed },
last_attempt: i64 = 0,
last_success: i64 = 0,
counts: compiler.Counts = .{},
/// Fixed-size, no allocation on the failure path.
last_error: [128]u8 = @splat(0),
last_error_len: u8 = 0,
};
pub const Manager = struct {
gpa: std.mem.Allocator,
database: *db.Db,
paths: Paths,
fetcher: *fetcher.Fetcher,
update: model.BlocklistUpdate,
total_budget: std.Io.Clock.Duration,
lock: std.Io.RwLock,
current: ?*matcher.Snapshot,
generation: u64,
statuses: []SourceStatus,
pub const Error = error{ OutOfMemory, ... } || db.Error || matcher.Snapshot.Error;
pub fn init(gpa, database, paths, fetcher_ptr, update, total_budget) Error!Manager;
pub fn deinit(self: *Manager, io: std.Io) void;
/// Reads the database and every compiled file, builds a snapshot and swaps
/// it in. Called at startup and after any refresh. A source whose compiled
/// files are missing or whose checksum does not match is marked
/// `.load_failed` and REFRESHED, not silently skipped.
pub fn reload(self: *Manager, io: std.Io) Error!void;
/// Fetch + compile + atomically replace the compiled files for one source,
/// then update its row. Returns false when the content was unchanged
/// (checksum equal), in which case only `last_updated` moves.
pub fn refreshSource(self: *Manager, io: std.Io, row: sources_repo.SourceRow) Error!bool;
/// Every enabled source, one at a time, then one `reload`. Never stops at
/// the first failure: a broken source must not hide the rest.
pub fn refreshAll(self: *Manager, io: std.Io) Error!void;
/// Long-running task: sleeps `update.interval_hours`, refreshes, repeats.
/// Returns on `error.Canceled`. Started with `io.concurrent` by Phase 7's
/// wiring; nothing in this milestone starts it automatically.
pub fn runScheduler(self: *Manager, io: std.Io) std.Io.Cancelable!void;
/// Reader side of the RCU swap. The handle holds a shared lock; the caller
/// MUST release it and MUST NOT retain the snapshot pointer afterwards.
pub const Handle = struct {
snapshot: *const matcher.Snapshot,
manager: *Manager,
pub fn release(self: Handle, io: std.Io) void;
};
pub fn acquire(self: *Manager, io: std.Io) ?Handle;
/// Copies the status table for the API and `nxdns check`.
pub fn statusSnapshot(self: *Manager, io: std.Io, out: []SourceStatus) usize;
/// Deletes `<id>.list`/`<id>.wild` files whose id is not in the database.
pub fn pruneOrphans(self: *Manager, io: std.Io) Error!void;
};
The swap is an std.Io.RwLock, not a lock-free pointer. PLAN §7.3 says "readers lock-free", and
this is a deliberate, documented deviation. Freeing the old snapshot safely without a lock requires
epoch-based reclamation or hazard pointers — a class of code that is very hard to get right and
impossible to test convincingly, bought for a household resolver whose target is 100 qps. A shared
lock held for the microseconds of one evaluate costs an uncontended atomic pair; the writer takes
the exclusive lock only on a swap, which happens on refresh. The old snapshot is freed after
unlock returns, and the Handle API makes "do not retain the pointer" the only shape the caller
can write. State this reasoning in the file, and have the orchestrator amend PLAN §7.3.
S8.4 refreshSource order — the order is the specification
now = std.Io.Clock.real.now(io).toSeconds();status.last_attempt = now.- Create
<subdir>/<id>.list.tmpand<id>.wild.tmpthroughcreateFileAtomic(.permissions = .fromMode(0o600),.replace = true). - Download into a third temporary file (
<id>.raw.tmp) rather than memory —max_body_bytesis 64 MB and the budget in §Memory has no room for it alongside two snapshots. Runfetcher.fetchunderio.concurrentwith atotal_budgetsleep racing it, and cancel the loser (milestone 3's pattern). A fetch failure sets.fetch_failedwith the error name, logs atwarn, and returns without touching the live compiled files. - Reopen the raw file, sniff the format with
parsers.detectFormatover the firstparsers.sample_lineslines, thencompiler.compilefrom aFile.Readerinto the two temporary writers. Any compile error sets.compile_failedand returns; the live files are still untouched. - If
result.checksumequals the storedchecksum,deinitthe atomics (which discards the temporaries), update onlylast_updated, and returnfalse. Recompiling identical content into a new file would invalidate the snapshot for nothing. - Otherwise write the header,
replaceboth atomics, delete the raw temporary, andupdateSourceStatswith the counts, the checksum andlast_updated = now. status.state = .ok,status.counts = result.counts,status.last_success = now.
Every failure path deletes its temporaries. No path leaves a .tmp behind, and no path deletes a
good compiled file.
S8.5 Failure visibility
- Every non-
okstate records the error name intoSourceStatus.last_errorand logs once atwarnwith the source URL and the state. Noerrlevel, per the binding logging policy: the condition is recorded and surfaced, not swallowed. refreshAllreturns successfully when at least one source failed; the failures live instatuses. It returns an error only when nothing could be done (out of memory, the database is unreachable). Phase 8 exposes the statuses atGET /api/blocklists;nxdns checkgains nothing in this milestone.reloadwith zero enabled sources is normal (a fresh install), produces a valid empty snapshot, and logs atinfo.
S8.6 Startup and scheduling policy
runScheduler refreshes a source at startup only when it needs it: no compiled file, a checksum
mismatch against the file on disk, or last_updated older than interval_hours. A cold restart of
a Pi must not re-download every list, and a boot loop must not turn into a download loop. After the
initial pass it sleeps interval_hours between full passes. update.enabled == false means
runScheduler returns immediately after the initial load; manual refresh through refreshAll still
works (Phase 8's POST /api/blocklists/update).
S8.7 Tests
In-file (:memory: database, no filesystem): init/deinit; acquire before any reload returns
null; the header writer produces the exact expected text for a known Counts; the header stripper
returns the body for a header-only file and for a file with no header; SourceStatus.last_error
truncation at 128 bytes.
Everything with real files, real HTTP and real swaps is S9's.
S8.8 Acceptance criteria
zig fmt --checkandzig ast-checkclean onmanager.zigandsources_repo.zig.sources_repo.ziggains exactly two public functions plusSourceRow/freeSourceRows; no existing signature changes; the new list has a deterministicORDER BY url.- No schema change, no migration step, no new column.
- Compiled files are written through
createFileAtomic+replaceat 0o600, in a 0o700 directory created withcreateDirPathStatus. - A fetch or compile failure leaves the previously compiled files byte-identical (test in S9).
- The RwLock deviation from PLAN §7.3 is documented in the file with its reasoning.
- The old snapshot is freed after
unlock, never while a reader could hold it. refreshAlldoes not stop at the first failing source.grep -n "std.log.err" src/filter/ src/local/returns nothing.
Session S9: src/filter/filter_integration_test.zig, tests/fuzz/blocklist_fuzz.zig
S9.1 Hermetic integration cases (-Dintegration, std.testing.tmpDir)
compile → load → match, through real files
- Compile a 5,000-line hosts fixture to real
.list/.wildfiles, build a snapshot from them, and assert 20 sampled domains match and 20 non-members do not. - Recompile the same fixture into a second directory: both files are byte-identical, and so is the checksum.
- Truncate a
.listfile mid-line and reload →error.NotSortedorMissingCompiledSource, and the previous snapshot is still serving (assert throughacquire).
fetcher against a loopback std.http.Server
- A 200 response with a hosts body → the compiled files exist,
blocklist_sourcescounters and checksum are written, andacquiresees the domains. - A 302 to a second path → followed, same result.
- A 404 →
.fetch_failed, the previously compiled files are byte-identical, and the snapshot still blocks what it blocked before. - A body larger than a lowered
max_body_bytes→error.BodyTooLarge, no.tmpfile remains in the blocklist directory (assert by listing it). - Refetching identical content →
refreshSourcereturnsfalse, the compiled files' mtimes are unchanged, andlast_updatedmoved.
swap
- Under a snapshot acquired by one task, a concurrent
reloadcompletes and the holding task still reads a consistent snapshot; afterreleaseand a re-acquire, the new generation is visible. pruneOrphansdeletes files for a deleted source and leaves live ones alone.
local records and forward zones, end to end
- Build
Recordsfrom a seeded database (throughlocal_repo.listLocalRecords), answer an A query fornas.lan, and re-parse the reply: one answer, correct rdata, correct TTL. - A CNAME local record answers an A query with the CNAME record and nothing else.
ForwardClientagainst a loopback UDP responder: an A query fornas.lan.homereturns the responder's answer, andZones.matchselected that resolver.- The loopback responder sets TC=1; the client retries over TCP against a loopback TCP responder
and returns the full answer, with
stats.udp_truncated == 1. - A silent responder →
error.Timeoutinside the configured budget (assert the elapsed time is below twice the budget). - A responder answering with a wrong ID →
error.ResponseMismatch.
blocked responses
- For each
blocking.responsemode, synthesize a reply for a blocked A and AAAA query and re-parse it:.zerogives0.0.0.0/::withblocking.ttl;.nxdomaingives NXDOMAIN with no answer.
S9.2 Fuzz target
tests/fuzz/blocklist_fuzz.zig, in the style of tests/fuzz/dns_fuzz.zig (milestone 2): a
std.testing.fuzz test per parser plus one for wildcard.matches, each filling a buffer through
Smith.sliceWithHash and feeding the prefix in. The acceptance property is "does not crash and does
not hang": every parser returns a Line for any byte string, and wildcard.matches terminates for
any pattern/domain pair (the pattern is first passed through validate, and only accepted patterns
reach matches, matching how production uses it). Corpus entries: one hosts line, one ABP line, one
regex line, one over-long line.
S9.3 The build change (orchestrator, not this session)
const parsers_mod = b.createModule(.{
.root_source_file = b.path("src/filter/parsers.zig"),
.target = target,
.optimize = optimize,
});
const blocklist_fuzz_mod = b.createModule(.{
.root_source_file = b.path("tests/fuzz/blocklist_fuzz.zig"),
.target = target,
.optimize = optimize,
});
blocklist_fuzz_mod.addImport("parsers", parsers_mod);
const blocklist_fuzz_tests = b.addTest(.{
.name = "blocklist-fuzz",
.use_llvm = if (fuzz) true else null,
.root_module = blocklist_fuzz_mod,
});
test_step.dependOn(&b.addRunArtifact(blocklist_fuzz_tests).step);
This is exactly why S1's five files import nothing outside src/filter/: parsers.zig is a module
root here and in the aggregator, and an import that escaped its directory would fail with
error.ImportOutsideModulePath (milestone 2). The in-file tests of those five files still run in the
aggregator artifact; the fuzz artifact runs only blocklist_fuzz.zig's tests.
S9.4 Acceptance criteria
zig fmt --checkandzig ast-checkclean on both files.- All 17 hermetic cases exist as named tests and pass under
zig build test -Dintegration. - No hermetic test reaches a non-loopback address or leaves a file outside its
tmpDir. - The fuzz tests pass under plain
zig build test(corpus replay + empty input).
Module Layout
src/filter/parsers.zig S1 format vocabulary + dispatch + sniffing (std only)
src/filter/parser_hosts.zig S1 hosts-file lines (std only)
src/filter/parser_domains.zig S1 one-domain-per-line lists (std only)
src/filter/parser_abp.zig S1 ABP subset; modifiers/exceptions unsupported (std only)
src/filter/wildcard.zig S1 `*`-label pattern validate + match (std only)
src/filter/domain_set.zig S2 arena + open-addressed exact set
src/filter/compiler.zig S2 stream -> sorted, deduped .list/.wild bodies + sha256
src/filter/safesearch.zig S3 per-group safe-search table + rewrite
src/filter/response.zig S3 blocked-response synthesis (zero | nxdomain)
src/filter/rules.zig S5 one group's compiled allow/block rules
src/filter/matcher.zig S5 Snapshot, §3.10 precedence, group-for-client
src/filter/fetcher.zig S6 std.http.Client GET, streamed, capped
src/filter/manager.zig S8 compiled files, refresh, metadata, RCU swap, scheduler
src/filter/filter_integration_test.zig S9 -Dintegration: real files, loopback HTTP/UDP/TCP
src/local/records.zig S4 local A/AAAA/CNAME lookup + answer writing
src/local/forward_zones.zig S4 zone suffix match -> validate.Resolver
src/local/forward_client.zig S7 plain UDP/TCP resolver, transport.Client
src/storage/repositories/sources_repo.zig S8 += listSourceRows, updateSourceStats
tests/fuzz/blocklist_fuzz.zig S9 parser + wildcard fuzz targets
File Ownership
| Files | Owner | Notes |
|---|---|---|
src/filter/parsers.zig, parser_hosts.zig, parser_domains.zig, parser_abp.zig, wildcard.zig |
S1 | frozen after S1 verifies; std-only imports |
src/filter/domain_set.zig, src/filter/compiler.zig |
S2 | frozen after S2 verifies |
src/filter/safesearch.zig, src/filter/response.zig |
S3 | |
src/local/records.zig, src/local/forward_zones.zig |
S4 | |
src/filter/rules.zig, src/filter/matcher.zig |
S5 | |
src/filter/fetcher.zig |
S6 | |
src/local/forward_client.zig |
S7 | |
src/filter/manager.zig, src/storage/repositories/sources_repo.zig |
S8 | the only milestone-4 file any session edits, and only additively (S8.1) |
src/filter/filter_integration_test.zig, tests/fuzz/blocklist_fuzz.zig |
S9 | |
build.zig, build.zig.zon, src/tests.zig |
orchestrator | no session edits these |
PLAN.md §7.1, §7.3 |
orchestrator | amended per resolved ambiguities 1 and 3, and per S8.3 |
No session touches src/dns/, src/server/, src/upstream/, src/platform/, src/config/,
src/cli.zig or src/main.zig. A needed change there is reported, not made.
Acceptance Criteria (Milestone 5 Complete)
zig build testexits 0 with every new file wired intosrc/tests.zig, including the newblocklist-fuzzartifact.zig build test -Dintegrationexits 0: milestone 1's loopback TLS echo, milestone 3's listener and resolver tests, milestone 4's 21 storage cases, and all 17 filtering cases.zig build crossstill produces two statically linked executables.- The PLAN §3.10 precedence table is proven row by row by the S5.4 tests.
- Compiling a fixture twice produces byte-identical bodies and checksums; compiling a shuffled permutation produces the same bodies.
grep -rn "std.log.err" src/filter/ src/local/returns nothing.grep -rln "std.Io," src/filter/ src/local/names onlyfetcher.zig,manager.zig,forward_client.zigand the S9 test filefilter_integration_test.zig(reader/writer interface parameters do not count; check the match).grep -rn "regex" src/filter/shows counting and skipping only — no matching engine.- A hosts fixture containing
localhost,ip6-localhostandbroadcasthostcompiles to a body containing none of them. - A snapshot over a 1,000,000-entry synthetic list reports
memoryBytes()under 40 MB (S5.4's inequality test, scaled) — the runnable form of the PLAN §18 budget. zig fmt --checkclean repo-wide; GPG-signed lowercase commits.
Anti-Requirements
- No handler integration.
src/server/handler.zigis not edited. Nothing in this milestone is called from the query path; composing local records → forward zones → filtering → safe-search → cache → upstream is Phase 7. The pieces are built and tested standalone. - No CNAME uncloaking. PLAN §6.3 is explicitly Phase 7 (§16). No chain walking, no depth-8 budget, no re-evaluation of answer-section names anywhere in this milestone.
- No cache, no rate limiter, no query log, no disk monitor, no retention, no log rotation.
Phase 6. A
Decisioncarries areasonfor the future query log; nothing writes one. - No pause/resume. Phase 7.
- No web API, no SSE, no
/metrics, no auth. Phase 8.SourceStatusexists for that phase to read; no handler exists. - No regex engine, in any form, for any reason. PLAN §2.2. Regex lines are counted and skipped.
- No allow rules sourced from blocklists. ABP exception rules (
@@) are counted as unsupported. Allow policy lives in therulestable where an operator can see it. - No blocklist storage in SQLite. PLAN Decision A: compiled flat files under
<data_dir>/blocklists/, metadata columns only inconfig.db. - No schema change and no migration step. Every column this milestone writes already exists.
- No HTTP compression, no conditional requests (
ETag/If-Modified-Since), no HTTP/2. Unchanged content is detected by comparing the compiled checksum, which also catches a source that changes its headers without changing its content. - No client auto-materialization, no
last_seenupdates. Phase 7 (resolved ambiguity 4). - No snapshot persistence. The snapshot is rebuilt from the database and the compiled files at startup; caching it on disk would add a fourth thing that can be stale.
- No lock-free reclamation scheme. The documented
RwLockdeviation (S8.3) is the design, not a placeholder for a later epoch-based rewrite. - No third-party Zig packages. stdlib plus the two pinned C libraries.
As built (S1–S4, S6, S7 and orchestrator wiring)
Deviations from the text above, recorded after the first six sessions verified. Where this section and the session text disagree, this section wins.
S1 parsers + wildcard. validate counts labels before the 253-byte length check, so
TooManyLabels is reachable. A hosts line with a sink address and no name is .unsupported.
||example.com without ^ is .wildcard with covers_apex = true. detectFormat treats $ as a
marker only on non-comment lines; ||, @@ and element-hiding separators are matched anchored.
looksLikeIpLiteral lives in parsers.zig and is shared with parser_hosts.zig.
wildcard.matches is total on unvalidated input: the label splitter returns null above max_labels
and matches returns false — no assert, because parsers.zig is the S9.3 fuzz root.
S2 domain_set + compiler. The line-length cap is enforced both ways: error.StreamTooLong is
discarded with discardDelimiterInclusive, and a returned line over max_line_len is skipped;
both increment long_lines. DomainSet.build maps an empty line to error.NotSorted and a line
over 255 bytes to error.SetTooLarge; per line, the lowercase check runs before the order check.
covers_apex applies only to .wildcard lines: *.x in a hosts or domains list is a wildcard with
no apex entry.
S3 safesearch + response. The safe-search table as written above was not sorted; the built table
holds the same entry set sorted ascending by std.mem.order (www.youtube-nocookie.com <
www.youtube.com; youtube.com < youtube.googleapis.com < youtubei.googleapis.com). rewrite
uses catch unreachable on name.fromText, upheld by the every-entry-is-valid test. The
mode × qtype × OPT matrix is eight named tests, each looping over OPT presence internally; all
sixteen cases are exercised and re-parsed.
S4 records + forward_zones. A CNAME is exclusive at a name (RFC 1034 §3.6.2): records sort by
(owner, rtype) with rank A, AAAA, CNAME, and a name carrying a CNAME answers with the CNAME run
alone. validate.zig does not reject a CNAME coexisting with an A, so the mixed case is reachable
from a hand-edited database. qtype == .any returns the whole record run. The root name is
rejected (BadRecordName / BadZone), as is any byte ≥ 0x80 in an owner, CNAME target or zone.
Each table is items plus one flat byte block — two surviving allocations, freed by deinit.
S6 fetcher. Fetcher.last_status: ?std.http.Status carries the numeric status past
error.HttpStatus (see S6.1). error.WriteFailed from the output writer maps to
error.Unexpected; the caller owns the writer and reads the concrete failure there. A narrowLocal
step folds transport.mapLocal's fd-quota members into error.SystemResources and the rest into
error.Unexpected. A declared content-length over max_body_bytes returns error.BodyTooLarge
before the body streams.
S7 forward_client. read_timeout is std.Io.Clock.Duration (S9 constructs it as
.{ .raw = .fromMilliseconds(200), .clock = .awake }). Stats has a fourth field,
foreign_datagrams. failures counts .peer_fault and .local_resource only — cancellations are
shutdown, not failure. The TC bit is read only after validateResponse matches the reply.
frame_buf is split in half between the stream writer and reader; min_frame_buf = 1024 is
asserted in init.
Orchestrator wiring. All thirteen new files are imported by src/tests.zig individually
(wildcard.zig is unreachable through parsers.zig for test collection). The TCP framing helpers
(prefix_len, framePrefix, parsePrefix) moved to upstream/transport.zig as their single home;
dot_client.zig, tcp_server.zig, forward_client.zig and both server integration tests now use
the transport.* forms, and the canonical framing tests live in transport.zig. zig build test
exits 0 after the move.
S5 rules + matcher. Snapshot.Input gains group_ids: []const i64 and source_ids: []const i64, parallel to groups and sources (the model structs carry no row id); length mismatch is an
assert. compiled is []const ?Compiled — null for an enabled source is
error.MissingCompiledSource; a disabled source needs no entry. On a prefix-length tie the lower
priority number wins, matching address.matchLongest and pool.zig. max_wildcards_per_group
counts both wildcard lists of a group combined, over rows before deduplication. Duplicate rule rows
are deduplicated before DomainSet.build. Level-6 nesting iterates parents outermost (most specific
first), sources innermost. A group_sources row naming a disabled source is legal; an unknown URL is
error.UnknownSource, an unknown group error.UnknownGroup. RuleSet carries a fifth field,
wildcard_bytes, backing both pattern lists. The snapshot builds every sub-structure from its own
arena, so Snapshot.deinit is one arena.deinit(). S8 must fill group_ids, source_ids and
the optional compiled entries accordingly.
S8 manager + sources_repo. The compile stage writes plain <id>.list.tmp / <id>.wild.tmp;
publish streams header + body into the final files via createFileAtomic + sync + replace at
0o600 — the header carries counts that exist only after the compile, so it cannot go into the same
atomic pass. Group row ids come from groups_repo.groupId(database, name) (added by the
orchestrator; a vanished name is error.GroupSetChanged) — the manager does not run raw SQL. A
source whose compiled files are missing, unreadable or checksum-mismatched is marked .load_failed
and excluded from the snapshot; the reload succeeds without it. A present, checksum-clean but
malformed body still fails the build and the previous snapshot keeps serving. updateSourceStats
takes the whole stats struct; the unchanged path re-writes the row and verifies the on-disk bodies
hash to the stored checksum before skipping (a corrupt file takes the rewrite path, which repairs
it). SourceStatus.state is a named pub const State with a no_valid_entries member: a download
that compiles to zero domains and zero wildcards with any nonzero invalid/unsupported/long-line
count fails the refresh, leaves the previous files serving, and records
NoValidEntries invalid=N unsupported=N long_lines=N. A failed refresh seeds its status from the
prior entry, so last_success and the counts of the still-serving files survive. A second lock,
writer_lock (plain std.Io.Mutex, separate from the RCU lock so readers never wait behind a
download), serializes reload, refreshSource, refreshAll, pruneOrphans and the startup pass.
Format sniffing collects parsers.sample_lines countable lines (never a flat byte window).
SourceStatus is a value type that borrows nothing: url is inline [max_url_len]u8 + url_len
(max_url_len = 255, truncation in the status only; the full url lives on the source row), read via
urlText(); a copy made under the lock outlives the table it came from. SourceStatus also splits
the refresh fact from the load fact: state records the latest attempt to produce the files,
loaded: bool records whether they are filtering right now, and a load outcome never overwrites a
refresh failure (State.isRefreshFailure names the three refresh states) — after refreshAll, a
source can honestly read .fetch_failed with loaded = true. Refresh tmp cleanup defers are
installed before the calls that create the files, so cancellation and OutOfMemory paths leak no
.tmp. A reload collects per-source LoadOutcomes while it builds the candidate snapshot and
applies them to the status table (applyLoadOutcomes) inside the same exclusive-lock section as the
swap — a reload that fails before publishing leaves both the snapshot and the status table
describing the previous generation, and the table and snapshot change atomically for readers. A
disabled source is recorded as not loaded; its state keeps the record of how it last stood. The
reload builds its status table off to the side (StatusTable, buildStatusTable, mergeStatuses)
and installs it in the same critical section that swaps the snapshot, so a reload that fails
publishes neither. refreshAll still syncs the table up front — a refresh pass needs somewhere to
record per-source outcomes as it goes.
Manager.Error adds Canceled, FileSystem,
GroupSetChanged. The snapshot seed comes from io.random (std.crypto.random does not exist in
0.16). The daily interval sleeps on .clock = .boot; S7's short timeouts stay on .awake.
sources_repo additions: SourceRow, SourceStats, listSourceRows, freeSourceRows,
updateSourceStats — additive only.
S9 note from S8: a loopback std.http.Server answering request.respond(body, .{}) deadlocks a
second fetch — the fetcher keeps the connection alive while a one-accept server waits for a new one.
Respond with .{ .keep_alive = false } or serve a keep-alive loop on the stream.
S9 integration + fuzz. Case 3 splits into the two real failure modes: a checksum-mismatched file
marks the source .load_failed and the reload succeeds without it (generation advances); only a
checksum-clean but malformed body fails reload and leaves the previous snapshot serving. Case 7
proves the cap from the response head: an explicit content-length of 100 MiB returns
error.BodyTooLarge before any body streams. Case 8 back-dates last_updated through
updateSourceStats and asserts inode and mtime of the compiled file are unchanged. Case 15 asserts
budget/2 <= elapsed < 2*budget (the POSIX backend wakes ~0.8 ms early on a 200 ms deadline). The
fuzz corpus is inline — tests/fuzz/corpus.zig imports the dns module, which the blocklist-fuzz
module does not have. Fuzz targets assert properties, not only absence of crashes: Line.text
windows the input (pointer containment), covers_apex only on .wildcard, detectFormat's answer
survives parseLine over the same bytes, and matches is exercised on rejected patterns.
Final wiring. build.zig gained the blocklist-fuzz artifact (module import parsers →
src/filter/parsers.zig, LLVM backend under -Dfuzz), hung off test_step beside the dns fuzz
artifact. Evaluation: zig build test, zig build test -Dintegration and zig build cross all
exit 0; both cross executables are statically linked.