storage and config: sqlite wrapper, migrations, querylog policy, repositories, zon config with import/export/check cli
This commit is contained in:
@@ -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});
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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, .{});
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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, .{});
|
||||
}
|
||||
@@ -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, .{});
|
||||
}
|
||||
@@ -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, .{});
|
||||
}
|
||||
Reference in New Issue
Block a user