Files
nxdns/src/config/reconcile.zig
T

2012 lines
81 KiB
Zig

//! 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 (`<id>.list` / `<id>.wild` /
//! `<id>.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 `<id>.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;
}