atproto utils for zig zat.dev
atproto sdk zig
26
fork

Configure Feed

Select the types of activity you want to include in your feed.

harden identity network resolution

authored by

zzstoatzz and committed by
Tangled
8287ff23 9473eb8d

+488 -39
+27 -31
src/internal/identity/did_resolver.zig
··· 8 8 const Did = @import("../syntax/did.zig").Did; 9 9 const DidDocument = @import("did_document.zig").DidDocument; 10 10 const HttpTransport = @import("../xrpc/transport.zig").HttpTransport; 11 + const network_safety = @import("network_safety.zig"); 12 + 13 + const max_did_document_size = 1 * 1024 * 1024; 11 14 12 15 pub const DidResolver = struct { 13 16 allocator: std.mem.Allocator, ··· 15 18 16 19 /// plc directory url (default: https://plc.directory) 17 20 plc_url: []const u8 = "https://plc.directory", 21 + /// DoH endpoint for did:web host safety preflight. 22 + doh_endpoint: []const u8 = "https://cloudflare-dns.com/dns-query", 18 23 19 24 pub fn init(io: std.Io, allocator: std.mem.Allocator) DidResolver { 20 25 return initWithOptions(io, allocator, .{}); ··· 52 57 const url = try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ self.plc_url, did.raw }); 53 58 defer self.allocator.free(url); 54 59 55 - return try self.fetchDidDocument(url); 60 + return try self.fetchDidDocument(url, null); 56 61 } 57 62 58 63 /// resolve did:web via .well-known ··· 90 95 try url_buf.appendSlice(self.allocator, "/did.json"); 91 96 } 92 97 93 - return try self.fetchDidDocument(url_buf.items); 98 + var checked_url = try network_safety.resolveIdentityUrl( 99 + self.allocator, 100 + &self.transport, 101 + self.doh_endpoint, 102 + url_buf.items, 103 + ); 104 + defer checked_url.deinit(self.allocator); 105 + return try self.fetchDidDocument(url_buf.items, checked_url.resolvedConnection()); 94 106 } 95 107 96 108 /// fetch and parse a did document from url 97 - fn fetchDidDocument(self: *DidResolver, url: []const u8) !DidDocument { 98 - const result = try self.transport.fetch(.{ .url = url }); 109 + fn fetchDidDocument( 110 + self: *DidResolver, 111 + url: []const u8, 112 + resolved_connection: ?HttpTransport.ResolvedConnection, 113 + ) !DidDocument { 114 + const result = try self.transport.fetch(.{ 115 + .url = url, 116 + .max_response_size = max_did_document_size, 117 + .redirect_behavior = .not_allowed, 118 + .resolved_connection = resolved_connection, 119 + }); 99 120 defer self.allocator.free(result.body); 100 121 101 122 if (result.status != .ok) { ··· 141 162 try std.testing.expectEqualStrings("did:plc:z72i7hdynmk6r22z27h6tvur", doc.id); 142 163 } 143 164 144 - test "regression: transport errors propagate distinct kinds" { 145 - // before this fix, transport.fetch had `catch return error.RequestFailed` 146 - // and fetchDidDocument had `catch return error.DidResolutionFailed`, so 147 - // every transport-layer failure (DNS, TCP, TLS) collapsed to one 148 - // indistinguishable error and callers had no way to see what was wrong. 149 - // this regression test asserts the underlying error kind survives the 150 - // resolver layer for at least one common transport failure mode. 151 - // 152 - // history: zlay 2026-04-08, where the host_authority pool failed at 100% 153 - // and we had no production telemetry on which transport error fired 154 - // because both layers had been swallowed. see relay docs/zlay-external- 155 - // review-2026-04-09.md. 165 + test "did:web loopback host is rejected before fetch" { 156 166 var resolver = DidResolver.init(std.Options.debug_io, std.testing.allocator); 157 167 defer resolver.deinit(); 158 168 159 - // 127.0.0.1:443 is almost certainly not listening on a test machine. 160 - // did:web:127.0.0.1 → https://127.0.0.1/.well-known/did.json → connect refused. 161 169 const did = Did.parse("did:web:127.0.0.1") orelse return error.SkipZigTest; 162 - if (resolver.resolve(did)) |doc| { 163 - // someone is actually serving a DID doc on 127.0.0.1:443 — skip rather 164 - // than fail, since the assertion below assumes a transport failure 165 - var d = doc; 166 - d.deinit(); 167 - return error.SkipZigTest; 168 - } else |err| { 169 - // exact error name varies by platform (ConnectionRefused on linux/darwin, 170 - // possibly different elsewhere). just assert it's not the catch-all that 171 - // the pre-fix code returned for everything. 172 - try std.testing.expect(err != error.DidResolutionFailed); 173 - try std.testing.expect(err != error.RequestFailed); 174 - } 170 + try std.testing.expectError(error.UnsafeIdentityHost, resolver.resolve(did)); 175 171 } 176 172 177 173 test "did:web url construction" {
+29 -5
src/internal/identity/handle_resolver.zig
··· 3 3 //! resolves AT Protocol handles via HTTP: 4 4 //! https://{handle}/.well-known/atproto-did 5 5 //! 6 - //! note: DNS TXT resolution (_atproto.{handle}) not yet implemented 7 - //! as zig std doesn't provide TXT record lookup. 6 + //! DNS TXT resolution uses DNS-over-HTTPS because Zig stdlib does not expose 7 + //! direct TXT lookup. 8 8 //! 9 9 //! see: https://atproto.com/specs/handle 10 10 ··· 12 12 const Handle = @import("../syntax/handle.zig").Handle; 13 13 const Did = @import("../syntax/did.zig").Did; 14 14 const HttpTransport = @import("../xrpc/transport.zig").HttpTransport; 15 + const network_safety = @import("network_safety.zig"); 16 + 17 + const max_handle_response_size = 8 * 1024; 18 + const max_dns_txt_response_size = 1 * 1024 * 1024; 15 19 16 20 pub const HandleResolver = struct { 17 21 allocator: std.mem.Allocator, ··· 34 38 pub fn resolve(self: *HandleResolver, handle: Handle) ![]const u8 { 35 39 if (self.resolveHttp(handle)) |did| { 36 40 return did; 37 - } else |_| { 38 - return try self.resolveDns(handle); 41 + } else |err| switch (err) { 42 + error.UnsafeIdentityHost => return err, 43 + else => return try self.resolveDns(handle), 39 44 } 40 45 } 41 46 ··· 48 53 ); 49 54 defer self.allocator.free(url); 50 55 51 - const result = self.transport.fetch(.{ .url = url }) catch return error.HttpResolutionFailed; 56 + var checked_url = network_safety.resolveIdentityUrl( 57 + self.allocator, 58 + &self.transport, 59 + self.doh_endpoint, 60 + url, 61 + ) catch |err| switch (err) { 62 + error.OutOfMemory => |e| return e, 63 + error.UnsafeIdentityHost => |e| return e, 64 + else => return error.HttpResolutionFailed, 65 + }; 66 + defer checked_url.deinit(self.allocator); 67 + 68 + const result = self.transport.fetch(.{ 69 + .url = url, 70 + .max_response_size = max_handle_response_size, 71 + .redirect_behavior = .not_allowed, 72 + .resolved_connection = checked_url.resolvedConnection(), 73 + }) catch return error.HttpResolutionFailed; 52 74 defer self.allocator.free(result.body); 53 75 54 76 if (result.status != .ok) { ··· 85 107 const result = self.transport.fetch(.{ 86 108 .url = url, 87 109 .accept = "application/dns-json", 110 + .max_response_size = max_dns_txt_response_size, 111 + .redirect_behavior = .not_allowed, 88 112 }) catch return error.DnsResolutionFailed; 89 113 defer self.allocator.free(result.body); 90 114
+283
src/internal/identity/network_safety.zig
··· 1 + //! Network safety checks for identity resolution. 2 + 3 + const std = @import("std"); 4 + const HttpTransport = @import("../xrpc/transport.zig").HttpTransport; 5 + 6 + pub const NetworkSafetyError = error{ 7 + MissingHost, 8 + UnsafeIdentityHost, 9 + IdentityDnsResolutionFailed, 10 + }; 11 + 12 + const max_dns_response_size = 1 * 1024 * 1024; 13 + 14 + pub const CheckedIdentityUrl = struct { 15 + host: []u8, 16 + dial_host: ?[]u8, 17 + 18 + pub fn deinit(self: *CheckedIdentityUrl, allocator: std.mem.Allocator) void { 19 + allocator.free(self.host); 20 + if (self.dial_host) |dial_host| allocator.free(dial_host); 21 + } 22 + 23 + pub fn resolvedConnection(self: CheckedIdentityUrl) ?HttpTransport.ResolvedConnection { 24 + return .{ 25 + .dial_host = self.dial_host orelse return null, 26 + .logical_host = self.host, 27 + }; 28 + } 29 + }; 30 + 31 + pub fn checkIdentityUrl(url: []const u8) (std.Uri.ParseError || NetworkSafetyError)!void { 32 + const uri = try std.Uri.parse(url); 33 + var host_buf: [std.Io.net.HostName.max_len]u8 = undefined; 34 + const host_name = uri.getHost(&host_buf) catch return error.MissingHost; 35 + try checkIdentityHost(host_name.bytes); 36 + } 37 + 38 + pub fn checkIdentityUrlResolved( 39 + allocator: std.mem.Allocator, 40 + transport: *HttpTransport, 41 + doh_endpoint: []const u8, 42 + url: []const u8, 43 + ) (std.Uri.ParseError || NetworkSafetyError || error{ OutOfMemory, ResponseTooLarge })!void { 44 + var checked = try resolveIdentityUrl(allocator, transport, doh_endpoint, url); 45 + checked.deinit(allocator); 46 + } 47 + 48 + pub fn resolveIdentityUrl( 49 + allocator: std.mem.Allocator, 50 + transport: *HttpTransport, 51 + doh_endpoint: []const u8, 52 + url: []const u8, 53 + ) (std.Uri.ParseError || NetworkSafetyError || error{ OutOfMemory, ResponseTooLarge })!CheckedIdentityUrl { 54 + const host = try hostFromUrlAlloc(allocator, url); 55 + errdefer allocator.free(host); 56 + try checkIdentityHost(host); 57 + 58 + // Literal IPs were fully checked above. Only DNS names need DoH preflight. 59 + if (isIpLiteral(host)) { 60 + return .{ .host = host, .dial_host = null }; 61 + } 62 + 63 + var saw_address = false; 64 + const dial_host = try checkDnsAnswers(allocator, transport, doh_endpoint, host, "A", &saw_address); 65 + errdefer if (dial_host) |addr| allocator.free(addr); 66 + const maybe_ip6 = try checkDnsAnswers(allocator, transport, doh_endpoint, host, "AAAA", &saw_address); 67 + if (maybe_ip6) |ip6| allocator.free(ip6); 68 + if (!saw_address) return error.IdentityDnsResolutionFailed; 69 + if (dial_host == null) return error.IdentityDnsResolutionFailed; 70 + 71 + return .{ .host = host, .dial_host = dial_host }; 72 + } 73 + 74 + fn hostFromUrlAlloc( 75 + allocator: std.mem.Allocator, 76 + url: []const u8, 77 + ) (std.Uri.ParseError || NetworkSafetyError || error{OutOfMemory})![]u8 { 78 + const uri = try std.Uri.parse(url); 79 + var host_buf: [std.Io.net.HostName.max_len]u8 = undefined; 80 + const host_name = uri.getHost(&host_buf) catch |err| switch (err) { 81 + error.UriMissingHost => return error.MissingHost, 82 + }; 83 + return try allocator.dupe(u8, host_name.bytes); 84 + } 85 + 86 + pub fn checkIdentityHost(host: []const u8) NetworkSafetyError!void { 87 + if (host.len == 0) return error.MissingHost; 88 + 89 + const host_without_trailing_dot = stripTrailingDot(host); 90 + if (std.ascii.eqlIgnoreCase(host_without_trailing_dot, "localhost")) { 91 + return error.UnsafeIdentityHost; 92 + } 93 + 94 + if (std.Io.net.Ip4Address.parse(host, 0)) |ip4| { 95 + if (isNonRoutableIp4(ip4.bytes)) return error.UnsafeIdentityHost; 96 + return; 97 + } else |_| {} 98 + 99 + const ip6_text = stripIp6Brackets(host); 100 + if (std.Io.net.Ip6Address.parse(ip6_text, 0)) |ip6| { 101 + if (ip4FromIp6Mapped(ip6.bytes)) |ip4| { 102 + if (isNonRoutableIp4(ip4)) return error.UnsafeIdentityHost; 103 + } 104 + if (isNonRoutableIp6(ip6.bytes)) return error.UnsafeIdentityHost; 105 + return; 106 + } else |_| {} 107 + } 108 + 109 + fn isIpLiteral(host: []const u8) bool { 110 + if (std.Io.net.Ip4Address.parse(host, 0)) |_| return true else |_| {} 111 + 112 + const ip6_text = stripIp6Brackets(host); 113 + if (std.Io.net.Ip6Address.parse(ip6_text, 0)) |_| return true else |_| {} 114 + 115 + return false; 116 + } 117 + 118 + fn checkDnsAnswers( 119 + allocator: std.mem.Allocator, 120 + transport: *HttpTransport, 121 + doh_endpoint: []const u8, 122 + host: []const u8, 123 + record_type: []const u8, 124 + saw_address: *bool, 125 + ) (NetworkSafetyError || error{ OutOfMemory, ResponseTooLarge })!?[]u8 { 126 + const url = try std.fmt.allocPrint( 127 + allocator, 128 + "{s}?name={s}&type={s}", 129 + .{ doh_endpoint, host, record_type }, 130 + ); 131 + defer allocator.free(url); 132 + 133 + const result = transport.fetch(.{ 134 + .url = url, 135 + .accept = "application/dns-json", 136 + .max_response_size = max_dns_response_size, 137 + .redirect_behavior = .not_allowed, 138 + }) catch |err| switch (err) { 139 + error.OutOfMemory => |e| return e, 140 + error.ResponseTooLarge => |e| return e, 141 + else => return error.IdentityDnsResolutionFailed, 142 + }; 143 + defer allocator.free(result.body); 144 + 145 + if (result.status != .ok) return error.IdentityDnsResolutionFailed; 146 + 147 + const parsed = std.json.parseFromSlice(DnsResponse, allocator, result.body, .{}) catch 148 + return error.IdentityDnsResolutionFailed; 149 + defer parsed.deinit(); 150 + 151 + const answers = parsed.value.Answer orelse return null; 152 + var first_ip4: ?[]u8 = null; 153 + errdefer if (first_ip4) |ip| allocator.free(ip); 154 + 155 + for (answers) |answer| { 156 + const data = answer.data orelse continue; 157 + switch (answer.type) { 158 + 1 => { 159 + saw_address.* = true; 160 + try checkIdentityHost(data); 161 + if (first_ip4 == null) first_ip4 = try allocator.dupe(u8, data); 162 + }, 163 + 28 => { 164 + saw_address.* = true; 165 + try checkIdentityHost(data); 166 + }, 167 + else => {}, 168 + } 169 + } 170 + return first_ip4; 171 + } 172 + 173 + fn stripIp6Brackets(host: []const u8) []const u8 { 174 + if (host.len >= 2 and host[0] == '[' and host[host.len - 1] == ']') { 175 + return host[1 .. host.len - 1]; 176 + } 177 + return host; 178 + } 179 + 180 + fn stripTrailingDot(host: []const u8) []const u8 { 181 + if (host.len > 0 and host[host.len - 1] == '.') return host[0 .. host.len - 1]; 182 + return host; 183 + } 184 + 185 + fn isNonRoutableIp4(ip: [4]u8) bool { 186 + return ip[0] == 0 or 187 + ip[0] == 10 or 188 + ip[0] == 127 or 189 + (ip[0] == 169 and ip[1] == 254) or 190 + (ip[0] == 172 and ip[1] >= 16 and ip[1] <= 31) or 191 + (ip[0] == 192 and ip[1] == 168); 192 + } 193 + 194 + fn isNonRoutableIp6(ip: [16]u8) bool { 195 + const all_zero = for (ip) |b| { 196 + if (b != 0) break false; 197 + } else true; 198 + 199 + return all_zero or 200 + isIp6Loopback(ip) or 201 + (ip[0] == 0xfe and (ip[1] & 0xc0) == 0x80) or // fe80::/10 link-local 202 + (ip[0] & 0xfe) == 0xfc; // fc00::/7 unique local 203 + } 204 + 205 + fn isIp6Loopback(ip: [16]u8) bool { 206 + for (ip[0..15]) |b| { 207 + if (b != 0) return false; 208 + } 209 + return ip[15] == 1; 210 + } 211 + 212 + fn ip4FromIp6Mapped(ip: [16]u8) ?[4]u8 { 213 + for (ip[0..10]) |b| { 214 + if (b != 0) return null; 215 + } 216 + if (ip[10] != 0xff or ip[11] != 0xff) return null; 217 + return ip[12..16].*; 218 + } 219 + 220 + const DnsResponse = struct { 221 + Status: i32, 222 + TC: bool = false, 223 + RD: bool = false, 224 + RA: bool = false, 225 + AD: bool = false, 226 + CD: bool = false, 227 + Question: ?[]Question = null, 228 + Answer: ?[]Answer = null, 229 + }; 230 + 231 + const Question = struct { 232 + name: []const u8, 233 + type: i32, 234 + }; 235 + 236 + const Answer = struct { 237 + name: []const u8, 238 + type: i32, 239 + TTL: i32, 240 + data: ?[]const u8 = null, 241 + }; 242 + 243 + test "identity host rejects obvious non-routable hosts" { 244 + try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("localhost")); 245 + try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("localhost.")); 246 + try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("127.0.0.1")); 247 + try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("10.1.2.3")); 248 + try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("172.16.0.1")); 249 + try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("192.168.1.1")); 250 + try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("169.254.1.1")); 251 + try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("[::1]")); 252 + try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("::ffff:127.0.0.1")); 253 + try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("fc00::1")); 254 + try checkIdentityHost("example.com"); 255 + try checkIdentityHost("8.8.8.8"); 256 + } 257 + 258 + test "identity url check rejects literal localhost" { 259 + try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityUrl("https://127.0.0.1/.well-known/did.json")); 260 + } 261 + 262 + test "identity dns answers reject non-routable addresses" { 263 + const json = 264 + \\{ 265 + \\ "Status": 0, 266 + \\ "Answer": [ 267 + \\ {"name": "evil.example.", "type": 1, "TTL": 60, "data": "127.0.0.1"} 268 + \\ ] 269 + \\} 270 + ; 271 + 272 + const parsed = try std.json.parseFromSlice(DnsResponse, std.testing.allocator, json, .{}); 273 + defer parsed.deinit(); 274 + 275 + var saw_address = false; 276 + for (parsed.value.Answer.?) |answer| { 277 + if (answer.type == 1 or answer.type == 28) { 278 + saw_address = true; 279 + try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost(answer.data.?)); 280 + } 281 + } 282 + try std.testing.expect(saw_address); 283 + }
+149 -3
src/internal/xrpc/transport.zig
··· 25 25 26 26 /// fetch a URL and write response to provided writer 27 27 pub fn fetch(self: *HttpTransport, options: FetchOptions) !FetchResult { 28 - var aw: std.Io.Writer.Allocating = .init(self.allocator); 29 - defer aw.deinit(); 30 - 31 28 var headers: std.http.Client.Request.Headers = .{ 32 29 .accept_encoding = .{ .override = "identity" }, // disable gzip - zig stdlib issue 33 30 .content_type = if (options.payload != null) .{ .override = "application/json" } else .default, ··· 59 56 } 60 57 } 61 58 59 + if (options.resolved_connection) |resolved| { 60 + return try self.fetchResolved(options, resolved, headers, extra_buf[0..extra_count]); 61 + } 62 + 63 + if (options.max_response_size) |max| { 64 + const body_buf = try self.allocator.alloc(u8, max); 65 + defer self.allocator.free(body_buf); 66 + var writer = std.Io.Writer.fixed(body_buf); 67 + 68 + const result = self.http_client.fetch(.{ 69 + .location = .{ .url = options.url }, 70 + .response_writer = &writer, 71 + .method = options.method, 72 + .payload = options.payload, 73 + .headers = headers, 74 + .extra_headers = extra_buf[0..extra_count], 75 + .keep_alive = self.keep_alive, 76 + .redirect_behavior = options.redirect_behavior, 77 + }) catch |err| switch (err) { 78 + error.WriteFailed => return error.ResponseTooLarge, 79 + else => |e| return e, 80 + }; 81 + 82 + return .{ 83 + .status = result.status, 84 + .body = try self.allocator.dupe(u8, writer.buffered()), 85 + }; 86 + } 87 + 88 + var aw: std.Io.Writer.Allocating = .init(self.allocator); 89 + defer aw.deinit(); 90 + 62 91 const result = try self.http_client.fetch(.{ 63 92 .location = .{ .url = options.url }, 64 93 .response_writer = &aw.writer, ··· 67 96 .headers = headers, 68 97 .extra_headers = extra_buf[0..extra_count], 69 98 .keep_alive = self.keep_alive, 99 + .redirect_behavior = options.redirect_behavior, 70 100 }); 71 101 72 102 return .{ ··· 75 105 }; 76 106 } 77 107 108 + fn fetchResolved( 109 + self: *HttpTransport, 110 + options: FetchOptions, 111 + resolved: ResolvedConnection, 112 + headers: std.http.Client.Request.Headers, 113 + extra_headers: []const std.http.Header, 114 + ) !FetchResult { 115 + const uri = try std.Uri.parse(options.url); 116 + const protocol = std.http.Client.Protocol.fromUri(uri) orelse return error.UnsupportedUriScheme; 117 + 118 + var uri_host_buf: [std.Io.net.HostName.max_len]u8 = undefined; 119 + const uri_host = try uri.getHost(&uri_host_buf); 120 + if (!std.ascii.eqlIgnoreCase(uri_host.bytes, resolved.logical_host)) { 121 + return error.ResolvedHostMismatch; 122 + } 123 + 124 + const dial_host = try std.Io.net.HostName.init(resolved.dial_host); 125 + const logical_host = try std.Io.net.HostName.init(resolved.logical_host); 126 + const connection = try self.http_client.connectTcpOptions(.{ 127 + .host = dial_host, 128 + .port = uri.port orelse defaultPort(protocol), 129 + .protocol = protocol, 130 + .proxied_host = logical_host, 131 + }); 132 + 133 + var request = self.http_client.request(options.method, uri, .{ 134 + .connection = connection, 135 + .headers = headers, 136 + .extra_headers = extra_headers, 137 + .keep_alive = self.keep_alive, 138 + .redirect_behavior = options.redirect_behavior orelse .unhandled, 139 + }) catch |err| { 140 + self.http_client.connection_pool.release(connection, self.io); 141 + return err; 142 + }; 143 + defer request.deinit(); 144 + 145 + if (options.payload) |payload| { 146 + request.transfer_encoding = .{ .content_length = payload.len }; 147 + var body = try request.sendBodyUnflushed(&.{}); 148 + try body.writer.writeAll(payload); 149 + try body.end(); 150 + try request.connection.?.flush(); 151 + } else { 152 + try request.sendBodiless(); 153 + } 154 + 155 + var response = try request.receiveHead(&.{}); 156 + if (options.max_response_size) |max| { 157 + const body_buf = try self.allocator.alloc(u8, max); 158 + defer self.allocator.free(body_buf); 159 + var writer = std.Io.Writer.fixed(body_buf); 160 + try streamResponseBody(&response, &writer); 161 + return .{ 162 + .status = response.head.status, 163 + .body = try self.allocator.dupe(u8, writer.buffered()), 164 + }; 165 + } 166 + 167 + var aw: std.Io.Writer.Allocating = .init(self.allocator); 168 + defer aw.deinit(); 169 + try streamResponseBody(&response, &aw.writer); 170 + return .{ 171 + .status = response.head.status, 172 + .body = try self.allocator.dupe(u8, aw.written()), 173 + }; 174 + } 175 + 78 176 pub const FetchOptions = struct { 79 177 url: []const u8, 80 178 method: std.http.Method = .GET, ··· 83 181 accept: ?[]const u8 = null, 84 182 content_type: ?[]const u8 = null, 85 183 extra_headers: ?[]const std.http.Header = null, 184 + max_response_size: ?usize = null, 185 + redirect_behavior: ?std.http.Client.Request.RedirectBehavior = null, 186 + resolved_connection: ?ResolvedConnection = null, 187 + }; 188 + 189 + pub const ResolvedConnection = struct { 190 + /// Checked address to dial. Currently IPv4 text, which std.Io.net.HostName accepts. 191 + dial_host: []const u8, 192 + /// Original URL host, used by std.http for TLS/SNI and connection identity. 193 + logical_host: []const u8, 86 194 }; 87 195 88 196 pub const FetchResult = struct { ··· 91 199 }; 92 200 }; 93 201 202 + fn streamResponseBody(response: *std.http.Client.Response, writer: *std.Io.Writer) !void { 203 + var transfer_buffer: [64]u8 = undefined; 204 + var decompress: std.http.Decompress = undefined; 205 + const reader = response.readerDecompressing(&transfer_buffer, &decompress, &.{}); 206 + _ = reader.streamRemaining(writer) catch |err| switch (err) { 207 + error.ReadFailed => return response.bodyErr().?, 208 + error.WriteFailed => return error.ResponseTooLarge, 209 + else => |e| return e, 210 + }; 211 + } 212 + 213 + fn defaultPort(protocol: std.http.Client.Protocol) u16 { 214 + return switch (protocol) { 215 + .plain => 80, 216 + .tls => 443, 217 + }; 218 + } 219 + 94 220 // === tests === 95 221 96 222 test "transport init/deinit" { ··· 98 224 var transport = HttpTransport.init(io, std.testing.allocator); 99 225 defer transport.deinit(); 100 226 } 227 + 228 + test "transport fixed writer maps overflow to ResponseTooLarge" { 229 + var buf: [0]u8 = .{}; 230 + var writer = std.Io.Writer.fixed(&buf); 231 + try std.testing.expectError(error.WriteFailed, writer.writeAll("x")); 232 + } 233 + 234 + test "resolved transport rejects mismatched logical host" { 235 + const io = std.Options.debug_io; 236 + var transport = HttpTransport.init(io, std.testing.allocator); 237 + defer transport.deinit(); 238 + 239 + try std.testing.expectError(error.ResolvedHostMismatch, transport.fetch(.{ 240 + .url = "http://example.com/", 241 + .resolved_connection = .{ 242 + .dial_host = "127.0.0.1", 243 + .logical_host = "other.example", 244 + }, 245 + })); 246 + }