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

This commit is contained in:
2026-08-01 14:21:44 +02:00
parent 17d0401f8a
commit 70bff22d75
23 changed files with 10142 additions and 47 deletions
+940
View File
@@ -0,0 +1,940 @@
//! The command line, everything except the process shell around it.
//!
//! `main.zig` parses `argv`, dispatches and returns an exit code; every command
//! body lives here and takes its writers as parameters, so a test can capture
//! what an operator would see into a `std.Io.Writer.Allocating`.
//!
//! Exit codes (milestone-1 convention, extended):
//!
//! - `0` success;
//! - `1` a runtime failure — I/O, database, out of memory;
//! - `2` a configuration the operator can fix, or a `check` that found a
//! problem;
//! - `64` a usage error.
const std = @import("std");
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
const Certificate = std.crypto.Certificate;
const tls = std.crypto.tls;
const config_export = @import("config/export.zig");
const import = @import("config/import.zig");
const model = @import("config/model.zig");
const validate = @import("config/validate.zig");
const db = @import("storage/db.zig");
const migrations = @import("storage/migrations.zig");
const doh_client = @import("upstream/doh_client.zig");
const dot_client = @import("upstream/dot_client.zig");
const pool = @import("upstream/pool.zig");
const transport = @import("upstream/transport.zig");
const version = @import("version.zig");
pub const exit_ok: u8 = 0;
pub const exit_runtime: u8 = 1;
pub const exit_check: u8 = 2;
pub const exit_usage: u8 = 64;
pub const config_db_name = "config.db";
pub const querylog_db_name = "querylog.db";
// ---------------------------------------------------------------------------
// argument parsing (pure)
// ---------------------------------------------------------------------------
pub const Paths = struct {
/// PLAN §3.13.
data_dir: []const u8 = "/var/lib/nxdns",
config: []const u8 = "/etc/nxdns/config.zon",
};
/// `config_explicit` records whether `--config` was given, because `check` has
/// to tell "the operator named a file" from "the default path happens to
/// exist".
pub const CheckArgs = struct { paths: Paths = .{}, config_explicit: bool = false };
pub const ExportArgs = struct { paths: Paths = .{}, out: ?[]const u8 = null };
pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, force: bool = false };
pub const Command = union(enum) {
run: Paths,
check: CheckArgs,
export_: ExportArgs,
import_: ImportArgs,
version,
help,
};
pub const ParseError = error{
UnknownCommand,
UnknownFlag,
MissingValue,
MissingArgument,
TooManyArguments,
};
/// `argv` excludes the program name. Every slice in the result borrows from
/// `argv`.
pub fn parseArgs(argv: []const []const u8) ParseError!Command {
if (argv.len == 0) return error.UnknownCommand;
const command = argv[0];
const rest = argv[1..];
if (eql(command, "version")) {
if (rest.len != 0) return error.TooManyArguments;
return .version;
}
if (eql(command, "help") or eql(command, "--help") or eql(command, "-h")) {
if (rest.len != 0) return error.TooManyArguments;
return .help;
}
if (eql(command, "run")) return .{ .run = (try parseCheckArgs(rest)).paths };
if (eql(command, "check")) return .{ .check = try parseCheckArgs(rest) };
if (eql(command, "export")) return .{ .export_ = try parseExportArgs(rest) };
if (eql(command, "import")) return .{ .import_ = try parseImportArgs(rest) };
return error.UnknownCommand;
}
const Flag = struct {
/// Without the leading `--`.
name: []const u8,
/// The `value` of `--flag=value`, absent for `--flag`.
attached: ?[]const u8,
};
fn splitFlag(arg: []const u8) ?Flag {
if (!std.mem.startsWith(u8, arg, "--")) return null;
const body = arg[2..];
if (std.mem.findScalar(u8, body, '=')) |at| {
return .{ .name = body[0..at], .attached = body[at + 1 ..] };
}
return .{ .name = body, .attached = null };
}
/// `--flag=value` and `--flag value` both work. An empty attached value is a
/// missing value, not an empty path.
fn flagValue(flag: Flag, argv: []const []const u8, i: *usize) ParseError![]const u8 {
if (flag.attached) |attached| {
if (attached.len == 0) return error.MissingValue;
return attached;
}
if (i.* + 1 >= argv.len) return error.MissingValue;
i.* += 1;
return argv[i.*];
}
/// `run` and `check` take the same two flags. `run` throws away
/// `config_explicit`; bootstrap reads the path either way.
fn parseCheckArgs(argv: []const []const u8) ParseError!CheckArgs {
var args: CheckArgs = .{};
var i: usize = 0;
while (i < argv.len) : (i += 1) {
const flag = splitFlag(argv[i]) orelse return error.TooManyArguments;
if (eql(flag.name, "data-dir")) {
args.paths.data_dir = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "config")) {
args.paths.config = try flagValue(flag, argv, &i);
args.config_explicit = true;
} else return error.UnknownFlag;
}
return args;
}
fn parseExportArgs(argv: []const []const u8) ParseError!ExportArgs {
var args: ExportArgs = .{};
var i: usize = 0;
while (i < argv.len) : (i += 1) {
const flag = splitFlag(argv[i]) orelse return error.TooManyArguments;
if (eql(flag.name, "data-dir")) {
args.paths.data_dir = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "out")) {
args.out = try flagValue(flag, argv, &i);
} else return error.UnknownFlag;
}
return args;
}
fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs {
var paths: Paths = .{};
var force = false;
var file: ?[]const u8 = null;
var i: usize = 0;
while (i < argv.len) : (i += 1) {
const flag = splitFlag(argv[i]) orelse {
if (file != null) return error.TooManyArguments;
file = argv[i];
continue;
};
if (eql(flag.name, "data-dir")) {
paths.data_dir = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "force")) {
// A boolean flag takes no value, so `--force=1` is not a spelling of
// any flag this program has.
if (flag.attached != null) return error.UnknownFlag;
force = true;
} else return error.UnknownFlag;
}
return .{
.paths = paths,
.file = file orelse return error.MissingArgument,
.force = force,
};
}
fn eql(a: []const u8, b: []const u8) bool {
return std.mem.eql(u8, a, b);
}
// ---------------------------------------------------------------------------
// path assembly
// ---------------------------------------------------------------------------
pub const DataDir = struct {
dir: std.Io.Dir,
config_db_path: [:0]const u8,
querylog_db_path: [:0]const u8,
/// Creates `data_dir` and its parents at mode 0700 when `create` is set,
/// then opens it. Plain `createDirPath` hardcodes 0777 (Dir.zig:843) and
/// must not be used here: this directory holds `config.db`, which holds
/// `web.password_hash`.
pub fn open(io: std.Io, gpa: Allocator, data_dir: []const u8, create: bool) !DataDir {
if (create) {
_ = try std.Io.Dir.cwd().createDirPathStatus(io, data_dir, .fromMode(0o700));
}
var dir = try std.Io.Dir.cwd().openDir(io, data_dir, .{});
errdefer dir.close(io);
// SQLite opens by path, not by directory handle, so both paths are
// joined and NUL-terminated here rather than resolved through `dir`.
const config_db_path = try std.fs.path.joinZ(gpa, &.{ data_dir, config_db_name });
errdefer gpa.free(config_db_path);
const querylog_db_path = try std.fs.path.joinZ(gpa, &.{ data_dir, querylog_db_name });
return .{
.dir = dir,
.config_db_path = config_db_path,
.querylog_db_path = querylog_db_path,
};
}
pub fn close(self: *DataDir, io: std.Io, gpa: Allocator) void {
self.dir.close(io);
gpa.free(self.config_db_path);
gpa.free(self.querylog_db_path);
}
/// The order of these three steps is the specification, not a style:
///
/// 1. `db.Db.open` creates `config.db`, at `0644 & ~umask` — SQLite's
/// choice, not ours.
/// 2. chmod 0600.
/// 3. `applyPragmas` turns on WAL, which is what creates `config.db-wal`
/// and `config.db-shm`. SQLite copies the main database file's
/// permissions onto both sidecars, so setting 0600 first gets them for
/// free. The other order leaves them world-readable, and the WAL of a
/// database holding `web.password_hash` is as sensitive as the database.
pub fn openConfigDb(self: *const DataDir, io: std.Io) !db.Db {
var database = try db.Db.open(self.config_db_path, .{});
errdefer database.close();
try self.dir.setFilePermissions(io, config_db_name, .fromMode(0o600), .{});
try db.applyPragmas(&database, .{});
return database;
}
};
// ---------------------------------------------------------------------------
// entry functions
// ---------------------------------------------------------------------------
pub const Runner = struct {
io: std.Io,
gpa: Allocator,
out: *Writer,
err: *Writer,
};
const usage_text =
\\usage: nxdns <command> [options]
\\
\\commands:
\\ run serve DNS
\\ check validate the configuration
\\ export write the configuration to stdout, or to --out
\\ import FILE replace the configuration with FILE
\\ version print version information
\\ help print this message
\\
\\options:
\\ --data-dir DIR data directory (default /var/lib/nxdns)
\\ --config FILE configuration file (default /etc/nxdns/config.zon)
\\ --out FILE write the export to FILE instead of stdout
\\ --force let import replace a database that already has content
\\
;
/// Returns nothing, so a writer failure here has nowhere to go. Every caller
/// flushes afterwards and reports that failure instead.
pub fn usage(w: *Writer) void {
w.writeAll(usage_text) catch {};
}
/// Both `main` and the tests end a command the same way: flush, then report.
/// A flush that fails is a runtime failure even when the command succeeded —
/// output the operator never received is not output.
fn finish(r: Runner, code: u8) u8 {
r.out.flush() catch return exit_runtime;
r.err.flush() catch return exit_runtime;
return code;
}
pub fn runUsageError(r: Runner, e: ParseError) u8 {
r.err.print("{s}\n\n", .{parseErrorMessage(e)}) catch {};
usage(r.err);
return finish(r, exit_usage);
}
fn parseErrorMessage(e: ParseError) []const u8 {
return switch (e) {
error.UnknownCommand => "unknown command",
error.UnknownFlag => "unknown flag",
error.MissingValue => "a flag was given without its value",
error.MissingArgument => "a required argument is missing",
error.TooManyArguments => "too many arguments",
};
}
pub fn runHelp(r: Runner) u8 {
usage(r.out);
return finish(r, exit_ok);
}
pub fn runVersion(r: Runner) u8 {
r.out.print("nxdns {s} ({s})\nzig {s}\n", .{
version.string,
version.git_commit,
version.zig_version_string,
}) catch return finish(r, exit_runtime);
return finish(r, exit_ok);
}
/// Unchanged from milestone 1. Wiring the configuration into the servers is
/// Phase 7; there is deliberately no half-built serving path here.
pub fn runRun(r: Runner, paths: Paths) u8 {
_ = paths;
r.out.writeAll("not implemented\n") catch return finish(r, exit_runtime);
return finish(r, exit_check);
}
pub fn runExport(r: Runner, args: ExportArgs) u8 {
exportImpl(r, args) catch |e| {
r.err.print("export failed: {s}\n", .{@errorName(e)}) catch {};
return finish(r, exit_runtime);
};
return finish(r, exit_ok);
}
fn exportImpl(r: Runner, args: ExportArgs) !void {
var data = try DataDir.open(r.io, r.gpa, args.paths.data_dir, false);
defer data.close(r.io, r.gpa);
var database = try data.openConfigDb(r.io);
defer database.close();
_ = try migrations.migrate(&database);
const path = args.out orelse return config_export.writeToWriter(r.gpa, &database, r.out);
// Relative to the working directory, not to the data directory: an operator
// running `nxdns export --out backup.zon` means the shell's directory.
// `writeToFile` is the atomic, 0600 path — the file carries
// `web.password_hash`.
try config_export.writeToFile(r.io, r.gpa, &database, std.Io.Dir.cwd(), path);
try r.out.print("wrote {s}\n", .{path});
}
pub fn runImport(r: Runner, args: ImportArgs) u8 {
var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit();
importImpl(r, args, &diags) catch |e| {
// Every problem, not just the first: an operator fixing a config file
// should need one run to see the whole list.
diags.writeAll(r.err) catch {};
r.err.print("import failed: {s}\n", .{@errorName(e)}) catch {};
return finish(r, failureExitCode(e, diags.problems.items.len));
};
return finish(r, exit_ok);
}
fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
var data = try DataDir.open(r.io, r.gpa, args.paths.data_dir, true);
defer data.close(r.io, r.gpa);
var database = try data.openConfigDb(r.io);
defer database.close();
_ = try migrations.migrate(&database);
try import.importFile(
r.io,
r.gpa,
&database,
std.Io.Dir.cwd(),
args.file,
.{ .force = args.force },
diags,
);
try r.out.print("imported {s}\n", .{args.file});
}
/// A configuration the operator can fix exits 2; everything else is a runtime
/// failure. `validate` records a diagnostic for every error it returns and then
/// returns the first one, so a non-empty diagnostics list is the reliable
/// discriminator; the named errors below are the config faults that never reach
/// the validator.
///
/// `error.OutOfMemory` is matched first, before the list is consulted. Both
/// recording paths — `validate` and import's per-line rendering of a ZON syntax
/// error — add one problem at a time and can run out of memory partway, which
/// leaves problems recorded for a run whose real outcome is a resource failure.
/// A partial report is not a verdict on the configuration, so the runtime exit
/// code wins.
fn failureExitCode(e: anyerror, problems: usize) u8 {
if (e == error.OutOfMemory) return exit_runtime;
if (problems != 0) return exit_check;
return switch (e) {
error.DatabaseNotEmpty,
error.ConfigTooLarge,
error.ParseZon,
error.PasswordAndHashBothSet,
=> exit_check,
else => exit_runtime,
};
}
// ---------------------------------------------------------------------------
// check
// ---------------------------------------------------------------------------
/// An A query for example.com: id 0x1234, RD set, one question. The probe wants
/// a name every resolver on earth answers, so a FAIL line means the upstream is
/// unreachable rather than that the name is odd.
const probe_query =
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
"\x07example\x03com\x00\x00\x01\x00\x01";
/// The point of `check` is that an operator sees every problem in one run, so
/// this never returns early on a finding. `probe` is false in unit tests and
/// true from `main`: the probe leaves the machine.
pub fn runCheck(r: Runner, args: CheckArgs, probe: bool) u8 {
const code = checkImpl(r, args, probe) catch |e| {
r.err.print("check failed: {s}\n", .{@errorName(e)}) catch {};
return finish(r, exit_runtime);
};
return finish(r, code);
}
fn checkImpl(r: Runner, args: CheckArgs, probe: bool) !u8 {
var arena_state: std.heap.ArenaAllocator = .init(r.gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Which source was used is printed in every branch, so the answer is never
// ambiguous about what it checked.
if (args.config_explicit) {
try r.out.print("checking configuration file {s}\n", .{args.paths.config});
return checkFile(r, arena, args.paths.config, probe);
}
const config_db_path = try std.fs.path.join(arena, &.{ args.paths.data_dir, config_db_name });
if (try pathExists(r.io, config_db_path)) {
try r.out.print("checking database {s}\n", .{config_db_path});
var data = try DataDir.open(r.io, r.gpa, args.paths.data_dir, false);
defer data.close(r.io, r.gpa);
// A `check` on a database one schema version behind must still work,
// which is what an operator runs right after an upgrade.
var database = try data.openConfigDb(r.io);
defer database.close();
_ = try migrations.migrate(&database);
const cfg = try config_export.readConfig(&database, arena);
return checkConfig(r, cfg, probe);
}
if (try pathExists(r.io, args.paths.config)) {
try r.out.print("checking configuration file {s}\n", .{args.paths.config});
return checkFile(r, arena, args.paths.config, probe);
}
try r.out.print("nothing to check: no {s} in {s} and no {s}\n", .{
config_db_name,
args.paths.data_dir,
args.paths.config,
});
return exit_check;
}
fn pathExists(io: std.Io, path: []const u8) std.Io.Dir.AccessError!bool {
std.Io.Dir.cwd().access(io, path, .{}) catch |e| switch (e) {
error.FileNotFound => return false,
else => |other| return other,
};
return true;
}
/// `AccessOptions.read` is the `R_OK` bit of `faccessat` (`Io/Threaded.zig`
/// `dirAccessPosix`); the default `.{}` sends mode 0, which is `F_OK` and tests
/// existence only. A file that exists but denies this user a read is exactly the
/// case `check` has to catch, so it needs the bit set.
fn pathReadable(io: std.Io, path: []const u8) std.Io.Dir.AccessError!bool {
std.Io.Dir.cwd().access(io, path, .{ .read = true }) catch |e| switch (e) {
error.FileNotFound, error.AccessDenied, error.PermissionDenied => return false,
else => |other| return other,
};
return true;
}
fn checkFile(r: Runner, arena: Allocator, path: []const u8, probe: bool) !u8 {
const source = std.Io.Dir.cwd().readFileAllocOptions(
r.io,
path,
arena,
.limited(import.max_config_bytes),
.of(u8),
0,
) catch |e| switch (e) {
error.StreamTooLong => {
try r.out.print("FAIL {s}: larger than {d} bytes\n", .{ path, import.max_config_bytes });
return exit_check;
},
else => |other| return other,
};
// Arena-owned and never handed to `std.zon.parse.free`; see the rule and its
// `parse.zig:874` citation in `config/import.zig`.
var zon_diag: std.zon.parse.Diagnostics = .{};
const cfg = std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
// The rendering carries the line and column, which is the whole value of
// running `check` against a file the operator just edited.
error.ParseZon => {
try r.out.print("FAIL {s}: {f}\n", .{ path, &zon_diag });
return exit_check;
},
};
return checkConfig(r, cfg, probe);
}
/// The half of `check` that has a `Config` already: validate, report every
/// diagnostic, then the certificate and upstream checks. With TLS disabled and
/// `probe` false it touches neither the filesystem nor the network, which is
/// what makes it unit-testable.
pub fn checkConfig(r: Runner, cfg: model.Config, probe: bool) !u8 {
var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit();
// The returned error is `problems[0].err` — one of the lines about to be
// printed — so it carries nothing the report does not. Only an allocation
// failure means the report itself is incomplete.
validate.validate(cfg, &diags) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => {},
};
try diags.writeAll(r.out);
var failures = diags.problems.items.len;
failures += try checkCertificates(r, cfg);
if (probe) failures += try probeUpstreams(r, cfg);
if (failures != 0) return exit_check;
try r.out.writeAll("OK: no problems found\n");
return exit_ok;
}
fn checkCertificates(r: Runner, cfg: model.Config) !usize {
return try checkTlsFiles(r, cfg.doh_server, "doh_server") +
try checkTlsFiles(r, cfg.dot_server, "dot_server");
}
/// An unreadable certificate or key fails the run; a key readable by anyone
/// beyond its owner is a warning (PLAN §19) and leaves the exit code alone,
/// because the service still starts.
fn checkTlsFiles(r: Runner, endpoint: model.TlsEndpoint, comptime section: []const u8) !usize {
if (!endpoint.enabled) return 0;
var failures: usize = 0;
if (!try pathReadable(r.io, endpoint.cert_path)) {
try r.out.print("FAIL " ++ section ++ ".cert_path: '{s}' is not readable\n", .{endpoint.cert_path});
failures += 1;
}
if (try pathReadable(r.io, endpoint.key_path)) {
const stat = try std.Io.Dir.cwd().statFile(r.io, endpoint.key_path, .{});
const mode = stat.permissions.toMode() & 0o777;
if (mode & 0o077 != 0) {
try r.out.print(
"WARN " ++ section ++ ".key_path: '{s}' is mode {o}; a TLS key must be readable by its owner only\n",
.{ endpoint.key_path, mode },
);
}
} else {
try r.out.print("FAIL " ++ section ++ ".key_path: '{s}' is not readable\n", .{endpoint.key_path});
failures += 1;
}
return failures;
}
/// One `Pool` per upstream, never one pool over all of them. The pool's job is
/// failover: a shared pool would report success as soon as any upstream
/// answered, and a broken upstream would stay invisible — the exact thing
/// `check` exists to surface. Driving the real pool rather than the client
/// directly keeps the deadline, cancellation and health machinery identical to
/// what the server will do.
fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
var failures: usize = 0;
var http: std.http.Client = .{ .allocator = r.gpa, .io = r.io };
defer http.deinit();
// Shared across every DoT endpoint: the scan is expensive and the trust
// store does not vary per upstream.
var bundle: Certificate.Bundle = .empty;
defer bundle.deinit(r.gpa);
var bundle_lock: std.Io.RwLock = .init;
const chunk = tls.Client.min_buffer_len;
const tls_buffers = try r.gpa.alloc(u8, 4 * chunk);
defer r.gpa.free(tls_buffers);
var request_buf: [1024]u8 = undefined;
var transfer_buf: [4096]u8 = undefined;
const response_buf = try r.gpa.alloc(u8, transport.max_message_len);
defer r.gpa.free(response_buf);
const attempt_timeout: std.Io.Clock.Duration = .{
.raw = model.totalTimeout(cfg.upstream),
.clock = .awake,
};
// The pool jitters backoff from this; one probe per upstream never reaches
// backoff, so the value only has to be a value.
const seed: u64 = @truncate(@as(u96, @bitCast(std.Io.Clock.real.now(r.io).nanoseconds)));
for (cfg.upstreams) |server| {
if (!server.enabled) continue;
const endpoint = transport.Endpoint.parse(server.url) catch {
try r.out.print("FAIL {s}: not an https:// or tls:// endpoint\n", .{server.url});
failures += 1;
continue;
};
// Both clients are pinned for the pool's lifetime: `Entry.client` is an
// erased pointer into one of them.
var doh: doh_client.DohClient = undefined;
var dot: dot_client.DotClient = undefined;
const client: transport.Client = switch (endpoint.scheme) {
.doh => doh: {
doh = doh_client.DohClient.init(&http, endpoint, &request_buf, &transfer_buf) catch {
try r.out.print("FAIL {s}: not a usable DoH url\n", .{server.url});
failures += 1;
continue;
};
break :doh doh.client();
},
.dot => dot: {
dot = dot_client.DotClient.init(endpoint, r.gpa, &bundle, &bundle_lock, .{
.tls_read = tls_buffers[0..chunk],
.tls_write = tls_buffers[chunk .. 2 * chunk],
.stream_read = tls_buffers[2 * chunk .. 3 * chunk],
.stream_write = tls_buffers[3 * chunk ..],
});
break :dot dot.client();
},
};
var entries = [_]pool.Entry{.{
.endpoint = endpoint,
.client = client,
.priority = server.priority,
.enabled = true,
.health = .init,
}};
var single: pool.Pool = .init(&entries, .{}, attempt_timeout, seed);
if (single.exchange(r.io, probe_query, response_buf)) |_| {
try r.out.print("OK {s}\n", .{server.url});
} else |_| {
// The concrete cause lives in the entry's health, which is where the
// pool put it; `@errorName` of the pool's return value would only
// repeat the last attempt's classification.
var snapshots: [1]pool.Snapshot = undefined;
const taken = try single.snapshot(r.io, &snapshots);
const detail = if (taken == 1) snapshots[0].last_error else "no detail recorded";
try r.out.print("FAIL {s}: {s}\n", .{ server.url, detail });
failures += 1;
}
}
return failures;
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
test "parseArgs accepts run with no flags" {
const command = try parseArgs(&.{"run"});
try testing.expectEqualStrings("/var/lib/nxdns", command.run.data_dir);
try testing.expectEqualStrings("/etc/nxdns/config.zon", command.run.config);
}
test "parseArgs accepts run with --data-dir and --config" {
const command = try parseArgs(&.{ "run", "--data-dir", "/srv/nx", "--config", "/tmp/c.zon" });
try testing.expectEqualStrings("/srv/nx", command.run.data_dir);
try testing.expectEqualStrings("/tmp/c.zon", command.run.config);
}
test "parseArgs accepts --data-dir with and without an equals sign" {
const attached = try parseArgs(&.{ "check", "--data-dir=/srv/nx" });
try testing.expectEqualStrings("/srv/nx", attached.check.paths.data_dir);
const separate = try parseArgs(&.{ "check", "--data-dir", "/srv/nx" });
try testing.expectEqualStrings("/srv/nx", separate.check.paths.data_dir);
}
test "parseArgs records whether check was given an explicit --config" {
const implicit = try parseArgs(&.{"check"});
try testing.expect(!implicit.check.config_explicit);
const explicit = try parseArgs(&.{ "check", "--config=/tmp/c.zon" });
try testing.expect(explicit.check.config_explicit);
try testing.expectEqualStrings("/tmp/c.zon", explicit.check.paths.config);
}
test "parseArgs accepts export with --out" {
const command = try parseArgs(&.{ "export", "--out", "backup.zon" });
try testing.expectEqualStrings("backup.zon", command.export_.out.?);
const bare = try parseArgs(&.{"export"});
try testing.expectEqual(@as(?[]const u8, null), bare.export_.out);
}
test "parseArgs accepts import with a file, --force and --data-dir" {
const command = try parseArgs(&.{ "import", "c.zon", "--force", "--data-dir=/srv/nx" });
try testing.expectEqualStrings("c.zon", command.import_.file);
try testing.expect(command.import_.force);
try testing.expectEqualStrings("/srv/nx", command.import_.paths.data_dir);
}
test "parseArgs accepts import with the file after the flags" {
const command = try parseArgs(&.{ "import", "--data-dir", "/srv/nx", "c.zon" });
try testing.expectEqualStrings("c.zon", command.import_.file);
try testing.expect(!command.import_.force);
}
test "parseArgs accepts version" {
try testing.expectEqual(Command.version, try parseArgs(&.{"version"}));
}
test "parseArgs accepts help, --help and -h" {
try testing.expectEqual(Command.help, try parseArgs(&.{"help"}));
try testing.expectEqual(Command.help, try parseArgs(&.{"--help"}));
try testing.expectEqual(Command.help, try parseArgs(&.{"-h"}));
}
test "parseArgs rejects import without a file" {
try testing.expectError(error.MissingArgument, parseArgs(&.{"import"}));
try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--force" }));
}
test "parseArgs rejects --out without a value" {
try testing.expectError(error.MissingValue, parseArgs(&.{ "export", "--out" }));
try testing.expectError(error.MissingValue, parseArgs(&.{ "export", "--out=" }));
}
test "parseArgs rejects an unknown flag" {
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "check", "--nope" }));
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--force=1" }));
}
test "parseArgs rejects an unknown command" {
try testing.expectError(error.UnknownCommand, parseArgs(&.{"frobnicate"}));
}
test "parseArgs rejects an empty argument list" {
try testing.expectError(error.UnknownCommand, parseArgs(&.{}));
}
test "parseArgs rejects an extra positional argument" {
try testing.expectError(error.TooManyArguments, parseArgs(&.{ "export", "extra" }));
try testing.expectError(error.TooManyArguments, parseArgs(&.{ "version", "extra" }));
try testing.expectError(error.TooManyArguments, parseArgs(&.{ "import", "a.zon", "b.zon" }));
try testing.expectError(error.TooManyArguments, parseArgs(&.{ "help", "extra" }));
try testing.expectError(error.TooManyArguments, parseArgs(&.{ "--help", "extra" }));
try testing.expectError(error.TooManyArguments, parseArgs(&.{ "-h", "extra" }));
}
test "usage writes non-empty text" {
var out: Writer.Allocating = .init(testing.allocator);
defer out.deinit();
usage(&out.writer);
try testing.expect(out.written().len > 0);
try testing.expect(std.mem.startsWith(u8, out.written(), "usage: nxdns"));
}
const Captured = struct {
threaded: std.Io.Threaded,
out: Writer.Allocating,
err: Writer.Allocating,
fn init(gpa: Allocator) Captured {
return .{
.threaded = .init(gpa, .{}),
.out = .init(gpa),
.err = .init(gpa),
};
}
fn deinit(self: *Captured) void {
self.out.deinit();
self.err.deinit();
self.threaded.deinit();
}
fn runner(self: *Captured) Runner {
return .{
.io = self.threaded.io(),
.gpa = testing.allocator,
.out = &self.out.writer,
.err = &self.err.writer,
};
}
};
fn countLines(text: []const u8) usize {
return std.mem.count(u8, text, "\n");
}
test "checkConfig returns 0 for a clean configuration" {
var captured: Captured = .init(testing.allocator);
defer captured.deinit();
const cfg: model.Config = .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
};
try testing.expectEqual(exit_ok, try checkConfig(captured.runner(), cfg, false));
try testing.expectEqualStrings("OK: no problems found\n", captured.out.written());
}
test "checkConfig returns 2 and prints one line per problem" {
var captured: Captured = .init(testing.allocator);
defer captured.deinit();
// Three problems: no group named 'default', no enabled upstream, and a
// zero port.
const cfg: model.Config = .{ .dns = .{ .port = 0 } };
try testing.expectEqual(exit_check, try checkConfig(captured.runner(), cfg, false));
const text = captured.out.written();
try testing.expectEqual(@as(usize, 3), countLines(text));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "dns.port:"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "groups:"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "upstreams:"));
}
test "checkConfig reports every problem rather than stopping at the first" {
var captured: Captured = .init(testing.allocator);
defer captured.deinit();
const cfg: model.Config = .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.clients = &.{
.{ .ip = "not-an-ip" },
.{ .ip = "also-not-an-ip" },
.{ .ip = "192.168.1.1", .group = "missing" },
},
};
try testing.expectEqual(exit_check, try checkConfig(captured.runner(), cfg, false));
try testing.expectEqual(@as(usize, 3), countLines(captured.out.written()));
}
test "runVersion prints the milestone-1 version lines and exits 0" {
var captured: Captured = .init(testing.allocator);
defer captured.deinit();
try testing.expectEqual(exit_ok, runVersion(captured.runner()));
try testing.expect(std.mem.startsWith(u8, captured.out.written(), "nxdns "));
try testing.expectEqual(@as(usize, 2), countLines(captured.out.written()));
}
test "runRun still reports that serving is not implemented" {
var captured: Captured = .init(testing.allocator);
defer captured.deinit();
try testing.expectEqual(exit_check, runRun(captured.runner(), .{}));
try testing.expectEqualStrings("not implemented\n", captured.out.written());
}
test "runUsageError names the fault and prints the usage text" {
var captured: Captured = .init(testing.allocator);
defer captured.deinit();
try testing.expectEqual(exit_usage, runUsageError(captured.runner(), error.UnknownFlag));
const text = captured.err.written();
try testing.expect(std.mem.startsWith(u8, text, "unknown flag\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "usage: nxdns"));
}
test "failureExitCode separates a fixable configuration from a runtime failure" {
try testing.expectEqual(exit_check, failureExitCode(error.DatabaseNotEmpty, 0));
try testing.expectEqual(exit_check, failureExitCode(error.ParseZon, 0));
try testing.expectEqual(exit_check, failureExitCode(error.NoUpstreams, 1));
try testing.expectEqual(exit_runtime, failureExitCode(error.IoErr, 0));
try testing.expectEqual(exit_runtime, failureExitCode(error.OutOfMemory, 0));
// A partial report from an allocation failure is a runtime failure, not a
// finding about the configuration.
try testing.expectEqual(exit_runtime, failureExitCode(error.OutOfMemory, 3));
}
test "an allocation failure while recording diagnostics exits 1, not 2" {
// The recording path for real: `validate` adds one problem per fault, so an
// allocator that fails partway leaves problems in the list AND returns
// `error.OutOfMemory`. How many allocations one problem costs is
// `Diagnostics.add`'s business, so the failure point is swept rather than
// guessed.
const cfg: model.Config = .{ .dns = .{ .port = 0 } };
var saw_partial_report = false;
var fail_index: usize = 0;
while (fail_index < 32) : (fail_index += 1) {
var failing: std.testing.FailingAllocator = .init(testing.allocator, .{ .fail_index = fail_index });
var diags: validate.Diagnostics = .init(failing.allocator());
defer diags.deinit();
validate.validate(cfg, &diags) catch |e| {
if (e != error.OutOfMemory) continue;
const problems = diags.problems.items.len;
if (problems == 0) continue;
saw_partial_report = true;
try testing.expectEqual(exit_runtime, failureExitCode(e, problems));
};
}
try testing.expect(saw_partial_report);
}
// `runCheck` against real paths, `runExport` and `runImport` all need a real
// data directory, and the upstream probe leaves the machine. Those cases are
// S7's: `src/storage/storage_integration_test.zig` cases 20-22.
+60
View File
@@ -0,0 +1,60 @@
//! First-start seeding (PLAN §3.5).
//!
//! A policy wrapper over `import.importFile`, and nothing more. There is exactly
//! one code path from a config file into the database, so bootstrap and
//! `nxdns import` cannot drift apart.
//!
//! The policy is three lines long:
//!
//! - no file → normal steady state, keep the database as it is;
//! - database already configured → the file is ignored, as PLAN §3.5 requires;
//! - otherwise → import it, and a file that is unreadable, unparseable or
//! invalid is an error. The operator wrote that file and meant it; starting
//! with silent defaults instead is the exact failure mode PLAN §1.3 exists to
//! prevent.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../storage/db.zig");
const import = @import("import.zig");
const validate = @import("validate.zig");
const log = std.log.scoped(.config_bootstrap);
pub const Outcome = enum { seeded, db_already_configured, no_config_file };
pub const Error = import.Error || std.Io.Dir.AccessError;
/// Called by `nxdns run` before serving.
pub fn bootstrap(
io: std.Io,
gpa: Allocator,
database: *db.Db,
dir: std.Io.Dir,
config_path: []const u8,
diags: *validate.Diagnostics,
) Error!Outcome {
dir.access(io, config_path, .{}) catch |e| switch (e) {
error.FileNotFound => {
log.info("no configuration file at '{s}'; using the database as it is", .{config_path});
return .no_config_file;
},
else => |other| return other,
};
// Deliberately before the read: on every start after the first, the file is
// not even opened.
if (!try import.isEmpty(database)) {
log.info("configuration file ignored; the database is already configured", .{});
return .db_already_configured;
}
try import.importFile(io, gpa, database, dir, config_path, .{ .force = false }, diags);
log.info("seeded the database from '{s}'", .{config_path});
return .seeded;
}
// Every path through `bootstrap` starts with a filesystem access, so all three
// outcomes are exercised in `src/storage/storage_integration_test.zig` (S7)
// against real files. There is nothing here that an in-memory test could reach.
+309
View File
@@ -0,0 +1,309 @@
//! `nxdns export`: the database rendered back as canonical ZON.
//!
//! Deterministic by construction. Every list arrives through a repository whose
//! `ORDER BY` ends in a unique column set, every scalar comes from the settings
//! map, and the header carries no timestamp, version or host name. That is what
//! makes `export` → `import` → `export` byte-identical, and it keeps a config
//! diff free of noise.
//!
//! Runtime columns are absent from the model on purpose, so two exports taken
//! minutes apart on a live server are identical too.
const std = @import("std");
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
const db = @import("../storage/db.zig");
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const groups_repo = @import("../storage/repositories/groups_repo.zig");
const local_repo = @import("../storage/repositories/local_repo.zig");
const rules_repo = @import("../storage/repositories/rules_repo.zig");
const settings_repo = @import("../storage/repositories/settings_repo.zig");
const sources_repo = @import("../storage/repositories/sources_repo.zig");
const upstreams_repo = @import("../storage/repositories/upstreams_repo.zig");
const model = @import("model.zig");
const log = std.log.scoped(.config_export);
/// What reading the database into a `Config` can fail with. A caller that only
/// wants the value never has to handle a filesystem error.
pub const ReadError = db.Error || model.SettingsError;
pub const Error = ReadError || Writer.Error ||
std.Io.Dir.CreateFileAtomicError || std.Io.File.SyncError || std.Io.File.Atomic.ReplaceError;
const header =
\\// nxdns configuration
\\// generated by `nxdns export` — the database is the source of truth
\\
;
/// Reads every repository into `arena` and assembles a `Config`. Every string in
/// the result belongs to `arena`; there is nothing else to free.
pub fn readConfig(database: *db.Db, arena: Allocator) ReadError!model.Config {
var cfg: model.Config = .{};
// The scalar sections first: an absent key keeps the model default, which is
// how a migration adds a setting with no data step. Unknown keys are counted
// and logged rather than refused — downgrading a binary must not make a
// config database unreadable.
const settings = try settings_repo.listSettings(database, arena);
var unknown_keys: usize = 0;
try model.fromSettings(settings.items, &cfg, &unknown_keys);
if (unknown_keys != 0) {
log.warn("{d} unknown settings key(s) were ignored while exporting", .{unknown_keys});
}
cfg.groups = (try groups_repo.listGroups(database, arena)).items;
cfg.upstreams = (try upstreams_repo.listUpstreams(database, arena)).items;
cfg.clients = (try clients_repo.listClients(database, arena)).items;
cfg.client_prefixes = (try clients_repo.listClientPrefixes(database, arena)).items;
cfg.blocklist_sources = (try sources_repo.listBlocklistSources(database, arena)).items;
cfg.group_sources = (try groups_repo.listGroupSources(database, arena)).items;
cfg.rules = (try rules_repo.listRules(database, arena)).items;
cfg.local_records = (try local_repo.listLocalRecords(database, arena)).items;
cfg.forward_zones = (try local_repo.listForwardZones(database, arena)).items;
// `web.password` is operator input and is never stored; the exported file
// always carries an empty one. This is exactly what makes the round trip
// stable: re-importing takes the "password is empty" branch and stores the
// same hash.
cfg.web.password = "";
return cfg;
}
/// Canonical ZON: the fixed header, then the value with every default emitted.
/// Emitting defaults makes the file a complete record of the running
/// configuration and makes the round trip independent of a later change to a
/// default value.
pub fn writeConfig(cfg: model.Config, w: *Writer) Writer.Error!void {
try w.writeAll(header);
try std.zon.stringify.serialize(cfg, .{
.whitespace = true,
.emit_default_optional_fields = true,
}, w);
try w.writeAll("\n");
}
pub fn writeToWriter(gpa: Allocator, database: *db.Db, w: *Writer) Error!void {
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const cfg = try readConfig(database, arena_state.allocator());
return writeConfig(cfg, w);
}
/// Atomic and owner-only. The exported file carries `web.password_hash`, so 0600
/// is not optional.
///
/// `createFileAtomic` puts its temporary file in the destination's own directory
/// (verified in `Io/Threaded.zig`: `atomicFileInit` receives either `dir` or the
/// directory opened on `dirname(dest_path)`), so the final `replace` is a
/// same-filesystem `rename` and is genuinely atomic.
pub fn writeToFile(
io: std.Io,
gpa: Allocator,
database: *db.Db,
dir: std.Io.Dir,
path: []const u8,
) Error!void {
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const cfg = try readConfig(database, arena_state.allocator());
var af = try dir.createFileAtomic(io, path, .{
.permissions = .fromMode(0o600),
.replace = true,
});
defer af.deinit(io);
var buf: [4096]u8 = undefined;
var fw = af.file.writer(io, &buf);
writeConfig(cfg, &fw.interface) catch |e| return reportWriteFailure(&fw, e);
fw.interface.flush() catch |e| return reportWriteFailure(&fw, e);
// Before `replace`, which closes the file: the rename must publish durable
// bytes, not an empty file with the content still in the page cache.
try af.file.sync(io);
try af.replace(io);
}
/// `Writer.Error` is a single `error.WriteFailed`; the cause lives on the
/// `File.Writer`. Logging it at `warn` is what turns "export failed" into
/// something an operator can act on.
fn reportWriteFailure(fw: *std.Io.File.Writer, e: Writer.Error) Writer.Error {
if (fw.err) |cause| log.warn("writing the export failed: {s}", .{@errorName(cause)});
return e;
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
const migrations = @import("../storage/migrations.zig");
const import = @import("import.zig");
const validate = @import("validate.zig");
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;
}
const seed_source: [:0]const u8 =
\\.{
\\ .dns = .{ .port = 5353 },
\\ .logging = .{ .level = .err, .retention_days = 7 },
\\ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaGhhc2g" },
\\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } },
\\ .upstreams = .{
\\ .{ .url = "https://dns.example/dns-query", .priority = 10 },
\\ .{ .url = "tls://dot.example:853", .priority = 20, .enabled = false },
\\ },
\\ .clients = .{ .{ .ip = "fd00::1", .name = "tablet", .group = "kids" } },
\\ .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } },
\\ .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "ads" } },
\\ .group_sources = .{ .{ .group = "kids", .source_url = "https://lists.example/ads.txt" } },
\\ .rules = .{
\\ .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block },
\\ .{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow },
\\ },
\\ .local_records = .{
\\ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 600 },
\\ .{ .name = "nas.lan", .rtype = .aaaa, .value = "fd00::10" },
\\ },
\\ .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.1:53" } },
\\}
;
fn seed(io: std.Io, database: *db.Db, source: [:0]const u8) !void {
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
return import.importSource(io, testing.allocator, database, source, .{}, &diags);
}
test "writeConfig emits the fixed header and re-parses into an equal config" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = testing.allocator;
var database = try openMigrated();
defer database.close();
try seed(io, &database, seed_source);
var out: Writer.Allocating = .init(gpa);
defer out.deinit();
try writeToWriter(gpa, &database, &out.writer);
// The header is fixed text. Anything variable in it — a timestamp, a version
// or a host name — would break the byte-stable round trip.
const text = out.written();
try testing.expect(std.mem.startsWith(u8, text, header));
try testing.expectEqualStrings(header, text[0..header.len]);
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const source = try gpa.dupeZ(u8, text);
defer gpa.free(source);
const reparsed = try std.zon.parse.fromSliceAlloc(
model.Config,
arena_state.allocator(),
source,
null,
.{},
);
try testing.expectEqual(@as(u16, 5353), reparsed.dns.port);
try testing.expectEqual(model.LogLevel.err, reparsed.logging.level);
}
test "readConfig, writeConfig, import and readConfig again produce an equal config" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = testing.allocator;
var first = try openMigrated();
defer first.close();
try seed(io, &first, seed_source);
var text: Writer.Allocating = .init(gpa);
defer text.deinit();
try writeToWriter(gpa, &first, &text.writer);
const source = try text.toOwnedSliceSentinel(0);
defer gpa.free(source);
var second = try openMigrated();
defer second.close();
try seed(io, &second, source);
var arena_a: std.heap.ArenaAllocator = .init(gpa);
defer arena_a.deinit();
var arena_b: std.heap.ArenaAllocator = .init(gpa);
defer arena_b.deinit();
const a = try readConfig(&first, arena_a.allocator());
const b = try readConfig(&second, arena_b.allocator());
try testing.expectEqual(a.dns.port, b.dns.port);
try testing.expectEqual(a.logging.level, b.logging.level);
try testing.expectEqualStrings(a.web.password_hash, b.web.password_hash);
try testing.expectEqual(a.groups.len, b.groups.len);
try testing.expectEqual(a.rules.len, b.rules.len);
try testing.expectEqualStrings(a.clients[0].ip, b.clients[0].ip);
try testing.expectEqualStrings(a.forward_zones[0].resolver, b.forward_zones[0].resolver);
}
test "export is byte-stable across a re-import" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = testing.allocator;
var first = try openMigrated();
defer first.close();
try seed(io, &first, seed_source);
var a: Writer.Allocating = .init(gpa);
defer a.deinit();
try writeToWriter(gpa, &first, &a.writer);
const source = try gpa.dupeZ(u8, a.written());
defer gpa.free(source);
var second = try openMigrated();
defer second.close();
try seed(io, &second, source);
var b: Writer.Allocating = .init(gpa);
defer b.deinit();
try writeToWriter(gpa, &second, &b.writer);
try testing.expectEqualStrings(a.written(), b.written());
}
test "an exported password_hash survives a re-import unchanged" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = testing.allocator;
var database = try openMigrated();
defer database.close();
const cfg: model.Config = .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.web = .{ .password = "correct horse battery staple" },
};
try import.applyToDb(io, gpa, &database, cfg, 42, .{});
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const exported = try readConfig(&database, arena_state.allocator());
try testing.expectEqualStrings("", exported.web.password);
try testing.expect(std.mem.startsWith(u8, exported.web.password_hash, "$argon2id$"));
}
+621
View File
@@ -0,0 +1,621 @@
//! `nxdns import`: a ZON file becomes the whole content of `config.db`.
//!
//! The order is the specification. Nothing reaches the database until the file
//! has been read, parsed and validated, and every write happens inside one
//! `BEGIN IMMEDIATE` transaction, so a failed import leaves the database
//! byte-for-byte as it was.
//!
//! No filesystem write happens anywhere in this file: the input is opened
//! read-only and the database is SQLite's business.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../storage/db.zig");
const config_schema = @import("../storage/config_schema.zig");
const context = @import("../storage/repositories/context.zig");
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const groups_repo = @import("../storage/repositories/groups_repo.zig");
const local_repo = @import("../storage/repositories/local_repo.zig");
const rules_repo = @import("../storage/repositories/rules_repo.zig");
const settings_repo = @import("../storage/repositories/settings_repo.zig");
const sources_repo = @import("../storage/repositories/sources_repo.zig");
const upstreams_repo = @import("../storage/repositories/upstreams_repo.zig");
const address = @import("../platform/address.zig");
const model = @import("model.zig");
const validate = @import("validate.zig");
const log = std.log.scoped(.config_import);
pub const Options = struct { force: bool = false };
pub const Error = db.Error || validate.ValidateError || std.Io.Dir.ReadFileAllocError ||
error{ DatabaseNotEmpty, ConfigTooLarge, ParseZon, PasswordAndHashBothSet };
pub const max_config_bytes = 4 * 1024 * 1024;
/// Holds any PHC-encoded argon2id string comfortably.
const hash_buf_len = 256;
/// The canonical text of an IPv6 prefix, the longest value canonicalised here.
const canonical_buf_len = 64;
// ---------------------------------------------------------------------------
// emptiness
// ---------------------------------------------------------------------------
/// A database is "never configured" when the migrations have run and nothing
/// else has. The migrations themselves create `schema_version` and seed
/// `groups(1, 'default')`, so "no rows anywhere" is the wrong test.
///
/// True iff every table in `config_schema.content_tables` is empty, `groups`
/// holds exactly one row, and that row is the seeded `(1, 'default', 0)`.
///
/// The client count here includes auto-materialised rows: a server that has
/// answered one query is configured enough that a bootstrap file must not
/// overwrite it.
pub fn isEmpty(database: *db.Db) db.Error!bool {
// `inline for` over a comptime table list: every statement below is a
// compile-time string, so no table name is ever concatenated at run time.
inline for (config_schema.content_tables) |table| {
if (try database.queryInt("SELECT count(*) FROM " ++ table) != 0) return false;
}
if (try database.queryInt("SELECT count(*) FROM groups") != 1) return false;
const seeded = try database.queryInt(
"SELECT count(*) FROM groups WHERE id = 1 AND name = 'default' AND safe_search = 0",
);
return seeded == 1;
}
// ---------------------------------------------------------------------------
// import
// ---------------------------------------------------------------------------
/// Reads, parses, validates, then replaces the database contents.
pub fn importFile(
io: std.Io,
gpa: Allocator,
database: *db.Db,
dir: std.Io.Dir,
path: []const u8,
options: Options,
diags: *validate.Diagnostics,
) Error!void {
// `std.zon.parse` needs a sentinel-terminated source and `readFileAlloc`
// cannot supply one.
const source = dir.readFileAllocOptions(
io,
path,
gpa,
.limited(max_config_bytes),
.of(u8),
0,
) catch |e| switch (e) {
error.StreamTooLong => return error.ConfigTooLarge,
else => |other| return other,
};
defer gpa.free(source);
return importSource(io, gpa, database, source, options, diags);
}
/// `importFile` minus the file. It exists because every step from the parse
/// onwards is testable without touching a filesystem, and `nxdns check` (S6)
/// needs the same parse-and-validate half.
pub fn importSource(
io: std.Io,
gpa: Allocator,
database: *db.Db,
source: [:0]const u8,
options: Options,
diags: *validate.Diagnostics,
) Error!void {
// The parsed `Config` is arena-owned and `std.zon.parse.free` is NEVER
// called on it. `Parser.parseStruct` fills an absent field by copying the
// struct's default straight through (parse.zig:874), so a defaulted
// `[]const u8` — and this model has many non-empty string defaults — points
// into the binary's read-only data. `parse.free` keeps no record of which
// fields were parsed and which were defaulted, so it would `@memset` and
// free rodata. Freeing the arena is the only correct release.
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();
var zon_diag: std.zon.parse.Diagnostics = .{};
const cfg = std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.ParseZon => {
try reportParseFailure(diags, &zon_diag);
return error.ParseZon;
},
};
try validate.validate(cfg, diags);
const now = std.Io.Clock.real.now(io).toSeconds();
return applyToDb(io, gpa, database, cfg, now, options);
}
/// The line and column of a ZON syntax error are the only thing the operator can
/// act on, so they travel the same channel as every other config problem: the
/// caller's `Diagnostics`, which `nxdns import` already renders to stderr. The
/// global log is not that channel — an operator reading command output would see
/// a bare `ParseZon` and nothing else.
///
/// `std.zon.parse.Diagnostics` renders one "line:column: error: text" line per
/// problem, plus a "note:" line each, so each rendered line becomes one
/// `Problem` and the list keeps the parser's order.
fn reportParseFailure(
diags: *validate.Diagnostics,
zon_diag: *const std.zon.parse.Diagnostics,
) error{OutOfMemory}!void {
const rendered = try std.fmt.allocPrint(diags.gpa, "{f}", .{zon_diag});
defer diags.gpa.free(rendered);
var lines = std.mem.splitScalar(u8, rendered, '\n');
while (lines.next()) |line| {
if (line.len == 0) continue;
try diags.add(error.ParseZon, "config", .{}, "{s}", .{line});
}
}
/// The half `bootstrap` reuses: an already-parsed, already-validated config into
/// the database, all or nothing. `now` is the caller's timestamp for the runtime
/// columns the model omits.
pub fn applyToDb(
io: std.Io,
gpa: Allocator,
database: *db.Db,
cfg: model.Config,
now: i64,
options: Options,
) Error!void {
var tx = try db.Tx.begin(database);
errdefer tx.rollback();
// Inside the transaction on purpose. Checking before `BEGIN IMMEDIATE`
// would leave a TOCTOU window against a concurrently starting process;
// `BEGIN IMMEDIATE` already holds the write lock, so the check and the
// writes are one atomic unit.
if (!options.force and !try isEmpty(database)) return error.DatabaseNotEmpty;
inline for (config_schema.delete_order) |table| {
try database.exec("DELETE FROM " ++ table ++ ";");
}
var group_ids: context.IdMap = .empty;
defer group_ids.deinit(gpa);
var source_ids: context.IdMap = .empty;
defer source_ids.deinit(gpa);
try insertGroups(database, gpa, cfg, &group_ids);
try insertSources(database, gpa, cfg, &source_ids);
const ctx: context.InsertContext = .{
.now = now,
.group_ids = &group_ids,
.source_ids = &source_ids,
};
for (cfg.clients) |client| {
var buf: [canonical_buf_len]u8 = undefined;
var canonical = client;
canonical.ip = try canonicalIp(client.ip, &buf);
try clients_repo.insertClient(database, canonical, ctx);
}
for (cfg.client_prefixes) |entry| {
var buf: [canonical_buf_len]u8 = undefined;
var canonical = entry;
canonical.prefix = try canonicalPrefix(entry.prefix, &buf);
try clients_repo.insertClientPrefix(database, canonical, ctx);
}
for (cfg.upstreams) |item| try upstreams_repo.insertUpstream(database, item, ctx);
for (cfg.group_sources) |item| try groups_repo.insertGroupSource(database, item, ctx);
for (cfg.rules) |item| try rules_repo.insertRule(database, item, ctx);
for (cfg.local_records) |item| try local_repo.insertLocalRecord(database, item, ctx);
for (cfg.forward_zones) |item| try local_repo.insertForwardZone(database, item, ctx);
// The buffer must outlive `toSettings`: `effective.web.password_hash` points
// into it.
var hash_buf: [hash_buf_len]u8 = undefined;
var effective = cfg;
if (cfg.web.password.len != 0) {
if (cfg.web.password_hash.len != 0) return error.PasswordAndHashBothSet;
effective.web.password_hash = try hashPassword(io, gpa, cfg.web.password, &hash_buf);
}
// Operator input, never stored. `toSettings` skips the field in both
// directions; clearing it here keeps the in-memory value honest too.
effective.web.password = "";
var pairs: std.ArrayList(model.SettingPair) = .empty;
defer {
model.freeSettings(gpa, pairs.items);
pairs.deinit(gpa);
}
try model.toSettings(effective, gpa, &pairs);
for (pairs.items) |pair| try settings_repo.insertSetting(database, pair, ctx);
try tx.commit();
}
/// `default` goes in first and takes rowid 1. §11.2 seeds group 1 as `default`
/// and §7.2's fallback assignment depends on it; letting an import renumber it
/// would silently move every unassigned client.
///
/// The repositories expose no insert-with-id, so the id is taken rather than
/// given: SQLite assigns rowid 1 to the first row of an empty table, and the
/// table was emptied a few statements ago. The result is checked, not assumed.
fn insertGroups(database: *db.Db, gpa: Allocator, cfg: model.Config, ids: *context.IdMap) Error!void {
const default_index = indexOfGroup(cfg.groups, "default") orelse {
log.warn("the config declares no group named 'default'", .{});
return error.MissingDefaultGroup;
};
try insertGroup(database, gpa, cfg.groups[default_index], ids);
const default_id = ids.get("default").?;
if (default_id != 1) {
log.warn("group 'default' took id {d}, not 1", .{default_id});
return error.Unexpected;
}
for (cfg.groups, 0..) |group, i| {
if (i == default_index) continue;
try insertGroup(database, gpa, group, ids);
}
}
fn insertGroup(database: *db.Db, gpa: Allocator, group: model.Group, ids: *context.IdMap) Error!void {
try groups_repo.insertGroup(database, group, .{});
// The key borrows from `cfg`, which outlives the transaction.
try ids.put(gpa, group.name, database.lastInsertRowid());
}
fn insertSources(database: *db.Db, gpa: Allocator, cfg: model.Config, ids: *context.IdMap) Error!void {
for (cfg.blocklist_sources) |item| {
try sources_repo.insertBlocklistSource(database, item, .{});
try ids.put(gpa, item.url, database.lastInsertRowid());
}
}
fn indexOfGroup(groups: []const model.Group, name: []const u8) ?usize {
for (groups, 0..) |group, i| {
if (std.mem.eql(u8, group.name, name)) return i;
}
return null;
}
/// The validator compares client addresses after canonicalisation, so the row
/// this writes must be canonical too — otherwise `fd00::1` and
/// `FD00:0:0:0:0:0:0:1` pass validation as a duplicate pair and then collide on
/// the column's `UNIQUE`.
fn canonicalIp(text: []const u8, buf: []u8) error{BadClientIp}![]const u8 {
const addr = address.NetAddress.parse(text) catch return error.BadClientIp;
var w: std.Io.Writer = .fixed(buf);
addr.format(&w) catch return error.BadClientIp;
return w.buffered();
}
fn canonicalPrefix(text: []const u8, buf: []u8) error{BadClientPrefix}![]const u8 {
const prefix = address.Prefix.parse(text) catch return error.BadClientPrefix;
var w: std.Io.Writer = .fixed(buf);
prefix.format(&w) catch return error.BadClientPrefix;
return w.buffered();
}
/// argon2id with the OWASP parameters (t=2, m=19 MiB, p=1) rather than the
/// 64 MiB `interactive_2id`, because PLAN §18 budgets under 100 MB total on a
/// Pi 5.
///
/// `strHash`'s error set reaches beyond this module's (it carries
/// `std.Thread.SpawnError` and the PHC encoding errors), so anything that is
/// neither out of memory nor a cancellation is reported as `error.Unexpected`
/// with the real cause logged.
fn hashPassword(io: std.Io, gpa: Allocator, password: []const u8, buf: []u8) Error![]const u8 {
return std.crypto.pwhash.argon2.strHash(password, .{
.allocator = gpa,
.params = .owasp_2id,
.mode = .argon2id,
.encoding = .phc,
}, buf, io) catch |e| switch (e) {
error.OutOfMemory => error.OutOfMemory,
error.Canceled => error.Canceled,
else => {
log.warn("hashing web.password failed: {s}", .{@errorName(e)});
return error.Unexpected;
},
};
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
const migrations = @import("../storage/migrations.zig");
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;
}
/// Every row of every config table, rendered in a stable order. Two dumps are
/// equal exactly when the database content is.
fn dump(database: *db.Db, gpa: Allocator) ![]u8 {
var out: std.Io.Writer.Allocating = .init(gpa);
errdefer out.deinit();
const w = &out.writer;
try w.writeAll("groups\n");
var stmt = try database.prepare("SELECT id, name, safe_search FROM groups ORDER BY id");
defer stmt.deinit();
while (try stmt.step()) {
try w.print(" {d} {s} {d}\n", .{ stmt.columnInt(0), stmt.columnText(1), stmt.columnInt(2) });
}
inline for (config_schema.content_tables) |table| {
try w.print("{s}\n", .{table});
var rows = try database.prepare("SELECT * FROM " ++ table ++ " ORDER BY 1, 2");
defer rows.deinit();
const columns = db.c.sqlite3_column_count(rows.handle);
while (try rows.step()) {
var col: c_int = 0;
while (col < columns) : (col += 1) {
try w.print(" {s}", .{rows.columnText(col)});
}
try w.writeAll("\n");
}
}
return out.toOwnedSlice();
}
/// The smallest config that validates: one enabled upstream and the `default`
/// group.
const minimal_source: [:0]const u8 =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\}
;
/// Exercises every collection and a few non-default scalars.
const full_source: [:0]const u8 =
\\.{
\\ .dns = .{ .port = 5353 },
\\ .logging = .{ .level = .err, .retention_days = 7 },
\\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } },
\\ .upstreams = .{
\\ .{ .url = "https://dns.example/dns-query", .priority = 10 },
\\ .{ .url = "tls://dot.example:853", .priority = 20, .enabled = false },
\\ },
\\ .clients = .{ .{ .ip = "FD00:0:0:0:0:0:0:1", .name = "tablet", .group = "kids" } },
\\ .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } },
\\ .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "ads" } },
\\ .group_sources = .{ .{ .group = "kids", .source_url = "https://lists.example/ads.txt" } },
\\ .rules = .{ .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block } },
\\ .local_records = .{ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 600 } },
\\ .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.1:53" } },
\\}
;
fn importText(io: std.Io, database: *db.Db, source: [:0]const u8, options: Options) !void {
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
return importSource(io, testing.allocator, database, source, options, &diags);
}
test "isEmpty is true on a freshly migrated database" {
var database = try openMigrated();
defer database.close();
try testing.expect(try isEmpty(&database));
}
test "isEmpty is false once a settings row exists" {
var database = try openMigrated();
defer database.close();
try database.exec("INSERT INTO settings (key, value) VALUES ('dns.port', '53');");
try testing.expect(!try isEmpty(&database));
}
test "isEmpty is false once an auto-materialized client exists" {
var database = try openMigrated();
defer database.close();
try database.exec(
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES ('192.168.1.5', NULL, 1, 0, 1, 1);
);
try testing.expect(!try isEmpty(&database));
}
test "isEmpty is false once the seeded group is changed" {
var database = try openMigrated();
defer database.close();
try database.exec("UPDATE groups SET name = 'renamed' WHERE id = 1;");
try testing.expect(!try isEmpty(&database));
}
test "importSource seeds a migrated database and group 'default' keeps id 1" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
try importText(io, &database, full_source, .{});
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT id FROM groups WHERE name = 'default'"));
try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT count(*) FROM upstreams"));
// The v6 client address was written in canonical form, not as typed.
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM clients WHERE ip = 'fd00::1'"));
}
test "applyToDb without force refuses a configured database and changes nothing" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = testing.allocator;
var database = try openMigrated();
defer database.close();
try importText(io, &database, full_source, .{});
const before = try dump(&database, gpa);
defer gpa.free(before);
const second: model.Config = .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://other.example/dns-query" }},
};
try testing.expectError(error.DatabaseNotEmpty, applyToDb(io, gpa, &database, second, 42, .{}));
const after = try dump(&database, gpa);
defer gpa.free(after);
try testing.expectEqualStrings(before, after);
}
test "applyToDb rolls back completely when an insert fails mid-way" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = testing.allocator;
var database = try openMigrated();
defer database.close();
try importText(io, &database, full_source, .{});
const before = try dump(&database, gpa);
defer gpa.free(before);
// Two identical local records violate `UNIQUE(name, rtype, value)`. The
// validator would catch this, which is exactly why the test calls
// `applyToDb` directly: the all-or-nothing guarantee has to hold on its own.
const broken: model.Config = .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://other.example/dns-query" }},
.local_records = &.{
.{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" },
.{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" },
},
};
try testing.expectError(error.Constraint, applyToDb(io, gpa, &database, broken, 42, .{ .force = true }));
const after = try dump(&database, gpa);
defer gpa.free(after);
try testing.expectEqualStrings(before, after);
}
test "importSource writes nothing when validation fails" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
// No `default` group and no enabled upstream.
const bad: [:0]const u8 =
\\.{ .groups = .{ .{ .name = "kids" } } }
;
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
try testing.expectError(
error.MissingDefaultGroup,
importSource(io, testing.allocator, &database, bad, .{}, &diags),
);
try testing.expect(diags.problems.items.len >= 2);
try testing.expect(try isEmpty(&database));
}
test "importSource reports a ZON syntax error and writes nothing" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
try testing.expectError(
error.ParseZon,
importSource(io, testing.allocator, &database, ".{ .groups = ", .{}, &diags),
);
try testing.expect(try isEmpty(&database));
// The point of the diagnostic: what the CLI prints must name the line and the
// column, not just `ParseZon`.
try testing.expect(diags.problems.items.len >= 1);
var rendered: std.Io.Writer.Allocating = .init(testing.allocator);
defer rendered.deinit();
try diags.writeAll(&rendered.writer);
const text = rendered.written();
try testing.expect(std.mem.indexOf(u8, text, "1:14: error: ") != null);
}
test "a config omitting every optional field parses into an arena and leaks nothing" {
// The S5.1 rule as a test: `std.zon.parse.free` is never called, the arena
// is the only release, and `std.testing.allocator` fails the test if a
// defaulted rodata string were ever handed to the allocator.
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const cfg = try std.zon.parse.fromSliceAlloc(
model.Config,
arena_state.allocator(),
minimal_source,
null,
.{},
);
try testing.expectEqualStrings("0.0.0.0", cfg.dns.bind_ipv4);
try testing.expectEqual(@as(u16, 53), cfg.dns.port);
try testing.expectEqual(@as(usize, 1), cfg.groups.len);
}
test "a password is hashed into web.password_hash and never stored verbatim" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = testing.allocator;
var database = try openMigrated();
defer database.close();
const cfg: model.Config = .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.web = .{ .password = "correct horse battery staple" },
};
try applyToDb(io, gpa, &database, cfg, 42, .{});
var stmt = try database.prepare("SELECT value FROM settings WHERE key = 'web.password_hash'");
defer stmt.deinit();
try testing.expect(try stmt.step());
try testing.expect(std.mem.startsWith(u8, stmt.columnText(0), "$argon2id$"));
try testing.expectEqual(
@as(i64, 0),
try database.queryInt("SELECT count(*) FROM settings WHERE key = 'web.password'"),
);
}
test "a password and a password_hash together are refused" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
const cfg: model.Config = .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.web = .{ .password = "plaintext", .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" },
};
try testing.expectError(
error.PasswordAndHashBothSet,
applyToDb(io, testing.allocator, &database, cfg, 42, .{}),
);
try testing.expect(try isEmpty(&database));
}
+745
View File
@@ -0,0 +1,745 @@
//! The one configuration model. Bootstrap, import, export, the repositories and
//! the running server all speak this struct; nothing else describes nxdns
//! configuration.
//!
//! Pure: no `std.Io` value is a parameter anywhere, no SQLite, no clock. The
//! only `std.Io` types that appear are `std.Io.Duration` as a conversion result.
//!
//! Runtime columns are deliberately absent. `clients.first_seen`,
//! `clients.last_seen`, `rules.created_at` and
//! `blocklist_sources.{last_updated, domain_count, wildcard_count,
//! skipped_regex_count, checksum}` are facts a running server produces, not
//! configuration. Including them would make two exports taken minutes apart
//! differ, which would make the byte-stable round trip untestable against a
//! live server. Import sets the timestamps to the import time and leaves the
//! counters at their column defaults.
const std = @import("std");
const Allocator = std.mem.Allocator;
pub const Config = struct {
runtime: Runtime = .{},
upstream: Upstream = .{},
dns: Dns = .{},
blocking: Blocking = .{},
cache: Cache = .{},
web: Web = .{},
doh_server: TlsEndpoint = .{},
dot_server: TlsEndpoint = .{ .port = 853 },
edns: Edns = .{},
logging: Logging = .{},
disk: Disk = .{},
blocklist_update: BlocklistUpdate = .{},
groups: []const Group = &.{},
upstreams: []const UpstreamServer = &.{},
clients: []const Client = &.{},
client_prefixes: []const ClientPrefix = &.{},
blocklist_sources: []const BlocklistSource = &.{},
group_sources: []const GroupSource = &.{},
rules: []const Rule = &.{},
local_records: []const LocalRecord = &.{},
forward_zones: []const ForwardZone = &.{},
};
pub const IoBackend = enum {
threaded,
evented,
pub fn toDb(self: IoBackend) []const u8 {
return switch (self) {
.threaded => "threaded",
.evented => "evented",
};
}
pub fn fromDb(text: []const u8) ?IoBackend {
if (std.mem.eql(u8, text, "threaded")) return .threaded;
if (std.mem.eql(u8, text, "evented")) return .evented;
return null;
}
};
pub const Runtime = struct { io_backend: IoBackend = .threaded };
pub const Upstream = struct {
connect_timeout_ms: u32 = 2000,
read_timeout_ms: u32 = 3000,
total_timeout_ms: u32 = 5000,
};
pub const Dns = struct {
bind_ipv4: []const u8 = "0.0.0.0",
bind_ipv6: []const u8 = "::",
port: u16 = 53,
rate_limit: u32 = 1000,
rate_window_seconds: u32 = 60,
};
pub const BlockResponse = enum {
zero,
nxdomain,
pub fn toDb(self: BlockResponse) []const u8 {
return switch (self) {
.zero => "zero",
.nxdomain => "nxdomain",
};
}
pub fn fromDb(text: []const u8) ?BlockResponse {
if (std.mem.eql(u8, text, "zero")) return .zero;
if (std.mem.eql(u8, text, "nxdomain")) return .nxdomain;
return null;
}
};
pub const Blocking = struct { response: BlockResponse = .zero, ttl: u32 = 5 };
pub const Cache = struct { size: u32 = 10000, negative_ttl_max: u32 = 3600 };
pub const Web = struct {
enabled: bool = true,
bind: []const u8 = "0.0.0.0",
port: u16 = 8080,
/// Operator input only. Never a settings row, always exported as "".
password: []const u8 = "",
/// argon2id PHC string; "" disables authentication.
password_hash: []const u8 = "",
session_ttl_hours: u16 = 24,
api_rate_limit_per_min: u32 = 300,
sse_max_connections_per_ip: u16 = 3,
};
pub const TlsEndpoint = struct {
enabled: bool = false,
bind: []const u8 = "0.0.0.0",
port: u16 = 443,
cert_path: []const u8 = "/etc/nxdns/cert.pem",
key_path: []const u8 = "/etc/nxdns/key.pem",
};
pub const EcsMode = enum {
strip,
forward,
pub fn toDb(self: EcsMode) []const u8 {
return switch (self) {
.strip => "strip",
.forward => "forward",
};
}
pub fn fromDb(text: []const u8) ?EcsMode {
if (std.mem.eql(u8, text, "strip")) return .strip;
if (std.mem.eql(u8, text, "forward")) return .forward;
return null;
}
};
pub const Edns = struct { ecs_mode: EcsMode = .strip };
pub const LogLevel = enum {
err,
warn,
info,
debug,
/// `.err` stores as "error": that is the operator-facing word, and the Zig
/// tag cannot be `error` because it is a keyword.
pub fn toDb(self: LogLevel) []const u8 {
return switch (self) {
.err => "error",
.warn => "warn",
.info => "info",
.debug => "debug",
};
}
pub fn fromDb(text: []const u8) ?LogLevel {
if (std.mem.eql(u8, text, "error")) return .err;
if (std.mem.eql(u8, text, "warn")) return .warn;
if (std.mem.eql(u8, text, "info")) return .info;
if (std.mem.eql(u8, text, "debug")) return .debug;
return null;
}
};
pub const LogOutput = enum {
stderr,
syslog,
file,
pub fn toDb(self: LogOutput) []const u8 {
return switch (self) {
.stderr => "stderr",
.syslog => "syslog",
.file => "file",
};
}
pub fn fromDb(text: []const u8) ?LogOutput {
if (std.mem.eql(u8, text, "stderr")) return .stderr;
if (std.mem.eql(u8, text, "syslog")) return .syslog;
if (std.mem.eql(u8, text, "file")) return .file;
return null;
}
};
pub const Logging = struct {
level: LogLevel = .info,
retention_days: u16 = 30,
query_log_buffer_max: u32 = 10000,
hide_domains: bool = false,
hide_client_ips: bool = false,
output: LogOutput = .stderr,
file_path: []const u8 = "/var/log/nxdns/nxdns.log",
max_size_mb: u32 = 50,
max_files: u8 = 5,
};
pub const Disk = struct { min_free_mb: u32 = 200, warn_free_mb: u32 = 500 };
pub const BlocklistUpdate = struct { enabled: bool = true, interval_hours: u16 = 24 };
pub const Group = struct { name: []const u8, safe_search: bool = false };
pub const UpstreamServer = struct { url: []const u8, priority: i32 = 100, enabled: bool = true };
pub const Client = struct { ip: []const u8, name: []const u8 = "", group: []const u8 = "default" };
pub const ClientPrefix = struct { prefix: []const u8, group: []const u8 = "default", priority: i32 = 100 };
pub const BlocklistSource = struct {
url: []const u8,
name: []const u8,
enabled: bool = true,
is_suggested: bool = false,
};
pub const GroupSource = struct { group: []const u8, source_url: []const u8 };
pub const RuleKind = enum {
exact,
wildcard,
pub fn toDb(self: RuleKind) []const u8 {
return switch (self) {
.exact => "exact",
.wildcard => "wildcard",
};
}
pub fn fromDb(text: []const u8) ?RuleKind {
if (std.mem.eql(u8, text, "exact")) return .exact;
if (std.mem.eql(u8, text, "wildcard")) return .wildcard;
return null;
}
};
pub const RuleAction = enum {
allow,
block,
pub fn toDb(self: RuleAction) []const u8 {
return switch (self) {
.allow => "allow",
.block => "block",
};
}
pub fn fromDb(text: []const u8) ?RuleAction {
if (std.mem.eql(u8, text, "allow")) return .allow;
if (std.mem.eql(u8, text, "block")) return .block;
return null;
}
};
pub const Rule = struct { group: []const u8, pattern: []const u8, kind: RuleKind, action: RuleAction };
/// Tag names are lowercase because ZON enum literals are; the DB text is
/// uppercase because `CHECK(rtype IN ('A','AAAA','CNAME'))` says so.
pub const RecordType = enum {
a,
aaaa,
cname,
pub fn toDb(self: RecordType) []const u8 {
return switch (self) {
.a => "A",
.aaaa => "AAAA",
.cname => "CNAME",
};
}
pub fn fromDb(text: []const u8) ?RecordType {
if (std.mem.eql(u8, text, "A")) return .a;
if (std.mem.eql(u8, text, "AAAA")) return .aaaa;
if (std.mem.eql(u8, text, "CNAME")) return .cname;
return null;
}
};
pub const LocalRecord = struct { name: []const u8, rtype: RecordType, value: []const u8, ttl: u32 = 300 };
pub const ForwardZone = struct { zone: []const u8, resolver: []const u8 };
// ---------------------------------------------------------------------------
// Unit conversions (S2.4)
// ---------------------------------------------------------------------------
/// Fails the build unless `FieldType`'s maximum times `factor` fits `Dest`.
/// Overflow is made impossible by the types rather than checked at runtime,
/// which is why none of the conversions below can return an error.
pub fn assertFits(comptime FieldType: type, comptime factor: comptime_int, comptime Dest: type) void {
if (@as(u128, std.math.maxInt(FieldType)) * factor > @as(u128, std.math.maxInt(Dest))) {
@compileError("unit conversion overflows " ++ @typeName(Dest) ++ ": " ++
@typeName(FieldType) ++ " times the conversion factor does not fit");
}
}
comptime {
assertFits(u32, std.time.ns_per_ms, i96); // timeouts
assertFits(u16, 3600, i64); // session ttl, update interval
assertFits(u16, 86400, i64); // retention
assertFits(u32, 1024 * 1024, u64); // MiB conversions
}
pub fn connectTimeout(u: Upstream) std.Io.Duration {
return .{ .nanoseconds = @as(i96, u.connect_timeout_ms) * std.time.ns_per_ms };
}
pub fn readTimeout(u: Upstream) std.Io.Duration {
return .{ .nanoseconds = @as(i96, u.read_timeout_ms) * std.time.ns_per_ms };
}
pub fn totalTimeout(u: Upstream) std.Io.Duration {
return .{ .nanoseconds = @as(i96, u.total_timeout_ms) * std.time.ns_per_ms };
}
pub fn sessionTtlSeconds(w: Web) i64 {
return @as(i64, w.session_ttl_hours) * 3600;
}
pub fn retentionSeconds(l: Logging) i64 {
return @as(i64, l.retention_days) * 86400;
}
pub fn maxLogBytes(l: Logging) u64 {
return @as(u64, l.max_size_mb) * 1024 * 1024;
}
pub fn minFreeBytes(d: Disk) u64 {
return @as(u64, d.min_free_mb) * 1024 * 1024;
}
pub fn warnFreeBytes(d: Disk) u64 {
return @as(u64, d.warn_free_mb) * 1024 * 1024;
}
pub fn updateIntervalSeconds(b: BlocklistUpdate) i64 {
return @as(i64, b.interval_hours) * 3600;
}
// ---------------------------------------------------------------------------
// settings(key, value) bridge (S2.3)
// ---------------------------------------------------------------------------
pub const SettingPair = struct { key: []const u8, value: []const u8 };
pub const SettingsError = error{ BadSettingValue, OutOfMemory };
/// The scalar sections are exactly the `Config` fields whose type is a struct;
/// the collections are slices. Deriving the list this way means a new section
/// joins the settings mapping automatically and cannot drift out of it.
fn isScalarSection(comptime T: type) bool {
return @typeInfo(T) == .@"struct";
}
/// `web.password` is operator input, never a settings row: it is hashed into
/// `web.password_hash` at import time and discarded (S2.5).
fn isSkipped(comptime section: []const u8, comptime field: []const u8) bool {
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
}
fn encodeValue(comptime T: type, value: T, gpa: Allocator) error{OutOfMemory}![]u8 {
return switch (@typeInfo(T)) {
.bool => try gpa.dupe(u8, if (value) "true" else "false"),
.int => try std.fmt.allocPrint(gpa, "{d}", .{value}),
.@"enum" => try gpa.dupe(u8, value.toDb()),
.pointer => try gpa.dupe(u8, value),
else => @compileError("unsupported setting field type " ++ @typeName(T)),
};
}
/// Decoding an integer uses the field's declared type, so a stored value out of
/// that range is `error.BadSettingValue` and never a truncating cast.
fn decodeValue(comptime T: type, text: []const u8) error{BadSettingValue}!T {
return switch (@typeInfo(T)) {
.bool => if (std.mem.eql(u8, text, "true"))
true
else if (std.mem.eql(u8, text, "false"))
false
else
error.BadSettingValue,
.int => std.fmt.parseInt(T, text, 10) catch error.BadSettingValue,
.@"enum" => T.fromDb(text) orelse error.BadSettingValue,
.pointer => text,
else => @compileError("unsupported setting field type " ++ @typeName(T)),
};
}
/// Frees the `value` of every pair. Keys are comptime strings and are never
/// freed.
pub fn freeSettings(gpa: Allocator, pairs: []const SettingPair) void {
for (pairs) |pair| gpa.free(pair.value);
}
/// Writes every scalar field of `cfg` as a key/value pair into `out`. Keys are
/// comptime strings (never freed); values are allocated from `gpa` and belong
/// to the caller, which frees them with `freeSettings`. On failure nothing this
/// call appended survives.
pub fn toSettings(cfg: Config, gpa: Allocator, out: *std.ArrayList(SettingPair)) error{OutOfMemory}!void {
const start = out.items.len;
errdefer {
freeSettings(gpa, out.items[start..]);
out.shrinkRetainingCapacity(start);
}
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) {
const section = @field(cfg, section_field.name);
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
const value = try encodeValue(field.type, @field(section, field.name), gpa);
errdefer gpa.free(value);
try out.append(gpa, .{ .key = section_field.name ++ "." ++ field.name, .value = value });
}
}
}
}
}
/// Applies `pairs` onto `cfg`, which the caller has initialized to `.{}`.
/// An absent key keeps the default — that is how a migration adds a setting
/// with no data step. An unknown key is logged at `warn` and counted in
/// `unknown_keys`; it is never an error, because downgrading a binary must not
/// brick a config database.
///
/// String values are borrowed from `pairs`, so `cfg` lives no longer than the
/// storage the pairs point into.
pub fn fromSettings(pairs: []const SettingPair, cfg: *Config, unknown_keys: *usize) SettingsError!void {
for (pairs) |pair| {
var matched = false;
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) {
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
if (std.mem.eql(u8, pair.key, section_field.name ++ "." ++ field.name)) {
@field(@field(cfg, section_field.name), field.name) =
try decodeValue(field.type, pair.value);
matched = true;
}
}
}
}
}
if (!matched) {
unknown_keys.* += 1;
std.log.warn("unknown settings key '{s}' ignored", .{pair.key});
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const testing = std.testing;
/// Every key `toSettings` produces on a default `Config`, sorted. A field added
/// without updating this list breaks the test below, which is the point.
const expected_keys = [_][]const u8{
"blocking.response",
"blocking.ttl",
"blocklist_update.enabled",
"blocklist_update.interval_hours",
"cache.negative_ttl_max",
"cache.size",
"disk.min_free_mb",
"disk.warn_free_mb",
"dns.bind_ipv4",
"dns.bind_ipv6",
"dns.port",
"dns.rate_limit",
"dns.rate_window_seconds",
"doh_server.bind",
"doh_server.cert_path",
"doh_server.enabled",
"doh_server.key_path",
"doh_server.port",
"dot_server.bind",
"dot_server.cert_path",
"dot_server.enabled",
"dot_server.key_path",
"dot_server.port",
"edns.ecs_mode",
"logging.file_path",
"logging.hide_client_ips",
"logging.hide_domains",
"logging.level",
"logging.max_files",
"logging.max_size_mb",
"logging.output",
"logging.query_log_buffer_max",
"logging.retention_days",
"runtime.io_backend",
"upstream.connect_timeout_ms",
"upstream.read_timeout_ms",
"upstream.total_timeout_ms",
"web.api_rate_limit_per_min",
"web.bind",
"web.enabled",
"web.password_hash",
"web.port",
"web.session_ttl_hours",
"web.sse_max_connections_per_ip",
};
fn lessThanKey(_: void, a: SettingPair, b: SettingPair) bool {
return std.mem.lessThan(u8, a.key, b.key);
}
test "toSettings on a default config produces exactly the expected key list" {
const gpa = testing.allocator;
var pairs: std.ArrayList(SettingPair) = .empty;
defer {
freeSettings(gpa, pairs.items);
pairs.deinit(gpa);
}
try toSettings(.{}, gpa, &pairs);
std.mem.sort(SettingPair, pairs.items, {}, lessThanKey);
try testing.expectEqual(expected_keys.len, pairs.items.len);
for (expected_keys, pairs.items) |expected, pair| {
try testing.expectEqualStrings(expected, pair.key);
}
}
test "toSettings never emits web.password" {
const gpa = testing.allocator;
var pairs: std.ArrayList(SettingPair) = .empty;
defer {
freeSettings(gpa, pairs.items);
pairs.deinit(gpa);
}
try toSettings(.{ .web = .{ .password = "hunter2" } }, gpa, &pairs);
for (pairs.items) |pair| {
try testing.expect(!std.mem.eql(u8, pair.key, "web.password"));
}
}
test "toSettings and fromSettings round-trip a non-default config" {
const gpa = testing.allocator;
const original: Config = .{
.runtime = .{ .io_backend = .evented },
.upstream = .{ .connect_timeout_ms = 111, .read_timeout_ms = 222, .total_timeout_ms = 333 },
.dns = .{
.bind_ipv4 = "127.0.0.1",
.bind_ipv6 = "::1",
.port = 5353,
.rate_limit = 7,
.rate_window_seconds = 11,
},
.blocking = .{ .response = .nxdomain, .ttl = 13 },
.cache = .{ .size = 17, .negative_ttl_max = 19 },
.web = .{
.enabled = false,
.bind = "10.0.0.1",
.port = 9090,
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def",
.session_ttl_hours = 23,
.api_rate_limit_per_min = 29,
.sse_max_connections_per_ip = 31,
},
.doh_server = .{
.enabled = true,
.bind = "10.0.0.2",
.port = 4443,
.cert_path = "/a/cert.pem",
.key_path = "/a/key.pem",
},
.dot_server = .{
.enabled = true,
.bind = "10.0.0.3",
.port = 8853,
.cert_path = "/b/cert.pem",
.key_path = "/b/key.pem",
},
.edns = .{ .ecs_mode = .forward },
.logging = .{
.level = .err,
.retention_days = 41,
.query_log_buffer_max = 43,
.hide_domains = true,
.hide_client_ips = true,
.output = .file,
.file_path = "/var/log/x.log",
.max_size_mb = 47,
.max_files = 53,
},
.disk = .{ .min_free_mb = 59, .warn_free_mb = 61 },
.blocklist_update = .{ .enabled = false, .interval_hours = 67 },
};
var pairs: std.ArrayList(SettingPair) = .empty;
defer {
freeSettings(gpa, pairs.items);
pairs.deinit(gpa);
}
try toSettings(original, gpa, &pairs);
var restored: Config = .{};
var unknown: usize = 0;
try fromSettings(pairs.items, &restored, &unknown);
try testing.expectEqual(@as(usize, 0), unknown);
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) {
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
const a = @field(@field(original, section_field.name), field.name);
const b = @field(@field(restored, section_field.name), field.name);
if (comptime @typeInfo(field.type) == .pointer) {
try testing.expectEqualStrings(a, b);
} else {
try testing.expectEqual(a, b);
}
}
}
}
}
}
test "an unknown settings key is counted and not an error" {
var cfg: Config = .{};
var unknown: usize = 0;
const pairs = [_]SettingPair{
.{ .key = "dns.port", .value = "5300" },
.{ .key = "future.setting", .value = "whatever" },
.{ .key = "web.password", .value = "never a row" },
};
try fromSettings(&pairs, &cfg, &unknown);
try testing.expectEqual(@as(u16, 5300), cfg.dns.port);
// `web.password` is skipped in both directions, so it counts as unknown.
try testing.expectEqual(@as(usize, 2), unknown);
}
test "a malformed settings value is BadSettingValue" {
var cfg: Config = .{};
var unknown: usize = 0;
const bad_int = [_]SettingPair{.{ .key = "dns.port", .value = "not a number" }};
try testing.expectError(error.BadSettingValue, fromSettings(&bad_int, &cfg, &unknown));
// 70000 does not fit u16: an out-of-range value is refused, not truncated.
const out_of_range = [_]SettingPair{.{ .key = "dns.port", .value = "70000" }};
try testing.expectError(error.BadSettingValue, fromSettings(&out_of_range, &cfg, &unknown));
const bad_bool = [_]SettingPair{.{ .key = "web.enabled", .value = "yes" }};
try testing.expectError(error.BadSettingValue, fromSettings(&bad_bool, &cfg, &unknown));
const bad_enum = [_]SettingPair{.{ .key = "logging.level", .value = "verbose" }};
try testing.expectError(error.BadSettingValue, fromSettings(&bad_enum, &cfg, &unknown));
}
test "an absent key keeps the default" {
var cfg: Config = .{};
var unknown: usize = 0;
const pairs = [_]SettingPair{.{ .key = "dns.port", .value = "5300" }};
try fromSettings(&pairs, &cfg, &unknown);
try testing.expectEqual(@as(u32, 1000), cfg.dns.rate_limit);
try testing.expectEqual(LogLevel.info, cfg.logging.level);
}
test "LogLevel.err encodes as error and decodes back" {
try testing.expectEqualStrings("error", LogLevel.err.toDb());
try testing.expectEqual(LogLevel.err, LogLevel.fromDb("error").?);
try testing.expect(LogLevel.fromDb("err") == null);
}
fn expectEnumRoundTrip(comptime E: type) !void {
inline for (@typeInfo(E).@"enum".fields) |field| {
const value: E = @enumFromInt(field.value);
try testing.expectEqual(value, E.fromDb(value.toDb()).?);
}
try testing.expect(E.fromDb("nonsense") == null);
try testing.expect(E.fromDb("") == null);
}
test "every toDb and fromDb enum pair round-trips over all tags" {
try expectEnumRoundTrip(IoBackend);
try expectEnumRoundTrip(BlockResponse);
try expectEnumRoundTrip(EcsMode);
try expectEnumRoundTrip(LogLevel);
try expectEnumRoundTrip(LogOutput);
try expectEnumRoundTrip(RuleKind);
try expectEnumRoundTrip(RuleAction);
try expectEnumRoundTrip(RecordType);
}
test "RecordType stores the uppercase DDL spelling" {
try testing.expectEqualStrings("A", RecordType.a.toDb());
try testing.expectEqualStrings("AAAA", RecordType.aaaa.toDb());
try testing.expectEqualStrings("CNAME", RecordType.cname.toDb());
try testing.expect(RecordType.fromDb("a") == null);
}
test "unit conversions" {
try testing.expectEqual(
@as(i96, 2000) * std.time.ns_per_ms,
connectTimeout(.{}).nanoseconds,
);
try testing.expectEqual(
@as(i96, 3000) * std.time.ns_per_ms,
readTimeout(.{}).nanoseconds,
);
try testing.expectEqual(
@as(i96, 5000) * std.time.ns_per_ms,
totalTimeout(.{}).nanoseconds,
);
try testing.expectEqual(@as(i64, 24 * 3600), sessionTtlSeconds(.{}));
try testing.expectEqual(@as(i64, 30 * 86400), retentionSeconds(.{}));
try testing.expectEqual(@as(u64, 50 * 1024 * 1024), maxLogBytes(.{}));
try testing.expectEqual(@as(u64, 200 * 1024 * 1024), minFreeBytes(.{}));
try testing.expectEqual(@as(u64, 500 * 1024 * 1024), warnFreeBytes(.{}));
try testing.expectEqual(@as(i64, 24 * 3600), updateIntervalSeconds(.{}));
}
test "unit conversions at the field maximum do not overflow" {
const max_upstream: Upstream = .{
.connect_timeout_ms = std.math.maxInt(u32),
.read_timeout_ms = std.math.maxInt(u32),
.total_timeout_ms = std.math.maxInt(u32),
};
try testing.expectEqual(
@as(i96, std.math.maxInt(u32)) * std.time.ns_per_ms,
connectTimeout(max_upstream).nanoseconds,
);
try testing.expectEqual(
@as(i64, std.math.maxInt(u16)) * 3600,
sessionTtlSeconds(.{ .session_ttl_hours = std.math.maxInt(u16) }),
);
try testing.expectEqual(
@as(i64, std.math.maxInt(u16)) * 86400,
retentionSeconds(.{ .retention_days = std.math.maxInt(u16) }),
);
try testing.expectEqual(
@as(u64, std.math.maxInt(u32)) * 1024 * 1024,
maxLogBytes(.{ .max_size_mb = std.math.maxInt(u32) }),
);
}
File diff suppressed because it is too large Load Diff
+41 -47
View File
@@ -1,57 +1,51 @@
//! The process shell: build the writers, collect `argv`, dispatch, return an
//! exit code. Every command body lives in `cli.zig`, which takes its writers as
//! parameters and is therefore testable without a process.
const std = @import("std");
const version = @import("version.zig");
const cli = @import("cli.zig");
const usage =
\\usage: nxdns <command>
\\
\\commands:
\\ run serve DNS
\\ check validate the configuration
\\ export write local records to stdout
\\ import read local records from stdin
\\ version print version information
\\
;
const exit_ok = 0;
const exit_not_implemented = 2;
const exit_usage = 64;
// `src/tests.zig` imports this file, and this is how `cli.zig`'s tests reach
// the same runner. `src/tests.zig` is the orchestrator's file, not this
// session's.
comptime {
_ = @import("cli.zig");
}
pub fn main(init: std.process.Init) u8 {
// Both `File.Writer` values are self-referential and must not move, so they
// stay in these `var` slots for the whole of `main`.
var out_buffer: [4096]u8 = undefined;
var err_buffer: [4096]u8 = undefined;
var out = std.Io.File.stdout().writer(init.io, &out_buffer);
var err = std.Io.File.stderr().writer(init.io, &err_buffer);
const runner: cli.Runner = .{
.io = init.io,
.gpa = init.gpa,
.out = &out.interface,
.err = &err.interface,
};
var argv: std.ArrayList([]const u8) = .empty;
defer argv.deinit(init.gpa);
var args = init.minimal.args.iterate();
_ = args.skip();
const command = args.next() orelse return fail(init.io, usage);
if (std.mem.eql(u8, command, "version")) {
return print(init.io, "nxdns {s} ({s})\nzig {s}\n", .{
version.string,
version.git_commit,
version.zig_version_string,
});
while (args.next()) |arg| {
argv.append(init.gpa, arg) catch return cli.exit_runtime;
}
for ([_][]const u8{ "run", "check", "export", "import" }) |known| {
if (std.mem.eql(u8, command, known)) {
_ = print(init.io, "not implemented\n", .{});
return exit_not_implemented;
}
}
const command = cli.parseArgs(argv.items) catch |e| return cli.runUsageError(runner, e);
return fail(init.io, usage);
}
fn print(io: std.Io, comptime format: []const u8, arguments: anytype) u8 {
var buffer: [512]u8 = undefined;
var file_writer = std.Io.File.stdout().writer(io, &buffer);
file_writer.interface.print(format, arguments) catch return 1;
file_writer.interface.flush() catch return 1;
return exit_ok;
}
fn fail(io: std.Io, message: []const u8) u8 {
var buffer: [512]u8 = undefined;
var file_writer = std.Io.File.stderr().writer(io, &buffer);
file_writer.interface.writeAll(message) catch {};
file_writer.interface.flush() catch {};
return exit_usage;
return switch (command) {
.run => |paths| cli.runRun(runner, paths),
// `true`: the probe leaves the machine, which is right for an operator
// running `nxdns check` and wrong for a test.
.check => |args_| cli.runCheck(runner, args_, true),
.export_ => |args_| cli.runExport(runner, args_),
.import_ => |args_| cli.runImport(runner, args_),
.version => cli.runVersion(runner),
.help => cli.runHelp(runner),
};
}
+147
View File
@@ -0,0 +1,147 @@
//! The `config.db` schema, verbatim from PLAN §11.2, plus the two table orders
//! every other storage session needs.
//!
//! The DDL text is data, not code: `migrations.zig` carries it as step 1 and
//! never edits it in place. A schema change is a *new* step with new DDL, so
//! this string stays byte-identical to PLAN §11.2 forever.
const std = @import("std");
/// Migration step 1. Multi-statement text — it goes through `db.Db.exec`,
/// never through `prepare`.
pub const ddl_v1: [:0]const u8 =
\\CREATE TABLE schema_version (version INTEGER NOT NULL);
\\
\\CREATE TABLE groups (
\\ id INTEGER PRIMARY KEY,
\\ name TEXT NOT NULL UNIQUE,
\\ safe_search INTEGER NOT NULL DEFAULT 0
\\);
\\INSERT OR IGNORE INTO groups (id, name) VALUES (1, 'default');
\\
\\CREATE TABLE clients (
\\ id INTEGER PRIMARY KEY,
\\ ip TEXT NOT NULL UNIQUE, -- canonical text form (v4 dotted / v6 RFC 5952)
\\ name TEXT,
\\ group_id INTEGER NOT NULL REFERENCES groups(id),
\\ hand_edited INTEGER NOT NULL DEFAULT 0,
\\ first_seen INTEGER NOT NULL,
\\ last_seen INTEGER NOT NULL
\\);
\\
\\CREATE TABLE client_prefixes (
\\ id INTEGER PRIMARY KEY,
\\ prefix TEXT NOT NULL UNIQUE, -- "192.168.1.0/24", "fd00:abcd::/48"
\\ group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
\\ priority INTEGER NOT NULL DEFAULT 100
\\);
\\
\\CREATE TABLE upstreams (
\\ id INTEGER PRIMARY KEY,
\\ url TEXT NOT NULL UNIQUE,
\\ priority INTEGER NOT NULL DEFAULT 100,
\\ enabled INTEGER NOT NULL DEFAULT 1
\\);
\\
\\CREATE TABLE blocklist_sources (
\\ id INTEGER PRIMARY KEY,
\\ url TEXT NOT NULL UNIQUE,
\\ name TEXT NOT NULL,
\\ enabled INTEGER NOT NULL DEFAULT 1,
\\ is_suggested INTEGER NOT NULL DEFAULT 0,
\\ last_updated INTEGER,
\\ domain_count INTEGER NOT NULL DEFAULT 0,
\\ wildcard_count INTEGER NOT NULL DEFAULT 0,
\\ skipped_regex_count INTEGER NOT NULL DEFAULT 0,
\\ checksum TEXT
\\);
\\
\\CREATE TABLE group_sources (
\\ group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
\\ source_id INTEGER NOT NULL REFERENCES blocklist_sources(id) ON DELETE CASCADE,
\\ PRIMARY KEY (group_id, source_id)
\\);
\\
\\CREATE TABLE rules (
\\ id INTEGER PRIMARY KEY,
\\ group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
\\ pattern TEXT NOT NULL,
\\ kind TEXT NOT NULL CHECK(kind IN ('exact','wildcard')),
\\ action TEXT NOT NULL CHECK(action IN ('allow','block')),
\\ created_at INTEGER NOT NULL
\\);
\\
\\CREATE TABLE local_records (
\\ id INTEGER PRIMARY KEY,
\\ name TEXT NOT NULL,
\\ rtype TEXT NOT NULL CHECK(rtype IN ('A','AAAA','CNAME')),
\\ value TEXT NOT NULL,
\\ ttl INTEGER NOT NULL DEFAULT 300,
\\ UNIQUE(name, rtype, value)
\\);
\\
\\CREATE TABLE forward_zones (
\\ id INTEGER PRIMARY KEY,
\\ zone TEXT NOT NULL UNIQUE,
\\ resolver TEXT NOT NULL -- "udp://192.168.1.1:53"
\\);
\\
\\CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
;
/// Child-before-parent. Used by import's wipe step; correct under
/// `foreign_keys = ON`.
///
/// `upstreams`, `local_records`, `forward_zones` and `settings` have no foreign
/// keys, so their position is free; `groups` and `blocklist_sources` must come
/// last, after every referrer. `schema_version` is deliberately absent — an
/// import must never erase the stamped migration version.
pub const delete_order = [_][]const u8{
"group_sources", "rules", "client_prefixes", "clients",
"upstreams", "local_records", "forward_zones", "settings",
"blocklist_sources", "groups",
};
/// Every table whose emptiness defines "the database has never been configured"
/// (S5.2). `groups` is absent because migration step 1 seeds `(1, 'default')`,
/// so an empty database still holds one group row; `schema_version` is absent
/// for the same reason.
pub const content_tables = [_][]const u8{
"clients", "client_prefixes", "upstreams", "blocklist_sources",
"group_sources", "rules", "local_records", "forward_zones",
"settings",
};
const testing = std.testing;
test "delete_order lists every referrer before the table it references" {
// The two parents in the schema. Every child that references them must be
// deleted first, or `foreign_keys = ON` turns import's wipe into a
// Constraint error inside the transaction.
const referrers_of_groups = [_][]const u8{ "clients", "client_prefixes", "group_sources", "rules" };
const referrers_of_sources = [_][]const u8{"group_sources"};
try testing.expect(indexOf(&delete_order, "groups") != null);
for (referrers_of_groups) |child| {
try testing.expect(indexOf(&delete_order, child).? < indexOf(&delete_order, "groups").?);
}
for (referrers_of_sources) |child| {
try testing.expect(indexOf(&delete_order, child).? < indexOf(&delete_order, "blocklist_sources").?);
}
}
test "content_tables is delete_order without groups" {
try testing.expectEqual(delete_order.len - 1, content_tables.len);
for (content_tables) |name| {
try testing.expect(indexOf(&delete_order, name) != null);
}
try testing.expect(indexOf(&content_tables, "groups") == null);
try testing.expect(indexOf(&content_tables, "schema_version") == null);
}
fn indexOf(haystack: []const []const u8, needle: []const u8) ?usize {
for (haystack, 0..) |item, i| {
if (std.mem.eql(u8, item, needle)) return i;
}
return null;
}
+748
View File
@@ -0,0 +1,748 @@
//! The whole SQLite surface nxdns owns (PLAN Decision G). Nothing above this
//! file calls SQLite directly.
//!
//! **This file takes no `std.Io`.** It is the one deliberate exception to
//! Decision E. SQLite performs its own file I/O through its VFS; routing it
//! through `std.Io` would mean writing a custom SQLite VFS — a large,
//! security-sensitive component bought for nothing at household scale. Every
//! other storage file that touches the filesystem takes `io: std.Io`.
//!
//! The C API is declared by hand below. No `@cImport` — the handles stay
//! opaque, matching `src/platform/tls_server.zig`'s Mbed TLS approach.
const std = @import("std");
const assert = std.debug.assert;
const log = std.log.scoped(.db);
pub const c = struct {
pub const Sqlite3 = opaque {};
pub const Stmt = opaque {};
/// The C prototype is a function pointer, but the only value nxdns passes
/// is the `SQLITE_TRANSIENT` sentinel (-1), which is not a valid function
/// address — a Zig fn-pointer type would reject it on targets with aligned
/// function pointers (aarch64). `?*anyopaque` is ABI-identical.
pub const Destructor = ?*anyopaque;
/// `SQLITE_TRANSIENT`: tells SQLite to copy the bound bytes immediately.
pub const transient: Destructor = @ptrFromInt(@as(usize, @bitCast(@as(isize, -1))));
pub extern fn sqlite3_open_v2(filename: [*:0]const u8, ppDb: *?*Sqlite3, flags: c_int, zVfs: ?[*:0]const u8) c_int;
pub extern fn sqlite3_close_v2(db: ?*Sqlite3) c_int;
pub extern fn sqlite3_extended_result_codes(db: *Sqlite3, onoff: c_int) c_int;
pub extern fn sqlite3_busy_timeout(db: *Sqlite3, ms: c_int) c_int;
pub extern fn sqlite3_exec(db: *Sqlite3, sql: [*:0]const u8, cb: ?*const anyopaque, arg: ?*anyopaque, errmsg: ?*?[*:0]u8) c_int;
pub extern fn sqlite3_errmsg(db: *Sqlite3) [*:0]const u8;
pub extern fn sqlite3_errcode(db: *Sqlite3) c_int;
pub extern fn sqlite3_extended_errcode(db: *Sqlite3) c_int;
pub extern fn sqlite3_errstr(code: c_int) [*:0]const u8;
pub extern fn sqlite3_prepare_v2(db: *Sqlite3, sql: [*]const u8, n_byte: c_int, ppStmt: *?*c.Stmt, pzTail: ?*?[*]const u8) c_int;
pub extern fn sqlite3_step(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_reset(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_clear_bindings(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_finalize(stmt: ?*c.Stmt) c_int;
pub extern fn sqlite3_bind_int64(stmt: *c.Stmt, idx: c_int, value: i64) c_int;
pub extern fn sqlite3_bind_text(stmt: *c.Stmt, idx: c_int, text: [*]const u8, n: c_int, d: Destructor) c_int;
pub extern fn sqlite3_bind_null(stmt: *c.Stmt, idx: c_int) c_int;
pub extern fn sqlite3_bind_parameter_count(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_column_count(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_column_type(stmt: *c.Stmt, col: c_int) c_int;
pub extern fn sqlite3_column_int64(stmt: *c.Stmt, col: c_int) i64;
pub extern fn sqlite3_column_text(stmt: *c.Stmt, col: c_int) ?[*]const u8;
pub extern fn sqlite3_column_bytes(stmt: *c.Stmt, col: c_int) c_int;
pub extern fn sqlite3_last_insert_rowid(db: *Sqlite3) i64;
pub extern fn sqlite3_changes(db: *Sqlite3) c_int;
};
/// Result codes, from the vendored `sqlite3.h` (3.53.4).
pub const result = struct {
pub const ok: c_int = 0;
pub const err: c_int = 1;
pub const internal: c_int = 2;
pub const perm: c_int = 3;
pub const abort: c_int = 4;
pub const busy: c_int = 5;
pub const locked: c_int = 6;
pub const nomem: c_int = 7;
pub const readonly: c_int = 8;
pub const interrupt: c_int = 9;
pub const ioerr: c_int = 10;
pub const corrupt: c_int = 11;
pub const notfound: c_int = 12;
pub const full: c_int = 13;
pub const cantopen: c_int = 14;
pub const protocol: c_int = 15;
pub const empty: c_int = 16;
pub const schema: c_int = 17;
pub const toobig: c_int = 18;
pub const constraint: c_int = 19;
pub const mismatch: c_int = 20;
pub const misuse: c_int = 21;
pub const nolfs: c_int = 22;
pub const auth: c_int = 23;
pub const format: c_int = 24;
pub const range: c_int = 25;
pub const notadb: c_int = 26;
pub const row: c_int = 100;
pub const done: c_int = 101;
};
/// Open flags, from the vendored `sqlite3.h` (3.53.4).
pub const open_flag = struct {
pub const readonly: c_int = 0x1;
pub const readwrite: c_int = 0x2;
pub const create: c_int = 0x4;
pub const uri: c_int = 0x40;
pub const nomutex: c_int = 0x8000;
pub const fullmutex: c_int = 0x10000;
pub const exrescode: c_int = 0x2000000;
};
/// Column type codes returned by `sqlite3_column_type`.
pub const column_type = struct {
pub const integer: c_int = 1;
pub const float: c_int = 2;
pub const text: c_int = 3;
pub const blob: c_int = 4;
pub const null_value: c_int = 5;
};
pub const Error = error{
Abort,
Auth,
Busy,
CantOpen,
Constraint,
Corrupt,
Empty,
Format,
Full,
Internal,
Interrupt,
IoErr,
Locked,
Mismatch,
Misuse,
NoLfs,
NotADb,
NotFound,
Perm,
Protocol,
Range,
ReadOnly,
Schema,
TooBig,
SqliteError,
OutOfMemory,
Unexpected,
};
/// Maps a primary SQLite result code to `Error`. `SQLITE_NOMEM` becomes
/// `error.OutOfMemory` so it joins `transport.LocalResource` semantics: out of
/// memory is never the data's fault.
///
/// The switch runs on the primary code (`code & 0xff`), so every extended code
/// (`SQLITE_IOERR_*`, `SQLITE_CONSTRAINT_*`, `SQLITE_BUSY_SNAPSHOT`, …) lands on
/// its family. The extended code stays visible to humans through `Db.lastError`.
///
/// `SQLITE_ERROR` — the generic "SQL error" — maps to `error.Unexpected`, not to
/// `error.SqliteError`. `SqliteError` is reserved for a primary code this
/// function does not know, so an unmapped future code stays distinguishable
/// from an ordinary SQL error.
pub fn mapCode(code: c_int) Error {
const primary = code & 0xff;
assert(primary != result.ok);
assert(primary != result.row);
assert(primary != result.done);
return switch (primary) {
result.err => error.Unexpected,
result.internal => error.Internal,
result.perm => error.Perm,
result.abort => error.Abort,
result.busy => error.Busy,
result.locked => error.Locked,
result.nomem => error.OutOfMemory,
result.readonly => error.ReadOnly,
result.interrupt => error.Interrupt,
result.ioerr => error.IoErr,
result.corrupt => error.Corrupt,
result.notfound => error.NotFound,
result.full => error.Full,
result.cantopen => error.CantOpen,
result.protocol => error.Protocol,
result.empty => error.Empty,
result.schema => error.Schema,
result.toobig => error.TooBig,
result.constraint => error.Constraint,
result.mismatch => error.Mismatch,
result.misuse => error.Misuse,
result.nolfs => error.NoLfs,
result.auth => error.Auth,
result.format => error.Format,
result.range => error.Range,
result.notadb => error.NotADb,
else => error.SqliteError,
};
}
fn check(code: c_int) Error!void {
if (code == result.ok) return;
return mapCode(code);
}
pub const OpenMode = enum { read_write_create, read_write_existing, read_only, memory };
pub const OpenOptions = struct {
mode: OpenMode = .read_write_create,
busy_timeout_ms: c_int = 5000,
};
/// One SQLite connection.
///
/// A `Db` must not move once a `Stmt` prepared from it is alive: every `Stmt`
/// holds a `*Db`.
pub const Db = struct {
handle: *c.Sqlite3,
/// Every mode carries `FULLMUTEX` (serialized mode). Phase 6's query logger
/// and Phase 8's API handlers share one handle across `std.Io` tasks, and a
/// per-handle mutex inside SQLite is cheaper to be correct about than a
/// hand-rolled one; `config.db` write volume is negligible. `EXRESCODE`
/// makes `sqlite3_extended_errcode` meaningful from the first call.
///
/// `open` deliberately applies no pragmas — see `applyPragmas`, which the
/// migration runner must call before it opens a transaction.
///
/// For `.memory`, `path` is ignored and `":memory:"` is used.
pub fn open(path: [:0]const u8, options: OpenOptions) Error!Db {
const base = open_flag.exrescode | open_flag.fullmutex;
const flags: c_int = switch (options.mode) {
.read_write_create, .memory => base | open_flag.readwrite | open_flag.create,
.read_write_existing => base | open_flag.readwrite,
.read_only => base | open_flag.readonly,
};
const filename: [:0]const u8 = switch (options.mode) {
.memory => ":memory:",
else => path,
};
var handle: ?*c.Sqlite3 = null;
const rc = c.sqlite3_open_v2(filename.ptr, &handle, flags, null);
if (rc != result.ok) {
// sqlite3_open_v2 allocates a handle even on failure. Read the
// message from it, then close it; dropping it leaks on every
// failed open.
// Logged at `warn`, not `err`: the failure itself reaches the
// caller as a typed error, and this line only carries the message
// that would otherwise die with the handle.
if (handle) |h| {
log.warn("sqlite3_open_v2 failed for '{s}': {s} (code {d}/{d})", .{
filename,
std.mem.span(c.sqlite3_errmsg(h)),
rc & 0xff,
c.sqlite3_extended_errcode(h),
});
_ = c.sqlite3_close_v2(h);
} else {
log.warn("sqlite3_open_v2 failed for '{s}': {s} (code {d})", .{
filename,
std.mem.span(c.sqlite3_errstr(rc)),
rc,
});
}
return mapCode(rc);
}
const h = handle orelse return error.SqliteError;
// A silently ignored busy timeout is how a contended WAL database turns
// into random SQLITE_BUSY failures under load.
check(c.sqlite3_busy_timeout(h, options.busy_timeout_ms)) catch |e| {
_ = c.sqlite3_close_v2(h);
return e;
};
return .{ .handle = h };
}
pub fn close(self: *Db) void {
const rc = c.sqlite3_close_v2(self.handle);
if (rc != result.ok) {
log.err("sqlite3_close_v2 returned {s} (code {d})", .{
std.mem.span(c.sqlite3_errstr(rc)),
rc,
});
}
}
/// Borrowed; valid until the next SQLite call on this handle. Formats as
/// "<message> (code <primary>/<extended>)".
pub fn lastError(self: *Db, buf: []u8) []const u8 {
const extended = c.sqlite3_extended_errcode(self.handle);
const message = std.mem.span(c.sqlite3_errmsg(self.handle));
return std.fmt.bufPrint(buf, "{s} (code {d}/{d})", .{
message,
extended & 0xff,
extended,
}) catch "sqlite error (message did not fit the buffer)";
}
/// For DDL and multi-statement scripts. `errmsg` is passed as null and the
/// message is read back through `sqlite3_errmsg`, so there is no
/// `sqlite3_free` obligation.
pub fn exec(self: *Db, sql: [:0]const u8) Error!void {
return check(c.sqlite3_exec(self.handle, sql.ptr, null, null, null));
}
/// `sql` must hold exactly one statement; text with a second statement in it
/// is `error.Misuse` and belongs in `exec`.
pub fn prepare(self: *Db, sql: []const u8) Error!Stmt {
if (sql.len > std.math.maxInt(c_int)) return error.TooBig;
var handle: ?*c.Stmt = null;
var tail: ?[*]const u8 = null;
try check(c.sqlite3_prepare_v2(self.handle, sql.ptr, @intCast(sql.len), &handle, &tail));
const h = handle orelse return error.Misuse;
const tail_ptr = tail orelse sql.ptr + sql.len;
const consumed = @intFromPtr(tail_ptr) - @intFromPtr(sql.ptr);
const remaining = std.mem.trim(u8, sql[consumed..], " \t\r\n");
if (remaining.len != 0) {
_ = c.sqlite3_finalize(h);
return error.Misuse;
}
return .{ .handle = h, .db = self };
}
/// Runs `sql` (which must yield exactly one row with one integer column) and
/// returns it. A statement that produces no row is `error.SqliteError`.
///
/// The row shape is verified, not assumed: a result with a column count
/// other than 1, a first column that is not `SQLITE_INTEGER` (NULL, text,
/// float and blob all count), or a second row is `error.Misuse`. That is the
/// same member `prepare` returns for a caller that hands it the wrong SQL,
/// because these are the same class of fault — a caller bug or schema drift,
/// never a runtime condition. Without the checks a `SELECT` of the wrong
/// column silently returns 0.
pub fn queryInt(self: *Db, sql: []const u8) Error!i64 {
var stmt = try self.prepare(sql);
defer stmt.deinit();
if (!try stmt.step()) {
log.warn("queryInt produced no row for '{s}'", .{sql});
return error.SqliteError;
}
const columns = c.sqlite3_column_count(stmt.handle);
if (columns != 1) {
log.warn("queryInt expects 1 column, got {d}, for '{s}'", .{ columns, sql });
return error.Misuse;
}
const kind = c.sqlite3_column_type(stmt.handle, 0);
if (kind != column_type.integer) {
log.warn("queryInt expects an integer column, got type {d}, for '{s}'", .{ kind, sql });
return error.Misuse;
}
const value = stmt.columnInt(0);
if (try stmt.step()) {
log.warn("queryInt expects 1 row, got more, for '{s}'", .{sql});
return error.Misuse;
}
return value;
}
pub fn lastInsertRowid(self: *Db) i64 {
return c.sqlite3_last_insert_rowid(self.handle);
}
pub fn changes(self: *Db) i64 {
return c.sqlite3_changes(self.handle);
}
};
/// One prepared statement.
///
/// There is deliberately **no prepared-statement cache in this milestone**.
/// `config.db` is written a handful of times per process lifetime, so a cache is
/// unmeasured complexity here. Phase 6's query-log flush loop is the only hot
/// path and it owns its own long-lived statements. This is a decision, not an
/// oversight against PLAN §3.4.
pub const Stmt = struct {
handle: *c.Stmt,
db: *Db,
/// The code of the last failed `step`, or `SQLITE_OK`. `sqlite3_reset` and
/// `sqlite3_finalize` both re-report that code; without this the caller
/// would see one failure logged as a second, unrelated one.
pending_error: c_int = result.ok,
pub fn deinit(self: *Stmt) void {
const rc = c.sqlite3_finalize(self.handle);
if (rc != result.ok and rc != self.pending_error) {
log.err("sqlite3_finalize returned {s} (code {d})", .{
std.mem.span(c.sqlite3_errstr(rc)),
rc,
});
}
}
pub fn reset(self: *Stmt) Error!void {
const rc = c.sqlite3_reset(self.handle);
self.pending_error = result.ok;
try check(rc);
try check(c.sqlite3_clear_bindings(self.handle));
}
/// 1-based, matching SQLite.
pub fn bindInt(self: *Stmt, idx: c_int, value: i64) Error!void {
return check(c.sqlite3_bind_int64(self.handle, idx, value));
}
pub fn bindBool(self: *Stmt, idx: c_int, value: bool) Error!void {
return self.bindInt(idx, if (value) 1 else 0);
}
/// Binds with `SQLITE_TRANSIENT`, so SQLite copies the bytes and the caller
/// never has to keep `value` alive. The copy costs an allocation per bind;
/// at config.db volumes that is invisible, and it removes a whole class of
/// use-after-free from every caller.
pub fn bindText(self: *Stmt, idx: c_int, value: []const u8) Error!void {
if (value.len > std.math.maxInt(c_int)) return error.TooBig;
return check(c.sqlite3_bind_text(self.handle, idx, value.ptr, @intCast(value.len), c.transient));
}
pub fn bindTextOrNull(self: *Stmt, idx: c_int, value: ?[]const u8) Error!void {
if (value) |v| return self.bindText(idx, v);
return self.bindNull(idx);
}
pub fn bindNull(self: *Stmt, idx: c_int) Error!void {
return check(c.sqlite3_bind_null(self.handle, idx));
}
/// true = a row is available, false = the statement finished.
pub fn step(self: *Stmt) Error!bool {
const rc = c.sqlite3_step(self.handle);
if (rc == result.row) return true;
if (rc == result.done) return false;
self.pending_error = rc;
return mapCode(rc);
}
/// Runs to completion; asserts no rows were produced.
pub fn exec(self: *Stmt) Error!void {
const has_row = try self.step();
assert(!has_row);
}
pub fn columnInt(self: *Stmt, col: c_int) i64 {
return c.sqlite3_column_int64(self.handle, col);
}
pub fn columnBool(self: *Stmt, col: c_int) bool {
return self.columnInt(col) != 0;
}
pub fn isNull(self: *Stmt, col: c_int) bool {
return c.sqlite3_column_type(self.handle, col) == column_type.null_value;
}
/// Borrowed: valid only until the next `step`, `reset` or `deinit` on this
/// statement. Every caller that keeps the value must copy it.
///
/// A NULL column reads as `""`. A `NOT NULL` column makes that unreachable
/// in practice, but it must not be undefined behaviour.
pub fn columnText(self: *Stmt, col: c_int) []const u8 {
const ptr = c.sqlite3_column_text(self.handle, col) orelse return "";
const len = c.sqlite3_column_bytes(self.handle, col);
if (len <= 0) return "";
return ptr[0..@intCast(len)];
}
/// Borrowed under the same rules as `columnText`; NULL reads as `null`.
pub fn columnTextOrNull(self: *Stmt, col: c_int) ?[]const u8 {
if (self.isNull(col)) return null;
return self.columnText(col);
}
/// Copies into `gpa`. Caller owns the result.
pub fn columnTextAlloc(self: *Stmt, gpa: std.mem.Allocator, col: c_int) error{OutOfMemory}![]u8 {
return gpa.dupe(u8, self.columnText(col));
}
/// Copies into `gpa`. Caller owns the result. NULL reads as `null`.
pub fn columnTextAllocOrNull(self: *Stmt, gpa: std.mem.Allocator, col: c_int) error{OutOfMemory}!?[]u8 {
const value = self.columnTextOrNull(col) orelse return null;
return try gpa.dupe(u8, value);
}
};
pub const Pragmas = struct {
journal_wal: bool = true,
synchronous_normal: bool = true,
foreign_keys: bool = true,
};
/// MUST be called before any transaction is opened: `PRAGMA foreign_keys` is a
/// no-op inside a transaction, so applying it later silently leaves referential
/// integrity off.
pub fn applyPragmas(self: *Db, p: Pragmas) Error!void {
if (p.journal_wal) {
// The pragma returns a row holding the mode it actually reached. `exec`
// would discard that answer, and an in-memory database — which cannot do
// WAL — would look fine.
var stmt = try self.prepare("PRAGMA journal_mode = WAL");
defer stmt.deinit();
if (!try stmt.step()) return error.SqliteError;
const mode = stmt.columnText(0);
const wal = std.ascii.eqlIgnoreCase(mode, "wal");
const memory = std.ascii.eqlIgnoreCase(mode, "memory");
if (!wal and !memory) {
log.warn("PRAGMA journal_mode = WAL reported '{s}'", .{mode});
return error.SqliteError;
}
}
if (p.synchronous_normal) {
try self.exec("PRAGMA synchronous = NORMAL;");
}
if (p.foreign_keys) {
try self.exec("PRAGMA foreign_keys = ON;");
if (try self.queryInt("PRAGMA foreign_keys") != 1) {
log.warn("PRAGMA foreign_keys did not take", .{});
return error.SqliteError;
}
}
}
/// A write transaction.
///
/// Usage contract, followed everywhere in this milestone:
///
/// ```zig
/// var tx = try Tx.begin(db);
/// errdefer tx.rollback();
/// ... // all writes
/// try tx.commit();
/// ```
///
/// `commit` and `rollback` both clear `active`, so the `errdefer` after a
/// successful commit is a no-op.
pub const Tx = struct {
db: *Db,
active: bool,
/// BEGIN IMMEDIATE — takes the write lock up front. A deferred transaction
/// that upgrades mid-way can fail with SQLITE_BUSY after arbitrary work;
/// immediate cannot.
pub fn begin(db: *Db) Error!Tx {
try db.exec("BEGIN IMMEDIATE;");
return .{ .db = db, .active = true };
}
pub fn commit(self: *Tx) Error!void {
assert(self.active);
try self.db.exec("COMMIT;");
self.active = false;
}
/// Safe in `errdefer` and after `commit`. Never returns an error; a failed
/// ROLLBACK is logged at `err` level with the SQLite message, because a
/// database that will not roll back is an operational event, not a detail.
pub fn rollback(self: *Tx) void {
if (!self.active) return;
self.active = false;
self.db.exec("ROLLBACK;") catch {
var buf: [256]u8 = undefined;
log.err("ROLLBACK failed: {s}", .{self.db.lastError(&buf)});
};
}
};
const testing = std.testing;
fn openMemory() Error!Db {
return Db.open(":memory:", .{ .mode = .memory });
}
test "mapCode maps every primary result code to a distinct error" {
var seen: [26]Error = undefined;
var code: c_int = 1;
while (code <= 26) : (code += 1) {
seen[@intCast(code - 1)] = mapCode(code);
}
for (seen, 0..) |a, i| {
for (seen[i + 1 ..]) |b| {
try testing.expect(a != b);
}
}
}
test "mapCode maps SQLITE_NOMEM to error.OutOfMemory and keeps extended codes in the family" {
try testing.expectEqual(Error.OutOfMemory, mapCode(result.nomem));
// SQLITE_IOERR_READ = 266, SQLITE_CONSTRAINT_UNIQUE = 2067.
try testing.expectEqual(Error.IoErr, mapCode(266));
try testing.expectEqual(Error.Constraint, mapCode(2067));
// A primary code this build does not know stays visible as SqliteError.
try testing.expectEqual(Error.SqliteError, mapCode(99));
}
test "open and close an in-memory database" {
var db = try openMemory();
defer db.close();
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT 1"));
}
test "queryInt rejects a result that is not exactly one row of one integer" {
var db = try openMemory();
defer db.close();
// No row keeps the documented error.SqliteError.
try testing.expectError(error.SqliteError, db.queryInt("SELECT 1 WHERE 0"));
// Wrong column count.
try testing.expectError(error.Misuse, db.queryInt("SELECT 1, 2"));
// Wrong column type: NULL, text, float and blob are all rejected.
try testing.expectError(error.Misuse, db.queryInt("SELECT NULL"));
try testing.expectError(error.Misuse, db.queryInt("SELECT 'one'"));
try testing.expectError(error.Misuse, db.queryInt("SELECT 1.5"));
try testing.expectError(error.Misuse, db.queryInt("SELECT x'00'"));
// A second row.
try testing.expectError(error.Misuse, db.queryInt("SELECT 1 UNION ALL SELECT 2"));
}
test "queryInt accepts a single integer row after the shape checks" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);");
try db.exec("INSERT INTO t (id, name) VALUES (7, 'only');");
try testing.expectEqual(@as(i64, 7), try db.queryInt("SELECT id FROM t"));
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
try testing.expectEqual(@as(i64, -3), try db.queryInt("SELECT -3"));
// sum() over an empty table is NULL, not an integer: a caller that wants a
// total from a possibly-empty table must write total(), or COALESCE.
try testing.expectError(error.Misuse, db.queryInt("SELECT sum(id) FROM t WHERE 0"));
try testing.expectEqual(@as(i64, 7), try db.queryInt("SELECT sum(id) FROM t"));
}
test "applyPragmas succeeds and foreign_keys reads back as 1" {
var db = try openMemory();
defer db.close();
try applyPragmas(&db, .{});
try testing.expectEqual(@as(i64, 1), try db.queryInt("PRAGMA foreign_keys"));
}
test "open on a directory path returns error.CantOpen and leaks no handle" {
var i: usize = 0;
while (i < 1000) : (i += 1) {
try testing.expectError(error.CantOpen, Db.open(".", .{}));
}
}
test "prepare rejects text holding more than one statement" {
var db = try openMemory();
defer db.close();
try testing.expectError(error.Misuse, db.prepare("SELECT 1; SELECT 2"));
var stmt = try db.prepare("SELECT 1;");
stmt.deinit();
}
test "bind, step and column round-trip including a NULL text column" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, note TEXT, flag INTEGER NOT NULL);");
var insert = try db.prepare("INSERT INTO t (name, note, flag) VALUES (?1, ?2, ?3)");
defer insert.deinit();
try insert.bindText(1, "kitchen");
try insert.bindTextOrNull(2, null);
try insert.bindBool(3, true);
try insert.exec();
try testing.expectEqual(@as(i64, 1), db.changes());
try testing.expectEqual(@as(i64, 1), db.lastInsertRowid());
var select = try db.prepare("SELECT id, name, note, flag FROM t");
defer select.deinit();
try testing.expect(try select.step());
try testing.expectEqual(@as(i64, 1), select.columnInt(0));
try testing.expectEqualStrings("kitchen", select.columnText(1));
try testing.expect(select.isNull(2));
try testing.expectEqual(@as(?[]const u8, null), select.columnTextOrNull(2));
try testing.expectEqualStrings("", select.columnText(2));
try testing.expect(select.columnBool(3));
try testing.expect(!try select.step());
}
test "columnTextAlloc returns an owned copy that survives a subsequent step" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);");
try db.exec("INSERT INTO t (id, name) VALUES (1, 'first'), (2, 'second');");
var stmt = try db.prepare("SELECT name FROM t ORDER BY id");
defer stmt.deinit();
try testing.expect(try stmt.step());
const owned = try stmt.columnTextAlloc(testing.allocator, 0);
defer testing.allocator.free(owned);
const owned_or_null = try stmt.columnTextAllocOrNull(testing.allocator, 0);
defer if (owned_or_null) |v| testing.allocator.free(v);
try testing.expect(try stmt.step());
try testing.expectEqualStrings("second", stmt.columnText(0));
try testing.expectEqualStrings("first", owned);
try testing.expectEqualStrings("first", owned_or_null.?);
}
test "transaction commit persists and rollback discards" {
var db = try openMemory();
defer db.close();
try applyPragmas(&db, .{});
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
{
var tx = try Tx.begin(&db);
errdefer tx.rollback();
try db.exec("INSERT INTO t (id) VALUES (1);");
try tx.commit();
}
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
{
var tx = try Tx.begin(&db);
try db.exec("INSERT INTO t (id) VALUES (2);");
tx.rollback();
}
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
}
test "rollback after commit is a no-op" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
var tx = try Tx.begin(&db);
try db.exec("INSERT INTO t (id) VALUES (1);");
try tx.commit();
try testing.expect(!tx.active);
tx.rollback();
tx.rollback();
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
}
test "a row-producing statement reports its row through step" {
var db = try openMemory();
defer db.close();
// Stmt.exec asserts on this shape; the test observes it through `step`
// instead, so the assertion path stays out of the test binary.
var stmt = try db.prepare("SELECT 1");
defer stmt.deinit();
try testing.expect(try stmt.step());
}
test "a duplicate insert into a UNIQUE column returns error.Constraint" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);");
try db.exec("INSERT INTO t (name) VALUES ('only');");
var stmt = try db.prepare("INSERT INTO t (name) VALUES (?1)");
defer stmt.deinit();
try stmt.bindText(1, "only");
try testing.expectError(error.Constraint, stmt.step());
}
+255
View File
@@ -0,0 +1,255 @@
//! The `config.db` migration runner.
//!
//! Steps are compiled into the binary in ascending order and applied inside
//! **one** transaction, then the reached version is stamped. SQLite runs DDL
//! transactionally, so a step that fails leaves the file exactly as it was.
//!
//! A database stamped *newer* than this binary is never silently accepted and
//! never downgraded: it is `error.SchemaTooNew`, distinct from every other
//! error, so the CLI can tell the operator to install a newer nxdns.
const std = @import("std");
const assert = std.debug.assert;
const db = @import("db.zig");
const config_schema = @import("config_schema.zig");
const log = std.log.scoped(.migrations);
pub const Step = struct { version: u32, sql: [:0]const u8 };
pub const steps = [_]Step{
.{ .version = 1, .sql = config_schema.ddl_v1 },
};
pub const target_version: u32 = steps[steps.len - 1].version;
comptime {
assertOrdered(&steps);
}
pub const Error = db.Error || error{ SchemaTooNew, SchemaCorrupt };
/// Versions must be `1, 2, 3, …` with no gaps. A gap would make "apply every
/// step newer than the stamped version" ambiguous about what the stamp means.
fn assertOrdered(list: []const Step) void {
assert(list.len > 0);
for (list, 0..) |step, i| assert(@as(usize, step.version) == i + 1);
}
/// Reads the stamped version, applies every newer step in one transaction and
/// stamps the result. Returns the version now in the file.
///
/// `database` must already have had `db.applyPragmas` called: `PRAGMA
/// foreign_keys` is a no-op inside a transaction, so applying it afterwards
/// would silently leave referential integrity off.
pub fn migrate(database: *db.Db) Error!u32 {
return migrateSteps(database, &steps);
}
/// Same logic against an injected step list. The seam exists for the rollback
/// and stepwise-upgrade tests, which need a second step that `steps` does not
/// yet have.
pub fn migrateSteps(database: *db.Db, list: []const Step) Error!u32 {
assertOrdered(list);
const target = list[list.len - 1].version;
const current = try readVersion(database);
if (current > target) {
log.warn("config.db is at schema version {d}; this nxdns binary supports {d}", .{ current, target });
return error.SchemaTooNew;
}
if (current == target) return current;
var tx = try db.Tx.begin(database);
errdefer tx.rollback();
// Re-read under BEGIN IMMEDIATE. Two processes starting at the same moment
// both saw `current` above; the one that loses the write lock arrives here
// after the other committed and finds nothing to do.
const stamped = try readVersion(database);
if (stamped > target) {
log.warn("config.db is at schema version {d}; this nxdns binary supports {d}", .{ stamped, target });
return error.SchemaTooNew;
}
if (stamped == target) {
try tx.commit();
return stamped;
}
for (list) |step| {
if (step.version <= stamped) continue;
try database.exec(step.sql);
}
try database.exec("DELETE FROM schema_version;");
var stmt = try database.prepare("INSERT INTO schema_version (version) VALUES (?1)");
defer stmt.deinit();
try stmt.bindInt(1, target);
try stmt.exec();
try tx.commit();
log.info("config.db migrated from schema version {d} to {d}", .{ stamped, target });
return target;
}
/// `0` when `schema_version` does not exist yet. Zero rows or more than one row
/// is `error.SchemaCorrupt` — the version of a database is never guessed.
fn readVersion(database: *db.Db) Error!u32 {
const present = try database.queryInt(
"SELECT count(*) FROM sqlite_schema WHERE type='table' AND name='schema_version'",
);
if (present == 0) return 0;
const rows = try database.queryInt("SELECT count(*) FROM schema_version");
if (rows != 1) {
log.warn("schema_version holds {d} rows; exactly one is required", .{rows});
return error.SchemaCorrupt;
}
const version = try database.queryInt("SELECT version FROM schema_version");
if (version < 0 or version > std.math.maxInt(u32)) {
log.warn("schema_version holds an out-of-range version {d}", .{version});
return error.SchemaCorrupt;
}
return @intCast(version);
}
fn tableExists(database: *db.Db, name: []const u8) db.Error!bool {
var stmt = try database.prepare("SELECT count(*) FROM sqlite_schema WHERE type='table' AND name = ?1");
defer stmt.deinit();
try stmt.bindText(1, name);
if (!try stmt.step()) return error.SqliteError;
return stmt.columnInt(0) != 0;
}
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
return database;
}
test "migrate on a fresh database creates every table and seeds the default group" {
var database = try openMigrated();
defer database.close();
try testing.expectEqual(target_version, try migrate(&database));
const expected = [_][]const u8{
"schema_version", "groups", "clients", "client_prefixes",
"upstreams", "rules", "local_records", "forward_zones",
"blocklist_sources", "group_sources", "settings",
};
for (expected) |name| {
try testing.expect(try tableExists(&database, name));
}
try testing.expectEqual(
@as(i64, expected.len),
try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
);
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM groups"));
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT id FROM groups"));
var stmt = try database.prepare("SELECT name, safe_search FROM groups");
defer stmt.deinit();
try testing.expect(try stmt.step());
try testing.expectEqualStrings("default", stmt.columnText(0));
try testing.expect(!stmt.columnBool(1));
}
test "migrate is idempotent" {
var database = try openMigrated();
defer database.close();
try testing.expectEqual(target_version, try migrate(&database));
const before = try database.queryInt("SELECT count(*) FROM sqlite_schema");
const rowid_before = database.lastInsertRowid();
try testing.expectEqual(target_version, try migrate(&database));
try testing.expectEqual(before, try database.queryInt("SELECT count(*) FROM sqlite_schema"));
try testing.expectEqual(rowid_before, database.lastInsertRowid());
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM schema_version"));
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM groups"));
}
test "a database stamped newer than the binary is error.SchemaTooNew" {
var database = try openMigrated();
defer database.close();
_ = try migrate(&database);
const future: i64 = @as(i64, target_version) + 1;
var stmt = try database.prepare("UPDATE schema_version SET version = ?1");
defer stmt.deinit();
try stmt.bindInt(1, future);
try stmt.exec();
try testing.expectError(error.SchemaTooNew, migrate(&database));
try testing.expectEqual(future, try database.queryInt("SELECT version FROM schema_version"));
}
test "schema_version holding two rows is error.SchemaCorrupt" {
var database = try openMigrated();
defer database.close();
_ = try migrate(&database);
try database.exec("INSERT INTO schema_version (version) VALUES (1);");
try testing.expectError(error.SchemaCorrupt, migrate(&database));
}
test "a failing step rolls the whole migration back" {
var database = try openMigrated();
defer database.close();
const broken = [_]Step{
.{ .version = 1, .sql = config_schema.ddl_v1 },
.{ .version = 2, .sql = "CREATE TABLE second (" },
};
// db.zig maps SQLITE_ERROR — the generic "SQL error" — to error.Unexpected.
try testing.expectError(error.Unexpected, migrateSteps(&database, &broken));
try testing.expect(!try tableExists(&database, "schema_version"));
try testing.expect(!try tableExists(&database, "groups"));
try testing.expect(!try tableExists(&database, "second"));
try testing.expectEqual(@as(u32, 0), try readVersion(&database));
}
test "a stepwise upgrade applies only the new steps" {
var database = try openMigrated();
defer database.close();
const first = [_]Step{.{ .version = 1, .sql = config_schema.ddl_v1 }};
try testing.expectEqual(@as(u32, 1), try migrateSteps(&database, &first));
try testing.expect(try tableExists(&database, "groups"));
try testing.expect(!try tableExists(&database, "extra"));
const second = [_]Step{
.{ .version = 1, .sql = config_schema.ddl_v1 },
.{ .version = 2, .sql = "CREATE TABLE extra (id INTEGER PRIMARY KEY);" },
};
try testing.expectEqual(@as(u32, 2), try migrateSteps(&database, &second));
try testing.expect(try tableExists(&database, "extra"));
try testing.expectEqual(@as(u32, 2), try readVersion(&database));
// Step 1 did not run a second time: `groups` still holds one seeded row.
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM groups"));
}
test "delete_order and content_tables name exactly the tables the schema creates" {
var database = try openMigrated();
defer database.close();
_ = try migrate(&database);
for (config_schema.delete_order) |name| {
try testing.expect(try tableExists(&database, name));
}
for (config_schema.content_tables) |name| {
try testing.expect(try tableExists(&database, name));
}
// delete_order covers every table except `schema_version`.
try testing.expectEqual(
@as(i64, config_schema.delete_order.len + 1),
try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
);
}
+280
View File
@@ -0,0 +1,280 @@
//! The `querylog.db` schema and its open-or-recreate policy.
//!
//! `querylog.db` is never migrated (PLAN §3.7). It holds expendable log rows,
//! so a schema change replaces the file instead of upgrading it. The
//! replacement trigger is a fingerprint derived from the DDL text itself, so
//! editing the schema below automatically invalidates every existing file — the
//! policy cannot drift out of sync with the SQL.
//!
//! **Recreating is destructive, so the predicate is a positive whitelist.** Only
//! a missing file, `error.Corrupt`, `error.NotADb`, a failed `PRAGMA
//! quick_check` and a fingerprint mismatch recreate. Every other error
//! propagates and the file on disk is not touched. `error.Busy` / `error.Locked`
//! mean another process holds the write lock — waiting is right, deleting is
//! catastrophic. `error.OutOfMemory` is this process's problem. `error.CantOpen`
//! is usually a permission or missing-directory problem that recreating would
//! mask rather than fix. Same for `error.ReadOnly`, `error.IoErr`, `error.Full`,
//! `error.Perm`, `error.Auth` and `error.Canceled`.
const std = @import("std");
const db = @import("db.zig");
const log = std.log.scoped(.querylog_schema);
/// Verbatim from PLAN §11.3. Multi-statement text — it goes through
/// `db.Db.exec`, never through `prepare`.
pub const ddl: [:0]const u8 =
\\CREATE TABLE domains (
\\ id INTEGER PRIMARY KEY,
\\ domain TEXT NOT NULL UNIQUE
\\);
\\
\\CREATE TABLE query_log (
\\ id INTEGER PRIMARY KEY,
\\ timestamp INTEGER NOT NULL,
\\ domain_id INTEGER NOT NULL REFERENCES domains(id),
\\ client_ip TEXT NOT NULL, -- text, not a FK: log rows are immutable facts
\\ qtype INTEGER,
\\ blocked INTEGER NOT NULL,
\\ block_reason TEXT,
\\ response_time_us INTEGER,
\\ cache_hit INTEGER,
\\ upstream TEXT
\\);
\\CREATE INDEX idx_query_log_ts ON query_log(timestamp);
\\CREATE INDEX idx_query_log_client ON query_log(client_ip);
\\CREATE INDEX idx_query_log_domain ON query_log(domain_id);
;
/// `PRAGMA user_version` is a signed 32-bit field. Deriving the fingerprint from
/// the DDL means editing the schema automatically invalidates every existing
/// file — which is exactly the policy.
pub const fingerprint: i32 = blk: {
// Covers the CRC lookup-table generation in std.hash.crc, which evaluates
// under this scope's quota and overflows the 1000 default (and 100k).
@setEvalBranchQuota(2_000_000);
break :blk @bitCast(std.hash.Crc32.hash(ddl));
};
const set_user_version = std.fmt.comptimePrint("PRAGMA user_version = {d};", .{fingerprint});
/// Long enough for any path this program will be handed, plus the aside suffix.
/// A longer path is `error.NameTooLong`, which is what the filesystem calls
/// would have returned anyway.
const path_buf_len = 4096 + 64;
pub const RecreateReason = enum { missing, corrupt, not_a_database, quick_check_failed, fingerprint_mismatch };
pub const OpenResult = struct {
database: db.Db,
/// Non-null feeds a counter and the `/api/health` rollup in Phase 8.
recreated: ?RecreateReason,
};
pub const Error = db.Error || error{AsideNameCollision} ||
std.Io.Dir.RenamePreserveError || std.Io.Dir.DeleteFileError || std.Io.Dir.AccessError;
/// Opens `path`, recreating it if and only if it is genuinely unusable.
///
/// `path` is resolved twice by two different mechanisms: `dir`-relative for the
/// filesystem calls, and process-cwd-relative by SQLite's VFS, which knows
/// nothing about `dir`. The caller must therefore pass either an absolute path
/// with `dir` open on its parent, or `std.Io.Dir.cwd()` with a cwd-relative
/// path.
pub fn open(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) Error!OpenResult {
var handle: ?db.Db = null;
errdefer if (handle) |*h| h.close();
const reason: ?RecreateReason = probe: {
dir.access(io, path, .{}) catch |e| switch (e) {
error.FileNotFound => break :probe .missing,
else => |other| return other,
};
handle = db.Db.open(path, .{ .mode = .read_write_existing }) catch |e|
break :probe recreatable(e) orelse return e;
const opened = &handle.?;
db.applyPragmas(opened, .{}) catch |e|
break :probe recreatable(e) orelse return e;
const healthy = quickCheck(opened) catch |e|
break :probe recreatable(e) orelse return e;
if (!healthy) break :probe .quick_check_failed;
const stamped = opened.queryInt("PRAGMA user_version") catch |e|
break :probe recreatable(e) orelse return e;
if (stamped != fingerprint) break :probe .fingerprint_mismatch;
break :probe null;
};
const cause = reason orelse return .{ .database = handle.?, .recreated = null };
// Close first, so SQLite checkpoints and drops `-wal`/`-shm` where it can.
if (handle) |*h| h.close();
handle = null;
var aside_buf: [path_buf_len]u8 = undefined;
const aside: ?[]const u8 = if (cause == .missing)
null
else
try renameAside(io, dir, path, &aside_buf);
// Not optional: a stale WAL left beside the renamed database would be
// replayed into the freshly created file and corrupt it immediately. Any
// failure other than "already gone" propagates rather than building the new
// database on a half-cleaned state.
try deleteSidecars(io, dir, path);
const fresh = try createFresh(path);
if (cause == .missing) {
log.info("created querylog database '{s}'", .{path});
} else {
log.warn("recreated querylog database '{s}': {s}; previous file kept as '{s}'", .{
path,
@tagName(cause),
aside.?,
});
}
return .{ .database = fresh, .recreated = cause };
}
/// The whitelist. `null` means "propagate, do not touch the file".
fn recreatable(e: db.Error) ?RecreateReason {
return switch (e) {
error.Corrupt => .corrupt,
error.NotADb => .not_a_database,
else => null,
};
}
/// `PRAGMA quick_check` rather than `integrity_check`: it skips the expensive
/// index-vs-table cross-check while still catching structural damage, and a
/// damaged index on an expendable log is not worth a multi-second startup scan.
fn quickCheck(database: *db.Db) db.Error!bool {
var stmt = try database.prepare("PRAGMA quick_check");
defer stmt.deinit();
if (!try stmt.step()) return false;
return std.ascii.eqlIgnoreCase(stmt.columnText(0), "ok");
}
/// Renames the unusable file out of the way and returns the name it now has.
///
/// `renamePreserve` is `RENAME_NOREPLACE`: it returns `error.PathAlreadyExists`
/// instead of overwriting. A previously saved corrupt file must never be
/// destroyed by the next recreate, and two recreates in the same second are not
/// hypothetical on a boot loop — hence the uniquifying retries.
fn renameAside(io: std.Io, dir: std.Io.Dir, path: []const u8, buf: []u8) Error![]const u8 {
const seconds = std.Io.Clock.real.now(io).toSeconds();
var attempt: u32 = 0;
while (attempt < 100) : (attempt += 1) {
const aside = if (attempt == 0)
std.fmt.bufPrint(buf, "{s}.corrupt-{d}", .{ path, seconds }) catch return error.NameTooLong
else
std.fmt.bufPrint(buf, "{s}.corrupt-{d}-{d}", .{ path, seconds, attempt }) catch return error.NameTooLong;
dir.renamePreserve(path, dir, aside, io) catch |e| switch (e) {
error.PathAlreadyExists => continue,
else => |other| return other,
};
return aside;
}
return error.AsideNameCollision;
}
fn deleteSidecars(io: std.Io, dir: std.Io.Dir, path: []const u8) Error!void {
var buf: [path_buf_len]u8 = undefined;
for ([_][]const u8{ "-wal", "-shm" }) |suffix| {
const sidecar = std.fmt.bufPrint(&buf, "{s}{s}", .{ path, suffix }) catch return error.NameTooLong;
dir.deleteFile(io, sidecar) catch |e| switch (e) {
error.FileNotFound => {},
else => |other| return other,
};
}
}
fn createFresh(path: [:0]const u8) db.Error!db.Db {
var database = try db.Db.open(path, .{ .mode = .read_write_create });
errdefer database.close();
try db.applyPragmas(&database, .{});
var tx = try db.Tx.begin(&database);
errdefer tx.rollback();
try database.exec(ddl);
try database.exec(set_user_version);
try tx.commit();
return database;
}
const testing = std.testing;
test "fingerprint matches a fresh hash of the DDL" {
try testing.expectEqual(fingerprint, @as(i32, @bitCast(std.hash.Crc32.hash(ddl))));
}
test "ddl creates domains, query_log and the three indexes" {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
try database.exec(ddl);
try testing.expectEqual(
@as(i64, 2),
try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
);
const objects = [_][]const u8{
"domains", "query_log",
"idx_query_log_ts", "idx_query_log_client",
"idx_query_log_domain",
};
for (objects) |name| {
var stmt = try database.prepare("SELECT count(*) FROM sqlite_schema WHERE name = ?1");
defer stmt.deinit();
try stmt.bindText(1, name);
try testing.expect(try stmt.step());
try testing.expectEqual(@as(i64, 1), stmt.columnInt(0));
}
}
test "the user_version statement stamps the fingerprint" {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try database.exec(set_user_version);
try testing.expectEqual(@as(i64, fingerprint), try database.queryInt("PRAGMA user_version"));
}
// The behaviour these two cases describe — a resource error leaves the file on
// disk alone — is proven against a real file by "S7 case 23" in
// `storage_integration_test.zig`, which locks a healthy `querylog.db` from a
// second connection and asserts `open` returns `error.Busy` with the bytes, the
// file and the absence of an aside all intact. `error.OutOfMemory` has no such
// case: `open` takes no allocator, and SQLite allocates through its own global
// allocator, so there is no seam to inject a failure through. The two tests
// below are what covers it.
test "recreatable is a whitelist and never selects a resource error" {
try testing.expectEqual(RecreateReason.corrupt, recreatable(error.Corrupt).?);
try testing.expectEqual(RecreateReason.not_a_database, recreatable(error.NotADb).?);
const propagating = [_]db.Error{
error.Busy, error.Locked, error.OutOfMemory, error.CantOpen,
error.ReadOnly, error.IoErr, error.Full, error.Perm,
error.Auth, error.Misuse, error.Constraint, error.SqliteError,
error.Unexpected,
};
for (propagating) |e| {
try testing.expect(recreatable(e) == null);
}
}
test "recreatable selects exactly two of db.Error's members" {
// Exhaustive over the whole set, so a variant added to `db.Error` later
// defaults to propagate. The list above only proves the named errors are
// safe today; this proves nothing else can join the whitelist unnoticed.
var whitelisted: usize = 0;
inline for (@typeInfo(db.Error).error_set.?) |member| {
if (recreatable(@field(db.Error, member.name)) != null) whitelisted += 1;
}
try testing.expectEqual(@as(usize, 2), whitelisted);
}
+336
View File
@@ -0,0 +1,336 @@
//! `clients` and `client_prefixes`.
//!
//! `listClients` returns only `hand_edited = 1` rows. A client the server
//! materialised from live traffic is runtime state, not configuration, and must
//! not appear in an export. `countClients` counts **all** rows, because S5's
//! "has this database ever been configured" predicate needs the true count.
//!
//! Only list / insert / deleteAll / count exist.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const IdMap = context.IdMap;
const InsertContext = context.InsertContext;
// ---------------------------------------------------------------------------
// clients
// ---------------------------------------------------------------------------
const list_clients_sql =
\\SELECT c.ip, c.name, g.name FROM clients c
\\ JOIN groups g ON g.id = c.group_id
\\ WHERE c.hand_edited = 1
\\ ORDER BY c.ip
;
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listClients(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Client) {
var stmt = try database.prepare(list_clients_sql);
defer stmt.deinit();
var out: std.ArrayList(model.Client) = .empty;
// `errdefer`s run in reverse: `freeClients` is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeClients(gpa, out.items);
while (try stmt.step()) {
const ip = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(ip);
// `clients.name` is nullable; `columnTextAlloc` reads NULL as "", which
// is exactly the model's default.
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
const group = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(group);
try out.append(gpa, .{ .ip = ip, .name = name, .group = group });
}
return out;
}
pub fn freeClients(gpa: Allocator, items: []const model.Client) void {
for (items) |item| {
gpa.free(item.ip);
gpa.free(item.name);
gpa.free(item.group);
}
}
const insert_client_sql =
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES (?1, ?2, ?3, 1, ?4, ?4)
;
/// `hand_edited` is 1: a client that reached a repository through the config
/// model came from an operator's file, by definition.
pub fn insertClient(database: *db.Db, item: model.Client, ctx: InsertContext) db.Error!void {
const group_id = try ctx.groupId(item.group);
var stmt = try database.prepare(insert_client_sql);
defer stmt.deinit();
try stmt.bindText(1, item.ip);
try stmt.bindText(2, item.name);
try stmt.bindInt(3, group_id);
try stmt.bindInt(4, ctx.now);
try stmt.exec();
}
pub fn deleteAllClients(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM clients;");
}
/// Counts every row, including the ones `listClients` filters out.
pub fn countClients(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM clients");
}
// ---------------------------------------------------------------------------
// client_prefixes
// ---------------------------------------------------------------------------
const list_client_prefixes_sql =
\\SELECT p.prefix, g.name, p.priority FROM client_prefixes p
\\ JOIN groups g ON g.id = p.group_id
\\ ORDER BY p.prefix
;
pub fn listClientPrefixes(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ClientPrefix) {
var stmt = try database.prepare(list_client_prefixes_sql);
defer stmt.deinit();
var out: std.ArrayList(model.ClientPrefix) = .empty;
errdefer out.deinit(gpa);
errdefer freeClientPrefixes(gpa, out.items);
while (try stmt.step()) {
const prefix = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(prefix);
const group = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(group);
// The column is a 64-bit integer; the model field is `i32`. A value
// outside that range means something other than nxdns wrote the row.
const priority = std.math.cast(i32, stmt.columnInt(2)) orelse return error.Mismatch;
try out.append(gpa, .{ .prefix = prefix, .group = group, .priority = priority });
}
return out;
}
pub fn freeClientPrefixes(gpa: Allocator, items: []const model.ClientPrefix) void {
for (items) |item| {
gpa.free(item.prefix);
gpa.free(item.group);
}
}
pub fn insertClientPrefix(database: *db.Db, item: model.ClientPrefix, ctx: InsertContext) db.Error!void {
const group_id = try ctx.groupId(item.group);
var stmt = try database.prepare("INSERT INTO client_prefixes (prefix, group_id, priority) VALUES (?1, ?2, ?3)");
defer stmt.deinit();
try stmt.bindText(1, item.prefix);
try stmt.bindInt(2, group_id);
try stmt.bindInt(3, item.priority);
try stmt.exec();
}
pub fn deleteAllClientPrefixes(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM client_prefixes;");
}
pub fn countClientPrefixes(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM client_prefixes");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
/// Migration step 1 seeds `(1, 'default')`; `kids` is added here so the join
/// has two distinct groups to resolve.
fn seedGroups(database: *db.Db) !IdMap {
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
var ids: IdMap = .empty;
errdefer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
try ids.put(testing.allocator, "kids", 2);
return ids;
}
fn seedClients(database: *db.Db, ids: *const IdMap) !void {
const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids };
try insertClient(database, .{ .ip = "192.168.1.20", .name = "laptop", .group = "kids" }, ctx);
try insertClient(database, .{ .ip = "192.168.1.10", .name = "desk" }, ctx);
try insertClient(database, .{ .ip = "fd00::1", .group = "kids" }, ctx);
}
fn seedClientPrefixes(database: *db.Db, ids: *const IdMap) !void {
const ctx: InsertContext = .{ .group_ids = ids };
try insertClientPrefix(database, .{ .prefix = "192.168.2.0/24", .group = "kids", .priority = 10 }, ctx);
try insertClientPrefix(database, .{ .prefix = "192.168.1.0/24", .priority = 50 }, ctx);
try insertClientPrefix(database, .{ .prefix = "fd00::/48", .group = "kids" }, ctx);
}
test "clients round-trip in ip order with group names resolved" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClients(&database, &ids);
var items = try listClients(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeClients(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("192.168.1.10", items.items[0].ip);
try testing.expectEqualStrings("desk", items.items[0].name);
try testing.expectEqualStrings("default", items.items[0].group);
try testing.expectEqualStrings("192.168.1.20", items.items[1].ip);
try testing.expectEqualStrings("laptop", items.items[1].name);
try testing.expectEqualStrings("kids", items.items[1].group);
try testing.expectEqualStrings("fd00::1", items.items[2].ip);
try testing.expectEqualStrings("", items.items[2].name);
try testing.expectEqualStrings("kids", items.items[2].group);
try testing.expectEqual(
@as(i64, 1700000000),
try database.queryInt("SELECT first_seen FROM clients WHERE ip = '192.168.1.10'"),
);
try testing.expectEqual(
@as(i64, 1700000000),
try database.queryInt("SELECT last_seen FROM clients WHERE ip = '192.168.1.10'"),
);
}
test "a hand_edited = 0 client is absent from listClients but counted by countClients" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClients(&database, &ids);
try database.exec(
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES ('10.0.0.5', 'auto', 1, 0, 1, 1);
);
try testing.expectEqual(@as(i64, 4), try countClients(&database));
var items = try listClients(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeClients(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
for (items.items) |item| {
try testing.expect(!std.mem.eql(u8, item.ip, "10.0.0.5"));
}
}
test "deleteAllClients empties the table and countClients reflects it" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClients(&database, &ids);
try testing.expectEqual(@as(i64, 3), try countClients(&database));
try deleteAllClients(&database);
try testing.expectEqual(@as(i64, 0), try countClients(&database));
}
test "insertClient reports a group the caller's map does not hold" {
var database = try openMigrated();
defer database.close();
const ctx: InsertContext = .{};
try testing.expectError(
error.NotFound,
insertClient(&database, .{ .ip = "192.168.1.1" }, ctx),
);
}
fn listClientsUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
var database = try openMigrated();
defer database.close();
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
try seedClients(&database, ids);
var items = try listClients(&database, gpa);
defer items.deinit(gpa);
defer freeClients(gpa, items.items);
}
test "listClients is leak-safe under allocation failure" {
var ids: IdMap = .empty;
defer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
try ids.put(testing.allocator, "kids", 2);
try testing.checkAllAllocationFailures(testing.allocator, listClientsUnderFailure, .{&ids});
}
test "client_prefixes round-trip in prefix order with group names resolved" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClientPrefixes(&database, &ids);
var items = try listClientPrefixes(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeClientPrefixes(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("192.168.1.0/24", items.items[0].prefix);
try testing.expectEqualStrings("default", items.items[0].group);
try testing.expectEqual(@as(i32, 50), items.items[0].priority);
try testing.expectEqualStrings("192.168.2.0/24", items.items[1].prefix);
try testing.expectEqualStrings("kids", items.items[1].group);
try testing.expectEqual(@as(i32, 10), items.items[1].priority);
try testing.expectEqualStrings("fd00::/48", items.items[2].prefix);
try testing.expectEqualStrings("kids", items.items[2].group);
try testing.expectEqual(@as(i32, 100), items.items[2].priority);
}
test "deleteAllClientPrefixes empties the table and countClientPrefixes reflects it" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClientPrefixes(&database, &ids);
try testing.expectEqual(@as(i64, 3), try countClientPrefixes(&database));
try deleteAllClientPrefixes(&database);
try testing.expectEqual(@as(i64, 0), try countClientPrefixes(&database));
}
fn listClientPrefixesUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
var database = try openMigrated();
defer database.close();
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
try seedClientPrefixes(&database, ids);
var items = try listClientPrefixes(&database, gpa);
defer items.deinit(gpa);
defer freeClientPrefixes(gpa, items.items);
}
test "listClientPrefixes is leak-safe under allocation failure" {
var ids: IdMap = .empty;
defer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
try ids.put(testing.allocator, "kids", 2);
try testing.checkAllAllocationFailures(testing.allocator, listClientPrefixesUnderFailure, .{&ids});
}
+66
View File
@@ -0,0 +1,66 @@
//! What every `insert*` needs and the config model deliberately omits.
//!
//! `config/model.zig` carries no row ids and no timestamps: ids are not stable
//! across an import, and `first_seen` / `last_seen` / `created_at` are facts a
//! running server produces. Every insert that writes one of those columns reads
//! it from here instead.
//!
//! Building the id maps is the caller's job (S5): only the caller knows the ids
//! of the parent rows it just inserted.
const std = @import("std");
const log = std.log.scoped(.repositories);
/// Group name → `groups.id`, or blocklist source URL → `blocklist_sources.id`.
pub const IdMap = std.StringHashMapUnmanaged(i64);
const no_ids: IdMap = .empty;
pub const InsertContext = struct {
/// Unix epoch seconds, from `std.Io.Clock.real.now(io).toSeconds()`.
now: i64 = 0,
group_ids: *const IdMap = &no_ids,
source_ids: *const IdMap = &no_ids,
/// `error.NotFound` means the caller's map lacks a name the validator has
/// already proven the config declares. It is reported rather than asserted
/// so a caller bug aborts the import transaction instead of the process.
/// `NotFound` is a member of `db.Error`, so it needs no wider error set.
pub fn groupId(self: InsertContext, name: []const u8) error{NotFound}!i64 {
return self.group_ids.get(name) orelse {
log.warn("no group id for '{s}'", .{name});
return error.NotFound;
};
}
pub fn sourceId(self: InsertContext, url: []const u8) error{NotFound}!i64 {
return self.source_ids.get(url) orelse {
log.warn("no blocklist source id for '{s}'", .{url});
return error.NotFound;
};
}
};
const testing = std.testing;
test "an InsertContext with no maps reports a missing id rather than trapping" {
const ctx: InsertContext = .{};
try testing.expectError(error.NotFound, ctx.groupId("default"));
try testing.expectError(error.NotFound, ctx.sourceId("https://example.test/list.txt"));
}
test "InsertContext resolves names through the caller's maps" {
var groups: IdMap = .empty;
defer groups.deinit(testing.allocator);
try groups.put(testing.allocator, "default", 1);
var sources: IdMap = .empty;
defer sources.deinit(testing.allocator);
try sources.put(testing.allocator, "https://example.test/list.txt", 7);
const ctx: InsertContext = .{ .now = 1700000000, .group_ids = &groups, .source_ids = &sources };
try testing.expectEqual(@as(i64, 1), try ctx.groupId("default"));
try testing.expectEqual(@as(i64, 7), try ctx.sourceId("https://example.test/list.txt"));
try testing.expectError(error.NotFound, ctx.groupId("kids"));
}
+275
View File
@@ -0,0 +1,275 @@
//! `groups` and `group_sources`.
//!
//! Both lists yield model values holding **names**, never row ids: ids are not
//! stable across an import, so an export carrying them would not re-import into
//! the same shape.
//!
//! Only list / insert / deleteAll / count exist. Update-by-id, delete-by-id and
//! paged reads are Phase 8's REST surface; adding them now would be untested,
//! unused generality.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const IdMap = context.IdMap;
const InsertContext = context.InsertContext;
// ---------------------------------------------------------------------------
// groups
// ---------------------------------------------------------------------------
/// Every string in the result is a heap copy owned by `gpa`; free the whole
/// list with `freeGroups` and then `deinit` the list itself.
pub fn listGroups(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Group) {
var stmt = try database.prepare("SELECT name, safe_search FROM groups ORDER BY name");
defer stmt.deinit();
var out: std.ArrayList(model.Group) = .empty;
// Order matters: `errdefer`s run in reverse, so `freeGroups` must be
// declared *after* `deinit` to run *before* it. The other order reads
// `out.items` after the backing array is gone.
errdefer out.deinit(gpa);
errdefer freeGroups(gpa, out.items);
while (try stmt.step()) {
const name = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(name);
try out.append(gpa, .{ .name = name, .safe_search = stmt.columnBool(1) });
}
return out;
}
pub fn freeGroups(gpa: Allocator, items: []const model.Group) void {
for (items) |item| gpa.free(item.name);
}
pub fn insertGroup(database: *db.Db, item: model.Group, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare("INSERT INTO groups (name, safe_search) VALUES (?1, ?2)");
defer stmt.deinit();
try stmt.bindText(1, item.name);
try stmt.bindBool(2, item.safe_search);
try stmt.exec();
}
pub fn deleteAllGroups(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM groups;");
}
pub fn countGroups(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM groups");
}
// ---------------------------------------------------------------------------
// group_sources
// ---------------------------------------------------------------------------
const list_group_sources_sql =
\\SELECT g.name, s.url FROM group_sources gs
\\ JOIN groups g ON g.id = gs.group_id
\\ JOIN blocklist_sources s ON s.id = gs.source_id
\\ ORDER BY g.name, s.url
;
/// The two foreign keys are `NOT NULL` and enforced, so the join is total: a
/// `group_sources` row can never be dropped by it.
pub fn listGroupSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.GroupSource) {
var stmt = try database.prepare(list_group_sources_sql);
defer stmt.deinit();
var out: std.ArrayList(model.GroupSource) = .empty;
errdefer out.deinit(gpa);
errdefer freeGroupSources(gpa, out.items);
while (try stmt.step()) {
const group = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(group);
const source_url = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(source_url);
try out.append(gpa, .{ .group = group, .source_url = source_url });
}
return out;
}
pub fn freeGroupSources(gpa: Allocator, items: []const model.GroupSource) void {
for (items) |item| {
gpa.free(item.group);
gpa.free(item.source_url);
}
}
pub fn insertGroupSource(database: *db.Db, item: model.GroupSource, ctx: InsertContext) db.Error!void {
const group_id = try ctx.groupId(item.group);
const source_id = try ctx.sourceId(item.source_url);
var stmt = try database.prepare("INSERT INTO group_sources (group_id, source_id) VALUES (?1, ?2)");
defer stmt.deinit();
try stmt.bindInt(1, group_id);
try stmt.bindInt(2, source_id);
try stmt.exec();
}
pub fn deleteAllGroupSources(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM group_sources;");
}
pub fn countGroupSources(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM group_sources");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
/// Migration step 1 seeds `(1, 'default')`, so a migrated database already holds
/// one group and one known id.
fn defaultGroupIds() !IdMap {
var ids: IdMap = .empty;
errdefer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
return ids;
}
fn seedGroups(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertGroup(database, .{ .name = "kids", .safe_search = true }, ctx);
try insertGroup(database, .{ .name = "zeta" }, ctx);
try insertGroup(database, .{ .name = "alpha", .safe_search = true }, ctx);
}
fn seedGroupSources(database: *db.Db, ids: *const IdMap) !void {
try database.exec(
\\INSERT INTO blocklist_sources (id, url, name) VALUES
\\ (1, 'https://b.example/list.txt', 'B'),
\\ (2, 'https://a.example/list.txt', 'A');
);
var sources: IdMap = .empty;
defer sources.deinit(testing.allocator);
try sources.put(testing.allocator, "https://b.example/list.txt", 1);
try sources.put(testing.allocator, "https://a.example/list.txt", 2);
const ctx: InsertContext = .{ .group_ids = ids, .source_ids = &sources };
try insertGroupSource(database, .{ .group = "default", .source_url = "https://b.example/list.txt" }, ctx);
try insertGroupSource(database, .{ .group = "default", .source_url = "https://a.example/list.txt" }, ctx);
}
test "groups round-trip in name order" {
var database = try openMigrated();
defer database.close();
try seedGroups(&database);
var items = try listGroups(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeGroups(testing.allocator, items.items);
// The seeded `default` group sorts between `alpha` and `kids`.
try testing.expectEqual(@as(usize, 4), items.items.len);
try testing.expectEqualStrings("alpha", items.items[0].name);
try testing.expect(items.items[0].safe_search);
try testing.expectEqualStrings("default", items.items[1].name);
try testing.expect(!items.items[1].safe_search);
try testing.expectEqualStrings("kids", items.items[2].name);
try testing.expect(items.items[2].safe_search);
try testing.expectEqualStrings("zeta", items.items[3].name);
try testing.expect(!items.items[3].safe_search);
}
test "deleteAllGroups empties the table and countGroups reflects it" {
var database = try openMigrated();
defer database.close();
try seedGroups(&database);
try testing.expectEqual(@as(i64, 4), try countGroups(&database));
try deleteAllGroups(&database);
try testing.expectEqual(@as(i64, 0), try countGroups(&database));
var items = try listGroups(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeGroups(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 0), items.items.len);
}
fn listGroupsUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedGroups(&database);
var items = try listGroups(&database, gpa);
defer items.deinit(gpa);
defer freeGroups(gpa, items.items);
}
test "listGroups is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listGroupsUnderFailure, .{});
}
test "listGroupSources yields names, not ids" {
var database = try openMigrated();
defer database.close();
var ids = try defaultGroupIds();
defer ids.deinit(testing.allocator);
try seedGroupSources(&database, &ids);
var items = try listGroupSources(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeGroupSources(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 2), items.items.len);
try testing.expectEqualStrings("default", items.items[0].group);
try testing.expectEqualStrings("https://a.example/list.txt", items.items[0].source_url);
try testing.expectEqualStrings("default", items.items[1].group);
try testing.expectEqualStrings("https://b.example/list.txt", items.items[1].source_url);
}
test "deleteAllGroupSources empties the table and countGroupSources reflects it" {
var database = try openMigrated();
defer database.close();
var ids = try defaultGroupIds();
defer ids.deinit(testing.allocator);
try seedGroupSources(&database, &ids);
try testing.expectEqual(@as(i64, 2), try countGroupSources(&database));
try deleteAllGroupSources(&database);
try testing.expectEqual(@as(i64, 0), try countGroupSources(&database));
}
test "insertGroupSource reports an id the caller's map does not hold" {
var database = try openMigrated();
defer database.close();
const ctx: InsertContext = .{};
try testing.expectError(
error.NotFound,
insertGroupSource(&database, .{ .group = "kids", .source_url = "https://a.example/list.txt" }, ctx),
);
}
fn listGroupSourcesUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
var database = try openMigrated();
defer database.close();
try seedGroupSources(&database, ids);
var items = try listGroupSources(&database, gpa);
defer items.deinit(gpa);
defer freeGroupSources(gpa, items.items);
}
test "listGroupSources is leak-safe under allocation failure" {
var ids = try defaultGroupIds();
defer ids.deinit(testing.allocator);
try testing.checkAllAllocationFailures(testing.allocator, listGroupSourcesUnderFailure, .{&ids});
}
+255
View File
@@ -0,0 +1,255 @@
//! `local_records` and `forward_zones`.
//!
//! Only list / insert / deleteAll / count exist.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const InsertContext = context.InsertContext;
// ---------------------------------------------------------------------------
// local_records
// ---------------------------------------------------------------------------
const list_local_records_sql =
\\SELECT name, rtype, value, ttl FROM local_records ORDER BY name, rtype, value
;
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listLocalRecords(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.LocalRecord) {
var stmt = try database.prepare(list_local_records_sql);
defer stmt.deinit();
var out: std.ArrayList(model.LocalRecord) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeLocalRecords(gpa, out.items);
while (try stmt.step()) {
const name = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(name);
const value = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(value);
// The DDL's CHECK constraint makes the decode total for any row nxdns
// wrote; `error.Mismatch` covers a row that something else wrote.
const rtype = model.RecordType.fromDb(stmt.columnText(1)) orelse return error.Mismatch;
const ttl = std.math.cast(u32, stmt.columnInt(3)) orelse return error.Mismatch;
try out.append(gpa, .{ .name = name, .rtype = rtype, .value = value, .ttl = ttl });
}
return out;
}
pub fn freeLocalRecords(gpa: Allocator, items: []const model.LocalRecord) void {
for (items) |item| {
gpa.free(item.name);
gpa.free(item.value);
}
}
const insert_local_record_sql =
\\INSERT INTO local_records (name, rtype, value, ttl) VALUES (?1, ?2, ?3, ?4)
;
pub fn insertLocalRecord(database: *db.Db, item: model.LocalRecord, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare(insert_local_record_sql);
defer stmt.deinit();
try stmt.bindText(1, item.name);
try stmt.bindText(2, item.rtype.toDb());
try stmt.bindText(3, item.value);
try stmt.bindInt(4, item.ttl);
try stmt.exec();
}
pub fn deleteAllLocalRecords(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM local_records;");
}
pub fn countLocalRecords(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM local_records");
}
// ---------------------------------------------------------------------------
// forward_zones
// ---------------------------------------------------------------------------
pub fn listForwardZones(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ForwardZone) {
var stmt = try database.prepare("SELECT zone, resolver FROM forward_zones ORDER BY zone");
defer stmt.deinit();
var out: std.ArrayList(model.ForwardZone) = .empty;
errdefer out.deinit(gpa);
errdefer freeForwardZones(gpa, out.items);
while (try stmt.step()) {
const zone = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(zone);
const resolver = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(resolver);
try out.append(gpa, .{ .zone = zone, .resolver = resolver });
}
return out;
}
pub fn freeForwardZones(gpa: Allocator, items: []const model.ForwardZone) void {
for (items) |item| {
gpa.free(item.zone);
gpa.free(item.resolver);
}
}
pub fn insertForwardZone(database: *db.Db, item: model.ForwardZone, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare("INSERT INTO forward_zones (zone, resolver) VALUES (?1, ?2)");
defer stmt.deinit();
try stmt.bindText(1, item.zone);
try stmt.bindText(2, item.resolver);
try stmt.exec();
}
pub fn deleteAllForwardZones(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM forward_zones;");
}
pub fn countForwardZones(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM forward_zones");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
fn seedLocalRecords(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertLocalRecord(database, .{
.name = "nas.home.arpa",
.rtype = .aaaa,
.value = "fd00::5",
.ttl = 60,
}, ctx);
try insertLocalRecord(database, .{
.name = "nas.home.arpa",
.rtype = .a,
.value = "192.168.1.5",
}, ctx);
try insertLocalRecord(database, .{
.name = "alias.home.arpa",
.rtype = .cname,
.value = "nas.home.arpa",
.ttl = 120,
}, ctx);
}
fn seedForwardZones(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertForwardZone(database, .{ .zone = "work.example", .resolver = "udp://10.0.0.1:53" }, ctx);
try insertForwardZone(database, .{ .zone = "home.arpa", .resolver = "udp://192.168.1.1:53" }, ctx);
try insertForwardZone(database, .{ .zone = "lab.example", .resolver = "tcp://[fd00::1]:53" }, ctx);
}
test "local_records round-trip in name, rtype, value order" {
var database = try openMigrated();
defer database.close();
try seedLocalRecords(&database);
var items = try listLocalRecords(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeLocalRecords(testing.allocator, items.items);
// `rtype` is compared as stored text, so 'A' sorts before 'AAAA'.
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("alias.home.arpa", items.items[0].name);
try testing.expectEqual(model.RecordType.cname, items.items[0].rtype);
try testing.expectEqualStrings("nas.home.arpa", items.items[0].value);
try testing.expectEqual(@as(u32, 120), items.items[0].ttl);
try testing.expectEqualStrings("nas.home.arpa", items.items[1].name);
try testing.expectEqual(model.RecordType.a, items.items[1].rtype);
try testing.expectEqualStrings("192.168.1.5", items.items[1].value);
try testing.expectEqual(@as(u32, 300), items.items[1].ttl);
try testing.expectEqualStrings("nas.home.arpa", items.items[2].name);
try testing.expectEqual(model.RecordType.aaaa, items.items[2].rtype);
try testing.expectEqualStrings("fd00::5", items.items[2].value);
try testing.expectEqual(@as(u32, 60), items.items[2].ttl);
}
test "deleteAllLocalRecords empties the table and countLocalRecords reflects it" {
var database = try openMigrated();
defer database.close();
try seedLocalRecords(&database);
try testing.expectEqual(@as(i64, 3), try countLocalRecords(&database));
try deleteAllLocalRecords(&database);
try testing.expectEqual(@as(i64, 0), try countLocalRecords(&database));
}
fn listLocalRecordsUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedLocalRecords(&database);
var items = try listLocalRecords(&database, gpa);
defer items.deinit(gpa);
defer freeLocalRecords(gpa, items.items);
}
test "listLocalRecords is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listLocalRecordsUnderFailure, .{});
}
test "forward_zones round-trip in zone order" {
var database = try openMigrated();
defer database.close();
try seedForwardZones(&database);
var items = try listForwardZones(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeForwardZones(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("home.arpa", items.items[0].zone);
try testing.expectEqualStrings("udp://192.168.1.1:53", items.items[0].resolver);
try testing.expectEqualStrings("lab.example", items.items[1].zone);
try testing.expectEqualStrings("tcp://[fd00::1]:53", items.items[1].resolver);
try testing.expectEqualStrings("work.example", items.items[2].zone);
try testing.expectEqualStrings("udp://10.0.0.1:53", items.items[2].resolver);
}
test "deleteAllForwardZones empties the table and countForwardZones reflects it" {
var database = try openMigrated();
defer database.close();
try seedForwardZones(&database);
try testing.expectEqual(@as(i64, 3), try countForwardZones(&database));
try deleteAllForwardZones(&database);
try testing.expectEqual(@as(i64, 0), try countForwardZones(&database));
}
fn listForwardZonesUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedForwardZones(&database);
var items = try listForwardZones(&database, gpa);
defer items.deinit(gpa);
defer freeForwardZones(gpa, items.items);
}
test "listForwardZones is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listForwardZonesUnderFailure, .{});
}
+294
View File
@@ -0,0 +1,294 @@
//! `rules`.
//!
//! `rules` carries no `UNIQUE` constraint, so duplicate rules are legal. The
//! list therefore ends its `ORDER BY` with `id`, which is the only column that
//! makes the order — and so an export — deterministic.
//!
//! The list leads with the group *name*, not `group_id`. Ids are assigned by the
//! database and permute when a config is imported into a fresh database, so an
//! order that led with `group_id` would reorder the rules of an export →
//! import → export cycle. The name is the value the export emits, and it is the
//! same in both databases. The trailing `id` is stable for the same reason the
//! order as a whole is: `import` inserts the rules in export order, so the new
//! ids ascend in exactly the order this statement produced.
//!
//! Only list / insert / deleteAll / count exist.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const groups_repo = @import("groups_repo.zig");
const IdMap = context.IdMap;
const InsertContext = context.InsertContext;
const list_sql =
\\SELECT g.name, r.pattern, r.kind, r.action FROM rules r
\\ JOIN groups g ON g.id = r.group_id
\\ ORDER BY g.name, r.kind, r.action, r.pattern, r.id
;
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listRules(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Rule) {
var stmt = try database.prepare(list_sql);
defer stmt.deinit();
var out: std.ArrayList(model.Rule) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeRules(gpa, out.items);
while (try stmt.step()) {
const group = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(group);
const pattern = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(pattern);
// The DDL's CHECK constraints make both decodes total for any row nxdns
// wrote; `error.Mismatch` covers a row that something else wrote.
const kind = model.RuleKind.fromDb(stmt.columnText(2)) orelse return error.Mismatch;
const action = model.RuleAction.fromDb(stmt.columnText(3)) orelse return error.Mismatch;
try out.append(gpa, .{ .group = group, .pattern = pattern, .kind = kind, .action = action });
}
return out;
}
pub fn freeRules(gpa: Allocator, items: []const model.Rule) void {
for (items) |item| {
gpa.free(item.group);
gpa.free(item.pattern);
}
}
const insert_sql =
\\INSERT INTO rules (group_id, pattern, kind, action, created_at) VALUES (?1, ?2, ?3, ?4, ?5)
;
pub fn insertRule(database: *db.Db, item: model.Rule, ctx: InsertContext) db.Error!void {
const group_id = try ctx.groupId(item.group);
var stmt = try database.prepare(insert_sql);
defer stmt.deinit();
try stmt.bindInt(1, group_id);
try stmt.bindText(2, item.pattern);
try stmt.bindText(3, item.kind.toDb());
try stmt.bindText(4, item.action.toDb());
try stmt.bindInt(5, ctx.now);
try stmt.exec();
}
pub fn deleteAllRules(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM rules;");
}
pub fn countRules(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM rules");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
fn seedGroupIds() !IdMap {
var ids: IdMap = .empty;
errdefer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
return ids;
}
fn seedRules(database: *db.Db, ids: *const IdMap) !void {
const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids };
try insertRule(database, .{
.group = "default",
.pattern = "*.ads.example",
.kind = .wildcard,
.action = .block,
}, ctx);
try insertRule(database, .{
.group = "default",
.pattern = "tracker.example",
.kind = .exact,
.action = .block,
}, ctx);
try insertRule(database, .{
.group = "default",
.pattern = "allowed.example",
.kind = .exact,
.action = .allow,
}, ctx);
}
test "rules round-trip in group, kind, action, pattern, id order" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroupIds();
defer ids.deinit(testing.allocator);
try seedRules(&database, &ids);
var items = try listRules(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeRules(testing.allocator, items.items);
// One group, so `kind` leads: 'exact' before 'wildcard'; inside 'exact',
// 'allow' before 'block'.
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("allowed.example", items.items[0].pattern);
try testing.expectEqual(model.RuleKind.exact, items.items[0].kind);
try testing.expectEqual(model.RuleAction.allow, items.items[0].action);
try testing.expectEqualStrings("default", items.items[0].group);
try testing.expectEqualStrings("tracker.example", items.items[1].pattern);
try testing.expectEqual(model.RuleKind.exact, items.items[1].kind);
try testing.expectEqual(model.RuleAction.block, items.items[1].action);
try testing.expectEqualStrings("*.ads.example", items.items[2].pattern);
try testing.expectEqual(model.RuleKind.wildcard, items.items[2].kind);
try testing.expectEqual(model.RuleAction.block, items.items[2].action);
try testing.expectEqual(
@as(i64, 1700000000),
try database.queryInt("SELECT created_at FROM rules WHERE pattern = 'tracker.example'"),
);
}
test "a duplicate rule is accepted and stays deterministically ordered by id" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroupIds();
defer ids.deinit(testing.allocator);
const ctx: InsertContext = .{ .now = 1, .group_ids = &ids };
const rule: model.Rule = .{
.group = "default",
.pattern = "dup.example",
.kind = .exact,
.action = .block,
};
try insertRule(&database, rule, ctx);
try insertRule(&database, rule, ctx);
var items = try listRules(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeRules(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 2), items.items.len);
try testing.expectEqualStrings("dup.example", items.items[0].pattern);
try testing.expectEqualStrings("dup.example", items.items[1].pattern);
}
/// Inserts `names` in the given order and returns the ids the database assigned.
fn seedGroupsInOrder(database: *db.Db, names: []const []const u8) !IdMap {
var ids: IdMap = .empty;
errdefer ids.deinit(testing.allocator);
for (names) |name| {
try groups_repo.insertGroup(database, .{ .name = name }, .{});
try ids.put(testing.allocator, name, database.lastInsertRowid());
}
return ids;
}
fn seedCrossGroupRules(database: *db.Db, ids: *const IdMap) !void {
const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids };
try insertRule(database, .{
.group = "zeta",
.pattern = "z.example",
.kind = .exact,
.action = .block,
}, ctx);
try insertRule(database, .{
.group = "alpha",
.pattern = "a.example",
.kind = .exact,
.action = .block,
}, ctx);
}
test "list order does not depend on which id each group received" {
// Two databases hold the same rules under the same group names, but the
// groups were inserted in opposite orders, so every group id differs. This
// is what an export → import → export cycle does to the ids.
var first = try openMigrated();
defer first.close();
var first_ids = try seedGroupsInOrder(&first, &.{ "zeta", "alpha" });
defer first_ids.deinit(testing.allocator);
try seedCrossGroupRules(&first, &first_ids);
var second = try openMigrated();
defer second.close();
var second_ids = try seedGroupsInOrder(&second, &.{ "alpha", "zeta" });
defer second_ids.deinit(testing.allocator);
try seedCrossGroupRules(&second, &second_ids);
try testing.expect(first_ids.get("alpha").? != second_ids.get("alpha").?);
var a = try listRules(&first, testing.allocator);
defer a.deinit(testing.allocator);
defer freeRules(testing.allocator, a.items);
var b = try listRules(&second, testing.allocator);
defer b.deinit(testing.allocator);
defer freeRules(testing.allocator, b.items);
try testing.expectEqual(@as(usize, 2), a.items.len);
try testing.expectEqual(a.items.len, b.items.len);
for (a.items, b.items) |x, y| {
try testing.expectEqualStrings(x.group, y.group);
try testing.expectEqualStrings(x.pattern, y.pattern);
}
// And the sequence is the group names in order, not the insertion order.
try testing.expectEqualStrings("alpha", a.items[0].group);
try testing.expectEqualStrings("a.example", a.items[0].pattern);
try testing.expectEqualStrings("zeta", a.items[1].group);
try testing.expectEqualStrings("z.example", a.items[1].pattern);
}
test "deleteAllRules empties the table and countRules reflects it" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroupIds();
defer ids.deinit(testing.allocator);
try seedRules(&database, &ids);
try testing.expectEqual(@as(i64, 3), try countRules(&database));
try deleteAllRules(&database);
try testing.expectEqual(@as(i64, 0), try countRules(&database));
}
test "insertRule reports a group the caller's map does not hold" {
var database = try openMigrated();
defer database.close();
const ctx: InsertContext = .{};
try testing.expectError(error.NotFound, insertRule(&database, .{
.group = "kids",
.pattern = "x.example",
.kind = .exact,
.action = .block,
}, ctx));
}
fn listRulesUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
var database = try openMigrated();
defer database.close();
try seedRules(&database, ids);
var items = try listRules(&database, gpa);
defer items.deinit(gpa);
defer freeRules(gpa, items.items);
}
test "listRules is leak-safe under allocation failure" {
var ids = try seedGroupIds();
defer ids.deinit(testing.allocator);
try testing.checkAllAllocationFailures(testing.allocator, listRulesUnderFailure, .{&ids});
}
+145
View File
@@ -0,0 +1,145 @@
//! `settings`.
//!
//! The row type is `model.SettingPair`, the same type `model.toSettings` and
//! `model.fromSettings` speak, so the scalar sections cross the storage boundary
//! without a second shape.
//!
//! Only list / insert / deleteAll / count exist.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const InsertContext = context.InsertContext;
/// Both strings of every pair are heap copies owned by `gpa`.
pub fn listSettings(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.SettingPair) {
var stmt = try database.prepare("SELECT key, value FROM settings ORDER BY key");
defer stmt.deinit();
var out: std.ArrayList(model.SettingPair) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeSettings(gpa, out.items);
while (try stmt.step()) {
const key = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(key);
const value = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(value);
try out.append(gpa, .{ .key = key, .value = value });
}
return out;
}
/// Only for lists `listSettings` produced. `model.toSettings` builds pairs whose
/// `key` is a comptime string and must never be freed; that list is the caller's
/// to release, field by field.
pub fn freeSettings(gpa: Allocator, items: []const model.SettingPair) void {
for (items) |item| {
gpa.free(item.key);
gpa.free(item.value);
}
}
pub fn insertSetting(database: *db.Db, item: model.SettingPair, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare("INSERT INTO settings (key, value) VALUES (?1, ?2)");
defer stmt.deinit();
try stmt.bindText(1, item.key);
try stmt.bindText(2, item.value);
try stmt.exec();
}
pub fn deleteAllSettings(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM settings;");
}
pub fn countSettings(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM settings");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
fn seedSettings(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertSetting(database, .{ .key = "web.port", .value = "8080" }, ctx);
try insertSetting(database, .{ .key = "dns.port", .value = "53" }, ctx);
// An apostrophe proves the value is bound, not concatenated into the SQL.
try insertSetting(database, .{ .key = "logging.file_path", .value = "/var/log/o'brien.log" }, ctx);
}
test "settings round-trip in ascending key order" {
var database = try openMigrated();
defer database.close();
try seedSettings(&database);
var items = try listSettings(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeSettings(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("dns.port", items.items[0].key);
try testing.expectEqualStrings("53", items.items[0].value);
try testing.expectEqualStrings("logging.file_path", items.items[1].key);
try testing.expectEqualStrings("/var/log/o'brien.log", items.items[1].value);
try testing.expectEqualStrings("web.port", items.items[2].key);
try testing.expectEqualStrings("8080", items.items[2].value);
}
test "a value holding an apostrophe survives the round trip" {
var database = try openMigrated();
defer database.close();
const ctx: InsertContext = .{};
const value = "he said 'hello'; DROP TABLE settings;--";
try insertSetting(&database, .{ .key = "web.password_hash", .value = value }, ctx);
var items = try listSettings(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeSettings(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 1), items.items.len);
try testing.expectEqualStrings(value, items.items[0].value);
try testing.expectEqual(@as(i64, 1), try countSettings(&database));
}
test "deleteAllSettings empties the table and countSettings reflects it" {
var database = try openMigrated();
defer database.close();
try seedSettings(&database);
try testing.expectEqual(@as(i64, 3), try countSettings(&database));
try deleteAllSettings(&database);
try testing.expectEqual(@as(i64, 0), try countSettings(&database));
}
fn listSettingsUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedSettings(&database);
var items = try listSettings(&database, gpa);
defer items.deinit(gpa);
defer freeSettings(gpa, items.items);
}
test "listSettings is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listSettingsUnderFailure, .{});
}
+177
View File
@@ -0,0 +1,177 @@
//! `blocklist_sources`.
//!
//! Only the four configuration columns are read and written. `last_updated`,
//! `domain_count`, `wildcard_count`, `skipped_regex_count` and `checksum` are
//! facts a running server produces; an insert leaves them at their column
//! defaults so two exports taken minutes apart stay identical.
//!
//! Only list / insert / deleteAll / count exist.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const InsertContext = context.InsertContext;
const list_sql =
\\SELECT url, name, enabled, is_suggested FROM blocklist_sources ORDER BY url
;
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listBlocklistSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.BlocklistSource) {
var stmt = try database.prepare(list_sql);
defer stmt.deinit();
var out: std.ArrayList(model.BlocklistSource) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeBlocklistSources(gpa, out.items);
while (try stmt.step()) {
const url = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(url);
const name = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(name);
try out.append(gpa, .{
.url = url,
.name = name,
.enabled = stmt.columnBool(2),
.is_suggested = stmt.columnBool(3),
});
}
return out;
}
pub fn freeBlocklistSources(gpa: Allocator, items: []const model.BlocklistSource) void {
for (items) |item| {
gpa.free(item.url);
gpa.free(item.name);
}
}
const insert_sql =
\\INSERT INTO blocklist_sources (url, name, enabled, is_suggested) VALUES (?1, ?2, ?3, ?4)
;
pub fn insertBlocklistSource(database: *db.Db, item: model.BlocklistSource, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare(insert_sql);
defer stmt.deinit();
try stmt.bindText(1, item.url);
try stmt.bindText(2, item.name);
try stmt.bindBool(3, item.enabled);
try stmt.bindBool(4, item.is_suggested);
try stmt.exec();
}
pub fn deleteAllBlocklistSources(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM blocklist_sources;");
}
pub fn countBlocklistSources(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM blocklist_sources");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
fn seedSources(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertBlocklistSource(database, .{
.url = "https://c.example/list.txt",
.name = "C list",
}, ctx);
try insertBlocklistSource(database, .{
.url = "https://a.example/list.txt",
.name = "A list",
.enabled = false,
}, ctx);
try insertBlocklistSource(database, .{
.url = "https://b.example/list.txt",
.name = "B list",
.is_suggested = true,
}, ctx);
}
test "blocklist_sources round-trip in url order" {
var database = try openMigrated();
defer database.close();
try seedSources(&database);
var items = try listBlocklistSources(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeBlocklistSources(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("https://a.example/list.txt", items.items[0].url);
try testing.expectEqualStrings("A list", items.items[0].name);
try testing.expect(!items.items[0].enabled);
try testing.expect(!items.items[0].is_suggested);
try testing.expectEqualStrings("https://b.example/list.txt", items.items[1].url);
try testing.expectEqualStrings("B list", items.items[1].name);
try testing.expect(items.items[1].enabled);
try testing.expect(items.items[1].is_suggested);
try testing.expectEqualStrings("https://c.example/list.txt", items.items[2].url);
try testing.expectEqualStrings("C list", items.items[2].name);
try testing.expect(items.items[2].enabled);
try testing.expect(!items.items[2].is_suggested);
}
test "insertBlocklistSource leaves the runtime columns at their defaults" {
var database = try openMigrated();
defer database.close();
try seedSources(&database);
try testing.expectEqual(
@as(i64, 3),
try database.queryInt("SELECT count(*) FROM blocklist_sources WHERE last_updated IS NULL"),
);
try testing.expectEqual(
@as(i64, 3),
try database.queryInt("SELECT count(*) FROM blocklist_sources WHERE checksum IS NULL"),
);
try testing.expectEqual(
@as(i64, 0),
try database.queryInt("SELECT sum(domain_count + wildcard_count + skipped_regex_count) FROM blocklist_sources"),
);
}
test "deleteAllBlocklistSources empties the table and countBlocklistSources reflects it" {
var database = try openMigrated();
defer database.close();
try seedSources(&database);
try testing.expectEqual(@as(i64, 3), try countBlocklistSources(&database));
try deleteAllBlocklistSources(&database);
try testing.expectEqual(@as(i64, 0), try countBlocklistSources(&database));
}
fn listBlocklistSourcesUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedSources(&database);
var items = try listBlocklistSources(&database, gpa);
defer items.deinit(gpa);
defer freeBlocklistSources(gpa, items.items);
}
test "listBlocklistSources is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listBlocklistSourcesUnderFailure, .{});
}
+130
View File
@@ -0,0 +1,130 @@
//! `upstreams`.
//!
//! The list sorts by `priority` first because that is the operationally
//! meaningful order — it matches what `Pool.init` expects — and `url` breaks
//! ties uniquely, which is what makes an export byte-stable.
//!
//! Only list / insert / deleteAll / count exist.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const InsertContext = context.InsertContext;
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listUpstreams(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.UpstreamServer) {
var stmt = try database.prepare("SELECT url, priority, enabled FROM upstreams ORDER BY priority, url");
defer stmt.deinit();
var out: std.ArrayList(model.UpstreamServer) = .empty;
// `errdefer`s run in reverse: the free pass is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeUpstreams(gpa, out.items);
while (try stmt.step()) {
const url = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(url);
const priority = std.math.cast(i32, stmt.columnInt(1)) orelse return error.Mismatch;
try out.append(gpa, .{ .url = url, .priority = priority, .enabled = stmt.columnBool(2) });
}
return out;
}
pub fn freeUpstreams(gpa: Allocator, items: []const model.UpstreamServer) void {
for (items) |item| gpa.free(item.url);
}
pub fn insertUpstream(database: *db.Db, item: model.UpstreamServer, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare("INSERT INTO upstreams (url, priority, enabled) VALUES (?1, ?2, ?3)");
defer stmt.deinit();
try stmt.bindText(1, item.url);
try stmt.bindInt(2, item.priority);
try stmt.bindBool(3, item.enabled);
try stmt.exec();
}
pub fn deleteAllUpstreams(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM upstreams;");
}
pub fn countUpstreams(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM upstreams");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
fn seedUpstreams(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertUpstream(database, .{ .url = "https://dns.example/dns-query", .priority = 50 }, ctx);
try insertUpstream(database, .{ .url = "tls://1.1.1.1:853", .priority = 10, .enabled = false }, ctx);
try insertUpstream(database, .{ .url = "https://a.example/dns-query", .priority = 50 }, ctx);
}
test "upstreams round-trip in priority then url order" {
var database = try openMigrated();
defer database.close();
try seedUpstreams(&database);
var items = try listUpstreams(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeUpstreams(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 3), items.items.len);
try testing.expectEqualStrings("tls://1.1.1.1:853", items.items[0].url);
try testing.expectEqual(@as(i32, 10), items.items[0].priority);
try testing.expect(!items.items[0].enabled);
try testing.expectEqualStrings("https://a.example/dns-query", items.items[1].url);
try testing.expectEqual(@as(i32, 50), items.items[1].priority);
try testing.expect(items.items[1].enabled);
try testing.expectEqualStrings("https://dns.example/dns-query", items.items[2].url);
try testing.expectEqual(@as(i32, 50), items.items[2].priority);
try testing.expect(items.items[2].enabled);
}
test "deleteAllUpstreams empties the table and countUpstreams reflects it" {
var database = try openMigrated();
defer database.close();
try seedUpstreams(&database);
try testing.expectEqual(@as(i64, 3), try countUpstreams(&database));
try deleteAllUpstreams(&database);
try testing.expectEqual(@as(i64, 0), try countUpstreams(&database));
var items = try listUpstreams(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeUpstreams(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 0), items.items.len);
}
fn listUpstreamsUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedUpstreams(&database);
var items = try listUpstreams(&database, gpa);
defer items.deinit(gpa);
defer freeUpstreams(gpa, items.items);
}
test "listUpstreams is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listUpstreamsUnderFailure, .{});
}
File diff suppressed because it is too large Load Diff
+19
View File
@@ -27,6 +27,25 @@ comptime {
_ = @import("server/udp_server_integration_test.zig");
_ = @import("server/tcp_server_integration_test.zig");
_ = @import("server/resolver_integration_test.zig");
_ = @import("storage/db.zig");
_ = @import("config/model.zig");
_ = @import("config/validate.zig");
_ = @import("storage/config_schema.zig");
_ = @import("storage/migrations.zig");
_ = @import("storage/querylog_schema.zig");
_ = @import("storage/repositories/context.zig");
_ = @import("storage/repositories/groups_repo.zig");
_ = @import("storage/repositories/clients_repo.zig");
_ = @import("storage/repositories/upstreams_repo.zig");
_ = @import("storage/repositories/sources_repo.zig");
_ = @import("storage/repositories/rules_repo.zig");
_ = @import("storage/repositories/local_repo.zig");
_ = @import("storage/repositories/settings_repo.zig");
_ = @import("config/export.zig");
_ = @import("config/import.zig");
_ = @import("config/bootstrap.zig");
_ = @import("cli.zig");
_ = @import("storage/storage_integration_test.zig");
}
extern fn sqlite3_libversion() [*:0]const u8;