# Milestone 4: Storage + Config Goal (PLAN §16 Phase 4): a thin SQLite wrapper over the vendored amalgamation; `config.db` schema with a compiled-in migration runner; `querylog.db` schema with recreate-on-mismatch; repositories; one config model with a pure validator; ZON bootstrap, `nxdns import`, `nxdns export`, `nxdns check`. Exit: first start seeds the DB from `/etc/nxdns/config.zon`; `export` → `import` → `export` is byte-identical. 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` (module conventions and the "As built" notes). The Zig source of truth is `/home/mokhtar/app/zig` at tag `0.16.0`. The SQLite source of truth is the vendored `sqlite3.h` inside the pinned `sqlite` dependency (3.53.4). PLAN §3.5–3.7, §3.13, §11, §12, §15 are the scope authority; §11.2 and §11.3 carry the DDL, reproduced verbatim below. ## What already exists (do not respecify, import it) - `src/platform/address.zig` — `NetAddress.parse`, `NetAddress.format`, `Prefix.parse`. The validator uses these for every IP and prefix string. Do not write a second IP parser. - `src/dns/name.zig` — `Name.fromText` (pure, allocation-free, rejects empty/oversize labels and oversize names). The validator uses this for every domain-name-shaped string. Do not write a second hostname validator. - `src/upstream/transport.zig` — `Endpoint.parse` (`https://…` → DoH, `tls://…` → DoT), `Endpoint`, `Client`, `ExchangeError`, `group`. The validator uses `Endpoint.parse` for upstream URLs; `nxdns check` builds real clients from the parsed endpoints. - `src/upstream/health.zig` — `health.Config`, `health.State`. - `src/upstream/pool.zig` — `Pool.init(entries, cfg, attempt_timeout, seed)`, `Pool.exchange`, `Pool.snapshot(io, out) Cancelable!usize`, `Pool.Snapshot`, `Pool.Entry`. `nxdns check` probes through this, one `Pool` per upstream (see S6.4). - `src/upstream/doh_client.zig`, `src/upstream/dot_client.zig` — the two `transport.Client` implementations `check` instantiates. - `build.zig` — `-Dintegration` (hermetic, loopback/tmpdir only, PR-blocking) and `-Dlive` (leaves the machine, manual workflow only) reach test files through `@import("build_options")`. The test artifact and the executable already link the static `sqlite3` library; no build change is needed to call SQLite from Zig. The `sqlite` dependency does **not** install headers — declare the C API as `extern` functions in Zig, exactly as `src/tests.zig` and `src/platform/tls_server.zig` already do. ## Sessions ``` S1 (storage/db.zig) S2 (config/model.zig + config/validate.zig) [parallel, no deps] | | +--> S3 (config_schema + migrations + querylog_schema) [needs S1] | | +---------+--------------------+ v S4 (storage/repositories/*) [needs S1, S2, S3] v S5 (config/bootstrap + import + export) [needs S4] v S6 (cli.zig + main.zig) [needs S5 + the M3 pool] v S7 (storage_integration_test.zig) [needs everything] ``` S1 and S2 are fully specified below, so they 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 any `build.zig` change. A session that needs a build change reports the exact change in its completion report. ## Session verification protocol (read this before starting) `zig test ` does **not** work for the files in this milestone. Every file here either imports across `src/` subdirectories or calls into the linked `sqlite3` library, and a standalone `zig test` invocation has neither the module wiring nor the C library. Therefore: - Every session verifies its own work with `zig fmt --check ` and `zig ast-check `. `zig ast-check` reports only what is decidable from the source alone — syntax and AST-level errors. It does **not** type-check, so it cannot prove the code compiles. - The orchestrator wires the session's files into `src/tests.zig` and runs `zig build test` (and `zig build test -Dintegration` for S7). That run is the real gate. - A session must state in its completion report that its tests have not been executed, and list the exact test names it wrote so the orchestrator can confirm they ran. - No session edits `build.zig`, `build.zig.zon`, or `src/tests.zig`. ## Design invariants (all sessions) - **`src/storage/db.zig` takes no `std.Io`.** This is the one deliberate exception to Decision E, and it must be documented in a comment at the top of the file. SQLite performs its own file I/O through its VFS; routing it through `std.Io` would mean writing a custom SQLite VFS — a large, security- sensitive component bought for nothing at household scale. Every *other* file in this milestone that touches the filesystem (`querylog_schema.zig`, `config/import.zig`, `config/export.zig`, `config/bootstrap.zig`, `src/cli.zig`) takes `io: std.Io`. - **No `@cImport`.** SQLite is reached through hand-written `extern` declarations, matching milestone 1's mbedTLS approach. - **The validator is pure**: no `std.Io`, no SQLite, no clock. It takes a `Config` and an allocator (for diagnostic text) and returns typed errors. It is unit-testable with `std.testing.allocator` alone. - **Nothing is silently swallowed.** A failed `ROLLBACK` is logged at `err` level with the SQLite message. A `busy_timeout` that does not take is an error. An unknown settings key is logged at `warn` and counted. A recreated `querylog.db` is logged at `warn` with the reason and the rename-aside path. - **Every list query has a deterministic `ORDER BY` whose trailing column set is unique.** Byte-stable export depends on it; append `id` when nothing else guarantees uniqueness. - **Arithmetic on config-supplied integers cannot panic.** Every numeric config field has an explicit sized type; every unit conversion lives in a named function in `config/model.zig` guarded by a `comptime` assertion that the field type's maximum times the conversion factor fits the destination type (see S2.4). - **`std.zon.parse` output is arena-owned.** Never call `std.zon.parse.free` on a parsed `Config`. See the verdict in S5.1 — this is a hard rule, not a style preference. - Unit tests live in-file. Tests that touch the filesystem or open real databases live in `src/storage/storage_integration_test.zig`, guarded by `if (!build_options.integration) return error.SkipZigTest;`, mirroring milestones 1 and 3. Exception: tests that open an in-memory database (`":memory:"`) touch no filesystem and stay in-file in the default `zig build test`. --- ## Verified 0.16.0 stdlib facts used by this milestone Read from `/home/mokhtar/app/zig` at tag `0.16.0`. The `make*` family that pre-0.16 knowledge suggests **does not exist**; do not reach for it. ### Filesystem — `lib/std/Io/Dir.zig`, `lib/std/Io/File.zig` ```zig pub fn cwd() Dir // Dir.zig:88, no io param pub fn access(dir: Dir, io: Io, sub_path: []const u8, options: AccessOptions) AccessError!void // Dir.zig:438 pub fn openDir(dir: Dir, io: Io, sub_path: []const u8, options: OpenOptions) OpenError!Dir // Dir.zig:481 pub fn close(dir: Dir, io: Io) void // Dir.zig:490 pub fn openFile(dir: Dir, io: Io, sub_path: []const u8, options: OpenFileOptions) File.OpenError!File // Dir.zig:577 pub fn createFile(dir: Dir, io: Io, sub_path: []const u8, flags: CreateFileOptions) File.OpenError!File // Dir.zig:638 pub fn createDir(dir: Dir, io: Io, sub_path: []const u8, permissions: Permissions) CreateDirError!void // Dir.zig:797 pub fn createDirPath(dir: Dir, io: Io, sub_path: []const u8) CreateDirPathError!void // Dir.zig:843 pub fn createDirPathStatus(dir: Dir, io: Io, sub_path: []const u8, permissions: Permissions) CreateDirPathError!CreatePathStatus // Dir.zig:851 pub fn statFile(dir: Dir, io: Io, sub_path: []const u8, options: StatFileOptions) StatFileError!Stat // Dir.zig:899 pub fn deleteFile(dir: Dir, io: Io, sub_path: []const u8) DeleteFileError!void // Dir.zig:1004 pub fn readFileAlloc(dir: Dir, io: Io, sub_path: []const u8, gpa: Allocator, limit: Io.Limit) ReadFileAllocError![]u8 // Dir.zig:1326 pub fn readFileAllocOptions(dir: Dir, io: Io, sub_path: []const u8, gpa: Allocator, limit: Io.Limit, comptime alignment: std.mem.Alignment, comptime sentinel: ?u8) ... // Dir.zig:1346 pub fn setFilePermissions(dir: Dir, io: Io, sub_path: []const u8, new_permissions: File.Permissions, options: SetFilePermissionsOptions) SetFilePermissionsError!void // Dir.zig:1959 ``` `rename` takes `io` **last** and both directories (Dir.zig:1093): ```zig pub fn rename(old_dir: Dir, old_sub_path: []const u8, new_dir: Dir, new_sub_path: []const u8, io: Io) RenameError!void ``` It **replaces** an existing destination (doc comment Dir.zig:1085; POSIX `renameat`). The non-replacing counterpart is `renamePreserve` (Dir.zig:1133, same `io`-last order), which returns `error.PathAlreadyExists` — Linux `renameat2` with `RENAME_NOREPLACE`. S3 uses `renamePreserve` to uniquify the corrupt-querylog aside name. Permissions are `File.Permissions` (File.zig:335), a non-exhaustive enum over `std.posix.mode_t` with `fromMode(mode) Permissions` (File.zig:385) and `toMode() std.posix.mode_t` (File.zig:381). Named values `default_file = 0o666`, `default_dir = 0o777`. - 0o600 file: `dir.createFile(io, name, .{ .permissions = .fromMode(0o600) })` — `Dir.CreateFileOptions.permissions` (Dir.zig:632). There is **no `mode` field**; `File.CreateFlags` is a deprecated alias of `Dir.CreateFileOptions` (File.zig:158). - 0o700 directory: `dir.createDir(io, name, .fromMode(0o700))`, or `dir.createDirPathStatus(io, path, .fromMode(0o700))`. Plain `createDirPath` (Dir.zig:843) **hardcodes `.default_dir`** and must not be used where the mode matters. - There is no function named `chmod`; use `File.setPermissions` (File.zig:308), `Dir.setPermissions` (Dir.zig:1942), or `Dir.setFilePermissions` (Dir.zig:1959). Atomic write — present, but not under the pre-0.16 names (`atomicFile`/`AtomicFile` do not exist): ```zig pub fn createFileAtomic(dir: Dir, io: Io, sub_path: []const u8, options: CreateFileAtomicOptions) CreateFileAtomicError!File.Atomic // Dir.zig:1924 // CreateFileAtomicOptions (Dir.zig:1870): permissions: File.Permissions = .default_file, // make_path: bool = false, replace: bool = false pub fn deinit(af: *Atomic, io: Io) void // File/Atomic.zig:23 pub fn link(af: *Atomic, io: Io) LinkError!void // File/Atomic.zig:48 (non-replacing) pub fn replace(af: *Atomic, io: Io) ReplaceError!void // File/Atomic.zig:77 (replacing) ``` File I/O: ```zig pub fn writer(file: File, io: Io, buffer: []u8) Writer // File.zig:600, returns File.Writer BY VALUE pub fn close(file: File, io: Io) void // File.zig:221 pub fn sync(file: File, io: Io) SyncError!void // File.zig:241 (there is no syncAll) pub fn stat(file: File, io: Io) StatError!Stat // File.zig:141 ``` `File.writeAll` does **not** exist. Write through the interface: `var fw = file.writer(io, &buf); const w = &fw.interface; try w.writeAll(bytes); try w.flush();` The `File.Writer` value is self-referential (`@fieldParentPtr("interface", …)`, File/Writer.zig:90) and must be stored in a `var` that does not move. ### Clock — `lib/std/Io.zig` ```zig pub fn now(clock: Clock, io: Io) Io.Timestamp // Io.zig:778 → std.Io.Clock.real.now(io) pub const Timestamp = struct { nanoseconds: i96, ... } // Io.zig:906 pub fn toSeconds(t: Timestamp) i64 { return @intCast(@divTrunc(t.nanoseconds, std.time.ns_per_s)); } // Io.zig:943 ``` `io.now(.real)` does **not** exist (confirmed: no top-level `pub fn now` in Io.zig). Unix epoch seconds for `first_seen`/`last_seen`/`created_at` columns: `std.Io.Clock.real.now(io).toSeconds()`. `Clock.real` is documented (Io.zig:733) as relative to 1970-01-01T00:00:00Z, so no offset is needed. ### `std.zon` — `lib/std/zon/parse.zig`, `lib/std/zon/stringify.zig` ```zig pub fn fromSliceAlloc(T: type, gpa: Allocator, source: [:0]const u8, diag: ?*Diagnostics, options: Options) error{ OutOfMemory, ParseZon }!T // parse.zig:276 pub fn free(gpa: Allocator, value: anytype) void // parse.zig:412 pub fn serialize(val: anytype, options: SerializeOptions, writer: *Writer) Writer.Error!void // stringify.zig:45 // SerializeOptions (stringify.zig:29): whitespace: bool = true, emit_codepoint_literals = .never, // emit_strings_as_containers: bool = false, // emit_default_optional_fields: bool = true ``` ### Testing `std.testing.checkAllAllocationFailures(backing, test_fn, extra_args)` (testing.zig:1115) and `std.testing.FailingAllocator` (testing.zig:13) exist and are the required tool for the repository leak-safety criteria in S4. ### SQLite — vendored amalgamation 3.53.4 Verified from the pinned dependency's `sqlite3.h`. Result codes: `SQLITE_OK 0`, `ERROR 1`, `INTERNAL 2`, `PERM 3`, `ABORT 4`, `BUSY 5`, `LOCKED 6`, `NOMEM 7`, `READONLY 8`, `INTERRUPT 9`, `IOERR 10`, `CORRUPT 11`, `NOTFOUND 12`, `FULL 13`, `CANTOPEN 14`, `PROTOCOL 15`, `EMPTY 16`, `SCHEMA 17`, `TOOBIG 18`, `CONSTRAINT 19`, `MISMATCH 20`, `MISUSE 21`, `NOLFS 22`, `AUTH 23`, `FORMAT 24`, `RANGE 25`, `NOTADB 26`, `ROW 100`, `DONE 101`. Open flags: `READONLY 0x1`, `READWRITE 0x2`, `CREATE 0x4`, `URI 0x40`, `NOMUTEX 0x8000`, `FULLMUTEX 0x10000`, `EXRESCODE 0x2000000`. `SQLITE_STATIC` is destructor pointer `0`, `SQLITE_TRANSIENT` is `-1`. --- ## Session S1: `src/storage/db.zig` The whole SQLite surface nxdns owns (PLAN Decision G). Nothing above this file calls SQLite directly. ### S1.1 extern layer Declare the C API by hand. Opaque handles, no `@cImport`: ```zig pub const c = struct { pub const Sqlite3 = opaque {}; pub const Stmt = opaque {}; pub const Destructor = *const fn (?*anyopaque) callconv(.c) void; pub const transient: ?Destructor = @ptrFromInt(@as(usize, @bitCast(@as(isize, -1)))); pub extern fn sqlite3_open_v2(filename: [*:0]const u8, ppDb: *?*Sqlite3, flags: c_int, zVfs: ?[*:0]const u8) c_int; pub extern fn sqlite3_close_v2(db: ?*Sqlite3) c_int; pub extern fn sqlite3_extended_result_codes(db: *Sqlite3, onoff: c_int) c_int; pub extern fn sqlite3_busy_timeout(db: *Sqlite3, ms: c_int) c_int; pub extern fn sqlite3_exec(db: *Sqlite3, sql: [*:0]const u8, cb: ?*const anyopaque, arg: ?*anyopaque, errmsg: ?*?[*:0]u8) c_int; pub extern fn sqlite3_errmsg(db: *Sqlite3) [*:0]const u8; pub extern fn sqlite3_errcode(db: *Sqlite3) c_int; pub extern fn sqlite3_extended_errcode(db: *Sqlite3) c_int; pub extern fn sqlite3_errstr(code: c_int) [*:0]const u8; pub extern fn sqlite3_prepare_v2(db: *Sqlite3, sql: [*]const u8, n_byte: c_int, ppStmt: *?*Stmt, pzTail: ?*?[*]const u8) c_int; pub extern fn sqlite3_step(stmt: *Stmt) c_int; pub extern fn sqlite3_reset(stmt: *Stmt) c_int; pub extern fn sqlite3_clear_bindings(stmt: *Stmt) c_int; pub extern fn sqlite3_finalize(stmt: ?*Stmt) c_int; pub extern fn sqlite3_bind_int64(stmt: *Stmt, idx: c_int, value: i64) c_int; pub extern fn sqlite3_bind_text(stmt: *Stmt, idx: c_int, text: [*]const u8, n: c_int, d: ?Destructor) c_int; pub extern fn sqlite3_bind_null(stmt: *Stmt, idx: c_int) c_int; pub extern fn sqlite3_bind_parameter_count(stmt: *Stmt) c_int; pub extern fn sqlite3_column_count(stmt: *Stmt) c_int; pub extern fn sqlite3_column_type(stmt: *Stmt, col: c_int) c_int; pub extern fn sqlite3_column_int64(stmt: *Stmt, col: c_int) i64; pub extern fn sqlite3_column_text(stmt: *Stmt, col: c_int) ?[*]const u8; pub extern fn sqlite3_column_bytes(stmt: *Stmt, col: c_int) c_int; pub extern fn sqlite3_last_insert_rowid(db: *Sqlite3) i64; pub extern fn sqlite3_changes(db: *Sqlite3) c_int; }; ``` Column type codes are `SQLITE_INTEGER 1`, `FLOAT 2`, `TEXT 3`, `BLOB 4`, `NULL 5`; declare them as named constants rather than magic numbers. ### S1.2 error set and code mapping ```zig pub const Error = error{ Abort, Auth, Busy, CantOpen, Constraint, Corrupt, Empty, Format, Full, Internal, Interrupt, IoErr, Locked, Mismatch, Misuse, NoLfs, NotADb, NotFound, Perm, Protocol, Range, ReadOnly, Schema, TooBig, SqliteError, OutOfMemory, Unexpected, }; /// Maps a primary SQLite result code to `Error`. `SQLITE_NOMEM` becomes `error.OutOfMemory` /// so it joins `transport.LocalResource` semantics: out of memory is never the data's fault. pub fn mapCode(code: c_int) Error; ``` Rules: - `mapCode` switches on the **primary** code, `code & 0xff`, so extended codes (`SQLITE_IOERR_*`, `SQLITE_CONSTRAINT_*`, `SQLITE_BUSY_SNAPSHOT`, …) land on their family. The extended code is preserved for humans through `Db.lastError`. - `SQLITE_OK`, `SQLITE_ROW` and `SQLITE_DONE` are not errors — `mapCode` asserts it is never called with them. - The switch is explicit over every listed primary code with `else => error.SqliteError` for future codes; `SqliteError` is a distinct member so an unmapped code is visible rather than disguised as a known failure. - A test walks all codes 1–26 and asserts each maps to a distinct member, and that `mapCode(SQLITE_NOMEM) == error.OutOfMemory`. ### S1.3 `Db` ```zig pub const OpenMode = enum { read_write_create, read_write_existing, read_only, memory }; pub const OpenOptions = struct { mode: OpenMode = .read_write_create, busy_timeout_ms: c_int = 5000, }; pub const Db = struct { handle: *c.Sqlite3, pub fn open(path: [:0]const u8, options: OpenOptions) Error!Db; pub fn close(self: *Db) void; /// Borrowed; valid until the next SQLite call on this handle. Formats as /// " (code /)". pub fn lastError(self: *Db, buf: []u8) []const u8; pub fn exec(self: *Db, sql: [:0]const u8) Error!void; pub fn prepare(self: *Db, sql: []const u8) Error!Stmt; /// Runs `sql` (which must yield exactly one row with one integer column) and returns it. pub fn queryInt(self: *Db, sql: []const u8) Error!i64; pub fn lastInsertRowid(self: *Db) i64; pub fn changes(self: *Db) i64; }; ``` `open` rules: 1. Flags: `.read_write_create` → `READWRITE|CREATE|EXRESCODE|FULLMUTEX`; `.read_write_existing` → `READWRITE|EXRESCODE|FULLMUTEX`; `.read_only` → `READONLY|EXRESCODE|FULLMUTEX`; `.memory` → `READWRITE|CREATE|EXRESCODE|FULLMUTEX` with path `":memory:"`. `FULLMUTEX` (serialized mode) because Phase 6's query logger and Phase 8's API handlers will share one handle across `std.Io` tasks; a per-handle mutex inside SQLite is cheaper to be correct about than a hand-rolled one, and config.db write volume is negligible. Document this in a comment. `EXRESCODE` so `sqlite3_extended_errcode` is meaningful from the first call. 2. **`sqlite3_open_v2` allocates a handle even on failure.** On a non-`OK` return, read the message, call `sqlite3_close_v2` on the returned handle, and only then return the mapped error. Losing this handle is a leak on every failed open; a test with a directory path (`error.CantOpen`) covers it. 3. `sqlite3_busy_timeout` **result is checked** and mapped. A silently ignored busy timeout is how a contended WAL database turns into random `SQLITE_BUSY` failures under load. 4. `open` does **not** apply pragmas. `applyPragmas` is separate (S1.5) because the migration runner must apply `foreign_keys` before it opens a transaction. `prepare` takes a non-sentinel `[]const u8` and passes `@intCast(sql.len)` as `nByte`, with `pzTail` non-null; if the tail is not exhausted the statement text contained more than one statement — return `error.Misuse`. Multi-statement text belongs in `exec`. `exec` passes `null` for the `errmsg` out-parameter and reads `sqlite3_errmsg` instead, so there is no `sqlite3_free` obligation. `exec` is for DDL and multi-statement scripts only. ### S1.4 `Stmt` ```zig pub const Stmt = struct { handle: *c.Stmt, db: *Db, pub fn deinit(self: *Stmt) void; // sqlite3_finalize; logs a non-OK code at err level pub fn reset(self: *Stmt) Error!void; // sqlite3_reset + sqlite3_clear_bindings /// 1-based indices, matching SQLite. pub fn bindInt(self: *Stmt, idx: c_int, value: i64) Error!void; pub fn bindBool(self: *Stmt, idx: c_int, value: bool) Error!void; pub fn bindText(self: *Stmt, idx: c_int, value: []const u8) Error!void; pub fn bindTextOrNull(self: *Stmt, idx: c_int, value: ?[]const u8) Error!void; pub fn bindNull(self: *Stmt, idx: c_int) Error!void; /// true = a row is available, false = statement finished. pub fn step(self: *Stmt) Error!bool; /// Runs to completion; asserts no rows were produced. pub fn exec(self: *Stmt) Error!void; pub fn columnInt(self: *Stmt, col: c_int) i64; pub fn columnBool(self: *Stmt, col: c_int) bool; // != 0 pub fn isNull(self: *Stmt, col: c_int) bool; /// Borrowed: valid only until the next `step`, `reset` or `deinit` on this statement. /// Every caller that keeps the value must copy it. Documented on the function. pub fn columnText(self: *Stmt, col: c_int) []const u8; pub fn columnTextOrNull(self: *Stmt, col: c_int) ?[]const u8; /// Copies into `gpa`. Caller owns the result. pub fn columnTextAlloc(self: *Stmt, gpa: std.mem.Allocator, col: c_int) error{OutOfMemory}![]u8; pub fn columnTextAllocOrNull(self: *Stmt, gpa: std.mem.Allocator, col: c_int) error{OutOfMemory}!?[]u8; }; ``` - `bindText` uses `c.transient` so the caller never has to keep the source buffer alive. Document the cost (SQLite copies) and why correctness wins here. - `columnText` on a NULL column: `sqlite3_column_text` returns null; return `""` from `columnText` and `null` from `columnTextOrNull`. A schema with `NOT NULL` on the column makes the first case unreachable in practice, but it must not be undefined behaviour. - `step` maps `SQLITE_ROW` → `true`, `SQLITE_DONE` → `false`, everything else through `mapCode`. - **No prepared-statement cache in this milestone.** `config.db` is written a handful of times per process lifetime; a cache is unmeasured complexity here. Phase 6's query-log flush loop is the only hot path and it will own its own long-lived statements. State this decision in a comment so it does not read as an oversight against PLAN §3.4. ### S1.5 pragmas and transactions ```zig pub const Pragmas = struct { journal_wal: bool = true, synchronous_normal: bool = true, foreign_keys: bool = true, }; /// MUST be called before any transaction is opened: `PRAGMA foreign_keys` is a no-op inside a /// transaction, so applying it later silently leaves referential integrity off. pub fn applyPragmas(self: *Db, p: Pragmas) Error!void; ``` - `PRAGMA journal_mode = WAL` **returns a row**. Run it through `prepare`/`step` and verify the returned text equals `"wal"` (ASCII case-insensitive); otherwise return `error.SqliteError`. An `exec` here would discard the answer and a memory database (which cannot do WAL) would look fine. For `.memory` databases, accept `"memory"` as the result — in-memory tests must not fail on this. - `PRAGMA synchronous = NORMAL` produces no row; `exec` is fine. - `PRAGMA foreign_keys = ON` produces no row, but the follow-up `PRAGMA foreign_keys` **does**; read it back and require `1`, else `error.SqliteError`. ```zig pub const Tx = struct { db: *Db, active: bool, /// BEGIN IMMEDIATE — takes the write lock up front. A deferred transaction that upgrades /// mid-way can fail with SQLITE_BUSY after arbitrary work; immediate cannot. pub fn begin(db: *Db) Error!Tx; pub fn commit(self: *Tx) Error!void; /// Safe in `errdefer` and after `commit`. Never returns an error; a failed ROLLBACK is logged /// at `err` level with the SQLite message, because a database that will not roll back is an /// operational event, not a detail. pub fn rollback(self: *Tx) void; }; ``` Usage contract, stated in a doc comment and followed everywhere in this milestone: ```zig var tx = try Tx.begin(db); errdefer tx.rollback(); ... // all writes try tx.commit(); ``` `commit` and `rollback` both clear `active`, so the `errdefer` after a successful commit is a no-op. `begin` on a `Tx` whose `active` is already true is a programming error — assert. ### S1.6 Tests (in-file, `:memory:` only) - `mapCode` distinctness over codes 1–26; `SQLITE_NOMEM` → `error.OutOfMemory`. - Open/close a `:memory:` database; `applyPragmas` succeeds and `PRAGMA foreign_keys` reads back 1. - `open` on a path that is a directory returns `error.CantOpen` and leaks nothing (run it 1000 times in a loop; the test passes if it does not exhaust file descriptors — a crude but real regression guard for the close-on-failed-open rule). - `prepare` on two statements separated by `;` returns `error.Misuse`. - Bind/step/column round-trip: create a table, insert one row with text and integer columns, read it back, including a NULL text column through `columnTextOrNull`. - `columnTextAlloc` returns an owned copy that survives a subsequent `step`. - Transaction commits persist; `rollback` discards; `rollback` after `commit` is a no-op. - A statement whose `exec` produces a row triggers the assertion path — assert instead through `step` returning true, to keep the test free of `unreachable`. - A constraint violation (insert a duplicate into a `UNIQUE` column) returns `error.Constraint`. ### S1.7 Acceptance criteria - [ ] `zig fmt --check src/storage/db.zig` and `zig ast-check src/storage/db.zig` clean. - [ ] No `@cImport` anywhere in the file. - [ ] The `std.Io` exception is documented in a file-level comment. - [ ] `sqlite3_busy_timeout`'s result is checked; a failed `ROLLBACK` reaches `std.log.err`. - [ ] Every bullet in S1.6 exists as a named test. --- ## Session S2: `src/config/model.zig`, `src/config/validate.zig` Pure. No `std.Io`, no SQLite. These two files are the contract every later session codes against. ### S2.1 PLAN §12.1 amendments (deliberate, not drift) PLAN §12.1 sketches the bootstrap file. Three of its shapes cannot round-trip the database, and the database is truth (Decision F). The model below changes them; the orchestrator updates PLAN §12.1 to match. The reasons: 1. `.upstream.servers = .{ "url", … }` (a list of strings) cannot express the `upstreams` table's `priority` and `enabled` columns. Export would silently drop a disabled upstream or renumber priorities, and the byte-stable round trip would be a lie. The model uses `.upstreams = .{ .{ .url = …, .priority = 100, .enabled = true }, … }`. 2. `.local_records` entries gain `.ttl` (default 300), matching the column. 3. `web.password` is operator input; storing it verbatim contradicts PLAN §3.11 (argon2id). The model carries **both** `password` (input only, always exported as `""`) and `password_hash` (the stored PHC string). See S2.5. The DB also holds five collections §12.1 never mentions — `groups`, `clients`, `client_prefixes`, `blocklist_sources`, `rules` — and export must reproduce them. They are added below. ### S2.2 The model One struct, used by bootstrap, import, export and (Phase 7) the running server. Every field has a default so a minimal bootstrap file is legal. ```zig pub const Config = struct { runtime: Runtime = .{}, upstream: Upstream = .{}, dns: Dns = .{}, blocking: Blocking = .{}, cache: Cache = .{}, web: Web = .{}, doh_server: TlsEndpoint = .{}, dot_server: TlsEndpoint = .{ .port = 853 }, edns: Edns = .{}, logging: Logging = .{}, disk: Disk = .{}, blocklist_update: BlocklistUpdate = .{}, groups: []const Group = &.{}, upstreams: []const UpstreamServer = &.{}, clients: []const Client = &.{}, client_prefixes: []const ClientPrefix = &.{}, blocklist_sources: []const BlocklistSource = &.{}, group_sources: []const GroupSource = &.{}, rules: []const Rule = &.{}, local_records: []const LocalRecord = &.{}, forward_zones: []const ForwardZone = &.{}, }; ``` Scalar sections — the types are load-bearing (S2.4): ```zig pub const IoBackend = enum { threaded, evented }; // PLAN §3.2 pub const Runtime = struct { io_backend: IoBackend = .threaded }; pub const Upstream = struct { connect_timeout_ms: u32 = 2000, read_timeout_ms: u32 = 3000, total_timeout_ms: u32 = 5000, // PLAN §9 total budget }; pub const Dns = struct { bind_ipv4: []const u8 = "0.0.0.0", bind_ipv6: []const u8 = "::", port: u16 = 53, rate_limit: u32 = 1000, rate_window_seconds: u32 = 60, }; pub const BlockResponse = enum { zero, nxdomain }; pub const Blocking = struct { response: BlockResponse = .zero, ttl: u32 = 5 }; pub const Cache = struct { size: u32 = 10000, negative_ttl_max: u32 = 3600 }; pub const Web = struct { enabled: bool = true, bind: []const u8 = "0.0.0.0", port: u16 = 8080, password: []const u8 = "", // input only; never stored, always exported as "" password_hash: []const u8 = "", // argon2id PHC string; "" disables auth session_ttl_hours: u16 = 24, api_rate_limit_per_min: u32 = 300, sse_max_connections_per_ip: u16 = 3, }; pub const TlsEndpoint = struct { enabled: bool = false, bind: []const u8 = "0.0.0.0", port: u16 = 443, cert_path: []const u8 = "/etc/nxdns/cert.pem", key_path: []const u8 = "/etc/nxdns/key.pem", }; pub const EcsMode = enum { strip, forward }; pub const Edns = struct { ecs_mode: EcsMode = .strip }; pub const LogLevel = enum { err, warn, info, debug }; pub const LogOutput = enum { stderr, syslog, file }; pub const Logging = struct { level: LogLevel = .info, retention_days: u16 = 30, query_log_buffer_max: u32 = 10000, hide_domains: bool = false, hide_client_ips: bool = false, output: LogOutput = .stderr, file_path: []const u8 = "/var/log/nxdns/nxdns.log", max_size_mb: u32 = 50, max_files: u8 = 5, }; pub const Disk = struct { min_free_mb: u32 = 200, warn_free_mb: u32 = 500 }; pub const BlocklistUpdate = struct { enabled: bool = true, interval_hours: u16 = 24 }; ``` Collections — every group and source reference is **by name/URL**, never by row id, because ids are not stable across an import: ```zig pub const Group = struct { name: []const u8, safe_search: bool = false }; pub const UpstreamServer = struct { url: []const u8, priority: i32 = 100, enabled: bool = true }; pub const Client = struct { ip: []const u8, name: []const u8 = "", group: []const u8 = "default" }; pub const ClientPrefix = struct { prefix: []const u8, group: []const u8 = "default", priority: i32 = 100 }; pub const BlocklistSource = struct { url: []const u8, name: []const u8, enabled: bool = true, is_suggested: bool = false, }; pub const GroupSource = struct { group: []const u8, source_url: []const u8 }; pub const RuleKind = enum { exact, wildcard }; pub const RuleAction = enum { allow, block }; pub const Rule = struct { group: []const u8, pattern: []const u8, kind: RuleKind, action: RuleAction }; pub const RecordType = enum { a, aaaa, cname }; // stored as 'A'/'AAAA'/'CNAME' pub const LocalRecord = struct { name: []const u8, rtype: RecordType, value: []const u8, ttl: u32 = 300 }; pub const ForwardZone = struct { zone: []const u8, resolver: []const u8 }; ``` **Runtime columns are deliberately absent from the model.** `clients.first_seen`, `clients.last_seen`, `rules.created_at`, and `blocklist_sources.{last_updated, domain_count, wildcard_count, skipped_regex_count, checksum}` are facts a running server produces, not configuration. Including them would make two exports taken minutes apart differ and would make the byte-stable round-trip criterion untestable against a live server. Import sets the timestamps to the import time and the counters to their column defaults. Document this in the file. `RecordType` maps to the DDL's `CHECK(rtype IN ('A','AAAA','CNAME'))` through explicit `toDb`/`fromDb` functions in `model.zig`; the enum tag names are lowercase because ZON enum literals are, and `.A` would be an unusual Zig identifier. Same pattern for `RuleKind`, `RuleAction`, `BlockResponse`, `EcsMode`, `LogLevel`, `LogOutput`, `IoBackend`, whose DB text is the lowercase tag name (`@tagName`) with one exception: `LogLevel.err` is stored as `"error"`, because that is the operator-facing word. Both directions are explicit functions with a round-trip test. ### S2.3 settings mapping The eleven scalar sections live in `settings(key, value)` as TEXT. The key is `"
."`, built at comptime from the field names, so a new field cannot drift out of the mapping: ```zig pub const SettingPair = struct { key: []const u8, value: []const u8 }; /// Writes every scalar field of `cfg` as a key/value pair into `out`. Keys are comptime strings /// (never freed); values are allocated from `gpa`. pub fn toSettings(cfg: Config, gpa: std.mem.Allocator, out: *std.ArrayList(SettingPair)) error{OutOfMemory}!void; pub const SettingsError = error{ BadSettingValue, OutOfMemory }; /// Applies pairs onto `cfg`, which the caller has initialized to `.{}` (all defaults). /// An absent key keeps the default — that is how a migration adds a setting with no data step. /// An unknown key is logged at `warn` and counted in `unknown_keys`; it is never an error, because /// downgrading a binary must not brick a config database. pub fn fromSettings(pairs: []const SettingPair, cfg: *Config, unknown_keys: *usize) SettingsError!void; ``` Both are implemented with `inline for` over `@typeInfo(Section).@"struct".fields` for each section, so the section list appears exactly once. Value encoding, one function per field type: - `bool` → `"true"` / `"false"`; anything else → `error.BadSettingValue`. - integers → `std.fmt.parseInt` / decimal formatting, on the field's declared type, so an out-of-range stored value is `error.BadSettingValue` rather than a truncating cast. - enums → `std.meta.stringToEnum` on the DB text form; an unknown tag is `error.BadSettingValue`. - `[]const u8` → verbatim; allocated from `gpa` on the way out of the DB. `web.password` is skipped in **both** directions — it is never a settings row (S2.5). The full key list is 47 entries and is asserted by a test: `toSettings` on a default `Config` produces exactly the expected sorted key list, written out literally in the test. A field added without updating that literal breaks the test — which is the point. ### S2.4 no-panic arithmetic Every unit conversion the rest of the program needs lives here as a named function, and every one is guarded by a `comptime` assertion that the field type's maximum cannot overflow the destination: ```zig pub fn connectTimeout(u: Upstream) std.Io.Duration; // ms → ns pub fn readTimeout(u: Upstream) std.Io.Duration; pub fn totalTimeout(u: Upstream) std.Io.Duration; pub fn sessionTtlSeconds(w: Web) i64; // hours → seconds pub fn retentionSeconds(l: Logging) i64; // days → seconds pub fn maxLogBytes(l: Logging) u64; // MiB → bytes pub fn minFreeBytes(d: Disk) u64; pub fn warnFreeBytes(d: Disk) u64; pub fn updateIntervalSeconds(b: BlocklistUpdate) i64; // hours → seconds comptime { assertFits(u32, std.time.ns_per_ms, i96); // timeouts assertFits(u16, 3600, i64); // session ttl assertFits(u16, 86400, i64); // retention, update interval assertFits(u32, 1024 * 1024, u64); // MiB conversions } ``` `assertFits(FieldType, factor, Dest)` is a comptime helper that `@compileError`s unless `@as(u128, std.math.maxInt(FieldType)) * factor <= std.math.maxInt(Dest)`. No conversion in this file uses a `@intCast` on a value whose range has not been proven this way, and no conversion can return an error — the types make overflow impossible, which is stronger than checking at runtime. `std.Io.Duration` is nanoseconds in an `i96` (verified above), so the timeout conversions are trivially safe; the assertion is there so a later widening of a field type breaks the build. ### S2.5 password handling - `web.password` is operator input in a hand-written bootstrap or import file. - `web.password_hash` is the argon2id PHC string actually stored in `settings` under `web.password_hash`. - Import/bootstrap rule (implemented in S5, specified here because it is model semantics): - `password != "" and password_hash != ""` → `error.PasswordAndHashBothSet`. Ambiguity in a security setting is refused, not guessed. - `password != ""` → hash it, store the hash, discard the plaintext. - `password == ""` → store `password_hash` verbatim (possibly `""`, which disables auth per PLAN §3.11). - Export always emits `.password = ""` and the stored `.password_hash`. This is exactly what makes the round trip byte-stable: the second import takes the `password == ""` branch and stores the same hash, so the third export is identical to the second. - Hashing uses `std.crypto.pwhash.argon2.strHash(password, .{ .allocator = gpa, .params = .owasp_2id, .mode = .argon2id, .encoding = .phc }, out_buf, io)` (verified: argon2.zig:591; `Params.owasp_2id` is `t=2, m=19 MiB, p=1`, argon2.zig:96). `owasp_2id` rather than `interactive_2id` (64 MiB) because PLAN §18 budgets under 100 MB total on a Pi 5. `out_buf` is 256 bytes, which comfortably holds a PHC-encoded argon2id string. - **Not in this milestone:** verification, sessions, cookies, any login flow. Only the hash is produced and stored. ### S2.6 the validator ```zig pub const Problem = struct { /// Dotted path into the config, e.g. "upstreams[2].url" or "web.port". Owned by Diagnostics. path: []const u8, /// Human-readable, e.g. "unknown group 'kids'". Owned by Diagnostics. message: []const u8, err: ValidateError, }; pub const Diagnostics = struct { gpa: std.mem.Allocator, problems: std.ArrayList(Problem), pub fn init(gpa: std.mem.Allocator) Diagnostics; pub fn deinit(self: *Diagnostics) void; // frees every path and message pub fn add(self: *Diagnostics, err: ValidateError, comptime fmt: []const u8, args: anytype) error{OutOfMemory}!void; /// Writes one "path: message" line per problem. pub fn writeAll(self: *const Diagnostics, w: *std.Io.Writer) std.Io.Writer.Error!void; }; pub const ValidateError = error{ NoUpstreams, BadUpstreamUrl, DuplicateUpstreamUrl, MissingDefaultGroup, DuplicateGroupName, UnknownGroup, EmptyGroupName, BadClientIp, DuplicateClientIp, BadClientPrefix, DuplicateClientPrefix, BadSourceUrl, DuplicateSourceUrl, EmptySourceName, UnknownSource, DuplicateGroupSource, BadRulePattern, BadLocalRecordName, BadLocalRecordValue, DuplicateLocalRecord, BadForwardZone, DuplicateForwardZone, BadResolverUrl, BadPort, BadTimeout, BadTtl, BadRetention, BadLogRotation, BadDiskThresholds, BadRateLimit, BadBindAddress, MissingCertPath, MissingKeyPath, MissingLogPath, PasswordAndHashBothSet, OutOfMemory, }; /// Collects EVERY problem into `diags`, then returns the first one's `err`. /// Pure: no Io, no SQLite, no clock. The allocator is only for diagnostic text. pub fn validate(cfg: Config, diags: *Diagnostics) ValidateError!void; ``` The controlling requirement: **the validator must reject everything the schema would reject.** An import that passes validation and then dies on a `Constraint` mid-transaction gives the operator a SQLite error message instead of a line number. Concretely: *Uniqueness* — one check per `UNIQUE` constraint in §11.2, each implemented with a `std.StringHashMapUnmanaged(void)` over the already-seen values: | Constraint | Check | |---|---| | `groups.name UNIQUE` | `DuplicateGroupName`; also `EmptyGroupName` for `""` | | `upstreams.url UNIQUE` | `DuplicateUpstreamUrl` | | `clients.ip UNIQUE` | `DuplicateClientIp`, compared on the **canonical** form (see below) | | `client_prefixes.prefix UNIQUE` | `DuplicateClientPrefix`, canonical form | | `blocklist_sources.url UNIQUE` | `DuplicateSourceUrl` | | `group_sources PRIMARY KEY (group,source)` | `DuplicateGroupSource` | | `local_records UNIQUE(name,rtype,value)` | `DuplicateLocalRecord` | | `forward_zones.zone UNIQUE` | `DuplicateForwardZone` | `rules` has no `UNIQUE` constraint, so duplicate rules are accepted; export sorts them deterministically, so a duplicate still round-trips byte-stably. Client IPs and prefixes are compared **after canonicalization** through `NetAddress.parse` + `format` / `Prefix.parse` + `format`, because `fd00::1` and `FD00:0:0:0:0:0:0:1` are the same row to a human but two distinct rows to `UNIQUE`. Import writes the canonical form, so the validator must detect the collision the same way. *Foreign keys* — every `group` reference in **all four** referencing collections (`clients`, `client_prefixes`, `group_sources`, `rules`) must name a group present in `cfg.groups` (`UnknownGroup`), and every `group_sources.source_url` must name a source present in `cfg.blocklist_sources` (`UnknownSource`). `cfg.groups` must contain a group named `"default"` (`MissingDefaultGroup`) — §11.2 seeds it, §7.2 depends on it, and an import that dropped it would leave the server with nowhere to assign an unknown client. *Parsing* — reuse, do not reimplement: - upstream URLs → `transport.Endpoint.parse` (`BadUpstreamUrl`). - `clients.ip` → `NetAddress.parse` (`BadClientIp`). - `client_prefixes.prefix` → `Prefix.parse` (`BadClientPrefix`). - `dns.bind_ipv4` must parse as an `.ip4`, `dns.bind_ipv6` and `web.bind`, `doh_server.bind`, `dot_server.bind` must parse as either family (`BadBindAddress`). - every domain-shaped string — `rules.pattern` (with `*` segments removed first), `local_records.name`, a `cname` record's `value`, `forward_zones.zone` — through `dns.name.fromText`. - `local_records.value` for `a` must parse as `.ip4`, for `aaaa` as `.ip6` (`BadLocalRecordValue`). - `blocklist_sources.url` must be `http://` or `https://` with a non-empty host (`BadSourceUrl`); `blocklist_sources.name` non-empty (`EmptySourceName`). - `forward_zones.resolver` through a `pub fn parseResolver(text: []const u8) ResolverError!Resolver` declared **in this file**: scheme `udp://` or `tcp://` (PLAN §6.5 permits plain transports for local infra), host an IP literal via `NetAddress.parse`, port 1–65535, nothing after the authority. `transport.Endpoint.parse` cannot be used — it rejects these schemes by design. Phase 5's `local/forward_zones.zig` imports `parseResolver` rather than writing a second one. - Wildcard rule patterns: `kind == .wildcard` requires at least one label that is exactly `"*"`; `kind == .exact` requires no `*` at all. Every other label must satisfy `dns.name.fromText` when the `*` labels are substituted with a placeholder label. Matching semantics are Phase 5's; this is syntax only. *Ranges* — all `BadPort` / `BadTimeout` / … as listed: - `dns.port`, `web.port`, `doh_server.port`, `dot_server.port` in 1–65535 (0 is rejected). - `upstream.connect_timeout_ms`, `read_timeout_ms`, `total_timeout_ms` each ≥ 100 and ≤ 120_000; `total_timeout_ms >= connect_timeout_ms` and `>= read_timeout_ms`. - `dns.rate_limit` ≥ 1, `dns.rate_window_seconds` in 1–3600, `web.api_rate_limit_per_min` ≥ 1, `web.sse_max_connections_per_ip` ≥ 1 (`BadRateLimit`). - `blocking.ttl` ≤ 86400, `cache.negative_ttl_max` ≤ 86400, `local_records.ttl` in 1–604800 (`BadTtl`). - `logging.retention_days` ≥ 1, `logging.query_log_buffer_max` ≥ 1 (`BadRetention`). - `logging.max_size_mb` ≥ 1 and `logging.max_files` ≥ 1 (`BadLogRotation`). - `disk.min_free_mb <= disk.warn_free_mb`, both ≥ 1 (`BadDiskThresholds`). - `web.session_ttl_hours` ≥ 1, `blocklist_update.interval_hours` ≥ 1. - `cfg.upstreams` must contain at least one entry with `enabled = true` (`NoUpstreams`) — PLAN §12.2. *Conditional* — `doh_server.enabled` or `dot_server.enabled` requires non-empty `cert_path` and `key_path` (`MissingCertPath`, `MissingKeyPath`); readability is `nxdns check`'s job, not the pure validator's. `logging.output == .file` requires a non-empty absolute `file_path` (`MissingLogPath`). `web.password != "" and web.password_hash != ""` → `PasswordAndHashBothSet`. `validate` never stops at the first problem. It runs every check, appends every failure, and only then returns `diags.problems.items[0].err`. On success it returns without touching `diags`. ### S2.7 Tests (in-file) - A default `Config` with one enabled upstream and a `default` group validates cleanly. - One named test per `ValidateError` member (except `OutOfMemory`), each asserting the exact error and that the diagnostic path names the offending element (e.g. `"clients[1].ip"`). - A config with **five** distinct problems yields five diagnostics and returns the first one's error. - Duplicate detection across canonical forms: `clients` holding both `"fd00::1"` and `"FD00:0:0:0:0:0:0:1"` is `DuplicateClientIp`. - Unknown group referenced from each of the four collections in turn. - Unknown source referenced from `group_sources`. - `toSettings`/`fromSettings` round-trip on a non-default `Config` reproduces every scalar field. - `toSettings` on a default `Config` produces exactly the literal expected key list. - An unknown settings key increments `unknown_keys` and does not error; a malformed integer value is `error.BadSettingValue`. - `LogLevel.err` encodes as `"error"` and decodes back. - Every `toDb`/`fromDb` enum pair round-trips over all tags. - The `parseResolver` table: `udp://192.168.1.1:53` ok; `tcp://[fd00::1]:53` ok; `https://x/` → error; `udp://host.name:53` → error (IP literal required); `udp://1.1.1.1` → error (port required); `udp://1.1.1.1:0` → error. ### S2.8 Acceptance criteria - [ ] `zig fmt --check` and `zig ast-check` clean on both files. - [ ] Neither file imports `std.Io` for anything but `std.Io.Writer` (diagnostics) and `std.Io.Duration` (the timeout conversions). No `Io` value is a parameter anywhere. - [ ] Neither file imports `storage/db.zig`. - [ ] Every bullet in S2.7 exists as a named test. - [ ] The comptime `assertFits` block is present and every conversion function is covered by it. --- ## Session S3: `src/storage/config_schema.zig`, `src/storage/migrations.zig`, `src/storage/querylog_schema.zig` ### S3.1 `config_schema.zig` Holds the DDL, verbatim from PLAN §11.2, as the first migration step: ```sql CREATE TABLE schema_version (version INTEGER NOT NULL); CREATE TABLE groups ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, safe_search INTEGER NOT NULL DEFAULT 0 ); INSERT OR IGNORE INTO groups (id, name) VALUES (1, 'default'); CREATE TABLE clients ( id INTEGER PRIMARY KEY, ip TEXT NOT NULL UNIQUE, -- canonical text form (v4 dotted / v6 RFC 5952) name TEXT, group_id INTEGER NOT NULL REFERENCES groups(id), hand_edited INTEGER NOT NULL DEFAULT 0, first_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL ); CREATE TABLE client_prefixes ( id INTEGER PRIMARY KEY, prefix TEXT NOT NULL UNIQUE, -- "192.168.1.0/24", "fd00:abcd::/48" group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, priority INTEGER NOT NULL DEFAULT 100 ); CREATE TABLE upstreams ( id INTEGER PRIMARY KEY, url TEXT NOT NULL UNIQUE, priority INTEGER NOT NULL DEFAULT 100, enabled INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE blocklist_sources ( id INTEGER PRIMARY KEY, url TEXT NOT NULL UNIQUE, name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, is_suggested INTEGER NOT NULL DEFAULT 0, last_updated INTEGER, domain_count INTEGER NOT NULL DEFAULT 0, wildcard_count INTEGER NOT NULL DEFAULT 0, skipped_regex_count INTEGER NOT NULL DEFAULT 0, checksum TEXT ); CREATE TABLE group_sources ( group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, source_id INTEGER NOT NULL REFERENCES blocklist_sources(id) ON DELETE CASCADE, PRIMARY KEY (group_id, source_id) ); CREATE TABLE rules ( id INTEGER PRIMARY KEY, group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, pattern TEXT NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('exact','wildcard')), action TEXT NOT NULL CHECK(action IN ('allow','block')), created_at INTEGER NOT NULL ); CREATE TABLE local_records ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, rtype TEXT NOT NULL CHECK(rtype IN ('A','AAAA','CNAME')), value TEXT NOT NULL, ttl INTEGER NOT NULL DEFAULT 300, UNIQUE(name, rtype, value) ); CREATE TABLE forward_zones ( id INTEGER PRIMARY KEY, zone TEXT NOT NULL UNIQUE, resolver TEXT NOT NULL -- "udp://192.168.1.1:53" ); CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); ``` Exported as `pub const ddl_v1: [:0]const u8 = @embedFile(...)` or a multiline string literal — either is fine, but the SQL text must be byte-identical to the block above. Also in this file, because they belong with the schema and every other session needs them: ```zig /// Child-before-parent. Used by import's wipe step; correct under `foreign_keys = ON`. /// `upstreams`, `local_records`, `forward_zones` and `settings` have no foreign keys, so their /// position is free; `groups` and `blocklist_sources` must come last, after every referrer. pub const delete_order = [_][]const u8{ "group_sources", "rules", "client_prefixes", "clients", "upstreams", "local_records", "forward_zones", "settings", "blocklist_sources", "groups", }; /// Every table whose emptiness defines "the database has never been configured" (S5.2). pub const content_tables = [_][]const u8{ "clients", "client_prefixes", "upstreams", "blocklist_sources", "group_sources", "rules", "local_records", "forward_zones", "settings", }; ``` Check both lists against the DDL before using them and report any correction in the completion report — a wrong delete order surfaces as a `Constraint` error inside import's transaction, which is exactly the failure mode this milestone is meant to make impossible. ### S3.2 `migrations.zig` ```zig pub const Step = struct { version: u32, sql: [:0]const u8 }; pub const steps = [_]Step{ .{ .version = 1, .sql = config_schema.ddl_v1 }, }; pub const target_version: u32 = steps[steps.len - 1].version; pub const Error = db.Error || error{ SchemaTooNew, SchemaCorrupt }; /// Reads the stamped version, applies every newer step in ONE transaction, stamps the result. /// `db` must already have had `applyPragmas` called (foreign_keys is a no-op inside a transaction). pub fn migrate(database: *db.Db) Error!u32; /// Seam for tests: same logic against an injected step list. pub fn migrateSteps(database: *db.Db, list: []const Step) Error!u32; ``` Algorithm: 1. Assert at comptime that `steps` versions are `1, 2, 3, …` with no gaps and strictly increasing. 2. Read the current version: `SELECT count(*) FROM sqlite_schema WHERE type='table' AND name='schema_version'`. Zero → current version is `0`. Otherwise `SELECT version FROM schema_version` — it must yield **exactly one row**; zero rows or more than one row is `error.SchemaCorrupt`, never a guess. 3. `current > target` → `error.SchemaTooNew`. Distinct from every other error so the CLI can print "config.db is at schema version N; this nxdns binary supports N-1. Install a newer nxdns." A database from the future is never silently accepted, and never downgraded. 4. `current == target` → return `current`. No transaction, no write. 5. `var tx = try Tx.begin(database); errdefer tx.rollback();` then, for each step with `version > current`, in ascending order, `database.exec(step.sql)`. Then `DELETE FROM schema_version;` and `INSERT INTO schema_version (version) VALUES (?)` with the target. Then `tx.commit()`. SQLite runs DDL transactionally, so a failing step leaves the file exactly as it was. 6. Return the new version. Reading the version happens **outside** the transaction in step 2 and is re-read **inside** it before applying, so two processes starting simultaneously cannot both apply step N (the second sees the stamped version under `BEGIN IMMEDIATE` and finds nothing to do). Tests (in-file, `:memory:`): - Fresh database → `migrate` returns `target_version`; every table in the DDL exists (assert by counting `sqlite_schema` rows of `type='table'` matching a literal name list); `groups` holds exactly one row, `id = 1`, `name = 'default'`. - `migrate` twice is idempotent: the second call writes nothing (`sqlite3_changes` after it, or a before/after comparison of `sqlite_schema`) and returns the same version. - A database stamped `target_version + 1` → `error.SchemaTooNew`, and the stamped version is unchanged afterwards. - `schema_version` with two rows → `error.SchemaCorrupt`. - Rollback proof through `migrateSteps`: a two-step list whose second step is invalid SQL leaves the stamped version at the value before the call and leaves the second step's table absent. - Stepwise upgrade through `migrateSteps`: apply a one-step list, then a two-step list with the same first step, and assert the second step's effect is present and the version is 2. This is the "upgrade = install binary, restart" proof PLAN §20.11 asks for. ### S3.3 `querylog_schema.zig` The DDL, verbatim from PLAN §11.3: ```sql CREATE TABLE domains ( id INTEGER PRIMARY KEY, domain TEXT NOT NULL UNIQUE ); CREATE TABLE query_log ( id INTEGER PRIMARY KEY, timestamp INTEGER NOT NULL, domain_id INTEGER NOT NULL REFERENCES domains(id), client_ip TEXT NOT NULL, -- text, not a FK: log rows are immutable facts qtype INTEGER, blocked INTEGER NOT NULL, block_reason TEXT, response_time_us INTEGER, cache_hit INTEGER, upstream TEXT ); CREATE INDEX idx_query_log_ts ON query_log(timestamp); CREATE INDEX idx_query_log_client ON query_log(client_ip); CREATE INDEX idx_query_log_domain ON query_log(domain_id); ``` The fingerprint is derived from the DDL text, not maintained by hand: ```zig pub const ddl: [:0]const u8 = ...; /// PRAGMA user_version is a signed 32-bit field. Deriving the fingerprint from the DDL means /// editing the schema automatically invalidates every existing file — which is exactly the /// policy (PLAN §3.7: querylog.db is never migrated). pub const fingerprint: i32 = @bitCast(std.hash.Crc32.hash(ddl)); ``` `std.hash.Crc32.hash(bytes) u32` verified at `lib/std/hash/crc/impl.zig:96` via `lib/std/hash.zig:10`. ```zig pub const RecreateReason = enum { missing, corrupt, not_a_database, quick_check_failed, fingerprint_mismatch }; pub const OpenResult = struct { database: db.Db, recreated: ?RecreateReason, // non-null feeds a counter and the /api/health rollup in Phase 8 }; pub const Error = db.Error || error{AsideNameCollision} || std.Io.Dir.RenameError || std.Io.Dir.DeleteFileError || std.Io.Dir.AccessError; /// Opens `/querylog.db`, recreating it if and only if it is genuinely unusable. pub fn open(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) Error!OpenResult; ``` Open sequence: 1. `dir.access(io, path, .{})`. `error.FileNotFound` → create fresh (reason `.missing`, which is not an anomaly — do not log it as a warning on first start; log at `info`). 2. Open with `.read_write_existing`, `applyPragmas`. 3. `PRAGMA quick_check` — a single row whose text must be `"ok"`. Anything else → `.quick_check_failed`. `quick_check` rather than `integrity_check` because it skips the (expensive) index-vs-table cross-check while still catching structural damage, and a damaged index on an expendable log is not worth a multi-second startup scan. 4. `PRAGMA user_version` must equal `fingerprint`, else `.fingerprint_mismatch`. 5. Otherwise return the handle with `recreated = null`. **The recreate predicate is a whitelist, not a fallback.** Recreate only for: `error.Corrupt` → `.corrupt`; `error.NotADb` → `.not_a_database`; a failed `quick_check`; a fingerprint mismatch; a missing file. Every other error **propagates unchanged and the file is not touched**. Named explicitly, because each one has destroyed data in some other project: `error.Busy` and `error.Locked` (another process holds the write lock — waiting is right, deleting is catastrophic), `error.OutOfMemory` (this process's problem), `error.CantOpen` (usually a permissions or missing-directory problem — recreating would not help and would mask it), `error.ReadOnly`, `error.IoErr`, `error.Full`, `error.Perm`, `error.Auth`, `error.Canceled`. A test exists for `Busy`/`Locked`/`OutOfMemory` asserting the file's bytes are unchanged. Recreate sequence, in this order: 1. Close the handle (if one was opened) so SQLite checkpoints and drops `-wal`/`-shm` where it can. 2. Build the aside name `querylog.db.corrupt-` from `std.Io.Clock.real.now(io).toSeconds()`. Rename with **`dir.renamePreserve(path, dir, aside, io)`** (verified: `RENAME_NOREPLACE`, returns `error.PathAlreadyExists` when taken). On `error.PathAlreadyExists`, retry with `--1`, `-2`, … up to 100 attempts, then `error.AsideNameCollision`. The aside name must never overwrite a previously saved corrupt file — two recreates in the same second are not hypothetical on a boot loop. 3. `dir.deleteFile(io, "querylog.db-wal")` and `dir.deleteFile(io, "querylog.db-shm")`, tolerating **only** `error.FileNotFound`. This step is not optional: a stale WAL left beside the renamed database would be replayed into the freshly created file and corrupt it immediately. If either delete fails for any other reason, propagate — do not create the new database on top of a half-cleaned state. 4. Create fresh with `.read_write_create`, `applyPragmas`, `exec(ddl)`, and `PRAGMA user_version = ` inside one transaction. 5. `std.log.warn` with the reason and the aside path. `.missing` logs at `info` instead. Tests — these need real files, so they live in S7's integration file; S3 writes them there is **not** permitted (S7 owns that file). Instead S3 exposes the seams the tests need and lists the required cases in its completion report: - fresh directory → created, `recreated == .missing`, `user_version == fingerprint`; - reopen → `recreated == null`; - stamp a wrong `user_version` → recreated with `.fingerprint_mismatch`, aside file exists; - write garbage bytes over the file → recreated with `.not_a_database` or `.corrupt`; - two recreates within one second produce two distinct aside files; - a pre-existing stale `querylog.db-wal` is removed by the recreate. In-file tests S3 *can* write (`:memory:` only): `fingerprint` is stable across calls; `ddl` executes cleanly against a fresh memory database and creates `domains`, `query_log` and the three indexes. ### S3.4 Acceptance criteria - [ ] `zig fmt --check` and `zig ast-check` clean on all three files. - [ ] The DDL in `config_schema.zig` is byte-identical to PLAN §11.2; the DDL in `querylog_schema.zig` is byte-identical to PLAN §11.3. - [ ] `migrate` applies everything in one transaction and refuses a newer database with `error.SchemaTooNew`. - [ ] Every `migrations.zig` test bullet exists as a named test. - [ ] The recreate predicate is a positive whitelist; `Busy`, `Locked` and `OutOfMemory` propagate. - [ ] `renamePreserve` is used for the aside, with uniquifying retries. - [ ] `-wal` and `-shm` removal precedes creating the replacement. --- ## Session S4: `src/storage/repositories/*.zig` Seven files, one per PLAN §5 name that is in scope. `queries_repo.zig` is Phase 6 and is **not** created here. | File | Tables | |---|---| | `groups_repo.zig` | `groups`, `group_sources` | | `clients_repo.zig` | `clients`, `client_prefixes` | | `upstreams_repo.zig` | `upstreams` | | `sources_repo.zig` | `blocklist_sources` | | `rules_repo.zig` | `rules` | | `local_repo.zig` | `local_records`, `forward_zones` | | `settings_repo.zig` | `settings` | ### S4.1 Uniform API Each repository exposes, for each of its tables: ```zig pub fn listX(database: *db.Db, gpa: std.mem.Allocator) db.Error!std.ArrayList(model.X); pub fn freeX(gpa: std.mem.Allocator, items: []const model.X) void; pub fn insertX(database: *db.Db, item: model.X, ctx: InsertContext) db.Error!void; pub fn deleteAllX(database: *db.Db) db.Error!void; pub fn countX(database: *db.Db) db.Error!i64; ``` `InsertContext` carries what the model deliberately omits: `now: i64` (for `first_seen`, `last_seen`, `created_at`) and the id lookups (`group_ids: *const std.StringHashMapUnmanaged(i64)`, `source_ids: …`) that turn a name reference into the `group_id`/`source_id` column. Building those maps is the caller's job (S5), because only the caller knows the ids it just inserted. Only these five operations exist. Update-by-id, delete-by-id and paged reads are Phase 8's REST surface; adding them now would be untested, unused generality (AGENTS.md). ### S4.2 Ordering Every `list` uses an `ORDER BY` whose trailing columns are unique, so export is byte-stable: | List | `ORDER BY` | |---|---| | groups | `name` | | group_sources (joined to names) | `g.name, s.url` | | clients | `ip` | | client_prefixes | `prefix` | | upstreams | `priority, url` | | blocklist_sources | `url` | | rules | `group_id, kind, action, pattern, id` | | local_records | `name, rtype, value` | | forward_zones | `zone` | | settings | `key` | `rules` has no unique tuple, hence the trailing `id`. `upstreams` sorts by `priority` first because that is the operationally meaningful order (it matches `Pool.init`'s expectation), and `url` breaks ties uniquely. `group_sources` and the `group`-referencing collections are read through a join so the list yields model values holding **names**, not ids: ```sql SELECT g.name, s.url FROM group_sources gs JOIN groups g ON g.id = gs.group_id JOIN blocklist_sources s ON s.id = gs.source_id ORDER BY g.name, s.url ``` `clients` list applies `WHERE hand_edited = 1` (S2.2's rationale: auto-materialized clients are runtime state and must not appear in an export). `countClients` counts **all** rows — the emptiness predicate in S5 needs the true count. ### S4.3 Memory discipline - Returned strings are heap-owned copies via `Stmt.columnTextAlloc`. `columnText` borrows from SQLite and is invalidated by the next `step`; a repository that returns a borrowed slice is a use-after-free waiting for the second row. - Every `list` builds into a `std.ArrayList(T)` with an `errdefer` that frees **both** every element already appended and every string of the partially-built element: ```zig var out: std.ArrayList(model.Group) = .empty; errdefer freeGroups(gpa, out.items); errdefer out.deinit(gpa); while (try stmt.step()) { const name = try stmt.columnTextAlloc(gpa, 0); errdefer gpa.free(name); try out.append(gpa, .{ .name = name, .safe_search = stmt.columnBool(1) }); } ``` - `freeX` frees every heap string in every element and is idempotent against an empty slice. - The callers of `list` may pass an arena; `freeX` must still be correct against a general-purpose allocator, because the tests use `std.testing.allocator`. ### S4.4 Tests (in-file, `:memory:`) Each repository gets, at minimum: - Round trip: `migrate` a memory database, insert three rows, `list` them, assert the exact order produced by the specified `ORDER BY`, assert every field survived. - `deleteAll` empties the table and `count` reflects it. - A leak-safety test through `std.testing.checkAllAllocationFailures(std.testing.allocator, testListImpl, .{})`, where `testListImpl` seeds rows and calls `list` + `free`. This is the runnable form of "leak-safe error paths"; a missing `errdefer` fails it. - `clients_repo`: a `hand_edited = 0` row is absent from `listClients` but counted by `countClients`. - `settings_repo`: keys sort ascending; a value containing a `'` round-trips (proving parameter binding, not string concatenation, is used). - `groups_repo`: `listGroupSources` yields names, not ids, and rejects nothing — the FK guarantees the join is total. ### S4.5 Acceptance criteria - [ ] `zig fmt --check` and `zig ast-check` clean on all seven files. - [ ] Every list statement's `ORDER BY` matches the table in S4.2 exactly. - [ ] No repository returns a slice borrowed from SQLite. - [ ] Every repository has a passing `checkAllAllocationFailures` test. - [ ] No SQL is built by string concatenation of a value; every value is bound. - [ ] No update-by-id / delete-by-id / pagination functions exist. --- ## Session S5: `src/config/bootstrap.zig`, `src/config/import.zig`, `src/config/export.zig` ### S5.1 `std.zon` ownership — verified verdict, and the rule that follows **Verified in `/home/mokhtar/app/zig/lib/std/zon/parse.zig` at tag 0.16.0:** `Parser.parseStruct` fills absent fields from the struct's default value by copying the default straight through (`parse.zig:874–888`): ```zig inline for (field_found, 0..) |found, i| { if (!found) { const field_info = field_infos[i]; if (field_info.default_value_ptr) |default| { const typed: *const field_info.type = @ptrCast(@alignCast(default)); @field(result, field_info.name) = typed.*; } else { ... } } } ``` For a `[]const u8` field with a default like `"0.0.0.0"`, the resulting slice points into the binary's read-only data. `std.zon.parse.free` (`parse.zig:412–456`) recurses over **every** field of a struct with no record of which were parsed and which were defaulted, and its slice arm is `for (value) |item| free(gpa, item); gpa.free(value);`. `std.mem.Allocator.free` (`lib/std/mem/Allocator.zig`) returns early only for zero length; for a non-empty slice it does `@memset(bytes, undefined)` and then `rawFree`. Applied to a defaulted string that lives in rodata, that is a write to read-only memory followed by a free of a pointer the allocator never owned. **Verdict: calling `std.zon.parse.free` on a parsed `Config` with defaulted string fields is a crash, and the nxdns config model has many non-empty string defaults.** (The `free_on_error` path at `parse.zig:830` is *not* affected — its `errdefer` walks only `fields.names[0..initialized]`, the fields actually present in the source. The hazard is exclusively in the caller-invoked `free`.) **Rule, binding on this milestone and every later one:** parse into an `std.heap.ArenaAllocator`, free the arena, **never** call `std.zon.parse.free` on a `Config`. A comment stating this and citing `parse.zig:874` goes at the parse site in `import.zig`. A test asserts the shape of the rule by parsing a source that omits every optional field and then destroying the arena — under `std.testing.allocator` a stray `parse.free` would fail the test. ### S5.2 the emptiness predicate Shared by `bootstrap` and `import --force`-less mode; lives in `import.zig` and is exported: ```zig /// A database is "never configured" when migrations have run and nothing else has. /// Migrations themselves create `schema_version` and seed `groups(1,'default')`, so /// "no rows anywhere" is the wrong test. pub fn isEmpty(database: *db.Db) db.Error!bool; ``` True iff **all** of the following hold: 1. Every table in `config_schema.content_tables` has `count(*) = 0`. (That is `clients`, `client_prefixes`, `upstreams`, `blocklist_sources`, `group_sources`, `rules`, `local_records`, `forward_zones`, `settings`.) 2. `SELECT count(*) FROM groups` is exactly 1. 3. That row satisfies `id = 1 AND name = 'default' AND safe_search = 0`. Any deviation makes the database non-empty. Note that `countClients` here counts every row including auto-materialized ones — a server that has answered one query is configured enough that a bootstrap file must not overwrite it. ### S5.3 `import.zig` ```zig pub const Options = struct { force: bool = false }; pub const Error = db.Error || validate.ValidateError || std.Io.Dir.ReadFileAllocError || error{ DatabaseNotEmpty, ConfigTooLarge, ParseZon, PasswordAndHashBothSet }; pub const max_config_bytes = 4 * 1024 * 1024; /// Reads, parses, validates, then replaces the database contents. Nothing is written to the /// database and no file is created until every check has passed. pub fn importFile( io: std.Io, gpa: std.mem.Allocator, database: *db.Db, dir: std.Io.Dir, path: []const u8, options: Options, diags: *validate.Diagnostics, ) Error!void; /// The half that bootstrap reuses: an already-parsed, already-validated config into the database, /// all or nothing. `now` is the caller's timestamp for the runtime columns. pub fn applyToDb( io: std.Io, gpa: std.mem.Allocator, database: *db.Db, cfg: model.Config, now: i64, options: Options, ) Error!void; ``` `importFile` order — the order is the specification: 1. `dir.readFileAllocOptions(io, path, gpa, .limited(max_config_bytes), .of(u8), 0)` → a `[:0]u8`. `std.zon.parse` requires the sentinel; `readFileAlloc` cannot supply one. `error.StreamTooLong` maps to `error.ConfigTooLarge`. 2. `var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit();` then `std.zon.parse.fromSliceAlloc(model.Config, arena.allocator(), source, &zon_diag, .{})`. Never `parse.free`. `zon_diag` is a `std.zon.parse.Diagnostics`; on `error.ParseZon` its `{f}` rendering goes into `diags` verbatim so the operator sees line and column. 3. `validate.validate(cfg, diags)` — every problem collected before returning. 4. `applyToDb`. `applyToDb`: 1. `var tx = try db.Tx.begin(database); errdefer tx.rollback();` 2. **Inside** the transaction, `if (!options.force and !try isEmpty(database)) return error.DatabaseNotEmpty;`. Checking before `BEGIN IMMEDIATE` would be a TOCTOU window against a concurrently starting process; `BEGIN IMMEDIATE` already holds the write lock, so the check and the writes are one atomic unit. 3. Delete every table in `config_schema.delete_order` (children first, so `foreign_keys = ON` never fires). 4. Insert `groups`, `"default"` **first and with an explicit `id = 1`**, then the rest in list order. §11.2 seeds group 1 as `default` and §7.2's fallback assignment depends on it; letting an import renumber it would silently move every unassigned client. Build the `name → id` map from `sqlite3_last_insert_rowid` as you go. 5. Insert `blocklist_sources`, building the `url → id` map. 6. Insert `clients` (`hand_edited = 1`, `first_seen = last_seen = now`), `client_prefixes`, `upstreams`, `group_sources`, `rules` (`created_at = now`), `local_records`, `forward_zones` — each resolving names through the maps built above. 7. Resolve the password per S2.5 (`strHash` when `password != ""`), then `toSettings` and insert every pair. `web.password` is never a settings row. 8. `tx.commit()`. No filesystem write happens anywhere in `applyToDb`, and `importFile` opens the input read-only. The only file this milestone creates outside the data directory is `export --out`. ### S5.4 `export.zig` ```zig /// Reads every repository into `arena` and assembles a Config. Deterministic by construction: /// every list is ordered per S4.2 and every scalar comes from the settings map. pub fn readConfig(database: *db.Db, arena: std.mem.Allocator) Error!model.Config; /// Canonical ZON. Deterministic for a given Config — no timestamps, no host names, no counters. pub fn writeConfig(cfg: model.Config, w: *std.Io.Writer) std.Io.Writer.Error!void; /// Atomic, owner-only. The exported file contains `web.password_hash`, so 0600 is not optional. pub fn writeToFile(io: std.Io, gpa: std.mem.Allocator, database: *db.Db, dir: std.Io.Dir, path: []const u8) Error!void; pub fn writeToWriter(gpa: std.mem.Allocator, database: *db.Db, w: *std.Io.Writer) Error!void; ``` `writeConfig` emits a fixed two-line header comment and then the value: ``` // nxdns configuration // generated by `nxdns export` — the database is the source of truth ``` The header carries **no** timestamp, version or hostname. Any of those would break the byte-stable round trip and would turn a config diff into noise. ZON permits comments, and `std.zon.parse` reads through them. Serialization: `std.zon.stringify.serialize(cfg, .{ .whitespace = true, .emit_default_optional_fields = true }, w)`. Emitting defaults explicitly makes the exported file a complete, self-documenting record of the running configuration and makes the round trip independent of a later change to a default value. `writeToFile` uses the verified atomic helper: ```zig var af = try dir.createFileAtomic(io, path, .{ .permissions = .fromMode(0o600), .replace = true }); defer af.deinit(io); var fw = af.file.writer(io, &buf); try writeConfig(cfg, &fw.interface); try fw.interface.flush(); try af.file.sync(io); try af.replace(io); ``` The session must read `Io/Threaded.zig`'s `dirCreateFileAtomic` implementation and **confirm that the temporary file is created in the destination's directory** — a temporary on another filesystem cannot be renamed atomically into place. Record the finding in the completion report; if it is not, fall back to an explicit temp-plus-`rename` in the same directory (`rename` replaces, verified Dir.zig:1085) and say so. ### S5.5 `bootstrap.zig` ```zig pub const Outcome = enum { seeded, db_already_configured, no_config_file }; /// First-start seeding (PLAN §3.5). Called by `nxdns run` before serving. pub fn bootstrap( io: std.Io, gpa: std.mem.Allocator, database: *db.Db, dir: std.Io.Dir, config_path: []const u8, diags: *validate.Diagnostics, ) Error!Outcome; ``` 1. `dir.access(io, config_path, .{})` → `error.FileNotFound` returns `.no_config_file`. An absent bootstrap file is the normal steady state, not a problem: log at `info` and continue. Any other access error propagates. 2. `if (!try import.isEmpty(database)) return .db_already_configured;` — log at `info` ("configuration file ignored; the database is already configured"), do not read the file. PLAN §3.5: subsequent starts ignore the file. 3. Otherwise `import.importFile(..., .{ .force = false }, diags)`. A file that is present but unreadable, unparseable, or invalid is an **error** — the operator wrote it, meant it, and starting with silent defaults instead would be the exact silent-failure mode PLAN §1.3 exists to prevent. Return `.seeded` on success. There is no second copy of the seeding logic: bootstrap is a policy wrapper over `importFile`. ### S5.6 Tests In-file (`:memory:`, no filesystem): - `isEmpty` on a freshly migrated database is true; after one `settings` row it is false; after one auto-materialized `clients` row (`hand_edited = 0`) it is false; after renaming group 1 it is false. - `applyToDb` on a non-empty database without `force` returns `error.DatabaseNotEmpty` and leaves every row untouched (compare a full dump before and after). - `applyToDb` with a config whose insert fails mid-way (inject via a group name exceeding no constraint but a `local_records` row duplicating an earlier one — validation would catch it, so instead call `applyToDb` directly with an invalid config that bypasses `validate`) leaves the database exactly as it was. This is the all-or-nothing proof and it must not go through `importFile`. - `readConfig` → `writeConfig` → `parse` → `applyToDb` → `readConfig` produces an equal `Config`. - **Byte-stable round trip**: seed a memory database, `writeToWriter` into buffer A, create a second memory database, import buffer A, `writeToWriter` into buffer B, `try std.testing.expectEqualStrings(A, B)`. This is PLAN §20.8 as a runnable check. - Parse a ZON source omitting every optional field into an arena, destroy the arena, and assert no leak under `std.testing.allocator` (the S5.1 rule, as a test). - `password` set → the stored `web.password_hash` starts with `"$argon2id$"` and `settings` has no `web.password` key; `password` and `password_hash` both set → `error.PasswordAndHashBothSet`. - `import` of a config with a validation error writes nothing: assert `isEmpty` still true. Filesystem cases (`writeToFile` permissions and atomicity, `bootstrap`'s three outcomes against real files) belong to S7. ### S5.7 Acceptance criteria - [ ] `zig fmt --check` and `zig ast-check` clean on all three files. - [ ] `std.zon.parse.free` appears nowhere in the repository (`grep` it). - [ ] The arena rule is documented at the parse site with the `parse.zig:874` citation. - [ ] The non-empty check is inside the transaction. - [ ] Validation completes before the first `Tx.begin` and before any file is created. - [ ] The byte-stable round-trip test passes. - [ ] `bootstrap` has one code path into the database, and it is `importFile`. --- ## Session S6: `src/cli.zig`, `src/main.zig` `main.zig` becomes a thin shell: parse arguments, dispatch, return an exit code. Everything testable lives in `cli.zig`. ### S6.1 argument parsing (pure) ```zig pub const Command = union(enum) { run: Paths, check: CheckArgs, export_: ExportArgs, import_: ImportArgs, version, help, }; pub const Paths = struct { data_dir: []const u8 = "/var/lib/nxdns", // PLAN §3.13 config: []const u8 = "/etc/nxdns/config.zon", }; pub const CheckArgs = struct { paths: Paths = .{}, config_explicit: bool = false }; pub const ExportArgs = struct { paths: Paths = .{}, out: ?[]const u8 = null }; pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, force: bool = false }; pub const ParseError = error{ UnknownCommand, UnknownFlag, MissingValue, MissingArgument, TooManyArguments }; /// `argv` excludes the program name. Slices borrow from `argv`. pub fn parseArgs(argv: []const []const u8) ParseError!Command; ``` Accepted forms: ``` nxdns run [--data-dir DIR] [--config FILE] nxdns check [--data-dir DIR] [--config FILE] nxdns export [--data-dir DIR] [--out FILE] nxdns import FILE [--force] [--data-dir DIR] nxdns version nxdns help | --help | -h ``` `--flag=value` and `--flag value` both work. `config_explicit` records whether `--config` was given, because `check` needs to distinguish "the operator named a file" from "the default path happens to exist" (S6.4). Unknown flags and missing values are `ParseError`, not silent ignores. Exit codes: `0` success, `1` a runtime failure (I/O, database), `2` a validation or check failure, `64` a usage error (matching the existing milestone-1 convention). ### S6.2 path assembly ```zig pub const DataDir = struct { dir: std.Io.Dir, config_db_path: [:0]const u8, // "/config.db" querylog_db_path: [:0]const u8, // "/querylog.db" /// Creates `` (and parents) with mode 0700 if absent, then opens it. pub fn open(io: std.Io, gpa: std.mem.Allocator, data_dir: []const u8, create: bool) !DataDir; pub fn close(self: *DataDir, io: std.Io, gpa: std.mem.Allocator) void; }; ``` - Directory creation uses `Dir.cwd().createDirPathStatus(io, data_dir, .fromMode(0o700))`. Plain `createDirPath` hardcodes `0o777` (verified Dir.zig:843) and must not be used here — the data directory holds `config.db`, which holds `web.password_hash`. - `config.db` must end up mode 0600. SQLite creates it itself, at `0644 & ~umask`. The order matters: 1. `db.open(config_db_path, .{ .mode = .read_write_create })` — this creates the file. 2. `dir.setFilePermissions(io, "config.db", .fromMode(0o600), .{})`. 3. `applyPragmas` — this is what creates `config.db-wal` and `config.db-shm`, and SQLite copies the **main database file's** permissions onto them. Setting 0600 first therefore gets the sidecars for free; doing it in the other order leaves them world-readable. Encode this ordering in `DataDir.open` with a comment stating the reason, and cover it in S7. - `export --out` is written relative to the current working directory (an operator running `nxdns export --out backup.zon` means the shell's directory), not the data directory. ### S6.3 entry functions ```zig pub const Runner = struct { io: std.Io, gpa: std.mem.Allocator, out: *std.Io.Writer, err: *std.Io.Writer }; pub fn runCheck(r: Runner, args: CheckArgs, probe: bool) u8; pub fn runExport(r: Runner, args: ExportArgs) u8; pub fn runImport(r: Runner, args: ImportArgs) u8; pub fn runVersion(r: Runner) u8; pub fn usage(w: *std.Io.Writer) void; ``` Every one takes its writers as parameters so tests can capture output into `std.Io.Writer.Allocating`. `main.zig` builds the `Runner` from `std.process.Init` (`init.io`, `init.gpa`, `std.Io.File.stdout()/stderr()`), calls `parseArgs`, dispatches, and returns the code. `main.zig` contains no logic beyond that — no database calls, no formatting decisions. `run` prints `not implemented` and returns 2, unchanged from milestone 1. Wiring the server to the configuration is Phase 7. ### S6.4 `nxdns check` The point of `check` is that an operator sees **every** problem in one run. It never returns early. 1. Choose what to check: - `--config FILE` given → validate that file (parse it into an arena, then `validate`). - otherwise, if `/config.db` exists → migrate it (a `check` on a database a version behind should still work), `export.readConfig`, then `validate`. - otherwise, if the default `/etc/nxdns/config.zon` exists → validate that file. - otherwise → print "nothing to check: no config.db in and no " and return 2. Print which source was used, so the answer is never ambiguous. 2. Run `validate` and print **every** diagnostic through `Diagnostics.writeAll`, one `path: message` line each. Do not stop at the first. 3. Certificates: for each of `doh_server` and `dot_server` that is `enabled`, `dir.access(io, cert_path, .{})` and the same for `key_path`; print a `FAIL` line per unreadable path. Then `dir.statFile(io, key_path, .{})` and, if `stat.permissions.toMode() & 0o077 != 0`, print a `WARN` line — PLAN §19 requires TLS keys readable by the service user only. A warning does not change the exit code; an unreadable file does. 4. Upstream probe (only when `probe` is true — `main` passes true, unit tests pass false): for **each** enabled upstream, build its `transport.Endpoint`, its `DohClient` or `DotClient`, and a `pool.Pool` with that **single** entry, then `exchange` a hand-built `A example.com` query with `attempt_timeout` from `model.totalTimeout(cfg.upstream)`. One `Pool` per upstream rather than one pool over all of them, because the pool's job is failover — it would report success as soon as any upstream answered, and a broken upstream would stay invisible. Reusing the pool (rather than calling the client directly) keeps the deadline, cancellation and health machinery identical to what the server will do. Read `Pool.snapshot` for the error text and print one `OK`/`FAIL` line per upstream with the endpoint URL and, on failure, `snapshot.last_error`. 5. Exit `0` if there were no `FAIL` lines and validation passed, else `2`. Warnings alone keep `0`. The probe leaves the machine, so it is `-Dlive` territory: `runCheck(r, args, false)` is what the default test suite exercises, and the probing path gets one `-Dlive` test in S7. ### S6.5 Tests (in-file, pure) - `parseArgs` table: every accepted form above; `--data-dir` with and without `=`; `import` without a file → `MissingArgument`; `--out` without a value → `MissingValue`; `--nope` → `UnknownFlag`; `nxdns frobnicate` → `UnknownCommand`; `export extra` → `TooManyArguments`. - `usage` writes non-empty text. - `runCheck` with `probe = false` against an in-memory-backed config: a clean config returns 0 and a config with three problems returns 2 and prints three lines (assert on captured output). `runExport` / `runImport` need real files; those tests live in S7. ### S6.6 Acceptance criteria - [ ] `zig fmt --check` and `zig ast-check` clean on both files. - [ ] `main.zig` contains argument dispatch and nothing else; every command body is in `cli.zig` and takes its writers as parameters. - [ ] `nxdns version` behaviour is unchanged from milestone 1. - [ ] `check` prints every diagnostic before exiting. - [ ] `check` builds one `Pool` per upstream. - [ ] The `config.db` permission ordering (create → chmod 0600 → WAL) is implemented and commented. - [ ] Every `parseArgs` bullet exists as a named test. --- ## Session S7: `src/storage/storage_integration_test.zig` One file, everything real: real temporary directories, real database files, real exports. Guarded by `if (!build_options.integration) return error.SkipZigTest;` at the top of every test, and one `build_options.live` test for the upstream probe. Use `std.testing.tmpDir(.{})` for isolation — verified `pub fn tmpDir(opts: Io.Dir.OpenOptions) TmpDir` (`lib/std/testing.zig:634`). It uses `std.testing`'s own `io`, so the tests must drive the code under test with the same `Io` instance the returned `TmpDir` was created against; read `lib/std/testing.zig` around line 634 for how that `io` is obtained and report it. Capture command output with `std.Io.Writer.Allocating` (`lib/std/Io/Writer.zig:2502`). ### S7.1 Required cases *querylog recreate policy* (the S3.3 list, which S3 could not write): 1. Fresh directory → `open` creates the file, `recreated == .missing`, `PRAGMA user_version` equals `fingerprint`, `domains` and `query_log` exist. 2. Reopen the same file → `recreated == null`, no aside file was created. 3. Stamp a wrong `user_version`, reopen → `recreated == .fingerprint_mismatch`, exactly one `querylog.db.corrupt-*` file exists, and its bytes equal the original file's bytes. 4. Overwrite the file with 4096 bytes of garbage, reopen → recreated with `.not_a_database` or `.corrupt`; the garbage is preserved in the aside file. 5. Two recreates without an intervening second produce **two** aside files with distinct names (drive it by recreating twice in a loop; the uniquifier, not the clock, must make them distinct). 6. Create a stale `querylog.db-wal` beside a fingerprint-mismatched database; after the recreate, the stale `-wal` is gone. 7. A `querylog.db` whose file permissions deny reading (`0o000`) → the error propagates and the file still exists with its original bytes. This is the "never destroy a healthy file" guarantee. *config.db and permissions*: 8. `DataDir.open` on a fresh path creates the directory with mode `0o700` (assert via `statFile` → `permissions.toMode() & 0o777 == 0o700`). 9. After `DataDir.open` + `migrate`, `config.db` is `0o600` and `config.db-wal` (if present) is `0o600`. 10. `migrate` on a database stamped one version ahead → `error.SchemaTooNew`; the file is unchanged. *export / import / bootstrap*: 11. Seed a database, `export.writeToFile`, assert the output file is mode `0o600` and its first line is the header comment. 12. Byte-stable round trip **through real files**: export to `a.zon`, import into a second data directory, export to `b.zon`, `expectEqualStrings` on the two file contents. 13. `import` into a non-empty database without `--force` → `error.DatabaseNotEmpty` and the existing rows are unchanged; with `--force` → replaced. 14. `import` of a file with two validation errors → both diagnostics are produced, the database is still empty, and no file was created. 15. `bootstrap` with no config file → `.no_config_file`, database still empty. 16. `bootstrap` with a valid config file on an empty database → `.seeded`, and the rows match. 17. `bootstrap` on an already-configured database → `.db_already_configured` and the file is not even read (prove it by making the file invalid ZON: the call must still succeed). 18. `bootstrap` with a present-but-invalid config file on an empty database → an error, and the database is still empty. 19. `export.writeToFile` over an existing file replaces it atomically: assert the old content is gone and the mode is still `0o600`. *CLI end to end*: 20. `cli.runImport` then `cli.runExport` through the public entry functions, capturing stdout, reproducing case 12 at the CLI level and asserting the exit codes are 0. 21. `cli.runCheck` with `probe = false` against a seeded data directory returns 0; against a database seeded with a config carrying two problems returns 2 with two diagnostic lines. *live*: 22. Guarded by `build_options.live`: `cli.runCheck` with `probe = true` against a config naming `https://cloudflare-dns.com/dns-query` prints an `OK` line. A live failure is an environment finding, not a gate (milestone 1 and 3 convention). ### S7.2 Acceptance criteria - [ ] `zig fmt --check` and `zig ast-check` clean. - [ ] All 21 hermetic cases exist as named tests and pass under `zig build test -Dintegration`. - [ ] No test leaves a file behind outside its `tmpDir`. - [ ] No hermetic test opens a socket or resolves a name. --- ## Module Layout ``` src/storage/db.zig S1 sqlite3 wrapper: errors, Stmt, Tx, pragmas src/storage/config_schema.zig S3 PLAN §11.2 DDL, delete order, content tables src/storage/migrations.zig S3 numbered steps, one-transaction runner src/storage/querylog_schema.zig S3 PLAN §11.3 DDL, fingerprint, recreate policy src/storage/repositories/groups_repo.zig S4 groups, group_sources src/storage/repositories/clients_repo.zig S4 clients, client_prefixes src/storage/repositories/upstreams_repo.zig S4 upstreams src/storage/repositories/sources_repo.zig S4 blocklist_sources src/storage/repositories/rules_repo.zig S4 rules src/storage/repositories/local_repo.zig S4 local_records, forward_zones src/storage/repositories/settings_repo.zig S4 settings src/storage/storage_integration_test.zig S7 -Dintegration, real files src/config/model.zig S2 Config, settings mapping, unit conversions src/config/validate.zig S2 pure validator + Diagnostics + parseResolver src/config/bootstrap.zig S5 first-start seeding src/config/import.zig S5 read → parse → validate → replace src/config/export.zig S5 DB → canonical ZON, atomic 0600 write src/cli.zig S6 parseArgs, DataDir, runCheck/runExport/runImport src/main.zig S6 thin dispatch shell (rewritten) ``` ## File Ownership | Files | Owner | Notes | |---|---|---| | `src/storage/db.zig` | S1 | frozen after S1 verifies | | `src/config/model.zig`, `src/config/validate.zig` | S2 | frozen after S2 verifies | | `src/storage/config_schema.zig`, `src/storage/migrations.zig`, `src/storage/querylog_schema.zig` | S3 | | | `src/storage/repositories/*.zig` (7 files) | S4 | | | `src/config/bootstrap.zig`, `src/config/import.zig`, `src/config/export.zig` | S5 | | | `src/cli.zig`, `src/main.zig` | S6 | `main.zig` is rewritten; milestone-1 CLI behaviour preserved | | `src/storage/storage_integration_test.zig` | S7 | | | `build.zig`, `build.zig.zon`, `src/tests.zig` | orchestrator | no session edits these | | `PLAN.md` §12.1 | orchestrator | amended per S2.1 after S2 verifies | No session touches milestone 1, 2 or 3 files. A needed change there is reported, not made. In particular `src/upstream/*` and `src/server/*` are read-only in this milestone. ## Acceptance Criteria (Milestone 4 Complete) - [ ] `zig build test` exits 0 with every new file wired into `src/tests.zig`. - [ ] `zig build test -Dintegration` exits 0: milestone 1's loopback TLS echo, milestone 3's listener and resolver tests, and all 21 hermetic storage cases pass. - [ ] `zig build test -Dintegration -Dlive` exits 0 locally, or live failures are reported as environment findings with the exact error. - [ ] `zig build cross` still produces two statically linked executables. - [ ] `grep -rn "std.zon.parse.free" src/` returns nothing. - [ ] `grep -rn "@cImport" src/` returns nothing. - [ ] Byte-stable round trip: `nxdns import a.zon && nxdns export --out b.zon` on a fresh data directory, then `cmp a.zon b.zon` after the first export — expressed as test case 12 and reproducible by hand with: ``` ./zig-out/bin/nxdns --help >/dev/null ./zig-out/bin/nxdns import --data-dir /tmp/nx1 fixture.zon ./zig-out/bin/nxdns export --data-dir /tmp/nx1 --out /tmp/a.zon ./zig-out/bin/nxdns import --data-dir /tmp/nx2 /tmp/a.zon ./zig-out/bin/nxdns export --data-dir /tmp/nx2 --out /tmp/b.zon cmp /tmp/a.zon /tmp/b.zon ``` - [ ] `stat -c %a /tmp/nx1` reports `700`; `stat -c %a /tmp/nx1/config.db` and `/tmp/a.zon` report `600`. - [ ] `nxdns check --config ` prints 3 diagnostic lines and exits 2. - [ ] `nxdns import` of an invalid file leaves the database empty (`sqlite3 config.db "select count(*) from settings"` is 0 — or the equivalent assertion in test case 14, since the repo ships no sqlite3 CLI). - [ ] A `config.db` stamped one version ahead makes startup fail with the "schema too new" message, not a crash and not a silent downgrade. - [ ] `zig fmt --check` clean repo-wide; GPG-signed lowercase commits. ## Anti-Requirements - **No web server, no REST API, no SSE, no `/metrics`, no auth flow.** Phase 8. The argon2id hash is computed and stored; nothing verifies it yet. - **No blocklist downloading, parsing, or compiling.** Phase 5. `blocklist_sources` rows are metadata; no `.list`/`.wild` file is created, no HTTP fetch happens. - **No filtering, rule matching, wildcard matching, safe-search, CNAME uncloaking.** Phase 5. The validator checks rule *syntax* only. - **No query-log writing.** Phase 6. `querylog.db` is created and its schema is verified; nothing inserts a row, there is no `queries_repo.zig`, no buffer, no flush loop, no retention job, no VACUUM, no disk monitor. - **No DNS serving changes.** `src/dns/`, `src/server/`, `src/upstream/` are untouched. `nxdns run` still prints `not implemented`; wiring the config into the servers is Phase 7. - **No local records or conditional forwarding behaviour.** Phase 5. Their rows are stored and validated, nothing consumes them. - **No prepared-statement cache, no connection pool, no ORM, no query builder.** One handle per database, statements prepared where used. - **No `querylog.db` migrations, ever.** That is the design (PLAN §3.7), not a gap. - **No CRUD beyond list/insert/deleteAll/count** in the repositories. - **No config file watcher, no auto-regeneration, no reload signal.** PLAN §3.5. - **No third-party Zig packages.** stdlib plus the two pinned C libraries. ## As built The implementation matches the spec with these evaluation- and review-driven refinements (three review rounds; findings went 8 → 3 → 1 low): - **Logging policy (binding, repo-wide):** a condition that is returned as a typed error logs at `warn` at most; `err` is reserved for failures the code swallows (the logged-then-discarded ROLLBACK failure). The zig test runner fails any test that emits `err` logs, and the negative tests exercise these paths. - `db.zig`: `c.Destructor` is `?*anyopaque`, not a fn-pointer type — the `SQLITE_TRANSIENT` sentinel (-1) is not a valid function address and aarch64 fn pointers require alignment, so the fn-pointer form fails `zig build cross`. `queryInt` now enforces its contract: exactly one column, integer-typed, exactly one row. - `querylog_schema.zig`: the DDL fingerprint's comptime CRC needs `@setEvalBranchQuota(2_000_000)` (the stdlib lookup-table generation runs under the caller's quota). Behavior-level integration tests prove `open()` preserves the file under `Busy`/`Locked` (a concurrent exclusive transaction) and only whitelisted corruption recreates. - `rules_repo.zig`: rule listing orders by group NAME (subselect), not `group_id` — ids permute across import into a fresh database and would break the byte-stable round trip. - `validate.zig`: one shared `parseAuthority` (strict brackets, no userinfo/query/fragment, printable-ASCII-only, port 1–65535) backs both the source-URL check and `parseResolver`. `Problem.err` is `ProblemError` (`ValidateError || error{ParseZon}`) so import's ZON line/column diagnostics travel the same channel the CLI renders; `validate` returns the first problem it recorded itself. - `cli.zig`: certificate/key checks do a real readability probe, not an existence check; an OutOfMemory during diagnostics recording takes the runtime exit code, never the config-problem exit; `help` rejects trailing arguments like every other command. - **Discovered stdlib limitation** (recorded in specs/research/zig-0.16-api-notes.md): `Certificate.Parsed.verifyHostName` ignores `iPAddress` SANs, so DoT to an IP literal cannot pass verification on stock 0.16 — the `-Dlive` DoT case fails with `CertificateHostMismatch` against 1.1.1.1 while live DoH passes. Planned follow-up (own commit): a per-upstream `tls_name` for SNI + verification while dialing the IP.