lib/http/src/client/controlled.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 //! Provides a fixed-storage HTTP client for callers that choose storage bounds
2 //! before running a request. One request is prepared and run once. All request,
3 //! response, and connection storage resides in a single fixed-size struct kept
4 //! at the address where successful initialization placed it until
5 //! deinitialization. This is the *owner*. Caller-owned trust storage, the
6 //! worker stack, and kernel socket storage remain outside the owner.
7 //!
8 //! A single worker runs the request from start to finish, and calls block that
9 //! worker throughout execution.
10 //!
11 //! A separate handle carries a deadline and a cancellation flag that another
12 //! thread may set while a request runs. This handle is the *control*. The
13 //! running worker observes cancellation requests at control checks. Reads and
14 //! writes check the control. Blocking socket waits request poll intervals of at
15 //! most 10 milliseconds, shortened to the rounded-up remaining deadline
16 //! interval, and check cancellation and the deadline before and after polling.
17 //! The handle also carries a byte budget that bounds transfers. Each read and
18 //! write restricts transfer length to the remaining budget and subtracts the
19 //! bytes actually moved. Once the remaining budget reaches zero, the next read
20 //! or write returns `error.TransferCapacityExceeded`.
21
22 const std = @import("std");
23 const sys = @import("sys");
24 const client = @import("root.zig");
25 const alloc_phase = @import("alloc_phase");
26
27 pub const Limits = struct {
28 host_bytes: u16 = 253,
29 target_bytes: u16 = 1024,
30 request_bytes: u32 = 8192,
31 request_headers: u8 = 32,
32 response_headers: u8 = 32,
33 response_head_bytes: u16 = 4096,
34 /// The bytes reserved for the response body. A chunked body arrives with
35 /// the size prefixes and terminators of a chunked transfer encoding, known
36 /// as chunk framing. Chunk framing sits in this region until the body is
37 /// decoded over it in place, so the region holds the encoded form before it
38 /// holds the decoded one.
39 response_body_bytes: u32 = 4096,
40 trust_bytes: u32 = 1024 * 1024,
41 };
42
43 /// Provides the caller's choice between an unencrypted plain connection and a
44 /// connection verified against a caller-owned certificate authority bundle.
45 /// When verified transport is selected, the TLS handshake checks the peer
46 /// certificate chain against this bundle.
47 ///
48 /// The bundle, its bytes, and its index stay as they are from `init` until
49 /// `run` returns, and the allocator and `std.Io` handle stay borrowed for the
50 /// same span. Initialization records a SHA-256 digest over the bundle bytes and
51 /// index. Before network work begins, `run` recomputes this digest and returns
52 /// `error.TrustMismatch` if the values differ. The bundle byte length and index
53 /// capacity must each fit within `trust_bytes`, or fingerprinting returns
54 /// `error.Capacity`.
55 ///
56 /// The choice and the URL scheme have to agree. A plain choice with an HTTPS
57 /// URL, or a verified choice with an HTTP URL or an empty bundle, returns
58 /// `error.TrustMismatch`.
59 ///
60 /// `init` returns `error.UnsupportedPlatform` on platforms with
61 /// operating-system certificate-chain building or without supported control
62 /// I/O.
63 pub const Trust = union(enum) {
64 plain,
65 verified: struct { bundle: *sys.tls.Bundle, allocator: std.mem.Allocator, io: std.Io },
66 };
67
68 pub const Route = union(enum) {
69 address: sys.net.IpAddress,
70 dns: struct { nameserver: sys.net.IpAddress, family: sys.net.Control.DnsFamily },
71 };
72
73 pub const Request = struct {
74 method: []const u8,
75 url: []const u8,
76 headers: []const client.Header,
77 body: []const u8,
78 route: Route,
79 trust: *const Trust,
80 };
81
82 /// Builds the owner type for the supplied compile-time storage limits,
83 /// rejecting invalid combinations at compilation.
84 ///
85 /// Initialization parses the URL, validates the request, and writes the request
86 /// bytes into the owner without opening a connection. The socket is opened
87 /// inside `run`, which executes on the caller's worker. The owner records its
88 /// memory address upon successful initialization and verifies it during `run`
89 /// and `deinit`. The caller must keep the owner pinned at that address until
90 /// `deinit`, and leave it unchanged between `init` and `run`.
91 ///
92 /// During `run`, the worker changes owner state and buffers, retaining
93 /// exclusive access to the owner and socket. Cancellation touches only the
94 /// separate control handle. If `run` was started, it must return before
95 /// `deinit`. An initialized request may also be deinitialized without ever
96 /// running.
97 pub fn Owner(comptime limits: Limits) type {
98 if (limits.host_bytes == 0 or limits.host_bytes > 253 or limits.target_bytes == 0 or
99 limits.target_bytes > 16384 or limits.request_bytes == 0 or limits.request_bytes > 1024 * 1024 or
100 limits.response_head_bytes == 0 or limits.response_body_bytes > 1024 * 1024 or
101 limits.response_headers == 0 or limits.trust_bytes > 16 * 1024 * 1024)
102 @compileError("invalid controlled HTTP limits");
103 return struct {
104 origin: ?*const @This() = null,
105 state: enum { ready, running, complete } = .ready,
106 trust: Trust,
107 route: Route,
108 prepared: client.transport.Prepared,
109 method: [16]u8 = undefined,
110 method_length: u8,
111 host: [limits.host_bytes]u8 = undefined,
112 target: [limits.target_bytes]u8 = undefined,
113 request: [limits.request_bytes]u8 = undefined,
114 request_length: u32 = 0,
115 response_headers: [limits.response_headers]client.Header = undefined,
116 response_head: [limits.response_head_bytes]u8 = undefined,
117 response_body: [limits.response_body_bytes]u8 = undefined,
118 connection: client.transport.TlsConn = undefined,
119 lock: sys.tls.BundleLock = .init,
120 trust_digest: [32]u8 = @splat(0),
121 failure: ?anyerror = null,
122
123 const Self = @This();
124 pub const Limits = @TypeOf(limits);
125 pub const selected_limits = limits;
126 pub const storage_bytes = @sizeOf(Self);
127 pub const claim: alloc_phase.capacity.Declaration = .{
128 .source = .{
129 .id = "http.controlled.request",
130 .kind = .startup_static,
131 .limit_source = .caller,
132 .storage = .{
133 .covered = &.{.{ .id = "owned", .lifetime = .steady, .detail = "fixed request, response, TLS and operation metadata" }},
134 .excluded = &.{"caller immutable trust bundle and allocator, caller worker stack and kernel socket storage"},
135 },
136 .capacity = .{
137 .inputs = &.{},
138 .type_selectors = &.{alloc_phase.capacity.bindType(Self, "owned")},
139 .nodes = &.{ .{ .constant = 1 }, .{ .scale = .{ .node = 0, .coefficient = .{ .size_of_concrete_type = 0 } } } },
140 .assertions = &.{.{ .scope = .closure_total, .measure = .retained, .relation = .exact, .expression = 1 }},
141 },
142 .overload = .{ .kind = .reject_before_seal, .detail = "request refuses before network effects; response and wire bounds refuse incomplete output" },
143 .risks = .{
144 .transitive = .{ .status = .excluded, .detail = "caller frozen trust bundle and worker stack" },
145 .foreign = .{ .status = .excluded, .detail = "kernel sockets and pinned standard-library cryptography" },
146 },
147 .work = .{ .equation = "request and trust bounds plus quadratic encoded-body scanning; fixed TLS buffers and Control wire budget; waits check the absolute deadline every 10 ms" },
148 .obligations = &.{
149 .{ .key = "http_controlled_capacity", .role = .capacity_model },
150 .{ .key = "http_controlled_overload", .role = .overload },
151 .{ .key = "http_controlled_work", .role = .work_bound },
152 },
153 },
154 .bindings = .{
155 .owner = Self,
156 .seal = .{ .family = alloc_phase.capacity.selector(Self.init), .premise = .{ .class = .checked_semantic_fact, .authority = .checker } },
157 .teardown = .{ .family = alloc_phase.capacity.selector(Self.deinit), .premise = .{ .class = .checked_semantic_fact, .authority = .checker } },
158 },
159 };
160
161 comptime {
162 if (alloc_phase.capacity.validateDeclaration(Self, claim, .provisioned_exact)) |bad| {
163 @compileError("invalid controlled request claim: " ++ @tagName(bad));
164 }
165 }
166
167 pub fn init(self: *Self, request: Request) !void {
168 if (comptime sys.tls.Certificate.Chain != void or !sys.net.Control.supported())
169 return error.UnsupportedPlatform;
170 try validate(request, limits);
171 self.* = .{ .trust = request.trust.*, .route = request.route, .prepared = undefined, .method_length = @intCast(request.method.len) };
172 errdefer self.scrub();
173 @memcpy(self.method[0..request.method.len], request.method);
174 self.prepared = try client.ClientOperation.prepare(.{ .host = &self.host, .target = &self.target, .plain_read = &.{}, .plain_write = &.{} }, request.url);
175 if (std.mem.startsWith(u8, self.prepared.host, "[")) {
176 if (!std.mem.endsWith(u8, self.prepared.host, "]")) return error.InvalidRequest;
177 self.prepared.host = self.prepared.host[1 .. self.prepared.host.len - 1];
178 _ = sys.net.Address.parseIp(self.prepared.host, self.prepared.port) catch return error.InvalidRequest;
179 } else {
180 for (self.prepared.host) |byte| {
181 if (!std.ascii.isAlphanumeric(byte) and byte != '-' and byte != '.') return error.InvalidRequest;
182 }
183 }
184 const uri = std.Uri.parse(request.url) catch unreachable;
185 var target = std.Io.Writer.fixed(&self.target);
186 if (uri.path.isEmpty()) try target.writeByte('/') else uri.path.formatPath(&target) catch return error.ClientTargetCapacityExceeded;
187 if (uri.query) |query| {
188 target.writeByte('?') catch return error.ClientTargetCapacityExceeded;
189 query.formatQuery(&target) catch return error.ClientTargetCapacityExceeded;
190 }
191 self.prepared.target = target.buffered();
192 if (!safeComponent(self.prepared.host) or !safeComponent(self.prepared.target)) return error.InvalidRequest;
193 switch (request.trust.*) {
194 .plain => if (self.prepared.is_tls) {
195 return error.TrustMismatch;
196 },
197 .verified => |trust| {
198 if (!self.prepared.is_tls or trust.bundle.bytes.items.len == 0 or trust.bundle.map.count() == 0) return error.TrustMismatch;
199 self.trust_digest = try self.trustFingerprint();
200 },
201 }
202 if (request.route == .address and sys.net.ipAddressPort(request.route.address) != self.prepared.port)
203 return error.RouteMismatch;
204 var authority_buffer: [limits.host_bytes + 8]u8 = undefined;
205 const host = self.prepared.host;
206 const is_ip6 = std.mem.indexOfScalar(u8, host, ':') != null;
207 const default_port: u16 = if (self.prepared.is_tls) 443 else 80;
208 var authority = std.Io.Writer.fixed(&authority_buffer);
209 if (is_ip6) try authority.writeByte('[');
210 try authority.writeAll(host);
211 if (is_ip6) try authority.writeByte(']');
212 if (self.prepared.port != default_port) try authority.print(":{d}", .{self.prepared.port});
213 var writer = std.Io.Writer.fixed(&self.request);
214 client.ClientOperation.writeRequest(&writer, request.method, authority.buffered(), self.prepared.target, request.headers, request.body) catch return error.Capacity;
215 self.request_length = @intCast(writer.end);
216 self.origin = self;
217 std.debug.assert(self.request_length <= self.request.len);
218 }
219
220 /// Runs the prepared request and returns a complete response. The
221 /// operation performs no retries and no connection reuse. If the server
222 /// responds with a redirect status, the function returns
223 /// `error.RedirectRefused`. If the server responds with a protocol
224 /// upgrade, it returns `error.UpgradeRefused`, leaving subsequent
225 /// actions to the caller.
226 ///
227 /// `run` first checks the recorded owner address, returning
228 /// `error.WrongOwner` on a mismatch, and then requires the ready state,
229 /// returning `error.RequestConsumed` otherwise. Once these checks pass,
230 /// the attempt consumes the operation whether network work succeeds or
231 /// fails. A subsequent `run` on that initialized owner returns
232 /// `error.RequestConsumed`.
233 ///
234 /// After address and state checks pass, request and method bytes are
235 /// wiped when `run` returns, on success or failure. If request
236 /// execution fails, the response head and body are wiped as well. Early
237 /// returns from address or state check failures do not scrub buffers. A
238 /// successful response borrows header entries, head bytes, and body
239 /// bytes directly from the owner, so these regions remain valid until
240 /// `deinit` and must be left unchanged by the caller while the response
241 /// is in use.
242 pub fn run(self: *Self, control: *sys.net.Control) !client.ClientResponse {
243 if (self.origin != self) return error.WrongOwner;
244 if (self.state != .ready) return error.RequestConsumed;
245 self.state = .running;
246 defer {
247 self.state = .complete;
248 std.crypto.secureZero(u8, &self.request);
249 std.crypto.secureZero(u8, &self.method);
250 }
251 return self.perform(control) catch |err| {
252 self.failure = control.failure orelse err;
253 std.crypto.secureZero(u8, &self.response_head);
254 std.crypto.secureZero(u8, &self.response_body);
255 return self.failure.?;
256 };
257 }
258
259 fn perform(self: *Self, control: *sys.net.Control) !client.ClientResponse {
260 try control.check();
261 if (self.trust == .verified) try self.checkTrust();
262 const address = switch (self.route) {
263 .address => |value| value,
264 .dns => |dns| try control.resolve(self.prepared.host, self.prepared.port, dns.nameserver, dns.family),
265 };
266 const socket = try control.open(address);
267 self.connection.initAt(socket);
268 defer self.connection.deinit(false);
269 self.connection.socket_reader.control = control;
270 self.connection.socket_writer.control = control;
271 try control.connectTo(socket, address);
272 if (self.trust == .verified) try self.handshake(control);
273 const writer = if (self.prepared.is_tls) &self.connection.tls_client.?.writer else &self.connection.socket_writer.writer;
274 try writer.writeAll(self.request[0..self.request_length]);
275 try writer.flush();
276 try self.connection.socket_writer.writer.flush();
277 const reader = if (self.prepared.is_tls) &self.connection.tls_client.?.reader else &self.connection.socket_reader.reader;
278 var response = try client.response.read(.{ .headers = &self.response_headers, .head = &self.response_head, .body = &self.response_body }, reader, self.method[0..self.method_length]);
279 while (response.status < 200) {
280 try control.check();
281 if (response.status == 101) return error.UpgradeRefused;
282 response = try client.response.read(.{ .headers = &self.response_headers, .head = &self.response_head, .body = &self.response_body }, reader, self.method[0..self.method_length]);
283 }
284 try control.check();
285 if (response.status >= 300 and response.status < 400) return error.RedirectRefused;
286 return response;
287 }
288
289 fn trustFingerprint(self: *Self) ![32]u8 {
290 const bundle = self.trust.verified.bundle;
291 if (bundle.bytes.items.len > limits.trust_bytes or bundle.map.capacity() > limits.trust_bytes)
292 return error.Capacity;
293 var hash = std.crypto.hash.sha2.Sha256.init(.{});
294 hash.update("tiny.http.trust/v1");
295 var lengths: [16]u8 = undefined;
296 std.mem.writeInt(u64, lengths[0..8], bundle.bytes.items.len, .little);
297 std.mem.writeInt(u64, lengths[8..16], bundle.map.count(), .little);
298 hash.update(&lengths);
299 hash.update(bundle.bytes.items);
300 var iterator = bundle.map.iterator();
301 while (iterator.next()) |entry| {
302 if (entry.key_ptr.start > entry.key_ptr.end or entry.key_ptr.end > bundle.bytes.items.len or
303 entry.value_ptr.* >= bundle.bytes.items.len) return error.TrustMismatch;
304 var encoded: [12]u8 = undefined;
305 std.mem.writeInt(u32, encoded[0..4], entry.key_ptr.start, .little);
306 std.mem.writeInt(u32, encoded[4..8], entry.key_ptr.end, .little);
307 std.mem.writeInt(u32, encoded[8..12], entry.value_ptr.*, .little);
308 hash.update(&encoded);
309 }
310 return hash.finalResult();
311 }
312
313 fn checkTrust(self: *Self) !void {
314 const digest = try self.trustFingerprint();
315 if (!std.mem.eql(u8, &digest, &self.trust_digest)) return error.TrustMismatch;
316 }
317
318 fn handshake(self: *Self, control: *sys.net.Control) !void {
319 const trust = self.trust.verified;
320 var entropy: [sys.tls.Client.Options.entropy_len]u8 = undefined;
321 defer std.crypto.secureZero(u8, &entropy);
322 try control.entropy(&entropy);
323 try control.check();
324 self.connection.tls_client = try sys.tls.Client.init(&self.connection.socket_reader.reader, &self.connection.socket_writer.writer, .{
325 .host = .{ .explicit = self.prepared.host },
326 .ca = .{ .bundle = .{ .bundle = trust.bundle, .lock = &self.lock, .gpa = trust.allocator, .io = trust.io } },
327 .read_buffer = &self.connection.tls_read_buf,
328 .write_buffer = &self.connection.tls_write_buf,
329 .entropy = &entropy,
330 .realtime_now = sys.tls.realtimeNow(),
331 .allow_truncation_attacks = false,
332 });
333 try control.check();
334 }
335
336 /// Wipes the request, method, head, body, and connection bytes, and
337 /// releases the pinned owner. The function asserts that the owner
338 /// remains at its original address and that no `run` call is active. A
339 /// caller that requested cancellation must join the worker before
340 /// `deinit`. This function also accepts an initialized request that was
341 /// never run.
342 pub fn deinit(self: *Self) void {
343 std.debug.assert(self.origin == self);
344 std.debug.assert(self.state != .running);
345 self.scrub();
346 self.origin = null;
347 }
348
349 fn scrub(self: *Self) void {
350 std.crypto.secureZero(u8, &self.request);
351 std.crypto.secureZero(u8, &self.method);
352 std.crypto.secureZero(u8, &self.response_head);
353 std.crypto.secureZero(u8, &self.response_body);
354 std.crypto.secureZero(u8, std.mem.asBytes(&self.connection));
355 }
356 };
357 }
358
359 fn safeComponent(value: []const u8) bool {
360 for (value) |byte| if (byte <= 32 or byte == 127) return false;
361 return value.len != 0;
362 }
363
364 fn validate(request: Request, limits: Limits) !void {
365 if (request.url.len > @as(usize, limits.host_bytes) + limits.target_bytes + 128 or
366 request.headers.len > limits.request_headers or request.body.len > limits.request_bytes)
367 return error.Capacity;
368 if (request.method.len == 0 or request.method.len > 16) return error.InvalidRequest;
369 for (request.method) |byte| if (byte < 'A' or byte > 'Z') return error.InvalidRequest;
370 if (std.mem.eql(u8, request.method, "CONNECT")) return error.InvalidRequest;
371 const uri = std.Uri.parse(request.url) catch return error.InvalidRequest;
372 if (uri.user != null or uri.password != null or uri.fragment != null) return error.InvalidRequest;
373 var count = request.body.len;
374 for (request.headers) |header| {
375 if (header.name.len > limits.request_bytes - count) return error.Capacity;
376 count += header.name.len;
377 if (header.value.len > limits.request_bytes - count) return error.Capacity;
378 count += header.value.len;
379 if (header.name.len == 0) return error.InvalidRequest;
380 for (header.name) |byte| if (!std.ascii.isAlphanumeric(byte) and byte != '-') return error.InvalidRequest;
381 for (header.value) |byte| if (byte < 32 or byte == 127) return error.InvalidRequest;
382 inline for (.{ "host", "content-length", "transfer-encoding", "connection", "upgrade" }) |name| {
383 if (std.ascii.eqlIgnoreCase(header.name, name)) return error.InvalidRequest;
384 }
385 }
386 }