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