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 }