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

+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 + }