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

This commit is contained in:
2026-08-01 14:21:44 +02:00
parent 17d0401f8a
commit 70bff22d75
23 changed files with 10142 additions and 47 deletions
+940
View File
@@ -0,0 +1,940 @@
//! The command line, everything except the process shell around it.
//!
//! `main.zig` parses `argv`, dispatches and returns an exit code; every command
//! body lives here and takes its writers as parameters, so a test can capture
//! what an operator would see into a `std.Io.Writer.Allocating`.
//!
//! Exit codes (milestone-1 convention, extended):
//!
//! - `0` success;
//! - `1` a runtime failure — I/O, database, out of memory;
//! - `2` a configuration the operator can fix, or a `check` that found a
//! problem;
//! - `64` a usage error.
const std = @import("std");
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
const Certificate = std.crypto.Certificate;
const tls = std.crypto.tls;
const config_export = @import("config/export.zig");
const import = @import("config/import.zig");
const model = @import("config/model.zig");
const validate = @import("config/validate.zig");
const db = @import("storage/db.zig");
const migrations = @import("storage/migrations.zig");
const doh_client = @import("upstream/doh_client.zig");
const dot_client = @import("upstream/dot_client.zig");
const pool = @import("upstream/pool.zig");
const transport = @import("upstream/transport.zig");
const version = @import("version.zig");
pub const exit_ok: u8 = 0;
pub const exit_runtime: u8 = 1;
pub const exit_check: u8 = 2;
pub const exit_usage: u8 = 64;
pub const config_db_name = "config.db";
pub const querylog_db_name = "querylog.db";
// ---------------------------------------------------------------------------
// argument parsing (pure)
// ---------------------------------------------------------------------------
pub const Paths = struct {
/// PLAN §3.13.
data_dir: []const u8 = "/var/lib/nxdns",
config: []const u8 = "/etc/nxdns/config.zon",
};
/// `config_explicit` records whether `--config` was given, because `check` has
/// to tell "the operator named a file" from "the default path happens to
/// exist".
pub const CheckArgs = struct { paths: Paths = .{}, config_explicit: bool = false };
pub const ExportArgs = struct { paths: Paths = .{}, out: ?[]const u8 = null };
pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, force: bool = false };
pub const Command = union(enum) {
run: Paths,
check: CheckArgs,
export_: ExportArgs,
import_: ImportArgs,
version,
help,
};
pub const ParseError = error{
UnknownCommand,
UnknownFlag,
MissingValue,
MissingArgument,
TooManyArguments,
};
/// `argv` excludes the program name. Every slice in the result borrows from
/// `argv`.
pub fn parseArgs(argv: []const []const u8) ParseError!Command {
if (argv.len == 0) return error.UnknownCommand;
const command = argv[0];
const rest = argv[1..];
if (eql(command, "version")) {
if (rest.len != 0) return error.TooManyArguments;
return .version;
}
if (eql(command, "help") or eql(command, "--help") or eql(command, "-h")) {
if (rest.len != 0) return error.TooManyArguments;
return .help;
}
if (eql(command, "run")) return .{ .run = (try parseCheckArgs(rest)).paths };
if (eql(command, "check")) return .{ .check = try parseCheckArgs(rest) };
if (eql(command, "export")) return .{ .export_ = try parseExportArgs(rest) };
if (eql(command, "import")) return .{ .import_ = try parseImportArgs(rest) };
return error.UnknownCommand;
}
const Flag = struct {
/// Without the leading `--`.
name: []const u8,
/// The `value` of `--flag=value`, absent for `--flag`.
attached: ?[]const u8,
};
fn splitFlag(arg: []const u8) ?Flag {
if (!std.mem.startsWith(u8, arg, "--")) return null;
const body = arg[2..];
if (std.mem.findScalar(u8, body, '=')) |at| {
return .{ .name = body[0..at], .attached = body[at + 1 ..] };
}
return .{ .name = body, .attached = null };
}
/// `--flag=value` and `--flag value` both work. An empty attached value is a
/// missing value, not an empty path.
fn flagValue(flag: Flag, argv: []const []const u8, i: *usize) ParseError![]const u8 {
if (flag.attached) |attached| {
if (attached.len == 0) return error.MissingValue;
return attached;
}
if (i.* + 1 >= argv.len) return error.MissingValue;
i.* += 1;
return argv[i.*];
}
/// `run` and `check` take the same two flags. `run` throws away
/// `config_explicit`; bootstrap reads the path either way.
fn parseCheckArgs(argv: []const []const u8) ParseError!CheckArgs {
var args: CheckArgs = .{};
var i: usize = 0;
while (i < argv.len) : (i += 1) {
const flag = splitFlag(argv[i]) orelse return error.TooManyArguments;
if (eql(flag.name, "data-dir")) {
args.paths.data_dir = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "config")) {
args.paths.config = try flagValue(flag, argv, &i);
args.config_explicit = true;
} else return error.UnknownFlag;
}
return args;
}
fn parseExportArgs(argv: []const []const u8) ParseError!ExportArgs {
var args: ExportArgs = .{};
var i: usize = 0;
while (i < argv.len) : (i += 1) {
const flag = splitFlag(argv[i]) orelse return error.TooManyArguments;
if (eql(flag.name, "data-dir")) {
args.paths.data_dir = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "out")) {
args.out = try flagValue(flag, argv, &i);
} else return error.UnknownFlag;
}
return args;
}
fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs {
var paths: Paths = .{};
var force = false;
var file: ?[]const u8 = null;
var i: usize = 0;
while (i < argv.len) : (i += 1) {
const flag = splitFlag(argv[i]) orelse {
if (file != null) return error.TooManyArguments;
file = argv[i];
continue;
};
if (eql(flag.name, "data-dir")) {
paths.data_dir = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "force")) {
// A boolean flag takes no value, so `--force=1` is not a spelling of
// any flag this program has.
if (flag.attached != null) return error.UnknownFlag;
force = true;
} else return error.UnknownFlag;
}
return .{
.paths = paths,
.file = file orelse return error.MissingArgument,
.force = force,
};
}
fn eql(a: []const u8, b: []const u8) bool {
return std.mem.eql(u8, a, b);
}
// ---------------------------------------------------------------------------
// path assembly
// ---------------------------------------------------------------------------
pub const DataDir = struct {
dir: std.Io.Dir,
config_db_path: [:0]const u8,
querylog_db_path: [:0]const u8,
/// Creates `data_dir` and its parents at mode 0700 when `create` is set,
/// then opens it. Plain `createDirPath` hardcodes 0777 (Dir.zig:843) and
/// must not be used here: this directory holds `config.db`, which holds
/// `web.password_hash`.
pub fn open(io: std.Io, gpa: Allocator, data_dir: []const u8, create: bool) !DataDir {
if (create) {
_ = try std.Io.Dir.cwd().createDirPathStatus(io, data_dir, .fromMode(0o700));
}
var dir = try std.Io.Dir.cwd().openDir(io, data_dir, .{});
errdefer dir.close(io);
// SQLite opens by path, not by directory handle, so both paths are
// joined and NUL-terminated here rather than resolved through `dir`.
const config_db_path = try std.fs.path.joinZ(gpa, &.{ data_dir, config_db_name });
errdefer gpa.free(config_db_path);
const querylog_db_path = try std.fs.path.joinZ(gpa, &.{ data_dir, querylog_db_name });
return .{
.dir = dir,
.config_db_path = config_db_path,
.querylog_db_path = querylog_db_path,
};
}
pub fn close(self: *DataDir, io: std.Io, gpa: Allocator) void {
self.dir.close(io);
gpa.free(self.config_db_path);
gpa.free(self.querylog_db_path);
}
/// The order of these three steps is the specification, not a style:
///
/// 1. `db.Db.open` creates `config.db`, at `0644 & ~umask` — SQLite's
/// choice, not ours.
/// 2. chmod 0600.
/// 3. `applyPragmas` turns on WAL, which is what creates `config.db-wal`
/// and `config.db-shm`. SQLite copies the main database file's
/// permissions onto both sidecars, so setting 0600 first gets them for
/// free. The other order leaves them world-readable, and the WAL of a
/// database holding `web.password_hash` is as sensitive as the database.
pub fn openConfigDb(self: *const DataDir, io: std.Io) !db.Db {
var database = try db.Db.open(self.config_db_path, .{});
errdefer database.close();
try self.dir.setFilePermissions(io, config_db_name, .fromMode(0o600), .{});
try db.applyPragmas(&database, .{});
return database;
}
};
// ---------------------------------------------------------------------------
// entry functions
// ---------------------------------------------------------------------------
pub const Runner = struct {
io: std.Io,
gpa: Allocator,
out: *Writer,
err: *Writer,
};
const usage_text =
\\usage: nxdns <command> [options]
\\
\\commands:
\\ run serve DNS
\\ check validate the configuration
\\ export write the configuration to stdout, or to --out
\\ import FILE replace the configuration with FILE
\\ version print version information
\\ help print this message
\\
\\options:
\\ --data-dir DIR data directory (default /var/lib/nxdns)
\\ --config FILE configuration file (default /etc/nxdns/config.zon)
\\ --out FILE write the export to FILE instead of stdout
\\ --force let import replace a database that already has content
\\
;
/// Returns nothing, so a writer failure here has nowhere to go. Every caller
/// flushes afterwards and reports that failure instead.
pub fn usage(w: *Writer) void {
w.writeAll(usage_text) catch {};
}
/// Both `main` and the tests end a command the same way: flush, then report.
/// A flush that fails is a runtime failure even when the command succeeded —
/// output the operator never received is not output.
fn finish(r: Runner, code: u8) u8 {
r.out.flush() catch return exit_runtime;
r.err.flush() catch return exit_runtime;
return code;
}
pub fn runUsageError(r: Runner, e: ParseError) u8 {
r.err.print("{s}\n\n", .{parseErrorMessage(e)}) catch {};
usage(r.err);
return finish(r, exit_usage);
}
fn parseErrorMessage(e: ParseError) []const u8 {
return switch (e) {
error.UnknownCommand => "unknown command",
error.UnknownFlag => "unknown flag",
error.MissingValue => "a flag was given without its value",
error.MissingArgument => "a required argument is missing",
error.TooManyArguments => "too many arguments",
};
}
pub fn runHelp(r: Runner) u8 {
usage(r.out);
return finish(r, exit_ok);
}
pub fn runVersion(r: Runner) u8 {
r.out.print("nxdns {s} ({s})\nzig {s}\n", .{
version.string,
version.git_commit,
version.zig_version_string,
}) catch return finish(r, exit_runtime);
return finish(r, exit_ok);
}
/// Unchanged from milestone 1. Wiring the configuration into the servers is
/// Phase 7; there is deliberately no half-built serving path here.
pub fn runRun(r: Runner, paths: Paths) u8 {
_ = paths;
r.out.writeAll("not implemented\n") catch return finish(r, exit_runtime);
return finish(r, exit_check);
}
pub fn runExport(r: Runner, args: ExportArgs) u8 {
exportImpl(r, args) catch |e| {
r.err.print("export failed: {s}\n", .{@errorName(e)}) catch {};
return finish(r, exit_runtime);
};
return finish(r, exit_ok);
}
fn exportImpl(r: Runner, args: ExportArgs) !void {
var data = try DataDir.open(r.io, r.gpa, args.paths.data_dir, false);
defer data.close(r.io, r.gpa);
var database = try data.openConfigDb(r.io);
defer database.close();
_ = try migrations.migrate(&database);
const path = args.out orelse return config_export.writeToWriter(r.gpa, &database, r.out);
// Relative to the working directory, not to the data directory: an operator
// running `nxdns export --out backup.zon` means the shell's directory.
// `writeToFile` is the atomic, 0600 path — the file carries
// `web.password_hash`.
try config_export.writeToFile(r.io, r.gpa, &database, std.Io.Dir.cwd(), path);
try r.out.print("wrote {s}\n", .{path});
}
pub fn runImport(r: Runner, args: ImportArgs) u8 {
var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit();
importImpl(r, args, &diags) catch |e| {
// Every problem, not just the first: an operator fixing a config file
// should need one run to see the whole list.
diags.writeAll(r.err) catch {};
r.err.print("import failed: {s}\n", .{@errorName(e)}) catch {};
return finish(r, failureExitCode(e, diags.problems.items.len));
};
return finish(r, exit_ok);
}
fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
var data = try DataDir.open(r.io, r.gpa, args.paths.data_dir, true);
defer data.close(r.io, r.gpa);
var database = try data.openConfigDb(r.io);
defer database.close();
_ = try migrations.migrate(&database);
try import.importFile(
r.io,
r.gpa,
&database,
std.Io.Dir.cwd(),
args.file,
.{ .force = args.force },
diags,
);
try r.out.print("imported {s}\n", .{args.file});
}
/// A configuration the operator can fix exits 2; everything else is a runtime
/// failure. `validate` records a diagnostic for every error it returns and then
/// returns the first one, so a non-empty diagnostics list is the reliable
/// discriminator; the named errors below are the config faults that never reach
/// the validator.
///
/// `error.OutOfMemory` is matched first, before the list is consulted. Both
/// recording paths — `validate` and import's per-line rendering of a ZON syntax
/// error — add one problem at a time and can run out of memory partway, which
/// leaves problems recorded for a run whose real outcome is a resource failure.
/// A partial report is not a verdict on the configuration, so the runtime exit
/// code wins.
fn failureExitCode(e: anyerror, problems: usize) u8 {
if (e == error.OutOfMemory) return exit_runtime;
if (problems != 0) return exit_check;
return switch (e) {
error.DatabaseNotEmpty,
error.ConfigTooLarge,
error.ParseZon,
error.PasswordAndHashBothSet,
=> exit_check,
else => exit_runtime,
};
}
// ---------------------------------------------------------------------------
// check
// ---------------------------------------------------------------------------
/// An A query for example.com: id 0x1234, RD set, one question. The probe wants
/// a name every resolver on earth answers, so a FAIL line means the upstream is
/// unreachable rather than that the name is odd.
const probe_query =
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
"\x07example\x03com\x00\x00\x01\x00\x01";
/// The point of `check` is that an operator sees every problem in one run, so
/// this never returns early on a finding. `probe` is false in unit tests and
/// true from `main`: the probe leaves the machine.
pub fn runCheck(r: Runner, args: CheckArgs, probe: bool) u8 {
const code = checkImpl(r, args, probe) catch |e| {
r.err.print("check failed: {s}\n", .{@errorName(e)}) catch {};
return finish(r, exit_runtime);
};
return finish(r, code);
}
fn checkImpl(r: Runner, args: CheckArgs, probe: bool) !u8 {
var arena_state: std.heap.ArenaAllocator = .init(r.gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Which source was used is printed in every branch, so the answer is never
// ambiguous about what it checked.
if (args.config_explicit) {
try r.out.print("checking configuration file {s}\n", .{args.paths.config});
return checkFile(r, arena, args.paths.config, probe);
}
const config_db_path = try std.fs.path.join(arena, &.{ args.paths.data_dir, config_db_name });
if (try pathExists(r.io, config_db_path)) {
try r.out.print("checking database {s}\n", .{config_db_path});
var data = try DataDir.open(r.io, r.gpa, args.paths.data_dir, false);
defer data.close(r.io, r.gpa);
// A `check` on a database one schema version behind must still work,
// which is what an operator runs right after an upgrade.
var database = try data.openConfigDb(r.io);
defer database.close();
_ = try migrations.migrate(&database);
const cfg = try config_export.readConfig(&database, arena);
return checkConfig(r, cfg, probe);
}
if (try pathExists(r.io, args.paths.config)) {
try r.out.print("checking configuration file {s}\n", .{args.paths.config});
return checkFile(r, arena, args.paths.config, probe);
}
try r.out.print("nothing to check: no {s} in {s} and no {s}\n", .{
config_db_name,
args.paths.data_dir,
args.paths.config,
});
return exit_check;
}
fn pathExists(io: std.Io, path: []const u8) std.Io.Dir.AccessError!bool {
std.Io.Dir.cwd().access(io, path, .{}) catch |e| switch (e) {
error.FileNotFound => return false,
else => |other| return other,
};
return true;
}
/// `AccessOptions.read` is the `R_OK` bit of `faccessat` (`Io/Threaded.zig`
/// `dirAccessPosix`); the default `.{}` sends mode 0, which is `F_OK` and tests
/// existence only. A file that exists but denies this user a read is exactly the
/// case `check` has to catch, so it needs the bit set.
fn pathReadable(io: std.Io, path: []const u8) std.Io.Dir.AccessError!bool {
std.Io.Dir.cwd().access(io, path, .{ .read = true }) catch |e| switch (e) {
error.FileNotFound, error.AccessDenied, error.PermissionDenied => return false,
else => |other| return other,
};
return true;
}
fn checkFile(r: Runner, arena: Allocator, path: []const u8, probe: bool) !u8 {
const source = std.Io.Dir.cwd().readFileAllocOptions(
r.io,
path,
arena,
.limited(import.max_config_bytes),
.of(u8),
0,
) catch |e| switch (e) {
error.StreamTooLong => {
try r.out.print("FAIL {s}: larger than {d} bytes\n", .{ path, import.max_config_bytes });
return exit_check;
},
else => |other| return other,
};
// Arena-owned and never handed to `std.zon.parse.free`; see the rule and its
// `parse.zig:874` citation in `config/import.zig`.
var zon_diag: std.zon.parse.Diagnostics = .{};
const cfg = std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
// The rendering carries the line and column, which is the whole value of
// running `check` against a file the operator just edited.
error.ParseZon => {
try r.out.print("FAIL {s}: {f}\n", .{ path, &zon_diag });
return exit_check;
},
};
return checkConfig(r, cfg, probe);
}
/// The half of `check` that has a `Config` already: validate, report every
/// diagnostic, then the certificate and upstream checks. With TLS disabled and
/// `probe` false it touches neither the filesystem nor the network, which is
/// what makes it unit-testable.
pub fn checkConfig(r: Runner, cfg: model.Config, probe: bool) !u8 {
var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit();
// The returned error is `problems[0].err` — one of the lines about to be
// printed — so it carries nothing the report does not. Only an allocation
// failure means the report itself is incomplete.
validate.validate(cfg, &diags) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => {},
};
try diags.writeAll(r.out);
var failures = diags.problems.items.len;
failures += try checkCertificates(r, cfg);
if (probe) failures += try probeUpstreams(r, cfg);
if (failures != 0) return exit_check;
try r.out.writeAll("OK: no problems found\n");
return exit_ok;
}
fn checkCertificates(r: Runner, cfg: model.Config) !usize {
return try checkTlsFiles(r, cfg.doh_server, "doh_server") +
try checkTlsFiles(r, cfg.dot_server, "dot_server");
}
/// An unreadable certificate or key fails the run; a key readable by anyone
/// beyond its owner is a warning (PLAN §19) and leaves the exit code alone,
/// because the service still starts.
fn checkTlsFiles(r: Runner, endpoint: model.TlsEndpoint, comptime section: []const u8) !usize {
if (!endpoint.enabled) return 0;
var failures: usize = 0;
if (!try pathReadable(r.io, endpoint.cert_path)) {
try r.out.print("FAIL " ++ section ++ ".cert_path: '{s}' is not readable\n", .{endpoint.cert_path});
failures += 1;
}
if (try pathReadable(r.io, endpoint.key_path)) {
const stat = try std.Io.Dir.cwd().statFile(r.io, endpoint.key_path, .{});
const mode = stat.permissions.toMode() & 0o777;
if (mode & 0o077 != 0) {
try r.out.print(
"WARN " ++ section ++ ".key_path: '{s}' is mode {o}; a TLS key must be readable by its owner only\n",
.{ endpoint.key_path, mode },
);
}
} else {
try r.out.print("FAIL " ++ section ++ ".key_path: '{s}' is not readable\n", .{endpoint.key_path});
failures += 1;
}
return failures;
}
/// One `Pool` per upstream, never one pool over all of them. The pool's job is
/// failover: a shared pool would report success as soon as any upstream
/// answered, and a broken upstream would stay invisible — the exact thing
/// `check` exists to surface. Driving the real pool rather than the client
/// directly keeps the deadline, cancellation and health machinery identical to
/// what the server will do.
fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
var failures: usize = 0;
var http: std.http.Client = .{ .allocator = r.gpa, .io = r.io };
defer http.deinit();
// Shared across every DoT endpoint: the scan is expensive and the trust
// store does not vary per upstream.
var bundle: Certificate.Bundle = .empty;
defer bundle.deinit(r.gpa);
var bundle_lock: std.Io.RwLock = .init;
const chunk = tls.Client.min_buffer_len;
const tls_buffers = try r.gpa.alloc(u8, 4 * chunk);
defer r.gpa.free(tls_buffers);
var request_buf: [1024]u8 = undefined;
var transfer_buf: [4096]u8 = undefined;
const response_buf = try r.gpa.alloc(u8, transport.max_message_len);
defer r.gpa.free(response_buf);
const attempt_timeout: std.Io.Clock.Duration = .{
.raw = model.totalTimeout(cfg.upstream),
.clock = .awake,
};
// The pool jitters backoff from this; one probe per upstream never reaches
// backoff, so the value only has to be a value.
const seed: u64 = @truncate(@as(u96, @bitCast(std.Io.Clock.real.now(r.io).nanoseconds)));
for (cfg.upstreams) |server| {
if (!server.enabled) continue;
const endpoint = transport.Endpoint.parse(server.url) catch {
try r.out.print("FAIL {s}: not an https:// or tls:// endpoint\n", .{server.url});
failures += 1;
continue;
};
// Both clients are pinned for the pool's lifetime: `Entry.client` is an
// erased pointer into one of them.
var doh: doh_client.DohClient = undefined;
var dot: dot_client.DotClient = undefined;
const client: transport.Client = switch (endpoint.scheme) {
.doh => doh: {
doh = doh_client.DohClient.init(&http, endpoint, &request_buf, &transfer_buf) catch {
try r.out.print("FAIL {s}: not a usable DoH url\n", .{server.url});
failures += 1;
continue;
};
break :doh doh.client();
},
.dot => dot: {
dot = dot_client.DotClient.init(endpoint, r.gpa, &bundle, &bundle_lock, .{
.tls_read = tls_buffers[0..chunk],
.tls_write = tls_buffers[chunk .. 2 * chunk],
.stream_read = tls_buffers[2 * chunk .. 3 * chunk],
.stream_write = tls_buffers[3 * chunk ..],
});
break :dot dot.client();
},
};
var entries = [_]pool.Entry{.{
.endpoint = endpoint,
.client = client,
.priority = server.priority,
.enabled = true,
.health = .init,
}};
var single: pool.Pool = .init(&entries, .{}, attempt_timeout, seed);
if (single.exchange(r.io, probe_query, response_buf)) |_| {
try r.out.print("OK {s}\n", .{server.url});
} else |_| {
// The concrete cause lives in the entry's health, which is where the
// pool put it; `@errorName` of the pool's return value would only
// repeat the last attempt's classification.
var snapshots: [1]pool.Snapshot = undefined;
const taken = try single.snapshot(r.io, &snapshots);
const detail = if (taken == 1) snapshots[0].last_error else "no detail recorded";
try r.out.print("FAIL {s}: {s}\n", .{ server.url, detail });
failures += 1;
}
}
return failures;
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
test "parseArgs accepts run with no flags" {
const command = try parseArgs(&.{"run"});
try testing.expectEqualStrings("/var/lib/nxdns", command.run.data_dir);
try testing.expectEqualStrings("/etc/nxdns/config.zon", command.run.config);
}
test "parseArgs accepts run with --data-dir and --config" {
const command = try parseArgs(&.{ "run", "--data-dir", "/srv/nx", "--config", "/tmp/c.zon" });
try testing.expectEqualStrings("/srv/nx", command.run.data_dir);
try testing.expectEqualStrings("/tmp/c.zon", command.run.config);
}
test "parseArgs accepts --data-dir with and without an equals sign" {
const attached = try parseArgs(&.{ "check", "--data-dir=/srv/nx" });
try testing.expectEqualStrings("/srv/nx", attached.check.paths.data_dir);
const separate = try parseArgs(&.{ "check", "--data-dir", "/srv/nx" });
try testing.expectEqualStrings("/srv/nx", separate.check.paths.data_dir);
}
test "parseArgs records whether check was given an explicit --config" {
const implicit = try parseArgs(&.{"check"});
try testing.expect(!implicit.check.config_explicit);
const explicit = try parseArgs(&.{ "check", "--config=/tmp/c.zon" });
try testing.expect(explicit.check.config_explicit);
try testing.expectEqualStrings("/tmp/c.zon", explicit.check.paths.config);
}
test "parseArgs accepts export with --out" {
const command = try parseArgs(&.{ "export", "--out", "backup.zon" });
try testing.expectEqualStrings("backup.zon", command.export_.out.?);
const bare = try parseArgs(&.{"export"});
try testing.expectEqual(@as(?[]const u8, null), bare.export_.out);
}
test "parseArgs accepts import with a file, --force and --data-dir" {
const command = try parseArgs(&.{ "import", "c.zon", "--force", "--data-dir=/srv/nx" });
try testing.expectEqualStrings("c.zon", command.import_.file);
try testing.expect(command.import_.force);
try testing.expectEqualStrings("/srv/nx", command.import_.paths.data_dir);
}
test "parseArgs accepts import with the file after the flags" {
const command = try parseArgs(&.{ "import", "--data-dir", "/srv/nx", "c.zon" });
try testing.expectEqualStrings("c.zon", command.import_.file);
try testing.expect(!command.import_.force);
}
test "parseArgs accepts version" {
try testing.expectEqual(Command.version, try parseArgs(&.{"version"}));
}
test "parseArgs accepts help, --help and -h" {
try testing.expectEqual(Command.help, try parseArgs(&.{"help"}));
try testing.expectEqual(Command.help, try parseArgs(&.{"--help"}));
try testing.expectEqual(Command.help, try parseArgs(&.{"-h"}));
}
test "parseArgs rejects import without a file" {
try testing.expectError(error.MissingArgument, parseArgs(&.{"import"}));
try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--force" }));
}
test "parseArgs rejects --out without a value" {
try testing.expectError(error.MissingValue, parseArgs(&.{ "export", "--out" }));
try testing.expectError(error.MissingValue, parseArgs(&.{ "export", "--out=" }));
}
test "parseArgs rejects an unknown flag" {
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "check", "--nope" }));
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--force=1" }));
}
test "parseArgs rejects an unknown command" {
try testing.expectError(error.UnknownCommand, parseArgs(&.{"frobnicate"}));
}
test "parseArgs rejects an empty argument list" {
try testing.expectError(error.UnknownCommand, parseArgs(&.{}));
}
test "parseArgs rejects an extra positional argument" {
try testing.expectError(error.TooManyArguments, parseArgs(&.{ "export", "extra" }));
try testing.expectError(error.TooManyArguments, parseArgs(&.{ "version", "extra" }));
try testing.expectError(error.TooManyArguments, parseArgs(&.{ "import", "a.zon", "b.zon" }));
try testing.expectError(error.TooManyArguments, parseArgs(&.{ "help", "extra" }));
try testing.expectError(error.TooManyArguments, parseArgs(&.{ "--help", "extra" }));
try testing.expectError(error.TooManyArguments, parseArgs(&.{ "-h", "extra" }));
}
test "usage writes non-empty text" {
var out: Writer.Allocating = .init(testing.allocator);
defer out.deinit();
usage(&out.writer);
try testing.expect(out.written().len > 0);
try testing.expect(std.mem.startsWith(u8, out.written(), "usage: nxdns"));
}
const Captured = struct {
threaded: std.Io.Threaded,
out: Writer.Allocating,
err: Writer.Allocating,
fn init(gpa: Allocator) Captured {
return .{
.threaded = .init(gpa, .{}),
.out = .init(gpa),
.err = .init(gpa),
};
}
fn deinit(self: *Captured) void {
self.out.deinit();
self.err.deinit();
self.threaded.deinit();
}
fn runner(self: *Captured) Runner {
return .{
.io = self.threaded.io(),
.gpa = testing.allocator,
.out = &self.out.writer,
.err = &self.err.writer,
};
}
};
fn countLines(text: []const u8) usize {
return std.mem.count(u8, text, "\n");
}
test "checkConfig returns 0 for a clean configuration" {
var captured: Captured = .init(testing.allocator);
defer captured.deinit();
const cfg: model.Config = .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
};
try testing.expectEqual(exit_ok, try checkConfig(captured.runner(), cfg, false));
try testing.expectEqualStrings("OK: no problems found\n", captured.out.written());
}
test "checkConfig returns 2 and prints one line per problem" {
var captured: Captured = .init(testing.allocator);
defer captured.deinit();
// Three problems: no group named 'default', no enabled upstream, and a
// zero port.
const cfg: model.Config = .{ .dns = .{ .port = 0 } };
try testing.expectEqual(exit_check, try checkConfig(captured.runner(), cfg, false));
const text = captured.out.written();
try testing.expectEqual(@as(usize, 3), countLines(text));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "dns.port:"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "groups:"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "upstreams:"));
}
test "checkConfig reports every problem rather than stopping at the first" {
var captured: Captured = .init(testing.allocator);
defer captured.deinit();
const cfg: model.Config = .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.clients = &.{
.{ .ip = "not-an-ip" },
.{ .ip = "also-not-an-ip" },
.{ .ip = "192.168.1.1", .group = "missing" },
},
};
try testing.expectEqual(exit_check, try checkConfig(captured.runner(), cfg, false));
try testing.expectEqual(@as(usize, 3), countLines(captured.out.written()));
}
test "runVersion prints the milestone-1 version lines and exits 0" {
var captured: Captured = .init(testing.allocator);
defer captured.deinit();
try testing.expectEqual(exit_ok, runVersion(captured.runner()));
try testing.expect(std.mem.startsWith(u8, captured.out.written(), "nxdns "));
try testing.expectEqual(@as(usize, 2), countLines(captured.out.written()));
}
test "runRun still reports that serving is not implemented" {
var captured: Captured = .init(testing.allocator);
defer captured.deinit();
try testing.expectEqual(exit_check, runRun(captured.runner(), .{}));
try testing.expectEqualStrings("not implemented\n", captured.out.written());
}
test "runUsageError names the fault and prints the usage text" {
var captured: Captured = .init(testing.allocator);
defer captured.deinit();
try testing.expectEqual(exit_usage, runUsageError(captured.runner(), error.UnknownFlag));
const text = captured.err.written();
try testing.expect(std.mem.startsWith(u8, text, "unknown flag\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "usage: nxdns"));
}
test "failureExitCode separates a fixable configuration from a runtime failure" {
try testing.expectEqual(exit_check, failureExitCode(error.DatabaseNotEmpty, 0));
try testing.expectEqual(exit_check, failureExitCode(error.ParseZon, 0));
try testing.expectEqual(exit_check, failureExitCode(error.NoUpstreams, 1));
try testing.expectEqual(exit_runtime, failureExitCode(error.IoErr, 0));
try testing.expectEqual(exit_runtime, failureExitCode(error.OutOfMemory, 0));
// A partial report from an allocation failure is a runtime failure, not a
// finding about the configuration.
try testing.expectEqual(exit_runtime, failureExitCode(error.OutOfMemory, 3));
}
test "an allocation failure while recording diagnostics exits 1, not 2" {
// The recording path for real: `validate` adds one problem per fault, so an
// allocator that fails partway leaves problems in the list AND returns
// `error.OutOfMemory`. How many allocations one problem costs is
// `Diagnostics.add`'s business, so the failure point is swept rather than
// guessed.
const cfg: model.Config = .{ .dns = .{ .port = 0 } };
var saw_partial_report = false;
var fail_index: usize = 0;
while (fail_index < 32) : (fail_index += 1) {
var failing: std.testing.FailingAllocator = .init(testing.allocator, .{ .fail_index = fail_index });
var diags: validate.Diagnostics = .init(failing.allocator());
defer diags.deinit();
validate.validate(cfg, &diags) catch |e| {
if (e != error.OutOfMemory) continue;
const problems = diags.problems.items.len;
if (problems == 0) continue;
saw_partial_report = true;
try testing.expectEqual(exit_runtime, failureExitCode(e, problems));
};
}
try testing.expect(saw_partial_report);
}
// `runCheck` against real paths, `runExport` and `runImport` all need a real
// data directory, and the upstream probe leaves the machine. Those cases are
// S7's: `src/storage/storage_integration_test.zig` cases 20-22.