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