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
+745
View File
@@ -0,0 +1,745 @@
//! The one configuration model. Bootstrap, import, export, the repositories and
//! the running server all speak this struct; nothing else describes nxdns
//! configuration.
//!
//! Pure: no `std.Io` value is a parameter anywhere, no SQLite, no clock. The
//! only `std.Io` types that appear are `std.Io.Duration` as a conversion result.
//!
//! Runtime columns are deliberately absent. `clients.first_seen`,
//! `clients.last_seen`, `rules.created_at` and
//! `blocklist_sources.{last_updated, domain_count, wildcard_count,
//! skipped_regex_count, checksum}` are facts a running server produces, not
//! configuration. Including them would make two exports taken minutes apart
//! differ, which would make the byte-stable round trip untestable against a
//! live server. Import sets the timestamps to the import time and leaves the
//! counters at their column defaults.
const std = @import("std");
const Allocator = std.mem.Allocator;
pub const Config = struct {
runtime: Runtime = .{},
upstream: Upstream = .{},
dns: Dns = .{},
blocking: Blocking = .{},
cache: Cache = .{},
web: Web = .{},
doh_server: TlsEndpoint = .{},
dot_server: TlsEndpoint = .{ .port = 853 },
edns: Edns = .{},
logging: Logging = .{},
disk: Disk = .{},
blocklist_update: BlocklistUpdate = .{},
groups: []const Group = &.{},
upstreams: []const UpstreamServer = &.{},
clients: []const Client = &.{},
client_prefixes: []const ClientPrefix = &.{},
blocklist_sources: []const BlocklistSource = &.{},
group_sources: []const GroupSource = &.{},
rules: []const Rule = &.{},
local_records: []const LocalRecord = &.{},
forward_zones: []const ForwardZone = &.{},
};
pub const IoBackend = enum {
threaded,
evented,
pub fn toDb(self: IoBackend) []const u8 {
return switch (self) {
.threaded => "threaded",
.evented => "evented",
};
}
pub fn fromDb(text: []const u8) ?IoBackend {
if (std.mem.eql(u8, text, "threaded")) return .threaded;
if (std.mem.eql(u8, text, "evented")) return .evented;
return null;
}
};
pub const Runtime = struct { io_backend: IoBackend = .threaded };
pub const Upstream = struct {
connect_timeout_ms: u32 = 2000,
read_timeout_ms: u32 = 3000,
total_timeout_ms: u32 = 5000,
};
pub const Dns = struct {
bind_ipv4: []const u8 = "0.0.0.0",
bind_ipv6: []const u8 = "::",
port: u16 = 53,
rate_limit: u32 = 1000,
rate_window_seconds: u32 = 60,
};
pub const BlockResponse = enum {
zero,
nxdomain,
pub fn toDb(self: BlockResponse) []const u8 {
return switch (self) {
.zero => "zero",
.nxdomain => "nxdomain",
};
}
pub fn fromDb(text: []const u8) ?BlockResponse {
if (std.mem.eql(u8, text, "zero")) return .zero;
if (std.mem.eql(u8, text, "nxdomain")) return .nxdomain;
return null;
}
};
pub const Blocking = struct { response: BlockResponse = .zero, ttl: u32 = 5 };
pub const Cache = struct { size: u32 = 10000, negative_ttl_max: u32 = 3600 };
pub const Web = struct {
enabled: bool = true,
bind: []const u8 = "0.0.0.0",
port: u16 = 8080,
/// Operator input only. Never a settings row, always exported as "".
password: []const u8 = "",
/// argon2id PHC string; "" disables authentication.
password_hash: []const u8 = "",
session_ttl_hours: u16 = 24,
api_rate_limit_per_min: u32 = 300,
sse_max_connections_per_ip: u16 = 3,
};
pub const TlsEndpoint = struct {
enabled: bool = false,
bind: []const u8 = "0.0.0.0",
port: u16 = 443,
cert_path: []const u8 = "/etc/nxdns/cert.pem",
key_path: []const u8 = "/etc/nxdns/key.pem",
};
pub const EcsMode = enum {
strip,
forward,
pub fn toDb(self: EcsMode) []const u8 {
return switch (self) {
.strip => "strip",
.forward => "forward",
};
}
pub fn fromDb(text: []const u8) ?EcsMode {
if (std.mem.eql(u8, text, "strip")) return .strip;
if (std.mem.eql(u8, text, "forward")) return .forward;
return null;
}
};
pub const Edns = struct { ecs_mode: EcsMode = .strip };
pub const LogLevel = enum {
err,
warn,
info,
debug,
/// `.err` stores as "error": that is the operator-facing word, and the Zig
/// tag cannot be `error` because it is a keyword.
pub fn toDb(self: LogLevel) []const u8 {
return switch (self) {
.err => "error",
.warn => "warn",
.info => "info",
.debug => "debug",
};
}
pub fn fromDb(text: []const u8) ?LogLevel {
if (std.mem.eql(u8, text, "error")) return .err;
if (std.mem.eql(u8, text, "warn")) return .warn;
if (std.mem.eql(u8, text, "info")) return .info;
if (std.mem.eql(u8, text, "debug")) return .debug;
return null;
}
};
pub const LogOutput = enum {
stderr,
syslog,
file,
pub fn toDb(self: LogOutput) []const u8 {
return switch (self) {
.stderr => "stderr",
.syslog => "syslog",
.file => "file",
};
}
pub fn fromDb(text: []const u8) ?LogOutput {
if (std.mem.eql(u8, text, "stderr")) return .stderr;
if (std.mem.eql(u8, text, "syslog")) return .syslog;
if (std.mem.eql(u8, text, "file")) return .file;
return null;
}
};
pub const Logging = struct {
level: LogLevel = .info,
retention_days: u16 = 30,
query_log_buffer_max: u32 = 10000,
hide_domains: bool = false,
hide_client_ips: bool = false,
output: LogOutput = .stderr,
file_path: []const u8 = "/var/log/nxdns/nxdns.log",
max_size_mb: u32 = 50,
max_files: u8 = 5,
};
pub const Disk = struct { min_free_mb: u32 = 200, warn_free_mb: u32 = 500 };
pub const BlocklistUpdate = struct { enabled: bool = true, interval_hours: u16 = 24 };
pub const Group = struct { name: []const u8, safe_search: bool = false };
pub const UpstreamServer = struct { url: []const u8, priority: i32 = 100, enabled: bool = true };
pub const Client = struct { ip: []const u8, name: []const u8 = "", group: []const u8 = "default" };
pub const ClientPrefix = struct { prefix: []const u8, group: []const u8 = "default", priority: i32 = 100 };
pub const BlocklistSource = struct {
url: []const u8,
name: []const u8,
enabled: bool = true,
is_suggested: bool = false,
};
pub const GroupSource = struct { group: []const u8, source_url: []const u8 };
pub const RuleKind = enum {
exact,
wildcard,
pub fn toDb(self: RuleKind) []const u8 {
return switch (self) {
.exact => "exact",
.wildcard => "wildcard",
};
}
pub fn fromDb(text: []const u8) ?RuleKind {
if (std.mem.eql(u8, text, "exact")) return .exact;
if (std.mem.eql(u8, text, "wildcard")) return .wildcard;
return null;
}
};
pub const RuleAction = enum {
allow,
block,
pub fn toDb(self: RuleAction) []const u8 {
return switch (self) {
.allow => "allow",
.block => "block",
};
}
pub fn fromDb(text: []const u8) ?RuleAction {
if (std.mem.eql(u8, text, "allow")) return .allow;
if (std.mem.eql(u8, text, "block")) return .block;
return null;
}
};
pub const Rule = struct { group: []const u8, pattern: []const u8, kind: RuleKind, action: RuleAction };
/// Tag names are lowercase because ZON enum literals are; the DB text is
/// uppercase because `CHECK(rtype IN ('A','AAAA','CNAME'))` says so.
pub const RecordType = enum {
a,
aaaa,
cname,
pub fn toDb(self: RecordType) []const u8 {
return switch (self) {
.a => "A",
.aaaa => "AAAA",
.cname => "CNAME",
};
}
pub fn fromDb(text: []const u8) ?RecordType {
if (std.mem.eql(u8, text, "A")) return .a;
if (std.mem.eql(u8, text, "AAAA")) return .aaaa;
if (std.mem.eql(u8, text, "CNAME")) return .cname;
return null;
}
};
pub const LocalRecord = struct { name: []const u8, rtype: RecordType, value: []const u8, ttl: u32 = 300 };
pub const ForwardZone = struct { zone: []const u8, resolver: []const u8 };
// ---------------------------------------------------------------------------
// Unit conversions (S2.4)
// ---------------------------------------------------------------------------
/// Fails the build unless `FieldType`'s maximum times `factor` fits `Dest`.
/// Overflow is made impossible by the types rather than checked at runtime,
/// which is why none of the conversions below can return an error.
pub fn assertFits(comptime FieldType: type, comptime factor: comptime_int, comptime Dest: type) void {
if (@as(u128, std.math.maxInt(FieldType)) * factor > @as(u128, std.math.maxInt(Dest))) {
@compileError("unit conversion overflows " ++ @typeName(Dest) ++ ": " ++
@typeName(FieldType) ++ " times the conversion factor does not fit");
}
}
comptime {
assertFits(u32, std.time.ns_per_ms, i96); // timeouts
assertFits(u16, 3600, i64); // session ttl, update interval
assertFits(u16, 86400, i64); // retention
assertFits(u32, 1024 * 1024, u64); // MiB conversions
}
pub fn connectTimeout(u: Upstream) std.Io.Duration {
return .{ .nanoseconds = @as(i96, u.connect_timeout_ms) * std.time.ns_per_ms };
}
pub fn readTimeout(u: Upstream) std.Io.Duration {
return .{ .nanoseconds = @as(i96, u.read_timeout_ms) * std.time.ns_per_ms };
}
pub fn totalTimeout(u: Upstream) std.Io.Duration {
return .{ .nanoseconds = @as(i96, u.total_timeout_ms) * std.time.ns_per_ms };
}
pub fn sessionTtlSeconds(w: Web) i64 {
return @as(i64, w.session_ttl_hours) * 3600;
}
pub fn retentionSeconds(l: Logging) i64 {
return @as(i64, l.retention_days) * 86400;
}
pub fn maxLogBytes(l: Logging) u64 {
return @as(u64, l.max_size_mb) * 1024 * 1024;
}
pub fn minFreeBytes(d: Disk) u64 {
return @as(u64, d.min_free_mb) * 1024 * 1024;
}
pub fn warnFreeBytes(d: Disk) u64 {
return @as(u64, d.warn_free_mb) * 1024 * 1024;
}
pub fn updateIntervalSeconds(b: BlocklistUpdate) i64 {
return @as(i64, b.interval_hours) * 3600;
}
// ---------------------------------------------------------------------------
// settings(key, value) bridge (S2.3)
// ---------------------------------------------------------------------------
pub const SettingPair = struct { key: []const u8, value: []const u8 };
pub const SettingsError = error{ BadSettingValue, OutOfMemory };
/// The scalar sections are exactly the `Config` fields whose type is a struct;
/// the collections are slices. Deriving the list this way means a new section
/// joins the settings mapping automatically and cannot drift out of it.
fn isScalarSection(comptime T: type) bool {
return @typeInfo(T) == .@"struct";
}
/// `web.password` is operator input, never a settings row: it is hashed into
/// `web.password_hash` at import time and discarded (S2.5).
fn isSkipped(comptime section: []const u8, comptime field: []const u8) bool {
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
}
fn encodeValue(comptime T: type, value: T, gpa: Allocator) error{OutOfMemory}![]u8 {
return switch (@typeInfo(T)) {
.bool => try gpa.dupe(u8, if (value) "true" else "false"),
.int => try std.fmt.allocPrint(gpa, "{d}", .{value}),
.@"enum" => try gpa.dupe(u8, value.toDb()),
.pointer => try gpa.dupe(u8, value),
else => @compileError("unsupported setting field type " ++ @typeName(T)),
};
}
/// Decoding an integer uses the field's declared type, so a stored value out of
/// that range is `error.BadSettingValue` and never a truncating cast.
fn decodeValue(comptime T: type, text: []const u8) error{BadSettingValue}!T {
return switch (@typeInfo(T)) {
.bool => if (std.mem.eql(u8, text, "true"))
true
else if (std.mem.eql(u8, text, "false"))
false
else
error.BadSettingValue,
.int => std.fmt.parseInt(T, text, 10) catch error.BadSettingValue,
.@"enum" => T.fromDb(text) orelse error.BadSettingValue,
.pointer => text,
else => @compileError("unsupported setting field type " ++ @typeName(T)),
};
}
/// Frees the `value` of every pair. Keys are comptime strings and are never
/// freed.
pub fn freeSettings(gpa: Allocator, pairs: []const SettingPair) void {
for (pairs) |pair| gpa.free(pair.value);
}
/// Writes every scalar field of `cfg` as a key/value pair into `out`. Keys are
/// comptime strings (never freed); values are allocated from `gpa` and belong
/// to the caller, which frees them with `freeSettings`. On failure nothing this
/// call appended survives.
pub fn toSettings(cfg: Config, gpa: Allocator, out: *std.ArrayList(SettingPair)) error{OutOfMemory}!void {
const start = out.items.len;
errdefer {
freeSettings(gpa, out.items[start..]);
out.shrinkRetainingCapacity(start);
}
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) {
const section = @field(cfg, section_field.name);
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
const value = try encodeValue(field.type, @field(section, field.name), gpa);
errdefer gpa.free(value);
try out.append(gpa, .{ .key = section_field.name ++ "." ++ field.name, .value = value });
}
}
}
}
}
/// Applies `pairs` onto `cfg`, which the caller has initialized to `.{}`.
/// An absent key keeps the default — that is how a migration adds a setting
/// with no data step. An unknown key is logged at `warn` and counted in
/// `unknown_keys`; it is never an error, because downgrading a binary must not
/// brick a config database.
///
/// String values are borrowed from `pairs`, so `cfg` lives no longer than the
/// storage the pairs point into.
pub fn fromSettings(pairs: []const SettingPair, cfg: *Config, unknown_keys: *usize) SettingsError!void {
for (pairs) |pair| {
var matched = false;
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) {
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
if (std.mem.eql(u8, pair.key, section_field.name ++ "." ++ field.name)) {
@field(@field(cfg, section_field.name), field.name) =
try decodeValue(field.type, pair.value);
matched = true;
}
}
}
}
}
if (!matched) {
unknown_keys.* += 1;
std.log.warn("unknown settings key '{s}' ignored", .{pair.key});
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const testing = std.testing;
/// Every key `toSettings` produces on a default `Config`, sorted. A field added
/// without updating this list breaks the test below, which is the point.
const expected_keys = [_][]const u8{
"blocking.response",
"blocking.ttl",
"blocklist_update.enabled",
"blocklist_update.interval_hours",
"cache.negative_ttl_max",
"cache.size",
"disk.min_free_mb",
"disk.warn_free_mb",
"dns.bind_ipv4",
"dns.bind_ipv6",
"dns.port",
"dns.rate_limit",
"dns.rate_window_seconds",
"doh_server.bind",
"doh_server.cert_path",
"doh_server.enabled",
"doh_server.key_path",
"doh_server.port",
"dot_server.bind",
"dot_server.cert_path",
"dot_server.enabled",
"dot_server.key_path",
"dot_server.port",
"edns.ecs_mode",
"logging.file_path",
"logging.hide_client_ips",
"logging.hide_domains",
"logging.level",
"logging.max_files",
"logging.max_size_mb",
"logging.output",
"logging.query_log_buffer_max",
"logging.retention_days",
"runtime.io_backend",
"upstream.connect_timeout_ms",
"upstream.read_timeout_ms",
"upstream.total_timeout_ms",
"web.api_rate_limit_per_min",
"web.bind",
"web.enabled",
"web.password_hash",
"web.port",
"web.session_ttl_hours",
"web.sse_max_connections_per_ip",
};
fn lessThanKey(_: void, a: SettingPair, b: SettingPair) bool {
return std.mem.lessThan(u8, a.key, b.key);
}
test "toSettings on a default config produces exactly the expected key list" {
const gpa = testing.allocator;
var pairs: std.ArrayList(SettingPair) = .empty;
defer {
freeSettings(gpa, pairs.items);
pairs.deinit(gpa);
}
try toSettings(.{}, gpa, &pairs);
std.mem.sort(SettingPair, pairs.items, {}, lessThanKey);
try testing.expectEqual(expected_keys.len, pairs.items.len);
for (expected_keys, pairs.items) |expected, pair| {
try testing.expectEqualStrings(expected, pair.key);
}
}
test "toSettings never emits web.password" {
const gpa = testing.allocator;
var pairs: std.ArrayList(SettingPair) = .empty;
defer {
freeSettings(gpa, pairs.items);
pairs.deinit(gpa);
}
try toSettings(.{ .web = .{ .password = "hunter2" } }, gpa, &pairs);
for (pairs.items) |pair| {
try testing.expect(!std.mem.eql(u8, pair.key, "web.password"));
}
}
test "toSettings and fromSettings round-trip a non-default config" {
const gpa = testing.allocator;
const original: Config = .{
.runtime = .{ .io_backend = .evented },
.upstream = .{ .connect_timeout_ms = 111, .read_timeout_ms = 222, .total_timeout_ms = 333 },
.dns = .{
.bind_ipv4 = "127.0.0.1",
.bind_ipv6 = "::1",
.port = 5353,
.rate_limit = 7,
.rate_window_seconds = 11,
},
.blocking = .{ .response = .nxdomain, .ttl = 13 },
.cache = .{ .size = 17, .negative_ttl_max = 19 },
.web = .{
.enabled = false,
.bind = "10.0.0.1",
.port = 9090,
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def",
.session_ttl_hours = 23,
.api_rate_limit_per_min = 29,
.sse_max_connections_per_ip = 31,
},
.doh_server = .{
.enabled = true,
.bind = "10.0.0.2",
.port = 4443,
.cert_path = "/a/cert.pem",
.key_path = "/a/key.pem",
},
.dot_server = .{
.enabled = true,
.bind = "10.0.0.3",
.port = 8853,
.cert_path = "/b/cert.pem",
.key_path = "/b/key.pem",
},
.edns = .{ .ecs_mode = .forward },
.logging = .{
.level = .err,
.retention_days = 41,
.query_log_buffer_max = 43,
.hide_domains = true,
.hide_client_ips = true,
.output = .file,
.file_path = "/var/log/x.log",
.max_size_mb = 47,
.max_files = 53,
},
.disk = .{ .min_free_mb = 59, .warn_free_mb = 61 },
.blocklist_update = .{ .enabled = false, .interval_hours = 67 },
};
var pairs: std.ArrayList(SettingPair) = .empty;
defer {
freeSettings(gpa, pairs.items);
pairs.deinit(gpa);
}
try toSettings(original, gpa, &pairs);
var restored: Config = .{};
var unknown: usize = 0;
try fromSettings(pairs.items, &restored, &unknown);
try testing.expectEqual(@as(usize, 0), unknown);
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) {
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
const a = @field(@field(original, section_field.name), field.name);
const b = @field(@field(restored, section_field.name), field.name);
if (comptime @typeInfo(field.type) == .pointer) {
try testing.expectEqualStrings(a, b);
} else {
try testing.expectEqual(a, b);
}
}
}
}
}
}
test "an unknown settings key is counted and not an error" {
var cfg: Config = .{};
var unknown: usize = 0;
const pairs = [_]SettingPair{
.{ .key = "dns.port", .value = "5300" },
.{ .key = "future.setting", .value = "whatever" },
.{ .key = "web.password", .value = "never a row" },
};
try fromSettings(&pairs, &cfg, &unknown);
try testing.expectEqual(@as(u16, 5300), cfg.dns.port);
// `web.password` is skipped in both directions, so it counts as unknown.
try testing.expectEqual(@as(usize, 2), unknown);
}
test "a malformed settings value is BadSettingValue" {
var cfg: Config = .{};
var unknown: usize = 0;
const bad_int = [_]SettingPair{.{ .key = "dns.port", .value = "not a number" }};
try testing.expectError(error.BadSettingValue, fromSettings(&bad_int, &cfg, &unknown));
// 70000 does not fit u16: an out-of-range value is refused, not truncated.
const out_of_range = [_]SettingPair{.{ .key = "dns.port", .value = "70000" }};
try testing.expectError(error.BadSettingValue, fromSettings(&out_of_range, &cfg, &unknown));
const bad_bool = [_]SettingPair{.{ .key = "web.enabled", .value = "yes" }};
try testing.expectError(error.BadSettingValue, fromSettings(&bad_bool, &cfg, &unknown));
const bad_enum = [_]SettingPair{.{ .key = "logging.level", .value = "verbose" }};
try testing.expectError(error.BadSettingValue, fromSettings(&bad_enum, &cfg, &unknown));
}
test "an absent key keeps the default" {
var cfg: Config = .{};
var unknown: usize = 0;
const pairs = [_]SettingPair{.{ .key = "dns.port", .value = "5300" }};
try fromSettings(&pairs, &cfg, &unknown);
try testing.expectEqual(@as(u32, 1000), cfg.dns.rate_limit);
try testing.expectEqual(LogLevel.info, cfg.logging.level);
}
test "LogLevel.err encodes as error and decodes back" {
try testing.expectEqualStrings("error", LogLevel.err.toDb());
try testing.expectEqual(LogLevel.err, LogLevel.fromDb("error").?);
try testing.expect(LogLevel.fromDb("err") == null);
}
fn expectEnumRoundTrip(comptime E: type) !void {
inline for (@typeInfo(E).@"enum".fields) |field| {
const value: E = @enumFromInt(field.value);
try testing.expectEqual(value, E.fromDb(value.toDb()).?);
}
try testing.expect(E.fromDb("nonsense") == null);
try testing.expect(E.fromDb("") == null);
}
test "every toDb and fromDb enum pair round-trips over all tags" {
try expectEnumRoundTrip(IoBackend);
try expectEnumRoundTrip(BlockResponse);
try expectEnumRoundTrip(EcsMode);
try expectEnumRoundTrip(LogLevel);
try expectEnumRoundTrip(LogOutput);
try expectEnumRoundTrip(RuleKind);
try expectEnumRoundTrip(RuleAction);
try expectEnumRoundTrip(RecordType);
}
test "RecordType stores the uppercase DDL spelling" {
try testing.expectEqualStrings("A", RecordType.a.toDb());
try testing.expectEqualStrings("AAAA", RecordType.aaaa.toDb());
try testing.expectEqualStrings("CNAME", RecordType.cname.toDb());
try testing.expect(RecordType.fromDb("a") == null);
}
test "unit conversions" {
try testing.expectEqual(
@as(i96, 2000) * std.time.ns_per_ms,
connectTimeout(.{}).nanoseconds,
);
try testing.expectEqual(
@as(i96, 3000) * std.time.ns_per_ms,
readTimeout(.{}).nanoseconds,
);
try testing.expectEqual(
@as(i96, 5000) * std.time.ns_per_ms,
totalTimeout(.{}).nanoseconds,
);
try testing.expectEqual(@as(i64, 24 * 3600), sessionTtlSeconds(.{}));
try testing.expectEqual(@as(i64, 30 * 86400), retentionSeconds(.{}));
try testing.expectEqual(@as(u64, 50 * 1024 * 1024), maxLogBytes(.{}));
try testing.expectEqual(@as(u64, 200 * 1024 * 1024), minFreeBytes(.{}));
try testing.expectEqual(@as(u64, 500 * 1024 * 1024), warnFreeBytes(.{}));
try testing.expectEqual(@as(i64, 24 * 3600), updateIntervalSeconds(.{}));
}
test "unit conversions at the field maximum do not overflow" {
const max_upstream: Upstream = .{
.connect_timeout_ms = std.math.maxInt(u32),
.read_timeout_ms = std.math.maxInt(u32),
.total_timeout_ms = std.math.maxInt(u32),
};
try testing.expectEqual(
@as(i96, std.math.maxInt(u32)) * std.time.ns_per_ms,
connectTimeout(max_upstream).nanoseconds,
);
try testing.expectEqual(
@as(i64, std.math.maxInt(u16)) * 3600,
sessionTtlSeconds(.{ .session_ttl_hours = std.math.maxInt(u16) }),
);
try testing.expectEqual(
@as(i64, std.math.maxInt(u16)) * 86400,
retentionSeconds(.{ .retention_days = std.math.maxInt(u16) }),
);
try testing.expectEqual(
@as(u64, std.math.maxInt(u32)) * 1024 * 1024,
maxLogBytes(.{ .max_size_mb = std.math.maxInt(u32) }),
);
}