storage and config: sqlite wrapper, migrations, querylog policy, repositories, zon config with import/export/check cli

This commit is contained in:
2026-08-01 14:21:44 +02:00
parent 17d0401f8a
commit 70bff22d75
23 changed files with 10142 additions and 47 deletions
+336
View File
@@ -0,0 +1,336 @@
//! `clients` and `client_prefixes`.
//!
//! `listClients` returns only `hand_edited = 1` rows. A client the server
//! materialised from live traffic is runtime state, not configuration, and must
//! not appear in an export. `countClients` counts **all** rows, because S5's
//! "has this database ever been configured" predicate needs the true count.
//!
//! Only list / insert / deleteAll / count exist.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const IdMap = context.IdMap;
const InsertContext = context.InsertContext;
// ---------------------------------------------------------------------------
// clients
// ---------------------------------------------------------------------------
const list_clients_sql =
\\SELECT c.ip, c.name, g.name FROM clients c
\\ JOIN groups g ON g.id = c.group_id
\\ WHERE c.hand_edited = 1
\\ ORDER BY c.ip
;
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listClients(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Client) {
var stmt = try database.prepare(list_clients_sql);
defer stmt.deinit();
var out: std.ArrayList(model.Client) = .empty;
// `errdefer`s run in reverse: `freeClients` is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeClients(gpa, out.items);
while (try stmt.step()) {
const ip = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(ip);
// `clients.name` is nullable; `columnTextAlloc` reads NULL as "", which
// is exactly the model's default.
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
const group = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(group);
try out.append(gpa, .{ .ip = ip, .name = name, .group = group });
}
return out;
}
pub fn freeClients(gpa: Allocator, items: []const model.Client) void {
for (items) |item| {
gpa.free(item.ip);
gpa.free(item.name);
gpa.free(item.group);
}
}
const insert_client_sql =
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES (?1, ?2, ?3, 1, ?4, ?4)
;
/// `hand_edited` is 1: a client that reached a repository through the config
/// model came from an operator's file, by definition.
pub fn insertClient(database: *db.Db, item: model.Client, ctx: InsertContext) db.Error!void {
const group_id = try ctx.groupId(item.group);
var stmt = try database.prepare(insert_client_sql);
defer stmt.deinit();
try stmt.bindText(1, item.ip);
try stmt.bindText(2, item.name);
try stmt.bindInt(3, group_id);
try stmt.bindInt(4, ctx.now);
try stmt.exec();
}
pub fn deleteAllClients(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM clients;");
}
/// Counts every row, including the ones `listClients` filters out.
pub fn countClients(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM clients");
}
// ---------------------------------------------------------------------------
// client_prefixes
// ---------------------------------------------------------------------------
const list_client_prefixes_sql =
\\SELECT p.prefix, g.name, p.priority FROM client_prefixes p
\\ JOIN groups g ON g.id = p.group_id
\\ ORDER BY p.prefix
;
pub fn listClientPrefixes(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ClientPrefix) {
var stmt = try database.prepare(list_client_prefixes_sql);
defer stmt.deinit();
var out: std.ArrayList(model.ClientPrefix) = .empty;
errdefer out.deinit(gpa);
errdefer freeClientPrefixes(gpa, out.items);
while (try stmt.step()) {
const prefix = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(prefix);
const group = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(group);
// The column is a 64-bit integer; the model field is `i32`. A value
// outside that range means something other than nxdns wrote the row.
const priority = std.math.cast(i32, stmt.columnInt(2)) orelse return error.Mismatch;
try out.append(gpa, .{ .prefix = prefix, .group = group, .priority = priority });
}
return out;
}
pub fn freeClientPrefixes(gpa: Allocator, items: []const model.ClientPrefix) void {
for (items) |item| {
gpa.free(item.prefix);
gpa.free(item.group);
}
}
pub fn insertClientPrefix(database: *db.Db, item: model.ClientPrefix, ctx: InsertContext) db.Error!void {
const group_id = try ctx.groupId(item.group);
var stmt = try database.prepare("INSERT INTO client_prefixes (prefix, group_id, priority) VALUES (?1, ?2, ?3)");
defer stmt.deinit();
try stmt.bindText(1, item.prefix);
try stmt.bindInt(2, group_id);
try stmt.bindInt(3, item.priority);
try stmt.exec();
}
pub fn deleteAllClientPrefixes(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM client_prefixes;");
}
pub fn countClientPrefixes(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM client_prefixes");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
/// Migration step 1 seeds `(1, 'default')`; `kids` is added here so the join
/// has two distinct groups to resolve.
fn seedGroups(database: *db.Db) !IdMap {
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
var ids: IdMap = .empty;
errdefer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
try ids.put(testing.allocator, "kids", 2);
return ids;
}
fn seedClients(database: *db.Db, ids: *const IdMap) !void {
const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids };
try insertClient(database, .{ .ip = "192.168.1.20", .name = "laptop", .group = "kids" }, ctx);
try insertClient(database, .{ .ip = "192.168.1.10", .name = "desk" }, ctx);
try insertClient(database, .{ .ip = "fd00::1", .group = "kids" }, ctx);
}
fn seedClientPrefixes(database: *db.Db, ids: *const IdMap) !void {
const ctx: InsertContext = .{ .group_ids = ids };
try insertClientPrefix(database, .{ .prefix = "192.168.2.0/24", .group = "kids", .priority = 10 }, ctx);
try insertClientPrefix(database, .{ .prefix = "192.168.1.0/24", .priority = 50 }, ctx);
try insertClientPrefix(database, .{ .prefix = "fd00::/48", .group = "kids" }, ctx);
}
test "clients round-trip in ip order with group names resolved" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClients(&database, &ids);
var items = try listClients(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeClients(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("192.168.1.10", items.items[0].ip);
try testing.expectEqualStrings("desk", items.items[0].name);
try testing.expectEqualStrings("default", items.items[0].group);
try testing.expectEqualStrings("192.168.1.20", items.items[1].ip);
try testing.expectEqualStrings("laptop", items.items[1].name);
try testing.expectEqualStrings("kids", items.items[1].group);
try testing.expectEqualStrings("fd00::1", items.items[2].ip);
try testing.expectEqualStrings("", items.items[2].name);
try testing.expectEqualStrings("kids", items.items[2].group);
try testing.expectEqual(
@as(i64, 1700000000),
try database.queryInt("SELECT first_seen FROM clients WHERE ip = '192.168.1.10'"),
);
try testing.expectEqual(
@as(i64, 1700000000),
try database.queryInt("SELECT last_seen FROM clients WHERE ip = '192.168.1.10'"),
);
}
test "a hand_edited = 0 client is absent from listClients but counted by countClients" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClients(&database, &ids);
try database.exec(
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES ('10.0.0.5', 'auto', 1, 0, 1, 1);
);
try testing.expectEqual(@as(i64, 4), try countClients(&database));
var items = try listClients(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeClients(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
for (items.items) |item| {
try testing.expect(!std.mem.eql(u8, item.ip, "10.0.0.5"));
}
}
test "deleteAllClients empties the table and countClients reflects it" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClients(&database, &ids);
try testing.expectEqual(@as(i64, 3), try countClients(&database));
try deleteAllClients(&database);
try testing.expectEqual(@as(i64, 0), try countClients(&database));
}
test "insertClient reports a group the caller's map does not hold" {
var database = try openMigrated();
defer database.close();
const ctx: InsertContext = .{};
try testing.expectError(
error.NotFound,
insertClient(&database, .{ .ip = "192.168.1.1" }, ctx),
);
}
fn listClientsUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
var database = try openMigrated();
defer database.close();
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
try seedClients(&database, ids);
var items = try listClients(&database, gpa);
defer items.deinit(gpa);
defer freeClients(gpa, items.items);
}
test "listClients is leak-safe under allocation failure" {
var ids: IdMap = .empty;
defer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
try ids.put(testing.allocator, "kids", 2);
try testing.checkAllAllocationFailures(testing.allocator, listClientsUnderFailure, .{&ids});
}
test "client_prefixes round-trip in prefix order with group names resolved" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClientPrefixes(&database, &ids);
var items = try listClientPrefixes(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeClientPrefixes(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("192.168.1.0/24", items.items[0].prefix);
try testing.expectEqualStrings("default", items.items[0].group);
try testing.expectEqual(@as(i32, 50), items.items[0].priority);
try testing.expectEqualStrings("192.168.2.0/24", items.items[1].prefix);
try testing.expectEqualStrings("kids", items.items[1].group);
try testing.expectEqual(@as(i32, 10), items.items[1].priority);
try testing.expectEqualStrings("fd00::/48", items.items[2].prefix);
try testing.expectEqualStrings("kids", items.items[2].group);
try testing.expectEqual(@as(i32, 100), items.items[2].priority);
}
test "deleteAllClientPrefixes empties the table and countClientPrefixes reflects it" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClientPrefixes(&database, &ids);
try testing.expectEqual(@as(i64, 3), try countClientPrefixes(&database));
try deleteAllClientPrefixes(&database);
try testing.expectEqual(@as(i64, 0), try countClientPrefixes(&database));
}
fn listClientPrefixesUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
var database = try openMigrated();
defer database.close();
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
try seedClientPrefixes(&database, ids);
var items = try listClientPrefixes(&database, gpa);
defer items.deinit(gpa);
defer freeClientPrefixes(gpa, items.items);
}
test "listClientPrefixes is leak-safe under allocation failure" {
var ids: IdMap = .empty;
defer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
try ids.put(testing.allocator, "kids", 2);
try testing.checkAllAllocationFailures(testing.allocator, listClientPrefixesUnderFailure, .{&ids});
}
+66
View File
@@ -0,0 +1,66 @@
//! What every `insert*` needs and the config model deliberately omits.
//!
//! `config/model.zig` carries no row ids and no timestamps: ids are not stable
//! across an import, and `first_seen` / `last_seen` / `created_at` are facts a
//! running server produces. Every insert that writes one of those columns reads
//! it from here instead.
//!
//! Building the id maps is the caller's job (S5): only the caller knows the ids
//! of the parent rows it just inserted.
const std = @import("std");
const log = std.log.scoped(.repositories);
/// Group name → `groups.id`, or blocklist source URL → `blocklist_sources.id`.
pub const IdMap = std.StringHashMapUnmanaged(i64);
const no_ids: IdMap = .empty;
pub const InsertContext = struct {
/// Unix epoch seconds, from `std.Io.Clock.real.now(io).toSeconds()`.
now: i64 = 0,
group_ids: *const IdMap = &no_ids,
source_ids: *const IdMap = &no_ids,
/// `error.NotFound` means the caller's map lacks a name the validator has
/// already proven the config declares. It is reported rather than asserted
/// so a caller bug aborts the import transaction instead of the process.
/// `NotFound` is a member of `db.Error`, so it needs no wider error set.
pub fn groupId(self: InsertContext, name: []const u8) error{NotFound}!i64 {
return self.group_ids.get(name) orelse {
log.warn("no group id for '{s}'", .{name});
return error.NotFound;
};
}
pub fn sourceId(self: InsertContext, url: []const u8) error{NotFound}!i64 {
return self.source_ids.get(url) orelse {
log.warn("no blocklist source id for '{s}'", .{url});
return error.NotFound;
};
}
};
const testing = std.testing;
test "an InsertContext with no maps reports a missing id rather than trapping" {
const ctx: InsertContext = .{};
try testing.expectError(error.NotFound, ctx.groupId("default"));
try testing.expectError(error.NotFound, ctx.sourceId("https://example.test/list.txt"));
}
test "InsertContext resolves names through the caller's maps" {
var groups: IdMap = .empty;
defer groups.deinit(testing.allocator);
try groups.put(testing.allocator, "default", 1);
var sources: IdMap = .empty;
defer sources.deinit(testing.allocator);
try sources.put(testing.allocator, "https://example.test/list.txt", 7);
const ctx: InsertContext = .{ .now = 1700000000, .group_ids = &groups, .source_ids = &sources };
try testing.expectEqual(@as(i64, 1), try ctx.groupId("default"));
try testing.expectEqual(@as(i64, 7), try ctx.sourceId("https://example.test/list.txt"));
try testing.expectError(error.NotFound, ctx.groupId("kids"));
}
+275
View File
@@ -0,0 +1,275 @@
//! `groups` and `group_sources`.
//!
//! Both lists yield model values holding **names**, never row ids: ids are not
//! stable across an import, so an export carrying them would not re-import into
//! the same shape.
//!
//! Only list / insert / deleteAll / count exist. Update-by-id, delete-by-id and
//! paged reads are Phase 8's REST surface; adding them now would be untested,
//! unused generality.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const IdMap = context.IdMap;
const InsertContext = context.InsertContext;
// ---------------------------------------------------------------------------
// groups
// ---------------------------------------------------------------------------
/// Every string in the result is a heap copy owned by `gpa`; free the whole
/// list with `freeGroups` and then `deinit` the list itself.
pub fn listGroups(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Group) {
var stmt = try database.prepare("SELECT name, safe_search FROM groups ORDER BY name");
defer stmt.deinit();
var out: std.ArrayList(model.Group) = .empty;
// Order matters: `errdefer`s run in reverse, so `freeGroups` must be
// declared *after* `deinit` to run *before* it. The other order reads
// `out.items` after the backing array is gone.
errdefer out.deinit(gpa);
errdefer freeGroups(gpa, out.items);
while (try stmt.step()) {
const name = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(name);
try out.append(gpa, .{ .name = name, .safe_search = stmt.columnBool(1) });
}
return out;
}
pub fn freeGroups(gpa: Allocator, items: []const model.Group) void {
for (items) |item| gpa.free(item.name);
}
pub fn insertGroup(database: *db.Db, item: model.Group, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare("INSERT INTO groups (name, safe_search) VALUES (?1, ?2)");
defer stmt.deinit();
try stmt.bindText(1, item.name);
try stmt.bindBool(2, item.safe_search);
try stmt.exec();
}
pub fn deleteAllGroups(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM groups;");
}
pub fn countGroups(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM groups");
}
// ---------------------------------------------------------------------------
// group_sources
// ---------------------------------------------------------------------------
const list_group_sources_sql =
\\SELECT g.name, s.url FROM group_sources gs
\\ JOIN groups g ON g.id = gs.group_id
\\ JOIN blocklist_sources s ON s.id = gs.source_id
\\ ORDER BY g.name, s.url
;
/// The two foreign keys are `NOT NULL` and enforced, so the join is total: a
/// `group_sources` row can never be dropped by it.
pub fn listGroupSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.GroupSource) {
var stmt = try database.prepare(list_group_sources_sql);
defer stmt.deinit();
var out: std.ArrayList(model.GroupSource) = .empty;
errdefer out.deinit(gpa);
errdefer freeGroupSources(gpa, out.items);
while (try stmt.step()) {
const group = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(group);
const source_url = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(source_url);
try out.append(gpa, .{ .group = group, .source_url = source_url });
}
return out;
}
pub fn freeGroupSources(gpa: Allocator, items: []const model.GroupSource) void {
for (items) |item| {
gpa.free(item.group);
gpa.free(item.source_url);
}
}
pub fn insertGroupSource(database: *db.Db, item: model.GroupSource, ctx: InsertContext) db.Error!void {
const group_id = try ctx.groupId(item.group);
const source_id = try ctx.sourceId(item.source_url);
var stmt = try database.prepare("INSERT INTO group_sources (group_id, source_id) VALUES (?1, ?2)");
defer stmt.deinit();
try stmt.bindInt(1, group_id);
try stmt.bindInt(2, source_id);
try stmt.exec();
}
pub fn deleteAllGroupSources(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM group_sources;");
}
pub fn countGroupSources(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM group_sources");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
/// Migration step 1 seeds `(1, 'default')`, so a migrated database already holds
/// one group and one known id.
fn defaultGroupIds() !IdMap {
var ids: IdMap = .empty;
errdefer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
return ids;
}
fn seedGroups(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertGroup(database, .{ .name = "kids", .safe_search = true }, ctx);
try insertGroup(database, .{ .name = "zeta" }, ctx);
try insertGroup(database, .{ .name = "alpha", .safe_search = true }, ctx);
}
fn seedGroupSources(database: *db.Db, ids: *const IdMap) !void {
try database.exec(
\\INSERT INTO blocklist_sources (id, url, name) VALUES
\\ (1, 'https://b.example/list.txt', 'B'),
\\ (2, 'https://a.example/list.txt', 'A');
);
var sources: IdMap = .empty;
defer sources.deinit(testing.allocator);
try sources.put(testing.allocator, "https://b.example/list.txt", 1);
try sources.put(testing.allocator, "https://a.example/list.txt", 2);
const ctx: InsertContext = .{ .group_ids = ids, .source_ids = &sources };
try insertGroupSource(database, .{ .group = "default", .source_url = "https://b.example/list.txt" }, ctx);
try insertGroupSource(database, .{ .group = "default", .source_url = "https://a.example/list.txt" }, ctx);
}
test "groups round-trip in name order" {
var database = try openMigrated();
defer database.close();
try seedGroups(&database);
var items = try listGroups(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeGroups(testing.allocator, items.items);
// The seeded `default` group sorts between `alpha` and `kids`.
try testing.expectEqual(@as(usize, 4), items.items.len);
try testing.expectEqualStrings("alpha", items.items[0].name);
try testing.expect(items.items[0].safe_search);
try testing.expectEqualStrings("default", items.items[1].name);
try testing.expect(!items.items[1].safe_search);
try testing.expectEqualStrings("kids", items.items[2].name);
try testing.expect(items.items[2].safe_search);
try testing.expectEqualStrings("zeta", items.items[3].name);
try testing.expect(!items.items[3].safe_search);
}
test "deleteAllGroups empties the table and countGroups reflects it" {
var database = try openMigrated();
defer database.close();
try seedGroups(&database);
try testing.expectEqual(@as(i64, 4), try countGroups(&database));
try deleteAllGroups(&database);
try testing.expectEqual(@as(i64, 0), try countGroups(&database));
var items = try listGroups(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeGroups(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 0), items.items.len);
}
fn listGroupsUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedGroups(&database);
var items = try listGroups(&database, gpa);
defer items.deinit(gpa);
defer freeGroups(gpa, items.items);
}
test "listGroups is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listGroupsUnderFailure, .{});
}
test "listGroupSources yields names, not ids" {
var database = try openMigrated();
defer database.close();
var ids = try defaultGroupIds();
defer ids.deinit(testing.allocator);
try seedGroupSources(&database, &ids);
var items = try listGroupSources(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeGroupSources(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 2), items.items.len);
try testing.expectEqualStrings("default", items.items[0].group);
try testing.expectEqualStrings("https://a.example/list.txt", items.items[0].source_url);
try testing.expectEqualStrings("default", items.items[1].group);
try testing.expectEqualStrings("https://b.example/list.txt", items.items[1].source_url);
}
test "deleteAllGroupSources empties the table and countGroupSources reflects it" {
var database = try openMigrated();
defer database.close();
var ids = try defaultGroupIds();
defer ids.deinit(testing.allocator);
try seedGroupSources(&database, &ids);
try testing.expectEqual(@as(i64, 2), try countGroupSources(&database));
try deleteAllGroupSources(&database);
try testing.expectEqual(@as(i64, 0), try countGroupSources(&database));
}
test "insertGroupSource reports an id the caller's map does not hold" {
var database = try openMigrated();
defer database.close();
const ctx: InsertContext = .{};
try testing.expectError(
error.NotFound,
insertGroupSource(&database, .{ .group = "kids", .source_url = "https://a.example/list.txt" }, ctx),
);
}
fn listGroupSourcesUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
var database = try openMigrated();
defer database.close();
try seedGroupSources(&database, ids);
var items = try listGroupSources(&database, gpa);
defer items.deinit(gpa);
defer freeGroupSources(gpa, items.items);
}
test "listGroupSources is leak-safe under allocation failure" {
var ids = try defaultGroupIds();
defer ids.deinit(testing.allocator);
try testing.checkAllAllocationFailures(testing.allocator, listGroupSourcesUnderFailure, .{&ids});
}
+255
View File
@@ -0,0 +1,255 @@
//! `local_records` and `forward_zones`.
//!
//! Only list / insert / deleteAll / count exist.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const InsertContext = context.InsertContext;
// ---------------------------------------------------------------------------
// local_records
// ---------------------------------------------------------------------------
const list_local_records_sql =
\\SELECT name, rtype, value, ttl FROM local_records ORDER BY name, rtype, value
;
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listLocalRecords(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.LocalRecord) {
var stmt = try database.prepare(list_local_records_sql);
defer stmt.deinit();
var out: std.ArrayList(model.LocalRecord) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeLocalRecords(gpa, out.items);
while (try stmt.step()) {
const name = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(name);
const value = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(value);
// The DDL's CHECK constraint makes the decode total for any row nxdns
// wrote; `error.Mismatch` covers a row that something else wrote.
const rtype = model.RecordType.fromDb(stmt.columnText(1)) orelse return error.Mismatch;
const ttl = std.math.cast(u32, stmt.columnInt(3)) orelse return error.Mismatch;
try out.append(gpa, .{ .name = name, .rtype = rtype, .value = value, .ttl = ttl });
}
return out;
}
pub fn freeLocalRecords(gpa: Allocator, items: []const model.LocalRecord) void {
for (items) |item| {
gpa.free(item.name);
gpa.free(item.value);
}
}
const insert_local_record_sql =
\\INSERT INTO local_records (name, rtype, value, ttl) VALUES (?1, ?2, ?3, ?4)
;
pub fn insertLocalRecord(database: *db.Db, item: model.LocalRecord, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare(insert_local_record_sql);
defer stmt.deinit();
try stmt.bindText(1, item.name);
try stmt.bindText(2, item.rtype.toDb());
try stmt.bindText(3, item.value);
try stmt.bindInt(4, item.ttl);
try stmt.exec();
}
pub fn deleteAllLocalRecords(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM local_records;");
}
pub fn countLocalRecords(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM local_records");
}
// ---------------------------------------------------------------------------
// forward_zones
// ---------------------------------------------------------------------------
pub fn listForwardZones(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ForwardZone) {
var stmt = try database.prepare("SELECT zone, resolver FROM forward_zones ORDER BY zone");
defer stmt.deinit();
var out: std.ArrayList(model.ForwardZone) = .empty;
errdefer out.deinit(gpa);
errdefer freeForwardZones(gpa, out.items);
while (try stmt.step()) {
const zone = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(zone);
const resolver = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(resolver);
try out.append(gpa, .{ .zone = zone, .resolver = resolver });
}
return out;
}
pub fn freeForwardZones(gpa: Allocator, items: []const model.ForwardZone) void {
for (items) |item| {
gpa.free(item.zone);
gpa.free(item.resolver);
}
}
pub fn insertForwardZone(database: *db.Db, item: model.ForwardZone, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare("INSERT INTO forward_zones (zone, resolver) VALUES (?1, ?2)");
defer stmt.deinit();
try stmt.bindText(1, item.zone);
try stmt.bindText(2, item.resolver);
try stmt.exec();
}
pub fn deleteAllForwardZones(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM forward_zones;");
}
pub fn countForwardZones(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM forward_zones");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
fn seedLocalRecords(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertLocalRecord(database, .{
.name = "nas.home.arpa",
.rtype = .aaaa,
.value = "fd00::5",
.ttl = 60,
}, ctx);
try insertLocalRecord(database, .{
.name = "nas.home.arpa",
.rtype = .a,
.value = "192.168.1.5",
}, ctx);
try insertLocalRecord(database, .{
.name = "alias.home.arpa",
.rtype = .cname,
.value = "nas.home.arpa",
.ttl = 120,
}, ctx);
}
fn seedForwardZones(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertForwardZone(database, .{ .zone = "work.example", .resolver = "udp://10.0.0.1:53" }, ctx);
try insertForwardZone(database, .{ .zone = "home.arpa", .resolver = "udp://192.168.1.1:53" }, ctx);
try insertForwardZone(database, .{ .zone = "lab.example", .resolver = "tcp://[fd00::1]:53" }, ctx);
}
test "local_records round-trip in name, rtype, value order" {
var database = try openMigrated();
defer database.close();
try seedLocalRecords(&database);
var items = try listLocalRecords(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeLocalRecords(testing.allocator, items.items);
// `rtype` is compared as stored text, so 'A' sorts before 'AAAA'.
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("alias.home.arpa", items.items[0].name);
try testing.expectEqual(model.RecordType.cname, items.items[0].rtype);
try testing.expectEqualStrings("nas.home.arpa", items.items[0].value);
try testing.expectEqual(@as(u32, 120), items.items[0].ttl);
try testing.expectEqualStrings("nas.home.arpa", items.items[1].name);
try testing.expectEqual(model.RecordType.a, items.items[1].rtype);
try testing.expectEqualStrings("192.168.1.5", items.items[1].value);
try testing.expectEqual(@as(u32, 300), items.items[1].ttl);
try testing.expectEqualStrings("nas.home.arpa", items.items[2].name);
try testing.expectEqual(model.RecordType.aaaa, items.items[2].rtype);
try testing.expectEqualStrings("fd00::5", items.items[2].value);
try testing.expectEqual(@as(u32, 60), items.items[2].ttl);
}
test "deleteAllLocalRecords empties the table and countLocalRecords reflects it" {
var database = try openMigrated();
defer database.close();
try seedLocalRecords(&database);
try testing.expectEqual(@as(i64, 3), try countLocalRecords(&database));
try deleteAllLocalRecords(&database);
try testing.expectEqual(@as(i64, 0), try countLocalRecords(&database));
}
fn listLocalRecordsUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedLocalRecords(&database);
var items = try listLocalRecords(&database, gpa);
defer items.deinit(gpa);
defer freeLocalRecords(gpa, items.items);
}
test "listLocalRecords is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listLocalRecordsUnderFailure, .{});
}
test "forward_zones round-trip in zone order" {
var database = try openMigrated();
defer database.close();
try seedForwardZones(&database);
var items = try listForwardZones(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeForwardZones(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("home.arpa", items.items[0].zone);
try testing.expectEqualStrings("udp://192.168.1.1:53", items.items[0].resolver);
try testing.expectEqualStrings("lab.example", items.items[1].zone);
try testing.expectEqualStrings("tcp://[fd00::1]:53", items.items[1].resolver);
try testing.expectEqualStrings("work.example", items.items[2].zone);
try testing.expectEqualStrings("udp://10.0.0.1:53", items.items[2].resolver);
}
test "deleteAllForwardZones empties the table and countForwardZones reflects it" {
var database = try openMigrated();
defer database.close();
try seedForwardZones(&database);
try testing.expectEqual(@as(i64, 3), try countForwardZones(&database));
try deleteAllForwardZones(&database);
try testing.expectEqual(@as(i64, 0), try countForwardZones(&database));
}
fn listForwardZonesUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedForwardZones(&database);
var items = try listForwardZones(&database, gpa);
defer items.deinit(gpa);
defer freeForwardZones(gpa, items.items);
}
test "listForwardZones is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listForwardZonesUnderFailure, .{});
}
+294
View File
@@ -0,0 +1,294 @@
//! `rules`.
//!
//! `rules` carries no `UNIQUE` constraint, so duplicate rules are legal. The
//! list therefore ends its `ORDER BY` with `id`, which is the only column that
//! makes the order — and so an export — deterministic.
//!
//! The list leads with the group *name*, not `group_id`. Ids are assigned by the
//! database and permute when a config is imported into a fresh database, so an
//! order that led with `group_id` would reorder the rules of an export →
//! import → export cycle. The name is the value the export emits, and it is the
//! same in both databases. The trailing `id` is stable for the same reason the
//! order as a whole is: `import` inserts the rules in export order, so the new
//! ids ascend in exactly the order this statement produced.
//!
//! Only list / insert / deleteAll / count exist.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const groups_repo = @import("groups_repo.zig");
const IdMap = context.IdMap;
const InsertContext = context.InsertContext;
const list_sql =
\\SELECT g.name, r.pattern, r.kind, r.action FROM rules r
\\ JOIN groups g ON g.id = r.group_id
\\ ORDER BY g.name, r.kind, r.action, r.pattern, r.id
;
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listRules(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Rule) {
var stmt = try database.prepare(list_sql);
defer stmt.deinit();
var out: std.ArrayList(model.Rule) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeRules(gpa, out.items);
while (try stmt.step()) {
const group = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(group);
const pattern = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(pattern);
// The DDL's CHECK constraints make both decodes total for any row nxdns
// wrote; `error.Mismatch` covers a row that something else wrote.
const kind = model.RuleKind.fromDb(stmt.columnText(2)) orelse return error.Mismatch;
const action = model.RuleAction.fromDb(stmt.columnText(3)) orelse return error.Mismatch;
try out.append(gpa, .{ .group = group, .pattern = pattern, .kind = kind, .action = action });
}
return out;
}
pub fn freeRules(gpa: Allocator, items: []const model.Rule) void {
for (items) |item| {
gpa.free(item.group);
gpa.free(item.pattern);
}
}
const insert_sql =
\\INSERT INTO rules (group_id, pattern, kind, action, created_at) VALUES (?1, ?2, ?3, ?4, ?5)
;
pub fn insertRule(database: *db.Db, item: model.Rule, ctx: InsertContext) db.Error!void {
const group_id = try ctx.groupId(item.group);
var stmt = try database.prepare(insert_sql);
defer stmt.deinit();
try stmt.bindInt(1, group_id);
try stmt.bindText(2, item.pattern);
try stmt.bindText(3, item.kind.toDb());
try stmt.bindText(4, item.action.toDb());
try stmt.bindInt(5, ctx.now);
try stmt.exec();
}
pub fn deleteAllRules(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM rules;");
}
pub fn countRules(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM rules");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
fn seedGroupIds() !IdMap {
var ids: IdMap = .empty;
errdefer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
return ids;
}
fn seedRules(database: *db.Db, ids: *const IdMap) !void {
const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids };
try insertRule(database, .{
.group = "default",
.pattern = "*.ads.example",
.kind = .wildcard,
.action = .block,
}, ctx);
try insertRule(database, .{
.group = "default",
.pattern = "tracker.example",
.kind = .exact,
.action = .block,
}, ctx);
try insertRule(database, .{
.group = "default",
.pattern = "allowed.example",
.kind = .exact,
.action = .allow,
}, ctx);
}
test "rules round-trip in group, kind, action, pattern, id order" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroupIds();
defer ids.deinit(testing.allocator);
try seedRules(&database, &ids);
var items = try listRules(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeRules(testing.allocator, items.items);
// One group, so `kind` leads: 'exact' before 'wildcard'; inside 'exact',
// 'allow' before 'block'.
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("allowed.example", items.items[0].pattern);
try testing.expectEqual(model.RuleKind.exact, items.items[0].kind);
try testing.expectEqual(model.RuleAction.allow, items.items[0].action);
try testing.expectEqualStrings("default", items.items[0].group);
try testing.expectEqualStrings("tracker.example", items.items[1].pattern);
try testing.expectEqual(model.RuleKind.exact, items.items[1].kind);
try testing.expectEqual(model.RuleAction.block, items.items[1].action);
try testing.expectEqualStrings("*.ads.example", items.items[2].pattern);
try testing.expectEqual(model.RuleKind.wildcard, items.items[2].kind);
try testing.expectEqual(model.RuleAction.block, items.items[2].action);
try testing.expectEqual(
@as(i64, 1700000000),
try database.queryInt("SELECT created_at FROM rules WHERE pattern = 'tracker.example'"),
);
}
test "a duplicate rule is accepted and stays deterministically ordered by id" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroupIds();
defer ids.deinit(testing.allocator);
const ctx: InsertContext = .{ .now = 1, .group_ids = &ids };
const rule: model.Rule = .{
.group = "default",
.pattern = "dup.example",
.kind = .exact,
.action = .block,
};
try insertRule(&database, rule, ctx);
try insertRule(&database, rule, ctx);
var items = try listRules(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeRules(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 2), items.items.len);
try testing.expectEqualStrings("dup.example", items.items[0].pattern);
try testing.expectEqualStrings("dup.example", items.items[1].pattern);
}
/// Inserts `names` in the given order and returns the ids the database assigned.
fn seedGroupsInOrder(database: *db.Db, names: []const []const u8) !IdMap {
var ids: IdMap = .empty;
errdefer ids.deinit(testing.allocator);
for (names) |name| {
try groups_repo.insertGroup(database, .{ .name = name }, .{});
try ids.put(testing.allocator, name, database.lastInsertRowid());
}
return ids;
}
fn seedCrossGroupRules(database: *db.Db, ids: *const IdMap) !void {
const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids };
try insertRule(database, .{
.group = "zeta",
.pattern = "z.example",
.kind = .exact,
.action = .block,
}, ctx);
try insertRule(database, .{
.group = "alpha",
.pattern = "a.example",
.kind = .exact,
.action = .block,
}, ctx);
}
test "list order does not depend on which id each group received" {
// Two databases hold the same rules under the same group names, but the
// groups were inserted in opposite orders, so every group id differs. This
// is what an export → import → export cycle does to the ids.
var first = try openMigrated();
defer first.close();
var first_ids = try seedGroupsInOrder(&first, &.{ "zeta", "alpha" });
defer first_ids.deinit(testing.allocator);
try seedCrossGroupRules(&first, &first_ids);
var second = try openMigrated();
defer second.close();
var second_ids = try seedGroupsInOrder(&second, &.{ "alpha", "zeta" });
defer second_ids.deinit(testing.allocator);
try seedCrossGroupRules(&second, &second_ids);
try testing.expect(first_ids.get("alpha").? != second_ids.get("alpha").?);
var a = try listRules(&first, testing.allocator);
defer a.deinit(testing.allocator);
defer freeRules(testing.allocator, a.items);
var b = try listRules(&second, testing.allocator);
defer b.deinit(testing.allocator);
defer freeRules(testing.allocator, b.items);
try testing.expectEqual(@as(usize, 2), a.items.len);
try testing.expectEqual(a.items.len, b.items.len);
for (a.items, b.items) |x, y| {
try testing.expectEqualStrings(x.group, y.group);
try testing.expectEqualStrings(x.pattern, y.pattern);
}
// And the sequence is the group names in order, not the insertion order.
try testing.expectEqualStrings("alpha", a.items[0].group);
try testing.expectEqualStrings("a.example", a.items[0].pattern);
try testing.expectEqualStrings("zeta", a.items[1].group);
try testing.expectEqualStrings("z.example", a.items[1].pattern);
}
test "deleteAllRules empties the table and countRules reflects it" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroupIds();
defer ids.deinit(testing.allocator);
try seedRules(&database, &ids);
try testing.expectEqual(@as(i64, 3), try countRules(&database));
try deleteAllRules(&database);
try testing.expectEqual(@as(i64, 0), try countRules(&database));
}
test "insertRule reports a group the caller's map does not hold" {
var database = try openMigrated();
defer database.close();
const ctx: InsertContext = .{};
try testing.expectError(error.NotFound, insertRule(&database, .{
.group = "kids",
.pattern = "x.example",
.kind = .exact,
.action = .block,
}, ctx));
}
fn listRulesUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
var database = try openMigrated();
defer database.close();
try seedRules(&database, ids);
var items = try listRules(&database, gpa);
defer items.deinit(gpa);
defer freeRules(gpa, items.items);
}
test "listRules is leak-safe under allocation failure" {
var ids = try seedGroupIds();
defer ids.deinit(testing.allocator);
try testing.checkAllAllocationFailures(testing.allocator, listRulesUnderFailure, .{&ids});
}
+145
View File
@@ -0,0 +1,145 @@
//! `settings`.
//!
//! The row type is `model.SettingPair`, the same type `model.toSettings` and
//! `model.fromSettings` speak, so the scalar sections cross the storage boundary
//! without a second shape.
//!
//! Only list / insert / deleteAll / count exist.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const InsertContext = context.InsertContext;
/// Both strings of every pair are heap copies owned by `gpa`.
pub fn listSettings(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.SettingPair) {
var stmt = try database.prepare("SELECT key, value FROM settings ORDER BY key");
defer stmt.deinit();
var out: std.ArrayList(model.SettingPair) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeSettings(gpa, out.items);
while (try stmt.step()) {
const key = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(key);
const value = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(value);
try out.append(gpa, .{ .key = key, .value = value });
}
return out;
}
/// Only for lists `listSettings` produced. `model.toSettings` builds pairs whose
/// `key` is a comptime string and must never be freed; that list is the caller's
/// to release, field by field.
pub fn freeSettings(gpa: Allocator, items: []const model.SettingPair) void {
for (items) |item| {
gpa.free(item.key);
gpa.free(item.value);
}
}
pub fn insertSetting(database: *db.Db, item: model.SettingPair, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare("INSERT INTO settings (key, value) VALUES (?1, ?2)");
defer stmt.deinit();
try stmt.bindText(1, item.key);
try stmt.bindText(2, item.value);
try stmt.exec();
}
pub fn deleteAllSettings(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM settings;");
}
pub fn countSettings(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM settings");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
fn seedSettings(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertSetting(database, .{ .key = "web.port", .value = "8080" }, ctx);
try insertSetting(database, .{ .key = "dns.port", .value = "53" }, ctx);
// An apostrophe proves the value is bound, not concatenated into the SQL.
try insertSetting(database, .{ .key = "logging.file_path", .value = "/var/log/o'brien.log" }, ctx);
}
test "settings round-trip in ascending key order" {
var database = try openMigrated();
defer database.close();
try seedSettings(&database);
var items = try listSettings(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeSettings(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("dns.port", items.items[0].key);
try testing.expectEqualStrings("53", items.items[0].value);
try testing.expectEqualStrings("logging.file_path", items.items[1].key);
try testing.expectEqualStrings("/var/log/o'brien.log", items.items[1].value);
try testing.expectEqualStrings("web.port", items.items[2].key);
try testing.expectEqualStrings("8080", items.items[2].value);
}
test "a value holding an apostrophe survives the round trip" {
var database = try openMigrated();
defer database.close();
const ctx: InsertContext = .{};
const value = "he said 'hello'; DROP TABLE settings;--";
try insertSetting(&database, .{ .key = "web.password_hash", .value = value }, ctx);
var items = try listSettings(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeSettings(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 1), items.items.len);
try testing.expectEqualStrings(value, items.items[0].value);
try testing.expectEqual(@as(i64, 1), try countSettings(&database));
}
test "deleteAllSettings empties the table and countSettings reflects it" {
var database = try openMigrated();
defer database.close();
try seedSettings(&database);
try testing.expectEqual(@as(i64, 3), try countSettings(&database));
try deleteAllSettings(&database);
try testing.expectEqual(@as(i64, 0), try countSettings(&database));
}
fn listSettingsUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedSettings(&database);
var items = try listSettings(&database, gpa);
defer items.deinit(gpa);
defer freeSettings(gpa, items.items);
}
test "listSettings is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listSettingsUnderFailure, .{});
}
+177
View File
@@ -0,0 +1,177 @@
//! `blocklist_sources`.
//!
//! Only the four configuration columns are read and written. `last_updated`,
//! `domain_count`, `wildcard_count`, `skipped_regex_count` and `checksum` are
//! facts a running server produces; an insert leaves them at their column
//! defaults so two exports taken minutes apart stay identical.
//!
//! Only list / insert / deleteAll / count exist.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const InsertContext = context.InsertContext;
const list_sql =
\\SELECT url, name, enabled, is_suggested FROM blocklist_sources ORDER BY url
;
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listBlocklistSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.BlocklistSource) {
var stmt = try database.prepare(list_sql);
defer stmt.deinit();
var out: std.ArrayList(model.BlocklistSource) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeBlocklistSources(gpa, out.items);
while (try stmt.step()) {
const url = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(url);
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
try out.append(gpa, .{
.url = url,
.name = name,
.enabled = stmt.columnBool(2),
.is_suggested = stmt.columnBool(3),
});
}
return out;
}
pub fn freeBlocklistSources(gpa: Allocator, items: []const model.BlocklistSource) void {
for (items) |item| {
gpa.free(item.url);
gpa.free(item.name);
}
}
const insert_sql =
\\INSERT INTO blocklist_sources (url, name, enabled, is_suggested) VALUES (?1, ?2, ?3, ?4)
;
pub fn insertBlocklistSource(database: *db.Db, item: model.BlocklistSource, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare(insert_sql);
defer stmt.deinit();
try stmt.bindText(1, item.url);
try stmt.bindText(2, item.name);
try stmt.bindBool(3, item.enabled);
try stmt.bindBool(4, item.is_suggested);
try stmt.exec();
}
pub fn deleteAllBlocklistSources(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM blocklist_sources;");
}
pub fn countBlocklistSources(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM blocklist_sources");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
fn seedSources(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertBlocklistSource(database, .{
.url = "https://c.example/list.txt",
.name = "C list",
}, ctx);
try insertBlocklistSource(database, .{
.url = "https://a.example/list.txt",
.name = "A list",
.enabled = false,
}, ctx);
try insertBlocklistSource(database, .{
.url = "https://b.example/list.txt",
.name = "B list",
.is_suggested = true,
}, ctx);
}
test "blocklist_sources round-trip in url order" {
var database = try openMigrated();
defer database.close();
try seedSources(&database);
var items = try listBlocklistSources(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeBlocklistSources(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("https://a.example/list.txt", items.items[0].url);
try testing.expectEqualStrings("A list", items.items[0].name);
try testing.expect(!items.items[0].enabled);
try testing.expect(!items.items[0].is_suggested);
try testing.expectEqualStrings("https://b.example/list.txt", items.items[1].url);
try testing.expectEqualStrings("B list", items.items[1].name);
try testing.expect(items.items[1].enabled);
try testing.expect(items.items[1].is_suggested);
try testing.expectEqualStrings("https://c.example/list.txt", items.items[2].url);
try testing.expectEqualStrings("C list", items.items[2].name);
try testing.expect(items.items[2].enabled);
try testing.expect(!items.items[2].is_suggested);
}
test "insertBlocklistSource leaves the runtime columns at their defaults" {
var database = try openMigrated();
defer database.close();
try seedSources(&database);
try testing.expectEqual(
@as(i64, 3),
try database.queryInt("SELECT count(*) FROM blocklist_sources WHERE last_updated IS NULL"),
);
try testing.expectEqual(
@as(i64, 3),
try database.queryInt("SELECT count(*) FROM blocklist_sources WHERE checksum IS NULL"),
);
try testing.expectEqual(
@as(i64, 0),
try database.queryInt("SELECT sum(domain_count + wildcard_count + skipped_regex_count) FROM blocklist_sources"),
);
}
test "deleteAllBlocklistSources empties the table and countBlocklistSources reflects it" {
var database = try openMigrated();
defer database.close();
try seedSources(&database);
try testing.expectEqual(@as(i64, 3), try countBlocklistSources(&database));
try deleteAllBlocklistSources(&database);
try testing.expectEqual(@as(i64, 0), try countBlocklistSources(&database));
}
fn listBlocklistSourcesUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedSources(&database);
var items = try listBlocklistSources(&database, gpa);
defer items.deinit(gpa);
defer freeBlocklistSources(gpa, items.items);
}
test "listBlocklistSources is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listBlocklistSourcesUnderFailure, .{});
}
+130
View File
@@ -0,0 +1,130 @@
//! `upstreams`.
//!
//! The list sorts by `priority` first because that is the operationally
//! meaningful order — it matches what `Pool.init` expects — and `url` breaks
//! ties uniquely, which is what makes an export byte-stable.
//!
//! Only list / insert / deleteAll / count exist.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const InsertContext = context.InsertContext;
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listUpstreams(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.UpstreamServer) {
var stmt = try database.prepare("SELECT url, priority, enabled FROM upstreams ORDER BY priority, url");
defer stmt.deinit();
var out: std.ArrayList(model.UpstreamServer) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeUpstreams(gpa, out.items);
while (try stmt.step()) {
const url = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(url);
const priority = std.math.cast(i32, stmt.columnInt(1)) orelse return error.Mismatch;
try out.append(gpa, .{ .url = url, .priority = priority, .enabled = stmt.columnBool(2) });
}
return out;
}
pub fn freeUpstreams(gpa: Allocator, items: []const model.UpstreamServer) void {
for (items) |item| gpa.free(item.url);
}
pub fn insertUpstream(database: *db.Db, item: model.UpstreamServer, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare("INSERT INTO upstreams (url, priority, enabled) VALUES (?1, ?2, ?3)");
defer stmt.deinit();
try stmt.bindText(1, item.url);
try stmt.bindInt(2, item.priority);
try stmt.bindBool(3, item.enabled);
try stmt.exec();
}
pub fn deleteAllUpstreams(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM upstreams;");
}
pub fn countUpstreams(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM upstreams");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
fn seedUpstreams(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertUpstream(database, .{ .url = "https://dns.example/dns-query", .priority = 50 }, ctx);
try insertUpstream(database, .{ .url = "tls://1.1.1.1:853", .priority = 10, .enabled = false }, ctx);
try insertUpstream(database, .{ .url = "https://a.example/dns-query", .priority = 50 }, ctx);
}
test "upstreams round-trip in priority then url order" {
var database = try openMigrated();
defer database.close();
try seedUpstreams(&database);
var items = try listUpstreams(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeUpstreams(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("tls://1.1.1.1:853", items.items[0].url);
try testing.expectEqual(@as(i32, 10), items.items[0].priority);
try testing.expect(!items.items[0].enabled);
try testing.expectEqualStrings("https://a.example/dns-query", items.items[1].url);
try testing.expectEqual(@as(i32, 50), items.items[1].priority);
try testing.expect(items.items[1].enabled);
try testing.expectEqualStrings("https://dns.example/dns-query", items.items[2].url);
try testing.expectEqual(@as(i32, 50), items.items[2].priority);
try testing.expect(items.items[2].enabled);
}
test "deleteAllUpstreams empties the table and countUpstreams reflects it" {
var database = try openMigrated();
defer database.close();
try seedUpstreams(&database);
try testing.expectEqual(@as(i64, 3), try countUpstreams(&database));
try deleteAllUpstreams(&database);
try testing.expectEqual(@as(i64, 0), try countUpstreams(&database));
var items = try listUpstreams(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeUpstreams(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 0), items.items.len);
}
fn listUpstreamsUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedUpstreams(&database);
var items = try listUpstreams(&database, gpa);
defer items.deinit(gpa);
defer freeUpstreams(gpa, items.items);
}
test "listUpstreams is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listUpstreamsUnderFailure, .{});
}