Gates / frontend (push) Successful in 2m5s
Gates / test (push) Successful in 2m43s
Gates / test-aarch64 (push) Successful in 8m19s
Gates / package (push) Successful in 4m21s
Gates / container (push) Successful in 13s
CI / gates (push) Successful in 30m43s
495 lines
22 KiB
Zig
495 lines
22 KiB
Zig
//! RFC 8484 DNS over HTTPS upstream client.
|
|
//!
|
|
//! One `DohClient` wraps a caller-owned `std.http.Client`, which owns the
|
|
//! connection pool and the CA bundle. Several `DohClient` values can share one
|
|
//! `std.http.Client`, so a pool of DoH upstreams keeps one TLS connection per
|
|
//! host without this file knowing anything about pooling.
|
|
//!
|
|
//! HTTP/1.1 only — HTTP/2 is permanently out of scope (PLAN §2.2).
|
|
//!
|
|
//! Every buffer is caller-owned. Nothing here allocates, so an exchange cannot
|
|
//! fail for a reason this file invented.
|
|
|
|
const std = @import("std");
|
|
const transport = @import("transport.zig");
|
|
|
|
pub const media_type = "application/dns-message";
|
|
|
|
/// The smallest `request_buf` that can hold a real query: a header, a
|
|
/// maximum-length name, qtype/qclass and an OPT record all fit in 512 bytes.
|
|
pub const min_request_buf = 512;
|
|
pub const min_transfer_buf = 1024;
|
|
|
|
/// What `nxdns run` gives every DoH client, and what `nxdns check` probes an
|
|
/// upstream with. They live here rather than beside either caller because a
|
|
/// probe that used a different buffer than the running server would answer a
|
|
/// question nobody asked.
|
|
pub const default_request_buf_len = 1024;
|
|
pub const default_transfer_buf_len = 4096;
|
|
|
|
pub const DohClient = struct {
|
|
/// Caller-owned; shared across endpoints, pools connections.
|
|
http: *std.http.Client,
|
|
endpoint: transport.Endpoint,
|
|
/// Built once in `init` from `endpoint`.
|
|
uri: std.Uri,
|
|
/// Caller-owned. `Request.sendBodyComplete` takes `[]u8`, so the query has
|
|
/// to be copied out of the caller's const slice before it can be sent.
|
|
request_buf: []u8,
|
|
/// Caller-owned HTTP body transfer buffer.
|
|
transfer_buf: []u8,
|
|
|
|
pub const InitError = error{BadUrl};
|
|
|
|
/// `endpoint` stays the source of truth for host, port and scheme in logs;
|
|
/// `uri` exists only because `std.http.Client.request` takes one.
|
|
///
|
|
/// A non-DoH endpoint is `error.BadUrl` rather than an assert: the scheme
|
|
/// comes from configuration, so wiring a `tls://` URL to this client is a
|
|
/// config fault that must surface as a value, not a panic.
|
|
pub fn init(
|
|
http: *std.http.Client,
|
|
endpoint: transport.Endpoint,
|
|
request_buf: []u8,
|
|
transfer_buf: []u8,
|
|
) InitError!DohClient {
|
|
std.debug.assert(request_buf.len >= min_request_buf);
|
|
std.debug.assert(transfer_buf.len >= min_transfer_buf);
|
|
if (endpoint.scheme != .doh) return error.BadUrl;
|
|
const uri = std.Uri.parse(endpoint.url) catch return error.BadUrl;
|
|
return .{
|
|
.http = http,
|
|
.endpoint = endpoint,
|
|
.uri = uri,
|
|
.request_buf = request_buf,
|
|
.transfer_buf = transfer_buf,
|
|
};
|
|
}
|
|
|
|
pub fn leaf(self: *DohClient) transport.Leaf {
|
|
return .{ .ptr = self, .exchangeFn = exchangeFn };
|
|
}
|
|
|
|
fn exchangeFn(
|
|
ptr: *anyopaque,
|
|
io: std.Io,
|
|
query: []const u8,
|
|
response_buf: []u8,
|
|
) transport.LeafError!transport.Outcome {
|
|
const self: *DohClient = @ptrCast(@alignCast(ptr));
|
|
return self.exchange(io, query, response_buf);
|
|
}
|
|
|
|
/// Returns a prefix of `response_buf` holding a message that has already
|
|
/// passed `transport.validateResponse` against `query`.
|
|
///
|
|
/// The query ID is sent unchanged. RFC 8484 §4.1 suggests ID 0 so that HTTP
|
|
/// caches can share a response; nxdns puts no HTTP cache in this path, and
|
|
/// keeping the ID preserves the request/response binding that
|
|
/// `validateResponse` checks.
|
|
pub fn exchange(
|
|
self: *DohClient,
|
|
io: std.Io,
|
|
query: []const u8,
|
|
response_buf: []u8,
|
|
) transport.LeafError!transport.Outcome {
|
|
// `std.http.Client` carries the `std.Io` it was constructed with and
|
|
// takes none per request, so the interface's `io` is unused here. It
|
|
// stays in the signature because DoT and the pool need it.
|
|
_ = io;
|
|
|
|
if (query.len > self.request_buf.len) return error.BufferTooSmall;
|
|
@memcpy(self.request_buf[0..query.len], query);
|
|
|
|
var req = self.http.request(.POST, self.uri, .{
|
|
.keep_alive = true,
|
|
.redirect_behavior = .not_allowed,
|
|
.headers = .{
|
|
.content_type = .{ .override = media_type },
|
|
// A compressed body would need the decompressing reader and
|
|
// would stop being byte-exact, which `validateResponse` needs.
|
|
.accept_encoding = .{ .override = "identity" },
|
|
},
|
|
// `Request.Headers` has no `accept` field, so this one goes in by
|
|
// hand.
|
|
.extra_headers = &.{.{ .name = "accept", .value = media_type }},
|
|
}) catch |err| return fault(err, .connect);
|
|
defer req.deinit();
|
|
|
|
req.sendBodyComplete(self.request_buf[0..query.len]) catch |err|
|
|
return fault(sendCause(&req, err), .send);
|
|
|
|
// An empty redirect buffer is legal under `.not_allowed`: a redirect
|
|
// is an error before the location is ever read.
|
|
var resp = req.receiveHead(&.{}) catch |err| return fault(headCause(&req, err), .receive);
|
|
|
|
if (resp.head.status != .ok) return peerFault(error.HttpStatus, error.HttpStatus);
|
|
// `head.content_type` points into memory that `resp.reader` invalidates,
|
|
// so the check happens before the body stream starts.
|
|
if (!contentTypeOk(resp.head.content_type)) return peerFault(error.HttpContentType, error.HttpContentType);
|
|
if (resp.head.content_length) |declared| {
|
|
if (declared > response_buf.len) return peerFault(error.ResponseTooLarge, error.ResponseTooLarge);
|
|
}
|
|
|
|
const body = resp.reader(self.transfer_buf);
|
|
var len: usize = 0;
|
|
var ended = false;
|
|
while (len < response_buf.len) {
|
|
const n = body.readSliceShort(response_buf[len..]) catch |err|
|
|
return fault(bodyCause(&resp, err), .receive);
|
|
len += n;
|
|
if (n == 0) {
|
|
ended = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!ended) {
|
|
// `response_buf` filled exactly. One more read separates a message
|
|
// that fits from one that was cut off.
|
|
var probe: [1]u8 = undefined;
|
|
const n = body.readSliceShort(&probe) catch |err|
|
|
return fault(bodyCause(&resp, err), .receive);
|
|
if (n != 0) return peerFault(error.ResponseTooLarge, error.ResponseTooLarge);
|
|
}
|
|
|
|
transport.validateResponse(query, response_buf[0..len]) catch |err|
|
|
return peerFault(err, err);
|
|
return .{ .reply = response_buf[0..len] };
|
|
}
|
|
};
|
|
|
|
/// Which call failed. The phase is what decides the peer fault, and only the
|
|
/// call site knows it — guessing it from an error name would be wrong the first
|
|
/// time two phases shared an error.
|
|
const Phase = enum { connect, send, receive };
|
|
|
|
/// Every TLS failure this client can reach, named rather than matched by
|
|
/// prefix. Two groups:
|
|
///
|
|
/// - `std.http.Client.RequestError` collapses every handshake and bundle fault
|
|
/// into `TlsInitializationFailed` / `CertificateBundleLoadFailure`
|
|
/// (Client.zig:1470, :1717).
|
|
/// - `std.crypto.tls.Client.ReadError` is what `headCause`/`bodyCause` unwrap
|
|
/// out of a collapsed `error.ReadFailed`, through
|
|
/// `Connection.getReadError` (Client.zig:392), so its record-layer members
|
|
/// arrive here as themselves. Without them a decode error or a bad record MAC
|
|
/// would be reported as a plain receive failure.
|
|
fn fault(err: anyerror, phase: Phase) transport.LeafError!transport.Outcome {
|
|
if (transport.mapLocal(err)) |local| return local;
|
|
return peerFault(kindOf(err, phase), err);
|
|
}
|
|
|
|
/// A peer fault as an `Outcome`. Written out rather than inlined at every call
|
|
/// site so the classification and the cause cannot drift apart by a typo.
|
|
fn peerFault(kind: transport.PeerFault, cause: anyerror) transport.Outcome {
|
|
return .{ .fault = .{ .kind = kind, .cause = cause } };
|
|
}
|
|
|
|
fn kindOf(err: anyerror, phase: Phase) transport.PeerFault {
|
|
switch (err) {
|
|
error.TlsInitializationFailed,
|
|
error.CertificateBundleLoadFailure,
|
|
error.TlsAlert,
|
|
error.TlsBadLength,
|
|
error.TlsBadRecordMac,
|
|
error.TlsConnectionTruncated,
|
|
error.TlsDecodeError,
|
|
error.TlsRecordOverflow,
|
|
error.TlsUnexpectedMessage,
|
|
error.TlsIllegalParameter,
|
|
error.TlsSequenceOverflow,
|
|
=> return error.TlsFailed,
|
|
else => {},
|
|
}
|
|
return switch (phase) {
|
|
.connect => error.ConnectFailed,
|
|
.send => error.SendFailed,
|
|
.receive => error.ReceiveFailed,
|
|
};
|
|
}
|
|
|
|
// `error.X` in an expression names a member into existence rather than
|
|
// referring to one, so the switch above would keep compiling — and silently
|
|
// stop matching — if std renamed any of these. This block is what fails the
|
|
// build instead. Every member of the read-cause set must be classified, and the
|
|
// two collapsed names must still exist.
|
|
comptime {
|
|
for (@typeInfo(std.crypto.tls.Client.ReadError).error_set.?) |member| {
|
|
const value: anyerror = @field(std.crypto.tls.Client.ReadError, member.name);
|
|
if (kindOf(value, .receive) != error.TlsFailed) {
|
|
@compileError("unclassified TLS read cause: " ++ member.name);
|
|
}
|
|
}
|
|
for ([_][]const u8{ "TlsInitializationFailed", "CertificateBundleLoadFailure" }) |name| {
|
|
var found = false;
|
|
for (@typeInfo(std.http.Client.RequestError).error_set.?) |member| {
|
|
if (std.mem.eql(u8, member.name, name)) found = true;
|
|
}
|
|
if (!found) @compileError("std.http.Client.RequestError no longer names " ++ name);
|
|
}
|
|
}
|
|
|
|
const Connection = std.http.Client.Connection;
|
|
const Request = std.http.Client.Request;
|
|
const Response = std.http.Client.Response;
|
|
|
|
// The unwraps live in `transport.zig` because the blocklist fetcher needs the
|
|
// same concrete causes. They are aliased rather than qualified so the call
|
|
// sites and the tests below read as they did when they were local.
|
|
const sendCause = transport.sendCause;
|
|
const headCause = transport.headCause;
|
|
const bodyCause = transport.bodyCause;
|
|
|
|
/// RFC 8484 §6: the response media type is `application/dns-message`. The
|
|
/// header may carry parameters (`; charset=…`) and the type is case-insensitive
|
|
/// per RFC 9110 §8.3.1.
|
|
pub fn contentTypeOk(value: ?[]const u8) bool {
|
|
const raw = value orelse return false;
|
|
const without_params = if (std.mem.findScalar(u8, raw, ';')) |semi| raw[0..semi] else raw;
|
|
return std.ascii.eqlIgnoreCase(std.mem.trim(u8, without_params, " \t"), media_type);
|
|
}
|
|
|
|
const testing = std.testing;
|
|
|
|
test "init rejects a DoT endpoint before any io" {
|
|
var http: std.http.Client = undefined;
|
|
var request_buf: [min_request_buf]u8 = undefined;
|
|
var transfer_buf: [min_transfer_buf]u8 = undefined;
|
|
const endpoint = try transport.Endpoint.parse("tls://dns.google:853");
|
|
try testing.expectError(
|
|
error.BadUrl,
|
|
DohClient.init(&http, endpoint, &request_buf, &transfer_buf),
|
|
);
|
|
}
|
|
|
|
test "init builds a uri from the endpoint url" {
|
|
var http: std.http.Client = undefined;
|
|
var request_buf: [min_request_buf]u8 = undefined;
|
|
var transfer_buf: [min_transfer_buf]u8 = undefined;
|
|
const endpoint = try transport.Endpoint.parse("https://dns.example/dns-query");
|
|
const doh = try DohClient.init(&http, endpoint, &request_buf, &transfer_buf);
|
|
var component_buf: [256]u8 = undefined;
|
|
try testing.expectEqualStrings("https", doh.uri.scheme);
|
|
try testing.expectEqualStrings("dns.example", try doh.uri.host.?.toRaw(&component_buf));
|
|
try testing.expectEqualStrings("/dns-query", try doh.uri.path.toRaw(&component_buf));
|
|
try testing.expectEqualStrings("dns.example", doh.endpoint.host);
|
|
}
|
|
|
|
test "DohClient satisfies the transport.Leaf interface" {
|
|
var http: std.http.Client = undefined;
|
|
var request_buf: [min_request_buf]u8 = undefined;
|
|
var transfer_buf: [min_transfer_buf]u8 = undefined;
|
|
const endpoint = try transport.Endpoint.parse("https://dns.example/dns-query");
|
|
var doh = try DohClient.init(&http, endpoint, &request_buf, &transfer_buf);
|
|
|
|
// Instantiation is the check: the vtable is built from `exchangeFn`, so a
|
|
// signature drift is a compile error here. The `std.http.Client` above is
|
|
// never driven, and no exchange runs.
|
|
const c: transport.Leaf = doh.leaf();
|
|
try testing.expectEqual(@as(*anyopaque, @ptrCast(&doh)), c.ptr);
|
|
try testing.expectEqual(
|
|
@as(@TypeOf(c.exchangeFn), DohClient.exchangeFn),
|
|
c.exchangeFn,
|
|
);
|
|
}
|
|
|
|
test "exchange rejects a query larger than the request buffer" {
|
|
var http: std.http.Client = undefined;
|
|
var request_buf: [min_request_buf]u8 = undefined;
|
|
var transfer_buf: [min_transfer_buf]u8 = undefined;
|
|
const endpoint = try transport.Endpoint.parse("https://dns.example/dns-query");
|
|
var doh = try DohClient.init(&http, endpoint, &request_buf, &transfer_buf);
|
|
|
|
const oversized: [min_request_buf + 1]u8 = @splat(0);
|
|
var response_buf: [512]u8 = undefined;
|
|
// The size check precedes every use of `http`, so nothing is driven.
|
|
try testing.expectError(
|
|
error.BufferTooSmall,
|
|
doh.exchange(undefined, &oversized, &response_buf),
|
|
);
|
|
}
|
|
|
|
test "contentTypeOk accepts the RFC 8484 media type" {
|
|
try testing.expect(contentTypeOk("application/dns-message"));
|
|
try testing.expect(contentTypeOk("Application/DNS-Message"));
|
|
try testing.expect(contentTypeOk("application/dns-message; charset=utf-8"));
|
|
try testing.expect(contentTypeOk("application/dns-message ; charset=utf-8"));
|
|
try testing.expect(contentTypeOk(" application/dns-message "));
|
|
}
|
|
|
|
test "contentTypeOk rejects anything else" {
|
|
try testing.expect(!contentTypeOk(null));
|
|
try testing.expect(!contentTypeOk("text/html"));
|
|
try testing.expect(!contentTypeOk("application/json"));
|
|
try testing.expect(!contentTypeOk(""));
|
|
try testing.expect(!contentTypeOk("application/dns-message-extra"));
|
|
}
|
|
|
|
test "a local cause stays an error instead of becoming a fault" {
|
|
try testing.expectError(error.OutOfMemory, fault(error.OutOfMemory, .connect));
|
|
try testing.expectError(error.Canceled, fault(error.Canceled, .receive));
|
|
try testing.expectError(error.Unexpected, fault(error.Unexpected, .send));
|
|
}
|
|
|
|
test "kindOf maps the collapsed tls errors regardless of phase" {
|
|
try testing.expectEqual(error.TlsFailed, kindOf(error.TlsInitializationFailed, .connect));
|
|
try testing.expectEqual(error.TlsFailed, kindOf(error.TlsInitializationFailed, .receive));
|
|
try testing.expectEqual(error.TlsFailed, kindOf(error.CertificateBundleLoadFailure, .connect));
|
|
}
|
|
|
|
test "kindOf maps every unwrapped record-layer cause to TlsFailed" {
|
|
// The set is the one `Connection.getReadError` can hand back, so the loop
|
|
// fails the day std adds a member the switch does not name.
|
|
inline for (@typeInfo(std.crypto.tls.Client.ReadError).error_set.?) |member| {
|
|
try testing.expectEqual(
|
|
transport.PeerFault.TlsFailed,
|
|
kindOf(@field(std.crypto.tls.Client.ReadError, member.name), .receive),
|
|
);
|
|
}
|
|
}
|
|
|
|
test "kindOf maps remaining errors by phase" {
|
|
try testing.expectEqual(error.ConnectFailed, kindOf(error.ConnectionRefused, .connect));
|
|
try testing.expectEqual(error.SendFailed, kindOf(error.WriteFailed, .send));
|
|
try testing.expectEqual(error.ReceiveFailed, kindOf(error.ReadFailed, .receive));
|
|
try testing.expectEqual(error.ReceiveFailed, kindOf(error.HttpHeadersInvalid, .receive));
|
|
}
|
|
|
|
test "a fault carries the classification and the concrete cause" {
|
|
const failed = try faultOf(error.ConnectionRefused, .connect);
|
|
try testing.expectEqual(transport.PeerFault.ConnectFailed, failed.kind);
|
|
try testing.expectEqual(@as(anyerror, error.ConnectionRefused), failed.cause);
|
|
|
|
var buf: [64]u8 = undefined;
|
|
try testing.expectEqualStrings(
|
|
"ConnectFailed (cause ConnectionRefused)",
|
|
try std.fmt.bufPrint(&buf, "{f}", .{failed}),
|
|
);
|
|
}
|
|
|
|
/// Only the fields the unwrap helpers read are set. The rest of a `Connection`
|
|
/// is two buffered streams, a host name and a pool node, none of which the
|
|
/// helpers touch.
|
|
///
|
|
/// `.plain` on purpose: `Connection.getReadError` reaches a TLS connection's
|
|
/// stashed cause through `@fieldParentPtr`, which on a stub would read memory
|
|
/// that was never a `Tls`. What the test is about — that the accessor is
|
|
/// consulted at all — is the same on both protocols.
|
|
fn stubConnection(
|
|
read_err: ?std.Io.net.Stream.Reader.Error,
|
|
write_err: ?std.Io.net.Stream.Writer.Error,
|
|
) Connection {
|
|
var connection: Connection = undefined;
|
|
connection.protocol = .plain;
|
|
connection.stream_reader.err = read_err;
|
|
connection.stream_writer.err = write_err;
|
|
return connection;
|
|
}
|
|
|
|
/// The fault half of `fault`, for the unwrap tests. A local cause leaves this
|
|
/// as an error, which is what those tests assert instead.
|
|
fn faultOf(err: anyerror, phase: Phase) !transport.Fault {
|
|
return switch (try fault(err, phase)) {
|
|
.reply => error.TestExpectedFault,
|
|
.fault => |f| f,
|
|
};
|
|
}
|
|
|
|
fn stubRequest(connection: *Connection, body_err: ?std.http.Reader.BodyError) Request {
|
|
var req: Request = undefined;
|
|
req.connection = connection;
|
|
req.reader.body_err = body_err;
|
|
return req;
|
|
}
|
|
|
|
test "the send unwrap keeps a cancelled write out of the peer fault group" {
|
|
var connection = stubConnection(null, error.Canceled);
|
|
var req = stubRequest(&connection, null);
|
|
try testing.expectError(error.Canceled, fault(sendCause(&req, error.WriteFailed), .send));
|
|
}
|
|
|
|
test "the send unwrap keeps a local resource write failure out of the peer fault group" {
|
|
var connection = stubConnection(null, error.SystemResources);
|
|
var req = stubRequest(&connection, null);
|
|
try testing.expectError(error.SystemResources, fault(sendCause(&req, error.WriteFailed), .send));
|
|
}
|
|
|
|
test "the send unwrap reports a peer side cause as a send fault" {
|
|
var connection = stubConnection(null, error.ConnectionResetByPeer);
|
|
var req = stubRequest(&connection, null);
|
|
const failed = try faultOf(sendCause(&req, error.WriteFailed), .send);
|
|
try testing.expectEqual(transport.PeerFault.SendFailed, failed.kind);
|
|
try testing.expectEqual(@as(anyerror, error.ConnectionResetByPeer), failed.cause);
|
|
}
|
|
|
|
test "the head unwrap keeps a local resource read failure out of the peer fault group" {
|
|
var connection = stubConnection(error.SystemResources, null);
|
|
var req = stubRequest(&connection, null);
|
|
try testing.expectError(error.SystemResources, fault(headCause(&req, error.ReadFailed), .receive));
|
|
|
|
var canceled = stubConnection(error.Canceled, null);
|
|
var canceled_req = stubRequest(&canceled, null);
|
|
try testing.expectError(error.Canceled, fault(headCause(&canceled_req, error.ReadFailed), .receive));
|
|
}
|
|
|
|
test "the head unwrap reports a peer side cause as a receive fault" {
|
|
var connection = stubConnection(error.ConnectionResetByPeer, null);
|
|
var req = stubRequest(&connection, null);
|
|
const failed = try faultOf(headCause(&req, error.ReadFailed), .receive);
|
|
try testing.expectEqual(transport.PeerFault.ReceiveFailed, failed.kind);
|
|
try testing.expectEqual(@as(anyerror, error.ConnectionResetByPeer), failed.cause);
|
|
}
|
|
|
|
test "the body unwrap keeps a cancelled read out of the peer fault group" {
|
|
var connection = stubConnection(error.Canceled, null);
|
|
var req = stubRequest(&connection, null);
|
|
const resp: Response = .{ .request = &req, .head = undefined };
|
|
try testing.expectError(error.Canceled, fault(bodyCause(&resp, error.ReadFailed), .receive));
|
|
}
|
|
|
|
test "the body unwrap prefers an http framing fault over the connection" {
|
|
// A truncated chunk is the peer's doing and the connection carries no
|
|
// cause at all, so reading the connection first would report the wrong
|
|
// thing on the one path where both could be set.
|
|
var connection = stubConnection(null, null);
|
|
var req = stubRequest(&connection, error.HttpChunkTruncated);
|
|
const resp: Response = .{ .request = &req, .head = undefined };
|
|
try testing.expectEqual(error.HttpChunkTruncated, bodyCause(&resp, error.ReadFailed));
|
|
const failed = try faultOf(bodyCause(&resp, error.ReadFailed), .receive);
|
|
try testing.expectEqual(transport.PeerFault.ReceiveFailed, failed.kind);
|
|
try testing.expectEqual(@as(anyerror, error.HttpChunkTruncated), failed.cause);
|
|
}
|
|
|
|
test "the unwraps report the collapsed error when no cause was stored" {
|
|
var connection = stubConnection(null, null);
|
|
var req = stubRequest(&connection, null);
|
|
const resp: Response = .{ .request = &req, .head = undefined };
|
|
|
|
try testing.expectEqual(error.WriteFailed, sendCause(&req, error.WriteFailed));
|
|
try testing.expectEqual(error.ReadFailed, headCause(&req, error.ReadFailed));
|
|
try testing.expectEqual(error.ReadFailed, bodyCause(&resp, error.ReadFailed));
|
|
|
|
const sent = try faultOf(sendCause(&req, error.WriteFailed), .send);
|
|
try testing.expectEqual(transport.PeerFault.SendFailed, sent.kind);
|
|
try testing.expectEqual(@as(anyerror, error.WriteFailed), sent.cause);
|
|
|
|
const received = try faultOf(bodyCause(&resp, error.ReadFailed), .receive);
|
|
try testing.expectEqual(transport.PeerFault.ReceiveFailed, received.kind);
|
|
try testing.expectEqual(@as(anyerror, error.ReadFailed), received.cause);
|
|
}
|
|
|
|
test "the unwraps pass a non-collapsed error through untouched" {
|
|
// A stashed cause belongs to `error.ReadFailed` / `error.WriteFailed`. Any
|
|
// other error already names itself, so the stash must not be read over it.
|
|
var connection = stubConnection(error.Canceled, error.Canceled);
|
|
var req = stubRequest(&connection, error.HttpChunkInvalid);
|
|
const resp: Response = .{ .request = &req, .head = undefined };
|
|
|
|
try testing.expectEqual(error.EndOfStream, sendCause(&req, error.EndOfStream));
|
|
try testing.expectEqual(error.HttpHeadersInvalid, headCause(&req, error.HttpHeadersInvalid));
|
|
try testing.expectEqual(error.EndOfStream, bodyCause(&resp, error.EndOfStream));
|
|
const failed = try faultOf(headCause(&req, error.HttpHeadersInvalid), .receive);
|
|
try testing.expectEqual(transport.PeerFault.ReceiveFailed, failed.kind);
|
|
try testing.expectEqual(@as(anyerror, error.HttpHeadersInvalid), failed.cause);
|
|
}
|