From 70bff22d75ad335dc10f184c62147dbb252b77bc Mon Sep 17 00:00:00 2001 From: m5r Date: Sat, 1 Aug 2026 14:21:44 +0200 Subject: [PATCH] storage and config: sqlite wrapper, migrations, querylog policy, repositories, zon config with import/export/check cli --- specs/milestone-4.md | 1922 +++++++++++++++++++ specs/research/zig-0.16-api-notes.md | 11 + src/cli.zig | 940 +++++++++ src/config/bootstrap.zig | 60 + src/config/export.zig | 309 +++ src/config/import.zig | 621 ++++++ src/config/model.zig | 745 +++++++ src/config/validate.zig | 1309 +++++++++++++ src/main.zig | 88 +- src/storage/config_schema.zig | 147 ++ src/storage/db.zig | 748 ++++++++ src/storage/migrations.zig | 255 +++ src/storage/querylog_schema.zig | 280 +++ src/storage/repositories/clients_repo.zig | 336 ++++ src/storage/repositories/context.zig | 66 + src/storage/repositories/groups_repo.zig | 275 +++ src/storage/repositories/local_repo.zig | 255 +++ src/storage/repositories/rules_repo.zig | 294 +++ src/storage/repositories/settings_repo.zig | 145 ++ src/storage/repositories/sources_repo.zig | 177 ++ src/storage/repositories/upstreams_repo.zig | 130 ++ src/storage/storage_integration_test.zig | 1057 ++++++++++ src/tests.zig | 19 + 23 files changed, 10142 insertions(+), 47 deletions(-) create mode 100644 specs/milestone-4.md create mode 100644 src/cli.zig create mode 100644 src/config/bootstrap.zig create mode 100644 src/config/export.zig create mode 100644 src/config/import.zig create mode 100644 src/config/model.zig create mode 100644 src/config/validate.zig create mode 100644 src/storage/config_schema.zig create mode 100644 src/storage/db.zig create mode 100644 src/storage/migrations.zig create mode 100644 src/storage/querylog_schema.zig create mode 100644 src/storage/repositories/clients_repo.zig create mode 100644 src/storage/repositories/context.zig create mode 100644 src/storage/repositories/groups_repo.zig create mode 100644 src/storage/repositories/local_repo.zig create mode 100644 src/storage/repositories/rules_repo.zig create mode 100644 src/storage/repositories/settings_repo.zig create mode 100644 src/storage/repositories/sources_repo.zig create mode 100644 src/storage/repositories/upstreams_repo.zig create mode 100644 src/storage/storage_integration_test.zig diff --git a/specs/milestone-4.md b/specs/milestone-4.md new file mode 100644 index 0000000..b8ec22e --- /dev/null +++ b/specs/milestone-4.md @@ -0,0 +1,1922 @@ +# 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. diff --git a/specs/research/zig-0.16-api-notes.md b/specs/research/zig-0.16-api-notes.md index 0bce6c0..a0b0a52 100644 --- a/specs/research/zig-0.16-api-notes.md +++ b/specs/research/zig-0.16-api-notes.md @@ -184,3 +184,14 @@ changed most of these APIs. - TLS 1.2/1.3. **No ALPN, no session resumption** (fine for DoT; DoH over HTTP/1.1 works without ALPN in practice — verify against real upstreams in Phase 3). - CA roots: `std.crypto.Certificate.Bundle` (note the path), `bundle.rescan(gpa, io, now)`. + +## Certificate verification ignores IP SANs (verified 0.16.0) + +`std.crypto.Certificate.Parsed.verifyHostName` (Certificate.zig:313) checks only +`dNSName` general names in the SAN extension; the switch's `else => {}` skips +`iPAddress` (tag 7) entries entirely. Consequence: a TLS connection whose +verification name is an IP literal (DoT `tls://1.1.1.1:853`) always fails with +`error.CertificateHostMismatch` against certificates that carry the address only +as an iPAddress SAN — which is how Cloudflare and Quad9 issue theirs. A DoT +upstream therefore needs a DNS `tls_name` for SNI + verification while dialing +the IP; verifying by bare IP cannot work on stock 0.16. diff --git a/src/cli.zig b/src/cli.zig new file mode 100644 index 0000000..8100117 --- /dev/null +++ b/src/cli.zig @@ -0,0 +1,940 @@ +//! The command line, everything except the process shell around it. +//! +//! `main.zig` parses `argv`, dispatches and returns an exit code; every command +//! body lives here and takes its writers as parameters, so a test can capture +//! what an operator would see into a `std.Io.Writer.Allocating`. +//! +//! Exit codes (milestone-1 convention, extended): +//! +//! - `0` success; +//! - `1` a runtime failure — I/O, database, out of memory; +//! - `2` a configuration the operator can fix, or a `check` that found a +//! problem; +//! - `64` a usage error. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Writer = std.Io.Writer; +const Certificate = std.crypto.Certificate; +const tls = std.crypto.tls; + +const config_export = @import("config/export.zig"); +const import = @import("config/import.zig"); +const model = @import("config/model.zig"); +const validate = @import("config/validate.zig"); +const db = @import("storage/db.zig"); +const migrations = @import("storage/migrations.zig"); +const doh_client = @import("upstream/doh_client.zig"); +const dot_client = @import("upstream/dot_client.zig"); +const pool = @import("upstream/pool.zig"); +const transport = @import("upstream/transport.zig"); +const version = @import("version.zig"); + +pub const exit_ok: u8 = 0; +pub const exit_runtime: u8 = 1; +pub const exit_check: u8 = 2; +pub const exit_usage: u8 = 64; + +pub const config_db_name = "config.db"; +pub const querylog_db_name = "querylog.db"; + +// --------------------------------------------------------------------------- +// argument parsing (pure) +// --------------------------------------------------------------------------- + +pub const Paths = struct { + /// PLAN §3.13. + data_dir: []const u8 = "/var/lib/nxdns", + config: []const u8 = "/etc/nxdns/config.zon", +}; + +/// `config_explicit` records whether `--config` was given, because `check` has +/// to tell "the operator named a file" from "the default path happens to +/// exist". +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 Command = union(enum) { + run: Paths, + check: CheckArgs, + export_: ExportArgs, + import_: ImportArgs, + version, + help, +}; + +pub const ParseError = error{ + UnknownCommand, + UnknownFlag, + MissingValue, + MissingArgument, + TooManyArguments, +}; + +/// `argv` excludes the program name. Every slice in the result borrows from +/// `argv`. +pub fn parseArgs(argv: []const []const u8) ParseError!Command { + if (argv.len == 0) return error.UnknownCommand; + const command = argv[0]; + const rest = argv[1..]; + + if (eql(command, "version")) { + if (rest.len != 0) return error.TooManyArguments; + return .version; + } + if (eql(command, "help") or eql(command, "--help") or eql(command, "-h")) { + if (rest.len != 0) return error.TooManyArguments; + return .help; + } + if (eql(command, "run")) return .{ .run = (try parseCheckArgs(rest)).paths }; + if (eql(command, "check")) return .{ .check = try parseCheckArgs(rest) }; + if (eql(command, "export")) return .{ .export_ = try parseExportArgs(rest) }; + if (eql(command, "import")) return .{ .import_ = try parseImportArgs(rest) }; + return error.UnknownCommand; +} + +const Flag = struct { + /// Without the leading `--`. + name: []const u8, + /// The `value` of `--flag=value`, absent for `--flag`. + attached: ?[]const u8, +}; + +fn splitFlag(arg: []const u8) ?Flag { + if (!std.mem.startsWith(u8, arg, "--")) return null; + const body = arg[2..]; + if (std.mem.findScalar(u8, body, '=')) |at| { + return .{ .name = body[0..at], .attached = body[at + 1 ..] }; + } + return .{ .name = body, .attached = null }; +} + +/// `--flag=value` and `--flag value` both work. An empty attached value is a +/// missing value, not an empty path. +fn flagValue(flag: Flag, argv: []const []const u8, i: *usize) ParseError![]const u8 { + if (flag.attached) |attached| { + if (attached.len == 0) return error.MissingValue; + return attached; + } + if (i.* + 1 >= argv.len) return error.MissingValue; + i.* += 1; + return argv[i.*]; +} + +/// `run` and `check` take the same two flags. `run` throws away +/// `config_explicit`; bootstrap reads the path either way. +fn parseCheckArgs(argv: []const []const u8) ParseError!CheckArgs { + var args: CheckArgs = .{}; + var i: usize = 0; + while (i < argv.len) : (i += 1) { + const flag = splitFlag(argv[i]) orelse return error.TooManyArguments; + if (eql(flag.name, "data-dir")) { + args.paths.data_dir = try flagValue(flag, argv, &i); + } else if (eql(flag.name, "config")) { + args.paths.config = try flagValue(flag, argv, &i); + args.config_explicit = true; + } else return error.UnknownFlag; + } + return args; +} + +fn parseExportArgs(argv: []const []const u8) ParseError!ExportArgs { + var args: ExportArgs = .{}; + var i: usize = 0; + while (i < argv.len) : (i += 1) { + const flag = splitFlag(argv[i]) orelse return error.TooManyArguments; + if (eql(flag.name, "data-dir")) { + args.paths.data_dir = try flagValue(flag, argv, &i); + } else if (eql(flag.name, "out")) { + args.out = try flagValue(flag, argv, &i); + } else return error.UnknownFlag; + } + return args; +} + +fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs { + var paths: Paths = .{}; + var force = false; + var file: ?[]const u8 = null; + + var i: usize = 0; + while (i < argv.len) : (i += 1) { + const flag = splitFlag(argv[i]) orelse { + if (file != null) return error.TooManyArguments; + file = argv[i]; + continue; + }; + if (eql(flag.name, "data-dir")) { + paths.data_dir = try flagValue(flag, argv, &i); + } else if (eql(flag.name, "force")) { + // A boolean flag takes no value, so `--force=1` is not a spelling of + // any flag this program has. + if (flag.attached != null) return error.UnknownFlag; + force = true; + } else return error.UnknownFlag; + } + + return .{ + .paths = paths, + .file = file orelse return error.MissingArgument, + .force = force, + }; +} + +fn eql(a: []const u8, b: []const u8) bool { + return std.mem.eql(u8, a, b); +} + +// --------------------------------------------------------------------------- +// path assembly +// --------------------------------------------------------------------------- + +pub const DataDir = struct { + dir: std.Io.Dir, + config_db_path: [:0]const u8, + querylog_db_path: [:0]const u8, + + /// Creates `data_dir` and its parents at mode 0700 when `create` is set, + /// then opens it. Plain `createDirPath` hardcodes 0777 (Dir.zig:843) and + /// must not be used here: this directory holds `config.db`, which holds + /// `web.password_hash`. + pub fn open(io: std.Io, gpa: Allocator, data_dir: []const u8, create: bool) !DataDir { + if (create) { + _ = try std.Io.Dir.cwd().createDirPathStatus(io, data_dir, .fromMode(0o700)); + } + + var dir = try std.Io.Dir.cwd().openDir(io, data_dir, .{}); + errdefer dir.close(io); + + // SQLite opens by path, not by directory handle, so both paths are + // joined and NUL-terminated here rather than resolved through `dir`. + const config_db_path = try std.fs.path.joinZ(gpa, &.{ data_dir, config_db_name }); + errdefer gpa.free(config_db_path); + const querylog_db_path = try std.fs.path.joinZ(gpa, &.{ data_dir, querylog_db_name }); + + return .{ + .dir = dir, + .config_db_path = config_db_path, + .querylog_db_path = querylog_db_path, + }; + } + + pub fn close(self: *DataDir, io: std.Io, gpa: Allocator) void { + self.dir.close(io); + gpa.free(self.config_db_path); + gpa.free(self.querylog_db_path); + } + + /// The order of these three steps is the specification, not a style: + /// + /// 1. `db.Db.open` creates `config.db`, at `0644 & ~umask` — SQLite's + /// choice, not ours. + /// 2. chmod 0600. + /// 3. `applyPragmas` turns on WAL, which is what creates `config.db-wal` + /// and `config.db-shm`. SQLite copies the main database file's + /// permissions onto both sidecars, so setting 0600 first gets them for + /// free. The other order leaves them world-readable, and the WAL of a + /// database holding `web.password_hash` is as sensitive as the database. + pub fn openConfigDb(self: *const DataDir, io: std.Io) !db.Db { + var database = try db.Db.open(self.config_db_path, .{}); + errdefer database.close(); + try self.dir.setFilePermissions(io, config_db_name, .fromMode(0o600), .{}); + try db.applyPragmas(&database, .{}); + return database; + } +}; + +// --------------------------------------------------------------------------- +// entry functions +// --------------------------------------------------------------------------- + +pub const Runner = struct { + io: std.Io, + gpa: Allocator, + out: *Writer, + err: *Writer, +}; + +const usage_text = + \\usage: nxdns [options] + \\ + \\commands: + \\ run serve DNS + \\ check validate the configuration + \\ export write the configuration to stdout, or to --out + \\ import FILE replace the configuration with FILE + \\ version print version information + \\ help print this message + \\ + \\options: + \\ --data-dir DIR data directory (default /var/lib/nxdns) + \\ --config FILE configuration file (default /etc/nxdns/config.zon) + \\ --out FILE write the export to FILE instead of stdout + \\ --force let import replace a database that already has content + \\ +; + +/// Returns nothing, so a writer failure here has nowhere to go. Every caller +/// flushes afterwards and reports that failure instead. +pub fn usage(w: *Writer) void { + w.writeAll(usage_text) catch {}; +} + +/// Both `main` and the tests end a command the same way: flush, then report. +/// A flush that fails is a runtime failure even when the command succeeded — +/// output the operator never received is not output. +fn finish(r: Runner, code: u8) u8 { + r.out.flush() catch return exit_runtime; + r.err.flush() catch return exit_runtime; + return code; +} + +pub fn runUsageError(r: Runner, e: ParseError) u8 { + r.err.print("{s}\n\n", .{parseErrorMessage(e)}) catch {}; + usage(r.err); + return finish(r, exit_usage); +} + +fn parseErrorMessage(e: ParseError) []const u8 { + return switch (e) { + error.UnknownCommand => "unknown command", + error.UnknownFlag => "unknown flag", + error.MissingValue => "a flag was given without its value", + error.MissingArgument => "a required argument is missing", + error.TooManyArguments => "too many arguments", + }; +} + +pub fn runHelp(r: Runner) u8 { + usage(r.out); + return finish(r, exit_ok); +} + +pub fn runVersion(r: Runner) u8 { + r.out.print("nxdns {s} ({s})\nzig {s}\n", .{ + version.string, + version.git_commit, + version.zig_version_string, + }) catch return finish(r, exit_runtime); + return finish(r, exit_ok); +} + +/// Unchanged from milestone 1. Wiring the configuration into the servers is +/// Phase 7; there is deliberately no half-built serving path here. +pub fn runRun(r: Runner, paths: Paths) u8 { + _ = paths; + r.out.writeAll("not implemented\n") catch return finish(r, exit_runtime); + return finish(r, exit_check); +} + +pub fn runExport(r: Runner, args: ExportArgs) u8 { + exportImpl(r, args) catch |e| { + r.err.print("export failed: {s}\n", .{@errorName(e)}) catch {}; + return finish(r, exit_runtime); + }; + return finish(r, exit_ok); +} + +fn exportImpl(r: Runner, args: ExportArgs) !void { + var data = try DataDir.open(r.io, r.gpa, args.paths.data_dir, false); + defer data.close(r.io, r.gpa); + + var database = try data.openConfigDb(r.io); + defer database.close(); + _ = try migrations.migrate(&database); + + const path = args.out orelse return config_export.writeToWriter(r.gpa, &database, r.out); + + // Relative to the working directory, not to the data directory: an operator + // running `nxdns export --out backup.zon` means the shell's directory. + // `writeToFile` is the atomic, 0600 path — the file carries + // `web.password_hash`. + try config_export.writeToFile(r.io, r.gpa, &database, std.Io.Dir.cwd(), path); + try r.out.print("wrote {s}\n", .{path}); +} + +pub fn runImport(r: Runner, args: ImportArgs) u8 { + var diags: validate.Diagnostics = .init(r.gpa); + defer diags.deinit(); + + importImpl(r, args, &diags) catch |e| { + // Every problem, not just the first: an operator fixing a config file + // should need one run to see the whole list. + diags.writeAll(r.err) catch {}; + r.err.print("import failed: {s}\n", .{@errorName(e)}) catch {}; + return finish(r, failureExitCode(e, diags.problems.items.len)); + }; + return finish(r, exit_ok); +} + +fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void { + var data = try DataDir.open(r.io, r.gpa, args.paths.data_dir, true); + defer data.close(r.io, r.gpa); + + var database = try data.openConfigDb(r.io); + defer database.close(); + _ = try migrations.migrate(&database); + + try import.importFile( + r.io, + r.gpa, + &database, + std.Io.Dir.cwd(), + args.file, + .{ .force = args.force }, + diags, + ); + try r.out.print("imported {s}\n", .{args.file}); +} + +/// A configuration the operator can fix exits 2; everything else is a runtime +/// failure. `validate` records a diagnostic for every error it returns and then +/// returns the first one, so a non-empty diagnostics list is the reliable +/// discriminator; the named errors below are the config faults that never reach +/// the validator. +/// +/// `error.OutOfMemory` is matched first, before the list is consulted. Both +/// recording paths — `validate` and import's per-line rendering of a ZON syntax +/// error — add one problem at a time and can run out of memory partway, which +/// leaves problems recorded for a run whose real outcome is a resource failure. +/// A partial report is not a verdict on the configuration, so the runtime exit +/// code wins. +fn failureExitCode(e: anyerror, problems: usize) u8 { + if (e == error.OutOfMemory) return exit_runtime; + if (problems != 0) return exit_check; + return switch (e) { + error.DatabaseNotEmpty, + error.ConfigTooLarge, + error.ParseZon, + error.PasswordAndHashBothSet, + => exit_check, + else => exit_runtime, + }; +} + +// --------------------------------------------------------------------------- +// check +// --------------------------------------------------------------------------- + +/// An A query for example.com: id 0x1234, RD set, one question. The probe wants +/// a name every resolver on earth answers, so a FAIL line means the upstream is +/// unreachable rather than that the name is odd. +const probe_query = + "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01"; + +/// The point of `check` is that an operator sees every problem in one run, so +/// this never returns early on a finding. `probe` is false in unit tests and +/// true from `main`: the probe leaves the machine. +pub fn runCheck(r: Runner, args: CheckArgs, probe: bool) u8 { + const code = checkImpl(r, args, probe) catch |e| { + r.err.print("check failed: {s}\n", .{@errorName(e)}) catch {}; + return finish(r, exit_runtime); + }; + return finish(r, code); +} + +fn checkImpl(r: Runner, args: CheckArgs, probe: bool) !u8 { + var arena_state: std.heap.ArenaAllocator = .init(r.gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Which source was used is printed in every branch, so the answer is never + // ambiguous about what it checked. + if (args.config_explicit) { + try r.out.print("checking configuration file {s}\n", .{args.paths.config}); + return checkFile(r, arena, args.paths.config, probe); + } + + const config_db_path = try std.fs.path.join(arena, &.{ args.paths.data_dir, config_db_name }); + if (try pathExists(r.io, config_db_path)) { + try r.out.print("checking database {s}\n", .{config_db_path}); + + var data = try DataDir.open(r.io, r.gpa, args.paths.data_dir, false); + defer data.close(r.io, r.gpa); + + // A `check` on a database one schema version behind must still work, + // which is what an operator runs right after an upgrade. + var database = try data.openConfigDb(r.io); + defer database.close(); + _ = try migrations.migrate(&database); + + const cfg = try config_export.readConfig(&database, arena); + return checkConfig(r, cfg, probe); + } + + if (try pathExists(r.io, args.paths.config)) { + try r.out.print("checking configuration file {s}\n", .{args.paths.config}); + return checkFile(r, arena, args.paths.config, probe); + } + + try r.out.print("nothing to check: no {s} in {s} and no {s}\n", .{ + config_db_name, + args.paths.data_dir, + args.paths.config, + }); + return exit_check; +} + +fn pathExists(io: std.Io, path: []const u8) std.Io.Dir.AccessError!bool { + std.Io.Dir.cwd().access(io, path, .{}) catch |e| switch (e) { + error.FileNotFound => return false, + else => |other| return other, + }; + return true; +} + +/// `AccessOptions.read` is the `R_OK` bit of `faccessat` (`Io/Threaded.zig` +/// `dirAccessPosix`); the default `.{}` sends mode 0, which is `F_OK` and tests +/// existence only. A file that exists but denies this user a read is exactly the +/// case `check` has to catch, so it needs the bit set. +fn pathReadable(io: std.Io, path: []const u8) std.Io.Dir.AccessError!bool { + std.Io.Dir.cwd().access(io, path, .{ .read = true }) catch |e| switch (e) { + error.FileNotFound, error.AccessDenied, error.PermissionDenied => return false, + else => |other| return other, + }; + return true; +} + +fn checkFile(r: Runner, arena: Allocator, path: []const u8, probe: bool) !u8 { + const source = std.Io.Dir.cwd().readFileAllocOptions( + r.io, + path, + arena, + .limited(import.max_config_bytes), + .of(u8), + 0, + ) catch |e| switch (e) { + error.StreamTooLong => { + try r.out.print("FAIL {s}: larger than {d} bytes\n", .{ path, import.max_config_bytes }); + return exit_check; + }, + else => |other| return other, + }; + + // Arena-owned and never handed to `std.zon.parse.free`; see the rule and its + // `parse.zig:874` citation in `config/import.zig`. + var zon_diag: std.zon.parse.Diagnostics = .{}; + const cfg = std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) { + error.OutOfMemory => return error.OutOfMemory, + // The rendering carries the line and column, which is the whole value of + // running `check` against a file the operator just edited. + error.ParseZon => { + try r.out.print("FAIL {s}: {f}\n", .{ path, &zon_diag }); + return exit_check; + }, + }; + + return checkConfig(r, cfg, probe); +} + +/// The half of `check` that has a `Config` already: validate, report every +/// diagnostic, then the certificate and upstream checks. With TLS disabled and +/// `probe` false it touches neither the filesystem nor the network, which is +/// what makes it unit-testable. +pub fn checkConfig(r: Runner, cfg: model.Config, probe: bool) !u8 { + var diags: validate.Diagnostics = .init(r.gpa); + defer diags.deinit(); + + // The returned error is `problems[0].err` — one of the lines about to be + // printed — so it carries nothing the report does not. Only an allocation + // failure means the report itself is incomplete. + validate.validate(cfg, &diags) catch |e| switch (e) { + error.OutOfMemory => return error.OutOfMemory, + else => {}, + }; + try diags.writeAll(r.out); + + var failures = diags.problems.items.len; + failures += try checkCertificates(r, cfg); + if (probe) failures += try probeUpstreams(r, cfg); + + if (failures != 0) return exit_check; + try r.out.writeAll("OK: no problems found\n"); + return exit_ok; +} + +fn checkCertificates(r: Runner, cfg: model.Config) !usize { + return try checkTlsFiles(r, cfg.doh_server, "doh_server") + + try checkTlsFiles(r, cfg.dot_server, "dot_server"); +} + +/// An unreadable certificate or key fails the run; a key readable by anyone +/// beyond its owner is a warning (PLAN §19) and leaves the exit code alone, +/// because the service still starts. +fn checkTlsFiles(r: Runner, endpoint: model.TlsEndpoint, comptime section: []const u8) !usize { + if (!endpoint.enabled) return 0; + var failures: usize = 0; + + if (!try pathReadable(r.io, endpoint.cert_path)) { + try r.out.print("FAIL " ++ section ++ ".cert_path: '{s}' is not readable\n", .{endpoint.cert_path}); + failures += 1; + } + + if (try pathReadable(r.io, endpoint.key_path)) { + const stat = try std.Io.Dir.cwd().statFile(r.io, endpoint.key_path, .{}); + const mode = stat.permissions.toMode() & 0o777; + if (mode & 0o077 != 0) { + try r.out.print( + "WARN " ++ section ++ ".key_path: '{s}' is mode {o}; a TLS key must be readable by its owner only\n", + .{ endpoint.key_path, mode }, + ); + } + } else { + try r.out.print("FAIL " ++ section ++ ".key_path: '{s}' is not readable\n", .{endpoint.key_path}); + failures += 1; + } + + return failures; +} + +/// One `Pool` per upstream, never one pool over all of them. The pool's job is +/// failover: a shared pool would report success as soon as any upstream +/// answered, and a broken upstream would stay invisible — the exact thing +/// `check` exists to surface. Driving the real pool rather than the client +/// directly keeps the deadline, cancellation and health machinery identical to +/// what the server will do. +fn probeUpstreams(r: Runner, cfg: model.Config) !usize { + var failures: usize = 0; + + var http: std.http.Client = .{ .allocator = r.gpa, .io = r.io }; + defer http.deinit(); + + // Shared across every DoT endpoint: the scan is expensive and the trust + // store does not vary per upstream. + var bundle: Certificate.Bundle = .empty; + defer bundle.deinit(r.gpa); + var bundle_lock: std.Io.RwLock = .init; + + const chunk = tls.Client.min_buffer_len; + const tls_buffers = try r.gpa.alloc(u8, 4 * chunk); + defer r.gpa.free(tls_buffers); + + var request_buf: [1024]u8 = undefined; + var transfer_buf: [4096]u8 = undefined; + const response_buf = try r.gpa.alloc(u8, transport.max_message_len); + defer r.gpa.free(response_buf); + + const attempt_timeout: std.Io.Clock.Duration = .{ + .raw = model.totalTimeout(cfg.upstream), + .clock = .awake, + }; + // The pool jitters backoff from this; one probe per upstream never reaches + // backoff, so the value only has to be a value. + const seed: u64 = @truncate(@as(u96, @bitCast(std.Io.Clock.real.now(r.io).nanoseconds))); + + for (cfg.upstreams) |server| { + if (!server.enabled) continue; + + const endpoint = transport.Endpoint.parse(server.url) catch { + try r.out.print("FAIL {s}: not an https:// or tls:// endpoint\n", .{server.url}); + failures += 1; + continue; + }; + + // Both clients are pinned for the pool's lifetime: `Entry.client` is an + // erased pointer into one of them. + var doh: doh_client.DohClient = undefined; + var dot: dot_client.DotClient = undefined; + const client: transport.Client = switch (endpoint.scheme) { + .doh => doh: { + doh = doh_client.DohClient.init(&http, endpoint, &request_buf, &transfer_buf) catch { + try r.out.print("FAIL {s}: not a usable DoH url\n", .{server.url}); + failures += 1; + continue; + }; + break :doh doh.client(); + }, + .dot => dot: { + dot = dot_client.DotClient.init(endpoint, r.gpa, &bundle, &bundle_lock, .{ + .tls_read = tls_buffers[0..chunk], + .tls_write = tls_buffers[chunk .. 2 * chunk], + .stream_read = tls_buffers[2 * chunk .. 3 * chunk], + .stream_write = tls_buffers[3 * chunk ..], + }); + break :dot dot.client(); + }, + }; + + var entries = [_]pool.Entry{.{ + .endpoint = endpoint, + .client = client, + .priority = server.priority, + .enabled = true, + .health = .init, + }}; + var single: pool.Pool = .init(&entries, .{}, attempt_timeout, seed); + + if (single.exchange(r.io, probe_query, response_buf)) |_| { + try r.out.print("OK {s}\n", .{server.url}); + } else |_| { + // The concrete cause lives in the entry's health, which is where the + // pool put it; `@errorName` of the pool's return value would only + // repeat the last attempt's classification. + var snapshots: [1]pool.Snapshot = undefined; + const taken = try single.snapshot(r.io, &snapshots); + const detail = if (taken == 1) snapshots[0].last_error else "no detail recorded"; + try r.out.print("FAIL {s}: {s}\n", .{ server.url, detail }); + failures += 1; + } + } + + return failures; +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "parseArgs accepts run with no flags" { + const command = try parseArgs(&.{"run"}); + try testing.expectEqualStrings("/var/lib/nxdns", command.run.data_dir); + try testing.expectEqualStrings("/etc/nxdns/config.zon", command.run.config); +} + +test "parseArgs accepts run with --data-dir and --config" { + const command = try parseArgs(&.{ "run", "--data-dir", "/srv/nx", "--config", "/tmp/c.zon" }); + try testing.expectEqualStrings("/srv/nx", command.run.data_dir); + try testing.expectEqualStrings("/tmp/c.zon", command.run.config); +} + +test "parseArgs accepts --data-dir with and without an equals sign" { + const attached = try parseArgs(&.{ "check", "--data-dir=/srv/nx" }); + try testing.expectEqualStrings("/srv/nx", attached.check.paths.data_dir); + + const separate = try parseArgs(&.{ "check", "--data-dir", "/srv/nx" }); + try testing.expectEqualStrings("/srv/nx", separate.check.paths.data_dir); +} + +test "parseArgs records whether check was given an explicit --config" { + const implicit = try parseArgs(&.{"check"}); + try testing.expect(!implicit.check.config_explicit); + + const explicit = try parseArgs(&.{ "check", "--config=/tmp/c.zon" }); + try testing.expect(explicit.check.config_explicit); + try testing.expectEqualStrings("/tmp/c.zon", explicit.check.paths.config); +} + +test "parseArgs accepts export with --out" { + const command = try parseArgs(&.{ "export", "--out", "backup.zon" }); + try testing.expectEqualStrings("backup.zon", command.export_.out.?); + + const bare = try parseArgs(&.{"export"}); + try testing.expectEqual(@as(?[]const u8, null), bare.export_.out); +} + +test "parseArgs accepts import with a file, --force and --data-dir" { + const command = try parseArgs(&.{ "import", "c.zon", "--force", "--data-dir=/srv/nx" }); + try testing.expectEqualStrings("c.zon", command.import_.file); + try testing.expect(command.import_.force); + try testing.expectEqualStrings("/srv/nx", command.import_.paths.data_dir); +} + +test "parseArgs accepts import with the file after the flags" { + const command = try parseArgs(&.{ "import", "--data-dir", "/srv/nx", "c.zon" }); + try testing.expectEqualStrings("c.zon", command.import_.file); + try testing.expect(!command.import_.force); +} + +test "parseArgs accepts version" { + try testing.expectEqual(Command.version, try parseArgs(&.{"version"})); +} + +test "parseArgs accepts help, --help and -h" { + try testing.expectEqual(Command.help, try parseArgs(&.{"help"})); + try testing.expectEqual(Command.help, try parseArgs(&.{"--help"})); + try testing.expectEqual(Command.help, try parseArgs(&.{"-h"})); +} + +test "parseArgs rejects import without a file" { + try testing.expectError(error.MissingArgument, parseArgs(&.{"import"})); + try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--force" })); +} + +test "parseArgs rejects --out without a value" { + try testing.expectError(error.MissingValue, parseArgs(&.{ "export", "--out" })); + try testing.expectError(error.MissingValue, parseArgs(&.{ "export", "--out=" })); +} + +test "parseArgs rejects an unknown flag" { + try testing.expectError(error.UnknownFlag, parseArgs(&.{ "check", "--nope" })); + try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--force=1" })); +} + +test "parseArgs rejects an unknown command" { + try testing.expectError(error.UnknownCommand, parseArgs(&.{"frobnicate"})); +} + +test "parseArgs rejects an empty argument list" { + try testing.expectError(error.UnknownCommand, parseArgs(&.{})); +} + +test "parseArgs rejects an extra positional argument" { + try testing.expectError(error.TooManyArguments, parseArgs(&.{ "export", "extra" })); + try testing.expectError(error.TooManyArguments, parseArgs(&.{ "version", "extra" })); + try testing.expectError(error.TooManyArguments, parseArgs(&.{ "import", "a.zon", "b.zon" })); + try testing.expectError(error.TooManyArguments, parseArgs(&.{ "help", "extra" })); + try testing.expectError(error.TooManyArguments, parseArgs(&.{ "--help", "extra" })); + try testing.expectError(error.TooManyArguments, parseArgs(&.{ "-h", "extra" })); +} + +test "usage writes non-empty text" { + var out: Writer.Allocating = .init(testing.allocator); + defer out.deinit(); + usage(&out.writer); + try testing.expect(out.written().len > 0); + try testing.expect(std.mem.startsWith(u8, out.written(), "usage: nxdns")); +} + +const Captured = struct { + threaded: std.Io.Threaded, + out: Writer.Allocating, + err: Writer.Allocating, + + fn init(gpa: Allocator) Captured { + return .{ + .threaded = .init(gpa, .{}), + .out = .init(gpa), + .err = .init(gpa), + }; + } + + fn deinit(self: *Captured) void { + self.out.deinit(); + self.err.deinit(); + self.threaded.deinit(); + } + + fn runner(self: *Captured) Runner { + return .{ + .io = self.threaded.io(), + .gpa = testing.allocator, + .out = &self.out.writer, + .err = &self.err.writer, + }; + } +}; + +fn countLines(text: []const u8) usize { + return std.mem.count(u8, text, "\n"); +} + +test "checkConfig returns 0 for a clean configuration" { + var captured: Captured = .init(testing.allocator); + defer captured.deinit(); + + const cfg: model.Config = .{ + .groups = &.{.{ .name = "default" }}, + .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, + }; + + try testing.expectEqual(exit_ok, try checkConfig(captured.runner(), cfg, false)); + try testing.expectEqualStrings("OK: no problems found\n", captured.out.written()); +} + +test "checkConfig returns 2 and prints one line per problem" { + var captured: Captured = .init(testing.allocator); + defer captured.deinit(); + + // Three problems: no group named 'default', no enabled upstream, and a + // zero port. + const cfg: model.Config = .{ .dns = .{ .port = 0 } }; + + try testing.expectEqual(exit_check, try checkConfig(captured.runner(), cfg, false)); + + const text = captured.out.written(); + try testing.expectEqual(@as(usize, 3), countLines(text)); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "dns.port:")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "groups:")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "upstreams:")); +} + +test "checkConfig reports every problem rather than stopping at the first" { + var captured: Captured = .init(testing.allocator); + defer captured.deinit(); + + const cfg: model.Config = .{ + .groups = &.{.{ .name = "default" }}, + .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, + .clients = &.{ + .{ .ip = "not-an-ip" }, + .{ .ip = "also-not-an-ip" }, + .{ .ip = "192.168.1.1", .group = "missing" }, + }, + }; + + try testing.expectEqual(exit_check, try checkConfig(captured.runner(), cfg, false)); + try testing.expectEqual(@as(usize, 3), countLines(captured.out.written())); +} + +test "runVersion prints the milestone-1 version lines and exits 0" { + var captured: Captured = .init(testing.allocator); + defer captured.deinit(); + + try testing.expectEqual(exit_ok, runVersion(captured.runner())); + try testing.expect(std.mem.startsWith(u8, captured.out.written(), "nxdns ")); + try testing.expectEqual(@as(usize, 2), countLines(captured.out.written())); +} + +test "runRun still reports that serving is not implemented" { + var captured: Captured = .init(testing.allocator); + defer captured.deinit(); + + try testing.expectEqual(exit_check, runRun(captured.runner(), .{})); + try testing.expectEqualStrings("not implemented\n", captured.out.written()); +} + +test "runUsageError names the fault and prints the usage text" { + var captured: Captured = .init(testing.allocator); + defer captured.deinit(); + + try testing.expectEqual(exit_usage, runUsageError(captured.runner(), error.UnknownFlag)); + const text = captured.err.written(); + try testing.expect(std.mem.startsWith(u8, text, "unknown flag\n")); + try testing.expect(std.mem.containsAtLeast(u8, text, 1, "usage: nxdns")); +} + +test "failureExitCode separates a fixable configuration from a runtime failure" { + try testing.expectEqual(exit_check, failureExitCode(error.DatabaseNotEmpty, 0)); + try testing.expectEqual(exit_check, failureExitCode(error.ParseZon, 0)); + try testing.expectEqual(exit_check, failureExitCode(error.NoUpstreams, 1)); + try testing.expectEqual(exit_runtime, failureExitCode(error.IoErr, 0)); + try testing.expectEqual(exit_runtime, failureExitCode(error.OutOfMemory, 0)); + // A partial report from an allocation failure is a runtime failure, not a + // finding about the configuration. + try testing.expectEqual(exit_runtime, failureExitCode(error.OutOfMemory, 3)); +} + +test "an allocation failure while recording diagnostics exits 1, not 2" { + // The recording path for real: `validate` adds one problem per fault, so an + // allocator that fails partway leaves problems in the list AND returns + // `error.OutOfMemory`. How many allocations one problem costs is + // `Diagnostics.add`'s business, so the failure point is swept rather than + // guessed. + const cfg: model.Config = .{ .dns = .{ .port = 0 } }; + + var saw_partial_report = false; + var fail_index: usize = 0; + while (fail_index < 32) : (fail_index += 1) { + var failing: std.testing.FailingAllocator = .init(testing.allocator, .{ .fail_index = fail_index }); + var diags: validate.Diagnostics = .init(failing.allocator()); + defer diags.deinit(); + + validate.validate(cfg, &diags) catch |e| { + if (e != error.OutOfMemory) continue; + const problems = diags.problems.items.len; + if (problems == 0) continue; + saw_partial_report = true; + try testing.expectEqual(exit_runtime, failureExitCode(e, problems)); + }; + } + + try testing.expect(saw_partial_report); +} + +// `runCheck` against real paths, `runExport` and `runImport` all need a real +// data directory, and the upstream probe leaves the machine. Those cases are +// S7's: `src/storage/storage_integration_test.zig` cases 20-22. diff --git a/src/config/bootstrap.zig b/src/config/bootstrap.zig new file mode 100644 index 0000000..56e15f1 --- /dev/null +++ b/src/config/bootstrap.zig @@ -0,0 +1,60 @@ +//! First-start seeding (PLAN §3.5). +//! +//! A policy wrapper over `import.importFile`, and nothing more. There is exactly +//! one code path from a config file into the database, so bootstrap and +//! `nxdns import` cannot drift apart. +//! +//! The policy is three lines long: +//! +//! - no file → normal steady state, keep the database as it is; +//! - database already configured → the file is ignored, as PLAN §3.5 requires; +//! - otherwise → import it, and a file that is unreadable, unparseable or +//! invalid is an error. The operator wrote that file and meant it; starting +//! with silent defaults instead is the exact failure mode PLAN §1.3 exists to +//! prevent. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const db = @import("../storage/db.zig"); +const import = @import("import.zig"); +const validate = @import("validate.zig"); + +const log = std.log.scoped(.config_bootstrap); + +pub const Outcome = enum { seeded, db_already_configured, no_config_file }; + +pub const Error = import.Error || std.Io.Dir.AccessError; + +/// Called by `nxdns run` before serving. +pub fn bootstrap( + io: std.Io, + gpa: Allocator, + database: *db.Db, + dir: std.Io.Dir, + config_path: []const u8, + diags: *validate.Diagnostics, +) Error!Outcome { + dir.access(io, config_path, .{}) catch |e| switch (e) { + error.FileNotFound => { + log.info("no configuration file at '{s}'; using the database as it is", .{config_path}); + return .no_config_file; + }, + else => |other| return other, + }; + + // Deliberately before the read: on every start after the first, the file is + // not even opened. + if (!try import.isEmpty(database)) { + log.info("configuration file ignored; the database is already configured", .{}); + return .db_already_configured; + } + + try import.importFile(io, gpa, database, dir, config_path, .{ .force = false }, diags); + log.info("seeded the database from '{s}'", .{config_path}); + return .seeded; +} + +// Every path through `bootstrap` starts with a filesystem access, so all three +// outcomes are exercised in `src/storage/storage_integration_test.zig` (S7) +// against real files. There is nothing here that an in-memory test could reach. diff --git a/src/config/export.zig b/src/config/export.zig new file mode 100644 index 0000000..f63fe5d --- /dev/null +++ b/src/config/export.zig @@ -0,0 +1,309 @@ +//! `nxdns export`: the database rendered back as canonical ZON. +//! +//! Deterministic by construction. Every list arrives through a repository whose +//! `ORDER BY` ends in a unique column set, every scalar comes from the settings +//! map, and the header carries no timestamp, version or host name. That is what +//! makes `export` → `import` → `export` byte-identical, and it keeps a config +//! diff free of noise. +//! +//! Runtime columns are absent from the model on purpose, so two exports taken +//! minutes apart on a live server are identical too. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Writer = std.Io.Writer; + +const db = @import("../storage/db.zig"); +const clients_repo = @import("../storage/repositories/clients_repo.zig"); +const groups_repo = @import("../storage/repositories/groups_repo.zig"); +const local_repo = @import("../storage/repositories/local_repo.zig"); +const rules_repo = @import("../storage/repositories/rules_repo.zig"); +const settings_repo = @import("../storage/repositories/settings_repo.zig"); +const sources_repo = @import("../storage/repositories/sources_repo.zig"); +const upstreams_repo = @import("../storage/repositories/upstreams_repo.zig"); +const model = @import("model.zig"); + +const log = std.log.scoped(.config_export); + +/// What reading the database into a `Config` can fail with. A caller that only +/// wants the value never has to handle a filesystem error. +pub const ReadError = db.Error || model.SettingsError; + +pub const Error = ReadError || Writer.Error || + std.Io.Dir.CreateFileAtomicError || std.Io.File.SyncError || std.Io.File.Atomic.ReplaceError; + +const header = + \\// nxdns configuration + \\// generated by `nxdns export` — the database is the source of truth + \\ +; + +/// Reads every repository into `arena` and assembles a `Config`. Every string in +/// the result belongs to `arena`; there is nothing else to free. +pub fn readConfig(database: *db.Db, arena: Allocator) ReadError!model.Config { + var cfg: model.Config = .{}; + + // The scalar sections first: an absent key keeps the model default, which is + // how a migration adds a setting with no data step. Unknown keys are counted + // and logged rather than refused — downgrading a binary must not make a + // config database unreadable. + const settings = try settings_repo.listSettings(database, arena); + var unknown_keys: usize = 0; + try model.fromSettings(settings.items, &cfg, &unknown_keys); + if (unknown_keys != 0) { + log.warn("{d} unknown settings key(s) were ignored while exporting", .{unknown_keys}); + } + + cfg.groups = (try groups_repo.listGroups(database, arena)).items; + cfg.upstreams = (try upstreams_repo.listUpstreams(database, arena)).items; + cfg.clients = (try clients_repo.listClients(database, arena)).items; + cfg.client_prefixes = (try clients_repo.listClientPrefixes(database, arena)).items; + cfg.blocklist_sources = (try sources_repo.listBlocklistSources(database, arena)).items; + cfg.group_sources = (try groups_repo.listGroupSources(database, arena)).items; + cfg.rules = (try rules_repo.listRules(database, arena)).items; + cfg.local_records = (try local_repo.listLocalRecords(database, arena)).items; + cfg.forward_zones = (try local_repo.listForwardZones(database, arena)).items; + + // `web.password` is operator input and is never stored; the exported file + // always carries an empty one. This is exactly what makes the round trip + // stable: re-importing takes the "password is empty" branch and stores the + // same hash. + cfg.web.password = ""; + return cfg; +} + +/// Canonical ZON: the fixed header, then the value with every default emitted. +/// Emitting defaults makes the file a complete record of the running +/// configuration and makes the round trip independent of a later change to a +/// default value. +pub fn writeConfig(cfg: model.Config, w: *Writer) Writer.Error!void { + try w.writeAll(header); + try std.zon.stringify.serialize(cfg, .{ + .whitespace = true, + .emit_default_optional_fields = true, + }, w); + try w.writeAll("\n"); +} + +pub fn writeToWriter(gpa: Allocator, database: *db.Db, w: *Writer) Error!void { + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + + const cfg = try readConfig(database, arena_state.allocator()); + return writeConfig(cfg, w); +} + +/// Atomic and owner-only. The exported file carries `web.password_hash`, so 0600 +/// is not optional. +/// +/// `createFileAtomic` puts its temporary file in the destination's own directory +/// (verified in `Io/Threaded.zig`: `atomicFileInit` receives either `dir` or the +/// directory opened on `dirname(dest_path)`), so the final `replace` is a +/// same-filesystem `rename` and is genuinely atomic. +pub fn writeToFile( + io: std.Io, + gpa: Allocator, + database: *db.Db, + dir: std.Io.Dir, + path: []const u8, +) Error!void { + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const cfg = try readConfig(database, arena_state.allocator()); + + var af = try dir.createFileAtomic(io, path, .{ + .permissions = .fromMode(0o600), + .replace = true, + }); + defer af.deinit(io); + + var buf: [4096]u8 = undefined; + var fw = af.file.writer(io, &buf); + writeConfig(cfg, &fw.interface) catch |e| return reportWriteFailure(&fw, e); + fw.interface.flush() catch |e| return reportWriteFailure(&fw, e); + + // Before `replace`, which closes the file: the rename must publish durable + // bytes, not an empty file with the content still in the page cache. + try af.file.sync(io); + try af.replace(io); +} + +/// `Writer.Error` is a single `error.WriteFailed`; the cause lives on the +/// `File.Writer`. Logging it at `warn` is what turns "export failed" into +/// something an operator can act on. +fn reportWriteFailure(fw: *std.Io.File.Writer, e: Writer.Error) Writer.Error { + if (fw.err) |cause| log.warn("writing the export failed: {s}", .{@errorName(cause)}); + return e; +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; +const migrations = @import("../storage/migrations.zig"); +const import = @import("import.zig"); +const validate = @import("validate.zig"); + +fn openMigrated() !db.Db { + var database = try db.Db.open(":memory:", .{ .mode = .memory }); + errdefer database.close(); + try db.applyPragmas(&database, .{}); + _ = try migrations.migrate(&database); + return database; +} + +const seed_source: [:0]const u8 = + \\.{ + \\ .dns = .{ .port = 5353 }, + \\ .logging = .{ .level = .err, .retention_days = 7 }, + \\ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaGhhc2g" }, + \\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } }, + \\ .upstreams = .{ + \\ .{ .url = "https://dns.example/dns-query", .priority = 10 }, + \\ .{ .url = "tls://dot.example:853", .priority = 20, .enabled = false }, + \\ }, + \\ .clients = .{ .{ .ip = "fd00::1", .name = "tablet", .group = "kids" } }, + \\ .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } }, + \\ .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "ads" } }, + \\ .group_sources = .{ .{ .group = "kids", .source_url = "https://lists.example/ads.txt" } }, + \\ .rules = .{ + \\ .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block }, + \\ .{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow }, + \\ }, + \\ .local_records = .{ + \\ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 600 }, + \\ .{ .name = "nas.lan", .rtype = .aaaa, .value = "fd00::10" }, + \\ }, + \\ .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.1:53" } }, + \\} +; + +fn seed(io: std.Io, database: *db.Db, source: [:0]const u8) !void { + var diags: validate.Diagnostics = .init(testing.allocator); + defer diags.deinit(); + return import.importSource(io, testing.allocator, database, source, .{}, &diags); +} + +test "writeConfig emits the fixed header and re-parses into an equal config" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const gpa = testing.allocator; + + var database = try openMigrated(); + defer database.close(); + try seed(io, &database, seed_source); + + var out: Writer.Allocating = .init(gpa); + defer out.deinit(); + try writeToWriter(gpa, &database, &out.writer); + + // The header is fixed text. Anything variable in it — a timestamp, a version + // or a host name — would break the byte-stable round trip. + const text = out.written(); + try testing.expect(std.mem.startsWith(u8, text, header)); + try testing.expectEqualStrings(header, text[0..header.len]); + + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const source = try gpa.dupeZ(u8, text); + defer gpa.free(source); + const reparsed = try std.zon.parse.fromSliceAlloc( + model.Config, + arena_state.allocator(), + source, + null, + .{}, + ); + try testing.expectEqual(@as(u16, 5353), reparsed.dns.port); + try testing.expectEqual(model.LogLevel.err, reparsed.logging.level); +} + +test "readConfig, writeConfig, import and readConfig again produce an equal config" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const gpa = testing.allocator; + + var first = try openMigrated(); + defer first.close(); + try seed(io, &first, seed_source); + + var text: Writer.Allocating = .init(gpa); + defer text.deinit(); + try writeToWriter(gpa, &first, &text.writer); + const source = try text.toOwnedSliceSentinel(0); + defer gpa.free(source); + + var second = try openMigrated(); + defer second.close(); + try seed(io, &second, source); + + var arena_a: std.heap.ArenaAllocator = .init(gpa); + defer arena_a.deinit(); + var arena_b: std.heap.ArenaAllocator = .init(gpa); + defer arena_b.deinit(); + + const a = try readConfig(&first, arena_a.allocator()); + const b = try readConfig(&second, arena_b.allocator()); + + try testing.expectEqual(a.dns.port, b.dns.port); + try testing.expectEqual(a.logging.level, b.logging.level); + try testing.expectEqualStrings(a.web.password_hash, b.web.password_hash); + try testing.expectEqual(a.groups.len, b.groups.len); + try testing.expectEqual(a.rules.len, b.rules.len); + try testing.expectEqualStrings(a.clients[0].ip, b.clients[0].ip); + try testing.expectEqualStrings(a.forward_zones[0].resolver, b.forward_zones[0].resolver); +} + +test "export is byte-stable across a re-import" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const gpa = testing.allocator; + + var first = try openMigrated(); + defer first.close(); + try seed(io, &first, seed_source); + + var a: Writer.Allocating = .init(gpa); + defer a.deinit(); + try writeToWriter(gpa, &first, &a.writer); + const source = try gpa.dupeZ(u8, a.written()); + defer gpa.free(source); + + var second = try openMigrated(); + defer second.close(); + try seed(io, &second, source); + + var b: Writer.Allocating = .init(gpa); + defer b.deinit(); + try writeToWriter(gpa, &second, &b.writer); + + try testing.expectEqualStrings(a.written(), b.written()); +} + +test "an exported password_hash survives a re-import unchanged" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const gpa = testing.allocator; + + var database = try openMigrated(); + defer database.close(); + + const cfg: model.Config = .{ + .groups = &.{.{ .name = "default" }}, + .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, + .web = .{ .password = "correct horse battery staple" }, + }; + try import.applyToDb(io, gpa, &database, cfg, 42, .{}); + + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const exported = try readConfig(&database, arena_state.allocator()); + + try testing.expectEqualStrings("", exported.web.password); + try testing.expect(std.mem.startsWith(u8, exported.web.password_hash, "$argon2id$")); +} diff --git a/src/config/import.zig b/src/config/import.zig new file mode 100644 index 0000000..99587fe --- /dev/null +++ b/src/config/import.zig @@ -0,0 +1,621 @@ +//! `nxdns import`: a ZON file becomes the whole content of `config.db`. +//! +//! The order is the specification. Nothing reaches the database until the file +//! has been read, parsed and validated, and every write happens inside one +//! `BEGIN IMMEDIATE` transaction, so a failed import leaves the database +//! byte-for-byte as it was. +//! +//! No filesystem write happens anywhere in this file: the input is opened +//! read-only and the database is SQLite's business. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const db = @import("../storage/db.zig"); +const config_schema = @import("../storage/config_schema.zig"); +const context = @import("../storage/repositories/context.zig"); +const clients_repo = @import("../storage/repositories/clients_repo.zig"); +const groups_repo = @import("../storage/repositories/groups_repo.zig"); +const local_repo = @import("../storage/repositories/local_repo.zig"); +const rules_repo = @import("../storage/repositories/rules_repo.zig"); +const settings_repo = @import("../storage/repositories/settings_repo.zig"); +const sources_repo = @import("../storage/repositories/sources_repo.zig"); +const upstreams_repo = @import("../storage/repositories/upstreams_repo.zig"); +const address = @import("../platform/address.zig"); +const model = @import("model.zig"); +const validate = @import("validate.zig"); + +const log = std.log.scoped(.config_import); + +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; + +/// Holds any PHC-encoded argon2id string comfortably. +const hash_buf_len = 256; + +/// The canonical text of an IPv6 prefix, the longest value canonicalised here. +const canonical_buf_len = 64; + +// --------------------------------------------------------------------------- +// emptiness +// --------------------------------------------------------------------------- + +/// A database is "never configured" when the migrations have run and nothing +/// else has. The migrations themselves create `schema_version` and seed +/// `groups(1, 'default')`, so "no rows anywhere" is the wrong test. +/// +/// True iff every table in `config_schema.content_tables` is empty, `groups` +/// holds exactly one row, and that row is the seeded `(1, 'default', 0)`. +/// +/// The client count here includes auto-materialised rows: a server that has +/// answered one query is configured enough that a bootstrap file must not +/// overwrite it. +pub fn isEmpty(database: *db.Db) db.Error!bool { + // `inline for` over a comptime table list: every statement below is a + // compile-time string, so no table name is ever concatenated at run time. + inline for (config_schema.content_tables) |table| { + if (try database.queryInt("SELECT count(*) FROM " ++ table) != 0) return false; + } + if (try database.queryInt("SELECT count(*) FROM groups") != 1) return false; + const seeded = try database.queryInt( + "SELECT count(*) FROM groups WHERE id = 1 AND name = 'default' AND safe_search = 0", + ); + return seeded == 1; +} + +// --------------------------------------------------------------------------- +// import +// --------------------------------------------------------------------------- + +/// Reads, parses, validates, then replaces the database contents. +pub fn importFile( + io: std.Io, + gpa: Allocator, + database: *db.Db, + dir: std.Io.Dir, + path: []const u8, + options: Options, + diags: *validate.Diagnostics, +) Error!void { + // `std.zon.parse` needs a sentinel-terminated source and `readFileAlloc` + // cannot supply one. + const source = dir.readFileAllocOptions( + io, + path, + gpa, + .limited(max_config_bytes), + .of(u8), + 0, + ) catch |e| switch (e) { + error.StreamTooLong => return error.ConfigTooLarge, + else => |other| return other, + }; + defer gpa.free(source); + + return importSource(io, gpa, database, source, options, diags); +} + +/// `importFile` minus the file. It exists because every step from the parse +/// onwards is testable without touching a filesystem, and `nxdns check` (S6) +/// needs the same parse-and-validate half. +pub fn importSource( + io: std.Io, + gpa: Allocator, + database: *db.Db, + source: [:0]const u8, + options: Options, + diags: *validate.Diagnostics, +) Error!void { + // The parsed `Config` is arena-owned and `std.zon.parse.free` is NEVER + // called on it. `Parser.parseStruct` fills an absent field by copying the + // struct's default straight through (parse.zig:874), so a defaulted + // `[]const u8` — and this model has many non-empty string defaults — points + // into the binary's read-only data. `parse.free` keeps no record of which + // fields were parsed and which were defaulted, so it would `@memset` and + // free rodata. Freeing the arena is the only correct release. + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var zon_diag: std.zon.parse.Diagnostics = .{}; + const cfg = std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) { + error.OutOfMemory => return error.OutOfMemory, + error.ParseZon => { + try reportParseFailure(diags, &zon_diag); + return error.ParseZon; + }, + }; + + try validate.validate(cfg, diags); + + const now = std.Io.Clock.real.now(io).toSeconds(); + return applyToDb(io, gpa, database, cfg, now, options); +} + +/// The line and column of a ZON syntax error are the only thing the operator can +/// act on, so they travel the same channel as every other config problem: the +/// caller's `Diagnostics`, which `nxdns import` already renders to stderr. The +/// global log is not that channel — an operator reading command output would see +/// a bare `ParseZon` and nothing else. +/// +/// `std.zon.parse.Diagnostics` renders one "line:column: error: text" line per +/// problem, plus a "note:" line each, so each rendered line becomes one +/// `Problem` and the list keeps the parser's order. +fn reportParseFailure( + diags: *validate.Diagnostics, + zon_diag: *const std.zon.parse.Diagnostics, +) error{OutOfMemory}!void { + const rendered = try std.fmt.allocPrint(diags.gpa, "{f}", .{zon_diag}); + defer diags.gpa.free(rendered); + + var lines = std.mem.splitScalar(u8, rendered, '\n'); + while (lines.next()) |line| { + if (line.len == 0) continue; + try diags.add(error.ParseZon, "config", .{}, "{s}", .{line}); + } +} + +/// The half `bootstrap` reuses: an already-parsed, already-validated config into +/// the database, all or nothing. `now` is the caller's timestamp for the runtime +/// columns the model omits. +pub fn applyToDb( + io: std.Io, + gpa: Allocator, + database: *db.Db, + cfg: model.Config, + now: i64, + options: Options, +) Error!void { + var tx = try db.Tx.begin(database); + errdefer tx.rollback(); + + // Inside the transaction on purpose. Checking before `BEGIN IMMEDIATE` + // would leave 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. + if (!options.force and !try isEmpty(database)) return error.DatabaseNotEmpty; + + inline for (config_schema.delete_order) |table| { + try database.exec("DELETE FROM " ++ table ++ ";"); + } + + var group_ids: context.IdMap = .empty; + defer group_ids.deinit(gpa); + var source_ids: context.IdMap = .empty; + defer source_ids.deinit(gpa); + + try insertGroups(database, gpa, cfg, &group_ids); + try insertSources(database, gpa, cfg, &source_ids); + + const ctx: context.InsertContext = .{ + .now = now, + .group_ids = &group_ids, + .source_ids = &source_ids, + }; + + for (cfg.clients) |client| { + var buf: [canonical_buf_len]u8 = undefined; + var canonical = client; + canonical.ip = try canonicalIp(client.ip, &buf); + try clients_repo.insertClient(database, canonical, ctx); + } + for (cfg.client_prefixes) |entry| { + var buf: [canonical_buf_len]u8 = undefined; + var canonical = entry; + canonical.prefix = try canonicalPrefix(entry.prefix, &buf); + try clients_repo.insertClientPrefix(database, canonical, ctx); + } + for (cfg.upstreams) |item| try upstreams_repo.insertUpstream(database, item, ctx); + for (cfg.group_sources) |item| try groups_repo.insertGroupSource(database, item, ctx); + for (cfg.rules) |item| try rules_repo.insertRule(database, item, ctx); + for (cfg.local_records) |item| try local_repo.insertLocalRecord(database, item, ctx); + for (cfg.forward_zones) |item| try local_repo.insertForwardZone(database, item, ctx); + + // The buffer must outlive `toSettings`: `effective.web.password_hash` points + // into it. + var hash_buf: [hash_buf_len]u8 = undefined; + var effective = cfg; + if (cfg.web.password.len != 0) { + if (cfg.web.password_hash.len != 0) return error.PasswordAndHashBothSet; + effective.web.password_hash = try hashPassword(io, gpa, cfg.web.password, &hash_buf); + } + // Operator input, never stored. `toSettings` skips the field in both + // directions; clearing it here keeps the in-memory value honest too. + effective.web.password = ""; + + var pairs: std.ArrayList(model.SettingPair) = .empty; + defer { + model.freeSettings(gpa, pairs.items); + pairs.deinit(gpa); + } + try model.toSettings(effective, gpa, &pairs); + for (pairs.items) |pair| try settings_repo.insertSetting(database, pair, ctx); + + try tx.commit(); +} + +/// `default` goes in first and takes rowid 1. §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. +/// +/// The repositories expose no insert-with-id, so the id is taken rather than +/// given: SQLite assigns rowid 1 to the first row of an empty table, and the +/// table was emptied a few statements ago. The result is checked, not assumed. +fn insertGroups(database: *db.Db, gpa: Allocator, cfg: model.Config, ids: *context.IdMap) Error!void { + const default_index = indexOfGroup(cfg.groups, "default") orelse { + log.warn("the config declares no group named 'default'", .{}); + return error.MissingDefaultGroup; + }; + + try insertGroup(database, gpa, cfg.groups[default_index], ids); + const default_id = ids.get("default").?; + if (default_id != 1) { + log.warn("group 'default' took id {d}, not 1", .{default_id}); + return error.Unexpected; + } + + for (cfg.groups, 0..) |group, i| { + if (i == default_index) continue; + try insertGroup(database, gpa, group, ids); + } +} + +fn insertGroup(database: *db.Db, gpa: Allocator, group: model.Group, ids: *context.IdMap) Error!void { + try groups_repo.insertGroup(database, group, .{}); + // The key borrows from `cfg`, which outlives the transaction. + try ids.put(gpa, group.name, database.lastInsertRowid()); +} + +fn insertSources(database: *db.Db, gpa: Allocator, cfg: model.Config, ids: *context.IdMap) Error!void { + for (cfg.blocklist_sources) |item| { + try sources_repo.insertBlocklistSource(database, item, .{}); + try ids.put(gpa, item.url, database.lastInsertRowid()); + } +} + +fn indexOfGroup(groups: []const model.Group, name: []const u8) ?usize { + for (groups, 0..) |group, i| { + if (std.mem.eql(u8, group.name, name)) return i; + } + return null; +} + +/// The validator compares client addresses after canonicalisation, so the row +/// this writes must be canonical too — otherwise `fd00::1` and +/// `FD00:0:0:0:0:0:0:1` pass validation as a duplicate pair and then collide on +/// the column's `UNIQUE`. +fn canonicalIp(text: []const u8, buf: []u8) error{BadClientIp}![]const u8 { + const addr = address.NetAddress.parse(text) catch return error.BadClientIp; + var w: std.Io.Writer = .fixed(buf); + addr.format(&w) catch return error.BadClientIp; + return w.buffered(); +} + +fn canonicalPrefix(text: []const u8, buf: []u8) error{BadClientPrefix}![]const u8 { + const prefix = address.Prefix.parse(text) catch return error.BadClientPrefix; + var w: std.Io.Writer = .fixed(buf); + prefix.format(&w) catch return error.BadClientPrefix; + return w.buffered(); +} + +/// argon2id with the OWASP parameters (t=2, m=19 MiB, p=1) rather than the +/// 64 MiB `interactive_2id`, because PLAN §18 budgets under 100 MB total on a +/// Pi 5. +/// +/// `strHash`'s error set reaches beyond this module's (it carries +/// `std.Thread.SpawnError` and the PHC encoding errors), so anything that is +/// neither out of memory nor a cancellation is reported as `error.Unexpected` +/// with the real cause logged. +fn hashPassword(io: std.Io, gpa: Allocator, password: []const u8, buf: []u8) Error![]const u8 { + return std.crypto.pwhash.argon2.strHash(password, .{ + .allocator = gpa, + .params = .owasp_2id, + .mode = .argon2id, + .encoding = .phc, + }, buf, io) catch |e| switch (e) { + error.OutOfMemory => error.OutOfMemory, + error.Canceled => error.Canceled, + else => { + log.warn("hashing web.password failed: {s}", .{@errorName(e)}); + return error.Unexpected; + }, + }; +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; +const migrations = @import("../storage/migrations.zig"); + +fn openMigrated() !db.Db { + var database = try db.Db.open(":memory:", .{ .mode = .memory }); + errdefer database.close(); + try db.applyPragmas(&database, .{}); + _ = try migrations.migrate(&database); + return database; +} + +/// Every row of every config table, rendered in a stable order. Two dumps are +/// equal exactly when the database content is. +fn dump(database: *db.Db, gpa: Allocator) ![]u8 { + var out: std.Io.Writer.Allocating = .init(gpa); + errdefer out.deinit(); + const w = &out.writer; + + try w.writeAll("groups\n"); + var stmt = try database.prepare("SELECT id, name, safe_search FROM groups ORDER BY id"); + defer stmt.deinit(); + while (try stmt.step()) { + try w.print(" {d} {s} {d}\n", .{ stmt.columnInt(0), stmt.columnText(1), stmt.columnInt(2) }); + } + + inline for (config_schema.content_tables) |table| { + try w.print("{s}\n", .{table}); + var rows = try database.prepare("SELECT * FROM " ++ table ++ " ORDER BY 1, 2"); + defer rows.deinit(); + const columns = db.c.sqlite3_column_count(rows.handle); + while (try rows.step()) { + var col: c_int = 0; + while (col < columns) : (col += 1) { + try w.print(" {s}", .{rows.columnText(col)}); + } + try w.writeAll("\n"); + } + } + return out.toOwnedSlice(); +} + +/// The smallest config that validates: one enabled upstream and the `default` +/// group. +const minimal_source: [:0]const u8 = + \\.{ + \\ .groups = .{ .{ .name = "default" } }, + \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, + \\} +; + +/// Exercises every collection and a few non-default scalars. +const full_source: [:0]const u8 = + \\.{ + \\ .dns = .{ .port = 5353 }, + \\ .logging = .{ .level = .err, .retention_days = 7 }, + \\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } }, + \\ .upstreams = .{ + \\ .{ .url = "https://dns.example/dns-query", .priority = 10 }, + \\ .{ .url = "tls://dot.example:853", .priority = 20, .enabled = false }, + \\ }, + \\ .clients = .{ .{ .ip = "FD00:0:0:0:0:0:0:1", .name = "tablet", .group = "kids" } }, + \\ .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } }, + \\ .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "ads" } }, + \\ .group_sources = .{ .{ .group = "kids", .source_url = "https://lists.example/ads.txt" } }, + \\ .rules = .{ .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block } }, + \\ .local_records = .{ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 600 } }, + \\ .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.1:53" } }, + \\} +; + +fn importText(io: std.Io, database: *db.Db, source: [:0]const u8, options: Options) !void { + var diags: validate.Diagnostics = .init(testing.allocator); + defer diags.deinit(); + return importSource(io, testing.allocator, database, source, options, &diags); +} + +test "isEmpty is true on a freshly migrated database" { + var database = try openMigrated(); + defer database.close(); + try testing.expect(try isEmpty(&database)); +} + +test "isEmpty is false once a settings row exists" { + var database = try openMigrated(); + defer database.close(); + try database.exec("INSERT INTO settings (key, value) VALUES ('dns.port', '53');"); + try testing.expect(!try isEmpty(&database)); +} + +test "isEmpty is false once an auto-materialized client exists" { + var database = try openMigrated(); + defer database.close(); + try database.exec( + \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen) + \\VALUES ('192.168.1.5', NULL, 1, 0, 1, 1); + ); + try testing.expect(!try isEmpty(&database)); +} + +test "isEmpty is false once the seeded group is changed" { + var database = try openMigrated(); + defer database.close(); + try database.exec("UPDATE groups SET name = 'renamed' WHERE id = 1;"); + try testing.expect(!try isEmpty(&database)); +} + +test "importSource seeds a migrated database and group 'default' keeps id 1" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var database = try openMigrated(); + defer database.close(); + + try importText(io, &database, full_source, .{}); + try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT id FROM groups WHERE name = 'default'")); + try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT count(*) FROM upstreams")); + // The v6 client address was written in canonical form, not as typed. + try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM clients WHERE ip = 'fd00::1'")); +} + +test "applyToDb without force refuses a configured database and changes nothing" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const gpa = testing.allocator; + + var database = try openMigrated(); + defer database.close(); + try importText(io, &database, full_source, .{}); + + const before = try dump(&database, gpa); + defer gpa.free(before); + + const second: model.Config = .{ + .groups = &.{.{ .name = "default" }}, + .upstreams = &.{.{ .url = "https://other.example/dns-query" }}, + }; + try testing.expectError(error.DatabaseNotEmpty, applyToDb(io, gpa, &database, second, 42, .{})); + + const after = try dump(&database, gpa); + defer gpa.free(after); + try testing.expectEqualStrings(before, after); +} + +test "applyToDb rolls back completely when an insert fails mid-way" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const gpa = testing.allocator; + + var database = try openMigrated(); + defer database.close(); + try importText(io, &database, full_source, .{}); + + const before = try dump(&database, gpa); + defer gpa.free(before); + + // Two identical local records violate `UNIQUE(name, rtype, value)`. The + // validator would catch this, which is exactly why the test calls + // `applyToDb` directly: the all-or-nothing guarantee has to hold on its own. + const broken: model.Config = .{ + .groups = &.{.{ .name = "default" }}, + .upstreams = &.{.{ .url = "https://other.example/dns-query" }}, + .local_records = &.{ + .{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" }, + .{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" }, + }, + }; + try testing.expectError(error.Constraint, applyToDb(io, gpa, &database, broken, 42, .{ .force = true })); + + const after = try dump(&database, gpa); + defer gpa.free(after); + try testing.expectEqualStrings(before, after); +} + +test "importSource writes nothing when validation fails" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var database = try openMigrated(); + defer database.close(); + + // No `default` group and no enabled upstream. + const bad: [:0]const u8 = + \\.{ .groups = .{ .{ .name = "kids" } } } + ; + var diags: validate.Diagnostics = .init(testing.allocator); + defer diags.deinit(); + try testing.expectError( + error.MissingDefaultGroup, + importSource(io, testing.allocator, &database, bad, .{}, &diags), + ); + try testing.expect(diags.problems.items.len >= 2); + try testing.expect(try isEmpty(&database)); +} + +test "importSource reports a ZON syntax error and writes nothing" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var database = try openMigrated(); + defer database.close(); + + var diags: validate.Diagnostics = .init(testing.allocator); + defer diags.deinit(); + try testing.expectError( + error.ParseZon, + importSource(io, testing.allocator, &database, ".{ .groups = ", .{}, &diags), + ); + try testing.expect(try isEmpty(&database)); + + // The point of the diagnostic: what the CLI prints must name the line and the + // column, not just `ParseZon`. + try testing.expect(diags.problems.items.len >= 1); + var rendered: std.Io.Writer.Allocating = .init(testing.allocator); + defer rendered.deinit(); + try diags.writeAll(&rendered.writer); + const text = rendered.written(); + try testing.expect(std.mem.indexOf(u8, text, "1:14: error: ") != null); +} + +test "a config omitting every optional field parses into an arena and leaks nothing" { + // The S5.1 rule as a test: `std.zon.parse.free` is never called, the arena + // is the only release, and `std.testing.allocator` fails the test if a + // defaulted rodata string were ever handed to the allocator. + var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena_state.deinit(); + + const cfg = try std.zon.parse.fromSliceAlloc( + model.Config, + arena_state.allocator(), + minimal_source, + null, + .{}, + ); + try testing.expectEqualStrings("0.0.0.0", cfg.dns.bind_ipv4); + try testing.expectEqual(@as(u16, 53), cfg.dns.port); + try testing.expectEqual(@as(usize, 1), cfg.groups.len); +} + +test "a password is hashed into web.password_hash and never stored verbatim" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const gpa = testing.allocator; + + var database = try openMigrated(); + defer database.close(); + + const cfg: model.Config = .{ + .groups = &.{.{ .name = "default" }}, + .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, + .web = .{ .password = "correct horse battery staple" }, + }; + try applyToDb(io, gpa, &database, cfg, 42, .{}); + + var stmt = try database.prepare("SELECT value FROM settings WHERE key = 'web.password_hash'"); + defer stmt.deinit(); + try testing.expect(try stmt.step()); + try testing.expect(std.mem.startsWith(u8, stmt.columnText(0), "$argon2id$")); + + try testing.expectEqual( + @as(i64, 0), + try database.queryInt("SELECT count(*) FROM settings WHERE key = 'web.password'"), + ); +} + +test "a password and a password_hash together are refused" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var database = try openMigrated(); + defer database.close(); + + const cfg: model.Config = .{ + .groups = &.{.{ .name = "default" }}, + .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, + .web = .{ .password = "plaintext", .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" }, + }; + try testing.expectError( + error.PasswordAndHashBothSet, + applyToDb(io, testing.allocator, &database, cfg, 42, .{}), + ); + try testing.expect(try isEmpty(&database)); +} diff --git a/src/config/model.zig b/src/config/model.zig new file mode 100644 index 0000000..804e119 --- /dev/null +++ b/src/config/model.zig @@ -0,0 +1,745 @@ +//! The one configuration model. Bootstrap, import, export, the repositories and +//! the running server all speak this struct; nothing else describes nxdns +//! configuration. +//! +//! Pure: no `std.Io` value is a parameter anywhere, no SQLite, no clock. The +//! only `std.Io` types that appear are `std.Io.Duration` as a conversion result. +//! +//! Runtime columns are deliberately absent. `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, which would make the byte-stable round trip untestable against a +//! live server. Import sets the timestamps to the import time and leaves the +//! counters at their column defaults. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +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 = &.{}, +}; + +pub const IoBackend = enum { + threaded, + evented, + + pub fn toDb(self: IoBackend) []const u8 { + return switch (self) { + .threaded => "threaded", + .evented => "evented", + }; + } + + pub fn fromDb(text: []const u8) ?IoBackend { + if (std.mem.eql(u8, text, "threaded")) return .threaded; + if (std.mem.eql(u8, text, "evented")) return .evented; + return null; + } +}; + +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, +}; + +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 fn toDb(self: BlockResponse) []const u8 { + return switch (self) { + .zero => "zero", + .nxdomain => "nxdomain", + }; + } + + pub fn fromDb(text: []const u8) ?BlockResponse { + if (std.mem.eql(u8, text, "zero")) return .zero; + if (std.mem.eql(u8, text, "nxdomain")) return .nxdomain; + return null; + } +}; + +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, + /// Operator input only. Never a settings row, always exported as "". + password: []const u8 = "", + /// argon2id PHC string; "" disables authentication. + password_hash: []const u8 = "", + 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 fn toDb(self: EcsMode) []const u8 { + return switch (self) { + .strip => "strip", + .forward => "forward", + }; + } + + pub fn fromDb(text: []const u8) ?EcsMode { + if (std.mem.eql(u8, text, "strip")) return .strip; + if (std.mem.eql(u8, text, "forward")) return .forward; + return null; + } +}; + +pub const Edns = struct { ecs_mode: EcsMode = .strip }; + +pub const LogLevel = enum { + err, + warn, + info, + debug, + + /// `.err` stores as "error": that is the operator-facing word, and the Zig + /// tag cannot be `error` because it is a keyword. + pub fn toDb(self: LogLevel) []const u8 { + return switch (self) { + .err => "error", + .warn => "warn", + .info => "info", + .debug => "debug", + }; + } + + pub fn fromDb(text: []const u8) ?LogLevel { + if (std.mem.eql(u8, text, "error")) return .err; + if (std.mem.eql(u8, text, "warn")) return .warn; + if (std.mem.eql(u8, text, "info")) return .info; + if (std.mem.eql(u8, text, "debug")) return .debug; + return null; + } +}; + +pub const LogOutput = enum { + stderr, + syslog, + file, + + pub fn toDb(self: LogOutput) []const u8 { + return switch (self) { + .stderr => "stderr", + .syslog => "syslog", + .file => "file", + }; + } + + pub fn fromDb(text: []const u8) ?LogOutput { + if (std.mem.eql(u8, text, "stderr")) return .stderr; + if (std.mem.eql(u8, text, "syslog")) return .syslog; + if (std.mem.eql(u8, text, "file")) return .file; + return null; + } +}; + +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 }; + +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 fn toDb(self: RuleKind) []const u8 { + return switch (self) { + .exact => "exact", + .wildcard => "wildcard", + }; + } + + pub fn fromDb(text: []const u8) ?RuleKind { + if (std.mem.eql(u8, text, "exact")) return .exact; + if (std.mem.eql(u8, text, "wildcard")) return .wildcard; + return null; + } +}; + +pub const RuleAction = enum { + allow, + block, + + pub fn toDb(self: RuleAction) []const u8 { + return switch (self) { + .allow => "allow", + .block => "block", + }; + } + + pub fn fromDb(text: []const u8) ?RuleAction { + if (std.mem.eql(u8, text, "allow")) return .allow; + if (std.mem.eql(u8, text, "block")) return .block; + return null; + } +}; + +pub const Rule = struct { group: []const u8, pattern: []const u8, kind: RuleKind, action: RuleAction }; + +/// Tag names are lowercase because ZON enum literals are; the DB text is +/// uppercase because `CHECK(rtype IN ('A','AAAA','CNAME'))` says so. +pub const RecordType = enum { + a, + aaaa, + cname, + + pub fn toDb(self: RecordType) []const u8 { + return switch (self) { + .a => "A", + .aaaa => "AAAA", + .cname => "CNAME", + }; + } + + pub fn fromDb(text: []const u8) ?RecordType { + if (std.mem.eql(u8, text, "A")) return .a; + if (std.mem.eql(u8, text, "AAAA")) return .aaaa; + if (std.mem.eql(u8, text, "CNAME")) return .cname; + return null; + } +}; + +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 }; + +// --------------------------------------------------------------------------- +// Unit conversions (S2.4) +// --------------------------------------------------------------------------- + +/// Fails the build unless `FieldType`'s maximum times `factor` fits `Dest`. +/// Overflow is made impossible by the types rather than checked at runtime, +/// which is why none of the conversions below can return an error. +pub fn assertFits(comptime FieldType: type, comptime factor: comptime_int, comptime Dest: type) void { + if (@as(u128, std.math.maxInt(FieldType)) * factor > @as(u128, std.math.maxInt(Dest))) { + @compileError("unit conversion overflows " ++ @typeName(Dest) ++ ": " ++ + @typeName(FieldType) ++ " times the conversion factor does not fit"); + } +} + +comptime { + assertFits(u32, std.time.ns_per_ms, i96); // timeouts + assertFits(u16, 3600, i64); // session ttl, update interval + assertFits(u16, 86400, i64); // retention + assertFits(u32, 1024 * 1024, u64); // MiB conversions +} + +pub fn connectTimeout(u: Upstream) std.Io.Duration { + return .{ .nanoseconds = @as(i96, u.connect_timeout_ms) * std.time.ns_per_ms }; +} + +pub fn readTimeout(u: Upstream) std.Io.Duration { + return .{ .nanoseconds = @as(i96, u.read_timeout_ms) * std.time.ns_per_ms }; +} + +pub fn totalTimeout(u: Upstream) std.Io.Duration { + return .{ .nanoseconds = @as(i96, u.total_timeout_ms) * std.time.ns_per_ms }; +} + +pub fn sessionTtlSeconds(w: Web) i64 { + return @as(i64, w.session_ttl_hours) * 3600; +} + +pub fn retentionSeconds(l: Logging) i64 { + return @as(i64, l.retention_days) * 86400; +} + +pub fn maxLogBytes(l: Logging) u64 { + return @as(u64, l.max_size_mb) * 1024 * 1024; +} + +pub fn minFreeBytes(d: Disk) u64 { + return @as(u64, d.min_free_mb) * 1024 * 1024; +} + +pub fn warnFreeBytes(d: Disk) u64 { + return @as(u64, d.warn_free_mb) * 1024 * 1024; +} + +pub fn updateIntervalSeconds(b: BlocklistUpdate) i64 { + return @as(i64, b.interval_hours) * 3600; +} + +// --------------------------------------------------------------------------- +// settings(key, value) bridge (S2.3) +// --------------------------------------------------------------------------- + +pub const SettingPair = struct { key: []const u8, value: []const u8 }; + +pub const SettingsError = error{ BadSettingValue, OutOfMemory }; + +/// The scalar sections are exactly the `Config` fields whose type is a struct; +/// the collections are slices. Deriving the list this way means a new section +/// joins the settings mapping automatically and cannot drift out of it. +fn isScalarSection(comptime T: type) bool { + return @typeInfo(T) == .@"struct"; +} + +/// `web.password` is operator input, never a settings row: it is hashed into +/// `web.password_hash` at import time and discarded (S2.5). +fn isSkipped(comptime section: []const u8, comptime field: []const u8) bool { + return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password"); +} + +fn encodeValue(comptime T: type, value: T, gpa: Allocator) error{OutOfMemory}![]u8 { + return switch (@typeInfo(T)) { + .bool => try gpa.dupe(u8, if (value) "true" else "false"), + .int => try std.fmt.allocPrint(gpa, "{d}", .{value}), + .@"enum" => try gpa.dupe(u8, value.toDb()), + .pointer => try gpa.dupe(u8, value), + else => @compileError("unsupported setting field type " ++ @typeName(T)), + }; +} + +/// Decoding an integer uses the field's declared type, so a stored value out of +/// that range is `error.BadSettingValue` and never a truncating cast. +fn decodeValue(comptime T: type, text: []const u8) error{BadSettingValue}!T { + return switch (@typeInfo(T)) { + .bool => if (std.mem.eql(u8, text, "true")) + true + else if (std.mem.eql(u8, text, "false")) + false + else + error.BadSettingValue, + .int => std.fmt.parseInt(T, text, 10) catch error.BadSettingValue, + .@"enum" => T.fromDb(text) orelse error.BadSettingValue, + .pointer => text, + else => @compileError("unsupported setting field type " ++ @typeName(T)), + }; +} + +/// Frees the `value` of every pair. Keys are comptime strings and are never +/// freed. +pub fn freeSettings(gpa: Allocator, pairs: []const SettingPair) void { + for (pairs) |pair| gpa.free(pair.value); +} + +/// Writes every scalar field of `cfg` as a key/value pair into `out`. Keys are +/// comptime strings (never freed); values are allocated from `gpa` and belong +/// to the caller, which frees them with `freeSettings`. On failure nothing this +/// call appended survives. +pub fn toSettings(cfg: Config, gpa: Allocator, out: *std.ArrayList(SettingPair)) error{OutOfMemory}!void { + const start = out.items.len; + errdefer { + freeSettings(gpa, out.items[start..]); + out.shrinkRetainingCapacity(start); + } + + inline for (@typeInfo(Config).@"struct".fields) |section_field| { + if (comptime isScalarSection(section_field.type)) { + const section = @field(cfg, section_field.name); + inline for (@typeInfo(section_field.type).@"struct".fields) |field| { + if (comptime !isSkipped(section_field.name, field.name)) { + const value = try encodeValue(field.type, @field(section, field.name), gpa); + errdefer gpa.free(value); + try out.append(gpa, .{ .key = section_field.name ++ "." ++ field.name, .value = value }); + } + } + } + } +} + +/// Applies `pairs` onto `cfg`, which the caller has initialized to `.{}`. +/// 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. +/// +/// String values are borrowed from `pairs`, so `cfg` lives no longer than the +/// storage the pairs point into. +pub fn fromSettings(pairs: []const SettingPair, cfg: *Config, unknown_keys: *usize) SettingsError!void { + for (pairs) |pair| { + var matched = false; + inline for (@typeInfo(Config).@"struct".fields) |section_field| { + if (comptime isScalarSection(section_field.type)) { + inline for (@typeInfo(section_field.type).@"struct".fields) |field| { + if (comptime !isSkipped(section_field.name, field.name)) { + if (std.mem.eql(u8, pair.key, section_field.name ++ "." ++ field.name)) { + @field(@field(cfg, section_field.name), field.name) = + try decodeValue(field.type, pair.value); + matched = true; + } + } + } + } + } + if (!matched) { + unknown_keys.* += 1; + std.log.warn("unknown settings key '{s}' ignored", .{pair.key}); + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +/// Every key `toSettings` produces on a default `Config`, sorted. A field added +/// without updating this list breaks the test below, which is the point. +const expected_keys = [_][]const u8{ + "blocking.response", + "blocking.ttl", + "blocklist_update.enabled", + "blocklist_update.interval_hours", + "cache.negative_ttl_max", + "cache.size", + "disk.min_free_mb", + "disk.warn_free_mb", + "dns.bind_ipv4", + "dns.bind_ipv6", + "dns.port", + "dns.rate_limit", + "dns.rate_window_seconds", + "doh_server.bind", + "doh_server.cert_path", + "doh_server.enabled", + "doh_server.key_path", + "doh_server.port", + "dot_server.bind", + "dot_server.cert_path", + "dot_server.enabled", + "dot_server.key_path", + "dot_server.port", + "edns.ecs_mode", + "logging.file_path", + "logging.hide_client_ips", + "logging.hide_domains", + "logging.level", + "logging.max_files", + "logging.max_size_mb", + "logging.output", + "logging.query_log_buffer_max", + "logging.retention_days", + "runtime.io_backend", + "upstream.connect_timeout_ms", + "upstream.read_timeout_ms", + "upstream.total_timeout_ms", + "web.api_rate_limit_per_min", + "web.bind", + "web.enabled", + "web.password_hash", + "web.port", + "web.session_ttl_hours", + "web.sse_max_connections_per_ip", +}; + +fn lessThanKey(_: void, a: SettingPair, b: SettingPair) bool { + return std.mem.lessThan(u8, a.key, b.key); +} + +test "toSettings on a default config produces exactly the expected key list" { + const gpa = testing.allocator; + var pairs: std.ArrayList(SettingPair) = .empty; + defer { + freeSettings(gpa, pairs.items); + pairs.deinit(gpa); + } + + try toSettings(.{}, gpa, &pairs); + std.mem.sort(SettingPair, pairs.items, {}, lessThanKey); + + try testing.expectEqual(expected_keys.len, pairs.items.len); + for (expected_keys, pairs.items) |expected, pair| { + try testing.expectEqualStrings(expected, pair.key); + } +} + +test "toSettings never emits web.password" { + const gpa = testing.allocator; + var pairs: std.ArrayList(SettingPair) = .empty; + defer { + freeSettings(gpa, pairs.items); + pairs.deinit(gpa); + } + + try toSettings(.{ .web = .{ .password = "hunter2" } }, gpa, &pairs); + for (pairs.items) |pair| { + try testing.expect(!std.mem.eql(u8, pair.key, "web.password")); + } +} + +test "toSettings and fromSettings round-trip a non-default config" { + const gpa = testing.allocator; + const original: Config = .{ + .runtime = .{ .io_backend = .evented }, + .upstream = .{ .connect_timeout_ms = 111, .read_timeout_ms = 222, .total_timeout_ms = 333 }, + .dns = .{ + .bind_ipv4 = "127.0.0.1", + .bind_ipv6 = "::1", + .port = 5353, + .rate_limit = 7, + .rate_window_seconds = 11, + }, + .blocking = .{ .response = .nxdomain, .ttl = 13 }, + .cache = .{ .size = 17, .negative_ttl_max = 19 }, + .web = .{ + .enabled = false, + .bind = "10.0.0.1", + .port = 9090, + .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def", + .session_ttl_hours = 23, + .api_rate_limit_per_min = 29, + .sse_max_connections_per_ip = 31, + }, + .doh_server = .{ + .enabled = true, + .bind = "10.0.0.2", + .port = 4443, + .cert_path = "/a/cert.pem", + .key_path = "/a/key.pem", + }, + .dot_server = .{ + .enabled = true, + .bind = "10.0.0.3", + .port = 8853, + .cert_path = "/b/cert.pem", + .key_path = "/b/key.pem", + }, + .edns = .{ .ecs_mode = .forward }, + .logging = .{ + .level = .err, + .retention_days = 41, + .query_log_buffer_max = 43, + .hide_domains = true, + .hide_client_ips = true, + .output = .file, + .file_path = "/var/log/x.log", + .max_size_mb = 47, + .max_files = 53, + }, + .disk = .{ .min_free_mb = 59, .warn_free_mb = 61 }, + .blocklist_update = .{ .enabled = false, .interval_hours = 67 }, + }; + + var pairs: std.ArrayList(SettingPair) = .empty; + defer { + freeSettings(gpa, pairs.items); + pairs.deinit(gpa); + } + try toSettings(original, gpa, &pairs); + + var restored: Config = .{}; + var unknown: usize = 0; + try fromSettings(pairs.items, &restored, &unknown); + try testing.expectEqual(@as(usize, 0), unknown); + + inline for (@typeInfo(Config).@"struct".fields) |section_field| { + if (comptime isScalarSection(section_field.type)) { + inline for (@typeInfo(section_field.type).@"struct".fields) |field| { + if (comptime !isSkipped(section_field.name, field.name)) { + const a = @field(@field(original, section_field.name), field.name); + const b = @field(@field(restored, section_field.name), field.name); + if (comptime @typeInfo(field.type) == .pointer) { + try testing.expectEqualStrings(a, b); + } else { + try testing.expectEqual(a, b); + } + } + } + } + } +} + +test "an unknown settings key is counted and not an error" { + var cfg: Config = .{}; + var unknown: usize = 0; + const pairs = [_]SettingPair{ + .{ .key = "dns.port", .value = "5300" }, + .{ .key = "future.setting", .value = "whatever" }, + .{ .key = "web.password", .value = "never a row" }, + }; + + try fromSettings(&pairs, &cfg, &unknown); + try testing.expectEqual(@as(u16, 5300), cfg.dns.port); + // `web.password` is skipped in both directions, so it counts as unknown. + try testing.expectEqual(@as(usize, 2), unknown); +} + +test "a malformed settings value is BadSettingValue" { + var cfg: Config = .{}; + var unknown: usize = 0; + + const bad_int = [_]SettingPair{.{ .key = "dns.port", .value = "not a number" }}; + try testing.expectError(error.BadSettingValue, fromSettings(&bad_int, &cfg, &unknown)); + + // 70000 does not fit u16: an out-of-range value is refused, not truncated. + const out_of_range = [_]SettingPair{.{ .key = "dns.port", .value = "70000" }}; + try testing.expectError(error.BadSettingValue, fromSettings(&out_of_range, &cfg, &unknown)); + + const bad_bool = [_]SettingPair{.{ .key = "web.enabled", .value = "yes" }}; + try testing.expectError(error.BadSettingValue, fromSettings(&bad_bool, &cfg, &unknown)); + + const bad_enum = [_]SettingPair{.{ .key = "logging.level", .value = "verbose" }}; + try testing.expectError(error.BadSettingValue, fromSettings(&bad_enum, &cfg, &unknown)); +} + +test "an absent key keeps the default" { + var cfg: Config = .{}; + var unknown: usize = 0; + const pairs = [_]SettingPair{.{ .key = "dns.port", .value = "5300" }}; + + try fromSettings(&pairs, &cfg, &unknown); + try testing.expectEqual(@as(u32, 1000), cfg.dns.rate_limit); + try testing.expectEqual(LogLevel.info, cfg.logging.level); +} + +test "LogLevel.err encodes as error and decodes back" { + try testing.expectEqualStrings("error", LogLevel.err.toDb()); + try testing.expectEqual(LogLevel.err, LogLevel.fromDb("error").?); + try testing.expect(LogLevel.fromDb("err") == null); +} + +fn expectEnumRoundTrip(comptime E: type) !void { + inline for (@typeInfo(E).@"enum".fields) |field| { + const value: E = @enumFromInt(field.value); + try testing.expectEqual(value, E.fromDb(value.toDb()).?); + } + try testing.expect(E.fromDb("nonsense") == null); + try testing.expect(E.fromDb("") == null); +} + +test "every toDb and fromDb enum pair round-trips over all tags" { + try expectEnumRoundTrip(IoBackend); + try expectEnumRoundTrip(BlockResponse); + try expectEnumRoundTrip(EcsMode); + try expectEnumRoundTrip(LogLevel); + try expectEnumRoundTrip(LogOutput); + try expectEnumRoundTrip(RuleKind); + try expectEnumRoundTrip(RuleAction); + try expectEnumRoundTrip(RecordType); +} + +test "RecordType stores the uppercase DDL spelling" { + try testing.expectEqualStrings("A", RecordType.a.toDb()); + try testing.expectEqualStrings("AAAA", RecordType.aaaa.toDb()); + try testing.expectEqualStrings("CNAME", RecordType.cname.toDb()); + try testing.expect(RecordType.fromDb("a") == null); +} + +test "unit conversions" { + try testing.expectEqual( + @as(i96, 2000) * std.time.ns_per_ms, + connectTimeout(.{}).nanoseconds, + ); + try testing.expectEqual( + @as(i96, 3000) * std.time.ns_per_ms, + readTimeout(.{}).nanoseconds, + ); + try testing.expectEqual( + @as(i96, 5000) * std.time.ns_per_ms, + totalTimeout(.{}).nanoseconds, + ); + try testing.expectEqual(@as(i64, 24 * 3600), sessionTtlSeconds(.{})); + try testing.expectEqual(@as(i64, 30 * 86400), retentionSeconds(.{})); + try testing.expectEqual(@as(u64, 50 * 1024 * 1024), maxLogBytes(.{})); + try testing.expectEqual(@as(u64, 200 * 1024 * 1024), minFreeBytes(.{})); + try testing.expectEqual(@as(u64, 500 * 1024 * 1024), warnFreeBytes(.{})); + try testing.expectEqual(@as(i64, 24 * 3600), updateIntervalSeconds(.{})); +} + +test "unit conversions at the field maximum do not overflow" { + const max_upstream: Upstream = .{ + .connect_timeout_ms = std.math.maxInt(u32), + .read_timeout_ms = std.math.maxInt(u32), + .total_timeout_ms = std.math.maxInt(u32), + }; + try testing.expectEqual( + @as(i96, std.math.maxInt(u32)) * std.time.ns_per_ms, + connectTimeout(max_upstream).nanoseconds, + ); + try testing.expectEqual( + @as(i64, std.math.maxInt(u16)) * 3600, + sessionTtlSeconds(.{ .session_ttl_hours = std.math.maxInt(u16) }), + ); + try testing.expectEqual( + @as(i64, std.math.maxInt(u16)) * 86400, + retentionSeconds(.{ .retention_days = std.math.maxInt(u16) }), + ); + try testing.expectEqual( + @as(u64, std.math.maxInt(u32)) * 1024 * 1024, + maxLogBytes(.{ .max_size_mb = std.math.maxInt(u32) }), + ); +} diff --git a/src/config/validate.zig b/src/config/validate.zig new file mode 100644 index 0000000..c4e4fa9 --- /dev/null +++ b/src/config/validate.zig @@ -0,0 +1,1309 @@ +//! The pure configuration validator. +//! +//! The controlling requirement: this validator must reject everything the +//! `config.db` schema would reject. An import that passes validation and then +//! dies on a `Constraint` mid-transaction hands the operator a SQLite message +//! instead of a line number, so every `UNIQUE` and every foreign key in +//! PLAN §11.2 has a check here. +//! +//! Pure: no `std.Io` value is a parameter anywhere, no SQLite, no clock. The +//! only `std.Io` type used is `std.Io.Writer`, for rendering diagnostics. The +//! allocator exists for diagnostic text and scratch bookkeeping alone. +//! +//! Parsers are reused, never reimplemented: `transport.Endpoint.parse` for +//! upstream URLs, `NetAddress.parse` / `Prefix.parse` for addresses, and +//! `dns.name.fromText` for every domain-shaped string. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Writer = std.Io.Writer; + +const model = @import("model.zig"); +const address = @import("../platform/address.zig"); +const dns_name = @import("../dns/name.zig"); +const transport = @import("../upstream/transport.zig"); + +const Config = model.Config; +const NetAddress = address.NetAddress; +const Prefix = address.Prefix; + +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, +}; + +/// What a `Problem` can carry: every semantic `ValidateError`, plus the one +/// syntactic failure (`ParseZon`) that `config/import.zig` routes through the +/// same channel so a syntax error's line/column reaches the operator's output. +pub const ProblemError = ValidateError || error{ParseZon}; + +pub const Problem = struct { + /// Dotted path into the config, e.g. "upstreams[2].url". Owned by `Diagnostics`. + path: []const u8, + /// Human-readable, e.g. "unknown group 'kids'". Owned by `Diagnostics`. + message: []const u8, + err: ProblemError, +}; + +pub const Diagnostics = struct { + gpa: Allocator, + problems: std.ArrayList(Problem), + + pub fn init(gpa: Allocator) Diagnostics { + return .{ .gpa = gpa, .problems = .empty }; + } + + pub fn deinit(self: *Diagnostics) void { + for (self.problems.items) |problem| { + self.gpa.free(problem.path); + self.gpa.free(problem.message); + } + self.problems.deinit(self.gpa); + } + + /// Both the path and the message are formatted, because a path carries the + /// offending element's index ("clients[1].ip") and a message carries the + /// offending value. `Diagnostics` owns both strings from here on. + pub fn add( + self: *Diagnostics, + err: ProblemError, + comptime path_fmt: []const u8, + path_args: anytype, + comptime message_fmt: []const u8, + message_args: anytype, + ) error{OutOfMemory}!void { + const path = try std.fmt.allocPrint(self.gpa, path_fmt, path_args); + errdefer self.gpa.free(path); + const message = try std.fmt.allocPrint(self.gpa, message_fmt, message_args); + errdefer self.gpa.free(message); + try self.problems.append(self.gpa, .{ .path = path, .message = message, .err = err }); + } + + /// One "path: message" line per problem. + pub fn writeAll(self: *const Diagnostics, w: *Writer) Writer.Error!void { + for (self.problems.items) |problem| { + try w.print("{s}: {s}\n", .{ problem.path, problem.message }); + } + } +}; + +// --------------------------------------------------------------------------- +// Forward-zone resolvers +// --------------------------------------------------------------------------- + +pub const ResolverScheme = enum { udp, tcp }; + +pub const Resolver = struct { + scheme: ResolverScheme, + addr: NetAddress, + port: u16, +}; + +pub const ResolverError = error{ UnsupportedScheme, BadHost, BadPort, BadUrl }; + +/// `udp://192.168.1.1:53`, `tcp://[fd00::1]:53`. PLAN §6.5 permits plain +/// transports for local infrastructure, which is why this exists at all; +/// `transport.Endpoint.parse` rejects both schemes by design. +/// +/// The host must be an IP literal: a forward zone points at a box on the LAN, +/// and resolving the resolver's own name is a bootstrap problem nxdns declines +/// to have. The port is mandatory for the same reason a typo must not silently +/// become 53. Phase 5's `local/forward_zones.zig` imports this rather than +/// writing a second parser. +pub fn parseResolver(text: []const u8) ResolverError!Resolver { + const udp_prefix = "udp://"; + const tcp_prefix = "tcp://"; + + const scheme: ResolverScheme, const rest = if (std.mem.startsWith(u8, text, udp_prefix)) + .{ .udp, text[udp_prefix.len..] } + else if (std.mem.startsWith(u8, text, tcp_prefix)) + .{ .tcp, text[tcp_prefix.len..] } + else + return error.UnsupportedScheme; + + if (std.mem.findScalar(u8, rest, '/') != null) return error.BadUrl; + + const authority = try parseAuthority(rest); + const port = authority.port orelse return error.BadPort; + const addr = NetAddress.parse(authority.host) catch return error.BadHost; + + return .{ .scheme = scheme, .addr = addr, .port = port }; +} + +// --------------------------------------------------------------------------- +// validate +// --------------------------------------------------------------------------- + +/// Collects EVERY problem into `diags`, then returns the first one's `err`. +/// Checks run in a fixed order — the scalar sections in `Config` declaration +/// order, then the collections in `Config` declaration order — so the returned +/// error is deterministic for a given config. +pub fn validate(cfg: Config, diags: *Diagnostics) ValidateError!void { + var arena_state: std.heap.ArenaAllocator = .init(diags.gpa); + defer arena_state.deinit(); + const scratch = arena_state.allocator(); + + // The caller may hand in a `Diagnostics` that already holds problems from + // another layer (import's ParseZon lines), so the returned error is the + // first problem THIS call recorded — always a `ValidateError`, which makes + // the cast checked-safe. + const first = diags.problems.items.len; + try checkScalars(cfg, diags); + try checkCollections(cfg, diags, scratch); + + if (diags.problems.items.len == first) return; + return @errorCast(diags.problems.items[first].err); +} + +const min_timeout_ms = 100; +const max_timeout_ms = 120_000; +const max_ttl_seconds = 86_400; +const max_record_ttl_seconds = 604_800; +const max_rate_window_seconds = 3_600; + +fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void { + const up = cfg.upstream; + try checkTimeout(diags, up.connect_timeout_ms, "upstream.connect_timeout_ms"); + try checkTimeout(diags, up.read_timeout_ms, "upstream.read_timeout_ms"); + try checkTimeout(diags, up.total_timeout_ms, "upstream.total_timeout_ms"); + if (up.total_timeout_ms < up.connect_timeout_ms or up.total_timeout_ms < up.read_timeout_ms) { + try diags.add( + error.BadTimeout, + "upstream.total_timeout_ms", + .{}, + "total budget {d}ms is below connect {d}ms or read {d}ms", + .{ up.total_timeout_ms, up.connect_timeout_ms, up.read_timeout_ms }, + ); + } + + try checkBind(diags, cfg.dns.bind_ipv4, "dns.bind_ipv4", true); + try checkBind(diags, cfg.dns.bind_ipv6, "dns.bind_ipv6", false); + try checkPort(diags, cfg.dns.port, "dns.port"); + if (cfg.dns.rate_limit < 1) { + try diags.add(error.BadRateLimit, "dns.rate_limit", .{}, "must be at least 1", .{}); + } + if (cfg.dns.rate_window_seconds < 1 or cfg.dns.rate_window_seconds > max_rate_window_seconds) { + try diags.add( + error.BadRateLimit, + "dns.rate_window_seconds", + .{}, + "must be 1-{d}, got {d}", + .{ max_rate_window_seconds, cfg.dns.rate_window_seconds }, + ); + } + + if (cfg.blocking.ttl > max_ttl_seconds) { + try diags.add( + error.BadTtl, + "blocking.ttl", + .{}, + "must be at most {d}, got {d}", + .{ max_ttl_seconds, cfg.blocking.ttl }, + ); + } + + if (cfg.cache.negative_ttl_max > max_ttl_seconds) { + try diags.add( + error.BadTtl, + "cache.negative_ttl_max", + .{}, + "must be at most {d}, got {d}", + .{ max_ttl_seconds, cfg.cache.negative_ttl_max }, + ); + } + + try checkBind(diags, cfg.web.bind, "web.bind", false); + try checkPort(diags, cfg.web.port, "web.port"); + if (cfg.web.password.len != 0 and cfg.web.password_hash.len != 0) { + try diags.add( + error.PasswordAndHashBothSet, + "web.password", + .{}, + "password and password_hash are both set; ambiguity in a security setting is refused", + .{}, + ); + } + // A session TTL is a TTL; `BadTtl` is its bucket. + if (cfg.web.session_ttl_hours < 1) { + try diags.add(error.BadTtl, "web.session_ttl_hours", .{}, "must be at least 1", .{}); + } + if (cfg.web.api_rate_limit_per_min < 1) { + try diags.add(error.BadRateLimit, "web.api_rate_limit_per_min", .{}, "must be at least 1", .{}); + } + if (cfg.web.sse_max_connections_per_ip < 1) { + try diags.add(error.BadRateLimit, "web.sse_max_connections_per_ip", .{}, "must be at least 1", .{}); + } + + try checkTlsEndpoint(diags, cfg.doh_server, "doh_server"); + try checkTlsEndpoint(diags, cfg.dot_server, "dot_server"); + + if (cfg.logging.retention_days < 1) { + try diags.add(error.BadRetention, "logging.retention_days", .{}, "must be at least 1", .{}); + } + if (cfg.logging.query_log_buffer_max < 1) { + try diags.add(error.BadRetention, "logging.query_log_buffer_max", .{}, "must be at least 1", .{}); + } + if (cfg.logging.max_size_mb < 1) { + try diags.add(error.BadLogRotation, "logging.max_size_mb", .{}, "must be at least 1", .{}); + } + if (cfg.logging.max_files < 1) { + try diags.add(error.BadLogRotation, "logging.max_files", .{}, "must be at least 1", .{}); + } + if (cfg.logging.output == .file and + (cfg.logging.file_path.len == 0 or cfg.logging.file_path[0] != '/')) + { + try diags.add( + error.MissingLogPath, + "logging.file_path", + .{}, + "output is 'file' so file_path must be a non-empty absolute path, got '{s}'", + .{cfg.logging.file_path}, + ); + } + + if (cfg.disk.min_free_mb < 1 or cfg.disk.warn_free_mb < 1) { + try diags.add( + error.BadDiskThresholds, + "disk.min_free_mb", + .{}, + "both thresholds must be at least 1, got min {d} and warn {d}", + .{ cfg.disk.min_free_mb, cfg.disk.warn_free_mb }, + ); + } else if (cfg.disk.min_free_mb > cfg.disk.warn_free_mb) { + try diags.add( + error.BadDiskThresholds, + "disk.min_free_mb", + .{}, + "min_free_mb {d} is above warn_free_mb {d}", + .{ cfg.disk.min_free_mb, cfg.disk.warn_free_mb }, + ); + } + + // An update interval is a duration in hours, like the session TTL above. + if (cfg.blocklist_update.interval_hours < 1) { + try diags.add(error.BadTtl, "blocklist_update.interval_hours", .{}, "must be at least 1", .{}); + } +} + +fn checkPort(diags: *Diagnostics, port: u16, comptime path: []const u8) error{OutOfMemory}!void { + if (port == 0) { + try diags.add(error.BadPort, path, .{}, "must be 1-65535, got 0", .{}); + } +} + +fn checkTimeout(diags: *Diagnostics, value: u32, comptime path: []const u8) error{OutOfMemory}!void { + if (value < min_timeout_ms or value > max_timeout_ms) { + try diags.add( + error.BadTimeout, + path, + .{}, + "must be {d}-{d} ms, got {d}", + .{ min_timeout_ms, max_timeout_ms, value }, + ); + } +} + +fn checkBind( + diags: *Diagnostics, + text: []const u8, + comptime path: []const u8, + comptime require_ip4: bool, +) error{OutOfMemory}!void { + const addr = NetAddress.parse(text) catch { + try diags.add(error.BadBindAddress, path, .{}, "'{s}' is not an IP address", .{text}); + return; + }; + if (require_ip4 and std.meta.activeTag(addr) != NetAddress.ip4) { + try diags.add(error.BadBindAddress, path, .{}, "'{s}' is not an IPv4 address", .{text}); + } +} + +fn checkTlsEndpoint( + diags: *Diagnostics, + endpoint: model.TlsEndpoint, + comptime section: []const u8, +) error{OutOfMemory}!void { + try checkBind(diags, endpoint.bind, section ++ ".bind", false); + try checkPort(diags, endpoint.port, section ++ ".port"); + if (!endpoint.enabled) return; + // Readability of the files is `nxdns check`'s job, not the pure validator's. + if (endpoint.cert_path.len == 0) { + try diags.add( + error.MissingCertPath, + section ++ ".cert_path", + .{}, + section ++ " is enabled but cert_path is empty", + .{}, + ); + } + if (endpoint.key_path.len == 0) { + try diags.add( + error.MissingKeyPath, + section ++ ".key_path", + .{}, + section ++ " is enabled but key_path is empty", + .{}, + ); + } +} + +const StringSet = std.StringHashMapUnmanaged(void); + +/// True when `key` was already present. `key` must outlive `set`. +fn markSeen(set: *StringSet, scratch: Allocator, key: []const u8) error{OutOfMemory}!bool { + const gop = try set.getOrPut(scratch, key); + return gop.found_existing; +} + +/// Canonical text for an address, allocated from `scratch`. RFC 5952 form for +/// IPv6, dotted decimal for IPv4 — the same text import writes, so the +/// validator sees the collision `UNIQUE` would see. +fn canonical(scratch: Allocator, value: anytype) error{OutOfMemory}![]u8 { + // The longest form this writes is an IPv6 prefix, 45 + 4 bytes. + var buf: [64]u8 = undefined; + var w: Writer = .fixed(&buf); + value.format(&w) catch unreachable; + return scratch.dupe(u8, w.buffered()); +} + +fn checkCollections(cfg: Config, diags: *Diagnostics, scratch: Allocator) error{OutOfMemory}!void { + var group_names: StringSet = .empty; + var has_default = false; + for (cfg.groups, 0..) |group, i| { + if (group.name.len == 0) { + try diags.add(error.EmptyGroupName, "groups[{d}].name", .{i}, "group name is empty", .{}); + } else if (try markSeen(&group_names, scratch, group.name)) { + try diags.add( + error.DuplicateGroupName, + "groups[{d}].name", + .{i}, + "duplicate group name '{s}'", + .{group.name}, + ); + } + if (std.mem.eql(u8, group.name, "default")) has_default = true; + } + if (!has_default) { + try diags.add( + error.MissingDefaultGroup, + "groups", + .{}, + "no group named 'default'; every unknown client is assigned to it", + .{}, + ); + } + + var upstream_urls: StringSet = .empty; + var enabled_upstreams: usize = 0; + for (cfg.upstreams, 0..) |server, i| { + _ = transport.Endpoint.parse(server.url) catch { + try diags.add( + error.BadUpstreamUrl, + "upstreams[{d}].url", + .{i}, + "'{s}' is not an https:// or tls:// endpoint", + .{server.url}, + ); + }; + if (try markSeen(&upstream_urls, scratch, server.url)) { + try diags.add( + error.DuplicateUpstreamUrl, + "upstreams[{d}].url", + .{i}, + "duplicate upstream url '{s}'", + .{server.url}, + ); + } + if (server.enabled) enabled_upstreams += 1; + } + if (enabled_upstreams == 0) { + try diags.add( + error.NoUpstreams, + "upstreams", + .{}, + "at least one upstream must be enabled", + .{}, + ); + } + + var client_ips: StringSet = .empty; + for (cfg.clients, 0..) |client, i| { + if (NetAddress.parse(client.ip)) |addr| { + const text = try canonical(scratch, addr); + if (try markSeen(&client_ips, scratch, text)) { + try diags.add( + error.DuplicateClientIp, + "clients[{d}].ip", + .{i}, + "duplicate client ip '{s}' (canonical form '{s}')", + .{ client.ip, text }, + ); + } + } else |_| { + try diags.add( + error.BadClientIp, + "clients[{d}].ip", + .{i}, + "'{s}' is not an IP address", + .{client.ip}, + ); + } + try checkGroupRef(diags, &group_names, client.group, "clients[{d}].group", .{i}); + } + + var client_prefixes: StringSet = .empty; + for (cfg.client_prefixes, 0..) |entry, i| { + if (Prefix.parse(entry.prefix)) |prefix| { + const text = try canonical(scratch, prefix); + if (try markSeen(&client_prefixes, scratch, text)) { + try diags.add( + error.DuplicateClientPrefix, + "client_prefixes[{d}].prefix", + .{i}, + "duplicate client prefix '{s}' (canonical form '{s}')", + .{ entry.prefix, text }, + ); + } + } else |_| { + try diags.add( + error.BadClientPrefix, + "client_prefixes[{d}].prefix", + .{i}, + "'{s}' is not a CIDR prefix", + .{entry.prefix}, + ); + } + try checkGroupRef(diags, &group_names, entry.group, "client_prefixes[{d}].group", .{i}); + } + + var source_urls: StringSet = .empty; + for (cfg.blocklist_sources, 0..) |source, i| { + if (!sourceUrlIsValid(source.url)) { + try diags.add( + error.BadSourceUrl, + "blocklist_sources[{d}].url", + .{i}, + "'{s}' is not an http:// or https:// url with a host", + .{source.url}, + ); + } + if (try markSeen(&source_urls, scratch, source.url)) { + try diags.add( + error.DuplicateSourceUrl, + "blocklist_sources[{d}].url", + .{i}, + "duplicate source url '{s}'", + .{source.url}, + ); + } + if (source.name.len == 0) { + try diags.add( + error.EmptySourceName, + "blocklist_sources[{d}].name", + .{i}, + "source name is empty", + .{}, + ); + } + } + + var group_source_pairs: StringSet = .empty; + for (cfg.group_sources, 0..) |link, i| { + try checkGroupRef(diags, &group_names, link.group, "group_sources[{d}].group", .{i}); + if (!source_urls.contains(link.source_url)) { + try diags.add( + error.UnknownSource, + "group_sources[{d}].source_url", + .{i}, + "unknown blocklist source '{s}'", + .{link.source_url}, + ); + } + const key = try std.fmt.allocPrint(scratch, "{s}\x00{s}", .{ link.group, link.source_url }); + if (try markSeen(&group_source_pairs, scratch, key)) { + try diags.add( + error.DuplicateGroupSource, + "group_sources[{d}]", + .{i}, + "duplicate link from group '{s}' to source '{s}'", + .{ link.group, link.source_url }, + ); + } + } + + for (cfg.rules, 0..) |rule, i| { + try checkGroupRef(diags, &group_names, rule.group, "rules[{d}].group", .{i}); + if (!try patternIsValid(scratch, rule.pattern, rule.kind)) { + try diags.add( + error.BadRulePattern, + "rules[{d}].pattern", + .{i}, + "'{s}' is not a valid {s} pattern", + .{ rule.pattern, rule.kind.toDb() }, + ); + } + } + + var local_records: StringSet = .empty; + for (cfg.local_records, 0..) |record, i| { + _ = dns_name.fromText(record.name) catch { + try diags.add( + error.BadLocalRecordName, + "local_records[{d}].name", + .{i}, + "'{s}' is not a valid domain name", + .{record.name}, + ); + }; + if (!recordValueIsValid(record.rtype, record.value)) { + try diags.add( + error.BadLocalRecordValue, + "local_records[{d}].value", + .{i}, + "'{s}' is not a valid {s} value", + .{ record.value, record.rtype.toDb() }, + ); + } + if (record.ttl < 1 or record.ttl > max_record_ttl_seconds) { + try diags.add( + error.BadTtl, + "local_records[{d}].ttl", + .{i}, + "must be 1-{d}, got {d}", + .{ max_record_ttl_seconds, record.ttl }, + ); + } + const key = try std.fmt.allocPrint( + scratch, + "{s}\x00{s}\x00{s}", + .{ record.name, record.rtype.toDb(), record.value }, + ); + if (try markSeen(&local_records, scratch, key)) { + try diags.add( + error.DuplicateLocalRecord, + "local_records[{d}]", + .{i}, + "duplicate local record '{s}' {s} '{s}'", + .{ record.name, record.rtype.toDb(), record.value }, + ); + } + } + + var zones: StringSet = .empty; + for (cfg.forward_zones, 0..) |zone, i| { + _ = dns_name.fromText(zone.zone) catch { + try diags.add( + error.BadForwardZone, + "forward_zones[{d}].zone", + .{i}, + "'{s}' is not a valid domain name", + .{zone.zone}, + ); + }; + if (try markSeen(&zones, scratch, zone.zone)) { + try diags.add( + error.DuplicateForwardZone, + "forward_zones[{d}].zone", + .{i}, + "duplicate forward zone '{s}'", + .{zone.zone}, + ); + } + _ = parseResolver(zone.resolver) catch { + try diags.add( + error.BadResolverUrl, + "forward_zones[{d}].resolver", + .{i}, + "'{s}' is not a udp:// or tcp:// resolver with an IP literal and a port", + .{zone.resolver}, + ); + }; + } +} + +fn checkGroupRef( + diags: *Diagnostics, + group_names: *const StringSet, + name: []const u8, + comptime path_fmt: []const u8, + path_args: anytype, +) error{OutOfMemory}!void { + if (group_names.contains(name)) return; + try diags.add(error.UnknownGroup, path_fmt, path_args, "unknown group '{s}'", .{name}); +} + +pub const Authority = struct { + /// The host with the IPv6 brackets removed, never empty. + host: []const u8, + /// The port when the authority carried one, always 1-65535. + port: ?u16, + /// True when the host arrived in `[…]` form, so it is an IPv6 literal. + bracketed: bool, +}; + +const AuthorityError = error{ BadHost, BadPort, BadUrl }; + +/// The one set of rules this file applies to the authority of a url. The same +/// rules live in `transport.Endpoint.parse` for the two schemes that parser +/// accepts; this parser exists because the blocklist and resolver schemes +/// (`http://`, `udp://`, `tcp://`) are outside its scheme set, not because the +/// rules differ. Do not add a third set. +/// +/// The grammar, over the text before any `/`: +/// +/// authority = ( "[" ipv6 "]" / host ) [ ":" port ] +/// host = 1*( any byte except ":" "@" "?" "#" "[" "]", +/// above 0x20 and below 0x7F ) +/// port = 1*DIGIT, value 1-65535 +/// +/// An empty authority, and an empty host before a `:`, name no host to dial. +/// `@`, `?` and `#` open the userinfo, query and fragment components, and +/// nothing here implements them, so keeping one as literal host text would let +/// this host disagree with the authority an RFC 3986 parser reads out of the +/// same url. A space or a control byte is never legal in a url. A `[` opens an +/// IP literal, so it must be the first byte, it must have a `]`, the text +/// between them must be an IPv6 address, and only a port may follow the `]`. +fn parseAuthority(authority: []const u8) AuthorityError!Authority { + for (authority) |byte| switch (byte) { + '@', '?', '#' => return error.BadUrl, + // Everything outside printable ASCII: C0 controls and space, DEL, and + // the 0x80+ range (C1 controls and raw non-ASCII — a host is IDNA + // punycode by the time it is configuration text). + 0...' ', 0x7F...0xFF => return error.BadUrl, + else => {}, + }; + if (authority.len == 0) return error.BadHost; + + const host, const port_text, const bracketed = split: { + if (authority[0] == '[') { + const close = std.mem.findScalar(u8, authority, ']') orelse return error.BadUrl; + const tail = authority[close + 1 ..]; + if (tail.len == 0) break :split .{ authority[1..close], null, true }; + if (tail[0] != ':') return error.BadUrl; + break :split .{ authority[1..close], tail[1..], true }; + } + const colon = std.mem.findScalar(u8, authority, ':') orelse + break :split .{ authority, null, false }; + break :split .{ authority[0..colon], authority[colon + 1 ..], false }; + }; + + if (host.len == 0) return error.BadHost; + if (bracketed) { + const addr = NetAddress.parse(host) catch return error.BadHost; + if (std.meta.activeTag(addr) != NetAddress.ip6) return error.BadHost; + } else if (std.mem.findAny(u8, host, "[]") != null) { + return error.BadUrl; + } + + const port: ?u16 = if (port_text) |text| blk: { + if (text.len == 0) return error.BadPort; + for (text) |byte| { + if (byte < '0' or byte > '9') return error.BadPort; + } + const value = std.fmt.parseInt(u16, text, 10) catch return error.BadPort; + if (value == 0) return error.BadPort; + break :blk value; + } else null; + + return .{ .host = host, .port = port, .bracketed = bracketed }; +} + +/// True for an `http://` or `https://` url whose authority passes +/// `parseAuthority` and whose remainder carries no space or control byte. +/// The plain scheme is allowed because a list can live on the LAN. +/// +/// The path is not interpreted: a blocklist url is fetched, never dialed by +/// host and path separately, so a query string in it is the source's business. +fn sourceUrlIsValid(url: []const u8) bool { + const rest = if (std.mem.startsWith(u8, url, "http://")) + url["http://".len..] + else if (std.mem.startsWith(u8, url, "https://")) + url["https://".len..] + else + return false; + + const end = std.mem.findScalar(u8, rest, '/') orelse rest.len; + _ = parseAuthority(rest[0..end]) catch return false; + for (rest[end..]) |byte| { + if (byte <= ' ' or byte == 0x7F) return false; + } + return true; +} + +/// Syntax only. Matching semantics are Phase 5's: an exact pattern carries no +/// `*` at all, a wildcard pattern carries at least one label that is exactly +/// `*`, and every remaining label must survive `dns.name.fromText`. +fn patternIsValid( + scratch: Allocator, + pattern: []const u8, + kind: model.RuleKind, +) error{OutOfMemory}!bool { + switch (kind) { + .exact => { + if (std.mem.findScalar(u8, pattern, '*') != null) return false; + _ = dns_name.fromText(pattern) catch return false; + return true; + }, + .wildcard => { + var stars: usize = 0; + var substituted: std.ArrayList(u8) = .empty; + var it = std.mem.splitScalar(u8, pattern, '.'); + var first = true; + while (it.next()) |label| { + if (!first) try substituted.append(scratch, '.'); + first = false; + if (std.mem.eql(u8, label, "*")) { + stars += 1; + try substituted.append(scratch, 'x'); + } else { + try substituted.appendSlice(scratch, label); + } + } + if (stars == 0) return false; + _ = dns_name.fromText(substituted.items) catch return false; + return true; + }, + } +} + +fn recordValueIsValid(rtype: model.RecordType, value: []const u8) bool { + switch (rtype) { + .a => { + const addr = NetAddress.parse(value) catch return false; + return std.meta.activeTag(addr) == NetAddress.ip4; + }, + .aaaa => { + const addr = NetAddress.parse(value) catch return false; + return std.meta.activeTag(addr) == NetAddress.ip6; + }, + .cname => { + _ = dns_name.fromText(value) catch return false; + return true; + }, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +/// A config that validates cleanly. Every test below mutates one thing. +fn baseConfig() Config { + return .{ + .groups = &.{.{ .name = "default" }}, + .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, + }; +} + +fn expectProblem(cfg: Config, expected: ValidateError, expected_path: []const u8) !void { + var diags: Diagnostics = .init(testing.allocator); + defer diags.deinit(); + try testing.expectError(expected, validate(cfg, &diags)); + try testing.expect(diags.problems.items.len >= 1); + try testing.expectEqualStrings(expected_path, diags.problems.items[0].path); + try testing.expectEqual(expected, diags.problems.items[0].err); +} + +fn expectClean(cfg: Config) !void { + var diags: Diagnostics = .init(testing.allocator); + defer diags.deinit(); + validate(cfg, &diags) catch |err| { + var buf: [4096]u8 = undefined; + var w: Writer = .fixed(&buf); + diags.writeAll(&w) catch {}; + std.debug.print("unexpected problems:\n{s}", .{w.buffered()}); + return err; + }; + try testing.expectEqual(@as(usize, 0), diags.problems.items.len); +} + +test "a default config with one enabled upstream and a default group validates cleanly" { + try expectClean(baseConfig()); +} + +test "a fully populated config validates cleanly" { + var cfg = baseConfig(); + cfg.groups = &.{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } }; + cfg.upstreams = &.{ + .{ .url = "https://dns.example/dns-query" }, + .{ .url = "tls://1.1.1.1", .priority = 200, .enabled = false }, + }; + cfg.clients = &.{ + .{ .ip = "192.168.1.10", .name = "laptop" }, + .{ .ip = "fd00::1", .group = "kids" }, + }; + cfg.client_prefixes = &.{.{ .prefix = "192.168.2.0/24", .group = "kids" }}; + cfg.blocklist_sources = &.{.{ .url = "https://lists.example/hosts.txt", .name = "example" }}; + cfg.group_sources = &.{.{ .group = "kids", .source_url = "https://lists.example/hosts.txt" }}; + cfg.rules = &.{ + .{ .group = "kids", .pattern = "ads.example.com", .kind = .exact, .action = .block }, + .{ .group = "default", .pattern = "*.tracker.example", .kind = .wildcard, .action = .allow }, + }; + cfg.local_records = &.{ + .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" }, + .{ .name = "nas.lan", .rtype = .aaaa, .value = "fd00::5" }, + .{ .name = "www.lan", .rtype = .cname, .value = "nas.lan" }, + }; + cfg.forward_zones = &.{.{ .zone = "corp.lan", .resolver = "udp://192.168.1.1:53" }}; + try expectClean(cfg); +} + +test "error.NoUpstreams when nothing is enabled" { + var cfg = baseConfig(); + cfg.upstreams = &.{.{ .url = "https://dns.example/dns-query", .enabled = false }}; + try expectProblem(cfg, error.NoUpstreams, "upstreams"); +} + +test "error.BadUpstreamUrl on an unsupported scheme" { + var cfg = baseConfig(); + cfg.upstreams = &.{.{ .url = "ftp://dns.example/" }}; + try expectProblem(cfg, error.BadUpstreamUrl, "upstreams[0].url"); +} + +test "error.DuplicateUpstreamUrl" { + var cfg = baseConfig(); + cfg.upstreams = &.{ + .{ .url = "https://dns.example/dns-query" }, + .{ .url = "https://dns.example/dns-query" }, + }; + try expectProblem(cfg, error.DuplicateUpstreamUrl, "upstreams[1].url"); +} + +test "error.MissingDefaultGroup" { + var cfg = baseConfig(); + cfg.groups = &.{.{ .name = "kids" }}; + try expectProblem(cfg, error.MissingDefaultGroup, "groups"); +} + +test "error.DuplicateGroupName" { + var cfg = baseConfig(); + cfg.groups = &.{ .{ .name = "default" }, .{ .name = "kids" }, .{ .name = "kids" } }; + try expectProblem(cfg, error.DuplicateGroupName, "groups[2].name"); +} + +test "error.UnknownGroup" { + var cfg = baseConfig(); + cfg.clients = &.{.{ .ip = "192.168.1.10", .group = "kids" }}; + try expectProblem(cfg, error.UnknownGroup, "clients[0].group"); +} + +test "error.EmptyGroupName" { + var cfg = baseConfig(); + cfg.groups = &.{ .{ .name = "default" }, .{ .name = "" } }; + try expectProblem(cfg, error.EmptyGroupName, "groups[1].name"); +} + +test "error.BadClientIp" { + var cfg = baseConfig(); + cfg.clients = &.{.{ .ip = "nonsense" }}; + try expectProblem(cfg, error.BadClientIp, "clients[0].ip"); +} + +test "error.DuplicateClientIp" { + var cfg = baseConfig(); + cfg.clients = &.{ .{ .ip = "192.168.1.10" }, .{ .ip = "192.168.1.10" } }; + try expectProblem(cfg, error.DuplicateClientIp, "clients[1].ip"); +} + +test "error.BadClientPrefix" { + var cfg = baseConfig(); + cfg.client_prefixes = &.{.{ .prefix = "192.168.1.0" }}; + try expectProblem(cfg, error.BadClientPrefix, "client_prefixes[0].prefix"); +} + +test "error.DuplicateClientPrefix" { + var cfg = baseConfig(); + cfg.client_prefixes = &.{ + .{ .prefix = "192.168.1.0/24" }, + .{ .prefix = "192.168.1.55/24" }, + }; + try expectProblem(cfg, error.DuplicateClientPrefix, "client_prefixes[1].prefix"); +} + +test "error.BadSourceUrl" { + var cfg = baseConfig(); + cfg.blocklist_sources = &.{.{ .url = "ftp://lists.example/hosts.txt", .name = "example" }}; + try expectProblem(cfg, error.BadSourceUrl, "blocklist_sources[0].url"); +} + +test "a source url is rejected when its authority breaks an Endpoint.parse rule" { + const malformed = [_][]const u8{ + "https://?x", + "https://user@h/l", + "https:///l", + "https://h h/l", + "https://#f", + "https://", + "http://", + "https://:443/list", + "https://[::1/l", + "https://[]:80/l", + "https://[::1]x:80/l", + "https://h:/l", + "https://h:abc/l", + "https://h:0/l", + "https://h:70000/l", + "https://[nothex]/l", + }; + for (malformed) |url| { + var cfg = baseConfig(); + const sources = [_]model.BlocklistSource{.{ .url = url, .name = "example" }}; + cfg.blocklist_sources = &sources; + try expectProblem(cfg, error.BadSourceUrl, "blocklist_sources[0].url"); + } +} + +test "a source url is accepted over https and over http" { + var cfg = baseConfig(); + cfg.blocklist_sources = &.{ + .{ .url = "https://lists.example/hosts.txt", .name = "secure" }, + .{ .url = "http://nas.lan:8080/lists/hosts.txt?v=2", .name = "lan" }, + .{ .url = "https://[fd00::1]/hosts.txt", .name = "literal" }, + .{ .url = "https://[fd00::2]:8443/hosts.txt", .name = "literal with port" }, + .{ .url = "https://lists.example", .name = "no path" }, + }; + try expectClean(cfg); +} + +test "error.DuplicateSourceUrl" { + var cfg = baseConfig(); + cfg.blocklist_sources = &.{ + .{ .url = "https://lists.example/hosts.txt", .name = "a" }, + .{ .url = "https://lists.example/hosts.txt", .name = "b" }, + }; + try expectProblem(cfg, error.DuplicateSourceUrl, "blocklist_sources[1].url"); +} + +test "error.EmptySourceName" { + var cfg = baseConfig(); + cfg.blocklist_sources = &.{.{ .url = "https://lists.example/hosts.txt", .name = "" }}; + try expectProblem(cfg, error.EmptySourceName, "blocklist_sources[0].name"); +} + +test "error.UnknownSource" { + var cfg = baseConfig(); + cfg.group_sources = &.{.{ .group = "default", .source_url = "https://lists.example/hosts.txt" }}; + try expectProblem(cfg, error.UnknownSource, "group_sources[0].source_url"); +} + +test "error.DuplicateGroupSource" { + var cfg = baseConfig(); + cfg.blocklist_sources = &.{.{ .url = "https://lists.example/hosts.txt", .name = "example" }}; + cfg.group_sources = &.{ + .{ .group = "default", .source_url = "https://lists.example/hosts.txt" }, + .{ .group = "default", .source_url = "https://lists.example/hosts.txt" }, + }; + try expectProblem(cfg, error.DuplicateGroupSource, "group_sources[1]"); +} + +test "error.BadRulePattern" { + var cfg = baseConfig(); + cfg.rules = &.{.{ .group = "default", .pattern = "*.ads.example", .kind = .exact, .action = .block }}; + try expectProblem(cfg, error.BadRulePattern, "rules[0].pattern"); +} + +test "error.BadLocalRecordName" { + const too_long = "a" ** 64; + var cfg = baseConfig(); + cfg.local_records = &.{.{ .name = too_long ++ ".lan", .rtype = .a, .value = "192.168.1.5" }}; + try expectProblem(cfg, error.BadLocalRecordName, "local_records[0].name"); +} + +test "error.BadLocalRecordValue" { + var cfg = baseConfig(); + cfg.local_records = &.{.{ .name = "nas.lan", .rtype = .a, .value = "fd00::1" }}; + try expectProblem(cfg, error.BadLocalRecordValue, "local_records[0].value"); +} + +test "error.DuplicateLocalRecord" { + var cfg = baseConfig(); + cfg.local_records = &.{ + .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" }, + .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" }, + }; + try expectProblem(cfg, error.DuplicateLocalRecord, "local_records[1]"); +} + +test "error.BadForwardZone" { + const too_long = "a" ** 64; + var cfg = baseConfig(); + cfg.forward_zones = &.{.{ .zone = too_long ++ ".lan", .resolver = "udp://192.168.1.1:53" }}; + try expectProblem(cfg, error.BadForwardZone, "forward_zones[0].zone"); +} + +test "error.DuplicateForwardZone" { + var cfg = baseConfig(); + cfg.forward_zones = &.{ + .{ .zone = "corp.lan", .resolver = "udp://192.168.1.1:53" }, + .{ .zone = "corp.lan", .resolver = "tcp://192.168.1.2:53" }, + }; + try expectProblem(cfg, error.DuplicateForwardZone, "forward_zones[1].zone"); +} + +test "error.BadResolverUrl" { + var cfg = baseConfig(); + cfg.forward_zones = &.{.{ .zone = "corp.lan", .resolver = "https://resolver.example/" }}; + try expectProblem(cfg, error.BadResolverUrl, "forward_zones[0].resolver"); +} + +test "error.BadPort" { + var cfg = baseConfig(); + cfg.dns.port = 0; + try expectProblem(cfg, error.BadPort, "dns.port"); +} + +test "error.BadTimeout" { + var cfg = baseConfig(); + cfg.upstream.connect_timeout_ms = 10; + try expectProblem(cfg, error.BadTimeout, "upstream.connect_timeout_ms"); + + var budget = baseConfig(); + budget.upstream = .{ .connect_timeout_ms = 4000, .read_timeout_ms = 4000, .total_timeout_ms = 1000 }; + try expectProblem(budget, error.BadTimeout, "upstream.total_timeout_ms"); +} + +test "error.BadTtl" { + var cfg = baseConfig(); + cfg.blocking.ttl = 90_000; + try expectProblem(cfg, error.BadTtl, "blocking.ttl"); + + var record = baseConfig(); + record.local_records = &.{.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5", .ttl = 0 }}; + try expectProblem(record, error.BadTtl, "local_records[0].ttl"); +} + +test "error.BadRetention" { + var cfg = baseConfig(); + cfg.logging.retention_days = 0; + try expectProblem(cfg, error.BadRetention, "logging.retention_days"); +} + +test "error.BadLogRotation" { + var cfg = baseConfig(); + cfg.logging.max_files = 0; + try expectProblem(cfg, error.BadLogRotation, "logging.max_files"); +} + +test "error.BadDiskThresholds" { + var cfg = baseConfig(); + cfg.disk = .{ .min_free_mb = 600, .warn_free_mb = 500 }; + try expectProblem(cfg, error.BadDiskThresholds, "disk.min_free_mb"); +} + +test "error.BadRateLimit" { + var cfg = baseConfig(); + cfg.dns.rate_limit = 0; + try expectProblem(cfg, error.BadRateLimit, "dns.rate_limit"); +} + +test "error.BadBindAddress" { + var cfg = baseConfig(); + cfg.dns.bind_ipv4 = "::"; + try expectProblem(cfg, error.BadBindAddress, "dns.bind_ipv4"); + + var web = baseConfig(); + web.web.bind = "not an address"; + try expectProblem(web, error.BadBindAddress, "web.bind"); +} + +test "error.MissingCertPath" { + var cfg = baseConfig(); + cfg.doh_server = .{ .enabled = true, .cert_path = "" }; + try expectProblem(cfg, error.MissingCertPath, "doh_server.cert_path"); +} + +test "error.MissingKeyPath" { + var cfg = baseConfig(); + cfg.dot_server = .{ .enabled = true, .port = 853, .key_path = "" }; + try expectProblem(cfg, error.MissingKeyPath, "dot_server.key_path"); +} + +test "error.MissingLogPath" { + var cfg = baseConfig(); + cfg.logging.output = .file; + cfg.logging.file_path = ""; + try expectProblem(cfg, error.MissingLogPath, "logging.file_path"); + + var relative = baseConfig(); + relative.logging.output = .file; + relative.logging.file_path = "nxdns.log"; + try expectProblem(relative, error.MissingLogPath, "logging.file_path"); +} + +test "error.PasswordAndHashBothSet" { + var cfg = baseConfig(); + cfg.web.password = "hunter2"; + cfg.web.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def"; + try expectProblem(cfg, error.PasswordAndHashBothSet, "web.password"); +} + +test "a config with five distinct problems yields five diagnostics and the first error" { + var cfg = baseConfig(); + cfg.dns.port = 0; // BadPort, first in check order + cfg.blocking.ttl = 90_000; // BadTtl + cfg.logging.retention_days = 0; // BadRetention + cfg.disk = .{ .min_free_mb = 600, .warn_free_mb = 500 }; // BadDiskThresholds + cfg.clients = &.{.{ .ip = "nonsense" }}; // BadClientIp + + var diags: Diagnostics = .init(testing.allocator); + defer diags.deinit(); + try testing.expectError(error.BadPort, validate(cfg, &diags)); + try testing.expectEqual(@as(usize, 5), diags.problems.items.len); + try testing.expectEqualStrings("dns.port", diags.problems.items[0].path); + + var buf: [1024]u8 = undefined; + var w: Writer = .fixed(&buf); + try diags.writeAll(&w); + try testing.expect(std.mem.startsWith(u8, w.buffered(), "dns.port: ")); + try testing.expectEqual(@as(usize, 5), std.mem.count(u8, w.buffered(), "\n")); +} + +test "duplicate client ips are detected across canonical forms" { + var cfg = baseConfig(); + cfg.clients = &.{ .{ .ip = "fd00::1" }, .{ .ip = "FD00:0:0:0:0:0:0:1" } }; + try expectProblem(cfg, error.DuplicateClientIp, "clients[1].ip"); +} + +test "duplicate client prefixes are detected across canonical forms" { + var cfg = baseConfig(); + cfg.client_prefixes = &.{ + .{ .prefix = "fd00:abcd::/48" }, + .{ .prefix = "FD00:ABCD:0:1234::5/48" }, + }; + try expectProblem(cfg, error.DuplicateClientPrefix, "client_prefixes[1].prefix"); +} + +test "an unknown group is reported from each of the four referencing collections" { + var clients = baseConfig(); + clients.clients = &.{.{ .ip = "192.168.1.10", .group = "kids" }}; + try expectProblem(clients, error.UnknownGroup, "clients[0].group"); + + var prefixes = baseConfig(); + prefixes.client_prefixes = &.{.{ .prefix = "192.168.2.0/24", .group = "kids" }}; + try expectProblem(prefixes, error.UnknownGroup, "client_prefixes[0].group"); + + var links = baseConfig(); + links.blocklist_sources = &.{.{ .url = "https://lists.example/hosts.txt", .name = "example" }}; + links.group_sources = &.{.{ .group = "kids", .source_url = "https://lists.example/hosts.txt" }}; + try expectProblem(links, error.UnknownGroup, "group_sources[0].group"); + + var rules = baseConfig(); + rules.rules = &.{.{ .group = "kids", .pattern = "ads.example", .kind = .exact, .action = .block }}; + try expectProblem(rules, error.UnknownGroup, "rules[0].group"); +} + +test "an unknown source is reported from group_sources" { + var cfg = baseConfig(); + cfg.blocklist_sources = &.{.{ .url = "https://lists.example/a.txt", .name = "a" }}; + cfg.group_sources = &.{.{ .group = "default", .source_url = "https://lists.example/b.txt" }}; + try expectProblem(cfg, error.UnknownSource, "group_sources[0].source_url"); +} + +test "rule patterns accept wildcards only when the kind says so" { + var wildcard = baseConfig(); + wildcard.rules = &.{ + .{ .group = "default", .pattern = "*.ads.example", .kind = .wildcard, .action = .block }, + .{ .group = "default", .pattern = "*", .kind = .wildcard, .action = .allow }, + }; + try expectClean(wildcard); + + // A wildcard kind needs a label that is exactly "*". + var partial = baseConfig(); + partial.rules = &.{.{ .group = "default", .pattern = "ads*.example", .kind = .wildcard, .action = .block }}; + try expectProblem(partial, error.BadRulePattern, "rules[0].pattern"); + + // Every non-star label still has to be a legal label. + const too_long = "a" ** 64; + var bad_label = baseConfig(); + bad_label.rules = &.{.{ .group = "default", .pattern = "*." ++ too_long, .kind = .wildcard, .action = .block }}; + try expectProblem(bad_label, error.BadRulePattern, "rules[0].pattern"); +} + +test "parseResolver accepts udp and tcp with an IP literal and a port" { + const udp4 = try parseResolver("udp://192.168.1.1:53"); + try testing.expectEqual(ResolverScheme.udp, udp4.scheme); + try testing.expectEqual(@as(u16, 53), udp4.port); + try testing.expect(udp4.addr.eql(try NetAddress.parse("192.168.1.1"))); + + const tcp6 = try parseResolver("tcp://[fd00::1]:53"); + try testing.expectEqual(ResolverScheme.tcp, tcp6.scheme); + try testing.expectEqual(@as(u16, 53), tcp6.port); + try testing.expect(tcp6.addr.eql(try NetAddress.parse("fd00::1"))); +} + +test "parseResolver rejects everything else" { + try testing.expectError(error.UnsupportedScheme, parseResolver("https://x/")); + try testing.expectError(error.UnsupportedScheme, parseResolver("192.168.1.1:53")); + try testing.expectError(error.BadHost, parseResolver("udp://host.name:53")); + try testing.expectError(error.BadPort, parseResolver("udp://1.1.1.1")); + try testing.expectError(error.BadPort, parseResolver("udp://1.1.1.1:0")); + try testing.expectError(error.BadPort, parseResolver("udp://1.1.1.1:70000")); + try testing.expectError(error.BadPort, parseResolver("tcp://[fd00::1]")); + try testing.expectError(error.BadUrl, parseResolver("udp://1.1.1.1:53/path")); + try testing.expectError(error.BadUrl, parseResolver("udp://user@1.1.1.1:53")); + try testing.expectError(error.BadHost, parseResolver("udp://:53")); + try testing.expectError(error.BadHost, parseResolver("udp://")); + try testing.expectError(error.BadUrl, parseResolver("udp://[fd00::1:53")); + try testing.expectError(error.BadHost, parseResolver("udp://[]:53")); + try testing.expectError(error.BadUrl, parseResolver("udp://[fd00::1]x:53")); + try testing.expectError(error.BadHost, parseResolver("udp://[192.168.1.1]:53")); + try testing.expectError(error.BadPort, parseResolver("udp://1.1.1.1:")); + try testing.expectError(error.BadUrl, parseResolver("udp://1.1.1.1:5 3")); + try testing.expectError(error.BadPort, parseResolver("udp://1.1.1.1:+53")); +} + +test "parseAuthority splits the host from the port" { + const plain = try parseAuthority("lists.example"); + try testing.expectEqualStrings("lists.example", plain.host); + try testing.expectEqual(@as(?u16, null), plain.port); + try testing.expect(!plain.bracketed); + + const with_port = try parseAuthority("nas.lan:8080"); + try testing.expectEqualStrings("nas.lan", with_port.host); + try testing.expectEqual(@as(?u16, 8080), with_port.port); + + const v6 = try parseAuthority("[fd00::1]:853"); + try testing.expectEqualStrings("fd00::1", v6.host); + try testing.expectEqual(@as(?u16, 853), v6.port); + try testing.expect(v6.bracketed); + + const v6_no_port = try parseAuthority("[fd00::1]"); + try testing.expectEqualStrings("fd00::1", v6_no_port.host); + try testing.expectEqual(@as(?u16, null), v6_no_port.port); +} diff --git a/src/main.zig b/src/main.zig index ca5169a..8a2ba3b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,57 +1,51 @@ +//! The process shell: build the writers, collect `argv`, dispatch, return an +//! exit code. Every command body lives in `cli.zig`, which takes its writers as +//! parameters and is therefore testable without a process. + const std = @import("std"); -const version = @import("version.zig"); +const cli = @import("cli.zig"); -const usage = - \\usage: nxdns - \\ - \\commands: - \\ run serve DNS - \\ check validate the configuration - \\ export write local records to stdout - \\ import read local records from stdin - \\ version print version information - \\ -; - -const exit_ok = 0; -const exit_not_implemented = 2; -const exit_usage = 64; +// `src/tests.zig` imports this file, and this is how `cli.zig`'s tests reach +// the same runner. `src/tests.zig` is the orchestrator's file, not this +// session's. +comptime { + _ = @import("cli.zig"); +} pub fn main(init: std.process.Init) u8 { + // Both `File.Writer` values are self-referential and must not move, so they + // stay in these `var` slots for the whole of `main`. + var out_buffer: [4096]u8 = undefined; + var err_buffer: [4096]u8 = undefined; + var out = std.Io.File.stdout().writer(init.io, &out_buffer); + var err = std.Io.File.stderr().writer(init.io, &err_buffer); + + const runner: cli.Runner = .{ + .io = init.io, + .gpa = init.gpa, + .out = &out.interface, + .err = &err.interface, + }; + + var argv: std.ArrayList([]const u8) = .empty; + defer argv.deinit(init.gpa); + var args = init.minimal.args.iterate(); _ = args.skip(); - const command = args.next() orelse return fail(init.io, usage); - - if (std.mem.eql(u8, command, "version")) { - return print(init.io, "nxdns {s} ({s})\nzig {s}\n", .{ - version.string, - version.git_commit, - version.zig_version_string, - }); + while (args.next()) |arg| { + argv.append(init.gpa, arg) catch return cli.exit_runtime; } - for ([_][]const u8{ "run", "check", "export", "import" }) |known| { - if (std.mem.eql(u8, command, known)) { - _ = print(init.io, "not implemented\n", .{}); - return exit_not_implemented; - } - } + const command = cli.parseArgs(argv.items) catch |e| return cli.runUsageError(runner, e); - return fail(init.io, usage); -} - -fn print(io: std.Io, comptime format: []const u8, arguments: anytype) u8 { - var buffer: [512]u8 = undefined; - var file_writer = std.Io.File.stdout().writer(io, &buffer); - file_writer.interface.print(format, arguments) catch return 1; - file_writer.interface.flush() catch return 1; - return exit_ok; -} - -fn fail(io: std.Io, message: []const u8) u8 { - var buffer: [512]u8 = undefined; - var file_writer = std.Io.File.stderr().writer(io, &buffer); - file_writer.interface.writeAll(message) catch {}; - file_writer.interface.flush() catch {}; - return exit_usage; + return switch (command) { + .run => |paths| cli.runRun(runner, paths), + // `true`: the probe leaves the machine, which is right for an operator + // running `nxdns check` and wrong for a test. + .check => |args_| cli.runCheck(runner, args_, true), + .export_ => |args_| cli.runExport(runner, args_), + .import_ => |args_| cli.runImport(runner, args_), + .version => cli.runVersion(runner), + .help => cli.runHelp(runner), + }; } diff --git a/src/storage/config_schema.zig b/src/storage/config_schema.zig new file mode 100644 index 0000000..2ce1177 --- /dev/null +++ b/src/storage/config_schema.zig @@ -0,0 +1,147 @@ +//! The `config.db` schema, verbatim from PLAN §11.2, plus the two table orders +//! every other storage session needs. +//! +//! The DDL text is data, not code: `migrations.zig` carries it as step 1 and +//! never edits it in place. A schema change is a *new* step with new DDL, so +//! this string stays byte-identical to PLAN §11.2 forever. + +const std = @import("std"); + +/// Migration step 1. Multi-statement text — it goes through `db.Db.exec`, +/// never through `prepare`. +pub const ddl_v1: [:0]const u8 = + \\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); +; + +/// 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. `schema_version` is deliberately absent — an +/// import must never erase the stamped migration version. +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). `groups` is absent because migration step 1 seeds `(1, 'default')`, +/// so an empty database still holds one group row; `schema_version` is absent +/// for the same reason. +pub const content_tables = [_][]const u8{ + "clients", "client_prefixes", "upstreams", "blocklist_sources", + "group_sources", "rules", "local_records", "forward_zones", + "settings", +}; + +const testing = std.testing; + +test "delete_order lists every referrer before the table it references" { + // The two parents in the schema. Every child that references them must be + // deleted first, or `foreign_keys = ON` turns import's wipe into a + // Constraint error inside the transaction. + const referrers_of_groups = [_][]const u8{ "clients", "client_prefixes", "group_sources", "rules" }; + const referrers_of_sources = [_][]const u8{"group_sources"}; + + try testing.expect(indexOf(&delete_order, "groups") != null); + for (referrers_of_groups) |child| { + try testing.expect(indexOf(&delete_order, child).? < indexOf(&delete_order, "groups").?); + } + for (referrers_of_sources) |child| { + try testing.expect(indexOf(&delete_order, child).? < indexOf(&delete_order, "blocklist_sources").?); + } +} + +test "content_tables is delete_order without groups" { + try testing.expectEqual(delete_order.len - 1, content_tables.len); + for (content_tables) |name| { + try testing.expect(indexOf(&delete_order, name) != null); + } + try testing.expect(indexOf(&content_tables, "groups") == null); + try testing.expect(indexOf(&content_tables, "schema_version") == null); +} + +fn indexOf(haystack: []const []const u8, needle: []const u8) ?usize { + for (haystack, 0..) |item, i| { + if (std.mem.eql(u8, item, needle)) return i; + } + return null; +} diff --git a/src/storage/db.zig b/src/storage/db.zig new file mode 100644 index 0000000..975a4aa --- /dev/null +++ b/src/storage/db.zig @@ -0,0 +1,748 @@ +//! The whole SQLite surface nxdns owns (PLAN Decision G). Nothing above this +//! file calls SQLite directly. +//! +//! **This file takes no `std.Io`.** It is the one deliberate exception to +//! Decision E. 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 storage file that touches the filesystem takes `io: std.Io`. +//! +//! The C API is declared by hand below. No `@cImport` — the handles stay +//! opaque, matching `src/platform/tls_server.zig`'s Mbed TLS approach. + +const std = @import("std"); +const assert = std.debug.assert; + +const log = std.log.scoped(.db); + +pub const c = struct { + pub const Sqlite3 = opaque {}; + pub const Stmt = opaque {}; + /// The C prototype is a function pointer, but the only value nxdns passes + /// is the `SQLITE_TRANSIENT` sentinel (-1), which is not a valid function + /// address — a Zig fn-pointer type would reject it on targets with aligned + /// function pointers (aarch64). `?*anyopaque` is ABI-identical. + pub const Destructor = ?*anyopaque; + + /// `SQLITE_TRANSIENT`: tells SQLite to copy the bound bytes immediately. + 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: *?*c.Stmt, pzTail: ?*?[*]const u8) c_int; + pub extern fn sqlite3_step(stmt: *c.Stmt) c_int; + pub extern fn sqlite3_reset(stmt: *c.Stmt) c_int; + pub extern fn sqlite3_clear_bindings(stmt: *c.Stmt) c_int; + pub extern fn sqlite3_finalize(stmt: ?*c.Stmt) c_int; + pub extern fn sqlite3_bind_int64(stmt: *c.Stmt, idx: c_int, value: i64) c_int; + pub extern fn sqlite3_bind_text(stmt: *c.Stmt, idx: c_int, text: [*]const u8, n: c_int, d: Destructor) c_int; + pub extern fn sqlite3_bind_null(stmt: *c.Stmt, idx: c_int) c_int; + pub extern fn sqlite3_bind_parameter_count(stmt: *c.Stmt) c_int; + pub extern fn sqlite3_column_count(stmt: *c.Stmt) c_int; + pub extern fn sqlite3_column_type(stmt: *c.Stmt, col: c_int) c_int; + pub extern fn sqlite3_column_int64(stmt: *c.Stmt, col: c_int) i64; + pub extern fn sqlite3_column_text(stmt: *c.Stmt, col: c_int) ?[*]const u8; + pub extern fn sqlite3_column_bytes(stmt: *c.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; +}; + +/// Result codes, from the vendored `sqlite3.h` (3.53.4). +pub const result = struct { + pub const ok: c_int = 0; + pub const err: c_int = 1; + pub const internal: c_int = 2; + pub const perm: c_int = 3; + pub const abort: c_int = 4; + pub const busy: c_int = 5; + pub const locked: c_int = 6; + pub const nomem: c_int = 7; + pub const readonly: c_int = 8; + pub const interrupt: c_int = 9; + pub const ioerr: c_int = 10; + pub const corrupt: c_int = 11; + pub const notfound: c_int = 12; + pub const full: c_int = 13; + pub const cantopen: c_int = 14; + pub const protocol: c_int = 15; + pub const empty: c_int = 16; + pub const schema: c_int = 17; + pub const toobig: c_int = 18; + pub const constraint: c_int = 19; + pub const mismatch: c_int = 20; + pub const misuse: c_int = 21; + pub const nolfs: c_int = 22; + pub const auth: c_int = 23; + pub const format: c_int = 24; + pub const range: c_int = 25; + pub const notadb: c_int = 26; + pub const row: c_int = 100; + pub const done: c_int = 101; +}; + +/// Open flags, from the vendored `sqlite3.h` (3.53.4). +pub const open_flag = struct { + pub const readonly: c_int = 0x1; + pub const readwrite: c_int = 0x2; + pub const create: c_int = 0x4; + pub const uri: c_int = 0x40; + pub const nomutex: c_int = 0x8000; + pub const fullmutex: c_int = 0x10000; + pub const exrescode: c_int = 0x2000000; +}; + +/// Column type codes returned by `sqlite3_column_type`. +pub const column_type = struct { + pub const integer: c_int = 1; + pub const float: c_int = 2; + pub const text: c_int = 3; + pub const blob: c_int = 4; + pub const null_value: c_int = 5; +}; + +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. +/// +/// The switch runs on the primary code (`code & 0xff`), so every extended code +/// (`SQLITE_IOERR_*`, `SQLITE_CONSTRAINT_*`, `SQLITE_BUSY_SNAPSHOT`, …) lands on +/// its family. The extended code stays visible to humans through `Db.lastError`. +/// +/// `SQLITE_ERROR` — the generic "SQL error" — maps to `error.Unexpected`, not to +/// `error.SqliteError`. `SqliteError` is reserved for a primary code this +/// function does not know, so an unmapped future code stays distinguishable +/// from an ordinary SQL error. +pub fn mapCode(code: c_int) Error { + const primary = code & 0xff; + assert(primary != result.ok); + assert(primary != result.row); + assert(primary != result.done); + return switch (primary) { + result.err => error.Unexpected, + result.internal => error.Internal, + result.perm => error.Perm, + result.abort => error.Abort, + result.busy => error.Busy, + result.locked => error.Locked, + result.nomem => error.OutOfMemory, + result.readonly => error.ReadOnly, + result.interrupt => error.Interrupt, + result.ioerr => error.IoErr, + result.corrupt => error.Corrupt, + result.notfound => error.NotFound, + result.full => error.Full, + result.cantopen => error.CantOpen, + result.protocol => error.Protocol, + result.empty => error.Empty, + result.schema => error.Schema, + result.toobig => error.TooBig, + result.constraint => error.Constraint, + result.mismatch => error.Mismatch, + result.misuse => error.Misuse, + result.nolfs => error.NoLfs, + result.auth => error.Auth, + result.format => error.Format, + result.range => error.Range, + result.notadb => error.NotADb, + else => error.SqliteError, + }; +} + +fn check(code: c_int) Error!void { + if (code == result.ok) return; + return mapCode(code); +} + +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, +}; + +/// One SQLite connection. +/// +/// A `Db` must not move once a `Stmt` prepared from it is alive: every `Stmt` +/// holds a `*Db`. +pub const Db = struct { + handle: *c.Sqlite3, + + /// Every mode carries `FULLMUTEX` (serialized mode). Phase 6's query logger + /// and Phase 8's API handlers share one handle across `std.Io` tasks, and a + /// per-handle mutex inside SQLite is cheaper to be correct about than a + /// hand-rolled one; `config.db` write volume is negligible. `EXRESCODE` + /// makes `sqlite3_extended_errcode` meaningful from the first call. + /// + /// `open` deliberately applies no pragmas — see `applyPragmas`, which the + /// migration runner must call before it opens a transaction. + /// + /// For `.memory`, `path` is ignored and `":memory:"` is used. + pub fn open(path: [:0]const u8, options: OpenOptions) Error!Db { + const base = open_flag.exrescode | open_flag.fullmutex; + const flags: c_int = switch (options.mode) { + .read_write_create, .memory => base | open_flag.readwrite | open_flag.create, + .read_write_existing => base | open_flag.readwrite, + .read_only => base | open_flag.readonly, + }; + const filename: [:0]const u8 = switch (options.mode) { + .memory => ":memory:", + else => path, + }; + + var handle: ?*c.Sqlite3 = null; + const rc = c.sqlite3_open_v2(filename.ptr, &handle, flags, null); + if (rc != result.ok) { + // sqlite3_open_v2 allocates a handle even on failure. Read the + // message from it, then close it; dropping it leaks on every + // failed open. + // Logged at `warn`, not `err`: the failure itself reaches the + // caller as a typed error, and this line only carries the message + // that would otherwise die with the handle. + if (handle) |h| { + log.warn("sqlite3_open_v2 failed for '{s}': {s} (code {d}/{d})", .{ + filename, + std.mem.span(c.sqlite3_errmsg(h)), + rc & 0xff, + c.sqlite3_extended_errcode(h), + }); + _ = c.sqlite3_close_v2(h); + } else { + log.warn("sqlite3_open_v2 failed for '{s}': {s} (code {d})", .{ + filename, + std.mem.span(c.sqlite3_errstr(rc)), + rc, + }); + } + return mapCode(rc); + } + const h = handle orelse return error.SqliteError; + + // A silently ignored busy timeout is how a contended WAL database turns + // into random SQLITE_BUSY failures under load. + check(c.sqlite3_busy_timeout(h, options.busy_timeout_ms)) catch |e| { + _ = c.sqlite3_close_v2(h); + return e; + }; + return .{ .handle = h }; + } + + pub fn close(self: *Db) void { + const rc = c.sqlite3_close_v2(self.handle); + if (rc != result.ok) { + log.err("sqlite3_close_v2 returned {s} (code {d})", .{ + std.mem.span(c.sqlite3_errstr(rc)), + rc, + }); + } + } + + /// Borrowed; valid until the next SQLite call on this handle. Formats as + /// " (code /)". + pub fn lastError(self: *Db, buf: []u8) []const u8 { + const extended = c.sqlite3_extended_errcode(self.handle); + const message = std.mem.span(c.sqlite3_errmsg(self.handle)); + return std.fmt.bufPrint(buf, "{s} (code {d}/{d})", .{ + message, + extended & 0xff, + extended, + }) catch "sqlite error (message did not fit the buffer)"; + } + + /// For DDL and multi-statement scripts. `errmsg` is passed as null and the + /// message is read back through `sqlite3_errmsg`, so there is no + /// `sqlite3_free` obligation. + pub fn exec(self: *Db, sql: [:0]const u8) Error!void { + return check(c.sqlite3_exec(self.handle, sql.ptr, null, null, null)); + } + + /// `sql` must hold exactly one statement; text with a second statement in it + /// is `error.Misuse` and belongs in `exec`. + pub fn prepare(self: *Db, sql: []const u8) Error!Stmt { + if (sql.len > std.math.maxInt(c_int)) return error.TooBig; + var handle: ?*c.Stmt = null; + var tail: ?[*]const u8 = null; + try check(c.sqlite3_prepare_v2(self.handle, sql.ptr, @intCast(sql.len), &handle, &tail)); + const h = handle orelse return error.Misuse; + + const tail_ptr = tail orelse sql.ptr + sql.len; + const consumed = @intFromPtr(tail_ptr) - @intFromPtr(sql.ptr); + const remaining = std.mem.trim(u8, sql[consumed..], " \t\r\n"); + if (remaining.len != 0) { + _ = c.sqlite3_finalize(h); + return error.Misuse; + } + return .{ .handle = h, .db = self }; + } + + /// Runs `sql` (which must yield exactly one row with one integer column) and + /// returns it. A statement that produces no row is `error.SqliteError`. + /// + /// The row shape is verified, not assumed: a result with a column count + /// other than 1, a first column that is not `SQLITE_INTEGER` (NULL, text, + /// float and blob all count), or a second row is `error.Misuse`. That is the + /// same member `prepare` returns for a caller that hands it the wrong SQL, + /// because these are the same class of fault — a caller bug or schema drift, + /// never a runtime condition. Without the checks a `SELECT` of the wrong + /// column silently returns 0. + pub fn queryInt(self: *Db, sql: []const u8) Error!i64 { + var stmt = try self.prepare(sql); + defer stmt.deinit(); + if (!try stmt.step()) { + log.warn("queryInt produced no row for '{s}'", .{sql}); + return error.SqliteError; + } + const columns = c.sqlite3_column_count(stmt.handle); + if (columns != 1) { + log.warn("queryInt expects 1 column, got {d}, for '{s}'", .{ columns, sql }); + return error.Misuse; + } + const kind = c.sqlite3_column_type(stmt.handle, 0); + if (kind != column_type.integer) { + log.warn("queryInt expects an integer column, got type {d}, for '{s}'", .{ kind, sql }); + return error.Misuse; + } + const value = stmt.columnInt(0); + if (try stmt.step()) { + log.warn("queryInt expects 1 row, got more, for '{s}'", .{sql}); + return error.Misuse; + } + return value; + } + + pub fn lastInsertRowid(self: *Db) i64 { + return c.sqlite3_last_insert_rowid(self.handle); + } + + pub fn changes(self: *Db) i64 { + return c.sqlite3_changes(self.handle); + } +}; + +/// One prepared statement. +/// +/// There is deliberately **no prepared-statement cache in this milestone**. +/// `config.db` is written a handful of times per process lifetime, so a cache is +/// unmeasured complexity here. Phase 6's query-log flush loop is the only hot +/// path and it owns its own long-lived statements. This is a decision, not an +/// oversight against PLAN §3.4. +pub const Stmt = struct { + handle: *c.Stmt, + db: *Db, + /// The code of the last failed `step`, or `SQLITE_OK`. `sqlite3_reset` and + /// `sqlite3_finalize` both re-report that code; without this the caller + /// would see one failure logged as a second, unrelated one. + pending_error: c_int = result.ok, + + pub fn deinit(self: *Stmt) void { + const rc = c.sqlite3_finalize(self.handle); + if (rc != result.ok and rc != self.pending_error) { + log.err("sqlite3_finalize returned {s} (code {d})", .{ + std.mem.span(c.sqlite3_errstr(rc)), + rc, + }); + } + } + + pub fn reset(self: *Stmt) Error!void { + const rc = c.sqlite3_reset(self.handle); + self.pending_error = result.ok; + try check(rc); + try check(c.sqlite3_clear_bindings(self.handle)); + } + + /// 1-based, matching SQLite. + pub fn bindInt(self: *Stmt, idx: c_int, value: i64) Error!void { + return check(c.sqlite3_bind_int64(self.handle, idx, value)); + } + + pub fn bindBool(self: *Stmt, idx: c_int, value: bool) Error!void { + return self.bindInt(idx, if (value) 1 else 0); + } + + /// Binds with `SQLITE_TRANSIENT`, so SQLite copies the bytes and the caller + /// never has to keep `value` alive. The copy costs an allocation per bind; + /// at config.db volumes that is invisible, and it removes a whole class of + /// use-after-free from every caller. + pub fn bindText(self: *Stmt, idx: c_int, value: []const u8) Error!void { + if (value.len > std.math.maxInt(c_int)) return error.TooBig; + return check(c.sqlite3_bind_text(self.handle, idx, value.ptr, @intCast(value.len), c.transient)); + } + + pub fn bindTextOrNull(self: *Stmt, idx: c_int, value: ?[]const u8) Error!void { + if (value) |v| return self.bindText(idx, v); + return self.bindNull(idx); + } + + pub fn bindNull(self: *Stmt, idx: c_int) Error!void { + return check(c.sqlite3_bind_null(self.handle, idx)); + } + + /// true = a row is available, false = the statement finished. + pub fn step(self: *Stmt) Error!bool { + const rc = c.sqlite3_step(self.handle); + if (rc == result.row) return true; + if (rc == result.done) return false; + self.pending_error = rc; + return mapCode(rc); + } + + /// Runs to completion; asserts no rows were produced. + pub fn exec(self: *Stmt) Error!void { + const has_row = try self.step(); + assert(!has_row); + } + + pub fn columnInt(self: *Stmt, col: c_int) i64 { + return c.sqlite3_column_int64(self.handle, col); + } + + pub fn columnBool(self: *Stmt, col: c_int) bool { + return self.columnInt(col) != 0; + } + + pub fn isNull(self: *Stmt, col: c_int) bool { + return c.sqlite3_column_type(self.handle, col) == column_type.null_value; + } + + /// Borrowed: valid only until the next `step`, `reset` or `deinit` on this + /// statement. Every caller that keeps the value must copy it. + /// + /// A NULL column reads as `""`. A `NOT NULL` column makes that unreachable + /// in practice, but it must not be undefined behaviour. + pub fn columnText(self: *Stmt, col: c_int) []const u8 { + const ptr = c.sqlite3_column_text(self.handle, col) orelse return ""; + const len = c.sqlite3_column_bytes(self.handle, col); + if (len <= 0) return ""; + return ptr[0..@intCast(len)]; + } + + /// Borrowed under the same rules as `columnText`; NULL reads as `null`. + pub fn columnTextOrNull(self: *Stmt, col: c_int) ?[]const u8 { + if (self.isNull(col)) return null; + return self.columnText(col); + } + + /// Copies into `gpa`. Caller owns the result. + pub fn columnTextAlloc(self: *Stmt, gpa: std.mem.Allocator, col: c_int) error{OutOfMemory}![]u8 { + return gpa.dupe(u8, self.columnText(col)); + } + + /// Copies into `gpa`. Caller owns the result. NULL reads as `null`. + pub fn columnTextAllocOrNull(self: *Stmt, gpa: std.mem.Allocator, col: c_int) error{OutOfMemory}!?[]u8 { + const value = self.columnTextOrNull(col) orelse return null; + return try gpa.dupe(u8, value); + } +}; + +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 { + if (p.journal_wal) { + // The pragma returns a row holding the mode it actually reached. `exec` + // would discard that answer, and an in-memory database — which cannot do + // WAL — would look fine. + var stmt = try self.prepare("PRAGMA journal_mode = WAL"); + defer stmt.deinit(); + if (!try stmt.step()) return error.SqliteError; + const mode = stmt.columnText(0); + const wal = std.ascii.eqlIgnoreCase(mode, "wal"); + const memory = std.ascii.eqlIgnoreCase(mode, "memory"); + if (!wal and !memory) { + log.warn("PRAGMA journal_mode = WAL reported '{s}'", .{mode}); + return error.SqliteError; + } + } + if (p.synchronous_normal) { + try self.exec("PRAGMA synchronous = NORMAL;"); + } + if (p.foreign_keys) { + try self.exec("PRAGMA foreign_keys = ON;"); + if (try self.queryInt("PRAGMA foreign_keys") != 1) { + log.warn("PRAGMA foreign_keys did not take", .{}); + return error.SqliteError; + } + } +} + +/// A write transaction. +/// +/// Usage contract, 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. +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 { + try db.exec("BEGIN IMMEDIATE;"); + return .{ .db = db, .active = true }; + } + + pub fn commit(self: *Tx) Error!void { + assert(self.active); + try self.db.exec("COMMIT;"); + self.active = false; + } + + /// 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 { + if (!self.active) return; + self.active = false; + self.db.exec("ROLLBACK;") catch { + var buf: [256]u8 = undefined; + log.err("ROLLBACK failed: {s}", .{self.db.lastError(&buf)}); + }; + } +}; + +const testing = std.testing; + +fn openMemory() Error!Db { + return Db.open(":memory:", .{ .mode = .memory }); +} + +test "mapCode maps every primary result code to a distinct error" { + var seen: [26]Error = undefined; + var code: c_int = 1; + while (code <= 26) : (code += 1) { + seen[@intCast(code - 1)] = mapCode(code); + } + for (seen, 0..) |a, i| { + for (seen[i + 1 ..]) |b| { + try testing.expect(a != b); + } + } +} + +test "mapCode maps SQLITE_NOMEM to error.OutOfMemory and keeps extended codes in the family" { + try testing.expectEqual(Error.OutOfMemory, mapCode(result.nomem)); + // SQLITE_IOERR_READ = 266, SQLITE_CONSTRAINT_UNIQUE = 2067. + try testing.expectEqual(Error.IoErr, mapCode(266)); + try testing.expectEqual(Error.Constraint, mapCode(2067)); + // A primary code this build does not know stays visible as SqliteError. + try testing.expectEqual(Error.SqliteError, mapCode(99)); +} + +test "open and close an in-memory database" { + var db = try openMemory(); + defer db.close(); + try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT 1")); +} + +test "queryInt rejects a result that is not exactly one row of one integer" { + var db = try openMemory(); + defer db.close(); + + // No row keeps the documented error.SqliteError. + try testing.expectError(error.SqliteError, db.queryInt("SELECT 1 WHERE 0")); + + // Wrong column count. + try testing.expectError(error.Misuse, db.queryInt("SELECT 1, 2")); + + // Wrong column type: NULL, text, float and blob are all rejected. + try testing.expectError(error.Misuse, db.queryInt("SELECT NULL")); + try testing.expectError(error.Misuse, db.queryInt("SELECT 'one'")); + try testing.expectError(error.Misuse, db.queryInt("SELECT 1.5")); + try testing.expectError(error.Misuse, db.queryInt("SELECT x'00'")); + + // A second row. + try testing.expectError(error.Misuse, db.queryInt("SELECT 1 UNION ALL SELECT 2")); +} + +test "queryInt accepts a single integer row after the shape checks" { + var db = try openMemory(); + defer db.close(); + try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);"); + try db.exec("INSERT INTO t (id, name) VALUES (7, 'only');"); + + try testing.expectEqual(@as(i64, 7), try db.queryInt("SELECT id FROM t")); + try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t")); + try testing.expectEqual(@as(i64, -3), try db.queryInt("SELECT -3")); + // sum() over an empty table is NULL, not an integer: a caller that wants a + // total from a possibly-empty table must write total(), or COALESCE. + try testing.expectError(error.Misuse, db.queryInt("SELECT sum(id) FROM t WHERE 0")); + try testing.expectEqual(@as(i64, 7), try db.queryInt("SELECT sum(id) FROM t")); +} + +test "applyPragmas succeeds and foreign_keys reads back as 1" { + var db = try openMemory(); + defer db.close(); + try applyPragmas(&db, .{}); + try testing.expectEqual(@as(i64, 1), try db.queryInt("PRAGMA foreign_keys")); +} + +test "open on a directory path returns error.CantOpen and leaks no handle" { + var i: usize = 0; + while (i < 1000) : (i += 1) { + try testing.expectError(error.CantOpen, Db.open(".", .{})); + } +} + +test "prepare rejects text holding more than one statement" { + var db = try openMemory(); + defer db.close(); + try testing.expectError(error.Misuse, db.prepare("SELECT 1; SELECT 2")); + var stmt = try db.prepare("SELECT 1;"); + stmt.deinit(); +} + +test "bind, step and column round-trip including a NULL text column" { + var db = try openMemory(); + defer db.close(); + try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, note TEXT, flag INTEGER NOT NULL);"); + + var insert = try db.prepare("INSERT INTO t (name, note, flag) VALUES (?1, ?2, ?3)"); + defer insert.deinit(); + try insert.bindText(1, "kitchen"); + try insert.bindTextOrNull(2, null); + try insert.bindBool(3, true); + try insert.exec(); + try testing.expectEqual(@as(i64, 1), db.changes()); + try testing.expectEqual(@as(i64, 1), db.lastInsertRowid()); + + var select = try db.prepare("SELECT id, name, note, flag FROM t"); + defer select.deinit(); + try testing.expect(try select.step()); + try testing.expectEqual(@as(i64, 1), select.columnInt(0)); + try testing.expectEqualStrings("kitchen", select.columnText(1)); + try testing.expect(select.isNull(2)); + try testing.expectEqual(@as(?[]const u8, null), select.columnTextOrNull(2)); + try testing.expectEqualStrings("", select.columnText(2)); + try testing.expect(select.columnBool(3)); + try testing.expect(!try select.step()); +} + +test "columnTextAlloc returns an owned copy that survives a subsequent step" { + var db = try openMemory(); + defer db.close(); + try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);"); + try db.exec("INSERT INTO t (id, name) VALUES (1, 'first'), (2, 'second');"); + + var stmt = try db.prepare("SELECT name FROM t ORDER BY id"); + defer stmt.deinit(); + try testing.expect(try stmt.step()); + const owned = try stmt.columnTextAlloc(testing.allocator, 0); + defer testing.allocator.free(owned); + const owned_or_null = try stmt.columnTextAllocOrNull(testing.allocator, 0); + defer if (owned_or_null) |v| testing.allocator.free(v); + + try testing.expect(try stmt.step()); + try testing.expectEqualStrings("second", stmt.columnText(0)); + try testing.expectEqualStrings("first", owned); + try testing.expectEqualStrings("first", owned_or_null.?); +} + +test "transaction commit persists and rollback discards" { + var db = try openMemory(); + defer db.close(); + try applyPragmas(&db, .{}); + try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);"); + + { + var tx = try Tx.begin(&db); + errdefer tx.rollback(); + try db.exec("INSERT INTO t (id) VALUES (1);"); + try tx.commit(); + } + try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t")); + + { + var tx = try Tx.begin(&db); + try db.exec("INSERT INTO t (id) VALUES (2);"); + tx.rollback(); + } + try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t")); +} + +test "rollback after commit is a no-op" { + var db = try openMemory(); + defer db.close(); + try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);"); + + var tx = try Tx.begin(&db); + try db.exec("INSERT INTO t (id) VALUES (1);"); + try tx.commit(); + try testing.expect(!tx.active); + tx.rollback(); + tx.rollback(); + try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t")); +} + +test "a row-producing statement reports its row through step" { + var db = try openMemory(); + defer db.close(); + // Stmt.exec asserts on this shape; the test observes it through `step` + // instead, so the assertion path stays out of the test binary. + var stmt = try db.prepare("SELECT 1"); + defer stmt.deinit(); + try testing.expect(try stmt.step()); +} + +test "a duplicate insert into a UNIQUE column returns error.Constraint" { + var db = try openMemory(); + defer db.close(); + try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);"); + try db.exec("INSERT INTO t (name) VALUES ('only');"); + + var stmt = try db.prepare("INSERT INTO t (name) VALUES (?1)"); + defer stmt.deinit(); + try stmt.bindText(1, "only"); + try testing.expectError(error.Constraint, stmt.step()); +} diff --git a/src/storage/migrations.zig b/src/storage/migrations.zig new file mode 100644 index 0000000..8b9860b --- /dev/null +++ b/src/storage/migrations.zig @@ -0,0 +1,255 @@ +//! The `config.db` migration runner. +//! +//! Steps are compiled into the binary in ascending order and applied inside +//! **one** transaction, then the reached version is stamped. SQLite runs DDL +//! transactionally, so a step that fails leaves the file exactly as it was. +//! +//! A database stamped *newer* than this binary is never silently accepted and +//! never downgraded: it is `error.SchemaTooNew`, distinct from every other +//! error, so the CLI can tell the operator to install a newer nxdns. + +const std = @import("std"); +const assert = std.debug.assert; + +const db = @import("db.zig"); +const config_schema = @import("config_schema.zig"); + +const log = std.log.scoped(.migrations); + +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; + +comptime { + assertOrdered(&steps); +} + +pub const Error = db.Error || error{ SchemaTooNew, SchemaCorrupt }; + +/// Versions must be `1, 2, 3, …` with no gaps. A gap would make "apply every +/// step newer than the stamped version" ambiguous about what the stamp means. +fn assertOrdered(list: []const Step) void { + assert(list.len > 0); + for (list, 0..) |step, i| assert(@as(usize, step.version) == i + 1); +} + +/// Reads the stamped version, applies every newer step in one transaction and +/// stamps the result. Returns the version now in the file. +/// +/// `database` must already have had `db.applyPragmas` called: `PRAGMA +/// foreign_keys` is a no-op inside a transaction, so applying it afterwards +/// would silently leave referential integrity off. +pub fn migrate(database: *db.Db) Error!u32 { + return migrateSteps(database, &steps); +} + +/// Same logic against an injected step list. The seam exists for the rollback +/// and stepwise-upgrade tests, which need a second step that `steps` does not +/// yet have. +pub fn migrateSteps(database: *db.Db, list: []const Step) Error!u32 { + assertOrdered(list); + const target = list[list.len - 1].version; + + const current = try readVersion(database); + if (current > target) { + log.warn("config.db is at schema version {d}; this nxdns binary supports {d}", .{ current, target }); + return error.SchemaTooNew; + } + if (current == target) return current; + + var tx = try db.Tx.begin(database); + errdefer tx.rollback(); + + // Re-read under BEGIN IMMEDIATE. Two processes starting at the same moment + // both saw `current` above; the one that loses the write lock arrives here + // after the other committed and finds nothing to do. + const stamped = try readVersion(database); + if (stamped > target) { + log.warn("config.db is at schema version {d}; this nxdns binary supports {d}", .{ stamped, target }); + return error.SchemaTooNew; + } + if (stamped == target) { + try tx.commit(); + return stamped; + } + + for (list) |step| { + if (step.version <= stamped) continue; + try database.exec(step.sql); + } + + try database.exec("DELETE FROM schema_version;"); + var stmt = try database.prepare("INSERT INTO schema_version (version) VALUES (?1)"); + defer stmt.deinit(); + try stmt.bindInt(1, target); + try stmt.exec(); + + try tx.commit(); + log.info("config.db migrated from schema version {d} to {d}", .{ stamped, target }); + return target; +} + +/// `0` when `schema_version` does not exist yet. Zero rows or more than one row +/// is `error.SchemaCorrupt` — the version of a database is never guessed. +fn readVersion(database: *db.Db) Error!u32 { + const present = try database.queryInt( + "SELECT count(*) FROM sqlite_schema WHERE type='table' AND name='schema_version'", + ); + if (present == 0) return 0; + + const rows = try database.queryInt("SELECT count(*) FROM schema_version"); + if (rows != 1) { + log.warn("schema_version holds {d} rows; exactly one is required", .{rows}); + return error.SchemaCorrupt; + } + + const version = try database.queryInt("SELECT version FROM schema_version"); + if (version < 0 or version > std.math.maxInt(u32)) { + log.warn("schema_version holds an out-of-range version {d}", .{version}); + return error.SchemaCorrupt; + } + return @intCast(version); +} + +fn tableExists(database: *db.Db, name: []const u8) db.Error!bool { + var stmt = try database.prepare("SELECT count(*) FROM sqlite_schema WHERE type='table' AND name = ?1"); + defer stmt.deinit(); + try stmt.bindText(1, name); + if (!try stmt.step()) return error.SqliteError; + return stmt.columnInt(0) != 0; +} + +const testing = std.testing; + +fn openMigrated() !db.Db { + var database = try db.Db.open(":memory:", .{ .mode = .memory }); + errdefer database.close(); + try db.applyPragmas(&database, .{}); + return database; +} + +test "migrate on a fresh database creates every table and seeds the default group" { + var database = try openMigrated(); + defer database.close(); + + try testing.expectEqual(target_version, try migrate(&database)); + + const expected = [_][]const u8{ + "schema_version", "groups", "clients", "client_prefixes", + "upstreams", "rules", "local_records", "forward_zones", + "blocklist_sources", "group_sources", "settings", + }; + for (expected) |name| { + try testing.expect(try tableExists(&database, name)); + } + try testing.expectEqual( + @as(i64, expected.len), + try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"), + ); + + try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM groups")); + try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT id FROM groups")); + var stmt = try database.prepare("SELECT name, safe_search FROM groups"); + defer stmt.deinit(); + try testing.expect(try stmt.step()); + try testing.expectEqualStrings("default", stmt.columnText(0)); + try testing.expect(!stmt.columnBool(1)); +} + +test "migrate is idempotent" { + var database = try openMigrated(); + defer database.close(); + + try testing.expectEqual(target_version, try migrate(&database)); + const before = try database.queryInt("SELECT count(*) FROM sqlite_schema"); + const rowid_before = database.lastInsertRowid(); + + try testing.expectEqual(target_version, try migrate(&database)); + try testing.expectEqual(before, try database.queryInt("SELECT count(*) FROM sqlite_schema")); + try testing.expectEqual(rowid_before, database.lastInsertRowid()); + try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM schema_version")); + try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM groups")); +} + +test "a database stamped newer than the binary is error.SchemaTooNew" { + var database = try openMigrated(); + defer database.close(); + _ = try migrate(&database); + + const future: i64 = @as(i64, target_version) + 1; + var stmt = try database.prepare("UPDATE schema_version SET version = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, future); + try stmt.exec(); + + try testing.expectError(error.SchemaTooNew, migrate(&database)); + try testing.expectEqual(future, try database.queryInt("SELECT version FROM schema_version")); +} + +test "schema_version holding two rows is error.SchemaCorrupt" { + var database = try openMigrated(); + defer database.close(); + _ = try migrate(&database); + + try database.exec("INSERT INTO schema_version (version) VALUES (1);"); + try testing.expectError(error.SchemaCorrupt, migrate(&database)); +} + +test "a failing step rolls the whole migration back" { + var database = try openMigrated(); + defer database.close(); + + const broken = [_]Step{ + .{ .version = 1, .sql = config_schema.ddl_v1 }, + .{ .version = 2, .sql = "CREATE TABLE second (" }, + }; + // db.zig maps SQLITE_ERROR — the generic "SQL error" — to error.Unexpected. + try testing.expectError(error.Unexpected, migrateSteps(&database, &broken)); + + try testing.expect(!try tableExists(&database, "schema_version")); + try testing.expect(!try tableExists(&database, "groups")); + try testing.expect(!try tableExists(&database, "second")); + try testing.expectEqual(@as(u32, 0), try readVersion(&database)); +} + +test "a stepwise upgrade applies only the new steps" { + var database = try openMigrated(); + defer database.close(); + + const first = [_]Step{.{ .version = 1, .sql = config_schema.ddl_v1 }}; + try testing.expectEqual(@as(u32, 1), try migrateSteps(&database, &first)); + try testing.expect(try tableExists(&database, "groups")); + try testing.expect(!try tableExists(&database, "extra")); + + const second = [_]Step{ + .{ .version = 1, .sql = config_schema.ddl_v1 }, + .{ .version = 2, .sql = "CREATE TABLE extra (id INTEGER PRIMARY KEY);" }, + }; + try testing.expectEqual(@as(u32, 2), try migrateSteps(&database, &second)); + try testing.expect(try tableExists(&database, "extra")); + try testing.expectEqual(@as(u32, 2), try readVersion(&database)); + // Step 1 did not run a second time: `groups` still holds one seeded row. + try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM groups")); +} + +test "delete_order and content_tables name exactly the tables the schema creates" { + var database = try openMigrated(); + defer database.close(); + _ = try migrate(&database); + + for (config_schema.delete_order) |name| { + try testing.expect(try tableExists(&database, name)); + } + for (config_schema.content_tables) |name| { + try testing.expect(try tableExists(&database, name)); + } + // delete_order covers every table except `schema_version`. + try testing.expectEqual( + @as(i64, config_schema.delete_order.len + 1), + try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"), + ); +} diff --git a/src/storage/querylog_schema.zig b/src/storage/querylog_schema.zig new file mode 100644 index 0000000..5c3ed38 --- /dev/null +++ b/src/storage/querylog_schema.zig @@ -0,0 +1,280 @@ +//! The `querylog.db` schema and its open-or-recreate policy. +//! +//! `querylog.db` is never migrated (PLAN §3.7). It holds expendable log rows, +//! so a schema change replaces the file instead of upgrading it. The +//! replacement trigger is a fingerprint derived from the DDL text itself, so +//! editing the schema below automatically invalidates every existing file — the +//! policy cannot drift out of sync with the SQL. +//! +//! **Recreating is destructive, so the predicate is a positive whitelist.** Only +//! a missing file, `error.Corrupt`, `error.NotADb`, a failed `PRAGMA +//! quick_check` and a fingerprint mismatch recreate. Every other error +//! propagates and the file on disk is not touched. `error.Busy` / `error.Locked` +//! mean another process holds the write lock — waiting is right, deleting is +//! catastrophic. `error.OutOfMemory` is this process's problem. `error.CantOpen` +//! is usually a permission or missing-directory problem that recreating would +//! mask rather than fix. Same for `error.ReadOnly`, `error.IoErr`, `error.Full`, +//! `error.Perm`, `error.Auth` and `error.Canceled`. + +const std = @import("std"); + +const db = @import("db.zig"); + +const log = std.log.scoped(.querylog_schema); + +/// Verbatim from PLAN §11.3. Multi-statement text — it goes through +/// `db.Db.exec`, never through `prepare`. +pub const ddl: [:0]const u8 = + \\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); +; + +/// `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. +pub const fingerprint: i32 = blk: { + // Covers the CRC lookup-table generation in std.hash.crc, which evaluates + // under this scope's quota and overflows the 1000 default (and 100k). + @setEvalBranchQuota(2_000_000); + break :blk @bitCast(std.hash.Crc32.hash(ddl)); +}; + +const set_user_version = std.fmt.comptimePrint("PRAGMA user_version = {d};", .{fingerprint}); + +/// Long enough for any path this program will be handed, plus the aside suffix. +/// A longer path is `error.NameTooLong`, which is what the filesystem calls +/// would have returned anyway. +const path_buf_len = 4096 + 64; + +pub const RecreateReason = enum { missing, corrupt, not_a_database, quick_check_failed, fingerprint_mismatch }; + +pub const OpenResult = struct { + database: db.Db, + /// Non-null feeds a counter and the `/api/health` rollup in Phase 8. + recreated: ?RecreateReason, +}; + +pub const Error = db.Error || error{AsideNameCollision} || + std.Io.Dir.RenamePreserveError || std.Io.Dir.DeleteFileError || std.Io.Dir.AccessError; + +/// Opens `path`, recreating it if and only if it is genuinely unusable. +/// +/// `path` is resolved twice by two different mechanisms: `dir`-relative for the +/// filesystem calls, and process-cwd-relative by SQLite's VFS, which knows +/// nothing about `dir`. The caller must therefore pass either an absolute path +/// with `dir` open on its parent, or `std.Io.Dir.cwd()` with a cwd-relative +/// path. +pub fn open(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) Error!OpenResult { + var handle: ?db.Db = null; + errdefer if (handle) |*h| h.close(); + + const reason: ?RecreateReason = probe: { + dir.access(io, path, .{}) catch |e| switch (e) { + error.FileNotFound => break :probe .missing, + else => |other| return other, + }; + + handle = db.Db.open(path, .{ .mode = .read_write_existing }) catch |e| + break :probe recreatable(e) orelse return e; + const opened = &handle.?; + + db.applyPragmas(opened, .{}) catch |e| + break :probe recreatable(e) orelse return e; + + const healthy = quickCheck(opened) catch |e| + break :probe recreatable(e) orelse return e; + if (!healthy) break :probe .quick_check_failed; + + const stamped = opened.queryInt("PRAGMA user_version") catch |e| + break :probe recreatable(e) orelse return e; + if (stamped != fingerprint) break :probe .fingerprint_mismatch; + + break :probe null; + }; + + const cause = reason orelse return .{ .database = handle.?, .recreated = null }; + + // Close first, so SQLite checkpoints and drops `-wal`/`-shm` where it can. + if (handle) |*h| h.close(); + handle = null; + + var aside_buf: [path_buf_len]u8 = undefined; + const aside: ?[]const u8 = if (cause == .missing) + null + else + try renameAside(io, dir, path, &aside_buf); + + // Not optional: a stale WAL left beside the renamed database would be + // replayed into the freshly created file and corrupt it immediately. Any + // failure other than "already gone" propagates rather than building the new + // database on a half-cleaned state. + try deleteSidecars(io, dir, path); + + const fresh = try createFresh(path); + if (cause == .missing) { + log.info("created querylog database '{s}'", .{path}); + } else { + log.warn("recreated querylog database '{s}': {s}; previous file kept as '{s}'", .{ + path, + @tagName(cause), + aside.?, + }); + } + return .{ .database = fresh, .recreated = cause }; +} + +/// The whitelist. `null` means "propagate, do not touch the file". +fn recreatable(e: db.Error) ?RecreateReason { + return switch (e) { + error.Corrupt => .corrupt, + error.NotADb => .not_a_database, + else => null, + }; +} + +/// `PRAGMA quick_check` rather than `integrity_check`: 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. +fn quickCheck(database: *db.Db) db.Error!bool { + var stmt = try database.prepare("PRAGMA quick_check"); + defer stmt.deinit(); + if (!try stmt.step()) return false; + return std.ascii.eqlIgnoreCase(stmt.columnText(0), "ok"); +} + +/// Renames the unusable file out of the way and returns the name it now has. +/// +/// `renamePreserve` is `RENAME_NOREPLACE`: it returns `error.PathAlreadyExists` +/// instead of overwriting. A previously saved corrupt file must never be +/// destroyed by the next recreate, and two recreates in the same second are not +/// hypothetical on a boot loop — hence the uniquifying retries. +fn renameAside(io: std.Io, dir: std.Io.Dir, path: []const u8, buf: []u8) Error![]const u8 { + const seconds = std.Io.Clock.real.now(io).toSeconds(); + var attempt: u32 = 0; + while (attempt < 100) : (attempt += 1) { + const aside = if (attempt == 0) + std.fmt.bufPrint(buf, "{s}.corrupt-{d}", .{ path, seconds }) catch return error.NameTooLong + else + std.fmt.bufPrint(buf, "{s}.corrupt-{d}-{d}", .{ path, seconds, attempt }) catch return error.NameTooLong; + + dir.renamePreserve(path, dir, aside, io) catch |e| switch (e) { + error.PathAlreadyExists => continue, + else => |other| return other, + }; + return aside; + } + return error.AsideNameCollision; +} + +fn deleteSidecars(io: std.Io, dir: std.Io.Dir, path: []const u8) Error!void { + var buf: [path_buf_len]u8 = undefined; + for ([_][]const u8{ "-wal", "-shm" }) |suffix| { + const sidecar = std.fmt.bufPrint(&buf, "{s}{s}", .{ path, suffix }) catch return error.NameTooLong; + dir.deleteFile(io, sidecar) catch |e| switch (e) { + error.FileNotFound => {}, + else => |other| return other, + }; + } +} + +fn createFresh(path: [:0]const u8) db.Error!db.Db { + var database = try db.Db.open(path, .{ .mode = .read_write_create }); + errdefer database.close(); + try db.applyPragmas(&database, .{}); + + var tx = try db.Tx.begin(&database); + errdefer tx.rollback(); + try database.exec(ddl); + try database.exec(set_user_version); + try tx.commit(); + + return database; +} + +const testing = std.testing; + +test "fingerprint matches a fresh hash of the DDL" { + try testing.expectEqual(fingerprint, @as(i32, @bitCast(std.hash.Crc32.hash(ddl)))); +} + +test "ddl creates domains, query_log and the three indexes" { + var database = try db.Db.open(":memory:", .{ .mode = .memory }); + defer database.close(); + try db.applyPragmas(&database, .{}); + try database.exec(ddl); + + try testing.expectEqual( + @as(i64, 2), + try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"), + ); + const objects = [_][]const u8{ + "domains", "query_log", + "idx_query_log_ts", "idx_query_log_client", + "idx_query_log_domain", + }; + for (objects) |name| { + var stmt = try database.prepare("SELECT count(*) FROM sqlite_schema WHERE name = ?1"); + defer stmt.deinit(); + try stmt.bindText(1, name); + try testing.expect(try stmt.step()); + try testing.expectEqual(@as(i64, 1), stmt.columnInt(0)); + } +} + +test "the user_version statement stamps the fingerprint" { + var database = try db.Db.open(":memory:", .{ .mode = .memory }); + defer database.close(); + try database.exec(set_user_version); + try testing.expectEqual(@as(i64, fingerprint), try database.queryInt("PRAGMA user_version")); +} + +// The behaviour these two cases describe — a resource error leaves the file on +// disk alone — is proven against a real file by "S7 case 23" in +// `storage_integration_test.zig`, which locks a healthy `querylog.db` from a +// second connection and asserts `open` returns `error.Busy` with the bytes, the +// file and the absence of an aside all intact. `error.OutOfMemory` has no such +// case: `open` takes no allocator, and SQLite allocates through its own global +// allocator, so there is no seam to inject a failure through. The two tests +// below are what covers it. +test "recreatable is a whitelist and never selects a resource error" { + try testing.expectEqual(RecreateReason.corrupt, recreatable(error.Corrupt).?); + try testing.expectEqual(RecreateReason.not_a_database, recreatable(error.NotADb).?); + const propagating = [_]db.Error{ + error.Busy, error.Locked, error.OutOfMemory, error.CantOpen, + error.ReadOnly, error.IoErr, error.Full, error.Perm, + error.Auth, error.Misuse, error.Constraint, error.SqliteError, + error.Unexpected, + }; + for (propagating) |e| { + try testing.expect(recreatable(e) == null); + } +} + +test "recreatable selects exactly two of db.Error's members" { + // Exhaustive over the whole set, so a variant added to `db.Error` later + // defaults to propagate. The list above only proves the named errors are + // safe today; this proves nothing else can join the whitelist unnoticed. + var whitelisted: usize = 0; + inline for (@typeInfo(db.Error).error_set.?) |member| { + if (recreatable(@field(db.Error, member.name)) != null) whitelisted += 1; + } + try testing.expectEqual(@as(usize, 2), whitelisted); +} diff --git a/src/storage/repositories/clients_repo.zig b/src/storage/repositories/clients_repo.zig new file mode 100644 index 0000000..2e583cf --- /dev/null +++ b/src/storage/repositories/clients_repo.zig @@ -0,0 +1,336 @@ +//! `clients` and `client_prefixes`. +//! +//! `listClients` returns only `hand_edited = 1` rows. A client the server +//! materialised from live traffic is runtime state, not configuration, and must +//! not appear in an export. `countClients` counts **all** rows, because S5's +//! "has this database ever been configured" predicate needs the true count. +//! +//! Only list / insert / deleteAll / count exist. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const db = @import("../db.zig"); +const migrations = @import("../migrations.zig"); +const model = @import("../../config/model.zig"); +const context = @import("context.zig"); + +const IdMap = context.IdMap; +const InsertContext = context.InsertContext; + +// --------------------------------------------------------------------------- +// clients +// --------------------------------------------------------------------------- + +const list_clients_sql = + \\SELECT c.ip, c.name, g.name FROM clients c + \\ JOIN groups g ON g.id = c.group_id + \\ WHERE c.hand_edited = 1 + \\ ORDER BY c.ip +; + +/// Every string in the result is a heap copy owned by `gpa`. +pub fn listClients(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Client) { + var stmt = try database.prepare(list_clients_sql); + defer stmt.deinit(); + + var out: std.ArrayList(model.Client) = .empty; + // `errdefer`s run in reverse: `freeClients` is declared last so it runs + // before the backing array is released. + errdefer out.deinit(gpa); + errdefer freeClients(gpa, out.items); + + while (try stmt.step()) { + const ip = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(ip); + // `clients.name` is nullable; `columnTextAlloc` reads NULL as "", which + // is exactly the model's default. + const name = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(name); + const group = try stmt.columnTextAlloc(gpa, 2); + errdefer gpa.free(group); + try out.append(gpa, .{ .ip = ip, .name = name, .group = group }); + } + return out; +} + +pub fn freeClients(gpa: Allocator, items: []const model.Client) void { + for (items) |item| { + gpa.free(item.ip); + gpa.free(item.name); + gpa.free(item.group); + } +} + +const insert_client_sql = + \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen) + \\VALUES (?1, ?2, ?3, 1, ?4, ?4) +; + +/// `hand_edited` is 1: a client that reached a repository through the config +/// model came from an operator's file, by definition. +pub fn insertClient(database: *db.Db, item: model.Client, ctx: InsertContext) db.Error!void { + const group_id = try ctx.groupId(item.group); + + var stmt = try database.prepare(insert_client_sql); + defer stmt.deinit(); + try stmt.bindText(1, item.ip); + try stmt.bindText(2, item.name); + try stmt.bindInt(3, group_id); + try stmt.bindInt(4, ctx.now); + try stmt.exec(); +} + +pub fn deleteAllClients(database: *db.Db) db.Error!void { + return database.exec("DELETE FROM clients;"); +} + +/// Counts every row, including the ones `listClients` filters out. +pub fn countClients(database: *db.Db) db.Error!i64 { + return database.queryInt("SELECT count(*) FROM clients"); +} + +// --------------------------------------------------------------------------- +// client_prefixes +// --------------------------------------------------------------------------- + +const list_client_prefixes_sql = + \\SELECT p.prefix, g.name, p.priority FROM client_prefixes p + \\ JOIN groups g ON g.id = p.group_id + \\ ORDER BY p.prefix +; + +pub fn listClientPrefixes(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ClientPrefix) { + var stmt = try database.prepare(list_client_prefixes_sql); + defer stmt.deinit(); + + var out: std.ArrayList(model.ClientPrefix) = .empty; + errdefer out.deinit(gpa); + errdefer freeClientPrefixes(gpa, out.items); + + while (try stmt.step()) { + const prefix = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(prefix); + const group = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(group); + // The column is a 64-bit integer; the model field is `i32`. A value + // outside that range means something other than nxdns wrote the row. + const priority = std.math.cast(i32, stmt.columnInt(2)) orelse return error.Mismatch; + try out.append(gpa, .{ .prefix = prefix, .group = group, .priority = priority }); + } + return out; +} + +pub fn freeClientPrefixes(gpa: Allocator, items: []const model.ClientPrefix) void { + for (items) |item| { + gpa.free(item.prefix); + gpa.free(item.group); + } +} + +pub fn insertClientPrefix(database: *db.Db, item: model.ClientPrefix, ctx: InsertContext) db.Error!void { + const group_id = try ctx.groupId(item.group); + + var stmt = try database.prepare("INSERT INTO client_prefixes (prefix, group_id, priority) VALUES (?1, ?2, ?3)"); + defer stmt.deinit(); + try stmt.bindText(1, item.prefix); + try stmt.bindInt(2, group_id); + try stmt.bindInt(3, item.priority); + try stmt.exec(); +} + +pub fn deleteAllClientPrefixes(database: *db.Db) db.Error!void { + return database.exec("DELETE FROM client_prefixes;"); +} + +pub fn countClientPrefixes(database: *db.Db) db.Error!i64 { + return database.queryInt("SELECT count(*) FROM client_prefixes"); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +fn openMigrated() !db.Db { + var database = try db.Db.open(":memory:", .{ .mode = .memory }); + errdefer database.close(); + try db.applyPragmas(&database, .{}); + _ = try migrations.migrate(&database); + return database; +} + +/// Migration step 1 seeds `(1, 'default')`; `kids` is added here so the join +/// has two distinct groups to resolve. +fn seedGroups(database: *db.Db) !IdMap { + try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');"); + var ids: IdMap = .empty; + errdefer ids.deinit(testing.allocator); + try ids.put(testing.allocator, "default", 1); + try ids.put(testing.allocator, "kids", 2); + return ids; +} + +fn seedClients(database: *db.Db, ids: *const IdMap) !void { + const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids }; + try insertClient(database, .{ .ip = "192.168.1.20", .name = "laptop", .group = "kids" }, ctx); + try insertClient(database, .{ .ip = "192.168.1.10", .name = "desk" }, ctx); + try insertClient(database, .{ .ip = "fd00::1", .group = "kids" }, ctx); +} + +fn seedClientPrefixes(database: *db.Db, ids: *const IdMap) !void { + const ctx: InsertContext = .{ .group_ids = ids }; + try insertClientPrefix(database, .{ .prefix = "192.168.2.0/24", .group = "kids", .priority = 10 }, ctx); + try insertClientPrefix(database, .{ .prefix = "192.168.1.0/24", .priority = 50 }, ctx); + try insertClientPrefix(database, .{ .prefix = "fd00::/48", .group = "kids" }, ctx); +} + +test "clients round-trip in ip order with group names resolved" { + var database = try openMigrated(); + defer database.close(); + var ids = try seedGroups(&database); + defer ids.deinit(testing.allocator); + try seedClients(&database, &ids); + + var items = try listClients(&database, testing.allocator); + defer items.deinit(testing.allocator); + defer freeClients(testing.allocator, items.items); + + try testing.expectEqual(@as(usize, 3), items.items.len); + try testing.expectEqualStrings("192.168.1.10", items.items[0].ip); + try testing.expectEqualStrings("desk", items.items[0].name); + try testing.expectEqualStrings("default", items.items[0].group); + try testing.expectEqualStrings("192.168.1.20", items.items[1].ip); + try testing.expectEqualStrings("laptop", items.items[1].name); + try testing.expectEqualStrings("kids", items.items[1].group); + try testing.expectEqualStrings("fd00::1", items.items[2].ip); + try testing.expectEqualStrings("", items.items[2].name); + try testing.expectEqualStrings("kids", items.items[2].group); + + try testing.expectEqual( + @as(i64, 1700000000), + try database.queryInt("SELECT first_seen FROM clients WHERE ip = '192.168.1.10'"), + ); + try testing.expectEqual( + @as(i64, 1700000000), + try database.queryInt("SELECT last_seen FROM clients WHERE ip = '192.168.1.10'"), + ); +} + +test "a hand_edited = 0 client is absent from listClients but counted by countClients" { + var database = try openMigrated(); + defer database.close(); + var ids = try seedGroups(&database); + defer ids.deinit(testing.allocator); + try seedClients(&database, &ids); + try database.exec( + \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen) + \\VALUES ('10.0.0.5', 'auto', 1, 0, 1, 1); + ); + + try testing.expectEqual(@as(i64, 4), try countClients(&database)); + + var items = try listClients(&database, testing.allocator); + defer items.deinit(testing.allocator); + defer freeClients(testing.allocator, items.items); + try testing.expectEqual(@as(usize, 3), items.items.len); + for (items.items) |item| { + try testing.expect(!std.mem.eql(u8, item.ip, "10.0.0.5")); + } +} + +test "deleteAllClients empties the table and countClients reflects it" { + var database = try openMigrated(); + defer database.close(); + var ids = try seedGroups(&database); + defer ids.deinit(testing.allocator); + try seedClients(&database, &ids); + + try testing.expectEqual(@as(i64, 3), try countClients(&database)); + try deleteAllClients(&database); + try testing.expectEqual(@as(i64, 0), try countClients(&database)); +} + +test "insertClient reports a group the caller's map does not hold" { + var database = try openMigrated(); + defer database.close(); + const ctx: InsertContext = .{}; + try testing.expectError( + error.NotFound, + insertClient(&database, .{ .ip = "192.168.1.1" }, ctx), + ); +} + +fn listClientsUnderFailure(gpa: Allocator, ids: *const IdMap) !void { + var database = try openMigrated(); + defer database.close(); + try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');"); + try seedClients(&database, ids); + + var items = try listClients(&database, gpa); + defer items.deinit(gpa); + defer freeClients(gpa, items.items); +} + +test "listClients is leak-safe under allocation failure" { + var ids: IdMap = .empty; + defer ids.deinit(testing.allocator); + try ids.put(testing.allocator, "default", 1); + try ids.put(testing.allocator, "kids", 2); + try testing.checkAllAllocationFailures(testing.allocator, listClientsUnderFailure, .{&ids}); +} + +test "client_prefixes round-trip in prefix order with group names resolved" { + var database = try openMigrated(); + defer database.close(); + var ids = try seedGroups(&database); + defer ids.deinit(testing.allocator); + try seedClientPrefixes(&database, &ids); + + var items = try listClientPrefixes(&database, testing.allocator); + defer items.deinit(testing.allocator); + defer freeClientPrefixes(testing.allocator, items.items); + + try testing.expectEqual(@as(usize, 3), items.items.len); + try testing.expectEqualStrings("192.168.1.0/24", items.items[0].prefix); + try testing.expectEqualStrings("default", items.items[0].group); + try testing.expectEqual(@as(i32, 50), items.items[0].priority); + try testing.expectEqualStrings("192.168.2.0/24", items.items[1].prefix); + try testing.expectEqualStrings("kids", items.items[1].group); + try testing.expectEqual(@as(i32, 10), items.items[1].priority); + try testing.expectEqualStrings("fd00::/48", items.items[2].prefix); + try testing.expectEqualStrings("kids", items.items[2].group); + try testing.expectEqual(@as(i32, 100), items.items[2].priority); +} + +test "deleteAllClientPrefixes empties the table and countClientPrefixes reflects it" { + var database = try openMigrated(); + defer database.close(); + var ids = try seedGroups(&database); + defer ids.deinit(testing.allocator); + try seedClientPrefixes(&database, &ids); + + try testing.expectEqual(@as(i64, 3), try countClientPrefixes(&database)); + try deleteAllClientPrefixes(&database); + try testing.expectEqual(@as(i64, 0), try countClientPrefixes(&database)); +} + +fn listClientPrefixesUnderFailure(gpa: Allocator, ids: *const IdMap) !void { + var database = try openMigrated(); + defer database.close(); + try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');"); + try seedClientPrefixes(&database, ids); + + var items = try listClientPrefixes(&database, gpa); + defer items.deinit(gpa); + defer freeClientPrefixes(gpa, items.items); +} + +test "listClientPrefixes is leak-safe under allocation failure" { + var ids: IdMap = .empty; + defer ids.deinit(testing.allocator); + try ids.put(testing.allocator, "default", 1); + try ids.put(testing.allocator, "kids", 2); + try testing.checkAllAllocationFailures(testing.allocator, listClientPrefixesUnderFailure, .{&ids}); +} diff --git a/src/storage/repositories/context.zig b/src/storage/repositories/context.zig new file mode 100644 index 0000000..9aeca72 --- /dev/null +++ b/src/storage/repositories/context.zig @@ -0,0 +1,66 @@ +//! What every `insert*` needs and the config model deliberately omits. +//! +//! `config/model.zig` carries no row ids and no timestamps: ids are not stable +//! across an import, and `first_seen` / `last_seen` / `created_at` are facts a +//! running server produces. Every insert that writes one of those columns reads +//! it from here instead. +//! +//! Building the id maps is the caller's job (S5): only the caller knows the ids +//! of the parent rows it just inserted. + +const std = @import("std"); + +const log = std.log.scoped(.repositories); + +/// Group name → `groups.id`, or blocklist source URL → `blocklist_sources.id`. +pub const IdMap = std.StringHashMapUnmanaged(i64); + +const no_ids: IdMap = .empty; + +pub const InsertContext = struct { + /// Unix epoch seconds, from `std.Io.Clock.real.now(io).toSeconds()`. + now: i64 = 0, + group_ids: *const IdMap = &no_ids, + source_ids: *const IdMap = &no_ids, + + /// `error.NotFound` means the caller's map lacks a name the validator has + /// already proven the config declares. It is reported rather than asserted + /// so a caller bug aborts the import transaction instead of the process. + /// `NotFound` is a member of `db.Error`, so it needs no wider error set. + pub fn groupId(self: InsertContext, name: []const u8) error{NotFound}!i64 { + return self.group_ids.get(name) orelse { + log.warn("no group id for '{s}'", .{name}); + return error.NotFound; + }; + } + + pub fn sourceId(self: InsertContext, url: []const u8) error{NotFound}!i64 { + return self.source_ids.get(url) orelse { + log.warn("no blocklist source id for '{s}'", .{url}); + return error.NotFound; + }; + } +}; + +const testing = std.testing; + +test "an InsertContext with no maps reports a missing id rather than trapping" { + const ctx: InsertContext = .{}; + try testing.expectError(error.NotFound, ctx.groupId("default")); + try testing.expectError(error.NotFound, ctx.sourceId("https://example.test/list.txt")); +} + +test "InsertContext resolves names through the caller's maps" { + var groups: IdMap = .empty; + defer groups.deinit(testing.allocator); + try groups.put(testing.allocator, "default", 1); + + var sources: IdMap = .empty; + defer sources.deinit(testing.allocator); + try sources.put(testing.allocator, "https://example.test/list.txt", 7); + + const ctx: InsertContext = .{ .now = 1700000000, .group_ids = &groups, .source_ids = &sources }; + try testing.expectEqual(@as(i64, 1), try ctx.groupId("default")); + try testing.expectEqual(@as(i64, 7), try ctx.sourceId("https://example.test/list.txt")); + try testing.expectError(error.NotFound, ctx.groupId("kids")); +} diff --git a/src/storage/repositories/groups_repo.zig b/src/storage/repositories/groups_repo.zig new file mode 100644 index 0000000..194080c --- /dev/null +++ b/src/storage/repositories/groups_repo.zig @@ -0,0 +1,275 @@ +//! `groups` and `group_sources`. +//! +//! Both lists yield model values holding **names**, never row ids: ids are not +//! stable across an import, so an export carrying them would not re-import into +//! the same shape. +//! +//! Only list / insert / deleteAll / count exist. Update-by-id, delete-by-id and +//! paged reads are Phase 8's REST surface; adding them now would be untested, +//! unused generality. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const db = @import("../db.zig"); +const migrations = @import("../migrations.zig"); +const model = @import("../../config/model.zig"); +const context = @import("context.zig"); + +const IdMap = context.IdMap; +const InsertContext = context.InsertContext; + +// --------------------------------------------------------------------------- +// groups +// --------------------------------------------------------------------------- + +/// Every string in the result is a heap copy owned by `gpa`; free the whole +/// list with `freeGroups` and then `deinit` the list itself. +pub fn listGroups(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Group) { + var stmt = try database.prepare("SELECT name, safe_search FROM groups ORDER BY name"); + defer stmt.deinit(); + + var out: std.ArrayList(model.Group) = .empty; + // Order matters: `errdefer`s run in reverse, so `freeGroups` must be + // declared *after* `deinit` to run *before* it. The other order reads + // `out.items` after the backing array is gone. + errdefer out.deinit(gpa); + errdefer freeGroups(gpa, out.items); + + 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) }); + } + return out; +} + +pub fn freeGroups(gpa: Allocator, items: []const model.Group) void { + for (items) |item| gpa.free(item.name); +} + +pub fn insertGroup(database: *db.Db, item: model.Group, ctx: InsertContext) db.Error!void { + _ = ctx; + var stmt = try database.prepare("INSERT INTO groups (name, safe_search) VALUES (?1, ?2)"); + defer stmt.deinit(); + try stmt.bindText(1, item.name); + try stmt.bindBool(2, item.safe_search); + try stmt.exec(); +} + +pub fn deleteAllGroups(database: *db.Db) db.Error!void { + return database.exec("DELETE FROM groups;"); +} + +pub fn countGroups(database: *db.Db) db.Error!i64 { + return database.queryInt("SELECT count(*) FROM groups"); +} + +// --------------------------------------------------------------------------- +// group_sources +// --------------------------------------------------------------------------- + +const list_group_sources_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 +; + +/// The two foreign keys are `NOT NULL` and enforced, so the join is total: a +/// `group_sources` row can never be dropped by it. +pub fn listGroupSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.GroupSource) { + var stmt = try database.prepare(list_group_sources_sql); + defer stmt.deinit(); + + var out: std.ArrayList(model.GroupSource) = .empty; + errdefer out.deinit(gpa); + errdefer freeGroupSources(gpa, out.items); + + while (try stmt.step()) { + const group = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(group); + const source_url = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(source_url); + try out.append(gpa, .{ .group = group, .source_url = source_url }); + } + return out; +} + +pub fn freeGroupSources(gpa: Allocator, items: []const model.GroupSource) void { + for (items) |item| { + gpa.free(item.group); + gpa.free(item.source_url); + } +} + +pub fn insertGroupSource(database: *db.Db, item: model.GroupSource, ctx: InsertContext) db.Error!void { + const group_id = try ctx.groupId(item.group); + const source_id = try ctx.sourceId(item.source_url); + + var stmt = try database.prepare("INSERT INTO group_sources (group_id, source_id) VALUES (?1, ?2)"); + defer stmt.deinit(); + try stmt.bindInt(1, group_id); + try stmt.bindInt(2, source_id); + try stmt.exec(); +} + +pub fn deleteAllGroupSources(database: *db.Db) db.Error!void { + return database.exec("DELETE FROM group_sources;"); +} + +pub fn countGroupSources(database: *db.Db) db.Error!i64 { + return database.queryInt("SELECT count(*) FROM group_sources"); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +fn openMigrated() !db.Db { + var database = try db.Db.open(":memory:", .{ .mode = .memory }); + errdefer database.close(); + try db.applyPragmas(&database, .{}); + _ = try migrations.migrate(&database); + return database; +} + +/// Migration step 1 seeds `(1, 'default')`, so a migrated database already holds +/// one group and one known id. +fn defaultGroupIds() !IdMap { + var ids: IdMap = .empty; + errdefer ids.deinit(testing.allocator); + try ids.put(testing.allocator, "default", 1); + return ids; +} + +fn seedGroups(database: *db.Db) !void { + const ctx: InsertContext = .{}; + try insertGroup(database, .{ .name = "kids", .safe_search = true }, ctx); + try insertGroup(database, .{ .name = "zeta" }, ctx); + try insertGroup(database, .{ .name = "alpha", .safe_search = true }, ctx); +} + +fn seedGroupSources(database: *db.Db, ids: *const IdMap) !void { + try database.exec( + \\INSERT INTO blocklist_sources (id, url, name) VALUES + \\ (1, 'https://b.example/list.txt', 'B'), + \\ (2, 'https://a.example/list.txt', 'A'); + ); + var sources: IdMap = .empty; + defer sources.deinit(testing.allocator); + try sources.put(testing.allocator, "https://b.example/list.txt", 1); + try sources.put(testing.allocator, "https://a.example/list.txt", 2); + + const ctx: InsertContext = .{ .group_ids = ids, .source_ids = &sources }; + try insertGroupSource(database, .{ .group = "default", .source_url = "https://b.example/list.txt" }, ctx); + try insertGroupSource(database, .{ .group = "default", .source_url = "https://a.example/list.txt" }, ctx); +} + +test "groups round-trip in name order" { + var database = try openMigrated(); + defer database.close(); + try seedGroups(&database); + + var items = try listGroups(&database, testing.allocator); + defer items.deinit(testing.allocator); + defer freeGroups(testing.allocator, items.items); + + // The seeded `default` group sorts between `alpha` and `kids`. + try testing.expectEqual(@as(usize, 4), items.items.len); + try testing.expectEqualStrings("alpha", items.items[0].name); + try testing.expect(items.items[0].safe_search); + try testing.expectEqualStrings("default", items.items[1].name); + try testing.expect(!items.items[1].safe_search); + try testing.expectEqualStrings("kids", items.items[2].name); + try testing.expect(items.items[2].safe_search); + try testing.expectEqualStrings("zeta", items.items[3].name); + try testing.expect(!items.items[3].safe_search); +} + +test "deleteAllGroups empties the table and countGroups reflects it" { + var database = try openMigrated(); + defer database.close(); + try seedGroups(&database); + + try testing.expectEqual(@as(i64, 4), try countGroups(&database)); + try deleteAllGroups(&database); + try testing.expectEqual(@as(i64, 0), try countGroups(&database)); + + var items = try listGroups(&database, testing.allocator); + defer items.deinit(testing.allocator); + defer freeGroups(testing.allocator, items.items); + try testing.expectEqual(@as(usize, 0), items.items.len); +} + +fn listGroupsUnderFailure(gpa: Allocator) !void { + var database = try openMigrated(); + defer database.close(); + try seedGroups(&database); + + var items = try listGroups(&database, gpa); + defer items.deinit(gpa); + defer freeGroups(gpa, items.items); +} + +test "listGroups is leak-safe under allocation failure" { + try testing.checkAllAllocationFailures(testing.allocator, listGroupsUnderFailure, .{}); +} + +test "listGroupSources yields names, not ids" { + var database = try openMigrated(); + defer database.close(); + var ids = try defaultGroupIds(); + defer ids.deinit(testing.allocator); + try seedGroupSources(&database, &ids); + + var items = try listGroupSources(&database, testing.allocator); + defer items.deinit(testing.allocator); + defer freeGroupSources(testing.allocator, items.items); + + try testing.expectEqual(@as(usize, 2), items.items.len); + try testing.expectEqualStrings("default", items.items[0].group); + try testing.expectEqualStrings("https://a.example/list.txt", items.items[0].source_url); + try testing.expectEqualStrings("default", items.items[1].group); + try testing.expectEqualStrings("https://b.example/list.txt", items.items[1].source_url); +} + +test "deleteAllGroupSources empties the table and countGroupSources reflects it" { + var database = try openMigrated(); + defer database.close(); + var ids = try defaultGroupIds(); + defer ids.deinit(testing.allocator); + try seedGroupSources(&database, &ids); + + try testing.expectEqual(@as(i64, 2), try countGroupSources(&database)); + try deleteAllGroupSources(&database); + try testing.expectEqual(@as(i64, 0), try countGroupSources(&database)); +} + +test "insertGroupSource reports an id the caller's map does not hold" { + var database = try openMigrated(); + defer database.close(); + const ctx: InsertContext = .{}; + try testing.expectError( + error.NotFound, + insertGroupSource(&database, .{ .group = "kids", .source_url = "https://a.example/list.txt" }, ctx), + ); +} + +fn listGroupSourcesUnderFailure(gpa: Allocator, ids: *const IdMap) !void { + var database = try openMigrated(); + defer database.close(); + try seedGroupSources(&database, ids); + + var items = try listGroupSources(&database, gpa); + defer items.deinit(gpa); + defer freeGroupSources(gpa, items.items); +} + +test "listGroupSources is leak-safe under allocation failure" { + var ids = try defaultGroupIds(); + defer ids.deinit(testing.allocator); + try testing.checkAllAllocationFailures(testing.allocator, listGroupSourcesUnderFailure, .{&ids}); +} diff --git a/src/storage/repositories/local_repo.zig b/src/storage/repositories/local_repo.zig new file mode 100644 index 0000000..20f7e8a --- /dev/null +++ b/src/storage/repositories/local_repo.zig @@ -0,0 +1,255 @@ +//! `local_records` and `forward_zones`. +//! +//! Only list / insert / deleteAll / count exist. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const db = @import("../db.zig"); +const migrations = @import("../migrations.zig"); +const model = @import("../../config/model.zig"); +const context = @import("context.zig"); + +const InsertContext = context.InsertContext; + +// --------------------------------------------------------------------------- +// local_records +// --------------------------------------------------------------------------- + +const list_local_records_sql = + \\SELECT name, rtype, value, ttl FROM local_records ORDER BY name, rtype, value +; + +/// Every string in the result is a heap copy owned by `gpa`. +pub fn listLocalRecords(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.LocalRecord) { + var stmt = try database.prepare(list_local_records_sql); + defer stmt.deinit(); + + var out: std.ArrayList(model.LocalRecord) = .empty; + // `errdefer`s run in reverse: the free pass is declared last so it runs + // before the backing array is released. + errdefer out.deinit(gpa); + errdefer freeLocalRecords(gpa, out.items); + + while (try stmt.step()) { + const name = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(name); + const value = try stmt.columnTextAlloc(gpa, 2); + errdefer gpa.free(value); + // The DDL's CHECK constraint makes the decode total for any row nxdns + // wrote; `error.Mismatch` covers a row that something else wrote. + const rtype = model.RecordType.fromDb(stmt.columnText(1)) orelse return error.Mismatch; + const ttl = std.math.cast(u32, stmt.columnInt(3)) orelse return error.Mismatch; + try out.append(gpa, .{ .name = name, .rtype = rtype, .value = value, .ttl = ttl }); + } + return out; +} + +pub fn freeLocalRecords(gpa: Allocator, items: []const model.LocalRecord) void { + for (items) |item| { + gpa.free(item.name); + gpa.free(item.value); + } +} + +const insert_local_record_sql = + \\INSERT INTO local_records (name, rtype, value, ttl) VALUES (?1, ?2, ?3, ?4) +; + +pub fn insertLocalRecord(database: *db.Db, item: model.LocalRecord, ctx: InsertContext) db.Error!void { + _ = ctx; + var stmt = try database.prepare(insert_local_record_sql); + defer stmt.deinit(); + try stmt.bindText(1, item.name); + try stmt.bindText(2, item.rtype.toDb()); + try stmt.bindText(3, item.value); + try stmt.bindInt(4, item.ttl); + try stmt.exec(); +} + +pub fn deleteAllLocalRecords(database: *db.Db) db.Error!void { + return database.exec("DELETE FROM local_records;"); +} + +pub fn countLocalRecords(database: *db.Db) db.Error!i64 { + return database.queryInt("SELECT count(*) FROM local_records"); +} + +// --------------------------------------------------------------------------- +// forward_zones +// --------------------------------------------------------------------------- + +pub fn listForwardZones(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ForwardZone) { + var stmt = try database.prepare("SELECT zone, resolver FROM forward_zones ORDER BY zone"); + defer stmt.deinit(); + + var out: std.ArrayList(model.ForwardZone) = .empty; + errdefer out.deinit(gpa); + errdefer freeForwardZones(gpa, out.items); + + while (try stmt.step()) { + const zone = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(zone); + const resolver = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(resolver); + try out.append(gpa, .{ .zone = zone, .resolver = resolver }); + } + return out; +} + +pub fn freeForwardZones(gpa: Allocator, items: []const model.ForwardZone) void { + for (items) |item| { + gpa.free(item.zone); + gpa.free(item.resolver); + } +} + +pub fn insertForwardZone(database: *db.Db, item: model.ForwardZone, ctx: InsertContext) db.Error!void { + _ = ctx; + var stmt = try database.prepare("INSERT INTO forward_zones (zone, resolver) VALUES (?1, ?2)"); + defer stmt.deinit(); + try stmt.bindText(1, item.zone); + try stmt.bindText(2, item.resolver); + try stmt.exec(); +} + +pub fn deleteAllForwardZones(database: *db.Db) db.Error!void { + return database.exec("DELETE FROM forward_zones;"); +} + +pub fn countForwardZones(database: *db.Db) db.Error!i64 { + return database.queryInt("SELECT count(*) FROM forward_zones"); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +fn openMigrated() !db.Db { + var database = try db.Db.open(":memory:", .{ .mode = .memory }); + errdefer database.close(); + try db.applyPragmas(&database, .{}); + _ = try migrations.migrate(&database); + return database; +} + +fn seedLocalRecords(database: *db.Db) !void { + const ctx: InsertContext = .{}; + try insertLocalRecord(database, .{ + .name = "nas.home.arpa", + .rtype = .aaaa, + .value = "fd00::5", + .ttl = 60, + }, ctx); + try insertLocalRecord(database, .{ + .name = "nas.home.arpa", + .rtype = .a, + .value = "192.168.1.5", + }, ctx); + try insertLocalRecord(database, .{ + .name = "alias.home.arpa", + .rtype = .cname, + .value = "nas.home.arpa", + .ttl = 120, + }, ctx); +} + +fn seedForwardZones(database: *db.Db) !void { + const ctx: InsertContext = .{}; + try insertForwardZone(database, .{ .zone = "work.example", .resolver = "udp://10.0.0.1:53" }, ctx); + try insertForwardZone(database, .{ .zone = "home.arpa", .resolver = "udp://192.168.1.1:53" }, ctx); + try insertForwardZone(database, .{ .zone = "lab.example", .resolver = "tcp://[fd00::1]:53" }, ctx); +} + +test "local_records round-trip in name, rtype, value order" { + var database = try openMigrated(); + defer database.close(); + try seedLocalRecords(&database); + + var items = try listLocalRecords(&database, testing.allocator); + defer items.deinit(testing.allocator); + defer freeLocalRecords(testing.allocator, items.items); + + // `rtype` is compared as stored text, so 'A' sorts before 'AAAA'. + try testing.expectEqual(@as(usize, 3), items.items.len); + try testing.expectEqualStrings("alias.home.arpa", items.items[0].name); + try testing.expectEqual(model.RecordType.cname, items.items[0].rtype); + try testing.expectEqualStrings("nas.home.arpa", items.items[0].value); + try testing.expectEqual(@as(u32, 120), items.items[0].ttl); + try testing.expectEqualStrings("nas.home.arpa", items.items[1].name); + try testing.expectEqual(model.RecordType.a, items.items[1].rtype); + try testing.expectEqualStrings("192.168.1.5", items.items[1].value); + try testing.expectEqual(@as(u32, 300), items.items[1].ttl); + try testing.expectEqualStrings("nas.home.arpa", items.items[2].name); + try testing.expectEqual(model.RecordType.aaaa, items.items[2].rtype); + try testing.expectEqualStrings("fd00::5", items.items[2].value); + try testing.expectEqual(@as(u32, 60), items.items[2].ttl); +} + +test "deleteAllLocalRecords empties the table and countLocalRecords reflects it" { + var database = try openMigrated(); + defer database.close(); + try seedLocalRecords(&database); + + try testing.expectEqual(@as(i64, 3), try countLocalRecords(&database)); + try deleteAllLocalRecords(&database); + try testing.expectEqual(@as(i64, 0), try countLocalRecords(&database)); +} + +fn listLocalRecordsUnderFailure(gpa: Allocator) !void { + var database = try openMigrated(); + defer database.close(); + try seedLocalRecords(&database); + + var items = try listLocalRecords(&database, gpa); + defer items.deinit(gpa); + defer freeLocalRecords(gpa, items.items); +} + +test "listLocalRecords is leak-safe under allocation failure" { + try testing.checkAllAllocationFailures(testing.allocator, listLocalRecordsUnderFailure, .{}); +} + +test "forward_zones round-trip in zone order" { + var database = try openMigrated(); + defer database.close(); + try seedForwardZones(&database); + + var items = try listForwardZones(&database, testing.allocator); + defer items.deinit(testing.allocator); + defer freeForwardZones(testing.allocator, items.items); + + try testing.expectEqual(@as(usize, 3), items.items.len); + try testing.expectEqualStrings("home.arpa", items.items[0].zone); + try testing.expectEqualStrings("udp://192.168.1.1:53", items.items[0].resolver); + try testing.expectEqualStrings("lab.example", items.items[1].zone); + try testing.expectEqualStrings("tcp://[fd00::1]:53", items.items[1].resolver); + try testing.expectEqualStrings("work.example", items.items[2].zone); + try testing.expectEqualStrings("udp://10.0.0.1:53", items.items[2].resolver); +} + +test "deleteAllForwardZones empties the table and countForwardZones reflects it" { + var database = try openMigrated(); + defer database.close(); + try seedForwardZones(&database); + + try testing.expectEqual(@as(i64, 3), try countForwardZones(&database)); + try deleteAllForwardZones(&database); + try testing.expectEqual(@as(i64, 0), try countForwardZones(&database)); +} + +fn listForwardZonesUnderFailure(gpa: Allocator) !void { + var database = try openMigrated(); + defer database.close(); + try seedForwardZones(&database); + + var items = try listForwardZones(&database, gpa); + defer items.deinit(gpa); + defer freeForwardZones(gpa, items.items); +} + +test "listForwardZones is leak-safe under allocation failure" { + try testing.checkAllAllocationFailures(testing.allocator, listForwardZonesUnderFailure, .{}); +} diff --git a/src/storage/repositories/rules_repo.zig b/src/storage/repositories/rules_repo.zig new file mode 100644 index 0000000..cf1f889 --- /dev/null +++ b/src/storage/repositories/rules_repo.zig @@ -0,0 +1,294 @@ +//! `rules`. +//! +//! `rules` carries no `UNIQUE` constraint, so duplicate rules are legal. The +//! list therefore ends its `ORDER BY` with `id`, which is the only column that +//! makes the order — and so an export — deterministic. +//! +//! The list leads with the group *name*, not `group_id`. Ids are assigned by the +//! database and permute when a config is imported into a fresh database, so an +//! order that led with `group_id` would reorder the rules of an export → +//! import → export cycle. The name is the value the export emits, and it is the +//! same in both databases. The trailing `id` is stable for the same reason the +//! order as a whole is: `import` inserts the rules in export order, so the new +//! ids ascend in exactly the order this statement produced. +//! +//! Only list / insert / deleteAll / count exist. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const db = @import("../db.zig"); +const migrations = @import("../migrations.zig"); +const model = @import("../../config/model.zig"); +const context = @import("context.zig"); +const groups_repo = @import("groups_repo.zig"); + +const IdMap = context.IdMap; +const InsertContext = context.InsertContext; + +const list_sql = + \\SELECT g.name, r.pattern, r.kind, r.action FROM rules r + \\ JOIN groups g ON g.id = r.group_id + \\ ORDER BY g.name, r.kind, r.action, r.pattern, r.id +; + +/// Every string in the result is a heap copy owned by `gpa`. +pub fn listRules(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Rule) { + var stmt = try database.prepare(list_sql); + defer stmt.deinit(); + + var out: std.ArrayList(model.Rule) = .empty; + // `errdefer`s run in reverse: the free pass is declared last so it runs + // before the backing array is released. + errdefer out.deinit(gpa); + errdefer freeRules(gpa, out.items); + + while (try stmt.step()) { + const group = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(group); + const pattern = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(pattern); + // The DDL's CHECK constraints make both decodes total for any row nxdns + // wrote; `error.Mismatch` covers a row that something else wrote. + const kind = model.RuleKind.fromDb(stmt.columnText(2)) orelse return error.Mismatch; + const action = model.RuleAction.fromDb(stmt.columnText(3)) orelse return error.Mismatch; + try out.append(gpa, .{ .group = group, .pattern = pattern, .kind = kind, .action = action }); + } + return out; +} + +pub fn freeRules(gpa: Allocator, items: []const model.Rule) void { + for (items) |item| { + gpa.free(item.group); + gpa.free(item.pattern); + } +} + +const insert_sql = + \\INSERT INTO rules (group_id, pattern, kind, action, created_at) VALUES (?1, ?2, ?3, ?4, ?5) +; + +pub fn insertRule(database: *db.Db, item: model.Rule, ctx: InsertContext) db.Error!void { + const group_id = try ctx.groupId(item.group); + + var stmt = try database.prepare(insert_sql); + defer stmt.deinit(); + try stmt.bindInt(1, group_id); + try stmt.bindText(2, item.pattern); + try stmt.bindText(3, item.kind.toDb()); + try stmt.bindText(4, item.action.toDb()); + try stmt.bindInt(5, ctx.now); + try stmt.exec(); +} + +pub fn deleteAllRules(database: *db.Db) db.Error!void { + return database.exec("DELETE FROM rules;"); +} + +pub fn countRules(database: *db.Db) db.Error!i64 { + return database.queryInt("SELECT count(*) FROM rules"); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +fn openMigrated() !db.Db { + var database = try db.Db.open(":memory:", .{ .mode = .memory }); + errdefer database.close(); + try db.applyPragmas(&database, .{}); + _ = try migrations.migrate(&database); + return database; +} + +fn seedGroupIds() !IdMap { + var ids: IdMap = .empty; + errdefer ids.deinit(testing.allocator); + try ids.put(testing.allocator, "default", 1); + return ids; +} + +fn seedRules(database: *db.Db, ids: *const IdMap) !void { + const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids }; + try insertRule(database, .{ + .group = "default", + .pattern = "*.ads.example", + .kind = .wildcard, + .action = .block, + }, ctx); + try insertRule(database, .{ + .group = "default", + .pattern = "tracker.example", + .kind = .exact, + .action = .block, + }, ctx); + try insertRule(database, .{ + .group = "default", + .pattern = "allowed.example", + .kind = .exact, + .action = .allow, + }, ctx); +} + +test "rules round-trip in group, kind, action, pattern, id order" { + var database = try openMigrated(); + defer database.close(); + var ids = try seedGroupIds(); + defer ids.deinit(testing.allocator); + try seedRules(&database, &ids); + + var items = try listRules(&database, testing.allocator); + defer items.deinit(testing.allocator); + defer freeRules(testing.allocator, items.items); + + // One group, so `kind` leads: 'exact' before 'wildcard'; inside 'exact', + // 'allow' before 'block'. + try testing.expectEqual(@as(usize, 3), items.items.len); + try testing.expectEqualStrings("allowed.example", items.items[0].pattern); + try testing.expectEqual(model.RuleKind.exact, items.items[0].kind); + try testing.expectEqual(model.RuleAction.allow, items.items[0].action); + try testing.expectEqualStrings("default", items.items[0].group); + try testing.expectEqualStrings("tracker.example", items.items[1].pattern); + try testing.expectEqual(model.RuleKind.exact, items.items[1].kind); + try testing.expectEqual(model.RuleAction.block, items.items[1].action); + try testing.expectEqualStrings("*.ads.example", items.items[2].pattern); + try testing.expectEqual(model.RuleKind.wildcard, items.items[2].kind); + try testing.expectEqual(model.RuleAction.block, items.items[2].action); + + try testing.expectEqual( + @as(i64, 1700000000), + try database.queryInt("SELECT created_at FROM rules WHERE pattern = 'tracker.example'"), + ); +} + +test "a duplicate rule is accepted and stays deterministically ordered by id" { + var database = try openMigrated(); + defer database.close(); + var ids = try seedGroupIds(); + defer ids.deinit(testing.allocator); + + const ctx: InsertContext = .{ .now = 1, .group_ids = &ids }; + const rule: model.Rule = .{ + .group = "default", + .pattern = "dup.example", + .kind = .exact, + .action = .block, + }; + try insertRule(&database, rule, ctx); + try insertRule(&database, rule, ctx); + + var items = try listRules(&database, testing.allocator); + defer items.deinit(testing.allocator); + defer freeRules(testing.allocator, items.items); + try testing.expectEqual(@as(usize, 2), items.items.len); + try testing.expectEqualStrings("dup.example", items.items[0].pattern); + try testing.expectEqualStrings("dup.example", items.items[1].pattern); +} + +/// Inserts `names` in the given order and returns the ids the database assigned. +fn seedGroupsInOrder(database: *db.Db, names: []const []const u8) !IdMap { + var ids: IdMap = .empty; + errdefer ids.deinit(testing.allocator); + for (names) |name| { + try groups_repo.insertGroup(database, .{ .name = name }, .{}); + try ids.put(testing.allocator, name, database.lastInsertRowid()); + } + return ids; +} + +fn seedCrossGroupRules(database: *db.Db, ids: *const IdMap) !void { + const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids }; + try insertRule(database, .{ + .group = "zeta", + .pattern = "z.example", + .kind = .exact, + .action = .block, + }, ctx); + try insertRule(database, .{ + .group = "alpha", + .pattern = "a.example", + .kind = .exact, + .action = .block, + }, ctx); +} + +test "list order does not depend on which id each group received" { + // Two databases hold the same rules under the same group names, but the + // groups were inserted in opposite orders, so every group id differs. This + // is what an export → import → export cycle does to the ids. + var first = try openMigrated(); + defer first.close(); + var first_ids = try seedGroupsInOrder(&first, &.{ "zeta", "alpha" }); + defer first_ids.deinit(testing.allocator); + try seedCrossGroupRules(&first, &first_ids); + + var second = try openMigrated(); + defer second.close(); + var second_ids = try seedGroupsInOrder(&second, &.{ "alpha", "zeta" }); + defer second_ids.deinit(testing.allocator); + try seedCrossGroupRules(&second, &second_ids); + + try testing.expect(first_ids.get("alpha").? != second_ids.get("alpha").?); + + var a = try listRules(&first, testing.allocator); + defer a.deinit(testing.allocator); + defer freeRules(testing.allocator, a.items); + var b = try listRules(&second, testing.allocator); + defer b.deinit(testing.allocator); + defer freeRules(testing.allocator, b.items); + + try testing.expectEqual(@as(usize, 2), a.items.len); + try testing.expectEqual(a.items.len, b.items.len); + for (a.items, b.items) |x, y| { + try testing.expectEqualStrings(x.group, y.group); + try testing.expectEqualStrings(x.pattern, y.pattern); + } + + // And the sequence is the group names in order, not the insertion order. + try testing.expectEqualStrings("alpha", a.items[0].group); + try testing.expectEqualStrings("a.example", a.items[0].pattern); + try testing.expectEqualStrings("zeta", a.items[1].group); + try testing.expectEqualStrings("z.example", a.items[1].pattern); +} + +test "deleteAllRules empties the table and countRules reflects it" { + var database = try openMigrated(); + defer database.close(); + var ids = try seedGroupIds(); + defer ids.deinit(testing.allocator); + try seedRules(&database, &ids); + + try testing.expectEqual(@as(i64, 3), try countRules(&database)); + try deleteAllRules(&database); + try testing.expectEqual(@as(i64, 0), try countRules(&database)); +} + +test "insertRule reports a group the caller's map does not hold" { + var database = try openMigrated(); + defer database.close(); + const ctx: InsertContext = .{}; + try testing.expectError(error.NotFound, insertRule(&database, .{ + .group = "kids", + .pattern = "x.example", + .kind = .exact, + .action = .block, + }, ctx)); +} + +fn listRulesUnderFailure(gpa: Allocator, ids: *const IdMap) !void { + var database = try openMigrated(); + defer database.close(); + try seedRules(&database, ids); + + var items = try listRules(&database, gpa); + defer items.deinit(gpa); + defer freeRules(gpa, items.items); +} + +test "listRules is leak-safe under allocation failure" { + var ids = try seedGroupIds(); + defer ids.deinit(testing.allocator); + try testing.checkAllAllocationFailures(testing.allocator, listRulesUnderFailure, .{&ids}); +} diff --git a/src/storage/repositories/settings_repo.zig b/src/storage/repositories/settings_repo.zig new file mode 100644 index 0000000..5c96c88 --- /dev/null +++ b/src/storage/repositories/settings_repo.zig @@ -0,0 +1,145 @@ +//! `settings`. +//! +//! The row type is `model.SettingPair`, the same type `model.toSettings` and +//! `model.fromSettings` speak, so the scalar sections cross the storage boundary +//! without a second shape. +//! +//! Only list / insert / deleteAll / count exist. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const db = @import("../db.zig"); +const migrations = @import("../migrations.zig"); +const model = @import("../../config/model.zig"); +const context = @import("context.zig"); + +const InsertContext = context.InsertContext; + +/// Both strings of every pair are heap copies owned by `gpa`. +pub fn listSettings(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.SettingPair) { + var stmt = try database.prepare("SELECT key, value FROM settings ORDER BY key"); + defer stmt.deinit(); + + var out: std.ArrayList(model.SettingPair) = .empty; + // `errdefer`s run in reverse: the free pass is declared last so it runs + // before the backing array is released. + errdefer out.deinit(gpa); + errdefer freeSettings(gpa, out.items); + + while (try stmt.step()) { + const key = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(key); + const value = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(value); + try out.append(gpa, .{ .key = key, .value = value }); + } + return out; +} + +/// Only for lists `listSettings` produced. `model.toSettings` builds pairs whose +/// `key` is a comptime string and must never be freed; that list is the caller's +/// to release, field by field. +pub fn freeSettings(gpa: Allocator, items: []const model.SettingPair) void { + for (items) |item| { + gpa.free(item.key); + gpa.free(item.value); + } +} + +pub fn insertSetting(database: *db.Db, item: model.SettingPair, ctx: InsertContext) db.Error!void { + _ = ctx; + var stmt = try database.prepare("INSERT INTO settings (key, value) VALUES (?1, ?2)"); + defer stmt.deinit(); + try stmt.bindText(1, item.key); + try stmt.bindText(2, item.value); + try stmt.exec(); +} + +pub fn deleteAllSettings(database: *db.Db) db.Error!void { + return database.exec("DELETE FROM settings;"); +} + +pub fn countSettings(database: *db.Db) db.Error!i64 { + return database.queryInt("SELECT count(*) FROM settings"); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +fn openMigrated() !db.Db { + var database = try db.Db.open(":memory:", .{ .mode = .memory }); + errdefer database.close(); + try db.applyPragmas(&database, .{}); + _ = try migrations.migrate(&database); + return database; +} + +fn seedSettings(database: *db.Db) !void { + const ctx: InsertContext = .{}; + try insertSetting(database, .{ .key = "web.port", .value = "8080" }, ctx); + try insertSetting(database, .{ .key = "dns.port", .value = "53" }, ctx); + // An apostrophe proves the value is bound, not concatenated into the SQL. + try insertSetting(database, .{ .key = "logging.file_path", .value = "/var/log/o'brien.log" }, ctx); +} + +test "settings round-trip in ascending key order" { + var database = try openMigrated(); + defer database.close(); + try seedSettings(&database); + + var items = try listSettings(&database, testing.allocator); + defer items.deinit(testing.allocator); + defer freeSettings(testing.allocator, items.items); + + try testing.expectEqual(@as(usize, 3), items.items.len); + try testing.expectEqualStrings("dns.port", items.items[0].key); + try testing.expectEqualStrings("53", items.items[0].value); + try testing.expectEqualStrings("logging.file_path", items.items[1].key); + try testing.expectEqualStrings("/var/log/o'brien.log", items.items[1].value); + try testing.expectEqualStrings("web.port", items.items[2].key); + try testing.expectEqualStrings("8080", items.items[2].value); +} + +test "a value holding an apostrophe survives the round trip" { + var database = try openMigrated(); + defer database.close(); + const ctx: InsertContext = .{}; + const value = "he said 'hello'; DROP TABLE settings;--"; + try insertSetting(&database, .{ .key = "web.password_hash", .value = value }, ctx); + + var items = try listSettings(&database, testing.allocator); + defer items.deinit(testing.allocator); + defer freeSettings(testing.allocator, items.items); + + try testing.expectEqual(@as(usize, 1), items.items.len); + try testing.expectEqualStrings(value, items.items[0].value); + try testing.expectEqual(@as(i64, 1), try countSettings(&database)); +} + +test "deleteAllSettings empties the table and countSettings reflects it" { + var database = try openMigrated(); + defer database.close(); + try seedSettings(&database); + + try testing.expectEqual(@as(i64, 3), try countSettings(&database)); + try deleteAllSettings(&database); + try testing.expectEqual(@as(i64, 0), try countSettings(&database)); +} + +fn listSettingsUnderFailure(gpa: Allocator) !void { + var database = try openMigrated(); + defer database.close(); + try seedSettings(&database); + + var items = try listSettings(&database, gpa); + defer items.deinit(gpa); + defer freeSettings(gpa, items.items); +} + +test "listSettings is leak-safe under allocation failure" { + try testing.checkAllAllocationFailures(testing.allocator, listSettingsUnderFailure, .{}); +} diff --git a/src/storage/repositories/sources_repo.zig b/src/storage/repositories/sources_repo.zig new file mode 100644 index 0000000..cff4867 --- /dev/null +++ b/src/storage/repositories/sources_repo.zig @@ -0,0 +1,177 @@ +//! `blocklist_sources`. +//! +//! Only the four configuration columns are read and written. `last_updated`, +//! `domain_count`, `wildcard_count`, `skipped_regex_count` and `checksum` are +//! facts a running server produces; an insert leaves them at their column +//! defaults so two exports taken minutes apart stay identical. +//! +//! Only list / insert / deleteAll / count exist. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const db = @import("../db.zig"); +const migrations = @import("../migrations.zig"); +const model = @import("../../config/model.zig"); +const context = @import("context.zig"); + +const InsertContext = context.InsertContext; + +const list_sql = + \\SELECT url, name, enabled, is_suggested FROM blocklist_sources ORDER BY url +; + +/// Every string in the result is a heap copy owned by `gpa`. +pub fn listBlocklistSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.BlocklistSource) { + var stmt = try database.prepare(list_sql); + defer stmt.deinit(); + + var out: std.ArrayList(model.BlocklistSource) = .empty; + // `errdefer`s run in reverse: the free pass is declared last so it runs + // before the backing array is released. + errdefer out.deinit(gpa); + errdefer freeBlocklistSources(gpa, out.items); + + while (try stmt.step()) { + const url = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(url); + const name = try stmt.columnTextAlloc(gpa, 1); + errdefer gpa.free(name); + try out.append(gpa, .{ + .url = url, + .name = name, + .enabled = stmt.columnBool(2), + .is_suggested = stmt.columnBool(3), + }); + } + return out; +} + +pub fn freeBlocklistSources(gpa: Allocator, items: []const model.BlocklistSource) void { + for (items) |item| { + gpa.free(item.url); + gpa.free(item.name); + } +} + +const insert_sql = + \\INSERT INTO blocklist_sources (url, name, enabled, is_suggested) VALUES (?1, ?2, ?3, ?4) +; + +pub fn insertBlocklistSource(database: *db.Db, item: model.BlocklistSource, ctx: InsertContext) db.Error!void { + _ = ctx; + var stmt = try database.prepare(insert_sql); + defer stmt.deinit(); + try stmt.bindText(1, item.url); + try stmt.bindText(2, item.name); + try stmt.bindBool(3, item.enabled); + try stmt.bindBool(4, item.is_suggested); + try stmt.exec(); +} + +pub fn deleteAllBlocklistSources(database: *db.Db) db.Error!void { + return database.exec("DELETE FROM blocklist_sources;"); +} + +pub fn countBlocklistSources(database: *db.Db) db.Error!i64 { + return database.queryInt("SELECT count(*) FROM blocklist_sources"); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +fn openMigrated() !db.Db { + var database = try db.Db.open(":memory:", .{ .mode = .memory }); + errdefer database.close(); + try db.applyPragmas(&database, .{}); + _ = try migrations.migrate(&database); + return database; +} + +fn seedSources(database: *db.Db) !void { + const ctx: InsertContext = .{}; + try insertBlocklistSource(database, .{ + .url = "https://c.example/list.txt", + .name = "C list", + }, ctx); + try insertBlocklistSource(database, .{ + .url = "https://a.example/list.txt", + .name = "A list", + .enabled = false, + }, ctx); + try insertBlocklistSource(database, .{ + .url = "https://b.example/list.txt", + .name = "B list", + .is_suggested = true, + }, ctx); +} + +test "blocklist_sources round-trip in url order" { + var database = try openMigrated(); + defer database.close(); + try seedSources(&database); + + var items = try listBlocklistSources(&database, testing.allocator); + defer items.deinit(testing.allocator); + defer freeBlocklistSources(testing.allocator, items.items); + + try testing.expectEqual(@as(usize, 3), items.items.len); + try testing.expectEqualStrings("https://a.example/list.txt", items.items[0].url); + try testing.expectEqualStrings("A list", items.items[0].name); + try testing.expect(!items.items[0].enabled); + try testing.expect(!items.items[0].is_suggested); + try testing.expectEqualStrings("https://b.example/list.txt", items.items[1].url); + try testing.expectEqualStrings("B list", items.items[1].name); + try testing.expect(items.items[1].enabled); + try testing.expect(items.items[1].is_suggested); + try testing.expectEqualStrings("https://c.example/list.txt", items.items[2].url); + try testing.expectEqualStrings("C list", items.items[2].name); + try testing.expect(items.items[2].enabled); + try testing.expect(!items.items[2].is_suggested); +} + +test "insertBlocklistSource leaves the runtime columns at their defaults" { + var database = try openMigrated(); + defer database.close(); + try seedSources(&database); + + try testing.expectEqual( + @as(i64, 3), + try database.queryInt("SELECT count(*) FROM blocklist_sources WHERE last_updated IS NULL"), + ); + try testing.expectEqual( + @as(i64, 3), + try database.queryInt("SELECT count(*) FROM blocklist_sources WHERE checksum IS NULL"), + ); + try testing.expectEqual( + @as(i64, 0), + try database.queryInt("SELECT sum(domain_count + wildcard_count + skipped_regex_count) FROM blocklist_sources"), + ); +} + +test "deleteAllBlocklistSources empties the table and countBlocklistSources reflects it" { + var database = try openMigrated(); + defer database.close(); + try seedSources(&database); + + try testing.expectEqual(@as(i64, 3), try countBlocklistSources(&database)); + try deleteAllBlocklistSources(&database); + try testing.expectEqual(@as(i64, 0), try countBlocklistSources(&database)); +} + +fn listBlocklistSourcesUnderFailure(gpa: Allocator) !void { + var database = try openMigrated(); + defer database.close(); + try seedSources(&database); + + var items = try listBlocklistSources(&database, gpa); + defer items.deinit(gpa); + defer freeBlocklistSources(gpa, items.items); +} + +test "listBlocklistSources is leak-safe under allocation failure" { + try testing.checkAllAllocationFailures(testing.allocator, listBlocklistSourcesUnderFailure, .{}); +} diff --git a/src/storage/repositories/upstreams_repo.zig b/src/storage/repositories/upstreams_repo.zig new file mode 100644 index 0000000..7095a85 --- /dev/null +++ b/src/storage/repositories/upstreams_repo.zig @@ -0,0 +1,130 @@ +//! `upstreams`. +//! +//! The list sorts by `priority` first because that is the operationally +//! meaningful order — it matches what `Pool.init` expects — and `url` breaks +//! ties uniquely, which is what makes an export byte-stable. +//! +//! Only list / insert / deleteAll / count exist. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const db = @import("../db.zig"); +const migrations = @import("../migrations.zig"); +const model = @import("../../config/model.zig"); +const context = @import("context.zig"); + +const InsertContext = context.InsertContext; + +/// Every string in the result is a heap copy owned by `gpa`. +pub fn listUpstreams(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.UpstreamServer) { + var stmt = try database.prepare("SELECT url, priority, enabled FROM upstreams ORDER BY priority, url"); + defer stmt.deinit(); + + var out: std.ArrayList(model.UpstreamServer) = .empty; + // `errdefer`s run in reverse: the free pass is declared last so it runs + // before the backing array is released. + errdefer out.deinit(gpa); + errdefer freeUpstreams(gpa, out.items); + + while (try stmt.step()) { + const url = try stmt.columnTextAlloc(gpa, 0); + errdefer gpa.free(url); + const priority = std.math.cast(i32, stmt.columnInt(1)) orelse return error.Mismatch; + try out.append(gpa, .{ .url = url, .priority = priority, .enabled = stmt.columnBool(2) }); + } + return out; +} + +pub fn freeUpstreams(gpa: Allocator, items: []const model.UpstreamServer) void { + for (items) |item| gpa.free(item.url); +} + +pub fn insertUpstream(database: *db.Db, item: model.UpstreamServer, ctx: InsertContext) db.Error!void { + _ = ctx; + var stmt = try database.prepare("INSERT INTO upstreams (url, priority, enabled) VALUES (?1, ?2, ?3)"); + defer stmt.deinit(); + try stmt.bindText(1, item.url); + try stmt.bindInt(2, item.priority); + try stmt.bindBool(3, item.enabled); + try stmt.exec(); +} + +pub fn deleteAllUpstreams(database: *db.Db) db.Error!void { + return database.exec("DELETE FROM upstreams;"); +} + +pub fn countUpstreams(database: *db.Db) db.Error!i64 { + return database.queryInt("SELECT count(*) FROM upstreams"); +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +fn openMigrated() !db.Db { + var database = try db.Db.open(":memory:", .{ .mode = .memory }); + errdefer database.close(); + try db.applyPragmas(&database, .{}); + _ = try migrations.migrate(&database); + return database; +} + +fn seedUpstreams(database: *db.Db) !void { + const ctx: InsertContext = .{}; + try insertUpstream(database, .{ .url = "https://dns.example/dns-query", .priority = 50 }, ctx); + try insertUpstream(database, .{ .url = "tls://1.1.1.1:853", .priority = 10, .enabled = false }, ctx); + try insertUpstream(database, .{ .url = "https://a.example/dns-query", .priority = 50 }, ctx); +} + +test "upstreams round-trip in priority then url order" { + var database = try openMigrated(); + defer database.close(); + try seedUpstreams(&database); + + var items = try listUpstreams(&database, testing.allocator); + defer items.deinit(testing.allocator); + defer freeUpstreams(testing.allocator, items.items); + + try testing.expectEqual(@as(usize, 3), items.items.len); + try testing.expectEqualStrings("tls://1.1.1.1:853", items.items[0].url); + try testing.expectEqual(@as(i32, 10), items.items[0].priority); + try testing.expect(!items.items[0].enabled); + try testing.expectEqualStrings("https://a.example/dns-query", items.items[1].url); + try testing.expectEqual(@as(i32, 50), items.items[1].priority); + try testing.expect(items.items[1].enabled); + try testing.expectEqualStrings("https://dns.example/dns-query", items.items[2].url); + try testing.expectEqual(@as(i32, 50), items.items[2].priority); + try testing.expect(items.items[2].enabled); +} + +test "deleteAllUpstreams empties the table and countUpstreams reflects it" { + var database = try openMigrated(); + defer database.close(); + try seedUpstreams(&database); + + try testing.expectEqual(@as(i64, 3), try countUpstreams(&database)); + try deleteAllUpstreams(&database); + try testing.expectEqual(@as(i64, 0), try countUpstreams(&database)); + + var items = try listUpstreams(&database, testing.allocator); + defer items.deinit(testing.allocator); + defer freeUpstreams(testing.allocator, items.items); + try testing.expectEqual(@as(usize, 0), items.items.len); +} + +fn listUpstreamsUnderFailure(gpa: Allocator) !void { + var database = try openMigrated(); + defer database.close(); + try seedUpstreams(&database); + + var items = try listUpstreams(&database, gpa); + defer items.deinit(gpa); + defer freeUpstreams(gpa, items.items); +} + +test "listUpstreams is leak-safe under allocation failure" { + try testing.checkAllAllocationFailures(testing.allocator, listUpstreamsUnderFailure, .{}); +} diff --git a/src/storage/storage_integration_test.zig b/src/storage/storage_integration_test.zig new file mode 100644 index 0000000..b69b83c --- /dev/null +++ b/src/storage/storage_integration_test.zig @@ -0,0 +1,1057 @@ +//! Milestone-4 storage integration tests (spec S7): real directories, real +//! database files, real exports. +//! +//! This lives in its own file because it needs `@import("build_options")`, which +//! only exists when the compilation is driven by `build.zig`. The body compiles +//! on every `zig build test` run, so it cannot rot, and every test skips at run +//! time unless `-Dintegration` is passed. Case 22 additionally needs `-Dlive`. +//! +//! Hermetic: every case works inside one `std.testing.tmpDir`, and no case opens +//! a socket or resolves a name. Case 22 is the single exception and it is +//! guarded separately. +//! +//! Two mechanisms resolve the same paths here. `std.Io.Dir` calls go through the +//! temporary directory handle, while SQLite resolves its filenames through its +//! own VFS, which knows nothing about directory handles. Every path handed to +//! the database layer is therefore built relative to the process working +//! directory, which is what `Fixture.root` is for. + +const std = @import("std"); +const builtin = @import("builtin"); +const build_options = @import("build_options"); +const Writer = std.Io.Writer; + +const cli = @import("../cli.zig"); +const bootstrap = @import("../config/bootstrap.zig"); +const config_export = @import("../config/export.zig"); +const import = @import("../config/import.zig"); +const model = @import("../config/model.zig"); +const validate = @import("../config/validate.zig"); +const db = @import("db.zig"); +const migrations = @import("migrations.zig"); +const querylog_schema = @import("querylog_schema.zig"); + +const testing = std.testing; + +/// `std.testing.tmpDir` creates its directory against `std.testing.io`, so every +/// call into the code under test uses the same `Io` instance. +const io = testing.io; + +// --------------------------------------------------------------------------- +// fixture +// --------------------------------------------------------------------------- + +/// Where `std.testing.tmpDir` puts its directories (`lib/std/testing.zig:634`). +const tmp_prefix = ".zig-cache/tmp/"; + +const sub_path_len = @typeInfo(@FieldType(testing.TmpDir, "sub_path")).array.len; + +const path_buf_len = 256; +const file_limit: std.Io.Limit = .limited(8 * 1024 * 1024); + +const Fixture = struct { + tmp: testing.TmpDir, + root_buf: [tmp_prefix.len + sub_path_len]u8, + + fn init() Fixture { + var self: Fixture = .{ + .tmp = testing.tmpDir(.{ .iterate = true }), + .root_buf = undefined, + }; + @memcpy(self.root_buf[0..tmp_prefix.len], tmp_prefix); + @memcpy(self.root_buf[tmp_prefix.len..], &self.tmp.sub_path); + return self; + } + + fn deinit(self: *Fixture) void { + self.tmp.cleanup(); + } + + /// The temporary directory as a path relative to the process working + /// directory. + fn root(self: *const Fixture) []const u8 { + return &self.root_buf; + } + + fn path(self: *const Fixture, buf: []u8, name: []const u8) ![]const u8 { + return std.fmt.bufPrint(buf, "{s}/{s}", .{ self.root(), name }); + } + + fn pathZ(self: *const Fixture, buf: []u8, name: []const u8) ![:0]const u8 { + return std.fmt.bufPrintZ(buf, "{s}/{s}", .{ self.root(), name }); + } + + fn write(self: *const Fixture, name: []const u8, data: []const u8) !void { + return self.tmp.dir.writeFile(io, .{ .sub_path = name, .data = data }); + } + + fn read(self: *const Fixture, name: []const u8) ![]u8 { + return self.tmp.dir.readFileAlloc(io, name, testing.allocator, file_limit); + } + + fn exists(self: *const Fixture, name: []const u8) !bool { + self.tmp.dir.access(io, name, .{}) catch |e| switch (e) { + error.FileNotFound => return false, + else => |other| return other, + }; + return true; + } +}; + +/// An open, migrated `config.db` inside the fixture, reached exactly the way the +/// CLI reaches it. +const Data = struct { + dir: cli.DataDir, + database: db.Db, + + fn deinit(self: *Data) void { + self.database.close(); + self.dir.close(io, testing.allocator); + } +}; + +fn openDataDir(f: *const Fixture, name: []const u8) !cli.DataDir { + var buf: [path_buf_len]u8 = undefined; + const dir_path = try f.path(&buf, name); + return cli.DataDir.open(io, testing.allocator, dir_path, true); +} + +fn openMigrated(f: *const Fixture, name: []const u8) !Data { + var dir = try openDataDir(f, name); + errdefer dir.close(io, testing.allocator); + + var database = try dir.openConfigDb(io); + errdefer database.close(); + _ = try migrations.migrate(&database); + + return .{ .dir = dir, .database = database }; +} + +fn importInto(f: *const Fixture, data: *Data, file: []const u8, force: bool) !void { + var diags: validate.Diagnostics = .init(testing.allocator); + defer diags.deinit(); + return import.importFile( + io, + testing.allocator, + &data.database, + f.tmp.dir, + file, + .{ .force = force }, + &diags, + ); +} + +fn expectMode(f: *const Fixture, name: []const u8, expected: std.posix.mode_t) !void { + const stat = try f.tmp.dir.statFile(io, name, .{}); + const mode = stat.permissions.toMode() & 0o777; + if (mode != expected) { + std.debug.print("mode of '{s}' is {o}, expected {o}\n", .{ name, mode, expected }); + return error.TestUnexpectedResult; + } +} + +/// Running as root defeats a permission test: root bypasses the mode bits, the +/// open succeeds and the case proves nothing. Skipping is honest; asserting +/// would be a false pass. +fn runningAsRoot() bool { + return switch (builtin.os.tag) { + .linux => std.os.linux.geteuid() == 0, + else => false, + }; +} + +const Captured = struct { + out: Writer.Allocating, + err: Writer.Allocating, + + fn init() Captured { + return .{ .out = .init(testing.allocator), .err = .init(testing.allocator) }; + } + + fn deinit(self: *Captured) void { + self.out.deinit(); + self.err.deinit(); + } + + fn runner(self: *Captured) cli.Runner { + return .{ + .io = io, + .gpa = testing.allocator, + .out = &self.out.writer, + .err = &self.err.writer, + }; + } +}; + +fn countLines(text: []const u8) usize { + return std.mem.count(u8, text, "\n"); +} + +// --------------------------------------------------------------------------- +// querylog aside files +// --------------------------------------------------------------------------- + +const aside_prefix = "querylog.db.corrupt-"; + +const Names = struct { + items: std.ArrayList([]u8), + + fn deinit(self: *Names) void { + for (self.items.items) |name| testing.allocator.free(name); + self.items.deinit(testing.allocator); + } +}; + +fn collectAsides(f: *const Fixture) !Names { + var names: Names = .{ .items = .empty }; + errdefer names.deinit(); + + var it = f.tmp.dir.iterate(); + while (try it.next(io)) |entry| { + if (!std.mem.startsWith(u8, entry.name, aside_prefix)) continue; + try names.items.append(testing.allocator, try testing.allocator.dupe(u8, entry.name)); + } + return names; +} + +/// Writes `value` into `PRAGMA user_version` without touching anything else, so +/// the file stays a healthy database that merely carries the wrong fingerprint. +fn stampUserVersion(path: [:0]const u8, value: i32) !void { + var database = try db.Db.open(path, .{ .mode = .read_write_existing }); + defer database.close(); + var buf: [64]u8 = undefined; + const sql = try std.fmt.bufPrintZ(&buf, "PRAGMA user_version = {d};", .{value}); + try database.exec(sql); +} + +fn createQuerylog(f: *const Fixture) !void { + var buf: [path_buf_len]u8 = undefined; + const path = try f.pathZ(&buf, "querylog.db"); + var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path); + result.database.close(); + try testing.expectEqual(querylog_schema.RecreateReason.missing, result.recreated.?); +} + +/// Holds `querylog.db` locked against every other connection, the way a second +/// nxdns process running on the same data directory would. +/// +/// `BEGIN EXCLUSIVE` on its own does not do this. The file is in WAL mode, where +/// one writer and any number of readers coexist by design, so the probing `open` +/// would read straight past it. `PRAGMA locking_mode = EXCLUSIVE`, set before +/// this connection touches the file, makes SQLite take an exclusive lock on the +/// file itself and keep it until the connection closes. The holder writes +/// nothing, so the bytes on disk are unchanged for the case to compare against. +const LockHolder = struct { + database: db.Db, + held: bool, + + fn take(path: [:0]const u8) !LockHolder { + var database = try db.Db.open(path, .{ .mode = .read_write_existing }); + errdefer database.close(); + try database.exec("PRAGMA locking_mode = EXCLUSIVE;"); + try database.exec("BEGIN EXCLUSIVE;"); + return .{ .database = database, .held = true }; + } + + /// Idempotent, so the case can `defer` it and still release early. A failed + /// ROLLBACK is not worth reporting here: closing the connection drops the + /// lock either way, which is the only thing this function owes the case. + fn release(self: *LockHolder) void { + if (!self.held) return; + self.held = false; + self.database.exec("ROLLBACK;") catch {}; + self.database.close(); + } +}; + +// --------------------------------------------------------------------------- +// configuration fixtures +// --------------------------------------------------------------------------- + +/// The smallest configuration that validates. +const minimal_config = + \\.{ + \\ .groups = .{ .{ .name = "default" } }, + \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, + \\} +; + +/// A second valid configuration, distinguishable from `minimal_config` by one +/// query on `upstreams`. +const other_config = + \\.{ + \\ .groups = .{ .{ .name = "default" } }, + \\ .upstreams = .{ .{ .url = "https://other.example/dns-query" } }, + \\} +; + +/// Exercises every collection and several non-default scalars, so the byte- +/// stable round trip has something to be stable about. +const rich_config = + \\.{ + \\ .dns = .{ .port = 5353 }, + \\ .logging = .{ .level = .err, .retention_days = 7 }, + \\ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaGhhc2g" }, + \\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } }, + \\ .upstreams = .{ + \\ .{ .url = "https://dns.example/dns-query", .priority = 10 }, + \\ .{ .url = "tls://dot.example:853", .priority = 20, .enabled = false }, + \\ }, + \\ .clients = .{ .{ .ip = "fd00::1", .name = "tablet", .group = "kids" } }, + \\ .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } }, + \\ .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "ads" } }, + \\ .group_sources = .{ .{ .group = "kids", .source_url = "https://lists.example/ads.txt" } }, + \\ .rules = .{ + \\ .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block }, + \\ .{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow }, + \\ }, + \\ .local_records = .{ + \\ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 600 }, + \\ .{ .name = "nas.lan", .rtype = .aaaa, .value = "fd00::10" }, + \\ }, + \\ .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.1:53" } }, + \\} +; + +/// Valid ZON, two validation problems: `dns.port` is 0 and `blocking.ttl` +/// exceeds a day. +const two_problem_config = + \\.{ + \\ .dns = .{ .port = 0 }, + \\ .blocking = .{ .ttl = 90000 }, + \\ .groups = .{ .{ .name = "default" } }, + \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, + \\} +; + +/// The same two problems as a `Config`, for the seeding path that skips the +/// validator. +const two_problem_model: model.Config = .{ + .dns = .{ .port = 0 }, + .blocking = .{ .ttl = 90000 }, + .groups = &.{.{ .name = "default" }}, + .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, +}; + +const broken_zon = ".{ .groups = "; + +const export_header_line = "// nxdns configuration\n"; + +// --------------------------------------------------------------------------- +// case 1-7 and 23: the querylog recreate policy +// --------------------------------------------------------------------------- + +test "S7 case 1: querylog open on a fresh directory creates the schema" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + + var buf: [path_buf_len]u8 = undefined; + const path = try f.pathZ(&buf, "querylog.db"); + + var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path); + defer result.database.close(); + + try testing.expectEqual(querylog_schema.RecreateReason.missing, result.recreated.?); + try testing.expectEqual( + @as(i64, querylog_schema.fingerprint), + try result.database.queryInt("PRAGMA user_version"), + ); + try testing.expectEqual( + @as(i64, 1), + try result.database.queryInt("SELECT count(*) FROM sqlite_schema WHERE name = 'domains'"), + ); + try testing.expectEqual( + @as(i64, 1), + try result.database.queryInt("SELECT count(*) FROM sqlite_schema WHERE name = 'query_log'"), + ); +} + +test "S7 case 2: reopening a healthy querylog recreates nothing" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + try createQuerylog(&f); + + var buf: [path_buf_len]u8 = undefined; + const path = try f.pathZ(&buf, "querylog.db"); + + var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path); + defer result.database.close(); + + try testing.expectEqual(@as(?querylog_schema.RecreateReason, null), result.recreated); + + var asides = try collectAsides(&f); + defer asides.deinit(); + try testing.expectEqual(@as(usize, 0), asides.items.items.len); +} + +test "S7 case 3: a wrong user_version recreates and keeps the old file aside" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + try createQuerylog(&f); + + var buf: [path_buf_len]u8 = undefined; + const path = try f.pathZ(&buf, "querylog.db"); + try stampUserVersion(path, querylog_schema.fingerprint +% 1); + + const original = try f.read("querylog.db"); + defer testing.allocator.free(original); + + var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path); + defer result.database.close(); + try testing.expectEqual( + querylog_schema.RecreateReason.fingerprint_mismatch, + result.recreated.?, + ); + + var asides = try collectAsides(&f); + defer asides.deinit(); + try testing.expectEqual(@as(usize, 1), asides.items.items.len); + + const kept = try f.read(asides.items.items[0]); + defer testing.allocator.free(kept); + try testing.expectEqualSlices(u8, original, kept); +} + +test "S7 case 4: a garbage file recreates and the garbage is preserved" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + try createQuerylog(&f); + + var garbage: [4096]u8 = undefined; + @memset(&garbage, 0xab); + try f.write("querylog.db", &garbage); + + var buf: [path_buf_len]u8 = undefined; + const path = try f.pathZ(&buf, "querylog.db"); + + var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path); + defer result.database.close(); + + const reason = result.recreated.?; + try testing.expect(reason == .not_a_database or reason == .corrupt); + + var asides = try collectAsides(&f); + defer asides.deinit(); + try testing.expectEqual(@as(usize, 1), asides.items.items.len); + + const kept = try f.read(asides.items.items[0]); + defer testing.allocator.free(kept); + try testing.expectEqualSlices(u8, &garbage, kept); +} + +test "S7 case 5: two recreates in the same second produce two distinct aside files" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + try createQuerylog(&f); + + var buf: [path_buf_len]u8 = undefined; + const path = try f.pathZ(&buf, "querylog.db"); + + var round: usize = 0; + while (round < 2) : (round += 1) { + try stampUserVersion(path, querylog_schema.fingerprint +% 1); + var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path); + defer result.database.close(); + try testing.expectEqual( + querylog_schema.RecreateReason.fingerprint_mismatch, + result.recreated.?, + ); + } + + var asides = try collectAsides(&f); + defer asides.deinit(); + try testing.expectEqual(@as(usize, 2), asides.items.items.len); + try testing.expect(!std.mem.eql(u8, asides.items.items[0], asides.items.items[1])); +} + +test "S7 case 6: a stale write-ahead log is removed before the fresh database is created" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + try createQuerylog(&f); + + var buf: [path_buf_len]u8 = undefined; + const path = try f.pathZ(&buf, "querylog.db"); + try stampUserVersion(path, querylog_schema.fingerprint +% 1); + + // Existence alone proves nothing: the fresh database turns WAL on again and + // writes its own `-wal`. The marker is what distinguishes the stale file + // from the new one. + const marker = "NXDNS-STALE-WAL-MARKER"; + try f.write("querylog.db-wal", marker ** 16); + + var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path); + defer result.database.close(); + try testing.expectEqual( + querylog_schema.RecreateReason.fingerprint_mismatch, + result.recreated.?, + ); + + if (try f.exists("querylog.db-wal")) { + const wal = try f.read("querylog.db-wal"); + defer testing.allocator.free(wal); + try testing.expectEqual(@as(usize, 0), std.mem.count(u8, wal, marker)); + } +} + +test "S7 case 7: an unreadable querylog propagates the error and is never destroyed" { + if (!build_options.integration) return error.SkipZigTest; + if (runningAsRoot()) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + try createQuerylog(&f); + + const original = try f.read("querylog.db"); + defer testing.allocator.free(original); + + var buf: [path_buf_len]u8 = undefined; + const path = try f.pathZ(&buf, "querylog.db"); + + try f.tmp.dir.setFilePermissions(io, "querylog.db", .fromMode(0o000), .{}); + const result = querylog_schema.open(io, std.Io.Dir.cwd(), path); + try f.tmp.dir.setFilePermissions(io, "querylog.db", .fromMode(0o600), .{}); + + try testing.expectError(error.CantOpen, result); + + const after = try f.read("querylog.db"); + defer testing.allocator.free(after); + try testing.expectEqualSlices(u8, original, after); + + var asides = try collectAsides(&f); + defer asides.deinit(); + try testing.expectEqual(@as(usize, 0), asides.items.items.len); +} + +test "S7 case 23: a locked querylog propagates Busy and is never destroyed" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + try createQuerylog(&f); + + const original = try f.read("querylog.db"); + defer testing.allocator.free(original); + + var buf: [path_buf_len]u8 = undefined; + const path = try f.pathZ(&buf, "querylog.db"); + + var holder = try LockHolder.take(path); + defer holder.release(); + + // The conflict surfaces inside `applyPragmas`, on `PRAGMA journal_mode = + // WAL`. `querylog_schema.open` takes `db.OpenOptions`' default 5000 ms busy + // timeout, so the call waits the timeout out before it reports the + // conflict. Waiting is the behaviour under test, and shortening it would + // mean adding a timeout knob to production code for the test's benefit, so + // this case costs about five seconds. + if (querylog_schema.open(io, std.Io.Dir.cwd(), path)) |result| { + var opened = result; + opened.database.close(); + return error.TestUnexpectedResult; + } else |e| { + try testing.expect(e == error.Busy or e == error.Locked); + } + + try testing.expect(try f.exists("querylog.db")); + + const after = try f.read("querylog.db"); + defer testing.allocator.free(after); + try testing.expectEqualSlices(u8, original, after); + + var asides = try collectAsides(&f); + defer asides.deinit(); + try testing.expectEqual(@as(usize, 0), asides.items.items.len); + + // The same file, once the lock is gone, is opened without a recreate: the + // failure above was transient and left nothing behind that would force one. + holder.release(); + + var reopened = try querylog_schema.open(io, std.Io.Dir.cwd(), path); + defer reopened.database.close(); + try testing.expectEqual(@as(?querylog_schema.RecreateReason, null), reopened.recreated); + try testing.expectEqual( + @as(i64, querylog_schema.fingerprint), + try reopened.database.queryInt("PRAGMA user_version"), + ); + + var asides_after = try collectAsides(&f); + defer asides_after.deinit(); + try testing.expectEqual(@as(usize, 0), asides_after.items.items.len); +} + +// --------------------------------------------------------------------------- +// case 8-10: config.db, permissions and the schema stamp +// --------------------------------------------------------------------------- + +test "S7 case 8: DataDir.open creates the data directory at mode 0700" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + + var data = try openDataDir(&f, "data"); + defer data.close(io, testing.allocator); + + try expectMode(&f, "data", 0o700); +} + +test "S7 case 9: config.db and its write-ahead log are mode 0600" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + + var data = try openMigrated(&f, "data"); + defer data.deinit(); + + try expectMode(&f, "data/config.db", 0o600); + if (try f.exists("data/config.db-wal")) { + try expectMode(&f, "data/config.db-wal", 0o600); + } +} + +test "S7 case 10: a config.db stamped one version ahead is refused and left alone" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + + { + var data = try openMigrated(&f, "data"); + defer data.deinit(); + + var stmt = try data.database.prepare("UPDATE schema_version SET version = ?1"); + defer stmt.deinit(); + try stmt.bindInt(1, @as(i64, migrations.target_version) + 1); + try stmt.exec(); + } + + const before = try f.read("data/config.db"); + defer testing.allocator.free(before); + + { + var dir = try openDataDir(&f, "data"); + defer dir.close(io, testing.allocator); + var database = try dir.openConfigDb(io); + defer database.close(); + try testing.expectError(error.SchemaTooNew, migrations.migrate(&database)); + } + + const after = try f.read("data/config.db"); + defer testing.allocator.free(after); + try testing.expectEqualSlices(u8, before, after); +} + +// --------------------------------------------------------------------------- +// case 11-19: export, import and bootstrap on real files +// --------------------------------------------------------------------------- + +test "S7 case 11: an exported file is mode 0600 and starts with the header comment" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + try f.write("config.zon", rich_config); + + var data = try openMigrated(&f, "data"); + defer data.deinit(); + try importInto(&f, &data, "config.zon", false); + + try config_export.writeToFile(io, testing.allocator, &data.database, f.tmp.dir, "out.zon"); + + try expectMode(&f, "out.zon", 0o600); + const text = try f.read("out.zon"); + defer testing.allocator.free(text); + try testing.expect(std.mem.startsWith(u8, text, export_header_line)); +} + +test "S7 case 12: export, import and export again are byte-identical files" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + try f.write("config.zon", rich_config); + + { + var first = try openMigrated(&f, "one"); + defer first.deinit(); + try importInto(&f, &first, "config.zon", false); + try config_export.writeToFile(io, testing.allocator, &first.database, f.tmp.dir, "a.zon"); + } + { + var second = try openMigrated(&f, "two"); + defer second.deinit(); + try importInto(&f, &second, "a.zon", false); + try config_export.writeToFile(io, testing.allocator, &second.database, f.tmp.dir, "b.zon"); + } + + const a = try f.read("a.zon"); + defer testing.allocator.free(a); + const b = try f.read("b.zon"); + defer testing.allocator.free(b); + try testing.expectEqualStrings(a, b); +} + +test "S7 case 13: import refuses a configured database unless --force is given" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + try f.write("first.zon", minimal_config); + try f.write("second.zon", other_config); + + var data = try openMigrated(&f, "data"); + defer data.deinit(); + try importInto(&f, &data, "first.zon", false); + + try testing.expectError(error.DatabaseNotEmpty, importInto(&f, &data, "second.zon", false)); + try testing.expectEqual( + @as(i64, 1), + try data.database.queryInt( + "SELECT count(*) FROM upstreams WHERE url = 'https://dns.example/dns-query'", + ), + ); + + try importInto(&f, &data, "second.zon", true); + try testing.expectEqual( + @as(i64, 1), + try data.database.queryInt( + "SELECT count(*) FROM upstreams WHERE url = 'https://other.example/dns-query'", + ), + ); + try testing.expectEqual(@as(i64, 1), try data.database.queryInt("SELECT count(*) FROM upstreams")); +} + +test "S7 case 14: an invalid import reports every problem and writes nothing" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + try f.write("config.zon", two_problem_config); + + var data = try openMigrated(&f, "data"); + defer data.deinit(); + + var diags: validate.Diagnostics = .init(testing.allocator); + defer diags.deinit(); + + if (import.importFile( + io, + testing.allocator, + &data.database, + f.tmp.dir, + "config.zon", + .{ .force = false }, + &diags, + )) |_| { + return error.TestUnexpectedResult; + } else |_| {} + + try testing.expectEqual(@as(usize, 2), diags.problems.items.len); + try testing.expect(try import.isEmpty(&data.database)); + + // Nothing beyond the database and its sidecars was created. + var dir = try f.tmp.dir.openDir(io, "data", .{ .iterate = true }); + defer dir.close(io); + var it = dir.iterate(); + while (try it.next(io)) |entry| { + try testing.expect(std.mem.startsWith(u8, entry.name, cli.config_db_name)); + } +} + +test "S7 case 15: bootstrap with no configuration file leaves the database empty" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + + var data = try openMigrated(&f, "data"); + defer data.deinit(); + + var diags: validate.Diagnostics = .init(testing.allocator); + defer diags.deinit(); + + const outcome = try bootstrap.bootstrap( + io, + testing.allocator, + &data.database, + f.tmp.dir, + "config.zon", + &diags, + ); + try testing.expectEqual(bootstrap.Outcome.no_config_file, outcome); + try testing.expect(try import.isEmpty(&data.database)); +} + +test "S7 case 16: bootstrap seeds an empty database from the configuration file" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + try f.write("config.zon", rich_config); + + var data = try openMigrated(&f, "data"); + defer data.deinit(); + + var diags: validate.Diagnostics = .init(testing.allocator); + defer diags.deinit(); + + const outcome = try bootstrap.bootstrap( + io, + testing.allocator, + &data.database, + f.tmp.dir, + "config.zon", + &diags, + ); + try testing.expectEqual(bootstrap.Outcome.seeded, outcome); + try testing.expectEqual(@as(i64, 2), try data.database.queryInt("SELECT count(*) FROM groups")); + try testing.expectEqual(@as(i64, 2), try data.database.queryInt("SELECT count(*) FROM upstreams")); + try testing.expectEqual( + @as(i64, 1), + try data.database.queryInt("SELECT count(*) FROM clients WHERE ip = 'fd00::1'"), + ); + try testing.expectEqual( + @as(i64, 5353), + try data.database.queryInt("SELECT CAST(value AS INTEGER) FROM settings WHERE key = 'dns.port'"), + ); +} + +test "S7 case 17: bootstrap on a configured database never reads the file" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + try f.write("seed.zon", minimal_config); + + var data = try openMigrated(&f, "data"); + defer data.deinit(); + try importInto(&f, &data, "seed.zon", false); + + // Unparseable on purpose: the call can only succeed if the file is never + // opened. + try f.write("config.zon", broken_zon); + + var diags: validate.Diagnostics = .init(testing.allocator); + defer diags.deinit(); + + const outcome = try bootstrap.bootstrap( + io, + testing.allocator, + &data.database, + f.tmp.dir, + "config.zon", + &diags, + ); + try testing.expectEqual(bootstrap.Outcome.db_already_configured, outcome); + try testing.expectEqual(@as(usize, 0), diags.problems.items.len); + try testing.expectEqual( + @as(i64, 1), + try data.database.queryInt( + "SELECT count(*) FROM upstreams WHERE url = 'https://dns.example/dns-query'", + ), + ); +} + +test "S7 case 18: bootstrap with an invalid configuration file fails and writes nothing" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + try f.write("config.zon", two_problem_config); + + var data = try openMigrated(&f, "data"); + defer data.deinit(); + + var diags: validate.Diagnostics = .init(testing.allocator); + defer diags.deinit(); + + if (bootstrap.bootstrap( + io, + testing.allocator, + &data.database, + f.tmp.dir, + "config.zon", + &diags, + )) |outcome| { + std.debug.print("bootstrap unexpectedly returned .{s}\n", .{@tagName(outcome)}); + return error.TestUnexpectedResult; + } else |_| {} + + try testing.expectEqual(@as(usize, 2), diags.problems.items.len); + try testing.expect(try import.isEmpty(&data.database)); +} + +test "S7 case 19: writeToFile replaces an existing file and restores mode 0600" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + try f.write("config.zon", rich_config); + + var data = try openMigrated(&f, "data"); + defer data.deinit(); + try importInto(&f, &data, "config.zon", false); + + const stale = "stale content that must not survive\n"; + try f.write("out.zon", stale); + + try config_export.writeToFile(io, testing.allocator, &data.database, f.tmp.dir, "out.zon"); + + const text = try f.read("out.zon"); + defer testing.allocator.free(text); + try testing.expectEqual(@as(usize, 0), std.mem.count(u8, text, stale)); + try testing.expect(std.mem.startsWith(u8, text, export_header_line)); + try expectMode(&f, "out.zon", 0o600); +} + +// --------------------------------------------------------------------------- +// case 20-21: the CLI entry functions end to end +// --------------------------------------------------------------------------- + +test "S7 case 20: runImport and runExport reproduce the byte-stable round trip" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + try f.write("config.zon", rich_config); + + var captured: Captured = .init(); + defer captured.deinit(); + const r = captured.runner(); + + var one_buf: [path_buf_len]u8 = undefined; + const one = try f.path(&one_buf, "one"); + var two_buf: [path_buf_len]u8 = undefined; + const two = try f.path(&two_buf, "two"); + var config_buf: [path_buf_len]u8 = undefined; + const config_path = try f.path(&config_buf, "config.zon"); + var a_buf: [path_buf_len]u8 = undefined; + const a_path = try f.path(&a_buf, "a.zon"); + var b_buf: [path_buf_len]u8 = undefined; + const b_path = try f.path(&b_buf, "b.zon"); + + try testing.expectEqual( + cli.exit_ok, + cli.runImport(r, .{ .paths = .{ .data_dir = one }, .file = config_path }), + ); + try testing.expectEqual( + cli.exit_ok, + cli.runExport(r, .{ .paths = .{ .data_dir = one }, .out = a_path }), + ); + try testing.expectEqual( + cli.exit_ok, + cli.runImport(r, .{ .paths = .{ .data_dir = two }, .file = a_path }), + ); + try testing.expectEqual( + cli.exit_ok, + cli.runExport(r, .{ .paths = .{ .data_dir = two }, .out = b_path }), + ); + try testing.expectEqualStrings("", captured.err.written()); + + const a = try f.read("a.zon"); + defer testing.allocator.free(a); + const b = try f.read("b.zon"); + defer testing.allocator.free(b); + try testing.expectEqualStrings(a, b); + try expectMode(&f, "a.zon", 0o600); + try expectMode(&f, "one", 0o700); +} + +test "S7 case 21: runCheck passes a seeded database and reports two stored problems" { + if (!build_options.integration) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + try f.write("config.zon", rich_config); + + var good_buf: [path_buf_len]u8 = undefined; + const good = try f.path(&good_buf, "good"); + var bad_buf: [path_buf_len]u8 = undefined; + const bad = try f.path(&bad_buf, "bad"); + + { + var data = try openMigrated(&f, "good"); + defer data.deinit(); + try importInto(&f, &data, "config.zon", false); + } + { + // `applyToDb` rather than an import: the validator would refuse this + // configuration, and the case needs the problems to reach the database. + var data = try openMigrated(&f, "bad"); + defer data.deinit(); + try import.applyToDb(io, testing.allocator, &data.database, two_problem_model, 42, .{}); + } + + { + var captured: Captured = .init(); + defer captured.deinit(); + try testing.expectEqual( + cli.exit_ok, + cli.runCheck(captured.runner(), .{ .paths = .{ .data_dir = good } }, false), + ); + try testing.expect(std.mem.count(u8, captured.out.written(), "OK: no problems found") == 1); + } + { + var captured: Captured = .init(); + defer captured.deinit(); + try testing.expectEqual( + cli.exit_check, + cli.runCheck(captured.runner(), .{ .paths = .{ .data_dir = bad } }, false), + ); + const text = captured.out.written(); + // One "checking database …" line plus one line per problem. + try testing.expectEqual(@as(usize, 3), countLines(text)); + try testing.expect(std.mem.count(u8, text, "dns.port:") == 1); + try testing.expect(std.mem.count(u8, text, "blocking.ttl:") == 1); + } +} + +// --------------------------------------------------------------------------- +// case 22: live +// --------------------------------------------------------------------------- + +// Leaves the machine, so it is `-Dlive` only. A failure here is an environment +// finding, not a gate (the milestone-1 and milestone-3 convention). +test "S7 case 22: runCheck probes a real upstream and prints an OK line" { + if (!build_options.live) return error.SkipZigTest; + + var f: Fixture = .init(); + defer f.deinit(); + try f.write("config.zon", + \\.{ + \\ .groups = .{ .{ .name = "default" } }, + \\ .upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } }, + \\} + ); + + var config_buf: [path_buf_len]u8 = undefined; + const config_path = try f.path(&config_buf, "config.zon"); + + var captured: Captured = .init(); + defer captured.deinit(); + + const code = cli.runCheck( + captured.runner(), + .{ .paths = .{ .config = config_path }, .config_explicit = true }, + true, + ); + try testing.expectEqual(cli.exit_ok, code); + try testing.expect(std.mem.count( + u8, + captured.out.written(), + "OK https://cloudflare-dns.com/dns-query\n", + ) == 1); +} diff --git a/src/tests.zig b/src/tests.zig index 5dca584..9de20ea 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -27,6 +27,25 @@ comptime { _ = @import("server/udp_server_integration_test.zig"); _ = @import("server/tcp_server_integration_test.zig"); _ = @import("server/resolver_integration_test.zig"); + _ = @import("storage/db.zig"); + _ = @import("config/model.zig"); + _ = @import("config/validate.zig"); + _ = @import("storage/config_schema.zig"); + _ = @import("storage/migrations.zig"); + _ = @import("storage/querylog_schema.zig"); + _ = @import("storage/repositories/context.zig"); + _ = @import("storage/repositories/groups_repo.zig"); + _ = @import("storage/repositories/clients_repo.zig"); + _ = @import("storage/repositories/upstreams_repo.zig"); + _ = @import("storage/repositories/sources_repo.zig"); + _ = @import("storage/repositories/rules_repo.zig"); + _ = @import("storage/repositories/local_repo.zig"); + _ = @import("storage/repositories/settings_repo.zig"); + _ = @import("config/export.zig"); + _ = @import("config/import.zig"); + _ = @import("config/bootstrap.zig"); + _ = @import("cli.zig"); + _ = @import("storage/storage_integration_test.zig"); } extern fn sqlite3_libversion() [*:0]const u8;