2012 lines
81 KiB
Zig
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;
|
|
}
|