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