milestone 18: collapse duplicated infrastructure into shared listener core, crud list helper, resource shells, transport race, name and line helpers, ui modules
CI / test (push) Successful in 1m22s
CI / test-aarch64 (push) Successful in 5m6s
CI / frontend (push) Successful in 45s
CI / cross (push) Successful in 7m53s
CI / docker (push) Failing after 1h10m57s

This commit is contained in:
2026-08-07 18:20:30 +02:00
parent c50c6d285a
commit 6f67940995
82 changed files with 3167 additions and 3114 deletions
+47 -89
View File
@@ -38,35 +38,23 @@ const list_clients_sql =
/// 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();
return crud.listRows(model.Client, database, gpa, list_clients_sql, readClient);
}
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;
fn readClient(stmt: *db.Stmt, gpa: Allocator) db.Error!model.Client {
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);
return .{ .ip = ip, .name = name, .group = group };
}
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);
}
crud.freeRows(model.Client, gpa, items);
}
const insert_client_sql =
@@ -154,31 +142,22 @@ const list_client_prefixes_sql =
;
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();
return crud.listRows(model.ClientPrefix, database, gpa, list_client_prefixes_sql, readClientPrefix);
}
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;
fn readClientPrefix(stmt: *db.Stmt, gpa: Allocator) db.Error!model.ClientPrefix {
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;
return .{ .prefix = prefix, .group = group, .priority = priority };
}
pub fn freeClientPrefixes(gpa: Allocator, items: []const model.ClientPrefix) void {
for (items) |item| {
gpa.free(item.prefix);
gpa.free(item.group);
}
crud.freeRows(model.ClientPrefix, gpa, items);
}
pub fn insertClientPrefix(database: *db.Db, item: model.ClientPrefix, ctx: InsertContext) db.Error!void {
@@ -257,29 +236,15 @@ const get_client_sql =
/// Every client, materialised ones included. Every string is a heap copy owned
/// by `gpa`.
pub fn listClientRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientRow) {
var stmt = try database.prepare(list_client_rows_sql);
defer stmt.deinit();
var out: std.ArrayList(ClientRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeClientRows(gpa, out.items);
while (try stmt.step()) {
const row = try readClientRow(&stmt, gpa);
errdefer freeClientRow(gpa, row);
try out.append(gpa, row);
}
return out;
return crud.listRows(ClientRow, database, gpa, list_client_rows_sql, readClientRow);
}
pub fn freeClientRow(gpa: Allocator, row: ClientRow) void {
gpa.free(row.ip);
gpa.free(row.name);
gpa.free(row.group);
crud.freeRow(ClientRow, gpa, row);
}
pub fn freeClientRows(gpa: Allocator, items: []const ClientRow) void {
for (items) |item| freeClientRow(gpa, item);
crud.freeRows(ClientRow, gpa, items);
}
pub fn getClient(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ClientRow {
@@ -383,39 +348,32 @@ const list_client_prefix_rows_sql =
/// Same order as `listClientPrefixes`; every string is a heap copy owned by
/// `gpa`.
pub fn listClientPrefixRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientPrefixRow) {
var stmt = try database.prepare(list_client_prefix_rows_sql);
defer stmt.deinit();
return crud.listRows(ClientPrefixRow, database, gpa, list_client_prefix_rows_sql, readClientPrefixRow);
}
var out: std.ArrayList(ClientPrefixRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeClientPrefixRows(gpa, out.items);
while (try stmt.step()) {
const prefix = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(prefix);
const group = try stmt.columnTextAlloc(gpa, 3);
errdefer gpa.free(group);
// The column is a 64-bit integer; the row field is `i32`. A value
// outside that range means something other than nxdns wrote the row.
const priority = std.math.cast(i32, stmt.columnInt(4)) orelse return error.Mismatch;
try out.append(gpa, .{
.id = stmt.columnInt(0),
.prefix = prefix,
.group_id = stmt.columnInt(2),
.group = group,
.priority = priority,
});
}
return out;
fn readClientPrefixRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ClientPrefixRow {
const prefix = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(prefix);
const group = try stmt.columnTextAlloc(gpa, 3);
errdefer gpa.free(group);
// The column is a 64-bit integer; the row field is `i32`. A value outside
// that range means something other than nxdns wrote the row.
const priority = std.math.cast(i32, stmt.columnInt(4)) orelse return error.Mismatch;
return .{
.id = stmt.columnInt(0),
.prefix = prefix,
.group_id = stmt.columnInt(2),
.group = group,
.priority = priority,
};
}
pub fn freeClientPrefixRow(gpa: Allocator, row: ClientPrefixRow) void {
gpa.free(row.prefix);
gpa.free(row.group);
crud.freeRow(ClientPrefixRow, gpa, row);
}
pub fn freeClientPrefixRows(gpa: Allocator, items: []const ClientPrefixRow) void {
for (items) |item| freeClientPrefixRow(gpa, item);
crud.freeRows(ClientPrefixRow, gpa, items);
}
/// Replaces the whole prefix table inside a transaction (ruling 9 makes
+190
View File
@@ -8,8 +8,14 @@
//! `error.Constraint` needs no helper — `Stmt.exec` already reports it, and the
//! handler layer maps it to 409. Each mutation documents which constraint of
//! `config_schema.ddl_v1` can fire.
//!
//! `listRows` and `freeRows` are the read half. Every `list*` function in this
//! directory reads rows into a `std.ArrayList` under the same unwind rules, and
//! the errdefer ordering those rules need is easy to write backwards. It is
//! written once here.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
@@ -25,6 +31,102 @@ pub fn execStrict(database: *db.Db, stmt: *db.Stmt) db.Error!void {
if (database.changes() == 0) return error.NotFound;
}
/// Reads every row `sql` produces into a list, with the memory-safety
/// choreography every `list*` function in this directory shares.
///
/// `readRow` allocates the row's owning fields from `gpa` and carries its own
/// per-column `errdefer`s, so a row that fails halfway releases the columns it
/// already read. This function owns everything around that: a failure after the
/// first append releases the rows already in the list and then the list itself.
///
/// The result is the caller's: free the rows with `freeRows` (or the repository
/// shim over it) and then `deinit` the list.
pub fn listRows(
comptime Row: type,
database: *db.Db,
gpa: Allocator,
comptime sql: []const u8,
comptime readRow: fn (*db.Stmt, Allocator) db.Error!Row,
) db.Error!std.ArrayList(Row) {
return listRowsBound(Row, database, gpa, sql, readRow, .{});
}
/// `listRows` for a statement with parameters. `args` is a tuple bound to
/// positions 1..n in order.
pub fn listRowsBound(
comptime Row: type,
database: *db.Db,
gpa: Allocator,
comptime sql: []const u8,
comptime readRow: fn (*db.Stmt, Allocator) db.Error!Row,
args: anytype,
) db.Error!std.ArrayList(Row) {
var stmt = try database.prepare(sql);
defer stmt.deinit();
inline for (args, 0..) |arg, i| try bindArg(&stmt, i + 1, arg);
var out: std.ArrayList(Row) = .empty;
// Order matters: `errdefer`s run in reverse, so the free pass is declared
// *after* `deinit` to run *before* it. The other order reads `out.items`
// after the backing array is gone.
errdefer out.deinit(gpa);
errdefer freeRows(Row, gpa, out.items);
while (try stmt.step()) {
const row = try readRow(&stmt, gpa);
errdefer freeRow(Row, gpa, row);
try out.append(gpa, row);
}
return out;
}
fn bindArg(stmt: *db.Stmt, idx: c_int, arg: anytype) db.Error!void {
const Arg = @TypeOf(arg);
if (Arg == []const u8 or Arg == []u8) return stmt.bindText(idx, arg);
return switch (@typeInfo(Arg)) {
.bool => stmt.bindInt(idx, @intFromBool(arg)),
.int, .comptime_int => stmt.bindInt(idx, arg),
else => @compileError("crud.listRowsBound: cannot bind a " ++ @typeName(Arg)),
};
}
/// Releases every owning field of every row `listRows` produced.
pub fn freeRows(comptime Row: type, gpa: Allocator, items: []const Row) void {
for (items) |item| freeRow(Row, gpa, item);
}
/// Releases the owning fields of one row.
///
/// A repository row owns its heap memory in exactly two shapes: `[]const u8`
/// and `?[]const u8` (`SourceRow.checksum` is the optional one — a shallow
/// slice-only reflection would leak its payload). Every other field must be a
/// plain value the row does not own. A field of any other shape is a
/// `@compileError`, so a row that grows a nested allocation cannot start
/// leaking silently: whoever adds it has to teach this function first.
pub fn freeRow(comptime Row: type, gpa: Allocator, row: Row) void {
switch (@typeInfo(Row)) {
.@"struct" => |info| inline for (info.fields) |field| {
freeField(field.type, @typeName(Row) ++ "." ++ field.name, gpa, @field(row, field.name));
},
else => freeField(Row, @typeName(Row), gpa, row),
}
}
fn freeField(comptime Field: type, comptime where: []const u8, gpa: Allocator, value: Field) void {
if (Field == []const u8 or Field == []u8) return gpa.free(value);
if (Field == ?[]const u8 or Field == ?[]u8) return if (value) |owned| gpa.free(owned);
comptime assertUnowning(Field, where);
}
fn assertUnowning(comptime Field: type, comptime where: []const u8) void {
switch (@typeInfo(Field)) {
.bool, .int, .float, .@"enum", .void => {},
.optional => |info| assertUnowning(info.child, where),
else => @compileError("crud.freeRow: " ++ where ++ " is a " ++ @typeName(Field) ++
", which is neither a plain value nor an owning slice; teach freeRow how to release it"),
}
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
@@ -78,3 +180,91 @@ test "execStrict reports NotFound for an id no row holds" {
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM forward_zones"));
}
/// Carries both owning shapes a repository row may hold: a `[]const u8` that is
/// always there, and the `?[]const u8` of `SourceRow.checksum`.
const TestRow = struct {
id: i64,
url: []const u8,
checksum: ?[]const u8,
};
const test_rows_sql = "SELECT id, url, checksum FROM blocklist_sources ORDER BY id";
fn readTestRow(stmt: *db.Stmt, gpa: Allocator) db.Error!TestRow {
const url = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(url);
const checksum = try stmt.columnTextAllocOrNull(gpa, 2);
errdefer if (checksum) |value| gpa.free(value);
return .{ .id = stmt.columnInt(0), .url = url, .checksum = checksum };
}
/// Two rows: the first carries a checksum, the second leaves it NULL, so one
/// read exercises both arms of the optional.
fn seedTestRows(database: *db.Db) !void {
try database.exec(
\\INSERT INTO blocklist_sources (id, url, name, checksum) VALUES
\\ (1, 'https://a.example/list.txt', 'A', 'aaaa'),
\\ (2, 'https://b.example/list.txt', 'B', NULL);
);
}
test "listRows reads every row and freeRows releases both owning shapes" {
var database = try openTable();
defer database.close();
try seedTestRows(&database);
var rows = try listRows(TestRow, &database, testing.allocator, test_rows_sql, readTestRow);
defer rows.deinit(testing.allocator);
defer freeRows(TestRow, testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 2), rows.items.len);
try testing.expectEqualStrings("https://a.example/list.txt", rows.items[0].url);
// The leak detector is what proves this payload is released.
try testing.expectEqualStrings("aaaa", rows.items[0].checksum.?);
try testing.expectEqual(@as(?[]const u8, null), rows.items[1].checksum);
}
fn listRowsUnderFailure(gpa: Allocator) !void {
var database = try openTable();
defer database.close();
try seedTestRows(&database);
var rows = try listRows(TestRow, &database, gpa, test_rows_sql, readTestRow);
defer rows.deinit(gpa);
defer freeRows(TestRow, gpa, rows.items);
// A row with a non-null checksum must be in the result, or the failure
// injection never reaches the optional's allocation.
try testing.expect(rows.items[0].checksum != null);
}
test "listRows is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listRowsUnderFailure, .{});
}
test "listRowsBound binds its arguments in tuple order" {
var database = try openTable();
defer database.close();
try seedTestRows(&database);
var rows = try listRowsBound(
TestRow,
&database,
testing.allocator,
"SELECT id, url, checksum FROM blocklist_sources WHERE id > ?1 AND name = ?2",
readTestRow,
.{ @as(i64, 1), @as([]const u8, "B") },
);
defer rows.deinit(testing.allocator);
defer freeRows(TestRow, testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 1), rows.items.len);
try testing.expectEqual(@as(i64, 2), rows.items[0].id);
}
test "freeRows over a row type with no owning field is a no-op" {
// `listGroupSourceIds` reads a bare `i64`; the reflection must accept a Row
// that is not a struct at all.
freeRows(i64, testing.allocator, &.{ 1, 2, 3 });
}
+49 -64
View File
@@ -27,26 +27,23 @@ const InsertContext = context.InsertContext;
/// 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();
return crud.listRows(
model.Group,
database,
gpa,
"SELECT name, safe_search FROM groups ORDER BY name",
readGroup,
);
}
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;
fn readGroup(stmt: *db.Stmt, gpa: Allocator) db.Error!model.Group {
const name = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(name);
return .{ .name = name, .safe_search = stmt.columnBool(1) };
}
pub fn freeGroups(gpa: Allocator, items: []const model.Group) void {
for (items) |item| gpa.free(item.name);
crud.freeRows(model.Group, gpa, items);
}
pub fn insertGroup(database: *db.Db, item: model.Group, ctx: InsertContext) db.Error!void {
@@ -91,28 +88,19 @@ const list_group_sources_sql =
/// 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();
return crud.listRows(model.GroupSource, database, gpa, list_group_sources_sql, readGroupSource);
}
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;
fn readGroupSource(stmt: *db.Stmt, gpa: Allocator) db.Error!model.GroupSource {
const group = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(group);
const source_url = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(source_url);
return .{ .group = group, .source_url = source_url };
}
pub fn freeGroupSources(gpa: Allocator, items: []const model.GroupSource) void {
for (items) |item| {
gpa.free(item.group);
gpa.free(item.source_url);
}
crud.freeRows(model.GroupSource, gpa, items);
}
pub fn insertGroupSource(database: *db.Db, item: model.GroupSource, ctx: InsertContext) db.Error!void {
@@ -145,31 +133,27 @@ pub const GroupRow = struct { id: i64, name: []const u8, safe_search: bool };
/// Same order as `listGroups`; every string is a heap copy owned by `gpa`.
pub fn listGroupRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(GroupRow) {
var stmt = try database.prepare("SELECT id, name, safe_search FROM groups ORDER BY name");
defer stmt.deinit();
return crud.listRows(
GroupRow,
database,
gpa,
"SELECT id, name, safe_search FROM groups ORDER BY name",
readGroupRow,
);
}
var out: std.ArrayList(GroupRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeGroupRows(gpa, out.items);
while (try stmt.step()) {
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
try out.append(gpa, .{
.id = stmt.columnInt(0),
.name = name,
.safe_search = stmt.columnBool(2),
});
}
return out;
fn readGroupRow(stmt: *db.Stmt, gpa: Allocator) db.Error!GroupRow {
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
return .{ .id = stmt.columnInt(0), .name = name, .safe_search = stmt.columnBool(2) };
}
pub fn freeGroupRow(gpa: Allocator, row: GroupRow) void {
gpa.free(row.name);
crud.freeRow(GroupRow, gpa, row);
}
pub fn freeGroupRows(gpa: Allocator, items: []const GroupRow) void {
for (items) |item| freeGroupRow(gpa, item);
crud.freeRows(GroupRow, gpa, items);
}
pub fn getGroup(database: *db.Db, gpa: Allocator, id: i64) db.Error!?GroupRow {
@@ -177,11 +161,7 @@ pub fn getGroup(database: *db.Db, gpa: Allocator, id: i64) db.Error!?GroupRow {
defer stmt.deinit();
try stmt.bindInt(1, id);
if (!try stmt.step()) return null;
return .{
.id = stmt.columnInt(0),
.name = try stmt.columnTextAlloc(gpa, 1),
.safe_search = stmt.columnBool(2),
};
return try readGroupRow(&stmt, gpa);
}
/// `error.Constraint`: `groups.name` is UNIQUE.
@@ -220,14 +200,19 @@ pub fn deleteGroup(database: *db.Db, id: i64) db.Error!void {
/// `group_id` yields an empty list, not an error: the caller that needs the
/// distinction reads the group itself.
pub fn listGroupSourceIds(database: *db.Db, gpa: Allocator, group_id: i64) db.Error!std.ArrayList(i64) {
var stmt = try database.prepare("SELECT source_id FROM group_sources WHERE group_id = ?1 ORDER BY source_id");
defer stmt.deinit();
try stmt.bindInt(1, group_id);
return crud.listRowsBound(
i64,
database,
gpa,
"SELECT source_id FROM group_sources WHERE group_id = ?1 ORDER BY source_id",
readSourceId,
.{group_id},
);
}
var out: std.ArrayList(i64) = .empty;
errdefer out.deinit(gpa);
while (try stmt.step()) try out.append(gpa, stmt.columnInt(0));
return out;
fn readSourceId(stmt: *db.Stmt, gpa: Allocator) db.Error!i64 {
_ = gpa;
return stmt.columnInt(0);
}
/// Replaces one group's whole source assignment inside a transaction, so a
+40 -74
View File
@@ -25,34 +25,23 @@ const list_local_records_sql =
/// 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();
return crud.listRows(model.LocalRecord, database, gpa, list_local_records_sql, readLocalRecord);
}
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;
fn readLocalRecord(stmt: *db.Stmt, gpa: Allocator) db.Error!model.LocalRecord {
// 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;
const name = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(name);
const value = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(value);
return .{ .name = name, .rtype = rtype, .value = value, .ttl = ttl };
}
pub fn freeLocalRecords(gpa: Allocator, items: []const model.LocalRecord) void {
for (items) |item| {
gpa.free(item.name);
gpa.free(item.value);
}
crud.freeRows(model.LocalRecord, gpa, items);
}
const insert_local_record_sql =
@@ -83,28 +72,25 @@ pub fn countLocalRecords(database: *db.Db) db.Error!i64 {
// ---------------------------------------------------------------------------
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();
return crud.listRows(
model.ForwardZone,
database,
gpa,
"SELECT zone, resolver FROM forward_zones ORDER BY zone",
readForwardZone,
);
}
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;
fn readForwardZone(stmt: *db.Stmt, gpa: Allocator) db.Error!model.ForwardZone {
const zone = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(zone);
const resolver = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(resolver);
return .{ .zone = zone, .resolver = resolver };
}
pub fn freeForwardZones(gpa: Allocator, items: []const model.ForwardZone) void {
for (items) |item| {
gpa.free(item.zone);
gpa.free(item.resolver);
}
crud.freeRows(model.ForwardZone, gpa, items);
}
pub fn insertForwardZone(database: *db.Db, item: model.ForwardZone, ctx: InsertContext) db.Error!void {
@@ -145,28 +131,15 @@ const list_local_record_rows_sql =
/// Same order as `listLocalRecords`; every string is a heap copy owned by `gpa`.
pub fn listLocalRecordRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(LocalRecordRow) {
var stmt = try database.prepare(list_local_record_rows_sql);
defer stmt.deinit();
var out: std.ArrayList(LocalRecordRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeLocalRecordRows(gpa, out.items);
while (try stmt.step()) {
const row = try readLocalRecordRow(&stmt, gpa);
errdefer freeLocalRecordRow(gpa, row);
try out.append(gpa, row);
}
return out;
return crud.listRows(LocalRecordRow, database, gpa, list_local_record_rows_sql, readLocalRecordRow);
}
pub fn freeLocalRecordRow(gpa: Allocator, row: LocalRecordRow) void {
gpa.free(row.name);
gpa.free(row.value);
crud.freeRow(LocalRecordRow, gpa, row);
}
pub fn freeLocalRecordRows(gpa: Allocator, items: []const LocalRecordRow) void {
for (items) |item| freeLocalRecordRow(gpa, item);
crud.freeRows(LocalRecordRow, gpa, items);
}
pub fn getLocalRecord(database: *db.Db, gpa: Allocator, id: i64) db.Error!?LocalRecordRow {
@@ -232,28 +205,21 @@ pub const ForwardZoneRow = struct { id: i64, zone: []const u8, resolver: []const
/// Same order as `listForwardZones`; every string is a heap copy owned by `gpa`.
pub fn listForwardZoneRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ForwardZoneRow) {
var stmt = try database.prepare("SELECT id, zone, resolver FROM forward_zones ORDER BY zone");
defer stmt.deinit();
var out: std.ArrayList(ForwardZoneRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeForwardZoneRows(gpa, out.items);
while (try stmt.step()) {
const row = try readForwardZoneRow(&stmt, gpa);
errdefer freeForwardZoneRow(gpa, row);
try out.append(gpa, row);
}
return out;
return crud.listRows(
ForwardZoneRow,
database,
gpa,
"SELECT id, zone, resolver FROM forward_zones ORDER BY zone",
readForwardZoneRow,
);
}
pub fn freeForwardZoneRow(gpa: Allocator, row: ForwardZoneRow) void {
gpa.free(row.zone);
gpa.free(row.resolver);
crud.freeRow(ForwardZoneRow, gpa, row);
}
pub fn freeForwardZoneRows(gpa: Allocator, items: []const ForwardZoneRow) void {
for (items) |item| freeForwardZoneRow(gpa, item);
crud.freeRows(ForwardZoneRow, gpa, items);
}
pub fn getForwardZone(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ForwardZoneRow {
+16 -40
View File
@@ -37,34 +37,23 @@ const list_sql =
/// 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();
return crud.listRows(model.Rule, database, gpa, list_sql, readRule);
}
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;
fn readRule(stmt: *db.Stmt, gpa: Allocator) db.Error!model.Rule {
// 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;
const group = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(group);
const pattern = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(pattern);
return .{ .group = group, .pattern = pattern, .kind = kind, .action = action };
}
pub fn freeRules(gpa: Allocator, items: []const model.Rule) void {
for (items) |item| {
gpa.free(item.group);
gpa.free(item.pattern);
}
crud.freeRows(model.Rule, gpa, items);
}
const insert_sql =
@@ -132,28 +121,15 @@ const get_rule_sql =
/// Same order as `listRules`; every string is a heap copy owned by `gpa`.
pub fn listRuleRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(RuleRow) {
var stmt = try database.prepare(list_rule_rows_sql);
defer stmt.deinit();
var out: std.ArrayList(RuleRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeRuleRows(gpa, out.items);
while (try stmt.step()) {
const row = try readRuleRow(&stmt, gpa);
errdefer freeRuleRow(gpa, row);
try out.append(gpa, row);
}
return out;
return crud.listRows(RuleRow, database, gpa, list_rule_rows_sql, readRuleRow);
}
pub fn freeRuleRow(gpa: Allocator, row: RuleRow) void {
gpa.free(row.group);
gpa.free(row.pattern);
crud.freeRow(RuleRow, gpa, row);
}
pub fn freeRuleRows(gpa: Allocator, items: []const RuleRow) void {
for (items) |item| freeRuleRow(gpa, item);
crud.freeRows(RuleRow, gpa, items);
}
pub fn getRule(database: *db.Db, gpa: Allocator, id: i64) db.Error!?RuleRow {
+16 -20
View File
@@ -15,38 +15,34 @@ const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const crud = @import("crud.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();
return crud.listRows(
model.SettingPair,
database,
gpa,
"SELECT key, value FROM settings ORDER BY key",
readSetting,
);
}
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;
fn readSetting(stmt: *db.Stmt, gpa: Allocator) db.Error!model.SettingPair {
const key = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(key);
const value = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(value);
return .{ .key = key, .value = value };
}
/// 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);
}
crud.freeRows(model.SettingPair, gpa, items);
}
pub fn insertSetting(database: *db.Db, item: model.SettingPair, ctx: InsertContext) db.Error!void {
+24 -44
View File
@@ -25,35 +25,24 @@ const list_sql =
/// 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();
return crud.listRows(model.BlocklistSource, database, gpa, list_sql, readBlocklistSource);
}
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;
fn readBlocklistSource(stmt: *db.Stmt, gpa: Allocator) db.Error!model.BlocklistSource {
const url = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(url);
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
return .{
.url = url,
.name = name,
.enabled = stmt.columnBool(2),
.is_suggested = stmt.columnBool(3),
};
}
pub fn freeBlocklistSources(gpa: Allocator, items: []const model.BlocklistSource) void {
for (items) |item| {
gpa.free(item.url);
gpa.free(item.name);
}
crud.freeRows(model.BlocklistSource, gpa, items);
}
const insert_sql =
@@ -125,21 +114,7 @@ const list_rows_sql = row_columns_sql ++ " ORDER BY url";
/// order `listBlocklistSources` uses. Every string is a heap copy owned by
/// `gpa`; free the whole list with `freeSourceRows` and then `deinit` the list.
pub fn listSourceRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(SourceRow) {
var stmt = try database.prepare(list_rows_sql);
defer stmt.deinit();
var out: std.ArrayList(SourceRow) = .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 freeSourceRows(gpa, out.items);
while (try stmt.step()) {
const row = try readSourceRow(&stmt, gpa);
errdefer freeSourceRow(gpa, row);
try out.append(gpa, row);
}
return out;
return crud.listRows(SourceRow, database, gpa, list_rows_sql, readSourceRow);
}
fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow {
@@ -163,14 +138,13 @@ fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow {
};
}
/// Frees `url`, `name` and the `checksum` payload when it is not null.
pub fn freeSourceRow(gpa: Allocator, row: SourceRow) void {
gpa.free(row.url);
gpa.free(row.name);
if (row.checksum) |value| gpa.free(value);
crud.freeRow(SourceRow, gpa, row);
}
pub fn freeSourceRows(gpa: Allocator, items: []const SourceRow) void {
for (items) |item| freeSourceRow(gpa, item);
crud.freeRows(SourceRow, gpa, items);
}
const update_stats_sql =
@@ -438,6 +412,12 @@ fn listSourceRowsUnderFailure(gpa: Allocator) !void {
var rows = try listSourceRows(&database, gpa);
defer rows.deinit(gpa);
defer freeSourceRows(gpa, rows.items);
// `checksum` is the one allocated optional in this directory. Without a
// non-null one in the result the injection never reaches its allocation and
// this test stops covering the shape it exists for. Row id 1 sorts last:
// `seedSources` inserts `c.example` first and the list orders by url.
try testing.expect(rows.items[2].checksum != null);
}
test "listSourceRows is leak-safe under allocation failure" {
+22 -42
View File
@@ -21,38 +21,31 @@ 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(
return crud.listRows(
model.UpstreamServer,
database,
gpa,
"SELECT url, priority, enabled, tls_name FROM upstreams ORDER BY priority, url",
readUpstream,
);
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;
const tls_name = try stmt.columnTextAlloc(gpa, 3);
errdefer gpa.free(tls_name);
try out.append(gpa, .{
.url = url,
.priority = priority,
.enabled = stmt.columnBool(2),
.tls_name = tls_name,
});
}
return out;
fn readUpstream(stmt: *db.Stmt, gpa: Allocator) db.Error!model.UpstreamServer {
const priority = std.math.cast(i32, stmt.columnInt(1)) orelse return error.Mismatch;
const url = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(url);
const tls_name = try stmt.columnTextAlloc(gpa, 3);
errdefer gpa.free(tls_name);
return .{
.url = url,
.priority = priority,
.enabled = stmt.columnBool(2),
.tls_name = tls_name,
};
}
pub fn freeUpstreams(gpa: Allocator, items: []const model.UpstreamServer) void {
for (items) |item| {
gpa.free(item.url);
gpa.free(item.tls_name);
}
crud.freeRows(model.UpstreamServer, gpa, items);
}
pub fn insertUpstream(database: *db.Db, item: model.UpstreamServer, ctx: InsertContext) db.Error!void {
@@ -101,28 +94,15 @@ const get_upstream_sql =
/// Same order as `listUpstreams`; every string is a heap copy owned by `gpa`.
pub fn listUpstreamRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(UpstreamRow) {
var stmt = try database.prepare(list_upstream_rows_sql);
defer stmt.deinit();
var out: std.ArrayList(UpstreamRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeUpstreamRows(gpa, out.items);
while (try stmt.step()) {
const row = try readUpstreamRow(&stmt, gpa);
errdefer freeUpstreamRow(gpa, row);
try out.append(gpa, row);
}
return out;
return crud.listRows(UpstreamRow, database, gpa, list_upstream_rows_sql, readUpstreamRow);
}
pub fn freeUpstreamRow(gpa: Allocator, row: UpstreamRow) void {
gpa.free(row.url);
gpa.free(row.tls_name);
crud.freeRow(UpstreamRow, gpa, row);
}
pub fn freeUpstreamRows(gpa: Allocator, items: []const UpstreamRow) void {
for (items) |item| freeUpstreamRow(gpa, item);
crud.freeRows(UpstreamRow, gpa, items);
}
pub fn getUpstream(database: *db.Db, gpa: Allocator, id: i64) db.Error!?UpstreamRow {