//! The one *declarative* configuration model: loading, reconciliation, import, //! export and the running server all speak this struct, and it is the whole //! shape of a config file. It is not the only shape the repositories accept — //! the API edits rows one at a time through narrower inputs such as //! `RuleInput`, `ClientInput` and `ClientEdit`, so a field added here does not //! reach those paths by itself. //! //! 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, //! exception_count, skipped_regex_count, skipped_unsupported_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. //! //! Declarative configuration reaches the database through exactly one path: //! `config/reconcile.zig`. `nxdns import` is a thin wrapper over it, and so is //! `run --config`. Reconciliation asks what changed rather than replacing //! wholesale, so a row the input still names keeps the runtime state attached //! to it: a source matched by url keeps its id, checksum and counters, a rule //! keeps its `created_at`, and a client keeps its first-seen and last-seen //! stamps. The source id and checksum are the two that decide whether a //! file-mode restart reuses the compiled bodies or downloads them again: //! `loadSource` names the files after the id and accepts them only against the //! stored checksum. The counters ride along as reported state. const std = @import("std"); const Allocator = std.mem.Allocator; pub const Config = struct { 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 Upstream = struct { /// Bounds one attempt against one upstream inside the pool's failover loop. attempt_timeout_ms: u32 = 2500, /// The forward-zone client's read deadline, and nothing else. It bounds a /// different subsystem from the two above (`src/local/forward_client.zig`), /// so no cross-check relates it to them. read_timeout_ms: u32 = 3000, /// The whole-exchange budget: every failover attempt together, not one of /// them. The pool races the entire loop against it. 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, never exported. /// /// Optional because absence and emptiness are different declarations: null /// means "the file says nothing about the password, keep the stored hash", /// while a present value is an instruction to set one. password: ?[]const u8 = null, /// argon2id PHC string. Null means "the file says nothing, keep what is /// stored"; an explicit `""` is the documented way to disable /// authentication. password_hash: ?[]const u8 = null, session_ttl_hours: u16 = 24, api_rate_limit_per_min: u32 = 300, /// Requests from the box itself skip the API rate limit. On by default: a /// local script or health probe is the operator's own traffic, not the /// abuse the limiter defends against (PLAN §10). api_localhost_exempt: bool = true, sse_max_connections_per_ip: u16 = 3, /// Comma-separated IP literals. A request arriving from one of these peers /// is identified by the last entry of its `X-Forwarded-For` header instead /// of by the socket peer, so the API limiter and the per-address SSE cap /// bind the real client rather than the proxy. Empty means no proxy is /// trusted and the socket peer is always the client. One string rather than /// a list because the settings codec stores scalars only. trusted_proxies: []const u8 = "", }; /// Walks `web.trusted_proxies`. The validator and the web server both read the /// setting, so what counts as one element — comma-separated, surrounding spaces /// and tabs trimmed — is spelled out once here. An empty setting yields nothing; /// an empty element (a stray comma) is yielded, so the validator can name it /// rather than silently drop it. pub fn trustedProxies(text: []const u8) TrustedProxyIterator { return .{ .rest = text, .done = text.len == 0 }; } pub const TrustedProxyIterator = struct { rest: []const u8, done: bool, pub fn next(self: *TrustedProxyIterator) ?[]const u8 { if (self.done) return null; const end = std.mem.findScalar(u8, self.rest, ',') orelse { const last = self.rest; self.done = true; return std.mem.trim(u8, last, " \t"); }; const element = self.rest[0..end]; self.rest = self.rest[end + 1 ..]; return std.mem.trim(u8, element, " \t"); } }; 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, /// How long the query-log writer gathers entries before it commits them. /// `0` does not wait at all: it flushes the entry that woke the writer plus /// whatever is already queued. query_log_flush_interval_s: u16 = 60, 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, /// DoT only. The DNS name used for SNI and certificate verification while /// the connection still dials the URL's host. `std.crypto.Certificate` /// matches dNSName SANs only, so a `tls://` upstream written as an IP /// literal cannot verify without one. Empty means "verify by the URL host". tls_name: []const u8 = "", }; 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 }; /// The three spellings `CHECK(kind IN ('exact','wildcard','regex'))` admits /// after migration step 4. pub const RuleKind = enum { exact, wildcard, regex, pub fn toDb(self: RuleKind) []const u8 { return switch (self) { .exact => "exact", .wildcard => "wildcard", .regex => "regex", }; } 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; if (std.mem.eql(u8, text, "regex")) return .regex; 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 readTimeout(u: Upstream) std.Io.Duration { return .{ .nanoseconds = @as(i96, u.read_timeout_ms) * std.time.ns_per_ms }; } pub fn attemptTimeout(u: Upstream) std.Io.Duration { return .{ .nanoseconds = @as(i96, u.attempt_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 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"; } /// The skip policy splits by direction, because encode and decode need /// different sets. /// /// `web.password` is operator input and is skipped both ways: it is hashed into /// `web.password_hash` and discarded (S2.5). /// /// `web.password_hash` is skipped on **encode only**. The reconcile engine owns /// that settings row directly — ruling 4 of milestone 20 makes absence mean /// "keep the stored hash", which a general encode pass cannot express. Skipping /// it on decode as well would leave `cfg.web.password_hash` null on every read /// path, turn `auth.authEnabled` false, and silently open the admin UI. fn isEncodeSkipped(comptime section: []const u8, comptime field: []const u8) bool { if (!std.mem.eql(u8, section, "web")) return false; return std.mem.eql(u8, field, "password") or std.mem.eql(u8, field, "password_hash"); } fn isDecodeSkipped(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, // A stored key is a present value, so an optional field decodes to a // non-null one; the null stays reserved for the absent key, which never // reaches this function at all. .optional => |info| try decodeValue(info.child, 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 !isEncodeSkipped(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 !isDecodeSkipped(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.query_log_flush_interval_s", "logging.retention_days", "upstream.attempt_timeout_ms", "upstream.read_timeout_ms", "upstream.total_timeout_ms", "web.api_localhost_exempt", "web.api_rate_limit_per_min", "web.bind", "web.enabled", "web.port", "web.session_ttl_hours", "web.sse_max_connections_per_ip", "web.trusted_proxies", }; 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 = .{ .upstream = .{ .attempt_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, .api_localhost_exempt = false, .sse_max_connections_per_ip = 31, .trusted_proxies = "10.0.0.9,fd00::9", }, .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, .query_log_flush_interval_s = 44, .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 !isEncodeSkipped(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 "web.password_hash decodes from the settings table but is never encoded" { const gpa = testing.allocator; var pairs: std.ArrayList(SettingPair) = .empty; defer { freeSettings(gpa, pairs.items); pairs.deinit(gpa); } // Encode: the reconciler owns that row, so no pass over the model emits it. const hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def"; try toSettings(.{ .web = .{ .password_hash = hash } }, gpa, &pairs); for (pairs.items) |pair| { try testing.expect(!std.mem.eql(u8, pair.key, "web.password_hash")); try testing.expect(!std.mem.eql(u8, pair.key, "web.password")); } // Decode: every read path still sees the stored hash, or `authEnabled` // would read false on a box that has a password set. var cfg: Config = .{}; var unknown: usize = 0; const stored = [_]SettingPair{.{ .key = "web.password_hash", .value = hash }}; try fromSettings(&stored, &cfg, &unknown); try testing.expectEqual(@as(usize, 0), unknown); try testing.expectEqualStrings(hash, cfg.web.password_hash.?); } test "an optional settings field is null when absent and non-null when present" { var absent: Config = .{}; var unknown: usize = 0; const other = [_]SettingPair{.{ .key = "dns.port", .value = "5300" }}; try fromSettings(&other, &absent, &unknown); try testing.expectEqual(@as(?[]const u8, null), absent.web.password_hash); // An explicit empty string is a present value, not an absent key: it is how // a config file disables authentication. var empty: Config = .{}; const disabled = [_]SettingPair{.{ .key = "web.password_hash", .value = "" }}; try fromSettings(&disabled, &empty, &unknown); try testing.expect(empty.web.password_hash != null); try testing.expectEqualStrings("", empty.web.password_hash.?); } 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(BlockResponse); try expectEnumRoundTrip(EcsMode); try expectEnumRoundTrip(LogLevel); try expectEnumRoundTrip(LogOutput); try expectEnumRoundTrip(RuleKind); try expectEnumRoundTrip(RuleAction); try expectEnumRoundTrip(RecordType); } test "RuleKind carries the third kind through export and import" { try testing.expectEqualStrings("regex", RuleKind.regex.toDb()); try testing.expectEqual(RuleKind.regex, RuleKind.fromDb("regex").?); try testing.expect(RuleKind.fromDb("Regex") == null); } 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, 2500) * std.time.ns_per_ms, attemptTimeout(.{}).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(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 = .{ .attempt_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, readTimeout(max_upstream).nanoseconds, ); try testing.expectEqual( @as(i96, std.math.maxInt(u32)) * std.time.ns_per_ms, attemptTimeout(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(u64, std.math.maxInt(u32)) * 1024 * 1024, maxLogBytes(.{ .max_size_mb = std.math.maxInt(u32) }), ); }