//! The pure configuration validator.
//!
//! The controlling requirement: this validator must reject everything the
//! `config.db` schema would reject. An import that passes validation and then
//! dies on a `Constraint` mid-transaction hands the operator a SQLite message
//! instead of a line number, so every `UNIQUE` and every foreign key in
//! PLAN §11.2 has a check here.
//!
//! Pure: no `std.Io` value is a parameter anywhere, no SQLite call, no clock.
//! The only `std.Io` type used is `std.Io.Writer`, for rendering diagnostics.
//! The allocator exists for diagnostic text and scratch bookkeeping alone. The
//! `storage/logger.zig` import is a comptime one — `query_log_buffer_max` is
//! derived from `@sizeOf(logger.Entry)`, because the bound this file enforces
//! on the queue is a bound on bytes and only the logger knows how wide a queued
//! entry is. Nothing in this file calls into storage.
//!
//! Parsers are reused, never reimplemented: `transport.Endpoint.parse` for
//! upstream URLs, `NetAddress.parse` / `Prefix.parse` for addresses, and
//! `dns.name.fromText` for every domain-shaped string.
//!
//! **No url is ever printed whole.** Every diagnostic that names a url renders
//! it through `safe_url.redact`, which drops the userinfo, the query and the
//! fragment. A diagnostic goes to stderr from `nxdns run` and `nxdns check`,
//! which under systemd is journald, and a url an operator supplied can carry a
//! password in its userinfo or an api key in its query. That the validator is
//! *rejecting* the url changes nothing: the line is written either way, and
//! `error.BadUrl` inputs are exactly the ones a parser cannot help redact —
//! which is why `redact` scans rather than parses. Do not add a tenth
//! diagnostic that prints `{s}` of a url.
//!
//! **No operator-supplied text is ever quoted by a format string.** A group
//! name, a client address, a rule pattern and a blocklist source name all come
//! out of the configuration file or out of a database row, and none of the four
//! is restricted to a printable character set on the way in. A `\n` in one of
//! them forges a whole diagnostic line and a `'` in one of them closes the
//! quotes a format string put around it, which forges a field. Both reach an
//! operator: `cli.zig` prints these messages to stdout, and
//! `web/handlers/mutations.zig` returns `Problem.message` as the body of a 400.
//! So every such value prints as `{f}` of `safe_url.quoteText`, which writes its
//! own quotes and escapes what would close them — the quote and its escape stay
//! in one place, where they cannot drift apart. Do not write `'{s}'` in this
//! file.

const std = @import("std");
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;

const model = @import("model.zig");
const address = @import("../platform/address.zig");
const dns_name = @import("../dns/name.zig");
const limits = @import("limits.zig");
const logger = @import("../storage/logger.zig");
const regex = @import("../filter/regex.zig");
const safe_url = @import("../safe_url.zig");
const pool = @import("../upstream/pool.zig");
const transport = @import("../upstream/transport.zig");

const Config = model.Config;
const NetAddress = address.NetAddress;
const Prefix = address.Prefix;

/// Every way this validator can say "the operator's configuration is wrong",
/// and nothing else. `config/faults.zig` consumes the set whole and calls every
/// member a configuration fault, so a member that is not a verdict on the
/// configuration would make that classification a lie.
///
/// `OutOfMemory` is therefore not here. The validator allocates diagnostic text
/// and can fail to, but a failed allocation says nothing about the file — it
/// says the report is incomplete — so it travels in `Error` below and exits 1
/// like every other resource failure.
pub const ValidateError = error{
    NoUpstreams,
    TooManyUpstreams,
    BadUpstreamUrl,
    UpstreamHostNotIpLiteral,
    DuplicateUpstreamUrl,
    BadTlsName,
    TlsNameOnNonTlsUpstream,
    MissingDefaultGroup,
    DuplicateGroupName,
    UnknownGroup,
    EmptyGroupName,
    GroupNameTooLong,
    BadClientIp,
    DuplicateClientIp,
    BadClientPrefix,
    DuplicateClientPrefix,
    BadSourceUrl,
    DuplicateSourceUrl,
    EmptySourceName,
    SourceNameTooLong,
    UnknownSource,
    DuplicateGroupSource,
    BadRulePattern,
    BadLocalRecordName,
    BadLocalRecordValue,
    DuplicateLocalRecord,
    BadForwardZone,
    DuplicateForwardZone,
    BadResolverUrl,
    BadPort,
    BadTimeout,
    BadTtl,
    BadCacheSize,
    BadRetention,
    BadFlushInterval,
    BadLogRotation,
    BadDiskThresholds,
    BadRateLimit,
    BadBindAddress,
    BadTrustedProxy,
    MissingCertPath,
    MissingKeyPath,
    MissingLogPath,
    PasswordAndHashBothSet,
    EmptyWebPassword,
};

/// What `validate` returns: a verdict on the configuration, or the allocation
/// failure that stopped it reaching one. Callers that must tell the two apart —
/// every one of them, because they map to different exit codes — match
/// `error.OutOfMemory` first and treat the rest as findings.
pub const Error = ValidateError || Allocator.Error;

/// What a `Problem` can carry: every semantic `ValidateError`, plus the one
/// syntactic failure (`ParseZon`) that `config/import.zig` routes through the
/// same channel so a syntax error's line/column reaches the operator's output,
/// plus the warnings — which are never returned by `validate` and so are not
/// `ValidateError` members.
/// Wider than `ValidateError`: the diagnostic channel also carries the problems
/// found before the validator ever sees a `Config` — the ZON parse, the managed
/// file that would not open (`config/loader.zig`), the file above the size limit
/// — and the one found after it, an import whose diff would delete rows. The
/// validator itself records only `ValidateError` members, which is what makes
/// `validate`'s `@errorCast` of its own findings checked-safe.
pub const ProblemError = ValidateError || error{
    ParseZon,
    SourceInNoGroup,
    ManagedConfigUnreadable,
    ConfigTooLarge,
    DestructiveImport,
};

/// `.fail` rejects the configuration and is what an exit code is computed from.
/// `.warn` reports something legal that is almost certainly not what the
/// operator meant; it is printed and it never changes an exit code.
pub const Severity = enum { fail, warn };

pub const Problem = struct {
    /// Dotted path into the config, e.g. "upstreams[2].url". Owned by `Diagnostics`.
    path: []const u8,
    /// Human-readable, e.g. "unknown group 'kids'". Owned by `Diagnostics`.
    message: []const u8,
    err: ProblemError,
    severity: Severity,
};

pub const Diagnostics = struct {
    gpa: Allocator,
    problems: std.ArrayList(Problem),

    pub fn init(gpa: Allocator) Diagnostics {
        return .{ .gpa = gpa, .problems = .empty };
    }

    pub fn deinit(self: *Diagnostics) void {
        for (self.problems.items) |problem| {
            self.gpa.free(problem.path);
            self.gpa.free(problem.message);
        }
        self.problems.deinit(self.gpa);
    }

    /// Records a failure. Both the path and the message are formatted, because a
    /// path carries the offending element's index ("clients[1].ip") and a
    /// message carries the offending value. `Diagnostics` owns both strings from
    /// here on.
    pub fn add(
        self: *Diagnostics,
        err: ProblemError,
        comptime path_fmt: []const u8,
        path_args: anytype,
        comptime message_fmt: []const u8,
        message_args: anytype,
    ) error{OutOfMemory}!void {
        return self.record(.fail, err, path_fmt, path_args, message_fmt, message_args);
    }

    /// Records a warning: the same rendering, and no effect on any exit code.
    pub fn addWarning(
        self: *Diagnostics,
        err: ProblemError,
        comptime path_fmt: []const u8,
        path_args: anytype,
        comptime message_fmt: []const u8,
        message_args: anytype,
    ) error{OutOfMemory}!void {
        return self.record(.warn, err, path_fmt, path_args, message_fmt, message_args);
    }

    fn record(
        self: *Diagnostics,
        severity: Severity,
        err: ProblemError,
        comptime path_fmt: []const u8,
        path_args: anytype,
        comptime message_fmt: []const u8,
        message_args: anytype,
    ) error{OutOfMemory}!void {
        const path = try std.fmt.allocPrint(self.gpa, path_fmt, path_args);
        errdefer self.gpa.free(path);
        const message = try std.fmt.allocPrint(self.gpa, message_fmt, message_args);
        errdefer self.gpa.free(message);
        try self.problems.append(self.gpa, .{
            .path = path,
            .message = message,
            .err = err,
            .severity = severity,
        });
    }

    /// How many problems reject the configuration. This is the number an exit
    /// code is computed from; `warningCount` is reported and ignored.
    pub fn failureCount(self: *const Diagnostics) usize {
        return self.count(.fail);
    }

    pub fn warningCount(self: *const Diagnostics) usize {
        return self.count(.warn);
    }

    fn count(self: *const Diagnostics, severity: Severity) usize {
        var total: usize = 0;
        for (self.problems.items) |problem| {
            if (problem.severity == severity) total += 1;
        }
        return total;
    }

    /// The first problem that rejects the configuration, for a caller that
    /// reports one line rather than the whole list. A warning is never it.
    pub fn firstFailure(self: *const Diagnostics) ?Problem {
        for (self.problems.items) |problem| {
            if (problem.severity == .fail) return problem;
        }
        return null;
    }

    /// One "FAIL path: message" or "WARN path: message" line per problem, in the
    /// order they were recorded.
    pub fn writeAll(self: *const Diagnostics, w: *Writer) Writer.Error!void {
        for (self.problems.items) |problem| {
            const label = switch (problem.severity) {
                .fail => "FAIL",
                .warn => "WARN",
            };
            try w.print("{s} {s}: {s}\n", .{ label, problem.path, problem.message });
        }
    }
};

// ---------------------------------------------------------------------------
// Forward-zone resolvers
// ---------------------------------------------------------------------------

pub const ResolverScheme = enum { udp, tcp };

pub const Resolver = struct {
    scheme: ResolverScheme,
    addr: NetAddress,
    port: u16,
};

pub const ResolverError = error{ UnsupportedScheme, BadHost, BadPort, BadUrl };

/// `udp://192.168.1.1:53`, `tcp://[fd00::1]:53`. PLAN §6.5 permits plain
/// transports for local infrastructure, which is why this exists at all;
/// `transport.Endpoint.parse` rejects both schemes by design.
///
/// The host must be an IP literal: a forward zone points at a box on the LAN,
/// and resolving the resolver's own name is a bootstrap problem nxdns declines
/// to have. The port is mandatory for the same reason a typo must not silently
/// become 53. Phase 5's `local/forward_zones.zig` imports this rather than
/// writing a second parser.
pub fn parseResolver(text: []const u8) ResolverError!Resolver {
    const udp_prefix = "udp://";
    const tcp_prefix = "tcp://";

    const scheme: ResolverScheme, const rest = if (std.mem.startsWith(u8, text, udp_prefix))
        .{ .udp, text[udp_prefix.len..] }
    else if (std.mem.startsWith(u8, text, tcp_prefix))
        .{ .tcp, text[tcp_prefix.len..] }
    else
        return error.UnsupportedScheme;

    if (std.mem.findScalar(u8, rest, '/') != null) return error.BadUrl;

    const authority = try parseAuthority(rest);
    const port = authority.port orelse return error.BadPort;
    const addr = NetAddress.parse(authority.host) catch return error.BadHost;

    return .{ .scheme = scheme, .addr = addr, .port = port };
}

// ---------------------------------------------------------------------------
// validate
// ---------------------------------------------------------------------------

/// Collects EVERY problem into `diags`, then returns the first failure's `err`.
/// Checks run in a fixed order — the scalar sections in `Config` declaration
/// order, then the collections in `Config` declaration order — so the returned
/// error is deterministic for a given config.
///
/// A configuration whose only problems are warnings is a valid configuration:
/// this returns nothing, and the warnings are in `diags` for the caller to
/// print.
pub fn validate(cfg: Config, diags: *Diagnostics) Error!void {
    var arena_state: std.heap.ArenaAllocator = .init(diags.gpa);
    defer arena_state.deinit();
    const scratch = arena_state.allocator();

    // The caller may hand in a `Diagnostics` that already holds problems from
    // another layer (import's ParseZon lines), so the returned error is the
    // first failure THIS call recorded — always a `ValidateError`, which makes
    // the cast checked-safe.
    const first = diags.problems.items.len;
    try checkScalars(cfg, diags);
    try checkCollections(cfg, diags, scratch);

    for (diags.problems.items[first..]) |problem| {
        if (problem.severity == .fail) return @errorCast(problem.err);
    }
}

const min_timeout_ms = 100;
const max_timeout_ms = 120_000;
const max_ttl_seconds = 86_400;
const max_record_ttl_seconds = 604_800;
const max_rate_window_seconds = 3_600;

/// The ceiling on every entry count nxdns allocates in full at boot: the query
/// log's ring and the DNS cache's slot array. Neither is grown on demand, so an
/// unchecked `maxInt(u32)` is a multi-terabyte allocation request during
/// startup — a `maxInt(u32)` query-log ring alone asks for about 1.8 TB. This
/// is a sanity bound, not a memory-fit guarantee: what actually fits depends on
/// the box, and nxdns does not try to know that.
const max_boot_entries = 1_000_000;

/// The ceiling on the query-log flush window. An hour of queries is already
/// more history than a crash is allowed to cost; beyond that the setting stops
/// being a batching knob and becomes a way to lose a working day of rows.
const max_flush_interval_s = 3_600;

fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
    const up = cfg.upstream;
    try checkTimeout(diags, up.attempt_timeout_ms, "upstream.attempt_timeout_ms");
    try checkTimeout(diags, up.read_timeout_ms, "upstream.read_timeout_ms");
    try checkTimeout(diags, up.total_timeout_ms, "upstream.total_timeout_ms");
    // The only cross-check that relates two knobs of one subsystem: the pool
    // races one attempt against `attempt` and the whole failover loop against
    // `total`, so an attempt budget above the total one can never be reached.
    // `read_timeout_ms` bounds the forward-zone client's whole exchange —
    // UDP attempt, TC=1 fallback and TCP retry under one budget — and is
    // deliberately unrelated to both.
    if (up.attempt_timeout_ms > up.total_timeout_ms) {
        try diags.add(
            error.BadTimeout,
            "upstream.attempt_timeout_ms",
            .{},
            "attempt budget {d}ms is above the total budget {d}ms",
            .{ up.attempt_timeout_ms, up.total_timeout_ms },
        );
    }

    try checkBind(diags, cfg.dns.bind_ipv4, "dns.bind_ipv4", .ip4);
    try checkBind(diags, cfg.dns.bind_ipv6, "dns.bind_ipv6", .ip6);
    try checkPort(diags, cfg.dns.port, "dns.port");
    if (cfg.dns.rate_limit < 1) {
        try diags.add(error.BadRateLimit, "dns.rate_limit", .{}, "must be at least 1", .{});
    }
    if (cfg.dns.rate_window_seconds < 1 or cfg.dns.rate_window_seconds > max_rate_window_seconds) {
        try diags.add(
            error.BadRateLimit,
            "dns.rate_window_seconds",
            .{},
            "must be 1-{d}, got {d}",
            .{ max_rate_window_seconds, cfg.dns.rate_window_seconds },
        );
    }

    if (cfg.blocking.ttl > max_ttl_seconds) {
        try diags.add(
            error.BadTtl,
            "blocking.ttl",
            .{},
            "must be at most {d}, got {d}",
            .{ max_ttl_seconds, cfg.blocking.ttl },
        );
    }

    if (cfg.cache.size < 1) {
        try diags.add(error.BadCacheSize, "cache.size", .{}, "must be at least 1", .{});
    }
    if (cfg.cache.size > max_boot_entries) {
        try diags.add(
            error.BadCacheSize,
            "cache.size",
            .{},
            "must be at most {d}, got {d}",
            .{ max_boot_entries, cfg.cache.size },
        );
    }

    if (cfg.cache.negative_ttl_max > max_ttl_seconds) {
        try diags.add(
            error.BadTtl,
            "cache.negative_ttl_max",
            .{},
            "must be at most {d}, got {d}",
            .{ max_ttl_seconds, cfg.cache.negative_ttl_max },
        );
    }

    try checkBind(diags, cfg.web.bind, "web.bind", .any);
    try checkPort(diags, cfg.web.port, "web.port");
    // Both fields are optional, and absence is the third state: a file that
    // states neither keeps the stored hash. So the test is on presence, not on
    // length.
    if (cfg.web.password != null and cfg.web.password_hash != null) {
        try diags.add(
            error.PasswordAndHashBothSet,
            "web.password",
            .{},
            "password and password_hash are both set; ambiguity in a security setting is refused",
            .{},
        );
    }
    // A present-but-empty password would hash the empty string into a non-empty
    // PHC — authentication on — while every login with an empty password is
    // refused: authentication on and unreachable. The remedy is named, because
    // the operator who wrote this meant one of two other things.
    if (cfg.web.password) |password| {
        if (password.len == 0) {
            try diags.add(
                error.EmptyWebPassword,
                "web.password",
                .{},
                "password is set to the empty string; omit the field to keep the stored password, " ++
                    "or set password_hash = \"\" to disable authentication",
                .{},
            );
        }
    }
    // A session TTL is a TTL; `BadTtl` is its bucket.
    if (cfg.web.session_ttl_hours < 1) {
        try diags.add(error.BadTtl, "web.session_ttl_hours", .{}, "must be at least 1", .{});
    }
    if (cfg.web.api_rate_limit_per_min < 1) {
        try diags.add(error.BadRateLimit, "web.api_rate_limit_per_min", .{}, "must be at least 1", .{});
    }
    if (cfg.web.sse_max_connections_per_ip < 1) {
        try diags.add(error.BadRateLimit, "web.sse_max_connections_per_ip", .{}, "must be at least 1", .{});
    }
    try checkTrustedProxies(diags, cfg.web.trusted_proxies);

    try checkTlsEndpoint(diags, cfg.doh_server, "doh_server");
    try checkTlsEndpoint(diags, cfg.dot_server, "dot_server");

    if (cfg.logging.retention_days < 1) {
        try diags.add(error.BadRetention, "logging.retention_days", .{}, "must be at least 1", .{});
    }
    if (cfg.logging.query_log_buffer_max < 1) {
        try diags.add(error.BadRetention, "logging.query_log_buffer_max", .{}, "must be at least 1", .{});
    }
    // Its own ceiling, not `max_boot_entries`: a queued `logger.Entry` carries
    // every provenance field by value, so the queue's cost is bytes rather than
    // entries and the bound follows the width of the entry.
    if (cfg.logging.query_log_buffer_max > logger.query_log_buffer_max) {
        try diags.add(
            error.BadRetention,
            "logging.query_log_buffer_max",
            .{},
            "must be at most {d}, got {d}",
            .{ logger.query_log_buffer_max, cfg.logging.query_log_buffer_max },
        );
    }
    // No floor: 0 is the documented "do not wait" setting, not a mistake.
    if (cfg.logging.query_log_flush_interval_s > max_flush_interval_s) {
        try diags.add(
            error.BadFlushInterval,
            "logging.query_log_flush_interval_s",
            .{},
            "must be at most {d}, got {d}",
            .{ max_flush_interval_s, cfg.logging.query_log_flush_interval_s },
        );
    }
    if (cfg.logging.max_size_mb < 1) {
        try diags.add(error.BadLogRotation, "logging.max_size_mb", .{}, "must be at least 1", .{});
    }
    if (cfg.logging.max_files < 1) {
        try diags.add(error.BadLogRotation, "logging.max_files", .{}, "must be at least 1", .{});
    }
    if (cfg.logging.output == .file and
        (cfg.logging.file_path.len == 0 or cfg.logging.file_path[0] != '/'))
    {
        try diags.add(
            error.MissingLogPath,
            "logging.file_path",
            .{},
            "output is 'file' so file_path must be a non-empty absolute path, got {f}",
            .{safe_url.quoteText(cfg.logging.file_path)},
        );
    }

    if (cfg.disk.min_free_mb < 1 or cfg.disk.warn_free_mb < 1) {
        try diags.add(
            error.BadDiskThresholds,
            "disk.min_free_mb",
            .{},
            "both thresholds must be at least 1, got min {d} and warn {d}",
            .{ cfg.disk.min_free_mb, cfg.disk.warn_free_mb },
        );
    } else if (cfg.disk.min_free_mb > cfg.disk.warn_free_mb) {
        try diags.add(
            error.BadDiskThresholds,
            "disk.min_free_mb",
            .{},
            "min_free_mb {d} is above warn_free_mb {d}",
            .{ cfg.disk.min_free_mb, cfg.disk.warn_free_mb },
        );
    }

    // An update interval is a duration in hours, like the session TTL above.
    if (cfg.blocklist_update.interval_hours < 1) {
        try diags.add(error.BadTtl, "blocklist_update.interval_hours", .{}, "must be at least 1", .{});
    }
}

fn checkPort(diags: *Diagnostics, port: u16, comptime path: []const u8) error{OutOfMemory}!void {
    if (port == 0) {
        try diags.add(error.BadPort, path, .{}, "must be 1-65535, got 0", .{});
    }
}

fn checkTimeout(diags: *Diagnostics, value: u32, comptime path: []const u8) error{OutOfMemory}!void {
    if (value < min_timeout_ms or value > max_timeout_ms) {
        try diags.add(
            error.BadTimeout,
            path,
            .{},
            "must be {d}-{d} ms, got {d}",
            .{ min_timeout_ms, max_timeout_ms, value },
        );
    }
}

const BindFamily = enum { ip4, ip6, any };

/// `dns.bind_ipv4` and `dns.bind_ipv6` each name one socket of the dual-stack
/// pair, so each must be a literal of its own family: an IPv4 wildcard in
/// `bind_ipv6` would bind IPv4 as the "v6" socket and make the real IPv4 bind
/// fail with AddressInUse — the IPv6 service silently disappears.
fn checkBind(
    diags: *Diagnostics,
    text: []const u8,
    comptime path: []const u8,
    comptime family: BindFamily,
) error{OutOfMemory}!void {
    const addr = NetAddress.parse(text) catch {
        try diags.add(error.BadBindAddress, path, .{}, "{f} is not an IP address", .{safe_url.quoteText(text)});
        return;
    };
    switch (family) {
        .ip4 => if (std.meta.activeTag(addr) != NetAddress.ip4) {
            try diags.add(error.BadBindAddress, path, .{}, "{f} is not an IPv4 address", .{safe_url.quoteText(text)});
        },
        .ip6 => if (std.meta.activeTag(addr) != NetAddress.ip6) {
            try diags.add(error.BadBindAddress, path, .{}, "{f} is not an IPv6 address", .{safe_url.quoteText(text)});
        },
        .any => {},
    }
}

/// Every element must be an IP literal, because the web server compares the
/// socket peer against them byte for byte — a hostname here would silently
/// trust nothing, and an operator who wrote one would believe their proxy was
/// trusted while the API limiter kept seeing loopback.
fn checkTrustedProxies(diags: *Diagnostics, text: []const u8) error{OutOfMemory}!void {
    var it = model.trustedProxies(text);
    while (it.next()) |element| {
        _ = NetAddress.parse(element) catch {
            try diags.add(
                error.BadTrustedProxy,
                "web.trusted_proxies",
                .{},
                "{f} is not an IP address; the list is comma-separated IP literals",
                .{safe_url.quoteText(element)},
            );
        };
    }
}

fn checkTlsEndpoint(
    diags: *Diagnostics,
    endpoint: model.TlsEndpoint,
    comptime section: []const u8,
) error{OutOfMemory}!void {
    try checkBind(diags, endpoint.bind, section ++ ".bind", .any);
    try checkPort(diags, endpoint.port, section ++ ".port");
    if (!endpoint.enabled) return;
    // Readability of the files is `nxdns check`'s job, not the pure validator's.
    if (endpoint.cert_path.len == 0) {
        try diags.add(
            error.MissingCertPath,
            section ++ ".cert_path",
            .{},
            section ++ " is enabled but cert_path is empty",
            .{},
        );
    }
    if (endpoint.key_path.len == 0) {
        try diags.add(
            error.MissingKeyPath,
            section ++ ".key_path",
            .{},
            section ++ " is enabled but key_path is empty",
            .{},
        );
    }
}

/// Each key mapped to the index of the entry that introduced it. The index is
/// there for the duplicate diagnostics: a message quoting the value cannot name
/// the entry a duplicate collides with, and for a url it cannot even show the
/// collision, because `safe_url.redact` drops the component two otherwise
/// identical urls differ in.
const IndexSet = std.StringHashMapUnmanaged(usize);

/// The index of the entry that first carried `key`, or null when `key` is new.
/// `key` must outlive `set`.
fn firstSeen(
    set: *IndexSet,
    scratch: Allocator,
    key: []const u8,
    index: usize,
) error{OutOfMemory}!?usize {
    const gop = try set.getOrPut(scratch, key);
    if (gop.found_existing) return gop.value_ptr.*;
    gop.value_ptr.* = index;
    return null;
}

/// Canonical text for an address, allocated from `scratch`. RFC 5952 form for
/// IPv6, dotted decimal for IPv4 — the same text import writes, so the
/// validator sees the collision `UNIQUE` would see.
fn canonical(scratch: Allocator, value: anytype) error{OutOfMemory}![]u8 {
    // The longest form this writes is an IPv6 prefix, 45 + 4 bytes.
    var buf: [64]u8 = undefined;
    var w: Writer = .fixed(&buf);
    value.format(&w) catch unreachable;
    return scratch.dupe(u8, w.buffered());
}

/// A `tls_name` overrides SNI and certificate verification for a DoT upstream,
/// which is the only transport that needs it: DoH verifies by the url host and
/// the http client would ignore this field, so a `tls_name` there is a config
/// error rather than a setting with no effect.
fn checkTlsName(
    diags: *Diagnostics,
    server: model.UpstreamServer,
    scheme: transport.Scheme,
    index: usize,
) error{OutOfMemory}!void {
    if (server.tls_name.len == 0) return;

    if (scheme != .dot) {
        try diags.add(
            error.TlsNameOnNonTlsUpstream,
            "upstreams[{d}].tls_name",
            .{index},
            "tls_name is only for a tls:// upstream; {f} verifies by its url host",
            .{safe_url.redactQuoted(server.url)},
        );
        return;
    }

    _ = dns_name.fromText(server.tls_name) catch {
        try diags.add(
            error.BadTlsName,
            "upstreams[{d}].tls_name",
            .{index},
            "{f} is not a valid domain name",
            .{safe_url.quoteText(server.tls_name)},
        );
    };
}

/// A `tls://` upstream's host must be an IP literal, because `DotClient` refuses
/// anything else on every dial (`upstream/dot_client.zig`'s `resolveAddress`:
/// resolving an upstream's own name is a bootstrap problem nxdns declines to
/// have). Without this check a hostname `tls://` config validates clean and then
/// fails at query time as a peer fault, which reads as "the upstream is down"
/// rather than "this line is wrong".
///
/// The mirror is exact: `NetAddress.parse` is `net.IpAddress.parse(text, 0)`
/// (`platform/address.zig`), the same call the client makes, so the two cannot
/// disagree about what a literal is. Note this is not `parseResolver` above —
/// that one validates a forward zone's resolver, a different field.
///
/// A `tls://` host is a hostname often enough that the message says what to do
/// instead rather than only what is wrong.
fn checkDotHost(
    diags: *Diagnostics,
    server: model.UpstreamServer,
    endpoint: transport.Endpoint,
    index: usize,
) error{OutOfMemory}!void {
    if (endpoint.scheme != .dot) return;
    _ = NetAddress.parse(endpoint.host) catch {
        try diags.add(
            error.UpstreamHostNotIpLiteral,
            "upstreams[{d}].url",
            .{index},
            "tls:// upstreams take an IP literal host; {f} names a host nxdns will not resolve. " ++
                "Use the address and put the name in tls_name, or configure the https:// form.",
            .{safe_url.redactQuoted(server.url)},
        );
    };
}

/// The shared shape of the two label caps.
///
/// Both names are copied by value into every `query_log` row that mentions
/// them, so the cap is what keeps a pasted paragraph out of the fixed buffers
/// of `storage/logger.zig`. Bytes, not codepoints: the buffer counts bytes.
fn checkNameLength(
    diags: *Diagnostics,
    comptime fault: ValidateError,
    comptime path: []const u8,
    path_args: anytype,
    value: []const u8,
    cap: usize,
) error{OutOfMemory}!void {
    if (value.len <= cap) return;
    try diags.add(
        fault,
        path,
        path_args,
        "must be at most {d} bytes, got {d}; the name is copied into every logged query",
        .{ cap, value.len },
    );
}

fn checkCollections(cfg: Config, diags: *Diagnostics, scratch: Allocator) error{OutOfMemory}!void {
    var group_names: IndexSet = .empty;
    var has_default = false;
    for (cfg.groups, 0..) |group, i| {
        if (group.name.len == 0) {
            try diags.add(error.EmptyGroupName, "groups[{d}].name", .{i}, "group name is empty", .{});
        } else if (try firstSeen(&group_names, scratch, group.name, i)) |_| {
            try diags.add(
                error.DuplicateGroupName,
                "groups[{d}].name",
                .{i},
                "duplicate group name {f}",
                .{safe_url.quoteText(group.name)},
            );
        }
        // Independent of the chain above: an over-long name is still a name,
        // and a duplicate of one is still a duplicate.
        try checkNameLength(
            diags,
            error.GroupNameTooLong,
            "groups[{d}].name",
            .{i},
            group.name,
            limits.max_group_name_len,
        );
        if (std.mem.eql(u8, group.name, "default")) has_default = true;
    }
    if (!has_default) {
        try diags.add(
            error.MissingDefaultGroup,
            "groups",
            .{},
            "no group named 'default'; every unknown client is assigned to it",
            .{},
        );
    }

    var upstream_urls: IndexSet = .empty;
    var enabled_upstreams: usize = 0;
    for (cfg.upstreams, 0..) |server, i| {
        // The scheme decides whether `tls_name` is meaningful, so the parse
        // result is kept rather than discarded. An unparseable url reports only
        // `BadUpstreamUrl`: what its scheme would have been is unknown.
        if (transport.Endpoint.parse(server.url)) |endpoint| {
            try checkTlsName(diags, server, endpoint.scheme, i);
            try checkDotHost(diags, server, endpoint, i);
        } else |_| {
            try diags.add(
                error.BadUpstreamUrl,
                "upstreams[{d}].url",
                .{i},
                "{f} is not an https:// or tls:// endpoint",
                .{safe_url.redactQuoted(server.url)},
            );
        }
        if (try firstSeen(&upstream_urls, scratch, server.url, i)) |first| {
            try diags.add(
                error.DuplicateUpstreamUrl,
                "upstreams[{d}].url",
                .{i},
                "duplicate of upstreams[{d}]",
                .{first},
            );
        }
        if (server.enabled) enabled_upstreams += 1;
    }
    if (enabled_upstreams == 0) {
        try diags.add(
            error.NoUpstreams,
            "upstreams",
            .{},
            "at least one upstream must be enabled",
            .{},
        );
    }
    // Each enabled upstream becomes one pool entry, and the failover loop
    // tracks the entries it has spent in a fixed bitset of `Pool.max_entries`
    // bits. Without this check a config past that bound reaches an assert and
    // panics at startup, which is the wrong way to tell an operator that a
    // number is too large. Disabled upstreams are not counted: they never
    // become entries.
    if (enabled_upstreams > pool.Pool.max_entries) {
        try diags.add(
            error.TooManyUpstreams,
            "upstreams",
            .{},
            "{d} upstreams are enabled; nxdns is built for at most {d}",
            .{ enabled_upstreams, pool.Pool.max_entries },
        );
    }

    var client_ips: IndexSet = .empty;
    for (cfg.clients, 0..) |client, i| {
        if (NetAddress.parse(client.ip)) |addr| {
            const text = try canonical(scratch, addr);
            if (try firstSeen(&client_ips, scratch, text, i)) |_| {
                try diags.add(
                    error.DuplicateClientIp,
                    "clients[{d}].ip",
                    .{i},
                    "duplicate client ip {f} (canonical form {f})",
                    .{ safe_url.quoteText(client.ip), safe_url.quoteText(text) },
                );
            }
        } else |_| {
            try diags.add(
                error.BadClientIp,
                "clients[{d}].ip",
                .{i},
                "{f} is not an IP address",
                .{safe_url.quoteText(client.ip)},
            );
        }
        try checkGroupRef(diags, &group_names, client.group, "clients[{d}].group", .{i});
    }

    var client_prefixes: IndexSet = .empty;
    for (cfg.client_prefixes, 0..) |entry, i| {
        if (Prefix.parse(entry.prefix)) |prefix| {
            const text = try canonical(scratch, prefix);
            if (try firstSeen(&client_prefixes, scratch, text, i)) |_| {
                try diags.add(
                    error.DuplicateClientPrefix,
                    "client_prefixes[{d}].prefix",
                    .{i},
                    "duplicate client prefix {f} (canonical form {f})",
                    .{ safe_url.quoteText(entry.prefix), safe_url.quoteText(text) },
                );
            }
        } else |_| {
            try diags.add(
                error.BadClientPrefix,
                "client_prefixes[{d}].prefix",
                .{i},
                "{f} is not a CIDR prefix",
                .{safe_url.quoteText(entry.prefix)},
            );
        }
        try checkGroupRef(diags, &group_names, entry.group, "client_prefixes[{d}].group", .{i});
    }

    var source_urls: IndexSet = .empty;
    for (cfg.blocklist_sources, 0..) |source, i| {
        if (!sourceUrlIsValid(source.url)) {
            try diags.add(
                error.BadSourceUrl,
                "blocklist_sources[{d}].url",
                .{i},
                "{f} is not an http:// or https:// url with a host",
                .{safe_url.redactQuoted(source.url)},
            );
        }
        if (try firstSeen(&source_urls, scratch, source.url, i)) |first| {
            try diags.add(
                error.DuplicateSourceUrl,
                "blocklist_sources[{d}].url",
                .{i},
                "duplicate of blocklist_sources[{d}]",
                .{first},
            );
        }
        if (source.name.len == 0) {
            try diags.add(
                error.EmptySourceName,
                "blocklist_sources[{d}].name",
                .{i},
                "source name is empty",
                .{},
            );
        }
        try checkNameLength(
            diags,
            error.SourceNameTooLong,
            "blocklist_sources[{d}].name",
            .{i},
            source.name,
            limits.max_source_name_len,
        );
    }

    var group_source_pairs: IndexSet = .empty;
    var linked_sources: IndexSet = .empty;
    for (cfg.group_sources, 0..) |link, i| {
        try checkGroupRef(diags, &group_names, link.group, "group_sources[{d}].group", .{i});
        _ = try firstSeen(&linked_sources, scratch, link.source_url, i);
        if (!source_urls.contains(link.source_url)) {
            try diags.add(
                error.UnknownSource,
                "group_sources[{d}].source_url",
                .{i},
                "unknown blocklist source {f}",
                .{safe_url.redactQuoted(link.source_url)},
            );
        }
        const key = try std.fmt.allocPrint(scratch, "{s}\x00{s}", .{ link.group, link.source_url });
        if (try firstSeen(&group_source_pairs, scratch, key, i)) |_| {
            try diags.add(
                error.DuplicateGroupSource,
                "group_sources[{d}]",
                .{i},
                "duplicate link from group {f} to source {f}",
                .{ safe_url.quoteText(link.group), safe_url.redactQuoted(link.source_url) },
            );
        }
    }

    // A source no group links to is downloaded, parsed and compiled into
    // nothing: `filter/compiler.zig` builds one set per group out of the
    // `group_sources` links, so an unlinked source blocks not one domain. The
    // operator sees a healthy source with a domain count and a filter that does
    // nothing, which is exactly the shape of the tutorial session that found
    // this. Legal configuration — a list may be staged before it is attached —
    // so it warns and leaves the exit code alone.
    for (cfg.blocklist_sources, 0..) |source, i| {
        if (linked_sources.contains(source.url)) continue;
        try diags.addWarning(
            error.SourceInNoGroup,
            "blocklist_sources[{d}]",
            .{i},
            "source {f} {f} belongs to no group, so nothing it lists is blocked; link it from group_sources",
            .{ safe_url.quoteText(source.name), safe_url.redactQuoted(source.url) },
        );
    }

    for (cfg.rules, 0..) |rule, i| {
        try checkGroupRef(diags, &group_names, rule.group, "rules[{d}].group", .{i});
        // `null` is a good pattern; anything else is the sentence fragment that
        // says which of the regex engine's limits refused it. The empty string
        // is a plain syntax refusal, which is the only verdict the exact and
        // wildcard kinds can reach.
        const detail: ?[]const u8 = if (patternIsValid(scratch, rule.pattern, rule.kind)) |valid|
            (if (valid) null else "")
        else |err| switch (err) {
            error.OutOfMemory => return error.OutOfMemory,
            error.BadPattern => "",
            error.PatternTooLong => std.fmt.comptimePrint(
                " (over {d} bytes)",
                .{regex.max_pattern_len},
            ),
            error.PatternTooComplex => std.fmt.comptimePrint(
                " (over {d} compiled instructions)",
                .{regex.max_program_len},
            ),
        };
        if (detail) |suffix| {
            try diags.add(
                error.BadRulePattern,
                "rules[{d}].pattern",
                .{i},
                "{f} is not a valid {s} pattern{s}",
                .{ safe_url.quoteText(rule.pattern), rule.kind.toDb(), suffix },
            );
        }
    }

    var local_records: IndexSet = .empty;
    for (cfg.local_records, 0..) |record, i| {
        _ = dns_name.fromText(record.name) catch {
            try diags.add(
                error.BadLocalRecordName,
                "local_records[{d}].name",
                .{i},
                "{f} is not a valid domain name",
                .{safe_url.quoteText(record.name)},
            );
        };
        if (!recordValueIsValid(record.rtype, record.value)) {
            try diags.add(
                error.BadLocalRecordValue,
                "local_records[{d}].value",
                .{i},
                "{f} is not a valid {s} value",
                .{ safe_url.quoteText(record.value), record.rtype.toDb() },
            );
        }
        if (record.ttl < 1 or record.ttl > max_record_ttl_seconds) {
            try diags.add(
                error.BadTtl,
                "local_records[{d}].ttl",
                .{i},
                "must be 1-{d}, got {d}",
                .{ max_record_ttl_seconds, record.ttl },
            );
        }
        const key = try std.fmt.allocPrint(
            scratch,
            "{s}\x00{s}\x00{s}",
            .{ record.name, record.rtype.toDb(), record.value },
        );
        if (try firstSeen(&local_records, scratch, key, i)) |_| {
            try diags.add(
                error.DuplicateLocalRecord,
                "local_records[{d}]",
                .{i},
                "duplicate local record {f} {s} {f}",
                .{ safe_url.quoteText(record.name), record.rtype.toDb(), safe_url.quoteText(record.value) },
            );
        }
    }

    var zones: IndexSet = .empty;
    for (cfg.forward_zones, 0..) |zone, i| {
        _ = dns_name.fromText(zone.zone) catch {
            try diags.add(
                error.BadForwardZone,
                "forward_zones[{d}].zone",
                .{i},
                "{f} is not a valid domain name",
                .{safe_url.quoteText(zone.zone)},
            );
        };
        if (try firstSeen(&zones, scratch, zone.zone, i)) |_| {
            try diags.add(
                error.DuplicateForwardZone,
                "forward_zones[{d}].zone",
                .{i},
                "duplicate forward zone {f}",
                .{safe_url.quoteText(zone.zone)},
            );
        }
        _ = parseResolver(zone.resolver) catch {
            try diags.add(
                error.BadResolverUrl,
                "forward_zones[{d}].resolver",
                .{i},
                "{f} is not a udp:// or tcp:// resolver with an IP literal and a port",
                .{safe_url.redactQuoted(zone.resolver)},
            );
        };
    }
}

fn checkGroupRef(
    diags: *Diagnostics,
    group_names: *const IndexSet,
    name: []const u8,
    comptime path_fmt: []const u8,
    path_args: anytype,
) error{OutOfMemory}!void {
    if (group_names.contains(name)) return;
    try diags.add(
        error.UnknownGroup,
        path_fmt,
        path_args,
        "unknown group {f}",
        .{safe_url.quoteText(name)},
    );
}

pub const Authority = struct {
    /// The host with the IPv6 brackets removed, never empty.
    host: []const u8,
    /// The port when the authority carried one, always 1-65535.
    port: ?u16,
    /// True when the host arrived in `[…]` form, so it is an IPv6 literal.
    bracketed: bool,
};

const AuthorityError = error{ BadHost, BadPort, BadUrl };

/// The one set of rules this file applies to the authority of a url. The same
/// rules live in `transport.Endpoint.parse` for the two schemes that parser
/// accepts; this parser exists because the blocklist and resolver schemes
/// (`http://`, `udp://`, `tcp://`) are outside its scheme set, not because the
/// rules differ. Do not add a third set.
///
/// The grammar, over the text before any `/`:
///
///     authority = ( "[" ipv6 "]" / host ) [ ":" port ]
///     host      = 1*( any byte except ":" "@" "?" "#" "[" "]",
///                     above 0x20 and below 0x7F )
///     port      = 1*DIGIT, value 1-65535
///
/// An empty authority, and an empty host before a `:`, name no host to dial.
/// `@`, `?` and `#` open the userinfo, query and fragment components, and
/// nothing here implements them, so keeping one as literal host text would let
/// this host disagree with the authority an RFC 3986 parser reads out of the
/// same url. A space or a control byte is never legal in a url. A `[` opens an
/// IP literal, so it must be the first byte, it must have a `]`, the text
/// between them must be an IPv6 address, and only a port may follow the `]`.
fn parseAuthority(authority: []const u8) AuthorityError!Authority {
    for (authority) |byte| switch (byte) {
        '@', '?', '#' => return error.BadUrl,
        // Everything outside printable ASCII: C0 controls and space, DEL, and
        // the 0x80+ range (C1 controls and raw non-ASCII — a host is IDNA
        // punycode by the time it is configuration text).
        0...' ', 0x7F...0xFF => return error.BadUrl,
        else => {},
    };
    if (authority.len == 0) return error.BadHost;

    const host, const port_text, const bracketed = split: {
        if (authority[0] == '[') {
            const close = std.mem.findScalar(u8, authority, ']') orelse return error.BadUrl;
            const tail = authority[close + 1 ..];
            if (tail.len == 0) break :split .{ authority[1..close], null, true };
            if (tail[0] != ':') return error.BadUrl;
            break :split .{ authority[1..close], tail[1..], true };
        }
        const colon = std.mem.findScalar(u8, authority, ':') orelse
            break :split .{ authority, null, false };
        break :split .{ authority[0..colon], authority[colon + 1 ..], false };
    };

    if (host.len == 0) return error.BadHost;
    if (bracketed) {
        const addr = NetAddress.parse(host) catch return error.BadHost;
        if (std.meta.activeTag(addr) != NetAddress.ip6) return error.BadHost;
    } else if (std.mem.findAny(u8, host, "[]") != null) {
        return error.BadUrl;
    }

    const port: ?u16 = if (port_text) |text| blk: {
        if (text.len == 0) return error.BadPort;
        for (text) |byte| {
            if (byte < '0' or byte > '9') return error.BadPort;
        }
        const value = std.fmt.parseInt(u16, text, 10) catch return error.BadPort;
        if (value == 0) return error.BadPort;
        break :blk value;
    } else null;

    return .{ .host = host, .port = port, .bracketed = bracketed };
}

/// True for an `http://` or `https://` url whose authority passes
/// `parseAuthority` and whose remainder carries no space or control byte.
/// The plain scheme is allowed because a list can live on the LAN.
///
/// The path is not interpreted: a blocklist url is fetched, never dialed by
/// host and path separately, so a query string in it is the source's business.
fn sourceUrlIsValid(url: []const u8) bool {
    const rest = if (std.mem.startsWith(u8, url, "http://"))
        url["http://".len..]
    else if (std.mem.startsWith(u8, url, "https://"))
        url["https://".len..]
    else
        return false;

    const end = std.mem.findScalar(u8, rest, '/') orelse rest.len;
    _ = parseAuthority(rest[0..end]) catch return false;
    for (rest[end..]) |byte| {
        if (byte <= ' ' or byte == 0x7F) return false;
    }
    return true;
}

/// Syntax only. Matching semantics are Phase 5's: an exact pattern carries no
/// `*` at all, a wildcard pattern carries at least one label that is exactly
/// `*`, and every remaining label must survive `dns.name.fromText`.
///
/// A regex pattern is validated by compiling it, and its three refusals arrive
/// as errors rather than as `false` so the caller can name the one that fired.
/// The distinction is the operator's, not the compiler's: "not a valid regex
/// pattern" sends someone hunting for a typo in a pattern whose only fault is
/// that it is longer than `regex.max_pattern_len` or wider than
/// `regex.max_program_len`, and neither limit is visible in the pattern text.
fn patternIsValid(
    scratch: Allocator,
    pattern: []const u8,
    kind: model.RuleKind,
) regex.Error!bool {
    switch (kind) {
        .exact => {
            if (std.mem.findScalar(u8, pattern, '*') != null) return false;
            _ = dns_name.fromText(pattern) catch return false;
            return true;
        },
        .wildcard => {
            var stars: usize = 0;
            var substituted: std.ArrayList(u8) = .empty;
            var it = std.mem.splitScalar(u8, pattern, '.');
            var first = true;
            while (it.next()) |label| {
                if (!first) try substituted.append(scratch, '.');
                first = false;
                if (std.mem.eql(u8, label, "*")) {
                    stars += 1;
                    try substituted.append(scratch, 'x');
                } else {
                    try substituted.appendSlice(scratch, label);
                }
            }
            if (stars == 0) return false;
            _ = dns_name.fromText(substituted.items) catch return false;
            return true;
        },
        .regex => {
            var program = try regex.compile(scratch, pattern);
            program.deinit(scratch);
            return true;
        },
    }
}

fn recordValueIsValid(rtype: model.RecordType, value: []const u8) bool {
    switch (rtype) {
        .a => {
            const addr = NetAddress.parse(value) catch return false;
            return std.meta.activeTag(addr) == NetAddress.ip4;
        },
        .aaaa => {
            const addr = NetAddress.parse(value) catch return false;
            return std.meta.activeTag(addr) == NetAddress.ip6;
        },
        .cname => {
            _ = dns_name.fromText(value) catch return false;
            return true;
        },
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

const testing = std.testing;

/// A config that validates cleanly. Every test below mutates one thing.
fn baseConfig() Config {
    return .{
        .groups = &.{.{ .name = "default" }},
        .upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
    };
}

fn expectProblem(cfg: Config, expected: ValidateError, expected_path: []const u8) !void {
    var diags: Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    try testing.expectError(expected, validate(cfg, &diags));
    const failure = diags.firstFailure() orelse return error.TestExpectedFailure;
    try testing.expectEqualStrings(expected_path, failure.path);
    try testing.expectEqual(expected, failure.err);
}

/// Clean means no failures AND no warnings: a warning nobody asked for is a
/// false alarm in `nxdns check`, so it has to be as hard to add by accident as
/// a failure is.
fn expectClean(cfg: Config) !void {
    var diags: Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    const result = validate(cfg, &diags);
    const failed = if (result) |_| false else |_| true;
    if (failed or diags.problems.items.len != 0) {
        var buf: [4096]u8 = undefined;
        var w: Writer = .fixed(&buf);
        diags.writeAll(&w) catch {};
        std.debug.print("unexpected problems:\n{s}", .{w.buffered()});
    }
    try result;
    try testing.expectEqual(@as(usize, 0), diags.problems.items.len);
}

test "a default config with one enabled upstream and a default group validates cleanly" {
    try expectClean(baseConfig());
}

test "a fully populated config validates cleanly" {
    var cfg = baseConfig();
    cfg.groups = &.{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } };
    cfg.upstreams = &.{
        .{ .url = "https://dns.example/dns-query" },
        .{ .url = "tls://1.1.1.1", .priority = 200, .enabled = false },
    };
    cfg.clients = &.{
        .{ .ip = "192.168.1.10", .name = "laptop" },
        .{ .ip = "fd00::1", .group = "kids" },
    };
    cfg.client_prefixes = &.{.{ .prefix = "192.168.2.0/24", .group = "kids" }};
    cfg.blocklist_sources = &.{.{ .url = "https://lists.example/hosts.txt", .name = "example" }};
    cfg.group_sources = &.{.{ .group = "kids", .source_url = "https://lists.example/hosts.txt" }};
    cfg.rules = &.{
        .{ .group = "kids", .pattern = "ads.example.com", .kind = .exact, .action = .block },
        .{ .group = "default", .pattern = "*.tracker.example", .kind = .wildcard, .action = .allow },
    };
    cfg.local_records = &.{
        .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" },
        .{ .name = "nas.lan", .rtype = .aaaa, .value = "fd00::5" },
        .{ .name = "www.lan", .rtype = .cname, .value = "nas.lan" },
    };
    cfg.forward_zones = &.{.{ .zone = "corp.lan", .resolver = "udp://192.168.1.1:53" }};
    try expectClean(cfg);
}

test "error.NoUpstreams when nothing is enabled" {
    var cfg = baseConfig();
    cfg.upstreams = &.{.{ .url = "https://dns.example/dns-query", .enabled = false }};
    try expectProblem(cfg, error.NoUpstreams, "upstreams");
}

/// `count` distinct enabled upstreams. Generated rather than written out
/// because the bound this exercises is 64, and a hand-written list that long
/// would say less than the loop does.
fn ManyUpstreams(comptime count: usize) type {
    return struct {
        const list: [count]model.UpstreamServer = blk: {
            var built: [count]model.UpstreamServer = undefined;
            for (&built, 0..) |*server, i| {
                server.* = .{ .url = std.fmt.comptimePrint("https://u{d}.example/dns-query", .{i}) };
            }
            break :blk built;
        };
    };
}

fn manyUpstreams(comptime count: usize) []const model.UpstreamServer {
    return &ManyUpstreams(count).list;
}

test "as many enabled upstreams as the pool holds validates cleanly" {
    var cfg = baseConfig();
    cfg.upstreams = manyUpstreams(pool.Pool.max_entries);
    try expectClean(cfg);
}

test "error.TooManyUpstreams one enabled upstream past the pool's bound" {
    // The pool asserts this bound, so without the check here a valid-looking
    // config panics at startup instead of being reported.
    var cfg = baseConfig();
    cfg.upstreams = manyUpstreams(pool.Pool.max_entries + 1);
    try expectProblem(cfg, error.TooManyUpstreams, "upstreams");
}

test "upstreams past the pool's bound are fine while they are disabled" {
    // Only enabled upstreams become pool entries, so a long list with a small
    // enabled subset is not near the bound at all.
    var cfg = baseConfig();
    cfg.upstreams = comptime blk: {
        var list = manyUpstreams(pool.Pool.max_entries + 1)[0 .. pool.Pool.max_entries + 1].*;
        for (list[1..]) |*server| server.enabled = false;
        const frozen = list;
        break :blk &frozen;
    };
    try expectClean(cfg);
}

test "error.BadUpstreamUrl on an unsupported scheme" {
    var cfg = baseConfig();
    cfg.upstreams = &.{.{ .url = "ftp://dns.example/" }};
    try expectProblem(cfg, error.BadUpstreamUrl, "upstreams[0].url");
}

test "error.DuplicateUpstreamUrl" {
    var cfg = baseConfig();
    cfg.upstreams = &.{
        .{ .url = "https://dns.example/dns-query" },
        .{ .url = "https://dns.example/dns-query" },
    };
    try expectProblem(cfg, error.DuplicateUpstreamUrl, "upstreams[1].url");
}

test "error.UpstreamHostNotIpLiteral on a hostname tls:// upstream" {
    // The form an operator copies off the NextDNS setup page. It parses, so it
    // used to validate clean and then fail every exchange as a peer fault.
    var cfg = baseConfig();
    cfg.upstreams = &.{.{ .url = "tls://abcd12.dns.nextdns.io" }};
    try expectProblem(cfg, error.UpstreamHostNotIpLiteral, "upstreams[0].url");

    // A tls_name does not excuse it: the name is what the certificate is
    // matched against, not what nxdns dials.
    var named = baseConfig();
    named.upstreams = &.{.{ .url = "tls://dns.quad9.net:853", .tls_name = "dns.quad9.net" }};
    try expectProblem(named, error.UpstreamHostNotIpLiteral, "upstreams[0].url");
}

test "an https:// upstream may name a host" {
    // The check is scheme-specific: DoH resolves through the HTTP client, so a
    // hostname there is the normal form.
    var cfg = baseConfig();
    cfg.upstreams = &.{.{ .url = "https://dns.nextdns.io/abcd12" }};
    try expectClean(cfg);
}

/// The longest host `transport.Endpoint.parse` accepts: four labels, 253 bytes.
const host_at_bound = ("a" ** 63 ++ ".") ** 3 ++ "a" ** 61;

test "an upstream host at the length bound validates cleanly" {
    var cfg = baseConfig();
    cfg.upstreams = &.{.{ .url = "https://" ++ host_at_bound ++ "/dns-query" }};
    try expectClean(cfg);
}

test "error.BadUpstreamUrl on an upstream host one byte past the length bound" {
    // The bound is the query log's `upstream` width and every other identity
    // built from the endpoint, so an over-long host has to fail here rather
    // than be shortened downstream.
    var cfg = baseConfig();
    cfg.upstreams = &.{.{ .url = "https://" ++ host_at_bound ++ "a/dns-query" }};
    try expectProblem(cfg, error.BadUpstreamUrl, "upstreams[0].url");
}

test "an IPv6 literal tls:// upstream validates cleanly" {
    // `Endpoint.parse` strips the brackets, so the host reaching the check is
    // exactly what the client hands to the address parser.
    var cfg = baseConfig();
    cfg.upstreams = &.{.{ .url = "tls://[2620:fe::fe]:853", .tls_name = "dns.quad9.net" }};
    try expectClean(cfg);
}

test "a tls_name on a tls:// upstream validates cleanly" {
    var cfg = baseConfig();
    cfg.upstreams = &.{.{ .url = "tls://1.1.1.1:853", .tls_name = "one.one.one.one" }};
    try expectClean(cfg);
}

test "error.BadTlsName on a malformed name" {
    var cfg = baseConfig();
    cfg.upstreams = &.{.{ .url = "tls://1.1.1.1:853", .tls_name = "one..one.one" }};
    try expectProblem(cfg, error.BadTlsName, "upstreams[0].tls_name");

    var too_long = baseConfig();
    too_long.upstreams = &.{.{ .url = "tls://1.1.1.1:853", .tls_name = "a" ** 64 ++ ".example" }};
    try expectProblem(too_long, error.BadTlsName, "upstreams[0].tls_name");
}

test "error.TlsNameOnNonTlsUpstream on a DoH upstream" {
    var cfg = baseConfig();
    cfg.upstreams = &.{.{ .url = "https://dns.example/dns-query", .tls_name = "dns.example" }};
    try expectProblem(cfg, error.TlsNameOnNonTlsUpstream, "upstreams[0].tls_name");
}

test "an empty tls_name is accepted on every scheme" {
    var cfg = baseConfig();
    cfg.upstreams = &.{
        .{ .url = "https://dns.example/dns-query" },
        .{ .url = "tls://9.9.9.9:853" },
    };
    try expectClean(cfg);
}

test "error.MissingDefaultGroup" {
    var cfg = baseConfig();
    cfg.groups = &.{.{ .name = "kids" }};
    try expectProblem(cfg, error.MissingDefaultGroup, "groups");
}

test "error.DuplicateGroupName" {
    var cfg = baseConfig();
    cfg.groups = &.{ .{ .name = "default" }, .{ .name = "kids" }, .{ .name = "kids" } };
    try expectProblem(cfg, error.DuplicateGroupName, "groups[2].name");
}

test "error.UnknownGroup" {
    var cfg = baseConfig();
    cfg.clients = &.{.{ .ip = "192.168.1.10", .group = "kids" }};
    try expectProblem(cfg, error.UnknownGroup, "clients[0].group");
}

test "error.EmptyGroupName" {
    var cfg = baseConfig();
    cfg.groups = &.{ .{ .name = "default" }, .{ .name = "" } };
    try expectProblem(cfg, error.EmptyGroupName, "groups[1].name");
}

test "error.GroupNameTooLong" {
    const cap = limits.max_group_name_len;
    // Exactly at the cap is accepted; one byte past it is not. The cap is what
    // `storage/logger.zig` sizes its `Entry` buffer from, so a name that passes
    // here is a name a logged row stores whole.
    var at_cap = baseConfig();
    at_cap.groups = &.{ .{ .name = "default" }, .{ .name = "g" ** cap } };
    try expectClean(at_cap);

    var over = baseConfig();
    over.groups = &.{ .{ .name = "default" }, .{ .name = "g" ** (cap + 1) } };
    try expectProblem(over, error.GroupNameTooLong, "groups[1].name");
}

test "error.SourceNameTooLong" {
    const cap = limits.max_source_name_len;
    const url = "https://lists.example/hosts.txt";

    var at_cap = baseConfig();
    at_cap.blocklist_sources = &.{.{ .url = url, .name = "s" ** cap }};
    at_cap.group_sources = &.{.{ .group = "default", .source_url = url }};
    try expectClean(at_cap);

    var over = baseConfig();
    over.blocklist_sources = &.{.{ .url = url, .name = "s" ** (cap + 1) }};
    over.group_sources = &.{.{ .group = "default", .source_url = url }};
    try expectProblem(over, error.SourceNameTooLong, "blocklist_sources[0].name");
}

test "error.BadClientIp" {
    var cfg = baseConfig();
    cfg.clients = &.{.{ .ip = "nonsense" }};
    try expectProblem(cfg, error.BadClientIp, "clients[0].ip");
}

test "error.DuplicateClientIp" {
    var cfg = baseConfig();
    cfg.clients = &.{ .{ .ip = "192.168.1.10" }, .{ .ip = "192.168.1.10" } };
    try expectProblem(cfg, error.DuplicateClientIp, "clients[1].ip");
}

test "error.BadClientPrefix" {
    var cfg = baseConfig();
    cfg.client_prefixes = &.{.{ .prefix = "192.168.1.0" }};
    try expectProblem(cfg, error.BadClientPrefix, "client_prefixes[0].prefix");
}

test "error.DuplicateClientPrefix" {
    var cfg = baseConfig();
    cfg.client_prefixes = &.{
        .{ .prefix = "192.168.1.0/24" },
        .{ .prefix = "192.168.1.55/24" },
    };
    try expectProblem(cfg, error.DuplicateClientPrefix, "client_prefixes[1].prefix");
}

test "error.BadSourceUrl" {
    var cfg = baseConfig();
    cfg.blocklist_sources = &.{.{ .url = "ftp://lists.example/hosts.txt", .name = "example" }};
    try expectProblem(cfg, error.BadSourceUrl, "blocklist_sources[0].url");
}

test "a source url is rejected when its authority breaks an Endpoint.parse rule" {
    const malformed = [_][]const u8{
        "https://?x",
        "https://user@h/l",
        "https:///l",
        "https://h h/l",
        "https://#f",
        "https://",
        "http://",
        "https://:443/list",
        "https://[::1/l",
        "https://[]:80/l",
        "https://[::1]x:80/l",
        "https://h:/l",
        "https://h:abc/l",
        "https://h:0/l",
        "https://h:70000/l",
        "https://[nothex]/l",
    };
    for (malformed) |url| {
        var cfg = baseConfig();
        const sources = [_]model.BlocklistSource{.{ .url = url, .name = "example" }};
        cfg.blocklist_sources = &sources;
        try expectProblem(cfg, error.BadSourceUrl, "blocklist_sources[0].url");
    }
}

test "a source url is accepted over https and over http" {
    var cfg = baseConfig();
    cfg.blocklist_sources = &.{
        .{ .url = "https://lists.example/hosts.txt", .name = "secure" },
        .{ .url = "http://nas.lan:8080/lists/hosts.txt?v=2", .name = "lan" },
        .{ .url = "https://[fd00::1]/hosts.txt", .name = "literal" },
        .{ .url = "https://[fd00::2]:8443/hosts.txt", .name = "literal with port" },
        .{ .url = "https://lists.example", .name = "no path" },
    };
    // Each one is linked, because an unlinked source is a warning of its own.
    cfg.group_sources = &.{
        .{ .group = "default", .source_url = "https://lists.example/hosts.txt" },
        .{ .group = "default", .source_url = "http://nas.lan:8080/lists/hosts.txt?v=2" },
        .{ .group = "default", .source_url = "https://[fd00::1]/hosts.txt" },
        .{ .group = "default", .source_url = "https://[fd00::2]:8443/hosts.txt" },
        .{ .group = "default", .source_url = "https://lists.example" },
    };
    try expectClean(cfg);
}

/// Renders every diagnostic `cfg` produces and fails if the secret survived
/// anywhere in the text, naming the line that leaked it.
///
/// Over the whole report rather than one message, deliberately: a url reaches
/// nine diagnostics here, and a fix that redacts the line a test names while a
/// neighbouring line still prints the password is not a fix.
fn expectNoSecret(cfg: Config, secret: []const u8) !void {
    var diags: Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    validate(cfg, &diags) catch |e| switch (e) {
        error.OutOfMemory => return e,
        else => {},
    };
    try testing.expect(diags.problems.items.len != 0);

    for (diags.problems.items) |problem| {
        if (std.mem.containsAtLeast(u8, problem.message, 1, secret)) {
            std.debug.print(
                "'{s}' reached a diagnostic: {s}: {s}\n",
                .{ secret, problem.path, problem.message },
            );
            return error.SecretInDiagnostic;
        }
    }
}

test "no diagnostic prints a url's userinfo or query" {
    // Observed on the real path: a rejected source url put the operator's
    // password on stderr, which under systemd is journald, where it stays.
    // Rejecting the url is not a defence — the line is written either way.
    const leaky_source = "http://lists:hunter2@127.0.0.1:9/hosts.txt";

    var bad_source = baseConfig();
    bad_source.blocklist_sources = &.{.{ .url = leaky_source, .name = "ads" }};
    try expectNoSecret(bad_source, "hunter2");

    // The same url twice: reported by `DuplicateSourceUrl`, and by the
    // no-group warning that a legal one earns.
    var duplicate_source = baseConfig();
    duplicate_source.blocklist_sources = &.{
        .{ .url = "https://token:hunter2@lists.example/hosts.txt", .name = "a" },
        .{ .url = "https://token:hunter2@lists.example/hosts.txt", .name = "b" },
    };
    try expectNoSecret(duplicate_source, "hunter2");

    // An api key in the query, through the link that names an undeclared source
    // and the duplicate link beside it.
    var link = baseConfig();
    link.group_sources = &.{
        .{ .group = "default", .source_url = "https://lists.example/h.txt?apikey=hunter2" },
        .{ .group = "default", .source_url = "https://lists.example/h.txt?apikey=hunter2" },
    };
    try expectNoSecret(link, "hunter2");

    // Upstreams carry the same shapes and print through the same channel.
    var bad_upstream = baseConfig();
    bad_upstream.upstreams = &.{.{ .url = "ftp://user:hunter2@dns.example/" }};
    try expectNoSecret(bad_upstream, "hunter2");

    var duplicate_upstream = baseConfig();
    duplicate_upstream.upstreams = &.{
        .{ .url = "https://dns.example/dns-query?key=hunter2" },
        .{ .url = "https://dns.example/dns-query?key=hunter2" },
    };
    try expectNoSecret(duplicate_upstream, "hunter2");

    // A `tls_name` on a DoH upstream reports the url it does not belong to.
    var tls_name = baseConfig();
    tls_name.upstreams = &.{.{
        .url = "https://dns.example/dns-query?key=hunter2",
        .tls_name = "dns.example",
    }};
    try expectNoSecret(tls_name, "hunter2");

    // A resolver may not carry userinfo at all, which means the line that
    // reports one is the line that would print it.
    var resolver = baseConfig();
    resolver.forward_zones = &.{.{ .zone = "corp.lan", .resolver = "udp://user:hunter2@192.168.1.1:53" }};
    try expectNoSecret(resolver, "hunter2");
}

/// Renders every diagnostic `cfg` produces and fails if any control byte
/// survived into the text, naming the line that carried it.
///
/// Over the whole report rather than one message, for the reason
/// `expectNoSecret` is: one operator-supplied name reaches several diagnostics,
/// and escaping the line a test names while a neighbouring line writes the raw
/// byte is not a fix.
fn expectNoControlByte(cfg: Config) !void {
    var diags: Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    validate(cfg, &diags) catch |e| switch (e) {
        error.OutOfMemory => return e,
        else => {},
    };
    try testing.expect(diags.problems.items.len != 0);

    for (diags.problems.items) |problem| {
        for (problem.message) |byte| {
            if (byte >= 0x20 and byte != 0x7f) continue;
            std.debug.print(
                "a control byte reached a diagnostic: {s}: {s}\n",
                .{ problem.path, problem.message },
            );
            return error.ControlByteInDiagnostic;
        }
    }
}

test "no diagnostic prints an operator's control characters" {
    // Every text field an operator can write, carrying the byte that ends a
    // line. A diagnostic goes to `nxdns check`'s stdout and to the body of a
    // 400 from `POST /api/blocklists`, and neither is a `std.log` line, so
    // nothing downstream escapes what these lines print.
    const forged = "ads\n2026-01-01 ERROR nxdns: forged\x1b[2K";

    var groups = baseConfig();
    groups.groups = &.{ .{ .name = "default" }, .{ .name = forged }, .{ .name = forged } };
    try expectNoControlByte(groups);

    var unknown_group = baseConfig();
    unknown_group.clients = &.{.{ .ip = "192.168.1.10", .group = forged }};
    try expectNoControlByte(unknown_group);

    var client = baseConfig();
    client.clients = &.{.{ .ip = forged }};
    try expectNoControlByte(client);

    var prefix = baseConfig();
    prefix.client_prefixes = &.{.{ .prefix = forged }};
    try expectNoControlByte(prefix);

    // The reported line: a name is printed by a warning that a legal
    // configuration earns, so this one needs no invalid value to reach output.
    var source = baseConfig();
    source.blocklist_sources = &.{.{ .url = "https://lists.example/hosts.txt", .name = forged }};
    try expectNoControlByte(source);

    var link = baseConfig();
    link.group_sources = &.{.{ .group = forged, .source_url = "https://lists.example/hosts.txt" }};
    try expectNoControlByte(link);

    // A `*` is what makes this pattern invalid for an exact rule, and so what
    // makes it reach the diagnostic: a label may hold any byte on the wire, so
    // `dns.name.fromText` accepts a newline inside one and a control character
    // alone does not reject a pattern.
    var rule = baseConfig();
    rule.rules = &.{.{ .group = "default", .pattern = "*" ++ forged, .kind = .exact, .action = .block }};
    try expectNoControlByte(rule);

    var record = baseConfig();
    record.local_records = &.{.{ .name = forged, .rtype = .a, .value = forged }};
    try expectNoControlByte(record);

    var duplicate_record = baseConfig();
    duplicate_record.local_records = &.{
        .{ .name = forged, .rtype = .cname, .value = "nas.lan" },
        .{ .name = forged, .rtype = .cname, .value = "nas.lan" },
    };
    try expectNoControlByte(duplicate_record);

    var zone = baseConfig();
    zone.forward_zones = &.{
        .{ .zone = forged, .resolver = "udp://192.168.1.1:53" },
        .{ .zone = forged, .resolver = "udp://192.168.1.1:53" },
    };
    try expectNoControlByte(zone);

    // The empty label is what rejects this name, for the reason the rule
    // pattern above needs a `*`.
    var tls_name = baseConfig();
    tls_name.upstreams = &.{.{ .url = "tls://1.1.1.1:853", .tls_name = ".." ++ forged }};
    try expectNoControlByte(tls_name);

    var bind = baseConfig();
    bind.dns.bind_ipv4 = forged;
    try expectNoControlByte(bind);

    var log_path = baseConfig();
    log_path.logging.output = .file;
    log_path.logging.file_path = forged;
    try expectNoControlByte(log_path);
}

test "a source name cannot forge a line or close the quotes around it" {
    // The name is operator-supplied text out of the configuration file or out of
    // a database row, printed beside a url. Unescaped, the `'` closes the quotes
    // the message put around it and the `\n` ends the line, so the warning below
    // would print a second line naming a url this source does not have.
    var cfg = baseConfig();
    cfg.blocklist_sources = &.{.{
        .url = "https://lists.example/hosts.txt",
        .name = "ads'\n2026-01-01 ERROR forged (https://decoy.example)",
    }};

    var diags: Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    // Legal configuration: the name is ugly, not invalid.
    try validate(cfg, &diags);
    try testing.expectEqual(@as(usize, 1), diags.warningCount());

    const problem = diags.problems.items[0];
    // The whole message, so the assertion fails in both directions: it fails if
    // an escape is dropped, and it fails if the quotes are written twice.
    try testing.expectEqualStrings(
        "source 'ads\\'\\n2026-01-01 ERROR forged (https://decoy.example)' " ++
            "'https://lists.example' belongs to no group, so nothing it lists is blocked; " ++
            "link it from group_sources",
        problem.message,
    );

    // What the operator sees: one line, which is the property the escaping
    // exists for. `writeAll` ends each problem with the only newline in it.
    var buf: [512]u8 = undefined;
    var w: Writer = .fixed(&buf);
    try diags.writeAll(&w);
    try testing.expectEqual(@as(usize, 1), std.mem.count(u8, w.buffered(), "\n"));
    try testing.expect(std.mem.endsWith(u8, w.buffered(), "link it from group_sources\n"));
}

test "a redacted diagnostic still names the source the operator has to fix" {
    // Redaction that costs the operator the identity of the offending entry
    // would trade one failure for another. The message keeps the scheme, the
    // host and the port — where the source points — and nothing else: the two
    // sources here sit on one host and differ only in the path, so the message
    // cannot tell them apart and is not asked to. What names the entry is
    // `path`, which carries the index into the config, and that is the whole
    // reason the url may be reduced to what does not carry a credential.
    var cfg = baseConfig();
    cfg.blocklist_sources = &.{
        .{ .url = "http://127.0.0.1:9/good.txt", .name = "ok" },
        .{ .url = "http://lists:hunter2@127.0.0.1:9/subscribe/tok3n/hosts.txt", .name = "leaky" },
    };
    cfg.group_sources = &.{
        .{ .group = "default", .source_url = "http://127.0.0.1:9/good.txt" },
        .{ .group = "default", .source_url = "http://lists:hunter2@127.0.0.1:9/subscribe/tok3n/hosts.txt" },
    };

    var diags: Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    try testing.expectError(error.BadSourceUrl, validate(cfg, &diags));

    const problem = diags.firstFailure() orelse return error.TestExpectedFailure;
    try testing.expectEqualStrings("blocklist_sources[1].url", problem.path);
    // The whole message, so the assertion fails in both directions: it fails if
    // the userinfo or the path token comes back, and it fails if redaction
    // over-reaches and stops saying where the source points.
    try testing.expectEqualStrings(
        "'http://127.0.0.1:9' is not an http:// or https:// url with a host",
        problem.message,
    );
    try testing.expect(!std.mem.containsAtLeast(u8, problem.message, 1, "hunter2"));
    try testing.expect(!std.mem.containsAtLeast(u8, problem.message, 1, "tok3n"));
}

test "a duplicate url names the entry it duplicates" {
    // Two NextDNS profiles differ only in the path, which `safe_url.redact`
    // drops, so a message quoting the url prints the same text for a colliding
    // pair and for an unrelated one. The index is the evidence instead, and it
    // is the first entry's index: `upstreams[2]` collides with `upstreams[0]`
    // here, and nothing derived from the loop counter can produce that.
    var cfg = baseConfig();
    cfg.upstreams = &.{
        .{ .url = "https://dns.nextdns.io/abcd12" },
        .{ .url = "https://dns.nextdns.io/efgh34" },
        .{ .url = "https://dns.nextdns.io/abcd12" },
    };

    var diags: Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    try testing.expectError(error.DuplicateUpstreamUrl, validate(cfg, &diags));

    try testing.expectEqual(@as(usize, 1), diags.failureCount());
    const problem = diags.firstFailure() orelse return error.TestExpectedFailure;
    try testing.expectEqualStrings("upstreams[2].url", problem.path);
    try testing.expectEqualStrings("duplicate of upstreams[0]", problem.message);
}

test "a duplicate source url names the entry it duplicates" {
    var cfg = baseConfig();
    cfg.blocklist_sources = &.{
        .{ .url = "https://lists.example/d/tok3n/hosts.txt", .name = "first" },
        .{ .url = "https://lists.example/d/other/hosts.txt", .name = "second" },
        .{ .url = "https://lists.example/d/tok3n/hosts.txt", .name = "third" },
    };
    cfg.group_sources = &.{
        .{ .group = "default", .source_url = "https://lists.example/d/tok3n/hosts.txt" },
        .{ .group = "default", .source_url = "https://lists.example/d/other/hosts.txt" },
    };

    var diags: Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    try testing.expectError(error.DuplicateSourceUrl, validate(cfg, &diags));

    try testing.expectEqual(@as(usize, 1), diags.failureCount());
    const problem = diags.firstFailure() orelse return error.TestExpectedFailure;
    try testing.expectEqualStrings("blocklist_sources[2].url", problem.path);
    try testing.expectEqualStrings("duplicate of blocklist_sources[0]", problem.message);
    // The token the url carries is what the old wording printed, twice.
    try testing.expect(!std.mem.containsAtLeast(u8, problem.message, 1, "tok3n"));
}

test "a blocklist source in no group is one warning and no failure" {
    var cfg = baseConfig();
    cfg.blocklist_sources = &.{.{ .url = "https://lists.example/hosts.txt", .name = "stevenblack" }};

    var diags: Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    // Legal configuration: no error, so no subcommand may fail on it.
    try validate(cfg, &diags);
    try testing.expectEqual(@as(usize, 0), diags.failureCount());
    try testing.expectEqual(@as(usize, 1), diags.warningCount());
    try testing.expectEqual(@as(?Problem, null), diags.firstFailure());

    const problem = diags.problems.items[0];
    try testing.expectEqual(Severity.warn, problem.severity);
    try testing.expectEqual(ProblemError.SourceInNoGroup, problem.err);
    try testing.expectEqualStrings("blocklist_sources[0]", problem.path);
    try testing.expect(std.mem.containsAtLeast(u8, problem.message, 1, "stevenblack"));

    var buf: [512]u8 = undefined;
    var w: Writer = .fixed(&buf);
    try diags.writeAll(&w);
    try testing.expect(std.mem.startsWith(u8, w.buffered(), "WARN blocklist_sources[0]: "));
}

test "a source linked from any group is not warned about" {
    var cfg = baseConfig();
    cfg.groups = &.{ .{ .name = "default" }, .{ .name = "kids" } };
    cfg.blocklist_sources = &.{
        .{ .url = "https://lists.example/a.txt", .name = "a" },
        .{ .url = "https://lists.example/b.txt", .name = "b" },
    };
    cfg.group_sources = &.{.{ .group = "kids", .source_url = "https://lists.example/a.txt" }};

    var diags: Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    try validate(cfg, &diags);
    try testing.expectEqual(@as(usize, 1), diags.warningCount());
    try testing.expectEqualStrings("blocklist_sources[1]", diags.problems.items[0].path);
}

test "a warning is reported next to a failure without becoming one" {
    var cfg = baseConfig();
    cfg.dns.port = 0;
    cfg.blocklist_sources = &.{.{ .url = "https://lists.example/hosts.txt", .name = "ads" }};

    var diags: Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    try testing.expectError(error.BadPort, validate(cfg, &diags));
    try testing.expectEqual(@as(usize, 1), diags.failureCount());
    try testing.expectEqual(@as(usize, 1), diags.warningCount());

    var buf: [512]u8 = undefined;
    var w: Writer = .fixed(&buf);
    try diags.writeAll(&w);
    try testing.expect(std.mem.startsWith(u8, w.buffered(), "FAIL dns.port: "));
    try testing.expect(std.mem.containsAtLeast(u8, w.buffered(), 1, "\nWARN blocklist_sources[0]: "));
}

test "error.DuplicateSourceUrl" {
    var cfg = baseConfig();
    cfg.blocklist_sources = &.{
        .{ .url = "https://lists.example/hosts.txt", .name = "a" },
        .{ .url = "https://lists.example/hosts.txt", .name = "b" },
    };
    try expectProblem(cfg, error.DuplicateSourceUrl, "blocklist_sources[1].url");
}

test "error.EmptySourceName" {
    var cfg = baseConfig();
    cfg.blocklist_sources = &.{.{ .url = "https://lists.example/hosts.txt", .name = "" }};
    try expectProblem(cfg, error.EmptySourceName, "blocklist_sources[0].name");
}

test "error.UnknownSource" {
    var cfg = baseConfig();
    cfg.group_sources = &.{.{ .group = "default", .source_url = "https://lists.example/hosts.txt" }};
    try expectProblem(cfg, error.UnknownSource, "group_sources[0].source_url");
}

test "error.DuplicateGroupSource" {
    var cfg = baseConfig();
    cfg.blocklist_sources = &.{.{ .url = "https://lists.example/hosts.txt", .name = "example" }};
    cfg.group_sources = &.{
        .{ .group = "default", .source_url = "https://lists.example/hosts.txt" },
        .{ .group = "default", .source_url = "https://lists.example/hosts.txt" },
    };
    try expectProblem(cfg, error.DuplicateGroupSource, "group_sources[1]");
}

test "error.BadRulePattern" {
    var cfg = baseConfig();
    cfg.rules = &.{.{ .group = "default", .pattern = "*.ads.example", .kind = .exact, .action = .block }};
    try expectProblem(cfg, error.BadRulePattern, "rules[0].pattern");
}

test "error.BadLocalRecordName" {
    const too_long = "a" ** 64;
    var cfg = baseConfig();
    cfg.local_records = &.{.{ .name = too_long ++ ".lan", .rtype = .a, .value = "192.168.1.5" }};
    try expectProblem(cfg, error.BadLocalRecordName, "local_records[0].name");
}

test "error.BadLocalRecordValue" {
    var cfg = baseConfig();
    cfg.local_records = &.{.{ .name = "nas.lan", .rtype = .a, .value = "fd00::1" }};
    try expectProblem(cfg, error.BadLocalRecordValue, "local_records[0].value");
}

test "error.DuplicateLocalRecord" {
    var cfg = baseConfig();
    cfg.local_records = &.{
        .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" },
        .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" },
    };
    try expectProblem(cfg, error.DuplicateLocalRecord, "local_records[1]");
}

test "error.BadForwardZone" {
    const too_long = "a" ** 64;
    var cfg = baseConfig();
    cfg.forward_zones = &.{.{ .zone = too_long ++ ".lan", .resolver = "udp://192.168.1.1:53" }};
    try expectProblem(cfg, error.BadForwardZone, "forward_zones[0].zone");
}

test "error.DuplicateForwardZone" {
    var cfg = baseConfig();
    cfg.forward_zones = &.{
        .{ .zone = "corp.lan", .resolver = "udp://192.168.1.1:53" },
        .{ .zone = "corp.lan", .resolver = "tcp://192.168.1.2:53" },
    };
    try expectProblem(cfg, error.DuplicateForwardZone, "forward_zones[1].zone");
}

test "error.BadResolverUrl" {
    var cfg = baseConfig();
    cfg.forward_zones = &.{.{ .zone = "corp.lan", .resolver = "https://resolver.example/" }};
    try expectProblem(cfg, error.BadResolverUrl, "forward_zones[0].resolver");
}

test "error.BadPort" {
    var cfg = baseConfig();
    cfg.dns.port = 0;
    try expectProblem(cfg, error.BadPort, "dns.port");
}

test "error.BadTimeout" {
    var cfg = baseConfig();
    cfg.upstream.read_timeout_ms = 10;
    try expectProblem(cfg, error.BadTimeout, "upstream.read_timeout_ms");

    var attempt = baseConfig();
    attempt.upstream.attempt_timeout_ms = 10;
    try expectProblem(attempt, error.BadTimeout, "upstream.attempt_timeout_ms");

    // An attempt budget above the total one can never be reached: the pool
    // cancels the whole loop when the total expires.
    var budget = baseConfig();
    budget.upstream = .{ .attempt_timeout_ms = 4000, .total_timeout_ms = 1000 };
    try expectProblem(budget, error.BadTimeout, "upstream.attempt_timeout_ms");

    // `read_timeout_ms` bounds the forward-zone client, a different subsystem,
    // so it is deliberately free to sit above the pool's total budget.
    var unrelated = baseConfig();
    unrelated.upstream = .{ .attempt_timeout_ms = 500, .read_timeout_ms = 9000, .total_timeout_ms = 1000 };
    try expectClean(unrelated);
}

test "error.BadCacheSize" {
    var zero = baseConfig();
    zero.cache.size = 0;
    try expectProblem(zero, error.BadCacheSize, "cache.size");

    // The slot array is allocated in full at boot, so an unchecked maxInt is a
    // startup that asks the kernel for more memory than the box has.
    var huge = baseConfig();
    huge.cache.size = std.math.maxInt(u32);
    try expectProblem(huge, error.BadCacheSize, "cache.size");

    var edge = baseConfig();
    edge.cache.size = max_boot_entries;
    try expectClean(edge);
}

test "error.BadTtl" {
    var cfg = baseConfig();
    cfg.blocking.ttl = 90_000;
    try expectProblem(cfg, error.BadTtl, "blocking.ttl");

    var record = baseConfig();
    record.local_records = &.{.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5", .ttl = 0 }};
    try expectProblem(record, error.BadTtl, "local_records[0].ttl");
}

test "error.BadRetention" {
    var cfg = baseConfig();
    cfg.logging.retention_days = 0;
    try expectProblem(cfg, error.BadRetention, "logging.retention_days");

    var zero_buffer = baseConfig();
    zero_buffer.logging.query_log_buffer_max = 0;
    try expectProblem(zero_buffer, error.BadRetention, "logging.query_log_buffer_max");

    // The ring is allocated in full at boot; an Entry is a few hundred bytes,
    // so maxInt(u32) asks for terabytes before the first query arrives.
    var huge_buffer = baseConfig();
    huge_buffer.logging.query_log_buffer_max = 4_000_000_000;
    try expectProblem(huge_buffer, error.BadRetention, "logging.query_log_buffer_max");
}

test "error.BadFlushInterval" {
    var cfg = baseConfig();
    cfg.logging.query_log_flush_interval_s = 3601;
    try expectProblem(cfg, error.BadFlushInterval, "logging.query_log_flush_interval_s");

    // 0 is the "do not wait" setting and 3600 is the ceiling itself: both are
    // legal, and a floor check would reject the first.
    var immediate = baseConfig();
    immediate.logging.query_log_flush_interval_s = 0;
    try expectClean(immediate);

    var edge = baseConfig();
    edge.logging.query_log_flush_interval_s = max_flush_interval_s;
    try expectClean(edge);
}

test "error.BadLogRotation" {
    var cfg = baseConfig();
    cfg.logging.max_files = 0;
    try expectProblem(cfg, error.BadLogRotation, "logging.max_files");
}

test "error.BadDiskThresholds" {
    var cfg = baseConfig();
    cfg.disk = .{ .min_free_mb = 600, .warn_free_mb = 500 };
    try expectProblem(cfg, error.BadDiskThresholds, "disk.min_free_mb");
}

test "error.BadRateLimit" {
    var cfg = baseConfig();
    cfg.dns.rate_limit = 0;
    try expectProblem(cfg, error.BadRateLimit, "dns.rate_limit");
}

test "error.BadBindAddress" {
    var cfg = baseConfig();
    cfg.dns.bind_ipv4 = "::";
    try expectProblem(cfg, error.BadBindAddress, "dns.bind_ipv4");

    var web = baseConfig();
    web.web.bind = "not an address";
    try expectProblem(web, error.BadBindAddress, "web.bind");
}

test "error.BadBindAddress on an IPv4 literal in dns.bind_ipv6" {
    var cfg = baseConfig();
    cfg.dns.bind_ipv6 = "0.0.0.0";
    try expectProblem(cfg, error.BadBindAddress, "dns.bind_ipv6");
}

test "error.BadTrustedProxy" {
    var cfg = baseConfig();
    cfg.web.trusted_proxies = "127.0.0.1, proxy.example";
    try expectProblem(cfg, error.BadTrustedProxy, "web.trusted_proxies");

    // A stray comma is named rather than dropped, so the operator sees the typo.
    var trailing = baseConfig();
    trailing.web.trusted_proxies = "127.0.0.1,";
    try expectProblem(trailing, error.BadTrustedProxy, "web.trusted_proxies");
}

test "a list of IP literals, and an empty one, both validate" {
    var cfg = baseConfig();
    cfg.web.trusted_proxies = " 127.0.0.1 , ::1,10.0.0.1 ";
    try expectClean(cfg);

    cfg.web.trusted_proxies = "";
    try expectClean(cfg);
}

test "error.MissingCertPath" {
    var cfg = baseConfig();
    cfg.doh_server = .{ .enabled = true, .cert_path = "" };
    try expectProblem(cfg, error.MissingCertPath, "doh_server.cert_path");
}

test "error.MissingKeyPath" {
    var cfg = baseConfig();
    cfg.dot_server = .{ .enabled = true, .port = 853, .key_path = "" };
    try expectProblem(cfg, error.MissingKeyPath, "dot_server.key_path");
}

test "error.MissingLogPath" {
    var cfg = baseConfig();
    cfg.logging.output = .file;
    cfg.logging.file_path = "";
    try expectProblem(cfg, error.MissingLogPath, "logging.file_path");

    var relative = baseConfig();
    relative.logging.output = .file;
    relative.logging.file_path = "nxdns.log";
    try expectProblem(relative, error.MissingLogPath, "logging.file_path");
}

test "error.PasswordAndHashBothSet" {
    var cfg = baseConfig();
    cfg.web.password = "hunter2";
    cfg.web.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def";
    try expectProblem(cfg, error.PasswordAndHashBothSet, "web.password");
}

test "error.EmptyWebPassword names password_hash as the way to disable auth" {
    var cfg = baseConfig();
    cfg.web.password = "";
    try expectProblem(cfg, error.EmptyWebPassword, "web.password");

    // The remedy has to be in the text: the operator who wrote `password = ""`
    // meant either "keep the current one" or "turn authentication off", and the
    // diagnostic is the only place that distinction is spelled out.
    var diags: Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    try testing.expectError(error.EmptyWebPassword, validate(cfg, &diags));
    try testing.expect(std.mem.indexOf(u8, diags.problems.items[0].message, "password_hash = \"\"") != null);

    // Absence is not emptiness: a file that states no password is legal and
    // means "keep the stored hash".
    var absent = baseConfig();
    absent.web.password = null;
    var quiet: Diagnostics = .init(testing.allocator);
    defer quiet.deinit();
    try validate(absent, &quiet);
}

test "a config with five distinct problems yields five diagnostics and the first error" {
    var cfg = baseConfig();
    cfg.dns.port = 0; // BadPort, first in check order
    cfg.blocking.ttl = 90_000; // BadTtl
    cfg.logging.retention_days = 0; // BadRetention
    cfg.disk = .{ .min_free_mb = 600, .warn_free_mb = 500 }; // BadDiskThresholds
    cfg.clients = &.{.{ .ip = "nonsense" }}; // BadClientIp

    var diags: Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    try testing.expectError(error.BadPort, validate(cfg, &diags));
    try testing.expectEqual(@as(usize, 5), diags.problems.items.len);
    try testing.expectEqual(@as(usize, 5), diags.failureCount());
    try testing.expectEqualStrings("dns.port", diags.problems.items[0].path);

    var buf: [1024]u8 = undefined;
    var w: Writer = .fixed(&buf);
    try diags.writeAll(&w);
    try testing.expect(std.mem.startsWith(u8, w.buffered(), "FAIL dns.port: "));
    try testing.expectEqual(@as(usize, 5), std.mem.count(u8, w.buffered(), "\n"));
}

test "duplicate client ips are detected across canonical forms" {
    var cfg = baseConfig();
    cfg.clients = &.{ .{ .ip = "fd00::1" }, .{ .ip = "FD00:0:0:0:0:0:0:1" } };
    try expectProblem(cfg, error.DuplicateClientIp, "clients[1].ip");
}

test "duplicate client prefixes are detected across canonical forms" {
    var cfg = baseConfig();
    cfg.client_prefixes = &.{
        .{ .prefix = "fd00:abcd::/48" },
        .{ .prefix = "FD00:ABCD:0:1234::5/48" },
    };
    try expectProblem(cfg, error.DuplicateClientPrefix, "client_prefixes[1].prefix");
}

test "an unknown group is reported from each of the four referencing collections" {
    var clients = baseConfig();
    clients.clients = &.{.{ .ip = "192.168.1.10", .group = "kids" }};
    try expectProblem(clients, error.UnknownGroup, "clients[0].group");

    var prefixes = baseConfig();
    prefixes.client_prefixes = &.{.{ .prefix = "192.168.2.0/24", .group = "kids" }};
    try expectProblem(prefixes, error.UnknownGroup, "client_prefixes[0].group");

    var links = baseConfig();
    links.blocklist_sources = &.{.{ .url = "https://lists.example/hosts.txt", .name = "example" }};
    links.group_sources = &.{.{ .group = "kids", .source_url = "https://lists.example/hosts.txt" }};
    try expectProblem(links, error.UnknownGroup, "group_sources[0].group");

    var rules = baseConfig();
    rules.rules = &.{.{ .group = "kids", .pattern = "ads.example", .kind = .exact, .action = .block }};
    try expectProblem(rules, error.UnknownGroup, "rules[0].group");
}

test "an unknown source is reported from group_sources" {
    var cfg = baseConfig();
    cfg.blocklist_sources = &.{.{ .url = "https://lists.example/a.txt", .name = "a" }};
    cfg.group_sources = &.{.{ .group = "default", .source_url = "https://lists.example/b.txt" }};
    try expectProblem(cfg, error.UnknownSource, "group_sources[0].source_url");
}

test "rule patterns accept wildcards only when the kind says so" {
    var wildcard = baseConfig();
    wildcard.rules = &.{
        .{ .group = "default", .pattern = "*.ads.example", .kind = .wildcard, .action = .block },
        .{ .group = "default", .pattern = "*", .kind = .wildcard, .action = .allow },
    };
    try expectClean(wildcard);

    // A wildcard kind needs a label that is exactly "*".
    var partial = baseConfig();
    partial.rules = &.{.{ .group = "default", .pattern = "ads*.example", .kind = .wildcard, .action = .block }};
    try expectProblem(partial, error.BadRulePattern, "rules[0].pattern");

    // Every non-star label still has to be a legal label.
    const too_long = "a" ** 64;
    var bad_label = baseConfig();
    bad_label.rules = &.{.{ .group = "default", .pattern = "*." ++ too_long, .kind = .wildcard, .action = .block }};
    try expectProblem(bad_label, error.BadRulePattern, "rules[0].pattern");
}

/// The message of the first failure, so a test can assert the sentence an
/// operator reads and not only the error tag.
fn expectMessage(cfg: Config, expected: ValidateError, expected_message: []const u8) !void {
    var diags: Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    try testing.expectError(expected, validate(cfg, &diags));
    const failure = diags.firstFailure() orelse return error.TestExpectedFailure;
    try testing.expectEqualStrings(expected_message, failure.message);
}

/// The tail of the first failure's message. `quoteText` truncates the value it
/// quotes at `safe_url.max_len`, so a diagnostic about an over-long pattern
/// cannot be matched whole.
fn expectMessageSuffix(cfg: Config, expected: ValidateError, expected_suffix: []const u8) !void {
    var diags: Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    try testing.expectError(expected, validate(cfg, &diags));
    const failure = diags.firstFailure() orelse return error.TestExpectedFailure;
    if (!std.mem.endsWith(u8, failure.message, expected_suffix)) {
        std.debug.print("message {s} does not end with {s}\n", .{ failure.message, expected_suffix });
        return error.TestExpectedEqual;
    }
}

fn regexRule(pattern: []const u8) [1]model.Rule {
    return .{.{ .group = "default", .pattern = pattern, .kind = .regex, .action = .block }};
}

test "a regex rule is validated by compiling it" {
    var cfg = baseConfig();
    const good = regexRule("^ad[0-9]+-\\.(example|test)\\.com$");
    cfg.rules = &good;
    try expectClean(cfg);

    // A regex is not a name: the wildcard and exact kinds reject `*`, and this
    // one has to accept the characters that make a pattern a pattern.
    const starred = regexRule("ads.*\\.example");
    cfg.rules = &starred;
    try expectClean(cfg);
}

test "each of the regex engine's three refusals names itself in the diagnostic" {
    var cfg = baseConfig();

    const unclosed = regexRule("(");
    cfg.rules = &unclosed;
    try expectProblem(cfg, error.BadRulePattern, "rules[0].pattern");
    try expectMessage(cfg, error.BadRulePattern, "'(' is not a valid regex pattern");

    // Too long and too complex are the two an operator cannot see by reading
    // the pattern, so the message has to carry the limit that fired.
    const too_long = regexRule("a" ** (regex.max_pattern_len + 1));
    cfg.rules = &too_long;
    try expectMessageSuffix(
        cfg,
        error.BadRulePattern,
        "is not a valid regex pattern (over 256 bytes)",
    );

    // Well inside 256 bytes of pattern, well past 1024 instructions of program.
    const too_complex = regexRule("(abcdefghij){200}");
    cfg.rules = &too_complex;
    try expectMessage(
        cfg,
        error.BadRulePattern,
        "'(abcdefghij){200}' is not a valid regex pattern (over 1024 compiled instructions)",
    );
}

test "an empty regex pattern is refused rather than matching every name" {
    var cfg = baseConfig();
    const empty = regexRule("");
    cfg.rules = &empty;
    try expectProblem(cfg, error.BadRulePattern, "rules[0].pattern");
}

test "parseResolver accepts udp and tcp with an IP literal and a port" {
    const udp4 = try parseResolver("udp://192.168.1.1:53");
    try testing.expectEqual(ResolverScheme.udp, udp4.scheme);
    try testing.expectEqual(@as(u16, 53), udp4.port);
    try testing.expect(udp4.addr.eql(try NetAddress.parse("192.168.1.1")));

    const tcp6 = try parseResolver("tcp://[fd00::1]:53");
    try testing.expectEqual(ResolverScheme.tcp, tcp6.scheme);
    try testing.expectEqual(@as(u16, 53), tcp6.port);
    try testing.expect(tcp6.addr.eql(try NetAddress.parse("fd00::1")));
}

test "parseResolver rejects everything else" {
    try testing.expectError(error.UnsupportedScheme, parseResolver("https://x/"));
    try testing.expectError(error.UnsupportedScheme, parseResolver("192.168.1.1:53"));
    try testing.expectError(error.BadHost, parseResolver("udp://host.name:53"));
    try testing.expectError(error.BadPort, parseResolver("udp://1.1.1.1"));
    try testing.expectError(error.BadPort, parseResolver("udp://1.1.1.1:0"));
    try testing.expectError(error.BadPort, parseResolver("udp://1.1.1.1:70000"));
    try testing.expectError(error.BadPort, parseResolver("tcp://[fd00::1]"));
    try testing.expectError(error.BadUrl, parseResolver("udp://1.1.1.1:53/path"));
    try testing.expectError(error.BadUrl, parseResolver("udp://user@1.1.1.1:53"));
    try testing.expectError(error.BadHost, parseResolver("udp://:53"));
    try testing.expectError(error.BadHost, parseResolver("udp://"));
    try testing.expectError(error.BadUrl, parseResolver("udp://[fd00::1:53"));
    try testing.expectError(error.BadHost, parseResolver("udp://[]:53"));
    try testing.expectError(error.BadUrl, parseResolver("udp://[fd00::1]x:53"));
    try testing.expectError(error.BadHost, parseResolver("udp://[192.168.1.1]:53"));
    try testing.expectError(error.BadPort, parseResolver("udp://1.1.1.1:"));
    try testing.expectError(error.BadUrl, parseResolver("udp://1.1.1.1:5 3"));
    try testing.expectError(error.BadPort, parseResolver("udp://1.1.1.1:+53"));
}

test "parseAuthority splits the host from the port" {
    const plain = try parseAuthority("lists.example");
    try testing.expectEqualStrings("lists.example", plain.host);
    try testing.expectEqual(@as(?u16, null), plain.port);
    try testing.expect(!plain.bracketed);

    const with_port = try parseAuthority("nas.lan:8080");
    try testing.expectEqualStrings("nas.lan", with_port.host);
    try testing.expectEqual(@as(?u16, 8080), with_port.port);

    const v6 = try parseAuthority("[fd00::1]:853");
    try testing.expectEqualStrings("fd00::1", v6.host);
    try testing.expectEqual(@as(?u16, 853), v6.port);
    try testing.expect(v6.bracketed);

    const v6_no_port = try parseAuthority("[fd00::1]");
    try testing.expectEqualStrings("fd00::1", v6_no_port.host);
    try testing.expectEqual(@as(?u16, null), v6_no_port.port);
}
