//! Converges `config.db` onto a parsed, validated configuration without wiping //! it (milestone 20, rulings 3, 4 and 5). //! //! The defect this module exists to fix: `import.applyToDb` deletes and //! reinserts every row, `blocklist_sources` included, and the compiled //! blocklists are named after the source row id (`.list` / `.wild` / //! `.allow`). A configuration re-applied on every boot would therefore hand //! every source a new id, orphan every compiled file, and re-download every //! blocklist on every restart. //! //! So nothing is wiped. Every table has an identity; a row the file and the //! database agree on is **updated in place**, keeping its row id and every //! runtime column beside it — checksum, `last_updated`, counters, //! `first_seen` / `last_seen`, `created_at`. A row the file no longer declares //! is deleted. A row the file declares and the database lacks is inserted. //! //! Two properties are load-bearing, and the tests at the bottom exist for them: //! //! * **Idempotence.** Applying the same configuration twice leaves the //! database byte-identical, non-canonical addresses and plaintext passwords //! included. //! * **Writes only on difference.** A matched row whose declarative columns //! already hold the file's values is not written at all, so an unchanged //! boot moves `sqlite3_total_changes` by zero and needs no WAL headroom. A //! full SD card cannot brick a restart that a wipe-free no-op would have //! survived. //! //! Pure of `std.Io` except where argon2 needs it. No filesystem access: the //! caller has already read and validated the file. //! //! **The caller decides the commit.** `begin` opens one `BEGIN IMMEDIATE`, runs //! every pass, and returns a `Pass` with that transaction still open and the //! `Summary` already filled in. Finishing it is the caller's move: //! `Pass.commit` or `Pass.rollback`, and one of the two must run. //! //! This shape is not a convenience — it is what ruling 6 needs. `nxdns import` //! refuses a run whose diff would delete rows unless `--allow-delete` says //! otherwise, and it has to decide that *after* seeing the counts and *inside* //! the write lock that produced them. An engine that committed on its own would //! leave the gate reading a database it had already changed, which is the //! TOCTOU window `BEGIN IMMEDIATE` exists to close: //! //! ```zig //! var pass = try reconcile.begin(io, gpa, database, cfg, now, .{}); //! errdefer pass.rollback(); //! if (!allow_delete and pass.summary.anyDeletes()) { //! pass.rollback(); //! return error.DestructiveImport; //! } //! try pass.commit(); //! ``` //! //! A failure *inside* `begin` needs no cleanup: it rolls its own transaction //! back and returns the error, so there is no half-open `Pass` to handle. 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 log = std.log.scoped(.config_reconcile); /// The row id migration step 1 seeds `groups` with. `§7.2`'s fallback /// assignment and `upsertSeen` both depend on it, so the engine refuses to /// commit a pass that would move it. pub const default_group_id: i64 = 1; /// The name of that row. The engine matches it and never creates it. pub const default_group_name = "default"; /// The settings key the engine writes directly rather than through /// `model.toSettings`, and the one key its sweep never removes (ruling 4). pub const password_hash_key = "web.password_hash"; /// Holds any PHC-encoded argon2id string comfortably (import.zig's number). const hash_buf_len = 256; /// The canonical text of an IPv6 prefix, the longest value canonicalised here. const canonical_buf_len = 64; pub const Error = db.Error || error{ BadClientIp, BadClientPrefix, MissingDefaultGroup, PasswordAndHashBothSet, Canceled, }; pub const TableCounts = struct { inserted: u32 = 0, updated: u32 = 0, deleted: u32 = 0, pub fn total(self: TableCounts) u32 { return self.inserted + self.updated + self.deleted; } }; /// Which way authentication moved, so a change to it is never a silent line /// item in a count (ruling 4). pub const AuthTransition = enum { none, enabled, disabled, rotated }; pub const Summary = struct { groups: TableCounts = .{}, sources: TableCounts = .{}, clients: TableCounts = .{}, client_prefixes: TableCounts = .{}, rules: TableCounts = .{}, group_sources: TableCounts = .{}, upstreams: TableCounts = .{}, local_records: TableCounts = .{}, forward_zones: TableCounts = .{}, settings: TableCounts = .{}, auth_transition: AuthTransition = .none, /// True when the pass changed nothing at all — the answer an unchanged /// configuration must produce. pub fn isNoOp(self: Summary) bool { return self.totals().total() == 0 and self.auth_transition == .none; } /// True when any table lost a row. `import`'s diff gate (ruling 6) is this /// predicate: reconcile never deletes runtime state, but deleting a /// declarative row the file stopped declaring is still destructive. pub fn anyDeletes(self: Summary) bool { return self.totals().deleted != 0; } /// The per-table counts added together. pub fn totals(self: Summary) TableCounts { var sum: TableCounts = .{}; inline for (@typeInfo(Summary).@"struct".fields) |field| { if (field.type == TableCounts) { const counts = @field(self, field.name); sum.inserted += counts.inserted; sum.updated += counts.updated; sum.deleted += counts.deleted; } } return sum; } }; pub const Options = struct { /// Collects the settings keys this pass wrote or removed, so the startup /// summary can name them (ruling 8 — the keys, never the values). Each key /// is duplicated into the `gpa` passed to `reconcile`; the caller owns and /// frees them. Null collects nothing. changed_settings: ?*std.ArrayList([]const u8) = null, }; /// A finished set of passes whose transaction is still open, and the summary of /// what they did. Exactly one of `commit` and `rollback` must run. /// /// Nothing this holds outlives the call that produced it: the `Summary` is /// plain counts and the `Tx` addresses the caller's own `Db`, so a `Pass` is /// free to be moved or returned. pub const Pass = struct { tx: db.Tx, summary: Summary, /// Publishes every write the passes made. pub fn commit(self: *Pass) Error!void { return self.tx.commit(); } /// Discards every write the passes made, leaving the database /// byte-for-byte as it was. Safe to call twice and safe after `commit`, so /// it works as an `errdefer` beside an explicit call. pub fn rollback(self: *Pass) void { self.tx.rollback(); } }; /// Converges `database` onto `cfg` inside one `BEGIN IMMEDIATE`, and returns /// with that transaction **open** — see this file's header for why, and for the /// `import` diff-gate shape it exists to serve. /// /// `now` is the caller's wall clock in epoch seconds, used only for the runtime /// columns a newly inserted row needs (`first_seen`, `last_seen`, /// `created_at`). No existing row's timestamp is ever restamped. /// /// On failure the transaction is already rolled back and the database is /// byte-for-byte as it was; the caller has nothing to finish. pub fn begin( io: std.Io, gpa: Allocator, database: *db.Db, cfg: model.Config, now: i64, options: Options, ) Error!Pass { var tx = try db.Tx.begin(database); errdefer tx.rollback(); const summary = try runPasses(io, gpa, database, cfg, now, options); return .{ .tx = tx, .summary = summary }; } /// Every pass, in order, on a transaction the caller has already opened. fn runPasses( io: std.Io, gpa: Allocator, database: *db.Db, cfg: model.Config, now: i64, options: Options, ) Error!Summary { var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var summary: Summary = .{}; var doomed: Doomed = .{}; // --- Phase A: upserts, parents first ----------------------------------- // // The referrers below resolve group names and source urls to the ids these // two passes have just settled, so nothing here may be reordered. var group_ids: context.IdMap = .empty; var source_ids: context.IdMap = .empty; try reconcileGroups(database, arena, cfg, &group_ids, &summary.groups, &doomed); // `reconcileGroups` has already refused a database whose group 1 is not the // seeded `default`, so what is left to catch here is a file that never // declares the group at all. _ = group_ids.get(default_group_name) orelse return error.MissingDefaultGroup; try reconcileSources(database, arena, cfg, &source_ids, &summary.sources, &doomed); const ctx: context.InsertContext = .{ .now = now, .group_ids = &group_ids, .source_ids = &source_ids, }; try reconcileClients(database, arena, cfg, ctx, &summary.clients, &doomed); try reconcileClientPrefixes(database, arena, cfg, ctx, &summary.client_prefixes, &doomed); try reconcileRules(database, arena, cfg, ctx, &summary.rules, &doomed); try reconcileGroupSources(database, arena, cfg, ctx, &summary.group_sources, &doomed); try reconcileUpstreams(database, arena, cfg, ctx, &summary.upstreams, &doomed); try reconcileLocalRecords(database, arena, cfg, ctx, &summary.local_records, &doomed); try reconcileForwardZones(database, arena, cfg, ctx, &summary.forward_zones, &doomed); try reconcileSettings(io, gpa, arena, database, cfg, &summary, &doomed, options); // --- Phase B: delete what the file no longer declares, child first ------ // // In `config_schema.delete_order`. Every declarative child of a dying group // is itself absent from the file — the validator guarantees a file's rules // and prefixes name a file's groups — so the child passes have already // deleted and counted those rows by the time the parent goes. The FK // cascades on `rules`, `client_prefixes` and `group_sources` are a safety // net here, never the accountant: a cascade that fired would remove rows // this summary did not count. comptime std.debug.assert(config_schema.delete_order.len == 10); for (doomed.group_sources.items) |pair| { try groups_repo.deleteGroupSourcePair(database, pair); summary.group_sources.deleted += 1; } for (doomed.rules.items) |id| { try rules_repo.deleteRule(database, id); summary.rules.deleted += 1; } for (doomed.client_prefixes.items) |id| { try clients_repo.deleteClientPrefix(database, id); summary.client_prefixes.deleted += 1; } for (doomed.clients.items) |id| { try clients_repo.deleteClient(database, id); summary.clients.deleted += 1; } for (doomed.upstreams.items) |id| { try upstreams_repo.deleteUpstream(database, id); summary.upstreams.deleted += 1; } for (doomed.local_records.items) |id| { try local_repo.deleteLocalRecord(database, id); summary.local_records.deleted += 1; } for (doomed.forward_zones.items) |id| { try local_repo.deleteForwardZone(database, id); summary.forward_zones.deleted += 1; } for (doomed.settings.items) |key| { try settings_repo.deleteSetting(database, key); summary.settings.deleted += 1; try recordSettingsKey(gpa, options, key); } for (doomed.sources.items) |id| { try sources_repo.deleteSource(database, id); summary.sources.deleted += 1; } // Immediately before the groups pass, and only here: `clients.group_id` // carries no `ON DELETE` action, so an observed device sitting in a group // the file stopped declaring would trip the foreign key mid-transaction — // exit 1 and a restart loop, after a `check` that said the file was fine. for (doomed.groups.items) |id| { const moved = try clients_repo.reassignObservedClients(database, id, default_group_id); summary.clients.updated += moved; } for (doomed.groups.items) |id| { try groups_repo.deleteGroup(database, id); summary.groups.deleted += 1; } return summary; } /// What Phase A found in the database and the file no longer declares. Every /// list is arena-owned and holds identities, not rows: Phase B does the /// deleting, in the order the foreign keys require. const Doomed = struct { groups: std.ArrayList(i64) = .empty, sources: std.ArrayList(i64) = .empty, clients: std.ArrayList(i64) = .empty, client_prefixes: std.ArrayList(i64) = .empty, rules: std.ArrayList(i64) = .empty, group_sources: std.ArrayList(groups_repo.GroupSourcePair) = .empty, upstreams: std.ArrayList(i64) = .empty, local_records: std.ArrayList(i64) = .empty, forward_zones: std.ArrayList(i64) = .empty, settings: std.ArrayList([]const u8) = .empty, }; // --------------------------------------------------------------------------- // Phase A, table by table // --------------------------------------------------------------------------- fn reconcileGroups( database: *db.Db, arena: Allocator, cfg: model.Config, ids: *context.IdMap, counts: *TableCounts, doomed: *Doomed, ) Error!void { const rows = try groups_repo.listGroupRows(database, arena); const matched = try matchFlags(arena, rows.items.len); // The pinned row is validated before any desired group can claim it. // Migration step 1 seeds `(1, 'default')` and the API refuses to rename or // delete it, so an absent or renamed group 1 means a hand-edited database. // The engine refuses that database rather than reseeding it: recreating the // group here would hand the fallback a fresh id on any table whose rowids // have moved on, and every unassigned client would follow it silently. const pinned = pinnedDefault(rows.items) orelse { log.warn( "group {d} is absent or is not named '{s}'", .{ default_group_id, default_group_name }, ); return error.Unexpected; }; for (cfg.groups) |group| { // `default` matches the pinned row and nothing else. It is never // inserted, whatever the file declares and whatever the table holds. const found = if (std.mem.eql(u8, group.name, default_group_name)) claim(matched, pinned) else findUnmatched(rows.items, matched, group, matchGroup); if (found) |i| { const row = rows.items[i]; if (row.safe_search != group.safe_search) { try groups_repo.updateGroup(database, row.id, group); counts.updated += 1; } // The key borrows from `cfg`, which outlives the transaction. try ids.put(arena, group.name, row.id); } else { const id = try groups_repo.insertGroupRow(database, group); counts.inserted += 1; try ids.put(arena, group.name, id); } } for (rows.items, matched) |row, hit| { if (!hit) try doomed.groups.append(arena, row.id); } } fn matchGroup(row: groups_repo.GroupRow, group: model.Group) bool { return std.mem.eql(u8, row.name, group.name); } /// The index of the row the migration pinned, or null when group 1 is absent or /// carries another name. fn pinnedDefault(rows: []const groups_repo.GroupRow) ?usize { for (rows, 0..) |row, i| { if (row.id != default_group_id) continue; return if (std.mem.eql(u8, row.name, default_group_name)) i else null; } return null; } /// Takes one named row, the way `findUnmatched` takes a searched one. Null when /// something has claimed it already, which for `default` means the file /// declared the group twice. fn claim(matched: []bool, i: usize) ?usize { if (matched[i]) return null; matched[i] = true; return i; } fn reconcileSources( database: *db.Db, arena: Allocator, cfg: model.Config, ids: *context.IdMap, counts: *TableCounts, doomed: *Doomed, ) Error!void { const rows = try sources_repo.listSourceRows(database, arena); const matched = try matchFlags(arena, rows.items.len); for (cfg.blocklist_sources) |item| { if (findUnmatched(rows.items, matched, item, matchSource)) |i| { const row = rows.items[i]; // `updateSource` writes the four configuration columns only; the // checksum, the counters and `last_updated` stay where the refresh // path left them, which is the whole point of matching by url. if (!std.mem.eql(u8, row.name, item.name) or row.enabled != item.enabled or row.is_suggested != item.is_suggested) { try sources_repo.updateSource(database, row.id, item); counts.updated += 1; } try ids.put(arena, item.url, row.id); } else { const id = try sources_repo.insertSourceRow(database, item); counts.inserted += 1; try ids.put(arena, item.url, id); } } for (rows.items, matched) |row, hit| { if (!hit) try doomed.sources.append(arena, row.id); } } fn matchSource(row: sources_repo.SourceRow, item: model.BlocklistSource) bool { return std.mem.eql(u8, row.url, item.url); } /// Clients are the one table where a row can predate the configuration: the DNS /// path materialises `hand_edited = 0` rows straight from live traffic. /// /// * An observed row the file says nothing about is kept wholesale and counts /// nothing. /// * An observed row whose address the file now declares is **promoted in /// place**: name, group and the flag are written, `first_seen` and /// `last_seen` are not, and the row id survives. It counts as an update, /// because a row was written. /// * Only a formerly declared row (`hand_edited = 1`) the file dropped is /// deleted. fn reconcileClients( database: *db.Db, arena: Allocator, cfg: model.Config, ctx: context.InsertContext, counts: *TableCounts, doomed: *Doomed, ) Error!void { const rows = try clients_repo.listClientRows(database, arena); const matched = try matchFlags(arena, rows.items.len); for (cfg.clients) |client| { var buf: [canonical_buf_len]u8 = undefined; var canonical = client; canonical.ip = try canonicalIp(client.ip, &buf); const group_id = try ctx.groupId(client.group); if (findUnmatched(rows.items, matched, canonical, matchClient)) |i| { const row = rows.items[i]; if (!std.mem.eql(u8, row.name, client.name) or row.group_id != group_id or !row.hand_edited) { try clients_repo.updateClient(database, row.id, .{ .name = client.name, .group_id = group_id, }); counts.updated += 1; } } else { try clients_repo.insertClient(database, canonical, ctx); counts.inserted += 1; } } for (rows.items, matched) |row, hit| { // An observed row is runtime state. The file cannot remove it — it can // only declare an address, never un-see one — so absence from the file // is not a reason to delete it. if (!hit and row.hand_edited) try doomed.clients.append(arena, row.id); } } fn matchClient(row: clients_repo.ClientRow, client: model.Client) bool { return std.mem.eql(u8, row.ip, client.ip); } fn reconcileClientPrefixes( database: *db.Db, arena: Allocator, cfg: model.Config, ctx: context.InsertContext, counts: *TableCounts, doomed: *Doomed, ) Error!void { const rows = try clients_repo.listClientPrefixRows(database, arena); const matched = try matchFlags(arena, rows.items.len); for (cfg.client_prefixes) |entry| { var buf: [canonical_buf_len]u8 = undefined; var canonical = entry; canonical.prefix = try canonicalPrefix(entry.prefix, &buf); const group_id = try ctx.groupId(entry.group); if (findUnmatched(rows.items, matched, canonical, matchPrefix)) |i| { const row = rows.items[i]; if (row.group_id != group_id or row.priority != entry.priority) { try clients_repo.updateClientPrefix(database, row.id, .{ .prefix = canonical.prefix, .group_id = group_id, .priority = entry.priority, }); counts.updated += 1; } } else { try clients_repo.insertClientPrefix(database, canonical, ctx); counts.inserted += 1; } } for (rows.items, matched) |row, hit| { if (!hit) try doomed.client_prefixes.append(arena, row.id); } } fn matchPrefix(row: clients_repo.ClientPrefixRow, entry: model.ClientPrefix) bool { return std.mem.eql(u8, row.prefix, entry.prefix); } /// `rules` has no natural key — nothing distinguishes two identical rules — so /// the identity is the whole tuple and the match is a **multiset**: exact /// duplicates pair off one for one, and a file carrying a rule twice keeps two /// rows with two `created_at` stamps. `findUnmatched` consuming its match is /// what makes that work. fn reconcileRules( database: *db.Db, arena: Allocator, cfg: model.Config, ctx: context.InsertContext, counts: *TableCounts, doomed: *Doomed, ) Error!void { const rows = try rules_repo.listRuleRows(database, arena); const matched = try matchFlags(arena, rows.items.len); for (cfg.rules) |rule| { const wanted: rules_repo.RuleInput = .{ .group_id = try ctx.groupId(rule.group), .pattern = rule.pattern, .kind = rule.kind, .action = rule.action, }; if (findUnmatched(rows.items, matched, wanted, matchRule)) |_| { // The tuple *is* the declarative content, so a match is never a // write and `created_at` never moves. } else { _ = try rules_repo.insertRuleRow(database, wanted, ctx.now); counts.inserted += 1; } } for (rows.items, matched) |row, hit| { if (!hit) try doomed.rules.append(arena, row.id); } } fn matchRule(row: rules_repo.RuleRow, wanted: rules_repo.RuleInput) bool { return row.group_id == wanted.group_id and row.kind == wanted.kind and row.action == wanted.action and std.mem.eql(u8, row.pattern, wanted.pattern); } /// The pair is the whole row, so this table has no update case at all. fn reconcileGroupSources( database: *db.Db, arena: Allocator, cfg: model.Config, ctx: context.InsertContext, counts: *TableCounts, doomed: *Doomed, ) Error!void { const rows = try groups_repo.listGroupSourcePairs(database, arena); const matched = try matchFlags(arena, rows.items.len); for (cfg.group_sources) |item| { const wanted: groups_repo.GroupSourcePair = .{ .group_id = try ctx.groupId(item.group), .source_id = try ctx.sourceId(item.source_url), }; if (findUnmatched(rows.items, matched, wanted, matchGroupSource) == null) { try groups_repo.insertGroupSource(database, item, ctx); counts.inserted += 1; } } for (rows.items, matched) |row, hit| { if (!hit) try doomed.group_sources.append(arena, row); } } fn matchGroupSource(row: groups_repo.GroupSourcePair, wanted: groups_repo.GroupSourcePair) bool { return row.group_id == wanted.group_id and row.source_id == wanted.source_id; } fn reconcileUpstreams( database: *db.Db, arena: Allocator, cfg: model.Config, ctx: context.InsertContext, counts: *TableCounts, doomed: *Doomed, ) Error!void { const rows = try upstreams_repo.listUpstreamRows(database, arena); const matched = try matchFlags(arena, rows.items.len); for (cfg.upstreams) |item| { if (findUnmatched(rows.items, matched, item, matchUpstream)) |i| { const row = rows.items[i]; if (row.priority != item.priority or row.enabled != item.enabled or !std.mem.eql(u8, row.tls_name, item.tls_name)) { try upstreams_repo.updateUpstream(database, row.id, item); counts.updated += 1; } } else { try upstreams_repo.insertUpstream(database, item, ctx); counts.inserted += 1; } } for (rows.items, matched) |row, hit| { if (!hit) try doomed.upstreams.append(arena, row.id); } } fn matchUpstream(row: upstreams_repo.UpstreamRow, item: model.UpstreamServer) bool { return std.mem.eql(u8, row.url, item.url); } fn reconcileLocalRecords( database: *db.Db, arena: Allocator, cfg: model.Config, ctx: context.InsertContext, counts: *TableCounts, doomed: *Doomed, ) Error!void { const rows = try local_repo.listLocalRecordRows(database, arena); const matched = try matchFlags(arena, rows.items.len); for (cfg.local_records) |item| { if (findUnmatched(rows.items, matched, item, matchLocalRecord)) |i| { const row = rows.items[i]; if (row.ttl != item.ttl) { try local_repo.updateLocalRecord(database, row.id, item); counts.updated += 1; } } else { try local_repo.insertLocalRecord(database, item, ctx); counts.inserted += 1; } } for (rows.items, matched) |row, hit| { if (!hit) try doomed.local_records.append(arena, row.id); } } /// `UNIQUE(name, rtype, value)` — the DDL's own key, so the ttl is the only /// declarative column an edit can touch. fn matchLocalRecord(row: local_repo.LocalRecordRow, item: model.LocalRecord) bool { return row.rtype == item.rtype and std.mem.eql(u8, row.name, item.name) and std.mem.eql(u8, row.value, item.value); } fn reconcileForwardZones( database: *db.Db, arena: Allocator, cfg: model.Config, ctx: context.InsertContext, counts: *TableCounts, doomed: *Doomed, ) Error!void { const rows = try local_repo.listForwardZoneRows(database, arena); const matched = try matchFlags(arena, rows.items.len); for (cfg.forward_zones) |item| { if (findUnmatched(rows.items, matched, item, matchForwardZone)) |i| { const row = rows.items[i]; if (!std.mem.eql(u8, row.resolver, item.resolver)) { try local_repo.updateForwardZone(database, row.id, item); counts.updated += 1; } } else { try local_repo.insertForwardZone(database, item, ctx); counts.inserted += 1; } } for (rows.items, matched) |row, hit| { if (!hit) try doomed.forward_zones.append(arena, row.id); } } fn matchForwardZone(row: local_repo.ForwardZoneRow, item: model.ForwardZone) bool { return std.mem.eql(u8, row.zone, item.zone); } // --------------------------------------------------------------------------- // settings, and the password rule // --------------------------------------------------------------------------- fn reconcileSettings( io: std.Io, gpa: Allocator, arena: Allocator, database: *db.Db, cfg: model.Config, summary: *Summary, doomed: *Doomed, options: Options, ) Error!void { const stored = try settings_repo.listSettings(database, arena); // `toSettings` no longer emits either password field (ruling 4), so what it // produces is exactly the set of keys a configuration fully determines. var pairs: std.ArrayList(model.SettingPair) = .empty; try model.toSettings(cfg, arena, &pairs); for (pairs.items) |pair| { if (storedValue(stored.items, pair.key)) |current| { if (std.mem.eql(u8, current, pair.value)) continue; try settings_repo.putSetting(database, pair.key, pair.value); summary.settings.updated += 1; } else { try settings_repo.putSetting(database, pair.key, pair.value); summary.settings.inserted += 1; } try recordSettingsKey(gpa, options, pair.key); } try reconcilePassword(io, gpa, database, cfg, stored.items, summary, options); // The sweep. `web.password_hash` is the single exemption, because the // engine owns that row and "the file said nothing" means keep it — a // general sweep would read the file's silence as a deletion and open the // admin UI to the LAN. for (stored.items) |pair| { if (std.mem.eql(u8, pair.key, password_hash_key)) continue; if (hasKey(pairs.items, pair.key)) continue; try doomed.settings.append(arena, pair.key); } } /// Ruling 4, the whole rule in one place. Presence is the question at every /// branch, never emptiness — a file that states nothing keeps the stored hash, /// and disabling authentication takes the explicit `password_hash = ""`. fn reconcilePassword( io: std.Io, gpa: Allocator, database: *db.Db, cfg: model.Config, stored: []const model.SettingPair, summary: *Summary, options: Options, ) Error!void { if (cfg.web.password != null and cfg.web.password_hash != null) { // `validate` rejects this first on every path an operator can reach. // The engine repeats the check because it must never guess which of two // contradictory security settings was meant. return error.PasswordAndHashBothSet; } const current = storedValue(stored, password_hash_key); var hash_buf: [hash_buf_len]u8 = undefined; const desired: ?[]const u8 = desired: { // A hash is written verbatim, `""` included: that is the documented way // to turn authentication off. if (cfg.web.password_hash) |hash| break :desired hash; if (cfg.web.password) |plain| { // Verify against the stored hash and keep it on a match. Not a cost // saving — argon2 verification recomputes the whole function with // the stored salt and costs exactly what hashing costs — but the // only way the file stays idempotent, since hashing generates a // fresh salt every time. Never replace this with a cached-plaintext // comparison; that would be a security bug. if (current) |value| { if (value.len != 0 and try verifyKeeps(io, gpa, value, plain)) break :desired value; } break :desired try hashPassword(io, gpa, plain, &hash_buf); } // Silence means keep. break :desired null; }; const was_on = if (current) |value| value.len != 0 else false; const now_on = if (desired) |value| value.len != 0 else was_on; if (desired) |value| { if (current) |value_before| { if (!std.mem.eql(u8, value_before, value)) { try settings_repo.putSetting(database, password_hash_key, value); summary.settings.updated += 1; try recordSettingsKey(gpa, options, password_hash_key); if (was_on and now_on) summary.auth_transition = .rotated; } } else { try settings_repo.putSetting(database, password_hash_key, value); summary.settings.inserted += 1; try recordSettingsKey(gpa, options, password_hash_key); } } if (was_on != now_on) summary.auth_transition = if (now_on) .enabled else .disabled; } fn storedValue(pairs: []const model.SettingPair, key: []const u8) ?[]const u8 { for (pairs) |pair| { if (std.mem.eql(u8, pair.key, key)) return pair.value; } return null; } fn hasKey(pairs: []const model.SettingPair, key: []const u8) bool { return storedValue(pairs, key) != null; } fn recordSettingsKey(gpa: Allocator, options: Options, key: []const u8) Error!void { const list = options.changed_settings orelse return; const copy = try gpa.dupe(u8, key); errdefer gpa.free(copy); try list.append(gpa, copy); } /// True when `plain` is the password behind `stored`, so the stored hash may be /// kept as it is. /// /// A stored PHC string this build cannot read is not a match: the file's /// password is the authority and a fresh hash replaces the unreadable one. The /// reason is logged at `warn` — never the password, never the hash. fn verifyKeeps(io: std.Io, gpa: Allocator, stored: []const u8, plain: []const u8) Error!bool { std.crypto.pwhash.argon2.strVerify(stored, plain, .{ .allocator = gpa }, io) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.Canceled => return error.Canceled, error.PasswordVerificationFailed => return false, else => { log.warn("the stored web.password_hash could not be verified ({s}); " ++ "hashing the configured password instead", .{@errorName(e)}); return false; }, }; return true; } /// argon2id with the OWASP parameters (t=2, m=19 MiB, p=1) — the same shape /// `import` and `PUT /api/settings` produce, so a hash cannot say where it came /// from. fn hashPassword(io: std.Io, gpa: Allocator, plain: []const u8, buf: []u8) Error![]const u8 { return std.crypto.pwhash.argon2.strHash(plain, .{ .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; }, }; } // --------------------------------------------------------------------------- // matching // --------------------------------------------------------------------------- fn matchFlags(arena: Allocator, len: usize) Allocator.Error![]bool { const flags = try arena.alloc(bool, len); @memset(flags, false); return flags; } /// The first row `equals` accepts that no earlier file entry has already /// claimed, marked as claimed on the way out. /// /// Consuming the match is what makes `rules` a multiset rather than a set: two /// identical file entries take two rows, and a third would insert. For every /// other table the identity is unique, so the consumption is invisible. fn findUnmatched( rows: anytype, matched: []bool, wanted: anytype, comptime equals: fn (@typeInfo(@TypeOf(rows)).pointer.child, @TypeOf(wanted)) bool, ) ?usize { for (rows, 0..) |row, i| { if (matched[i]) continue; if (equals(row, wanted)) { matched[i] = true; return i; } } return null; } /// The validator compares client addresses after canonicalisation and the /// column stores the canonical text, so the file's value is canonicalised /// *before* it is matched. Matching the raw file string against the canonical /// column would churn row ids under an unchanged non-canonical file, which is /// exactly what ruling 5 forbids — and an exported-config test could never /// catch it, because an export emits canonical forms. 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(); } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const testing = std.testing; const migrations = @import("../storage/migrations.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; } /// 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; inline for (config_schema.table_names) |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) { // The type marker is what makes "equal dumps" mean "equal // content": `columnText` renders SQL NULL as the empty string, // so without it a nulled `clients.name` and an emptied one // would compare equal and ruling 5 would be proved by a dump // that cannot see the difference. switch (db.c.sqlite3_column_type(rows.handle, col)) { db.column_type.null_value => try w.writeAll(" null"), db.column_type.integer => try w.print(" i:{d}", .{rows.columnInt(col)}), db.column_type.blob => try w.print(" b:{s}", .{rows.columnText(col)}), // Text, and float, which no config column declares. else => try w.print(" t:{s}", .{rows.columnText(col)}), } } try w.writeAll("\n"); } } return out.toOwnedSlice(); } /// Parses `source` and converges the database onto it, the way `run --config` /// will. Validation runs first, exactly as it does in production, so a test /// config that could never reach the engine fails here rather than silently /// exercising an unreachable path. fn applyText( io: std.Io, database: *db.Db, arena: Allocator, source: [:0]const u8, now: i64, ) !Summary { const cfg = try std.zon.parse.fromSliceAlloc(model.Config, arena, source, null, .{}); var diags: validate.Diagnostics = .init(testing.allocator); defer diags.deinit(); try validate.validate(cfg, &diags); var pass = try begin(io, testing.allocator, database, cfg, now, .{}); errdefer pass.rollback(); try pass.commit(); return pass.summary; } const minimal_source: [:0]const u8 = \\.{ \\ .groups = .{ .{ .name = "default" } }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\} ; /// Exercises every collection, with a non-canonical client address and a /// plaintext password: the two inputs a naive engine churns on. const full_source: [:0]const u8 = \\.{ \\ .dns = .{ .port = 5353 }, \\ .web = .{ .password = "correct horse battery staple" }, \\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } }, \\ .upstreams = .{ \\ .{ .url = "https://dns.example/dns-query", .priority = 10 }, \\ .{ .url = "tls://192.0.2.53:853", .priority = 20, .enabled = false, .tls_name = "dot.example" }, \\ }, \\ .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 }, \\ .{ .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 } }, \\ .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.1:53" } }, \\} ; const Bench = struct { threaded: std.Io.Threaded, arena_state: std.heap.ArenaAllocator, database: db.Db, fn init(self: *Bench) !void { self.threaded = .init(testing.allocator, .{}); self.arena_state = .init(testing.allocator); self.database = try openMigrated(); } fn deinit(self: *Bench) void { self.database.close(); self.arena_state.deinit(); self.threaded.deinit(); } fn io(self: *Bench) std.Io { return self.threaded.io(); } fn arena(self: *Bench) Allocator { return self.arena_state.allocator(); } fn apply(self: *Bench, source: [:0]const u8, now: i64) !Summary { return applyText(self.io(), &self.database, self.arena(), source, now); } }; /// Seeds the runtime columns of every source, the way a completed refresh /// would, so a test can prove the engine preserved them. fn seedSourceStats(database: *db.Db, id: i64) !void { return sources_repo.updateSourceStats(database, id, .{ .last_updated = 1_700_000_000, .domain_count = 4321, .wildcard_count = 21, .exception_count = 9, .skipped_regex_count = 7, .skipped_unsupported_count = 33, .checksum = "a" ** 64, }); } test "reconciling the same configuration twice is byte-identical and writes nothing" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); const gpa = testing.allocator; // A plaintext password and a non-canonical v6 address are both in // `full_source`: hashing generates a fresh salt per call and matching a raw // file string against a canonical column churns row ids, so a naive engine // fails this test on either input alone. _ = try bench.apply(full_source, 1_700_000_000); const before = try dump(&bench.database, gpa); defer gpa.free(before); const changes_before = bench.database.totalChanges(); // A later clock, so anything that restamped a timestamp would show. const summary = try bench.apply(full_source, 1_800_000_000); const after = try dump(&bench.database, gpa); defer gpa.free(after); try testing.expectEqualStrings(before, after); try testing.expect(summary.isNoOp()); try testing.expectEqual(AuthTransition.none, summary.auth_transition); // Stronger than byte-equality: an UPDATE that rewrote identical values // would leave the dump equal and still move this counter. try testing.expectEqual(changes_before, bench.database.totalChanges()); } test "a source keeps its id, its checksum and its counters across a reconcile" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); const gpa = testing.allocator; _ = try bench.apply(full_source, 1_700_000_000); var seeded = try sources_repo.listSourceRows(&bench.database, gpa); defer seeded.deinit(gpa); defer sources_repo.freeSourceRows(gpa, seeded.items); try testing.expectEqual(@as(usize, 1), seeded.items.len); const id = seeded.items[0].id; try seedSourceStats(&bench.database, id); // The name changes, which is a declarative edit; the url does not, so the // identity holds and the compiled `.list` stays valid. const renamed: [:0]const u8 = \\.{ \\ .groups = .{ .{ .name = "default" } }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\ .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "advertising" } }, \\} ; const summary = try bench.apply(renamed, 1_800_000_000); try testing.expectEqual(@as(u32, 1), summary.sources.updated); try testing.expectEqual(@as(u32, 0), summary.sources.deleted); try testing.expectEqual(@as(u32, 0), summary.sources.inserted); const row = (try sources_repo.getSource(&bench.database, gpa, id)).?; defer sources_repo.freeSourceRow(gpa, row); try testing.expectEqualStrings("advertising", row.name); try testing.expectEqual(@as(?i64, 1_700_000_000), row.last_updated); try testing.expectEqual(@as(i64, 4321), row.domain_count); try testing.expectEqual(@as(i64, 9), row.exception_count); try testing.expectEqual(@as(i64, 7), row.skipped_regex_count); try testing.expectEqual(@as(i64, 33), row.skipped_unsupported_count); try testing.expectEqualStrings("a" ** 64, row.checksum.?); } test "changing a source url is a new identity: new id, no runtime state" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); const gpa = testing.allocator; _ = try bench.apply(full_source, 1_700_000_000); const old_id = try bench.database.queryInt( "SELECT id FROM blocklist_sources WHERE url = 'https://lists.example/ads.txt'", ); try seedSourceStats(&bench.database, old_id); const moved: [:0]const u8 = \\.{ \\ .groups = .{ .{ .name = "default" } }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\ .blocklist_sources = .{ .{ .url = "https://lists.example/ads-v2.txt", .name = "ads" } }, \\} ; const summary = try bench.apply(moved, 1_800_000_000); try testing.expectEqual(@as(u32, 1), summary.sources.inserted); try testing.expectEqual(@as(u32, 1), summary.sources.deleted); var rows = try sources_repo.listSourceRows(&bench.database, gpa); defer rows.deinit(gpa); defer sources_repo.freeSourceRows(gpa, rows.items); try testing.expectEqual(@as(usize, 1), rows.items.len); try testing.expect(rows.items[0].id != old_id); // A fresh row means a fresh download, which is the accepted trade: the // compiled artefacts are named after the id, so they cannot follow a url. try testing.expectEqual(@as(?i64, null), rows.items[0].last_updated); try testing.expectEqual(@as(?[]const u8, null), rows.items[0].checksum); } test "a source the file stops declaring is removed" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); _ = try bench.apply(full_source, 1_700_000_000); const summary = try bench.apply(minimal_source, 1_800_000_000); try testing.expectEqual(@as(u32, 1), summary.sources.deleted); try testing.expectEqual(@as(i64, 0), try sources_repo.countBlocklistSources(&bench.database)); // The assignment went with it, counted by its own pass rather than left to // the ON DELETE CASCADE. try testing.expectEqual(@as(u32, 1), summary.group_sources.deleted); } test "an observed client survives a reconcile that never mentions it" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); try clients_repo.upsertSeen(&bench.database, "192.168.1.5", 1_700_000_000); try clients_repo.upsertSeen(&bench.database, "192.168.1.5", 1_700_000_100); const summary = try bench.apply(minimal_source, 1_800_000_000); try testing.expectEqual(@as(u32, 0), summary.clients.deleted); try testing.expectEqual(@as(u32, 0), summary.clients.updated); var stmt = try bench.database.prepare( "SELECT hand_edited, first_seen, last_seen FROM clients WHERE ip = '192.168.1.5'", ); defer stmt.deinit(); try testing.expect(try stmt.step()); try testing.expectEqual(@as(i64, 0), stmt.columnInt(0)); try testing.expectEqual(@as(i64, 1_700_000_000), stmt.columnInt(1)); try testing.expectEqual(@as(i64, 1_700_000_100), stmt.columnInt(2)); } test "declaring an observed address promotes the row in place" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); // Seen twice, so the two timestamps differ and no assertion below can pass // by carrying one column into the other. `full_source` names this address, // in a non-canonical spelling. try clients_repo.upsertSeen(&bench.database, "fd00::1", 1_700_000_200); try clients_repo.upsertSeen(&bench.database, "fd00::1", 1_700_000_900); const id_before = try bench.database.queryInt("SELECT id FROM clients WHERE ip = 'fd00::1'"); const summary = try bench.apply(full_source, 1_800_000_000); try testing.expectEqual(@as(u32, 1), summary.clients.updated); try testing.expectEqual(@as(u32, 0), summary.clients.inserted); try testing.expectEqual(@as(u32, 0), summary.clients.deleted); var stmt = try bench.database.prepare( \\SELECT c.id, c.hand_edited, c.name, g.name, c.first_seen, c.last_seen \\ FROM clients c JOIN groups g ON g.id = c.group_id WHERE c.ip = 'fd00::1' ); defer stmt.deinit(); try testing.expect(try stmt.step()); // The row id survives, so nothing keyed by it is orphaned. try testing.expectEqual(id_before, stmt.columnInt(0)); try testing.expectEqual(@as(i64, 1), stmt.columnInt(1)); try testing.expectEqualStrings("tablet", stmt.columnText(2)); try testing.expectEqualStrings("kids", stmt.columnText(3)); // The observation history is the tracker's. The reconcile clock is far // above both values, so a restamp would be unmissable. try testing.expectEqual(@as(i64, 1_700_000_200), stmt.columnInt(4)); try testing.expectEqual(@as(i64, 1_700_000_900), stmt.columnInt(5)); } test "a declared client the file drops is removed, an observed one is not" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); _ = try bench.apply(full_source, 1_700_000_000); try clients_repo.upsertSeen(&bench.database, "10.0.0.9", 1_700_000_200); const summary = try bench.apply(minimal_source, 1_800_000_000); try testing.expectEqual(@as(u32, 1), summary.clients.deleted); try testing.expectEqual(@as(i64, 0), try bench.database.queryInt( "SELECT count(*) FROM clients WHERE ip = 'fd00::1'", )); try testing.expectEqual(@as(i64, 1), try bench.database.queryInt( "SELECT count(*) FROM clients WHERE ip = '10.0.0.9' AND hand_edited = 0", )); } test "removing a group reassigns its observed clients to the default group" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); _ = try bench.apply(full_source, 1_700_000_000); // A device the DNS path materialised, moved into `kids` the way a prefix // rule or an operator would. `clients.group_id` has no ON DELETE action, so // without the reassignment the group delete below trips the foreign key, // the transaction aborts, and the box restart-loops on a file that `check` // called valid. try clients_repo.upsertSeen(&bench.database, "10.0.0.9", 1_700_000_200); const kids = (try groups_repo.groupId(&bench.database, "kids")).?; const observed = try bench.database.queryInt("SELECT id FROM clients WHERE ip = '10.0.0.9'"); try bench.database.exec("UPDATE clients SET group_id = 2 WHERE ip = '10.0.0.9';"); try testing.expectEqual(kids, try bench.database.queryInt( "SELECT group_id FROM clients WHERE ip = '10.0.0.9'", )); const summary = try bench.apply(minimal_source, 1_800_000_000); try testing.expectEqual(@as(u32, 1), summary.groups.deleted); var stmt = try bench.database.prepare( "SELECT id, group_id, hand_edited FROM clients WHERE ip = '10.0.0.9'", ); defer stmt.deinit(); try testing.expect(try stmt.step()); try testing.expectEqual(observed, stmt.columnInt(0)); try testing.expectEqual(default_group_id, stmt.columnInt(1)); // Reassignment is not a promotion: the device is still runtime state. try testing.expectEqual(@as(i64, 0), stmt.columnInt(2)); } test "renaming a group reassigns its observed clients rather than failing" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); _ = try bench.apply(full_source, 1_700_000_000); try clients_repo.upsertSeen(&bench.database, "10.0.0.9", 1_700_000_200); try bench.database.exec("UPDATE clients SET group_id = 2 WHERE ip = '10.0.0.9';"); // A rename is a delete plus an insert to an engine that matches groups by // name, so it walks into the same foreign key as a removal does. const renamed: [:0]const u8 = \\.{ \\ .groups = .{ .{ .name = "default" }, .{ .name = "children", .safe_search = true } }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\} ; const summary = try bench.apply(renamed, 1_800_000_000); try testing.expectEqual(@as(u32, 1), summary.groups.inserted); try testing.expectEqual(@as(u32, 1), summary.groups.deleted); try testing.expectEqual(default_group_id, try bench.database.queryInt( "SELECT group_id FROM clients WHERE ip = '10.0.0.9'", )); } test "editing safe_search on an existing group converges without moving its id" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); _ = try bench.apply(full_source, 1_700_000_000); const kids = (try groups_repo.groupId(&bench.database, "kids")).?; const relaxed: [:0]const u8 = \\.{ \\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = false } }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\} ; const summary = try bench.apply(relaxed, 1_800_000_000); try testing.expectEqual(@as(u32, 1), summary.groups.updated); try testing.expectEqual(@as(u32, 0), summary.groups.inserted); try testing.expectEqual(@as(u32, 0), summary.groups.deleted); try testing.expectEqual(kids, (try groups_repo.groupId(&bench.database, "kids")).?); try testing.expectEqual(@as(i64, 0), try bench.database.queryInt( "SELECT safe_search FROM groups WHERE name = 'kids'", )); } test "the default group keeps id 1 across every reconcile" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); _ = try bench.apply(full_source, 1_700_000_000); try testing.expectEqual(default_group_id, (try groups_repo.groupId(&bench.database, "default")).?); _ = try bench.apply(minimal_source, 1_800_000_000); try testing.expectEqual(default_group_id, (try groups_repo.groupId(&bench.database, "default")).?); } test "the default group keeps id 1 however late the file declares it" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); // File order is the operator's, not the engine's. A pass that inserted // groups in file order onto a fresh database — rather than matching // `default` to the row migration step 1 already seeded — would hand id 2 to // `default` here and trip the pin, so this is the ordering the engine has // to be indifferent to. const default_last: [:0]const u8 = \\.{ \\ .groups = .{ \\ .{ .name = "kids", .safe_search = true }, \\ .{ .name = "guests" }, \\ .{ .name = "default" }, \\ }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\} ; const summary = try bench.apply(default_last, 1_700_000_000); try testing.expectEqual(@as(u32, 2), summary.groups.inserted); try testing.expectEqual(@as(u32, 0), summary.groups.updated); try testing.expectEqual(default_group_id, (try groups_repo.groupId(&bench.database, "default")).?); // And the two new groups took ids of their own rather than colliding. try testing.expect((try groups_repo.groupId(&bench.database, "kids")).? != default_group_id); try testing.expect((try groups_repo.groupId(&bench.database, "guests")).? != default_group_id); // Re-applying the same file is still a no-op, which it would not be if the // first pass had settled the ids by luck. const again = try bench.apply(default_last, 1_800_000_000); try testing.expect(again.isNoOp()); } test "a database whose group 1 is not 'default' is refused, not renumbered" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); const gpa = testing.allocator; // The API refuses to rename or delete the default group and migration // step 1 seeds it, so only a hand-edited database reaches this state. It // must fail loudly: `upsertSeen` and §7.2's fallback both resolve an // unassigned client to group 1, so converging onto a file that puts // `default` somewhere else would silently move every such device. try bench.database.exec("UPDATE groups SET name = 'renamed' WHERE id = 1;"); const before = try dump(&bench.database, gpa); defer gpa.free(before); const cfg = try std.zon.parse.fromSliceAlloc(model.Config, bench.arena(), minimal_source, null, .{}); try testing.expectError( error.Unexpected, begin(bench.io(), gpa, &bench.database, cfg, 1_700_000_000, .{}), ); const after = try dump(&bench.database, gpa); defer gpa.free(after); try testing.expectEqualStrings(before, after); } test "a database with no group 1 is refused rather than reseeded" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); const gpa = testing.allocator; // The empty table is the dangerous shape, not the merely renamed one: the // file declares `default`, and an engine that treats it as an ordinary // desired group inserts it, collects id 1 from a table whose rowids start // over, and reports success while having quietly reseeded the row the // migration owns. try bench.database.exec("DELETE FROM groups;"); const cfg = try std.zon.parse.fromSliceAlloc(model.Config, bench.arena(), minimal_source, null, .{}); try testing.expectError( error.Unexpected, begin(bench.io(), gpa, &bench.database, cfg, 1_700_000_000, .{}), ); // Refused means refused: no group was created on the way out. try testing.expectEqual(@as(i64, 0), try bench.database.queryInt("SELECT count(*) FROM groups")); } test "recording a settings key frees the copy the list could not take" { // `testing.allocator` fails this test if the duped key outlives the failed // append, which is the whole assertion. var failing: std.testing.FailingAllocator = .init(testing.allocator, .{ .fail_index = 1 }); const gpa = failing.allocator(); var changed: std.ArrayList([]const u8) = .empty; defer changed.deinit(gpa); // The dupe is allocation 0 and succeeds; growing the list is allocation 1 // and does not. try testing.expectError( error.OutOfMemory, recordSettingsKey(gpa, .{ .changed_settings = &changed }, "dns.port"), ); try testing.expectEqual(@as(usize, 0), changed.items.len); } test "the dump tells SQL NULL apart from the empty string" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); const gpa = testing.allocator; // `clients.name` is nullable, so this pair of states is reachable. Ruling 5 // is proved by comparing dumps, and a dump that renders both as nothing // would call these two databases identical. _ = try bench.apply(minimal_source, 1_700_000_000); try bench.database.exec( \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen) \\VALUES ('10.0.0.5', NULL, 1, 0, 1700000000, 1700000000); , ); const with_null = try dump(&bench.database, gpa); defer gpa.free(with_null); try bench.database.exec("UPDATE clients SET name = '' WHERE ip = '10.0.0.5';"); const with_empty = try dump(&bench.database, gpa); defer gpa.free(with_empty); try testing.expect(!std.mem.eql(u8, with_null, with_empty)); } test "rules keep created_at across a reconcile, duplicates included" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); const gpa = testing.allocator; // `full_source` carries the same wildcard rule twice: the identity is a // multiset, so both rows must survive with both stamps. _ = try bench.apply(full_source, 1_700_000_000); var before = try rules_repo.listRuleRows(&bench.database, gpa); defer before.deinit(gpa); defer rules_repo.freeRuleRows(gpa, before.items); try testing.expectEqual(@as(usize, 3), before.items.len); for (before.items) |row| try testing.expectEqual(@as(i64, 1_700_000_000), row.created_at); const summary = try bench.apply(full_source, 1_800_000_000); try testing.expectEqual(@as(u32, 0), summary.rules.total()); var after = try rules_repo.listRuleRows(&bench.database, gpa); defer after.deinit(gpa); defer rules_repo.freeRuleRows(gpa, after.items); try testing.expectEqual(before.items.len, after.items.len); for (before.items, after.items) |old, new| { try testing.expectEqual(old.id, new.id); // The second pass ran with a clock 100 million seconds later. A rule // restamped by an edit-in-place would read 1_800_000_000 here. try testing.expectEqual(@as(i64, 1_700_000_000), new.created_at); } } test "a regex rule declared in the file converges into the table and back out" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); // Under `.managed_file` authority the API refuses rule writes, so this is // the only way a regex rule reaches the table in that mode. `reconcileRules` // compares the whole tuple and needs no code of its own for the new kind. const with_regex: [:0]const u8 = \\.{ \\ .groups = .{ .{ .name = "default" } }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\ .rules = .{ \\ .{ .group = "default", .pattern = "^ad[0-9]+-", .kind = .regex, .action = .block }, \\ }, \\} ; const first = try bench.apply(with_regex, 1_700_000_000); try testing.expectEqual(@as(u32, 1), first.rules.inserted); const gpa = testing.allocator; var rows = try rules_repo.listRuleRows(&bench.database, gpa); defer rows.deinit(gpa); defer rules_repo.freeRuleRows(gpa, rows.items); try testing.expectEqual(@as(usize, 1), rows.items.len); try testing.expectEqual(model.RuleKind.regex, rows.items[0].kind); try testing.expectEqualStrings("^ad[0-9]+-", rows.items[0].pattern); // Idempotent: the tuple matches itself, so a second pass writes nothing. const second = try bench.apply(with_regex, 1_800_000_000); try testing.expectEqual(@as(u32, 0), second.rules.total()); // And a file that stops declaring it takes the row with it. const without: [:0]const u8 = \\.{ \\ .groups = .{ .{ .name = "default" } }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\} ; const third = try bench.apply(without, 1_900_000_000); try testing.expectEqual(@as(u32, 1), third.rules.deleted); try testing.expectEqual(@as(i64, 0), try rules_repo.countRules(&bench.database)); } test "dropping one of two identical rules removes exactly one row" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); _ = try bench.apply(full_source, 1_700_000_000); const single: [:0]const u8 = \\.{ \\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\ .rules = .{ \\ .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block }, \\ }, \\} ; const summary = try bench.apply(single, 1_800_000_000); try testing.expectEqual(@as(u32, 2), summary.rules.deleted); try testing.expectEqual(@as(u32, 0), summary.rules.inserted); try testing.expectEqual(@as(i64, 1), try rules_repo.countRules(&bench.database)); } test "a plaintext password is hashed once and then verified and kept" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); const first = try bench.apply(full_source, 1_700_000_000); try testing.expectEqual(AuthTransition.enabled, first.auth_transition); var stmt = try bench.database.prepare("SELECT value FROM settings WHERE key = 'web.password_hash'"); defer stmt.deinit(); try testing.expect(try stmt.step()); const hash = try testing.allocator.dupe(u8, stmt.columnText(0)); defer testing.allocator.free(hash); try testing.expect(std.mem.startsWith(u8, hash, "$argon2id$")); // The plaintext is never a row. try testing.expectEqual(@as(i64, 0), try bench.database.queryInt( "SELECT count(*) FROM settings WHERE key = 'web.password'", )); // Second pass: hashing again would mint a fresh salt and a different PHC // string, so keeping the stored hash is what makes the file idempotent. const second = try bench.apply(full_source, 1_800_000_000); try testing.expectEqual(AuthTransition.none, second.auth_transition); try testing.expectEqual(@as(u32, 0), second.settings.total()); try testing.expectEqualStrings(hash, try storedHash(&bench.database, bench.arena())); } test "a changed plaintext password rehashes and reports a rotation" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); _ = try bench.apply(full_source, 1_700_000_000); const before = try testing.allocator.dupe(u8, try storedHash(&bench.database, bench.arena())); defer testing.allocator.free(before); const changed: [:0]const u8 = \\.{ \\ .web = .{ .password = "a different passphrase entirely" }, \\ .groups = .{ .{ .name = "default" } }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\} ; const summary = try bench.apply(changed, 1_800_000_000); try testing.expectEqual(AuthTransition.rotated, summary.auth_transition); const after = try storedHash(&bench.database, bench.arena()); try testing.expect(!std.mem.eql(u8, before, after)); try testing.expect(std.mem.startsWith(u8, after, "$argon2id$")); } test "a file that states no password leaves the stored hash and auth alone" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); _ = try bench.apply(full_source, 1_700_000_000); const before = try testing.allocator.dupe(u8, try storedHash(&bench.database, bench.arena())); defer testing.allocator.free(before); // The trap this rule exists for: an operator trims the ugly PHC string out // of an exported file, meaning "keep the current password". Reading that // silence as `""` would open the admin UI to the LAN. const summary = try bench.apply(minimal_source, 1_800_000_000); try testing.expectEqual(AuthTransition.none, summary.auth_transition); try testing.expectEqualStrings(before, try storedHash(&bench.database, bench.arena())); var cfg: model.Config = .{}; var unknown: usize = 0; const stored = try settings_repo.listSettings(&bench.database, bench.arena()); try model.fromSettings(stored.items, &cfg, &unknown); try testing.expect(cfg.web.password_hash != null); } test "an explicit empty password_hash disables authentication and says so" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); _ = try bench.apply(full_source, 1_700_000_000); const disabled: [:0]const u8 = \\.{ \\ .web = .{ .password_hash = "" }, \\ .groups = .{ .{ .name = "default" } }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\} ; const summary = try bench.apply(disabled, 1_800_000_000); try testing.expectEqual(AuthTransition.disabled, summary.auth_transition); try testing.expectEqualStrings("", try storedHash(&bench.database, bench.arena())); // And it stays disabled without churning the row. const again = try bench.apply(disabled, 1_900_000_000); try testing.expectEqual(AuthTransition.none, again.auth_transition); try testing.expectEqual(@as(u32, 0), again.settings.total()); } test "a password_hash the file states verbatim is written verbatim" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); const hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaGhhc2g"; const literal: [:0]const u8 = \\.{ \\ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaGhhc2g" }, \\ .groups = .{ .{ .name = "default" } }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\} ; const summary = try bench.apply(literal, 1_700_000_000); try testing.expectEqual(AuthTransition.enabled, summary.auth_transition); try testing.expectEqualStrings(hash, try storedHash(&bench.database, bench.arena())); const again = try bench.apply(literal, 1_800_000_000); try testing.expectEqual(@as(u32, 0), again.settings.total()); } test "a password and a password_hash together are refused" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); const gpa = testing.allocator; _ = try bench.apply(minimal_source, 1_700_000_000); const before = try dump(&bench.database, gpa); defer gpa.free(before); const both: 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, begin(bench.io(), gpa, &bench.database, both, 1_800_000_000, .{}), ); const after = try dump(&bench.database, gpa); defer gpa.free(after); try testing.expectEqualStrings(before, after); } test "the caller sees the delete counts before it decides, and can still refuse" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); const gpa = testing.allocator; _ = try bench.apply(full_source, 1_700_000_000); const before = try dump(&bench.database, gpa); defer gpa.free(before); // `import`'s diff gate (ruling 6), written the way R2 will write it: read // the counts, then decide. The read happens inside the same // `BEGIN IMMEDIATE` that produced them, so no other writer can change the // database between the count and the verdict. const cfg = try std.zon.parse.fromSliceAlloc(model.Config, bench.arena(), minimal_source, null, .{}); { var pass = try begin(bench.io(), gpa, &bench.database, cfg, 1_800_000_000, .{}); errdefer pass.rollback(); try testing.expect(pass.summary.anyDeletes()); try testing.expectEqual(@as(u32, 1), pass.summary.groups.deleted); pass.rollback(); } // Refusing costs the database nothing: every write the passes made on the // way to the count went with the transaction. // // The dump is the proof here, not `totalChanges`: that counter is the // connection's and counts rows a statement touched whether or not the // transaction survived, so it can only ever show that nothing was // *attempted* — which is the no-op test above, not this one. const after = try dump(&bench.database, gpa); defer gpa.free(after); try testing.expectEqualStrings(before, after); // And the same pass, allowed, applies. { var pass = try begin(bench.io(), gpa, &bench.database, cfg, 1_800_000_000, .{}); errdefer pass.rollback(); try pass.commit(); } const applied = try dump(&bench.database, gpa); defer gpa.free(applied); try testing.expect(!std.mem.eql(u8, before, applied)); try testing.expectEqual(@as(i64, 0), try sources_repo.countBlocklistSources(&bench.database)); } test "a failure mid-transaction rolls the whole pass back" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); const gpa = testing.allocator; _ = try bench.apply(full_source, 1_700_000_000); try clients_repo.upsertSeen(&bench.database, "10.0.0.9", 1_700_000_200); const before = try dump(&bench.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 bypasses it: // the all-or-nothing guarantee has to hold on its own, including for the // rows the passes before this one already wrote. 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, begin(bench.io(), gpa, &bench.database, broken, 1_800_000_000, .{}), ); const after = try dump(&bench.database, gpa); defer gpa.free(after); try testing.expectEqualStrings(before, after); } test "the summary names the settings keys that changed and never their values" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); const gpa = testing.allocator; _ = try bench.apply(full_source, 1_700_000_000); var changed: std.ArrayList([]const u8) = .empty; defer { for (changed.items) |key| gpa.free(key); changed.deinit(gpa); } const cfg = try std.zon.parse.fromSliceAlloc(model.Config, bench.arena(), \\.{ \\ .dns = .{ .port = 5300 }, \\ .groups = .{ .{ .name = "default" } }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\} , null, .{}); var pass = try begin(bench.io(), gpa, &bench.database, cfg, 1_800_000_000, .{ .changed_settings = &changed, }); errdefer pass.rollback(); try pass.commit(); const summary = pass.summary; try testing.expectEqual(@as(u32, 1), summary.settings.updated); var saw_port = false; for (changed.items) |key| { if (std.mem.eql(u8, key, "dns.port")) saw_port = true; try testing.expect(!std.mem.eql(u8, key, "web.password")); } try testing.expect(saw_port); } test "a settings key the model no longer produces is swept, and the hash is not" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); _ = try bench.apply(full_source, 1_700_000_000); // A key from a newer binary, left behind by a downgrade. try settings_repo.putSetting(&bench.database, "future.setting", "whatever"); const summary = try bench.apply(full_source, 1_800_000_000); try testing.expectEqual(@as(u32, 1), summary.settings.deleted); try testing.expectEqual(@as(i64, 0), try bench.database.queryInt( "SELECT count(*) FROM settings WHERE key = 'future.setting'", )); // The one exemption survived the sweep, and authentication with it. try testing.expectEqual(@as(i64, 1), try bench.database.queryInt( "SELECT count(*) FROM settings WHERE key = 'web.password_hash'", )); try testing.expectEqual(AuthTransition.none, summary.auth_transition); } test "reconciling an empty database from a full file inserts every table" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); const summary = try bench.apply(full_source, 1_700_000_000); // `default` is already there from migration step 1, so only `kids` is new. try testing.expectEqual(@as(u32, 1), summary.groups.inserted); try testing.expectEqual(@as(u32, 1), summary.sources.inserted); try testing.expectEqual(@as(u32, 1), summary.clients.inserted); try testing.expectEqual(@as(u32, 1), summary.client_prefixes.inserted); try testing.expectEqual(@as(u32, 3), summary.rules.inserted); try testing.expectEqual(@as(u32, 1), summary.group_sources.inserted); try testing.expectEqual(@as(u32, 2), summary.upstreams.inserted); try testing.expectEqual(@as(u32, 1), summary.local_records.inserted); try testing.expectEqual(@as(u32, 1), summary.forward_zones.inserted); try testing.expect(summary.settings.inserted > 0); try testing.expect(summary.anyDeletes() == false); // The v6 client was written canonical, not as the file spelled it. try testing.expectEqual(@as(i64, 1), try bench.database.queryInt( "SELECT count(*) FROM clients WHERE ip = 'fd00::1'", )); } test "every table loses the rows the file stopped declaring, and reports each" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); _ = try bench.apply(full_source, 1_700_000_000); const summary = try bench.apply(minimal_source, 1_800_000_000); // One assertion per table, because each delete pass is its own loop and a // missing one would otherwise leave rows behind that nothing counted. try testing.expectEqual(@as(u32, 1), summary.groups.deleted); try testing.expectEqual(@as(u32, 1), summary.sources.deleted); try testing.expectEqual(@as(u32, 1), summary.clients.deleted); try testing.expectEqual(@as(u32, 1), summary.client_prefixes.deleted); try testing.expectEqual(@as(u32, 3), summary.rules.deleted); try testing.expectEqual(@as(u32, 1), summary.group_sources.deleted); try testing.expectEqual(@as(u32, 1), summary.upstreams.deleted); try testing.expectEqual(@as(u32, 1), summary.local_records.deleted); try testing.expectEqual(@as(u32, 1), summary.forward_zones.deleted); // And the rows are actually gone, not merely counted. try testing.expectEqual(@as(i64, 1), try groups_repo.countGroups(&bench.database)); try testing.expectEqual(@as(i64, 0), try sources_repo.countBlocklistSources(&bench.database)); try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&bench.database)); try testing.expectEqual(@as(i64, 0), try clients_repo.countClientPrefixes(&bench.database)); try testing.expectEqual(@as(i64, 0), try rules_repo.countRules(&bench.database)); try testing.expectEqual(@as(i64, 0), try groups_repo.countGroupSources(&bench.database)); try testing.expectEqual(@as(i64, 1), try upstreams_repo.countUpstreams(&bench.database)); try testing.expectEqual(@as(i64, 0), try local_repo.countLocalRecords(&bench.database)); try testing.expectEqual(@as(i64, 0), try local_repo.countForwardZones(&bench.database)); } test "editing an upstream, a local record and a forward zone writes only those rows" { var bench: Bench = undefined; try bench.init(); defer bench.deinit(); _ = try bench.apply(full_source, 1_700_000_000); const edited: [:0]const u8 = \\.{ \\ .dns = .{ .port = 5353 }, \\ .web = .{ .password = "correct horse battery staple" }, \\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } }, \\ .upstreams = .{ \\ .{ .url = "https://dns.example/dns-query", .priority = 5 }, \\ .{ .url = "tls://192.0.2.53:853", .priority = 20, .enabled = false, .tls_name = "dot.example" }, \\ }, \\ .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 = 10 } }, \\ .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 = "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 = 60 } }, \\ .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.2:53" } }, \\} ; const summary = try bench.apply(edited, 1_800_000_000); try testing.expectEqual(@as(u32, 1), summary.upstreams.updated); try testing.expectEqual(@as(u32, 1), summary.client_prefixes.updated); try testing.expectEqual(@as(u32, 1), summary.local_records.updated); try testing.expectEqual(@as(u32, 1), summary.forward_zones.updated); // Everything else stayed as it was: an edit is not a rewrite of the file. try testing.expectEqual(@as(u32, 0), summary.groups.total()); try testing.expectEqual(@as(u32, 0), summary.sources.total()); try testing.expectEqual(@as(u32, 0), summary.clients.total()); try testing.expectEqual(@as(u32, 0), summary.rules.total()); try testing.expectEqual(@as(u32, 0), summary.settings.total()); try testing.expect(!summary.anyDeletes()); } fn storedHash(database: *db.Db, arena: Allocator) ![]const u8 { const pairs = try settings_repo.listSettings(database, arena); return storedValue(pairs.items, password_hash_key) orelse error.TestUnexpectedResult; }