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

This commit is contained in:
2026-08-01 14:21:44 +02:00
parent 17d0401f8a
commit 70bff22d75
23 changed files with 10142 additions and 47 deletions
+147
View File
@@ -0,0 +1,147 @@
//! The `config.db` schema, verbatim from PLAN §11.2, plus the two table orders
//! every other storage session needs.
//!
//! The DDL text is data, not code: `migrations.zig` carries it as step 1 and
//! never edits it in place. A schema change is a *new* step with new DDL, so
//! this string stays byte-identical to PLAN §11.2 forever.
const std = @import("std");
/// Migration step 1. Multi-statement text — it goes through `db.Db.exec`,
/// never through `prepare`.
pub const ddl_v1: [:0]const u8 =
\\CREATE TABLE schema_version (version INTEGER NOT NULL);
\\
\\CREATE TABLE groups (
\\ id INTEGER PRIMARY KEY,
\\ name TEXT NOT NULL UNIQUE,
\\ safe_search INTEGER NOT NULL DEFAULT 0
\\);
\\INSERT OR IGNORE INTO groups (id, name) VALUES (1, 'default');
\\
\\CREATE TABLE clients (
\\ id INTEGER PRIMARY KEY,
\\ ip TEXT NOT NULL UNIQUE, -- canonical text form (v4 dotted / v6 RFC 5952)
\\ name TEXT,
\\ group_id INTEGER NOT NULL REFERENCES groups(id),
\\ hand_edited INTEGER NOT NULL DEFAULT 0,
\\ first_seen INTEGER NOT NULL,
\\ last_seen INTEGER NOT NULL
\\);
\\
\\CREATE TABLE client_prefixes (
\\ id INTEGER PRIMARY KEY,
\\ prefix TEXT NOT NULL UNIQUE, -- "192.168.1.0/24", "fd00:abcd::/48"
\\ group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
\\ priority INTEGER NOT NULL DEFAULT 100
\\);
\\
\\CREATE TABLE upstreams (
\\ id INTEGER PRIMARY KEY,
\\ url TEXT NOT NULL UNIQUE,
\\ priority INTEGER NOT NULL DEFAULT 100,
\\ enabled INTEGER NOT NULL DEFAULT 1
\\);
\\
\\CREATE TABLE blocklist_sources (
\\ id INTEGER PRIMARY KEY,
\\ url TEXT NOT NULL UNIQUE,
\\ name TEXT NOT NULL,
\\ enabled INTEGER NOT NULL DEFAULT 1,
\\ is_suggested INTEGER NOT NULL DEFAULT 0,
\\ last_updated INTEGER,
\\ domain_count INTEGER NOT NULL DEFAULT 0,
\\ wildcard_count INTEGER NOT NULL DEFAULT 0,
\\ skipped_regex_count INTEGER NOT NULL DEFAULT 0,
\\ checksum TEXT
\\);
\\
\\CREATE TABLE group_sources (
\\ group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
\\ source_id INTEGER NOT NULL REFERENCES blocklist_sources(id) ON DELETE CASCADE,
\\ PRIMARY KEY (group_id, source_id)
\\);
\\
\\CREATE TABLE rules (
\\ id INTEGER PRIMARY KEY,
\\ group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
\\ pattern TEXT NOT NULL,
\\ kind TEXT NOT NULL CHECK(kind IN ('exact','wildcard')),
\\ action TEXT NOT NULL CHECK(action IN ('allow','block')),
\\ created_at INTEGER NOT NULL
\\);
\\
\\CREATE TABLE local_records (
\\ id INTEGER PRIMARY KEY,
\\ name TEXT NOT NULL,
\\ rtype TEXT NOT NULL CHECK(rtype IN ('A','AAAA','CNAME')),
\\ value TEXT NOT NULL,
\\ ttl INTEGER NOT NULL DEFAULT 300,
\\ UNIQUE(name, rtype, value)
\\);
\\
\\CREATE TABLE forward_zones (
\\ id INTEGER PRIMARY KEY,
\\ zone TEXT NOT NULL UNIQUE,
\\ resolver TEXT NOT NULL -- "udp://192.168.1.1:53"
\\);
\\
\\CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
;
/// Child-before-parent. Used by import's wipe step; correct under
/// `foreign_keys = ON`.
///
/// `upstreams`, `local_records`, `forward_zones` and `settings` have no foreign
/// keys, so their position is free; `groups` and `blocklist_sources` must come
/// last, after every referrer. `schema_version` is deliberately absent — an
/// import must never erase the stamped migration version.
pub const delete_order = [_][]const u8{
"group_sources", "rules", "client_prefixes", "clients",
"upstreams", "local_records", "forward_zones", "settings",
"blocklist_sources", "groups",
};
/// Every table whose emptiness defines "the database has never been configured"
/// (S5.2). `groups` is absent because migration step 1 seeds `(1, 'default')`,
/// so an empty database still holds one group row; `schema_version` is absent
/// for the same reason.
pub const content_tables = [_][]const u8{
"clients", "client_prefixes", "upstreams", "blocklist_sources",
"group_sources", "rules", "local_records", "forward_zones",
"settings",
};
const testing = std.testing;
test "delete_order lists every referrer before the table it references" {
// The two parents in the schema. Every child that references them must be
// deleted first, or `foreign_keys = ON` turns import's wipe into a
// Constraint error inside the transaction.
const referrers_of_groups = [_][]const u8{ "clients", "client_prefixes", "group_sources", "rules" };
const referrers_of_sources = [_][]const u8{"group_sources"};
try testing.expect(indexOf(&delete_order, "groups") != null);
for (referrers_of_groups) |child| {
try testing.expect(indexOf(&delete_order, child).? < indexOf(&delete_order, "groups").?);
}
for (referrers_of_sources) |child| {
try testing.expect(indexOf(&delete_order, child).? < indexOf(&delete_order, "blocklist_sources").?);
}
}
test "content_tables is delete_order without groups" {
try testing.expectEqual(delete_order.len - 1, content_tables.len);
for (content_tables) |name| {
try testing.expect(indexOf(&delete_order, name) != null);
}
try testing.expect(indexOf(&content_tables, "groups") == null);
try testing.expect(indexOf(&content_tables, "schema_version") == null);
}
fn indexOf(haystack: []const []const u8, needle: []const u8) ?usize {
for (haystack, 0..) |item, i| {
if (std.mem.eql(u8, item, needle)) return i;
}
return null;
}
+748
View File
@@ -0,0 +1,748 @@
//! The whole SQLite surface nxdns owns (PLAN Decision G). Nothing above this
//! file calls SQLite directly.
//!
//! **This file takes no `std.Io`.** It is the one deliberate exception to
//! Decision E. SQLite performs its own file I/O through its VFS; routing it
//! through `std.Io` would mean writing a custom SQLite VFS — a large,
//! security-sensitive component bought for nothing at household scale. Every
//! other storage file that touches the filesystem takes `io: std.Io`.
//!
//! The C API is declared by hand below. No `@cImport` — the handles stay
//! opaque, matching `src/platform/tls_server.zig`'s Mbed TLS approach.
const std = @import("std");
const assert = std.debug.assert;
const log = std.log.scoped(.db);
pub const c = struct {
pub const Sqlite3 = opaque {};
pub const Stmt = opaque {};
/// The C prototype is a function pointer, but the only value nxdns passes
/// is the `SQLITE_TRANSIENT` sentinel (-1), which is not a valid function
/// address — a Zig fn-pointer type would reject it on targets with aligned
/// function pointers (aarch64). `?*anyopaque` is ABI-identical.
pub const Destructor = ?*anyopaque;
/// `SQLITE_TRANSIENT`: tells SQLite to copy the bound bytes immediately.
pub const transient: Destructor = @ptrFromInt(@as(usize, @bitCast(@as(isize, -1))));
pub extern fn sqlite3_open_v2(filename: [*:0]const u8, ppDb: *?*Sqlite3, flags: c_int, zVfs: ?[*:0]const u8) c_int;
pub extern fn sqlite3_close_v2(db: ?*Sqlite3) c_int;
pub extern fn sqlite3_extended_result_codes(db: *Sqlite3, onoff: c_int) c_int;
pub extern fn sqlite3_busy_timeout(db: *Sqlite3, ms: c_int) c_int;
pub extern fn sqlite3_exec(db: *Sqlite3, sql: [*:0]const u8, cb: ?*const anyopaque, arg: ?*anyopaque, errmsg: ?*?[*:0]u8) c_int;
pub extern fn sqlite3_errmsg(db: *Sqlite3) [*:0]const u8;
pub extern fn sqlite3_errcode(db: *Sqlite3) c_int;
pub extern fn sqlite3_extended_errcode(db: *Sqlite3) c_int;
pub extern fn sqlite3_errstr(code: c_int) [*:0]const u8;
pub extern fn sqlite3_prepare_v2(db: *Sqlite3, sql: [*]const u8, n_byte: c_int, ppStmt: *?*c.Stmt, pzTail: ?*?[*]const u8) c_int;
pub extern fn sqlite3_step(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_reset(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_clear_bindings(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_finalize(stmt: ?*c.Stmt) c_int;
pub extern fn sqlite3_bind_int64(stmt: *c.Stmt, idx: c_int, value: i64) c_int;
pub extern fn sqlite3_bind_text(stmt: *c.Stmt, idx: c_int, text: [*]const u8, n: c_int, d: Destructor) c_int;
pub extern fn sqlite3_bind_null(stmt: *c.Stmt, idx: c_int) c_int;
pub extern fn sqlite3_bind_parameter_count(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_column_count(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_column_type(stmt: *c.Stmt, col: c_int) c_int;
pub extern fn sqlite3_column_int64(stmt: *c.Stmt, col: c_int) i64;
pub extern fn sqlite3_column_text(stmt: *c.Stmt, col: c_int) ?[*]const u8;
pub extern fn sqlite3_column_bytes(stmt: *c.Stmt, col: c_int) c_int;
pub extern fn sqlite3_last_insert_rowid(db: *Sqlite3) i64;
pub extern fn sqlite3_changes(db: *Sqlite3) c_int;
};
/// Result codes, from the vendored `sqlite3.h` (3.53.4).
pub const result = struct {
pub const ok: c_int = 0;
pub const err: c_int = 1;
pub const internal: c_int = 2;
pub const perm: c_int = 3;
pub const abort: c_int = 4;
pub const busy: c_int = 5;
pub const locked: c_int = 6;
pub const nomem: c_int = 7;
pub const readonly: c_int = 8;
pub const interrupt: c_int = 9;
pub const ioerr: c_int = 10;
pub const corrupt: c_int = 11;
pub const notfound: c_int = 12;
pub const full: c_int = 13;
pub const cantopen: c_int = 14;
pub const protocol: c_int = 15;
pub const empty: c_int = 16;
pub const schema: c_int = 17;
pub const toobig: c_int = 18;
pub const constraint: c_int = 19;
pub const mismatch: c_int = 20;
pub const misuse: c_int = 21;
pub const nolfs: c_int = 22;
pub const auth: c_int = 23;
pub const format: c_int = 24;
pub const range: c_int = 25;
pub const notadb: c_int = 26;
pub const row: c_int = 100;
pub const done: c_int = 101;
};
/// Open flags, from the vendored `sqlite3.h` (3.53.4).
pub const open_flag = struct {
pub const readonly: c_int = 0x1;
pub const readwrite: c_int = 0x2;
pub const create: c_int = 0x4;
pub const uri: c_int = 0x40;
pub const nomutex: c_int = 0x8000;
pub const fullmutex: c_int = 0x10000;
pub const exrescode: c_int = 0x2000000;
};
/// Column type codes returned by `sqlite3_column_type`.
pub const column_type = struct {
pub const integer: c_int = 1;
pub const float: c_int = 2;
pub const text: c_int = 3;
pub const blob: c_int = 4;
pub const null_value: c_int = 5;
};
pub const Error = error{
Abort,
Auth,
Busy,
CantOpen,
Constraint,
Corrupt,
Empty,
Format,
Full,
Internal,
Interrupt,
IoErr,
Locked,
Mismatch,
Misuse,
NoLfs,
NotADb,
NotFound,
Perm,
Protocol,
Range,
ReadOnly,
Schema,
TooBig,
SqliteError,
OutOfMemory,
Unexpected,
};
/// Maps a primary SQLite result code to `Error`. `SQLITE_NOMEM` becomes
/// `error.OutOfMemory` so it joins `transport.LocalResource` semantics: out of
/// memory is never the data's fault.
///
/// The switch runs on the primary code (`code & 0xff`), so every extended code
/// (`SQLITE_IOERR_*`, `SQLITE_CONSTRAINT_*`, `SQLITE_BUSY_SNAPSHOT`, …) lands on
/// its family. The extended code stays visible to humans through `Db.lastError`.
///
/// `SQLITE_ERROR` — the generic "SQL error" — maps to `error.Unexpected`, not to
/// `error.SqliteError`. `SqliteError` is reserved for a primary code this
/// function does not know, so an unmapped future code stays distinguishable
/// from an ordinary SQL error.
pub fn mapCode(code: c_int) Error {
const primary = code & 0xff;
assert(primary != result.ok);
assert(primary != result.row);
assert(primary != result.done);
return switch (primary) {
result.err => error.Unexpected,
result.internal => error.Internal,
result.perm => error.Perm,
result.abort => error.Abort,
result.busy => error.Busy,
result.locked => error.Locked,
result.nomem => error.OutOfMemory,
result.readonly => error.ReadOnly,
result.interrupt => error.Interrupt,
result.ioerr => error.IoErr,
result.corrupt => error.Corrupt,
result.notfound => error.NotFound,
result.full => error.Full,
result.cantopen => error.CantOpen,
result.protocol => error.Protocol,
result.empty => error.Empty,
result.schema => error.Schema,
result.toobig => error.TooBig,
result.constraint => error.Constraint,
result.mismatch => error.Mismatch,
result.misuse => error.Misuse,
result.nolfs => error.NoLfs,
result.auth => error.Auth,
result.format => error.Format,
result.range => error.Range,
result.notadb => error.NotADb,
else => error.SqliteError,
};
}
fn check(code: c_int) Error!void {
if (code == result.ok) return;
return mapCode(code);
}
pub const OpenMode = enum { read_write_create, read_write_existing, read_only, memory };
pub const OpenOptions = struct {
mode: OpenMode = .read_write_create,
busy_timeout_ms: c_int = 5000,
};
/// One SQLite connection.
///
/// A `Db` must not move once a `Stmt` prepared from it is alive: every `Stmt`
/// holds a `*Db`.
pub const Db = struct {
handle: *c.Sqlite3,
/// Every mode carries `FULLMUTEX` (serialized mode). Phase 6's query logger
/// and Phase 8's API handlers share one handle across `std.Io` tasks, and a
/// per-handle mutex inside SQLite is cheaper to be correct about than a
/// hand-rolled one; `config.db` write volume is negligible. `EXRESCODE`
/// makes `sqlite3_extended_errcode` meaningful from the first call.
///
/// `open` deliberately applies no pragmas — see `applyPragmas`, which the
/// migration runner must call before it opens a transaction.
///
/// For `.memory`, `path` is ignored and `":memory:"` is used.
pub fn open(path: [:0]const u8, options: OpenOptions) Error!Db {
const base = open_flag.exrescode | open_flag.fullmutex;
const flags: c_int = switch (options.mode) {
.read_write_create, .memory => base | open_flag.readwrite | open_flag.create,
.read_write_existing => base | open_flag.readwrite,
.read_only => base | open_flag.readonly,
};
const filename: [:0]const u8 = switch (options.mode) {
.memory => ":memory:",
else => path,
};
var handle: ?*c.Sqlite3 = null;
const rc = c.sqlite3_open_v2(filename.ptr, &handle, flags, null);
if (rc != result.ok) {
// sqlite3_open_v2 allocates a handle even on failure. Read the
// message from it, then close it; dropping it leaks on every
// failed open.
// Logged at `warn`, not `err`: the failure itself reaches the
// caller as a typed error, and this line only carries the message
// that would otherwise die with the handle.
if (handle) |h| {
log.warn("sqlite3_open_v2 failed for '{s}': {s} (code {d}/{d})", .{
filename,
std.mem.span(c.sqlite3_errmsg(h)),
rc & 0xff,
c.sqlite3_extended_errcode(h),
});
_ = c.sqlite3_close_v2(h);
} else {
log.warn("sqlite3_open_v2 failed for '{s}': {s} (code {d})", .{
filename,
std.mem.span(c.sqlite3_errstr(rc)),
rc,
});
}
return mapCode(rc);
}
const h = handle orelse return error.SqliteError;
// A silently ignored busy timeout is how a contended WAL database turns
// into random SQLITE_BUSY failures under load.
check(c.sqlite3_busy_timeout(h, options.busy_timeout_ms)) catch |e| {
_ = c.sqlite3_close_v2(h);
return e;
};
return .{ .handle = h };
}
pub fn close(self: *Db) void {
const rc = c.sqlite3_close_v2(self.handle);
if (rc != result.ok) {
log.err("sqlite3_close_v2 returned {s} (code {d})", .{
std.mem.span(c.sqlite3_errstr(rc)),
rc,
});
}
}
/// Borrowed; valid until the next SQLite call on this handle. Formats as
/// "<message> (code <primary>/<extended>)".
pub fn lastError(self: *Db, buf: []u8) []const u8 {
const extended = c.sqlite3_extended_errcode(self.handle);
const message = std.mem.span(c.sqlite3_errmsg(self.handle));
return std.fmt.bufPrint(buf, "{s} (code {d}/{d})", .{
message,
extended & 0xff,
extended,
}) catch "sqlite error (message did not fit the buffer)";
}
/// For DDL and multi-statement scripts. `errmsg` is passed as null and the
/// message is read back through `sqlite3_errmsg`, so there is no
/// `sqlite3_free` obligation.
pub fn exec(self: *Db, sql: [:0]const u8) Error!void {
return check(c.sqlite3_exec(self.handle, sql.ptr, null, null, null));
}
/// `sql` must hold exactly one statement; text with a second statement in it
/// is `error.Misuse` and belongs in `exec`.
pub fn prepare(self: *Db, sql: []const u8) Error!Stmt {
if (sql.len > std.math.maxInt(c_int)) return error.TooBig;
var handle: ?*c.Stmt = null;
var tail: ?[*]const u8 = null;
try check(c.sqlite3_prepare_v2(self.handle, sql.ptr, @intCast(sql.len), &handle, &tail));
const h = handle orelse return error.Misuse;
const tail_ptr = tail orelse sql.ptr + sql.len;
const consumed = @intFromPtr(tail_ptr) - @intFromPtr(sql.ptr);
const remaining = std.mem.trim(u8, sql[consumed..], " \t\r\n");
if (remaining.len != 0) {
_ = c.sqlite3_finalize(h);
return error.Misuse;
}
return .{ .handle = h, .db = self };
}
/// Runs `sql` (which must yield exactly one row with one integer column) and
/// returns it. A statement that produces no row is `error.SqliteError`.
///
/// The row shape is verified, not assumed: a result with a column count
/// other than 1, a first column that is not `SQLITE_INTEGER` (NULL, text,
/// float and blob all count), or a second row is `error.Misuse`. That is the
/// same member `prepare` returns for a caller that hands it the wrong SQL,
/// because these are the same class of fault — a caller bug or schema drift,
/// never a runtime condition. Without the checks a `SELECT` of the wrong
/// column silently returns 0.
pub fn queryInt(self: *Db, sql: []const u8) Error!i64 {
var stmt = try self.prepare(sql);
defer stmt.deinit();
if (!try stmt.step()) {
log.warn("queryInt produced no row for '{s}'", .{sql});
return error.SqliteError;
}
const columns = c.sqlite3_column_count(stmt.handle);
if (columns != 1) {
log.warn("queryInt expects 1 column, got {d}, for '{s}'", .{ columns, sql });
return error.Misuse;
}
const kind = c.sqlite3_column_type(stmt.handle, 0);
if (kind != column_type.integer) {
log.warn("queryInt expects an integer column, got type {d}, for '{s}'", .{ kind, sql });
return error.Misuse;
}
const value = stmt.columnInt(0);
if (try stmt.step()) {
log.warn("queryInt expects 1 row, got more, for '{s}'", .{sql});
return error.Misuse;
}
return value;
}
pub fn lastInsertRowid(self: *Db) i64 {
return c.sqlite3_last_insert_rowid(self.handle);
}
pub fn changes(self: *Db) i64 {
return c.sqlite3_changes(self.handle);
}
};
/// One prepared statement.
///
/// There is deliberately **no prepared-statement cache in this milestone**.
/// `config.db` is written a handful of times per process lifetime, so a cache is
/// unmeasured complexity here. Phase 6's query-log flush loop is the only hot
/// path and it owns its own long-lived statements. This is a decision, not an
/// oversight against PLAN §3.4.
pub const Stmt = struct {
handle: *c.Stmt,
db: *Db,
/// The code of the last failed `step`, or `SQLITE_OK`. `sqlite3_reset` and
/// `sqlite3_finalize` both re-report that code; without this the caller
/// would see one failure logged as a second, unrelated one.
pending_error: c_int = result.ok,
pub fn deinit(self: *Stmt) void {
const rc = c.sqlite3_finalize(self.handle);
if (rc != result.ok and rc != self.pending_error) {
log.err("sqlite3_finalize returned {s} (code {d})", .{
std.mem.span(c.sqlite3_errstr(rc)),
rc,
});
}
}
pub fn reset(self: *Stmt) Error!void {
const rc = c.sqlite3_reset(self.handle);
self.pending_error = result.ok;
try check(rc);
try check(c.sqlite3_clear_bindings(self.handle));
}
/// 1-based, matching SQLite.
pub fn bindInt(self: *Stmt, idx: c_int, value: i64) Error!void {
return check(c.sqlite3_bind_int64(self.handle, idx, value));
}
pub fn bindBool(self: *Stmt, idx: c_int, value: bool) Error!void {
return self.bindInt(idx, if (value) 1 else 0);
}
/// Binds with `SQLITE_TRANSIENT`, so SQLite copies the bytes and the caller
/// never has to keep `value` alive. The copy costs an allocation per bind;
/// at config.db volumes that is invisible, and it removes a whole class of
/// use-after-free from every caller.
pub fn bindText(self: *Stmt, idx: c_int, value: []const u8) Error!void {
if (value.len > std.math.maxInt(c_int)) return error.TooBig;
return check(c.sqlite3_bind_text(self.handle, idx, value.ptr, @intCast(value.len), c.transient));
}
pub fn bindTextOrNull(self: *Stmt, idx: c_int, value: ?[]const u8) Error!void {
if (value) |v| return self.bindText(idx, v);
return self.bindNull(idx);
}
pub fn bindNull(self: *Stmt, idx: c_int) Error!void {
return check(c.sqlite3_bind_null(self.handle, idx));
}
/// true = a row is available, false = the statement finished.
pub fn step(self: *Stmt) Error!bool {
const rc = c.sqlite3_step(self.handle);
if (rc == result.row) return true;
if (rc == result.done) return false;
self.pending_error = rc;
return mapCode(rc);
}
/// Runs to completion; asserts no rows were produced.
pub fn exec(self: *Stmt) Error!void {
const has_row = try self.step();
assert(!has_row);
}
pub fn columnInt(self: *Stmt, col: c_int) i64 {
return c.sqlite3_column_int64(self.handle, col);
}
pub fn columnBool(self: *Stmt, col: c_int) bool {
return self.columnInt(col) != 0;
}
pub fn isNull(self: *Stmt, col: c_int) bool {
return c.sqlite3_column_type(self.handle, col) == column_type.null_value;
}
/// Borrowed: valid only until the next `step`, `reset` or `deinit` on this
/// statement. Every caller that keeps the value must copy it.
///
/// A NULL column reads as `""`. A `NOT NULL` column makes that unreachable
/// in practice, but it must not be undefined behaviour.
pub fn columnText(self: *Stmt, col: c_int) []const u8 {
const ptr = c.sqlite3_column_text(self.handle, col) orelse return "";
const len = c.sqlite3_column_bytes(self.handle, col);
if (len <= 0) return "";
return ptr[0..@intCast(len)];
}
/// Borrowed under the same rules as `columnText`; NULL reads as `null`.
pub fn columnTextOrNull(self: *Stmt, col: c_int) ?[]const u8 {
if (self.isNull(col)) return null;
return self.columnText(col);
}
/// Copies into `gpa`. Caller owns the result.
pub fn columnTextAlloc(self: *Stmt, gpa: std.mem.Allocator, col: c_int) error{OutOfMemory}![]u8 {
return gpa.dupe(u8, self.columnText(col));
}
/// Copies into `gpa`. Caller owns the result. NULL reads as `null`.
pub fn columnTextAllocOrNull(self: *Stmt, gpa: std.mem.Allocator, col: c_int) error{OutOfMemory}!?[]u8 {
const value = self.columnTextOrNull(col) orelse return null;
return try gpa.dupe(u8, value);
}
};
pub const Pragmas = struct {
journal_wal: bool = true,
synchronous_normal: bool = true,
foreign_keys: bool = true,
};
/// MUST be called before any transaction is opened: `PRAGMA foreign_keys` is a
/// no-op inside a transaction, so applying it later silently leaves referential
/// integrity off.
pub fn applyPragmas(self: *Db, p: Pragmas) Error!void {
if (p.journal_wal) {
// The pragma returns a row holding the mode it actually reached. `exec`
// would discard that answer, and an in-memory database — which cannot do
// WAL — would look fine.
var stmt = try self.prepare("PRAGMA journal_mode = WAL");
defer stmt.deinit();
if (!try stmt.step()) return error.SqliteError;
const mode = stmt.columnText(0);
const wal = std.ascii.eqlIgnoreCase(mode, "wal");
const memory = std.ascii.eqlIgnoreCase(mode, "memory");
if (!wal and !memory) {
log.warn("PRAGMA journal_mode = WAL reported '{s}'", .{mode});
return error.SqliteError;
}
}
if (p.synchronous_normal) {
try self.exec("PRAGMA synchronous = NORMAL;");
}
if (p.foreign_keys) {
try self.exec("PRAGMA foreign_keys = ON;");
if (try self.queryInt("PRAGMA foreign_keys") != 1) {
log.warn("PRAGMA foreign_keys did not take", .{});
return error.SqliteError;
}
}
}
/// A write transaction.
///
/// Usage contract, followed everywhere in this milestone:
///
/// ```zig
/// var tx = try Tx.begin(db);
/// errdefer tx.rollback();
/// ... // all writes
/// try tx.commit();
/// ```
///
/// `commit` and `rollback` both clear `active`, so the `errdefer` after a
/// successful commit is a no-op.
pub const Tx = struct {
db: *Db,
active: bool,
/// BEGIN IMMEDIATE — takes the write lock up front. A deferred transaction
/// that upgrades mid-way can fail with SQLITE_BUSY after arbitrary work;
/// immediate cannot.
pub fn begin(db: *Db) Error!Tx {
try db.exec("BEGIN IMMEDIATE;");
return .{ .db = db, .active = true };
}
pub fn commit(self: *Tx) Error!void {
assert(self.active);
try self.db.exec("COMMIT;");
self.active = false;
}
/// Safe in `errdefer` and after `commit`. Never returns an error; a failed
/// ROLLBACK is logged at `err` level with the SQLite message, because a
/// database that will not roll back is an operational event, not a detail.
pub fn rollback(self: *Tx) void {
if (!self.active) return;
self.active = false;
self.db.exec("ROLLBACK;") catch {
var buf: [256]u8 = undefined;
log.err("ROLLBACK failed: {s}", .{self.db.lastError(&buf)});
};
}
};
const testing = std.testing;
fn openMemory() Error!Db {
return Db.open(":memory:", .{ .mode = .memory });
}
test "mapCode maps every primary result code to a distinct error" {
var seen: [26]Error = undefined;
var code: c_int = 1;
while (code <= 26) : (code += 1) {
seen[@intCast(code - 1)] = mapCode(code);
}
for (seen, 0..) |a, i| {
for (seen[i + 1 ..]) |b| {
try testing.expect(a != b);
}
}
}
test "mapCode maps SQLITE_NOMEM to error.OutOfMemory and keeps extended codes in the family" {
try testing.expectEqual(Error.OutOfMemory, mapCode(result.nomem));
// SQLITE_IOERR_READ = 266, SQLITE_CONSTRAINT_UNIQUE = 2067.
try testing.expectEqual(Error.IoErr, mapCode(266));
try testing.expectEqual(Error.Constraint, mapCode(2067));
// A primary code this build does not know stays visible as SqliteError.
try testing.expectEqual(Error.SqliteError, mapCode(99));
}
test "open and close an in-memory database" {
var db = try openMemory();
defer db.close();
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT 1"));
}
test "queryInt rejects a result that is not exactly one row of one integer" {
var db = try openMemory();
defer db.close();
// No row keeps the documented error.SqliteError.
try testing.expectError(error.SqliteError, db.queryInt("SELECT 1 WHERE 0"));
// Wrong column count.
try testing.expectError(error.Misuse, db.queryInt("SELECT 1, 2"));
// Wrong column type: NULL, text, float and blob are all rejected.
try testing.expectError(error.Misuse, db.queryInt("SELECT NULL"));
try testing.expectError(error.Misuse, db.queryInt("SELECT 'one'"));
try testing.expectError(error.Misuse, db.queryInt("SELECT 1.5"));
try testing.expectError(error.Misuse, db.queryInt("SELECT x'00'"));
// A second row.
try testing.expectError(error.Misuse, db.queryInt("SELECT 1 UNION ALL SELECT 2"));
}
test "queryInt accepts a single integer row after the shape checks" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);");
try db.exec("INSERT INTO t (id, name) VALUES (7, 'only');");
try testing.expectEqual(@as(i64, 7), try db.queryInt("SELECT id FROM t"));
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
try testing.expectEqual(@as(i64, -3), try db.queryInt("SELECT -3"));
// sum() over an empty table is NULL, not an integer: a caller that wants a
// total from a possibly-empty table must write total(), or COALESCE.
try testing.expectError(error.Misuse, db.queryInt("SELECT sum(id) FROM t WHERE 0"));
try testing.expectEqual(@as(i64, 7), try db.queryInt("SELECT sum(id) FROM t"));
}
test "applyPragmas succeeds and foreign_keys reads back as 1" {
var db = try openMemory();
defer db.close();
try applyPragmas(&db, .{});
try testing.expectEqual(@as(i64, 1), try db.queryInt("PRAGMA foreign_keys"));
}
test "open on a directory path returns error.CantOpen and leaks no handle" {
var i: usize = 0;
while (i < 1000) : (i += 1) {
try testing.expectError(error.CantOpen, Db.open(".", .{}));
}
}
test "prepare rejects text holding more than one statement" {
var db = try openMemory();
defer db.close();
try testing.expectError(error.Misuse, db.prepare("SELECT 1; SELECT 2"));
var stmt = try db.prepare("SELECT 1;");
stmt.deinit();
}
test "bind, step and column round-trip including a NULL text column" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, note TEXT, flag INTEGER NOT NULL);");
var insert = try db.prepare("INSERT INTO t (name, note, flag) VALUES (?1, ?2, ?3)");
defer insert.deinit();
try insert.bindText(1, "kitchen");
try insert.bindTextOrNull(2, null);
try insert.bindBool(3, true);
try insert.exec();
try testing.expectEqual(@as(i64, 1), db.changes());
try testing.expectEqual(@as(i64, 1), db.lastInsertRowid());
var select = try db.prepare("SELECT id, name, note, flag FROM t");
defer select.deinit();
try testing.expect(try select.step());
try testing.expectEqual(@as(i64, 1), select.columnInt(0));
try testing.expectEqualStrings("kitchen", select.columnText(1));
try testing.expect(select.isNull(2));
try testing.expectEqual(@as(?[]const u8, null), select.columnTextOrNull(2));
try testing.expectEqualStrings("", select.columnText(2));
try testing.expect(select.columnBool(3));
try testing.expect(!try select.step());
}
test "columnTextAlloc returns an owned copy that survives a subsequent step" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);");
try db.exec("INSERT INTO t (id, name) VALUES (1, 'first'), (2, 'second');");
var stmt = try db.prepare("SELECT name FROM t ORDER BY id");
defer stmt.deinit();
try testing.expect(try stmt.step());
const owned = try stmt.columnTextAlloc(testing.allocator, 0);
defer testing.allocator.free(owned);
const owned_or_null = try stmt.columnTextAllocOrNull(testing.allocator, 0);
defer if (owned_or_null) |v| testing.allocator.free(v);
try testing.expect(try stmt.step());
try testing.expectEqualStrings("second", stmt.columnText(0));
try testing.expectEqualStrings("first", owned);
try testing.expectEqualStrings("first", owned_or_null.?);
}
test "transaction commit persists and rollback discards" {
var db = try openMemory();
defer db.close();
try applyPragmas(&db, .{});
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
{
var tx = try Tx.begin(&db);
errdefer tx.rollback();
try db.exec("INSERT INTO t (id) VALUES (1);");
try tx.commit();
}
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
{
var tx = try Tx.begin(&db);
try db.exec("INSERT INTO t (id) VALUES (2);");
tx.rollback();
}
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
}
test "rollback after commit is a no-op" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
var tx = try Tx.begin(&db);
try db.exec("INSERT INTO t (id) VALUES (1);");
try tx.commit();
try testing.expect(!tx.active);
tx.rollback();
tx.rollback();
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
}
test "a row-producing statement reports its row through step" {
var db = try openMemory();
defer db.close();
// Stmt.exec asserts on this shape; the test observes it through `step`
// instead, so the assertion path stays out of the test binary.
var stmt = try db.prepare("SELECT 1");
defer stmt.deinit();
try testing.expect(try stmt.step());
}
test "a duplicate insert into a UNIQUE column returns error.Constraint" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);");
try db.exec("INSERT INTO t (name) VALUES ('only');");
var stmt = try db.prepare("INSERT INTO t (name) VALUES (?1)");
defer stmt.deinit();
try stmt.bindText(1, "only");
try testing.expectError(error.Constraint, stmt.step());
}
+255
View File
@@ -0,0 +1,255 @@
//! The `config.db` migration runner.
//!
//! Steps are compiled into the binary in ascending order and applied inside
//! **one** transaction, then the reached version is stamped. SQLite runs DDL
//! transactionally, so a step that fails leaves the file exactly as it was.
//!
//! A database stamped *newer* than this binary is never silently accepted and
//! never downgraded: it is `error.SchemaTooNew`, distinct from every other
//! error, so the CLI can tell the operator to install a newer nxdns.
const std = @import("std");
const assert = std.debug.assert;
const db = @import("db.zig");
const config_schema = @import("config_schema.zig");
const log = std.log.scoped(.migrations);
pub const Step = struct { version: u32, sql: [:0]const u8 };
pub const steps = [_]Step{
.{ .version = 1, .sql = config_schema.ddl_v1 },
};
pub const target_version: u32 = steps[steps.len - 1].version;
comptime {
assertOrdered(&steps);
}
pub const Error = db.Error || error{ SchemaTooNew, SchemaCorrupt };
/// Versions must be `1, 2, 3, …` with no gaps. A gap would make "apply every
/// step newer than the stamped version" ambiguous about what the stamp means.
fn assertOrdered(list: []const Step) void {
assert(list.len > 0);
for (list, 0..) |step, i| assert(@as(usize, step.version) == i + 1);
}
/// Reads the stamped version, applies every newer step in one transaction and
/// stamps the result. Returns the version now in the file.
///
/// `database` must already have had `db.applyPragmas` called: `PRAGMA
/// foreign_keys` is a no-op inside a transaction, so applying it afterwards
/// would silently leave referential integrity off.
pub fn migrate(database: *db.Db) Error!u32 {
return migrateSteps(database, &steps);
}
/// Same logic against an injected step list. The seam exists for the rollback
/// and stepwise-upgrade tests, which need a second step that `steps` does not
/// yet have.
pub fn migrateSteps(database: *db.Db, list: []const Step) Error!u32 {
assertOrdered(list);
const target = list[list.len - 1].version;
const current = try readVersion(database);
if (current > target) {
log.warn("config.db is at schema version {d}; this nxdns binary supports {d}", .{ current, target });
return error.SchemaTooNew;
}
if (current == target) return current;
var tx = try db.Tx.begin(database);
errdefer tx.rollback();
// Re-read under BEGIN IMMEDIATE. Two processes starting at the same moment
// both saw `current` above; the one that loses the write lock arrives here
// after the other committed and finds nothing to do.
const stamped = try readVersion(database);
if (stamped > target) {
log.warn("config.db is at schema version {d}; this nxdns binary supports {d}", .{ stamped, target });
return error.SchemaTooNew;
}
if (stamped == target) {
try tx.commit();
return stamped;
}
for (list) |step| {
if (step.version <= stamped) continue;
try database.exec(step.sql);
}
try database.exec("DELETE FROM schema_version;");
var stmt = try database.prepare("INSERT INTO schema_version (version) VALUES (?1)");
defer stmt.deinit();
try stmt.bindInt(1, target);
try stmt.exec();
try tx.commit();
log.info("config.db migrated from schema version {d} to {d}", .{ stamped, target });
return target;
}
/// `0` when `schema_version` does not exist yet. Zero rows or more than one row
/// is `error.SchemaCorrupt` — the version of a database is never guessed.
fn readVersion(database: *db.Db) Error!u32 {
const present = try database.queryInt(
"SELECT count(*) FROM sqlite_schema WHERE type='table' AND name='schema_version'",
);
if (present == 0) return 0;
const rows = try database.queryInt("SELECT count(*) FROM schema_version");
if (rows != 1) {
log.warn("schema_version holds {d} rows; exactly one is required", .{rows});
return error.SchemaCorrupt;
}
const version = try database.queryInt("SELECT version FROM schema_version");
if (version < 0 or version > std.math.maxInt(u32)) {
log.warn("schema_version holds an out-of-range version {d}", .{version});
return error.SchemaCorrupt;
}
return @intCast(version);
}
fn tableExists(database: *db.Db, name: []const u8) db.Error!bool {
var stmt = try database.prepare("SELECT count(*) FROM sqlite_schema WHERE type='table' AND name = ?1");
defer stmt.deinit();
try stmt.bindText(1, name);
if (!try stmt.step()) return error.SqliteError;
return stmt.columnInt(0) != 0;
}
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
return database;
}
test "migrate on a fresh database creates every table and seeds the default group" {
var database = try openMigrated();
defer database.close();
try testing.expectEqual(target_version, try migrate(&database));
const expected = [_][]const u8{
"schema_version", "groups", "clients", "client_prefixes",
"upstreams", "rules", "local_records", "forward_zones",
"blocklist_sources", "group_sources", "settings",
};
for (expected) |name| {
try testing.expect(try tableExists(&database, name));
}
try testing.expectEqual(
@as(i64, expected.len),
try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
);
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM groups"));
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT id FROM groups"));
var stmt = try database.prepare("SELECT name, safe_search FROM groups");
defer stmt.deinit();
try testing.expect(try stmt.step());
try testing.expectEqualStrings("default", stmt.columnText(0));
try testing.expect(!stmt.columnBool(1));
}
test "migrate is idempotent" {
var database = try openMigrated();
defer database.close();
try testing.expectEqual(target_version, try migrate(&database));
const before = try database.queryInt("SELECT count(*) FROM sqlite_schema");
const rowid_before = database.lastInsertRowid();
try testing.expectEqual(target_version, try migrate(&database));
try testing.expectEqual(before, try database.queryInt("SELECT count(*) FROM sqlite_schema"));
try testing.expectEqual(rowid_before, database.lastInsertRowid());
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM schema_version"));
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM groups"));
}
test "a database stamped newer than the binary is error.SchemaTooNew" {
var database = try openMigrated();
defer database.close();
_ = try migrate(&database);
const future: i64 = @as(i64, target_version) + 1;
var stmt = try database.prepare("UPDATE schema_version SET version = ?1");
defer stmt.deinit();
try stmt.bindInt(1, future);
try stmt.exec();
try testing.expectError(error.SchemaTooNew, migrate(&database));
try testing.expectEqual(future, try database.queryInt("SELECT version FROM schema_version"));
}
test "schema_version holding two rows is error.SchemaCorrupt" {
var database = try openMigrated();
defer database.close();
_ = try migrate(&database);
try database.exec("INSERT INTO schema_version (version) VALUES (1);");
try testing.expectError(error.SchemaCorrupt, migrate(&database));
}
test "a failing step rolls the whole migration back" {
var database = try openMigrated();
defer database.close();
const broken = [_]Step{
.{ .version = 1, .sql = config_schema.ddl_v1 },
.{ .version = 2, .sql = "CREATE TABLE second (" },
};
// db.zig maps SQLITE_ERROR — the generic "SQL error" — to error.Unexpected.
try testing.expectError(error.Unexpected, migrateSteps(&database, &broken));
try testing.expect(!try tableExists(&database, "schema_version"));
try testing.expect(!try tableExists(&database, "groups"));
try testing.expect(!try tableExists(&database, "second"));
try testing.expectEqual(@as(u32, 0), try readVersion(&database));
}
test "a stepwise upgrade applies only the new steps" {
var database = try openMigrated();
defer database.close();
const first = [_]Step{.{ .version = 1, .sql = config_schema.ddl_v1 }};
try testing.expectEqual(@as(u32, 1), try migrateSteps(&database, &first));
try testing.expect(try tableExists(&database, "groups"));
try testing.expect(!try tableExists(&database, "extra"));
const second = [_]Step{
.{ .version = 1, .sql = config_schema.ddl_v1 },
.{ .version = 2, .sql = "CREATE TABLE extra (id INTEGER PRIMARY KEY);" },
};
try testing.expectEqual(@as(u32, 2), try migrateSteps(&database, &second));
try testing.expect(try tableExists(&database, "extra"));
try testing.expectEqual(@as(u32, 2), try readVersion(&database));
// Step 1 did not run a second time: `groups` still holds one seeded row.
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM groups"));
}
test "delete_order and content_tables name exactly the tables the schema creates" {
var database = try openMigrated();
defer database.close();
_ = try migrate(&database);
for (config_schema.delete_order) |name| {
try testing.expect(try tableExists(&database, name));
}
for (config_schema.content_tables) |name| {
try testing.expect(try tableExists(&database, name));
}
// delete_order covers every table except `schema_version`.
try testing.expectEqual(
@as(i64, config_schema.delete_order.len + 1),
try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
);
}
+280
View File
@@ -0,0 +1,280 @@
//! The `querylog.db` schema and its open-or-recreate policy.
//!
//! `querylog.db` is never migrated (PLAN §3.7). It holds expendable log rows,
//! so a schema change replaces the file instead of upgrading it. The
//! replacement trigger is a fingerprint derived from the DDL text itself, so
//! editing the schema below automatically invalidates every existing file — the
//! policy cannot drift out of sync with the SQL.
//!
//! **Recreating is destructive, so the predicate is a positive whitelist.** Only
//! a missing file, `error.Corrupt`, `error.NotADb`, a failed `PRAGMA
//! quick_check` and a fingerprint mismatch recreate. Every other error
//! propagates and the file on disk is not touched. `error.Busy` / `error.Locked`
//! mean another process holds the write lock — waiting is right, deleting is
//! catastrophic. `error.OutOfMemory` is this process's problem. `error.CantOpen`
//! is usually a permission or missing-directory problem that recreating would
//! mask rather than fix. Same for `error.ReadOnly`, `error.IoErr`, `error.Full`,
//! `error.Perm`, `error.Auth` and `error.Canceled`.
const std = @import("std");
const db = @import("db.zig");
const log = std.log.scoped(.querylog_schema);
/// Verbatim from PLAN §11.3. Multi-statement text — it goes through
/// `db.Db.exec`, never through `prepare`.
pub const ddl: [:0]const u8 =
\\CREATE TABLE domains (
\\ id INTEGER PRIMARY KEY,
\\ domain TEXT NOT NULL UNIQUE
\\);
\\
\\CREATE TABLE query_log (
\\ id INTEGER PRIMARY KEY,
\\ timestamp INTEGER NOT NULL,
\\ domain_id INTEGER NOT NULL REFERENCES domains(id),
\\ client_ip TEXT NOT NULL, -- text, not a FK: log rows are immutable facts
\\ qtype INTEGER,
\\ blocked INTEGER NOT NULL,
\\ block_reason TEXT,
\\ response_time_us INTEGER,
\\ cache_hit INTEGER,
\\ upstream TEXT
\\);
\\CREATE INDEX idx_query_log_ts ON query_log(timestamp);
\\CREATE INDEX idx_query_log_client ON query_log(client_ip);
\\CREATE INDEX idx_query_log_domain ON query_log(domain_id);
;
/// `PRAGMA user_version` is a signed 32-bit field. Deriving the fingerprint from
/// the DDL means editing the schema automatically invalidates every existing
/// file — which is exactly the policy.
pub const fingerprint: i32 = blk: {
// Covers the CRC lookup-table generation in std.hash.crc, which evaluates
// under this scope's quota and overflows the 1000 default (and 100k).
@setEvalBranchQuota(2_000_000);
break :blk @bitCast(std.hash.Crc32.hash(ddl));
};
const set_user_version = std.fmt.comptimePrint("PRAGMA user_version = {d};", .{fingerprint});
/// Long enough for any path this program will be handed, plus the aside suffix.
/// A longer path is `error.NameTooLong`, which is what the filesystem calls
/// would have returned anyway.
const path_buf_len = 4096 + 64;
pub const RecreateReason = enum { missing, corrupt, not_a_database, quick_check_failed, fingerprint_mismatch };
pub const OpenResult = struct {
database: db.Db,
/// Non-null feeds a counter and the `/api/health` rollup in Phase 8.
recreated: ?RecreateReason,
};
pub const Error = db.Error || error{AsideNameCollision} ||
std.Io.Dir.RenamePreserveError || std.Io.Dir.DeleteFileError || std.Io.Dir.AccessError;
/// Opens `path`, recreating it if and only if it is genuinely unusable.
///
/// `path` is resolved twice by two different mechanisms: `dir`-relative for the
/// filesystem calls, and process-cwd-relative by SQLite's VFS, which knows
/// nothing about `dir`. The caller must therefore pass either an absolute path
/// with `dir` open on its parent, or `std.Io.Dir.cwd()` with a cwd-relative
/// path.
pub fn open(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) Error!OpenResult {
var handle: ?db.Db = null;
errdefer if (handle) |*h| h.close();
const reason: ?RecreateReason = probe: {
dir.access(io, path, .{}) catch |e| switch (e) {
error.FileNotFound => break :probe .missing,
else => |other| return other,
};
handle = db.Db.open(path, .{ .mode = .read_write_existing }) catch |e|
break :probe recreatable(e) orelse return e;
const opened = &handle.?;
db.applyPragmas(opened, .{}) catch |e|
break :probe recreatable(e) orelse return e;
const healthy = quickCheck(opened) catch |e|
break :probe recreatable(e) orelse return e;
if (!healthy) break :probe .quick_check_failed;
const stamped = opened.queryInt("PRAGMA user_version") catch |e|
break :probe recreatable(e) orelse return e;
if (stamped != fingerprint) break :probe .fingerprint_mismatch;
break :probe null;
};
const cause = reason orelse return .{ .database = handle.?, .recreated = null };
// Close first, so SQLite checkpoints and drops `-wal`/`-shm` where it can.
if (handle) |*h| h.close();
handle = null;
var aside_buf: [path_buf_len]u8 = undefined;
const aside: ?[]const u8 = if (cause == .missing)
null
else
try renameAside(io, dir, path, &aside_buf);
// Not optional: a stale WAL left beside the renamed database would be
// replayed into the freshly created file and corrupt it immediately. Any
// failure other than "already gone" propagates rather than building the new
// database on a half-cleaned state.
try deleteSidecars(io, dir, path);
const fresh = try createFresh(path);
if (cause == .missing) {
log.info("created querylog database '{s}'", .{path});
} else {
log.warn("recreated querylog database '{s}': {s}; previous file kept as '{s}'", .{
path,
@tagName(cause),
aside.?,
});
}
return .{ .database = fresh, .recreated = cause };
}
/// The whitelist. `null` means "propagate, do not touch the file".
fn recreatable(e: db.Error) ?RecreateReason {
return switch (e) {
error.Corrupt => .corrupt,
error.NotADb => .not_a_database,
else => null,
};
}
/// `PRAGMA quick_check` rather than `integrity_check`: it skips the expensive
/// index-vs-table cross-check while still catching structural damage, and a
/// damaged index on an expendable log is not worth a multi-second startup scan.
fn quickCheck(database: *db.Db) db.Error!bool {
var stmt = try database.prepare("PRAGMA quick_check");
defer stmt.deinit();
if (!try stmt.step()) return false;
return std.ascii.eqlIgnoreCase(stmt.columnText(0), "ok");
}
/// Renames the unusable file out of the way and returns the name it now has.
///
/// `renamePreserve` is `RENAME_NOREPLACE`: it returns `error.PathAlreadyExists`
/// instead of overwriting. A previously saved corrupt file must never be
/// destroyed by the next recreate, and two recreates in the same second are not
/// hypothetical on a boot loop — hence the uniquifying retries.
fn renameAside(io: std.Io, dir: std.Io.Dir, path: []const u8, buf: []u8) Error![]const u8 {
const seconds = std.Io.Clock.real.now(io).toSeconds();
var attempt: u32 = 0;
while (attempt < 100) : (attempt += 1) {
const aside = if (attempt == 0)
std.fmt.bufPrint(buf, "{s}.corrupt-{d}", .{ path, seconds }) catch return error.NameTooLong
else
std.fmt.bufPrint(buf, "{s}.corrupt-{d}-{d}", .{ path, seconds, attempt }) catch return error.NameTooLong;
dir.renamePreserve(path, dir, aside, io) catch |e| switch (e) {
error.PathAlreadyExists => continue,
else => |other| return other,
};
return aside;
}
return error.AsideNameCollision;
}
fn deleteSidecars(io: std.Io, dir: std.Io.Dir, path: []const u8) Error!void {
var buf: [path_buf_len]u8 = undefined;
for ([_][]const u8{ "-wal", "-shm" }) |suffix| {
const sidecar = std.fmt.bufPrint(&buf, "{s}{s}", .{ path, suffix }) catch return error.NameTooLong;
dir.deleteFile(io, sidecar) catch |e| switch (e) {
error.FileNotFound => {},
else => |other| return other,
};
}
}
fn createFresh(path: [:0]const u8) db.Error!db.Db {
var database = try db.Db.open(path, .{ .mode = .read_write_create });
errdefer database.close();
try db.applyPragmas(&database, .{});
var tx = try db.Tx.begin(&database);
errdefer tx.rollback();
try database.exec(ddl);
try database.exec(set_user_version);
try tx.commit();
return database;
}
const testing = std.testing;
test "fingerprint matches a fresh hash of the DDL" {
try testing.expectEqual(fingerprint, @as(i32, @bitCast(std.hash.Crc32.hash(ddl))));
}
test "ddl creates domains, query_log and the three indexes" {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
try database.exec(ddl);
try testing.expectEqual(
@as(i64, 2),
try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
);
const objects = [_][]const u8{
"domains", "query_log",
"idx_query_log_ts", "idx_query_log_client",
"idx_query_log_domain",
};
for (objects) |name| {
var stmt = try database.prepare("SELECT count(*) FROM sqlite_schema WHERE name = ?1");
defer stmt.deinit();
try stmt.bindText(1, name);
try testing.expect(try stmt.step());
try testing.expectEqual(@as(i64, 1), stmt.columnInt(0));
}
}
test "the user_version statement stamps the fingerprint" {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try database.exec(set_user_version);
try testing.expectEqual(@as(i64, fingerprint), try database.queryInt("PRAGMA user_version"));
}
// The behaviour these two cases describe — a resource error leaves the file on
// disk alone — is proven against a real file by "S7 case 23" in
// `storage_integration_test.zig`, which locks a healthy `querylog.db` from a
// second connection and asserts `open` returns `error.Busy` with the bytes, the
// file and the absence of an aside all intact. `error.OutOfMemory` has no such
// case: `open` takes no allocator, and SQLite allocates through its own global
// allocator, so there is no seam to inject a failure through. The two tests
// below are what covers it.
test "recreatable is a whitelist and never selects a resource error" {
try testing.expectEqual(RecreateReason.corrupt, recreatable(error.Corrupt).?);
try testing.expectEqual(RecreateReason.not_a_database, recreatable(error.NotADb).?);
const propagating = [_]db.Error{
error.Busy, error.Locked, error.OutOfMemory, error.CantOpen,
error.ReadOnly, error.IoErr, error.Full, error.Perm,
error.Auth, error.Misuse, error.Constraint, error.SqliteError,
error.Unexpected,
};
for (propagating) |e| {
try testing.expect(recreatable(e) == null);
}
}
test "recreatable selects exactly two of db.Error's members" {
// Exhaustive over the whole set, so a variant added to `db.Error` later
// defaults to propagate. The list above only proves the named errors are
// safe today; this proves nothing else can join the whitelist unnoticed.
var whitelisted: usize = 0;
inline for (@typeInfo(db.Error).error_set.?) |member| {
if (recreatable(@field(db.Error, member.name)) != null) whitelisted += 1;
}
try testing.expectEqual(@as(usize, 2), whitelisted);
}
+336
View File
@@ -0,0 +1,336 @@
//! `clients` and `client_prefixes`.
//!
//! `listClients` returns only `hand_edited = 1` rows. A client the server
//! materialised from live traffic is runtime state, not configuration, and must
//! not appear in an export. `countClients` counts **all** rows, because S5's
//! "has this database ever been configured" predicate needs the true count.
//!
//! Only list / insert / deleteAll / count exist.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const IdMap = context.IdMap;
const InsertContext = context.InsertContext;
// ---------------------------------------------------------------------------
// clients
// ---------------------------------------------------------------------------
const list_clients_sql =
\\SELECT c.ip, c.name, g.name FROM clients c
\\ JOIN groups g ON g.id = c.group_id
\\ WHERE c.hand_edited = 1
\\ ORDER BY c.ip
;
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listClients(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Client) {
var stmt = try database.prepare(list_clients_sql);
defer stmt.deinit();
var out: std.ArrayList(model.Client) = .empty;
// `errdefer`s run in reverse: `freeClients` is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeClients(gpa, out.items);
while (try stmt.step()) {
const ip = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(ip);
// `clients.name` is nullable; `columnTextAlloc` reads NULL as "", which
// is exactly the model's default.
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
const group = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(group);
try out.append(gpa, .{ .ip = ip, .name = name, .group = group });
}
return out;
}
pub fn freeClients(gpa: Allocator, items: []const model.Client) void {
for (items) |item| {
gpa.free(item.ip);
gpa.free(item.name);
gpa.free(item.group);
}
}
const insert_client_sql =
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES (?1, ?2, ?3, 1, ?4, ?4)
;
/// `hand_edited` is 1: a client that reached a repository through the config
/// model came from an operator's file, by definition.
pub fn insertClient(database: *db.Db, item: model.Client, ctx: InsertContext) db.Error!void {
const group_id = try ctx.groupId(item.group);
var stmt = try database.prepare(insert_client_sql);
defer stmt.deinit();
try stmt.bindText(1, item.ip);
try stmt.bindText(2, item.name);
try stmt.bindInt(3, group_id);
try stmt.bindInt(4, ctx.now);
try stmt.exec();
}
pub fn deleteAllClients(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM clients;");
}
/// Counts every row, including the ones `listClients` filters out.
pub fn countClients(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM clients");
}
// ---------------------------------------------------------------------------
// client_prefixes
// ---------------------------------------------------------------------------
const list_client_prefixes_sql =
\\SELECT p.prefix, g.name, p.priority FROM client_prefixes p
\\ JOIN groups g ON g.id = p.group_id
\\ ORDER BY p.prefix
;
pub fn listClientPrefixes(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ClientPrefix) {
var stmt = try database.prepare(list_client_prefixes_sql);
defer stmt.deinit();
var out: std.ArrayList(model.ClientPrefix) = .empty;
errdefer out.deinit(gpa);
errdefer freeClientPrefixes(gpa, out.items);
while (try stmt.step()) {
const prefix = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(prefix);
const group = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(group);
// The column is a 64-bit integer; the model field is `i32`. A value
// outside that range means something other than nxdns wrote the row.
const priority = std.math.cast(i32, stmt.columnInt(2)) orelse return error.Mismatch;
try out.append(gpa, .{ .prefix = prefix, .group = group, .priority = priority });
}
return out;
}
pub fn freeClientPrefixes(gpa: Allocator, items: []const model.ClientPrefix) void {
for (items) |item| {
gpa.free(item.prefix);
gpa.free(item.group);
}
}
pub fn insertClientPrefix(database: *db.Db, item: model.ClientPrefix, ctx: InsertContext) db.Error!void {
const group_id = try ctx.groupId(item.group);
var stmt = try database.prepare("INSERT INTO client_prefixes (prefix, group_id, priority) VALUES (?1, ?2, ?3)");
defer stmt.deinit();
try stmt.bindText(1, item.prefix);
try stmt.bindInt(2, group_id);
try stmt.bindInt(3, item.priority);
try stmt.exec();
}
pub fn deleteAllClientPrefixes(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM client_prefixes;");
}
pub fn countClientPrefixes(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM client_prefixes");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
/// Migration step 1 seeds `(1, 'default')`; `kids` is added here so the join
/// has two distinct groups to resolve.
fn seedGroups(database: *db.Db) !IdMap {
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
var ids: IdMap = .empty;
errdefer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
try ids.put(testing.allocator, "kids", 2);
return ids;
}
fn seedClients(database: *db.Db, ids: *const IdMap) !void {
const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids };
try insertClient(database, .{ .ip = "192.168.1.20", .name = "laptop", .group = "kids" }, ctx);
try insertClient(database, .{ .ip = "192.168.1.10", .name = "desk" }, ctx);
try insertClient(database, .{ .ip = "fd00::1", .group = "kids" }, ctx);
}
fn seedClientPrefixes(database: *db.Db, ids: *const IdMap) !void {
const ctx: InsertContext = .{ .group_ids = ids };
try insertClientPrefix(database, .{ .prefix = "192.168.2.0/24", .group = "kids", .priority = 10 }, ctx);
try insertClientPrefix(database, .{ .prefix = "192.168.1.0/24", .priority = 50 }, ctx);
try insertClientPrefix(database, .{ .prefix = "fd00::/48", .group = "kids" }, ctx);
}
test "clients round-trip in ip order with group names resolved" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClients(&database, &ids);
var items = try listClients(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeClients(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("192.168.1.10", items.items[0].ip);
try testing.expectEqualStrings("desk", items.items[0].name);
try testing.expectEqualStrings("default", items.items[0].group);
try testing.expectEqualStrings("192.168.1.20", items.items[1].ip);
try testing.expectEqualStrings("laptop", items.items[1].name);
try testing.expectEqualStrings("kids", items.items[1].group);
try testing.expectEqualStrings("fd00::1", items.items[2].ip);
try testing.expectEqualStrings("", items.items[2].name);
try testing.expectEqualStrings("kids", items.items[2].group);
try testing.expectEqual(
@as(i64, 1700000000),
try database.queryInt("SELECT first_seen FROM clients WHERE ip = '192.168.1.10'"),
);
try testing.expectEqual(
@as(i64, 1700000000),
try database.queryInt("SELECT last_seen FROM clients WHERE ip = '192.168.1.10'"),
);
}
test "a hand_edited = 0 client is absent from listClients but counted by countClients" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClients(&database, &ids);
try database.exec(
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES ('10.0.0.5', 'auto', 1, 0, 1, 1);
);
try testing.expectEqual(@as(i64, 4), try countClients(&database));
var items = try listClients(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeClients(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
for (items.items) |item| {
try testing.expect(!std.mem.eql(u8, item.ip, "10.0.0.5"));
}
}
test "deleteAllClients empties the table and countClients reflects it" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClients(&database, &ids);
try testing.expectEqual(@as(i64, 3), try countClients(&database));
try deleteAllClients(&database);
try testing.expectEqual(@as(i64, 0), try countClients(&database));
}
test "insertClient reports a group the caller's map does not hold" {
var database = try openMigrated();
defer database.close();
const ctx: InsertContext = .{};
try testing.expectError(
error.NotFound,
insertClient(&database, .{ .ip = "192.168.1.1" }, ctx),
);
}
fn listClientsUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
var database = try openMigrated();
defer database.close();
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
try seedClients(&database, ids);
var items = try listClients(&database, gpa);
defer items.deinit(gpa);
defer freeClients(gpa, items.items);
}
test "listClients is leak-safe under allocation failure" {
var ids: IdMap = .empty;
defer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
try ids.put(testing.allocator, "kids", 2);
try testing.checkAllAllocationFailures(testing.allocator, listClientsUnderFailure, .{&ids});
}
test "client_prefixes round-trip in prefix order with group names resolved" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClientPrefixes(&database, &ids);
var items = try listClientPrefixes(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeClientPrefixes(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("192.168.1.0/24", items.items[0].prefix);
try testing.expectEqualStrings("default", items.items[0].group);
try testing.expectEqual(@as(i32, 50), items.items[0].priority);
try testing.expectEqualStrings("192.168.2.0/24", items.items[1].prefix);
try testing.expectEqualStrings("kids", items.items[1].group);
try testing.expectEqual(@as(i32, 10), items.items[1].priority);
try testing.expectEqualStrings("fd00::/48", items.items[2].prefix);
try testing.expectEqualStrings("kids", items.items[2].group);
try testing.expectEqual(@as(i32, 100), items.items[2].priority);
}
test "deleteAllClientPrefixes empties the table and countClientPrefixes reflects it" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClientPrefixes(&database, &ids);
try testing.expectEqual(@as(i64, 3), try countClientPrefixes(&database));
try deleteAllClientPrefixes(&database);
try testing.expectEqual(@as(i64, 0), try countClientPrefixes(&database));
}
fn listClientPrefixesUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
var database = try openMigrated();
defer database.close();
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
try seedClientPrefixes(&database, ids);
var items = try listClientPrefixes(&database, gpa);
defer items.deinit(gpa);
defer freeClientPrefixes(gpa, items.items);
}
test "listClientPrefixes is leak-safe under allocation failure" {
var ids: IdMap = .empty;
defer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
try ids.put(testing.allocator, "kids", 2);
try testing.checkAllAllocationFailures(testing.allocator, listClientPrefixesUnderFailure, .{&ids});
}
+66
View File
@@ -0,0 +1,66 @@
//! What every `insert*` needs and the config model deliberately omits.
//!
//! `config/model.zig` carries no row ids and no timestamps: ids are not stable
//! across an import, and `first_seen` / `last_seen` / `created_at` are facts a
//! running server produces. Every insert that writes one of those columns reads
//! it from here instead.
//!
//! Building the id maps is the caller's job (S5): only the caller knows the ids
//! of the parent rows it just inserted.
const std = @import("std");
const log = std.log.scoped(.repositories);
/// Group name → `groups.id`, or blocklist source URL → `blocklist_sources.id`.
pub const IdMap = std.StringHashMapUnmanaged(i64);
const no_ids: IdMap = .empty;
pub const InsertContext = struct {
/// Unix epoch seconds, from `std.Io.Clock.real.now(io).toSeconds()`.
now: i64 = 0,
group_ids: *const IdMap = &no_ids,
source_ids: *const IdMap = &no_ids,
/// `error.NotFound` means the caller's map lacks a name the validator has
/// already proven the config declares. It is reported rather than asserted
/// so a caller bug aborts the import transaction instead of the process.
/// `NotFound` is a member of `db.Error`, so it needs no wider error set.
pub fn groupId(self: InsertContext, name: []const u8) error{NotFound}!i64 {
return self.group_ids.get(name) orelse {
log.warn("no group id for '{s}'", .{name});
return error.NotFound;
};
}
pub fn sourceId(self: InsertContext, url: []const u8) error{NotFound}!i64 {
return self.source_ids.get(url) orelse {
log.warn("no blocklist source id for '{s}'", .{url});
return error.NotFound;
};
}
};
const testing = std.testing;
test "an InsertContext with no maps reports a missing id rather than trapping" {
const ctx: InsertContext = .{};
try testing.expectError(error.NotFound, ctx.groupId("default"));
try testing.expectError(error.NotFound, ctx.sourceId("https://example.test/list.txt"));
}
test "InsertContext resolves names through the caller's maps" {
var groups: IdMap = .empty;
defer groups.deinit(testing.allocator);
try groups.put(testing.allocator, "default", 1);
var sources: IdMap = .empty;
defer sources.deinit(testing.allocator);
try sources.put(testing.allocator, "https://example.test/list.txt", 7);
const ctx: InsertContext = .{ .now = 1700000000, .group_ids = &groups, .source_ids = &sources };
try testing.expectEqual(@as(i64, 1), try ctx.groupId("default"));
try testing.expectEqual(@as(i64, 7), try ctx.sourceId("https://example.test/list.txt"));
try testing.expectError(error.NotFound, ctx.groupId("kids"));
}
+275
View File
@@ -0,0 +1,275 @@
//! `groups` and `group_sources`.
//!
//! Both lists yield model values holding **names**, never row ids: ids are not
//! stable across an import, so an export carrying them would not re-import into
//! the same shape.
//!
//! Only list / insert / deleteAll / count exist. Update-by-id, delete-by-id and
//! paged reads are Phase 8's REST surface; adding them now would be untested,
//! unused generality.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const IdMap = context.IdMap;
const InsertContext = context.InsertContext;
// ---------------------------------------------------------------------------
// groups
// ---------------------------------------------------------------------------
/// Every string in the result is a heap copy owned by `gpa`; free the whole
/// list with `freeGroups` and then `deinit` the list itself.
pub fn listGroups(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Group) {
var stmt = try database.prepare("SELECT name, safe_search FROM groups ORDER BY name");
defer stmt.deinit();
var out: std.ArrayList(model.Group) = .empty;
// Order matters: `errdefer`s run in reverse, so `freeGroups` must be
// declared *after* `deinit` to run *before* it. The other order reads
// `out.items` after the backing array is gone.
errdefer out.deinit(gpa);
errdefer freeGroups(gpa, out.items);
while (try stmt.step()) {
const name = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(name);
try out.append(gpa, .{ .name = name, .safe_search = stmt.columnBool(1) });
}
return out;
}
pub fn freeGroups(gpa: Allocator, items: []const model.Group) void {
for (items) |item| gpa.free(item.name);
}
pub fn insertGroup(database: *db.Db, item: model.Group, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare("INSERT INTO groups (name, safe_search) VALUES (?1, ?2)");
defer stmt.deinit();
try stmt.bindText(1, item.name);
try stmt.bindBool(2, item.safe_search);
try stmt.exec();
}
pub fn deleteAllGroups(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM groups;");
}
pub fn countGroups(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM groups");
}
// ---------------------------------------------------------------------------
// group_sources
// ---------------------------------------------------------------------------
const list_group_sources_sql =
\\SELECT g.name, s.url FROM group_sources gs
\\ JOIN groups g ON g.id = gs.group_id
\\ JOIN blocklist_sources s ON s.id = gs.source_id
\\ ORDER BY g.name, s.url
;
/// The two foreign keys are `NOT NULL` and enforced, so the join is total: a
/// `group_sources` row can never be dropped by it.
pub fn listGroupSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.GroupSource) {
var stmt = try database.prepare(list_group_sources_sql);
defer stmt.deinit();
var out: std.ArrayList(model.GroupSource) = .empty;
errdefer out.deinit(gpa);
errdefer freeGroupSources(gpa, out.items);
while (try stmt.step()) {
const group = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(group);
const source_url = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(source_url);
try out.append(gpa, .{ .group = group, .source_url = source_url });
}
return out;
}
pub fn freeGroupSources(gpa: Allocator, items: []const model.GroupSource) void {
for (items) |item| {
gpa.free(item.group);
gpa.free(item.source_url);
}
}
pub fn insertGroupSource(database: *db.Db, item: model.GroupSource, ctx: InsertContext) db.Error!void {
const group_id = try ctx.groupId(item.group);
const source_id = try ctx.sourceId(item.source_url);
var stmt = try database.prepare("INSERT INTO group_sources (group_id, source_id) VALUES (?1, ?2)");
defer stmt.deinit();
try stmt.bindInt(1, group_id);
try stmt.bindInt(2, source_id);
try stmt.exec();
}
pub fn deleteAllGroupSources(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM group_sources;");
}
pub fn countGroupSources(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM group_sources");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
/// Migration step 1 seeds `(1, 'default')`, so a migrated database already holds
/// one group and one known id.
fn defaultGroupIds() !IdMap {
var ids: IdMap = .empty;
errdefer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
return ids;
}
fn seedGroups(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertGroup(database, .{ .name = "kids", .safe_search = true }, ctx);
try insertGroup(database, .{ .name = "zeta" }, ctx);
try insertGroup(database, .{ .name = "alpha", .safe_search = true }, ctx);
}
fn seedGroupSources(database: *db.Db, ids: *const IdMap) !void {
try database.exec(
\\INSERT INTO blocklist_sources (id, url, name) VALUES
\\ (1, 'https://b.example/list.txt', 'B'),
\\ (2, 'https://a.example/list.txt', 'A');
);
var sources: IdMap = .empty;
defer sources.deinit(testing.allocator);
try sources.put(testing.allocator, "https://b.example/list.txt", 1);
try sources.put(testing.allocator, "https://a.example/list.txt", 2);
const ctx: InsertContext = .{ .group_ids = ids, .source_ids = &sources };
try insertGroupSource(database, .{ .group = "default", .source_url = "https://b.example/list.txt" }, ctx);
try insertGroupSource(database, .{ .group = "default", .source_url = "https://a.example/list.txt" }, ctx);
}
test "groups round-trip in name order" {
var database = try openMigrated();
defer database.close();
try seedGroups(&database);
var items = try listGroups(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeGroups(testing.allocator, items.items);
// The seeded `default` group sorts between `alpha` and `kids`.
try testing.expectEqual(@as(usize, 4), items.items.len);
try testing.expectEqualStrings("alpha", items.items[0].name);
try testing.expect(items.items[0].safe_search);
try testing.expectEqualStrings("default", items.items[1].name);
try testing.expect(!items.items[1].safe_search);
try testing.expectEqualStrings("kids", items.items[2].name);
try testing.expect(items.items[2].safe_search);
try testing.expectEqualStrings("zeta", items.items[3].name);
try testing.expect(!items.items[3].safe_search);
}
test "deleteAllGroups empties the table and countGroups reflects it" {
var database = try openMigrated();
defer database.close();
try seedGroups(&database);
try testing.expectEqual(@as(i64, 4), try countGroups(&database));
try deleteAllGroups(&database);
try testing.expectEqual(@as(i64, 0), try countGroups(&database));
var items = try listGroups(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeGroups(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 0), items.items.len);
}
fn listGroupsUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedGroups(&database);
var items = try listGroups(&database, gpa);
defer items.deinit(gpa);
defer freeGroups(gpa, items.items);
}
test "listGroups is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listGroupsUnderFailure, .{});
}
test "listGroupSources yields names, not ids" {
var database = try openMigrated();
defer database.close();
var ids = try defaultGroupIds();
defer ids.deinit(testing.allocator);
try seedGroupSources(&database, &ids);
var items = try listGroupSources(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeGroupSources(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 2), items.items.len);
try testing.expectEqualStrings("default", items.items[0].group);
try testing.expectEqualStrings("https://a.example/list.txt", items.items[0].source_url);
try testing.expectEqualStrings("default", items.items[1].group);
try testing.expectEqualStrings("https://b.example/list.txt", items.items[1].source_url);
}
test "deleteAllGroupSources empties the table and countGroupSources reflects it" {
var database = try openMigrated();
defer database.close();
var ids = try defaultGroupIds();
defer ids.deinit(testing.allocator);
try seedGroupSources(&database, &ids);
try testing.expectEqual(@as(i64, 2), try countGroupSources(&database));
try deleteAllGroupSources(&database);
try testing.expectEqual(@as(i64, 0), try countGroupSources(&database));
}
test "insertGroupSource reports an id the caller's map does not hold" {
var database = try openMigrated();
defer database.close();
const ctx: InsertContext = .{};
try testing.expectError(
error.NotFound,
insertGroupSource(&database, .{ .group = "kids", .source_url = "https://a.example/list.txt" }, ctx),
);
}
fn listGroupSourcesUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
var database = try openMigrated();
defer database.close();
try seedGroupSources(&database, ids);
var items = try listGroupSources(&database, gpa);
defer items.deinit(gpa);
defer freeGroupSources(gpa, items.items);
}
test "listGroupSources is leak-safe under allocation failure" {
var ids = try defaultGroupIds();
defer ids.deinit(testing.allocator);
try testing.checkAllAllocationFailures(testing.allocator, listGroupSourcesUnderFailure, .{&ids});
}
+255
View File
@@ -0,0 +1,255 @@
//! `local_records` and `forward_zones`.
//!
//! Only list / insert / deleteAll / count exist.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const InsertContext = context.InsertContext;
// ---------------------------------------------------------------------------
// local_records
// ---------------------------------------------------------------------------
const list_local_records_sql =
\\SELECT name, rtype, value, ttl FROM local_records ORDER BY name, rtype, value
;
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listLocalRecords(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.LocalRecord) {
var stmt = try database.prepare(list_local_records_sql);
defer stmt.deinit();
var out: std.ArrayList(model.LocalRecord) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeLocalRecords(gpa, out.items);
while (try stmt.step()) {
const name = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(name);
const value = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(value);
// The DDL's CHECK constraint makes the decode total for any row nxdns
// wrote; `error.Mismatch` covers a row that something else wrote.
const rtype = model.RecordType.fromDb(stmt.columnText(1)) orelse return error.Mismatch;
const ttl = std.math.cast(u32, stmt.columnInt(3)) orelse return error.Mismatch;
try out.append(gpa, .{ .name = name, .rtype = rtype, .value = value, .ttl = ttl });
}
return out;
}
pub fn freeLocalRecords(gpa: Allocator, items: []const model.LocalRecord) void {
for (items) |item| {
gpa.free(item.name);
gpa.free(item.value);
}
}
const insert_local_record_sql =
\\INSERT INTO local_records (name, rtype, value, ttl) VALUES (?1, ?2, ?3, ?4)
;
pub fn insertLocalRecord(database: *db.Db, item: model.LocalRecord, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare(insert_local_record_sql);
defer stmt.deinit();
try stmt.bindText(1, item.name);
try stmt.bindText(2, item.rtype.toDb());
try stmt.bindText(3, item.value);
try stmt.bindInt(4, item.ttl);
try stmt.exec();
}
pub fn deleteAllLocalRecords(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM local_records;");
}
pub fn countLocalRecords(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM local_records");
}
// ---------------------------------------------------------------------------
// forward_zones
// ---------------------------------------------------------------------------
pub fn listForwardZones(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ForwardZone) {
var stmt = try database.prepare("SELECT zone, resolver FROM forward_zones ORDER BY zone");
defer stmt.deinit();
var out: std.ArrayList(model.ForwardZone) = .empty;
errdefer out.deinit(gpa);
errdefer freeForwardZones(gpa, out.items);
while (try stmt.step()) {
const zone = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(zone);
const resolver = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(resolver);
try out.append(gpa, .{ .zone = zone, .resolver = resolver });
}
return out;
}
pub fn freeForwardZones(gpa: Allocator, items: []const model.ForwardZone) void {
for (items) |item| {
gpa.free(item.zone);
gpa.free(item.resolver);
}
}
pub fn insertForwardZone(database: *db.Db, item: model.ForwardZone, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare("INSERT INTO forward_zones (zone, resolver) VALUES (?1, ?2)");
defer stmt.deinit();
try stmt.bindText(1, item.zone);
try stmt.bindText(2, item.resolver);
try stmt.exec();
}
pub fn deleteAllForwardZones(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM forward_zones;");
}
pub fn countForwardZones(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM forward_zones");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
fn seedLocalRecords(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertLocalRecord(database, .{
.name = "nas.home.arpa",
.rtype = .aaaa,
.value = "fd00::5",
.ttl = 60,
}, ctx);
try insertLocalRecord(database, .{
.name = "nas.home.arpa",
.rtype = .a,
.value = "192.168.1.5",
}, ctx);
try insertLocalRecord(database, .{
.name = "alias.home.arpa",
.rtype = .cname,
.value = "nas.home.arpa",
.ttl = 120,
}, ctx);
}
fn seedForwardZones(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertForwardZone(database, .{ .zone = "work.example", .resolver = "udp://10.0.0.1:53" }, ctx);
try insertForwardZone(database, .{ .zone = "home.arpa", .resolver = "udp://192.168.1.1:53" }, ctx);
try insertForwardZone(database, .{ .zone = "lab.example", .resolver = "tcp://[fd00::1]:53" }, ctx);
}
test "local_records round-trip in name, rtype, value order" {
var database = try openMigrated();
defer database.close();
try seedLocalRecords(&database);
var items = try listLocalRecords(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeLocalRecords(testing.allocator, items.items);
// `rtype` is compared as stored text, so 'A' sorts before 'AAAA'.
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("alias.home.arpa", items.items[0].name);
try testing.expectEqual(model.RecordType.cname, items.items[0].rtype);
try testing.expectEqualStrings("nas.home.arpa", items.items[0].value);
try testing.expectEqual(@as(u32, 120), items.items[0].ttl);
try testing.expectEqualStrings("nas.home.arpa", items.items[1].name);
try testing.expectEqual(model.RecordType.a, items.items[1].rtype);
try testing.expectEqualStrings("192.168.1.5", items.items[1].value);
try testing.expectEqual(@as(u32, 300), items.items[1].ttl);
try testing.expectEqualStrings("nas.home.arpa", items.items[2].name);
try testing.expectEqual(model.RecordType.aaaa, items.items[2].rtype);
try testing.expectEqualStrings("fd00::5", items.items[2].value);
try testing.expectEqual(@as(u32, 60), items.items[2].ttl);
}
test "deleteAllLocalRecords empties the table and countLocalRecords reflects it" {
var database = try openMigrated();
defer database.close();
try seedLocalRecords(&database);
try testing.expectEqual(@as(i64, 3), try countLocalRecords(&database));
try deleteAllLocalRecords(&database);
try testing.expectEqual(@as(i64, 0), try countLocalRecords(&database));
}
fn listLocalRecordsUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedLocalRecords(&database);
var items = try listLocalRecords(&database, gpa);
defer items.deinit(gpa);
defer freeLocalRecords(gpa, items.items);
}
test "listLocalRecords is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listLocalRecordsUnderFailure, .{});
}
test "forward_zones round-trip in zone order" {
var database = try openMigrated();
defer database.close();
try seedForwardZones(&database);
var items = try listForwardZones(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeForwardZones(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("home.arpa", items.items[0].zone);
try testing.expectEqualStrings("udp://192.168.1.1:53", items.items[0].resolver);
try testing.expectEqualStrings("lab.example", items.items[1].zone);
try testing.expectEqualStrings("tcp://[fd00::1]:53", items.items[1].resolver);
try testing.expectEqualStrings("work.example", items.items[2].zone);
try testing.expectEqualStrings("udp://10.0.0.1:53", items.items[2].resolver);
}
test "deleteAllForwardZones empties the table and countForwardZones reflects it" {
var database = try openMigrated();
defer database.close();
try seedForwardZones(&database);
try testing.expectEqual(@as(i64, 3), try countForwardZones(&database));
try deleteAllForwardZones(&database);
try testing.expectEqual(@as(i64, 0), try countForwardZones(&database));
}
fn listForwardZonesUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedForwardZones(&database);
var items = try listForwardZones(&database, gpa);
defer items.deinit(gpa);
defer freeForwardZones(gpa, items.items);
}
test "listForwardZones is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listForwardZonesUnderFailure, .{});
}
+294
View File
@@ -0,0 +1,294 @@
//! `rules`.
//!
//! `rules` carries no `UNIQUE` constraint, so duplicate rules are legal. The
//! list therefore ends its `ORDER BY` with `id`, which is the only column that
//! makes the order — and so an export — deterministic.
//!
//! The list leads with the group *name*, not `group_id`. Ids are assigned by the
//! database and permute when a config is imported into a fresh database, so an
//! order that led with `group_id` would reorder the rules of an export →
//! import → export cycle. The name is the value the export emits, and it is the
//! same in both databases. The trailing `id` is stable for the same reason the
//! order as a whole is: `import` inserts the rules in export order, so the new
//! ids ascend in exactly the order this statement produced.
//!
//! Only list / insert / deleteAll / count exist.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const groups_repo = @import("groups_repo.zig");
const IdMap = context.IdMap;
const InsertContext = context.InsertContext;
const list_sql =
\\SELECT g.name, r.pattern, r.kind, r.action FROM rules r
\\ JOIN groups g ON g.id = r.group_id
\\ ORDER BY g.name, r.kind, r.action, r.pattern, r.id
;
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listRules(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Rule) {
var stmt = try database.prepare(list_sql);
defer stmt.deinit();
var out: std.ArrayList(model.Rule) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeRules(gpa, out.items);
while (try stmt.step()) {
const group = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(group);
const pattern = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(pattern);
// The DDL's CHECK constraints make both decodes total for any row nxdns
// wrote; `error.Mismatch` covers a row that something else wrote.
const kind = model.RuleKind.fromDb(stmt.columnText(2)) orelse return error.Mismatch;
const action = model.RuleAction.fromDb(stmt.columnText(3)) orelse return error.Mismatch;
try out.append(gpa, .{ .group = group, .pattern = pattern, .kind = kind, .action = action });
}
return out;
}
pub fn freeRules(gpa: Allocator, items: []const model.Rule) void {
for (items) |item| {
gpa.free(item.group);
gpa.free(item.pattern);
}
}
const insert_sql =
\\INSERT INTO rules (group_id, pattern, kind, action, created_at) VALUES (?1, ?2, ?3, ?4, ?5)
;
pub fn insertRule(database: *db.Db, item: model.Rule, ctx: InsertContext) db.Error!void {
const group_id = try ctx.groupId(item.group);
var stmt = try database.prepare(insert_sql);
defer stmt.deinit();
try stmt.bindInt(1, group_id);
try stmt.bindText(2, item.pattern);
try stmt.bindText(3, item.kind.toDb());
try stmt.bindText(4, item.action.toDb());
try stmt.bindInt(5, ctx.now);
try stmt.exec();
}
pub fn deleteAllRules(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM rules;");
}
pub fn countRules(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM rules");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
fn seedGroupIds() !IdMap {
var ids: IdMap = .empty;
errdefer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
return ids;
}
fn seedRules(database: *db.Db, ids: *const IdMap) !void {
const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids };
try insertRule(database, .{
.group = "default",
.pattern = "*.ads.example",
.kind = .wildcard,
.action = .block,
}, ctx);
try insertRule(database, .{
.group = "default",
.pattern = "tracker.example",
.kind = .exact,
.action = .block,
}, ctx);
try insertRule(database, .{
.group = "default",
.pattern = "allowed.example",
.kind = .exact,
.action = .allow,
}, ctx);
}
test "rules round-trip in group, kind, action, pattern, id order" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroupIds();
defer ids.deinit(testing.allocator);
try seedRules(&database, &ids);
var items = try listRules(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeRules(testing.allocator, items.items);
// One group, so `kind` leads: 'exact' before 'wildcard'; inside 'exact',
// 'allow' before 'block'.
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("allowed.example", items.items[0].pattern);
try testing.expectEqual(model.RuleKind.exact, items.items[0].kind);
try testing.expectEqual(model.RuleAction.allow, items.items[0].action);
try testing.expectEqualStrings("default", items.items[0].group);
try testing.expectEqualStrings("tracker.example", items.items[1].pattern);
try testing.expectEqual(model.RuleKind.exact, items.items[1].kind);
try testing.expectEqual(model.RuleAction.block, items.items[1].action);
try testing.expectEqualStrings("*.ads.example", items.items[2].pattern);
try testing.expectEqual(model.RuleKind.wildcard, items.items[2].kind);
try testing.expectEqual(model.RuleAction.block, items.items[2].action);
try testing.expectEqual(
@as(i64, 1700000000),
try database.queryInt("SELECT created_at FROM rules WHERE pattern = 'tracker.example'"),
);
}
test "a duplicate rule is accepted and stays deterministically ordered by id" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroupIds();
defer ids.deinit(testing.allocator);
const ctx: InsertContext = .{ .now = 1, .group_ids = &ids };
const rule: model.Rule = .{
.group = "default",
.pattern = "dup.example",
.kind = .exact,
.action = .block,
};
try insertRule(&database, rule, ctx);
try insertRule(&database, rule, ctx);
var items = try listRules(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeRules(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 2), items.items.len);
try testing.expectEqualStrings("dup.example", items.items[0].pattern);
try testing.expectEqualStrings("dup.example", items.items[1].pattern);
}
/// Inserts `names` in the given order and returns the ids the database assigned.
fn seedGroupsInOrder(database: *db.Db, names: []const []const u8) !IdMap {
var ids: IdMap = .empty;
errdefer ids.deinit(testing.allocator);
for (names) |name| {
try groups_repo.insertGroup(database, .{ .name = name }, .{});
try ids.put(testing.allocator, name, database.lastInsertRowid());
}
return ids;
}
fn seedCrossGroupRules(database: *db.Db, ids: *const IdMap) !void {
const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids };
try insertRule(database, .{
.group = "zeta",
.pattern = "z.example",
.kind = .exact,
.action = .block,
}, ctx);
try insertRule(database, .{
.group = "alpha",
.pattern = "a.example",
.kind = .exact,
.action = .block,
}, ctx);
}
test "list order does not depend on which id each group received" {
// Two databases hold the same rules under the same group names, but the
// groups were inserted in opposite orders, so every group id differs. This
// is what an export → import → export cycle does to the ids.
var first = try openMigrated();
defer first.close();
var first_ids = try seedGroupsInOrder(&first, &.{ "zeta", "alpha" });
defer first_ids.deinit(testing.allocator);
try seedCrossGroupRules(&first, &first_ids);
var second = try openMigrated();
defer second.close();
var second_ids = try seedGroupsInOrder(&second, &.{ "alpha", "zeta" });
defer second_ids.deinit(testing.allocator);
try seedCrossGroupRules(&second, &second_ids);
try testing.expect(first_ids.get("alpha").? != second_ids.get("alpha").?);
var a = try listRules(&first, testing.allocator);
defer a.deinit(testing.allocator);
defer freeRules(testing.allocator, a.items);
var b = try listRules(&second, testing.allocator);
defer b.deinit(testing.allocator);
defer freeRules(testing.allocator, b.items);
try testing.expectEqual(@as(usize, 2), a.items.len);
try testing.expectEqual(a.items.len, b.items.len);
for (a.items, b.items) |x, y| {
try testing.expectEqualStrings(x.group, y.group);
try testing.expectEqualStrings(x.pattern, y.pattern);
}
// And the sequence is the group names in order, not the insertion order.
try testing.expectEqualStrings("alpha", a.items[0].group);
try testing.expectEqualStrings("a.example", a.items[0].pattern);
try testing.expectEqualStrings("zeta", a.items[1].group);
try testing.expectEqualStrings("z.example", a.items[1].pattern);
}
test "deleteAllRules empties the table and countRules reflects it" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroupIds();
defer ids.deinit(testing.allocator);
try seedRules(&database, &ids);
try testing.expectEqual(@as(i64, 3), try countRules(&database));
try deleteAllRules(&database);
try testing.expectEqual(@as(i64, 0), try countRules(&database));
}
test "insertRule reports a group the caller's map does not hold" {
var database = try openMigrated();
defer database.close();
const ctx: InsertContext = .{};
try testing.expectError(error.NotFound, insertRule(&database, .{
.group = "kids",
.pattern = "x.example",
.kind = .exact,
.action = .block,
}, ctx));
}
fn listRulesUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
var database = try openMigrated();
defer database.close();
try seedRules(&database, ids);
var items = try listRules(&database, gpa);
defer items.deinit(gpa);
defer freeRules(gpa, items.items);
}
test "listRules is leak-safe under allocation failure" {
var ids = try seedGroupIds();
defer ids.deinit(testing.allocator);
try testing.checkAllAllocationFailures(testing.allocator, listRulesUnderFailure, .{&ids});
}
+145
View File
@@ -0,0 +1,145 @@
//! `settings`.
//!
//! The row type is `model.SettingPair`, the same type `model.toSettings` and
//! `model.fromSettings` speak, so the scalar sections cross the storage boundary
//! without a second shape.
//!
//! Only list / insert / deleteAll / count exist.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const InsertContext = context.InsertContext;
/// Both strings of every pair are heap copies owned by `gpa`.
pub fn listSettings(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.SettingPair) {
var stmt = try database.prepare("SELECT key, value FROM settings ORDER BY key");
defer stmt.deinit();
var out: std.ArrayList(model.SettingPair) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeSettings(gpa, out.items);
while (try stmt.step()) {
const key = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(key);
const value = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(value);
try out.append(gpa, .{ .key = key, .value = value });
}
return out;
}
/// Only for lists `listSettings` produced. `model.toSettings` builds pairs whose
/// `key` is a comptime string and must never be freed; that list is the caller's
/// to release, field by field.
pub fn freeSettings(gpa: Allocator, items: []const model.SettingPair) void {
for (items) |item| {
gpa.free(item.key);
gpa.free(item.value);
}
}
pub fn insertSetting(database: *db.Db, item: model.SettingPair, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare("INSERT INTO settings (key, value) VALUES (?1, ?2)");
defer stmt.deinit();
try stmt.bindText(1, item.key);
try stmt.bindText(2, item.value);
try stmt.exec();
}
pub fn deleteAllSettings(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM settings;");
}
pub fn countSettings(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM settings");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
fn seedSettings(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertSetting(database, .{ .key = "web.port", .value = "8080" }, ctx);
try insertSetting(database, .{ .key = "dns.port", .value = "53" }, ctx);
// An apostrophe proves the value is bound, not concatenated into the SQL.
try insertSetting(database, .{ .key = "logging.file_path", .value = "/var/log/o'brien.log" }, ctx);
}
test "settings round-trip in ascending key order" {
var database = try openMigrated();
defer database.close();
try seedSettings(&database);
var items = try listSettings(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeSettings(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("dns.port", items.items[0].key);
try testing.expectEqualStrings("53", items.items[0].value);
try testing.expectEqualStrings("logging.file_path", items.items[1].key);
try testing.expectEqualStrings("/var/log/o'brien.log", items.items[1].value);
try testing.expectEqualStrings("web.port", items.items[2].key);
try testing.expectEqualStrings("8080", items.items[2].value);
}
test "a value holding an apostrophe survives the round trip" {
var database = try openMigrated();
defer database.close();
const ctx: InsertContext = .{};
const value = "he said 'hello'; DROP TABLE settings;--";
try insertSetting(&database, .{ .key = "web.password_hash", .value = value }, ctx);
var items = try listSettings(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeSettings(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 1), items.items.len);
try testing.expectEqualStrings(value, items.items[0].value);
try testing.expectEqual(@as(i64, 1), try countSettings(&database));
}
test "deleteAllSettings empties the table and countSettings reflects it" {
var database = try openMigrated();
defer database.close();
try seedSettings(&database);
try testing.expectEqual(@as(i64, 3), try countSettings(&database));
try deleteAllSettings(&database);
try testing.expectEqual(@as(i64, 0), try countSettings(&database));
}
fn listSettingsUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedSettings(&database);
var items = try listSettings(&database, gpa);
defer items.deinit(gpa);
defer freeSettings(gpa, items.items);
}
test "listSettings is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listSettingsUnderFailure, .{});
}
+177
View File
@@ -0,0 +1,177 @@
//! `blocklist_sources`.
//!
//! Only the four configuration columns are read and written. `last_updated`,
//! `domain_count`, `wildcard_count`, `skipped_regex_count` and `checksum` are
//! facts a running server produces; an insert leaves them at their column
//! defaults so two exports taken minutes apart stay identical.
//!
//! Only list / insert / deleteAll / count exist.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const InsertContext = context.InsertContext;
const list_sql =
\\SELECT url, name, enabled, is_suggested FROM blocklist_sources ORDER BY url
;
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listBlocklistSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.BlocklistSource) {
var stmt = try database.prepare(list_sql);
defer stmt.deinit();
var out: std.ArrayList(model.BlocklistSource) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeBlocklistSources(gpa, out.items);
while (try stmt.step()) {
const url = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(url);
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
try out.append(gpa, .{
.url = url,
.name = name,
.enabled = stmt.columnBool(2),
.is_suggested = stmt.columnBool(3),
});
}
return out;
}
pub fn freeBlocklistSources(gpa: Allocator, items: []const model.BlocklistSource) void {
for (items) |item| {
gpa.free(item.url);
gpa.free(item.name);
}
}
const insert_sql =
\\INSERT INTO blocklist_sources (url, name, enabled, is_suggested) VALUES (?1, ?2, ?3, ?4)
;
pub fn insertBlocklistSource(database: *db.Db, item: model.BlocklistSource, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare(insert_sql);
defer stmt.deinit();
try stmt.bindText(1, item.url);
try stmt.bindText(2, item.name);
try stmt.bindBool(3, item.enabled);
try stmt.bindBool(4, item.is_suggested);
try stmt.exec();
}
pub fn deleteAllBlocklistSources(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM blocklist_sources;");
}
pub fn countBlocklistSources(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM blocklist_sources");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
fn seedSources(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertBlocklistSource(database, .{
.url = "https://c.example/list.txt",
.name = "C list",
}, ctx);
try insertBlocklistSource(database, .{
.url = "https://a.example/list.txt",
.name = "A list",
.enabled = false,
}, ctx);
try insertBlocklistSource(database, .{
.url = "https://b.example/list.txt",
.name = "B list",
.is_suggested = true,
}, ctx);
}
test "blocklist_sources round-trip in url order" {
var database = try openMigrated();
defer database.close();
try seedSources(&database);
var items = try listBlocklistSources(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeBlocklistSources(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("https://a.example/list.txt", items.items[0].url);
try testing.expectEqualStrings("A list", items.items[0].name);
try testing.expect(!items.items[0].enabled);
try testing.expect(!items.items[0].is_suggested);
try testing.expectEqualStrings("https://b.example/list.txt", items.items[1].url);
try testing.expectEqualStrings("B list", items.items[1].name);
try testing.expect(items.items[1].enabled);
try testing.expect(items.items[1].is_suggested);
try testing.expectEqualStrings("https://c.example/list.txt", items.items[2].url);
try testing.expectEqualStrings("C list", items.items[2].name);
try testing.expect(items.items[2].enabled);
try testing.expect(!items.items[2].is_suggested);
}
test "insertBlocklistSource leaves the runtime columns at their defaults" {
var database = try openMigrated();
defer database.close();
try seedSources(&database);
try testing.expectEqual(
@as(i64, 3),
try database.queryInt("SELECT count(*) FROM blocklist_sources WHERE last_updated IS NULL"),
);
try testing.expectEqual(
@as(i64, 3),
try database.queryInt("SELECT count(*) FROM blocklist_sources WHERE checksum IS NULL"),
);
try testing.expectEqual(
@as(i64, 0),
try database.queryInt("SELECT sum(domain_count + wildcard_count + skipped_regex_count) FROM blocklist_sources"),
);
}
test "deleteAllBlocklistSources empties the table and countBlocklistSources reflects it" {
var database = try openMigrated();
defer database.close();
try seedSources(&database);
try testing.expectEqual(@as(i64, 3), try countBlocklistSources(&database));
try deleteAllBlocklistSources(&database);
try testing.expectEqual(@as(i64, 0), try countBlocklistSources(&database));
}
fn listBlocklistSourcesUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedSources(&database);
var items = try listBlocklistSources(&database, gpa);
defer items.deinit(gpa);
defer freeBlocklistSources(gpa, items.items);
}
test "listBlocklistSources is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listBlocklistSourcesUnderFailure, .{});
}
+130
View File
@@ -0,0 +1,130 @@
//! `upstreams`.
//!
//! The list sorts by `priority` first because that is the operationally
//! meaningful order — it matches what `Pool.init` expects — and `url` breaks
//! ties uniquely, which is what makes an export byte-stable.
//!
//! Only list / insert / deleteAll / count exist.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const InsertContext = context.InsertContext;
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listUpstreams(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.UpstreamServer) {
var stmt = try database.prepare("SELECT url, priority, enabled FROM upstreams ORDER BY priority, url");
defer stmt.deinit();
var out: std.ArrayList(model.UpstreamServer) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeUpstreams(gpa, out.items);
while (try stmt.step()) {
const url = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(url);
const priority = std.math.cast(i32, stmt.columnInt(1)) orelse return error.Mismatch;
try out.append(gpa, .{ .url = url, .priority = priority, .enabled = stmt.columnBool(2) });
}
return out;
}
pub fn freeUpstreams(gpa: Allocator, items: []const model.UpstreamServer) void {
for (items) |item| gpa.free(item.url);
}
pub fn insertUpstream(database: *db.Db, item: model.UpstreamServer, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare("INSERT INTO upstreams (url, priority, enabled) VALUES (?1, ?2, ?3)");
defer stmt.deinit();
try stmt.bindText(1, item.url);
try stmt.bindInt(2, item.priority);
try stmt.bindBool(3, item.enabled);
try stmt.exec();
}
pub fn deleteAllUpstreams(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM upstreams;");
}
pub fn countUpstreams(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM upstreams");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
fn seedUpstreams(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertUpstream(database, .{ .url = "https://dns.example/dns-query", .priority = 50 }, ctx);
try insertUpstream(database, .{ .url = "tls://1.1.1.1:853", .priority = 10, .enabled = false }, ctx);
try insertUpstream(database, .{ .url = "https://a.example/dns-query", .priority = 50 }, ctx);
}
test "upstreams round-trip in priority then url order" {
var database = try openMigrated();
defer database.close();
try seedUpstreams(&database);
var items = try listUpstreams(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeUpstreams(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("tls://1.1.1.1:853", items.items[0].url);
try testing.expectEqual(@as(i32, 10), items.items[0].priority);
try testing.expect(!items.items[0].enabled);
try testing.expectEqualStrings("https://a.example/dns-query", items.items[1].url);
try testing.expectEqual(@as(i32, 50), items.items[1].priority);
try testing.expect(items.items[1].enabled);
try testing.expectEqualStrings("https://dns.example/dns-query", items.items[2].url);
try testing.expectEqual(@as(i32, 50), items.items[2].priority);
try testing.expect(items.items[2].enabled);
}
test "deleteAllUpstreams empties the table and countUpstreams reflects it" {
var database = try openMigrated();
defer database.close();
try seedUpstreams(&database);
try testing.expectEqual(@as(i64, 3), try countUpstreams(&database));
try deleteAllUpstreams(&database);
try testing.expectEqual(@as(i64, 0), try countUpstreams(&database));
var items = try listUpstreams(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeUpstreams(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 0), items.items.len);
}
fn listUpstreamsUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedUpstreams(&database);
var items = try listUpstreams(&database, gpa);
defer items.deinit(gpa);
defer freeUpstreams(gpa, items.items);
}
test "listUpstreams is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listUpstreamsUnderFailure, .{});
}
File diff suppressed because it is too large Load Diff