//! DNS over TLS upstream client (RFC 7858).
//!
//! DoT is DNS over TCP with a TLS layer in between: the same 2-byte big-endian
//! length prefix as RFC 1035 §4.2.2, carried on the plaintext side of the TLS
//! stream. All TLS work goes through `platform/tls_client.zig`; this file never
//! touches `std.crypto.tls.Client` directly.
//!
//! Every failure is classified by the phase it happened in — connect, handshake,
//! send, receive — after `transport.mapLocal` has had a chance to claim it. A
//! local resource error or a cancellation must never reach the pool as a peer
//! fault, so the concrete error is unwrapped from `error.ReadFailed` /
//! `error.WriteFailed` before it is classified.

const std = @import("std");
const net = std.Io.net;
const tls = std.crypto.tls;
const Certificate = std.crypto.Certificate;

const safe_url = @import("../safe_url.zig");
const transport = @import("transport.zig");
const tls_client = @import("../platform/tls_client.zig");

const log = std.log.scoped(.dot_client);

/// One diagnostic line about one client, as a value rather than a format string
/// repeated at each call site.
///
/// Two reasons, in that order. The url is redacted in exactly one place, so a
/// line added later cannot print it whole — the defect this type closes was four
/// call sites each formatting `endpoint.url` with `{s}`, missed by three review
/// rounds because each looked like the three beside it. And a `std.log` line is
/// not readable from a unit test under the default test runner, which installs
/// its own `std_options`; the tests below read this value instead of stderr.
const Diagnostic = struct {
    endpoint: transport.Endpoint,
    detail: Detail,

    const Detail = union(enum) {
        /// `resolveAddress` refused the host.
        not_an_ip_literal,
        connect_failed: anyerror,
        handshake_failed: Handshake,
        bundle_load_failed: anyerror,

        const Handshake = struct {
            verify_name: []const u8,
            cause: anyerror,
        };
    };

    pub fn format(self: Diagnostic, w: *std.Io.Writer) std.Io.Writer.Error!void {
        try w.print("dot upstream {f}: ", .{safe_url.redactQuoted(self.endpoint.url)});
        switch (self.detail) {
            // The redacted url ends in the host and the port, so naming the host
            // again would add nothing but an unredacted copy of it.
            .not_an_ip_literal => try w.writeAll("host is not an IP literal"),
            .connect_failed => |err| try w.print("connect failed: {s}", .{@errorName(err)}),
            // `verify_name` is a host name rather than a url, so it carries no
            // component redaction could drop. It goes through `quoteText` for
            // what that does to any operator-supplied string: it delimits it,
            // escapes it and bounds it.
            .handshake_failed => |hs| try w.print("TLS handshake as {f} failed: {s} ({t})", .{
                safe_url.quoteText(hs.verify_name),
                @errorName(hs.cause),
                tls_client.classify(hs.cause),
            }),
            .bundle_load_failed => |err| try w.print("CA bundle load failed: {s}", .{@errorName(err)}),
        }
    }
};

pub const ResolveError = error{ConnectFailed};

/// DoT endpoints take IP literals. Name resolution for upstreams is out of
/// scope for this milestone, and resolving silently would hide a config error
/// behind a slow, confusing failure, so a non-literal host fails immediately.
pub fn resolveAddress(endpoint: transport.Endpoint) ResolveError!net.IpAddress {
    return net.IpAddress.parse(endpoint.host, endpoint.port) catch error.ConnectFailed;
}

pub const DotClient = struct {
    endpoint: transport.Endpoint,
    /// SNI, and the name matched against the leaf certificate. The dial target
    /// stays `endpoint.host`, so this is what lets an upstream configured as an
    /// IP literal verify: `std.crypto.Certificate.Parsed.verifyHostName` matches
    /// dNSName SANs only and never an IP SAN, so `tls://1.1.1.1:853` alone is
    /// always `error.CertificateHostMismatch`. Borrowed, like `endpoint`.
    verify_name: []const u8,
    gpa: std.mem.Allocator,
    /// Caller-owned, shared across endpoints.
    bundle: *Certificate.Bundle,
    /// Caller-owned, guards `bundle`.
    bundle_lock: *std.Io.RwLock,
    /// Caller-owned. One `DotClient` is used by one task at a time.
    buffers: Buffers,

    pub const Buffers = struct {
        /// Plaintext read buffer.
        tls_read: []u8,
        /// Plaintext write buffer.
        tls_write: []u8,
        /// Ciphertext read buffer.
        stream_read: []u8,
        /// Ciphertext write buffer.
        stream_write: []u8,
    };

    /// A `.doh` endpoint or an undersized buffer is a wiring bug in this
    /// process, not a runtime condition, so both are assertions.
    ///
    /// An empty `tls_name` keeps the endpoint's own host as the verification
    /// name, which is correct whenever the url already carries a DNS name.
    pub fn init(
        endpoint: transport.Endpoint,
        tls_name: []const u8,
        gpa: std.mem.Allocator,
        bundle: *Certificate.Bundle,
        bundle_lock: *std.Io.RwLock,
        buffers: Buffers,
    ) DotClient {
        std.debug.assert(endpoint.scheme == .dot);
        std.debug.assert(buffers.tls_read.len >= tls.Client.min_buffer_len);
        std.debug.assert(buffers.tls_write.len >= tls.Client.min_buffer_len);
        std.debug.assert(buffers.stream_read.len >= tls.Client.min_buffer_len);
        std.debug.assert(buffers.stream_write.len >= tls.Client.min_buffer_len);
        return .{
            .endpoint = endpoint,
            .verify_name = if (tls_name.len == 0) endpoint.host else tls_name,
            .gpa = gpa,
            .bundle = bundle,
            .bundle_lock = bundle_lock,
            .buffers = buffers,
        };
    }

    pub fn client(self: *DotClient) transport.Client {
        return .{ .ptr = self, .exchangeFn = exchangeFn };
    }

    fn diagnose(self: *const DotClient, detail: Diagnostic.Detail) Diagnostic {
        return .{ .endpoint = self.endpoint, .detail = detail };
    }

    fn exchangeFn(
        ptr: *anyopaque,
        io: std.Io,
        query: []const u8,
        response_buf: []u8,
    ) transport.ExchangeError![]u8 {
        const self: *DotClient = @ptrCast(@alignCast(ptr));
        return self.exchange(io, query, response_buf);
    }

    /// One TCP connection and one TLS handshake per exchange, both closed
    /// before returning.
    ///
    /// Connection reuse is deliberately not built. At household query rates the
    /// saved round trips are worth less than what a per-exchange connection
    /// buys: the pool's per-attempt budget stays a plain race against one task,
    /// the failover path never has to reason about a half-dead pooled socket,
    /// and every failure is attributable to exactly one exchange.
    pub fn exchange(
        self: *DotClient,
        io: std.Io,
        query: []const u8,
        response_buf: []u8,
    ) transport.ExchangeError![]u8 {
        // The length prefix is 16-bit, so a longer query cannot be framed. No
        // listener in this process can produce one; a caller that does gets a
        // local error rather than a silently truncated frame.
        if (query.len > transport.max_message_len) return error.BufferTooSmall;

        const address = resolveAddress(self.endpoint) catch |err| {
            log.warn("{f}", .{self.diagnose(.not_an_ip_literal)});
            return err;
        };

        try self.ensureBundle(io);

        var stream = address.connect(io, .{ .mode = .stream }) catch |err| {
            log.debug("{f}", .{self.diagnose(.{ .connect_failed = err })});
            return transport.mapPhase(err, error.ConnectFailed);
        };
        defer transport.closeBlocked(io, &stream);

        // `TlsStream` is pinned: it holds its reader and writer by value and the
        // TLS client points at them, so it must not move after `init`.
        var tls_stream: tls_client.TlsStream = undefined;
        // `concreteHandshake` reads these two fields when the handshake reports
        // `error.ReadFailed` / `error.WriteFailed`. `TlsStream.init` sets them
        // before it can produce either error, but clearing them here keeps that
        // out of this file's correctness argument.
        tls_stream.stream_reader.err = null;
        tls_stream.stream_writer.err = null;
        tls_stream.init(io, &stream, self.bundle, self.bundle_lock, self.gpa, .{
            .host = self.verify_name,
            .ca = .system,
            .read_buffer = self.buffers.tls_read,
            .write_buffer = self.buffers.tls_write,
            .stream_read_buffer = self.buffers.stream_read,
            .stream_write_buffer = self.buffers.stream_write,
        }) catch |err| {
            const cause = concreteHandshake(&tls_stream, err);
            log.warn("{f}", .{self.diagnose(.{ .handshake_failed = .{
                .verify_name = self.verify_name,
                .cause = cause,
            } })});
            return transport.mapPhase(cause, error.TlsFailed);
        };
        defer transport.closeBlocked(io, &tls_stream);

        const writer = tls_stream.writer();
        const prefix = transport.framePrefix(@intCast(query.len));
        writer.writeAll(&prefix) catch |err| return sendFailure(&tls_stream, err);
        writer.writeAll(query) catch |err| return sendFailure(&tls_stream, err);
        // `TlsStream.flush`, not `writer.flush`: the latter leaves the encrypted
        // record in the socket writer's buffer and the query never leaves this
        // process.
        tls_stream.flush() catch |err| return sendFailure(&tls_stream, err);

        const reader = tls_stream.reader();
        var prefix_bytes: [transport.prefix_len]u8 = undefined;
        reader.readSliceAll(&prefix_bytes) catch |err| return receiveFailure(&tls_stream, err);

        const len = transport.parsePrefix(prefix_bytes);
        if (len == 0) return error.BadResponse;
        if (len > response_buf.len) return error.ResponseTooLarge;
        reader.readSliceAll(response_buf[0..len]) catch |err|
            return receiveFailure(&tls_stream, err);

        try transport.validateResponse(query, response_buf[0..len]);
        return response_buf[0..len];
    }

    /// Loads the system CA bundle before the handshake, so that a failure to
    /// read it keeps its concrete cause.
    ///
    /// `TlsStream.init` loads the bundle as well and returns early once
    /// `bundle` holds entries, so this runs the scan at most once per process.
    /// It exists because `TlsStream.init` folds every scan failure except
    /// cancellation into `error.CertificateBundleLoadFailure`. That name cannot
    /// tell an `error.OutOfMemory` from a corrupt PEM file, and the first is a
    /// local resource failure that must not count against the upstream's
    /// health. Scanning here keeps the concrete error for `transport.mapPhase`.
    fn ensureBundle(self: *DotClient, io: std.Io) transport.ExchangeError!void {
        {
            try self.bundle_lock.lockShared(io);
            defer self.bundle_lock.unlockShared(io);
            if (self.bundle.map.count() != 0) return;
        }

        try self.bundle_lock.lock(io);
        defer self.bundle_lock.unlock(io);
        if (self.bundle.map.count() != 0) return;

        // A partial scan leaves entries in `map`, which the check above would
        // read as "already loaded". Reset so the next exchange scans again.
        self.bundle.rescan(self.gpa, io, std.Io.Clock.real.now(io)) catch |err| {
            self.bundle.deinit(self.gpa);
            self.bundle.* = .empty;
            log.warn("{f}", .{self.diagnose(.{ .bundle_load_failed = err })});
            return transport.mapPhase(err, error.TlsFailed);
        };
    }
};

/// The handshake reads and writes through the socket reader and writer, so a
/// cancelled or resource-starved handshake surfaces as `error.ReadFailed` /
/// `error.WriteFailed` with the cause stashed on those two. Without this,
/// `error.Canceled` and `error.SystemResources` would reach the pool as
/// `error.TlsFailed` and count against the upstream's health.
///
/// Only the socket reader and writer are consulted: `tls.Client.init` returns
/// its error before `TlsStream.client` is assigned, so `client.read_err` does
/// not exist yet on this path.
fn concreteHandshake(stream: *tls_client.TlsStream, err: anyerror) anyerror {
    return switch (err) {
        error.ReadFailed => stream.stream_reader.err orelse err,
        error.WriteFailed => stream.stream_writer.err orelse err,
        else => err,
    };
}

/// `Io.Reader` collapses everything to `error.ReadFailed` and stashes the cause.
/// Unwrapping it is what keeps `error.Canceled` and the local resource errors
/// out of the health counters.
fn concreteRead(stream: *tls_client.TlsStream, err: anyerror) anyerror {
    if (err != error.ReadFailed) return err;
    if (stream.client.read_err) |cause| return cause;
    if (stream.stream_reader.err) |cause| return cause;
    return err;
}

fn concreteWrite(stream: *tls_client.TlsStream, err: anyerror) anyerror {
    if (err != error.WriteFailed) return err;
    if (stream.stream_writer.err) |cause| return cause;
    return err;
}

fn sendFailure(stream: *tls_client.TlsStream, err: anyerror) transport.ExchangeError {
    return transport.mapPhase(concreteWrite(stream, err), error.SendFailed);
}

fn receiveFailure(stream: *tls_client.TlsStream, err: anyerror) transport.ExchangeError {
    return transport.mapPhase(concreteRead(stream, err), error.ReceiveFailed);
}

const testing = std.testing;

fn expectDiagnostic(expected: []const u8, url: []const u8, detail: Diagnostic.Detail) !void {
    var buf: [8 * safe_url.max_len]u8 = undefined;
    const line = try std.fmt.bufPrint(&buf, "{f}", .{Diagnostic{
        .endpoint = .{ .scheme = .dot, .url = url, .host = "host.example", .port = 853, .path = "/" },
        .detail = detail,
    }});
    try testing.expectEqualStrings(expected, line);
}

test "every diagnostic line redacts the url it names the upstream by" {
    // The endpoints are built by hand rather than parsed, on purpose.
    // `Endpoint.parse` refuses `@`, `?` and `#` in the authority and refuses a
    // `.dot` path other than "/", so no url reaching this client through it can
    // carry a credential in a component `redact` drops. That is a property of a
    // parser one file away, not of this file, and these lines used to print
    // whatever `endpoint.url` held. The redaction is what keeps the parser's
    // rules from being load-bearing here.
    try expectDiagnostic(
        "dot upstream 'tls://dns.example': host is not an IP literal",
        "tls://user:hunter2@dns.example/abcd12",
        .not_an_ip_literal,
    );
    try expectDiagnostic(
        "dot upstream 'tls://dns.example:8853': connect failed: ConnectionRefused",
        "tls://dns.example:8853/abcd12",
        .{ .connect_failed = error.ConnectionRefused },
    );
    try expectDiagnostic(
        "dot upstream 'tls://dns.example': CA bundle load failed: FileNotFound",
        "tls://dns.example/abcd12?apikey=s3cr3t",
        .{ .bundle_load_failed = error.FileNotFound },
    );
    try expectDiagnostic(
        "dot upstream 'tls://dns.example': TLS handshake as 'one.one.one.one' failed: " ++
            "CertificateHostMismatch (certificate)",
        "tls://token@dns.example/abcd12",
        .{ .handshake_failed = .{
            .verify_name = "one.one.one.one",
            .cause = error.CertificateHostMismatch,
        } },
    );
}

test "a diagnostic escapes and bounds the operator-supplied text it prints" {
    // A control byte in either field would forge a second record on the one
    // output `std.log`'s own sink does not reach, and an unbounded host or
    // verification name would write an unbounded line.
    try expectDiagnostic(
        "dot upstream 'tls://dns.example\\n2026-01-01 ERROR forged': host is not an IP literal",
        "tls://dns.example\n2026-01-01 ERROR forged",
        .not_an_ip_literal,
    );
    try expectDiagnostic(
        "dot upstream 'tls://dns.example': TLS handshake as 'a\\nb' failed: TlsAlert (handshake)",
        "tls://dns.example",
        .{ .handshake_failed = .{ .verify_name = "a\nb", .cause = error.TlsAlert } },
    );

    // Doubling every operator-supplied field writes the same line, and each
    // field is built from the three byte costs at once: a printable byte spends
    // one character of the budget, `\n` spends two and `\x00` spends four. That
    // expansion is why `safe_url.max_len` counts printed characters rather than
    // source bytes, so the bound holds against the widest escape rather than in
    // spite of it.
    const long = "h\n\x00" ** (2 * safe_url.max_len);
    const longer = long ** 2;
    var short_buf: [16 * safe_url.max_len]u8 = undefined;
    var long_buf: [16 * safe_url.max_len]u8 = undefined;
    try testing.expectEqualStrings(
        try std.fmt.bufPrint(&short_buf, "{f}", .{Diagnostic{
            .endpoint = .{ .scheme = .dot, .url = "tls://" ++ long, .host = long, .port = 853, .path = "/" },
            .detail = .{ .handshake_failed = .{ .verify_name = long, .cause = error.TlsAlert } },
        }}),
        try std.fmt.bufPrint(&long_buf, "{f}", .{Diagnostic{
            .endpoint = .{ .scheme = .dot, .url = "tls://" ++ longer, .host = longer, .port = 853, .path = "/" },
            .detail = .{ .handshake_failed = .{ .verify_name = longer, .cause = error.TlsAlert } },
        }}),
    );
}

test "a diagnostic keeps what a parsed DoT url carries" {
    // The limit, pinned so it stays visible: a NextDNS DoT upstream is
    // `tls://abcd12.dns.nextdns.io`, whose hostname is the whole account
    // identifier. Redaction cannot remove it without leaving no host and an
    // unactionable line. See `safe_url.SafeUrl`.
    var buf: [8 * safe_url.max_len]u8 = undefined;
    const line = try std.fmt.bufPrint(&buf, "{f}", .{Diagnostic{
        .endpoint = try .parse("tls://abcd12.dns.nextdns.io"),
        .detail = .not_an_ip_literal,
    }});
    try testing.expectEqualStrings(
        "dot upstream 'tls://abcd12.dns.nextdns.io': host is not an IP literal",
        line,
    );
}

test "resolveAddress accepts IP literals" {
    const v4 = try resolveAddress(try .parse("tls://1.1.1.1:853"));
    try testing.expectEqual(@as(u16, 853), v4.ip4.port);
    try testing.expectEqualSlices(u8, &.{ 1, 1, 1, 1 }, &v4.ip4.bytes);

    const v6 = try resolveAddress(try .parse("tls://[2606:4700:4700::1111]"));
    try testing.expectEqual(@as(u16, transport.dot_default_port), v6.ip6.port);
}

test "resolveAddress rejects a non-literal host without touching the network" {
    try testing.expectError(
        error.ConnectFailed,
        resolveAddress(try .parse("tls://dns.google:853")),
    );
    try testing.expectError(
        error.ConnectFailed,
        resolveAddress(try .parse("tls://one.one.one.one")),
    );
}

/// Only the four `err` fields the unwrap helpers read are set; the rest of a
/// `TlsStream` is a socket reader, a socket writer and a TLS client, none of
/// which the helpers touch.
fn stubStream(
    read_err: ?net.Stream.Reader.Error,
    write_err: ?net.Stream.Writer.Error,
    tls_read_err: ?tls.Client.ReadError,
) tls_client.TlsStream {
    var stream: tls_client.TlsStream = undefined;
    stream.stream_reader.err = read_err;
    stream.stream_writer.err = write_err;
    stream.client.read_err = tls_read_err;
    return stream;
}

test "the handshake unwrap keeps a cancelled read out of the peer fault group" {
    var stream = stubStream(error.Canceled, null, null);
    const mapped = transport.mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed);
    try testing.expectEqual(transport.ExchangeError.Canceled, mapped);
    try testing.expectEqual(transport.Group.cancellation, transport.group(mapped));
}

test "the handshake unwrap keeps a local resource write failure out of the peer fault group" {
    var stream = stubStream(null, error.SystemResources, null);
    const mapped = transport.mapPhase(concreteHandshake(&stream, error.WriteFailed), error.TlsFailed);
    try testing.expectEqual(transport.ExchangeError.SystemResources, mapped);
    try testing.expectEqual(transport.Group.local_resource, transport.group(mapped));
}

test "the handshake unwrap reports a peer side cause as a TLS fault" {
    var reset = stubStream(error.ConnectionResetByPeer, null, null);
    try testing.expectEqual(
        transport.ExchangeError.TlsFailed,
        transport.mapPhase(concreteHandshake(&reset, error.ReadFailed), error.TlsFailed),
    );

    var refused = stubStream(null, error.ConnectionRefused, null);
    try testing.expectEqual(
        transport.ExchangeError.TlsFailed,
        transport.mapPhase(concreteHandshake(&refused, error.WriteFailed), error.TlsFailed),
    );
}

test "the handshake unwrap reports a TLS fault when no cause was stored" {
    var stream = stubStream(null, null, null);
    try testing.expectEqual(error.ReadFailed, concreteHandshake(&stream, error.ReadFailed));
    try testing.expectEqual(error.WriteFailed, concreteHandshake(&stream, error.WriteFailed));
    try testing.expectEqual(
        transport.ExchangeError.TlsFailed,
        transport.mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed),
    );
}

test "the handshake unwrap passes other errors through untouched" {
    var stream = stubStream(error.Canceled, error.Canceled, error.TlsAlert);
    try testing.expectEqual(
        error.CertificateExpired,
        concreteHandshake(&stream, error.CertificateExpired),
    );
    try testing.expectEqual(error.Canceled, concreteHandshake(&stream, error.Canceled));
    try testing.expectEqual(
        transport.ExchangeError.TlsFailed,
        transport.mapPhase(concreteHandshake(&stream, error.CertificateExpired), error.TlsFailed),
    );
    try testing.expectEqual(
        transport.ExchangeError.Canceled,
        transport.mapPhase(concreteHandshake(&stream, error.Canceled), error.TlsFailed),
    );
}

test "the send and receive unwraps prefer the stored cause" {
    var send = stubStream(null, error.Canceled, null);
    try testing.expectEqual(
        transport.ExchangeError.Canceled,
        sendFailure(&send, error.WriteFailed),
    );

    // The TLS client's own error wins over the socket reader's.
    var receive = stubStream(error.ConnectionResetByPeer, null, error.TlsAlert);
    try testing.expectEqual(
        transport.ExchangeError.ReceiveFailed,
        receiveFailure(&receive, error.ReadFailed),
    );

    var socket = stubStream(error.SystemResources, null, null);
    try testing.expectEqual(
        transport.ExchangeError.SystemResources,
        receiveFailure(&socket, error.ReadFailed),
    );
}

test "a CA bundle scan failure keeps local resource errors out of the peer fault group" {
    // `Certificate.Bundle.rescan` reaches these through `Allocator.Error`,
    // `Io.File.OpenError` and `Io.UnexpectedError`.
    const local = [_]anyerror{
        error.OutOfMemory,
        error.SystemResources,
        error.ProcessFdQuotaExceeded,
        error.SystemFdQuotaExceeded,
        error.Unexpected,
    };
    for (local) |err| {
        try testing.expectEqual(
            transport.Group.local_resource,
            transport.group(transport.mapPhase(err, error.TlsFailed)),
        );
    }

    try testing.expectEqual(
        transport.ExchangeError.Canceled,
        transport.mapPhase(error.Canceled, error.TlsFailed),
    );

    // A missing or corrupt bundle is not this process running out of anything,
    // so it stays a TLS fault.
    try testing.expectEqual(
        transport.ExchangeError.TlsFailed,
        transport.mapPhase(error.FileNotFound, error.TlsFailed),
    );
    try testing.expectEqual(
        transport.ExchangeError.TlsFailed,
        transport.mapPhase(error.MissingEndCertificateMarker, error.TlsFailed),
    );
}

test "DotClient satisfies the Client interface" {
    const gpa = testing.allocator;

    const buffer = try gpa.alloc(u8, 4 * tls.Client.min_buffer_len);
    defer gpa.free(buffer);
    const chunk = tls.Client.min_buffer_len;

    var bundle: Certificate.Bundle = .empty;
    defer bundle.deinit(gpa);
    var bundle_lock: std.Io.RwLock = .init;

    // `init` asserts `endpoint.scheme == .dot`; a `.doh` endpoint trips
    // `std.debug.assert`, which a test cannot catch in-process.
    var dot: DotClient = .init(try .parse("tls://9.9.9.9:853"), "", gpa, &bundle, &bundle_lock, .{
        .tls_read = buffer[0..chunk],
        .tls_write = buffer[chunk .. 2 * chunk],
        .stream_read = buffer[2 * chunk .. 3 * chunk],
        .stream_write = buffer[3 * chunk ..],
    });

    try testing.expectEqual(transport.Scheme.dot, dot.endpoint.scheme);
    try testing.expectEqualStrings("9.9.9.9", dot.endpoint.host);

    const iface: transport.Client = dot.client();
    try testing.expectEqual(@as(*anyopaque, @ptrCast(&dot)), iface.ptr);
}

test "a tls_name replaces the verification name and leaves the dial target alone" {
    const gpa = testing.allocator;

    const buffer = try gpa.alloc(u8, 4 * tls.Client.min_buffer_len);
    defer gpa.free(buffer);
    const chunk = tls.Client.min_buffer_len;

    var bundle: Certificate.Bundle = .empty;
    defer bundle.deinit(gpa);
    var bundle_lock: std.Io.RwLock = .init;

    const buffers: DotClient.Buffers = .{
        .tls_read = buffer[0..chunk],
        .tls_write = buffer[chunk .. 2 * chunk],
        .stream_read = buffer[2 * chunk .. 3 * chunk],
        .stream_write = buffer[3 * chunk ..],
    };

    const endpoint: transport.Endpoint = try .parse("tls://1.1.1.1:853");
    const named: DotClient = .init(endpoint, "one.one.one.one", gpa, &bundle, &bundle_lock, buffers);
    try testing.expectEqualStrings("one.one.one.one", named.verify_name);
    try testing.expectEqualStrings("1.1.1.1", named.endpoint.host);

    const address = try resolveAddress(named.endpoint);
    try testing.expectEqualSlices(u8, &.{ 1, 1, 1, 1 }, &address.ip4.bytes);
    try testing.expectEqual(@as(u16, 853), address.ip4.port);

    const plain: DotClient = .init(endpoint, "", gpa, &bundle, &bundle_lock, buffers);
    try testing.expectEqualStrings("1.1.1.1", plain.verify_name);
}
