Files
browser/src/network/http.zig
T
Karl Seguin 76c8f4bb6e webapi: window.stop()
window.stop() is less destructive than other mechanisms we have. For one, it
seems largely isolated to pending or inflight HTTP requests. For anther, it
keeps the page intact.

To achieve this, HttpClient gains an `cancelRequests` which is a gentler version
of `abortOwner`. It cancels inflight/pending HTTP requests, which results in
error callbacks (not shutdown callbacks) firing.

Just like https://github.com/lightpanda-io/browser/pull/3189 I ran into the
problem that I couldn't distinguish between an HTTP request that was canceled
because of user-action (e.g. calling window.stop(), or xhr.abort()) and an HTTP
request that was internally aborted. These now have distinct errors/flows so
that we can present the correct state. Most places that aborted now all
transfer.cancel() which results in a distinct `error.TransferCanceled` (some
places still abort -> `error.Abort`). It should be possible to revisit 3189 now.

The CDP "Page.stopLoading" now hooks into this new behavior. Fixes
https://github.com/lightpanda-io/browser/issues/3351
2026-09-01 14:55:19 +08:00

1014 lines
35 KiB
Zig

// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const Config = @import("../Config.zig");
const sys_net = @import("../sys/net.zig");
const libcurl = @import("../sys/libcurl.zig");
const crypto = @import("../sys/libcrypto.zig");
const IpFilter = @import("IpFilter.zig");
const Certificates = @import("Certificates.zig");
const log = lp.log;
const posix = std.posix;
pub const ENABLE_DEBUG = false;
pub const WaitFd = libcurl.CurlWaitFd;
pub const readfunc_pause = libcurl.curl_readfunc_pause;
pub const writefunc_error = libcurl.curl_writefunc_error;
pub const WsFrameType = libcurl.WsFrameType;
const Error = libcurl.Error;
pub fn curl_version() [*c]const u8 {
return libcurl.curl_version();
}
pub const Method = enum(u8) {
GET = 0,
PUT = 1,
POST = 2,
DELETE = 3,
HEAD = 4,
OPTIONS = 5,
PATCH = 6,
PROPFIND = 7,
};
pub const Header = struct {
name: []const u8,
value: []const u8,
pub const Param = struct {
key: []const u8,
value: []const u8,
};
pub fn parse(header_str: []const u8) ?Header {
const colon_pos = std.mem.indexOfScalar(u8, header_str, ':') orelse return null;
const name = std.mem.trim(u8, header_str[0..colon_pos], " \t");
const value = std.mem.trim(u8, header_str[colon_pos + 1 ..], " \t");
return .{ .name = name, .value = value };
}
// The header value up to the first ';', trimmed (e.g. "attachment" for a
// Content-Disposition, "text/html" for a Content-Type).
pub fn firstValue(self: Header) []const u8 {
const end = std.mem.indexOfScalar(u8, self.value, ';') orelse self.value.len;
return std.mem.trim(u8, self.value[0..end], " \t");
}
// Iterates the `; key=value` parameters that follow the header's first value.
pub fn params(self: Header) ParamIterator {
const start = std.mem.indexOfScalar(u8, self.value, ';') orelse self.value.len;
return .{ .rest = self.value[start..] };
}
// Returns the (unquoted) value of the first non-empty `key=` parameter, if any.
pub fn param(self: Header, key: []const u8) ?[]const u8 {
var it = self.params();
while (it.next()) |p| {
if (p.value.len > 0 and std.ascii.eqlIgnoreCase(p.key, key)) {
return p.value;
}
}
return null;
}
pub const ParamIterator = struct {
rest: []const u8,
pub fn next(self: *ParamIterator) ?Param {
while (self.rest.len > 0 and self.rest[0] == ';') {
self.rest = self.rest[1..];
const end = std.mem.indexOfScalar(u8, self.rest, ';') orelse self.rest.len;
const segment = self.rest[0..end];
self.rest = self.rest[end..];
const eq = std.mem.indexOfScalar(u8, segment, '=') orelse continue;
const key = std.mem.trim(u8, segment[0..eq], " \t");
if (key.len == 0) continue;
var value = std.mem.trim(u8, segment[eq + 1 ..], " \t");
if (value.len >= 2 and value[0] == '"' and value[value.len - 1] == '"') {
value = value[1 .. value.len - 1];
}
return .{ .key = key, .value = value };
}
return null;
}
};
};
// In normal cases, the header iterator comes from the curl connection.
// But it's also possible to inject a response, via `transfer.fulfill`. In that
// case, the response headers are a list, []const Http.Header.
// This union, is an iterator that exposes the same API for either case.
pub const HeaderIterator = union(enum) {
curl: CurlHeaderIterator,
list: ListHeaderIterator,
pub fn next(self: *HeaderIterator) ?Header {
switch (self.*) {
inline else => |*it| return it.next(),
}
}
pub fn collect(self: *HeaderIterator, allocator: std.mem.Allocator) !std.ArrayList(Header) {
var list: std.ArrayList(Header) = .empty;
while (self.next()) |hdr| {
try list.append(allocator, .{
.name = try allocator.dupe(u8, hdr.name),
.value = try allocator.dupe(u8, hdr.value),
});
}
return list;
}
const CurlHeaderIterator = struct {
conn: *const Connection,
prev: ?*libcurl.CurlHeader = null,
pub fn next(self: *CurlHeaderIterator) ?Header {
const h = libcurl.curl_easy_nextheader(self.conn._easy, .header, -1, self.prev) orelse return null;
self.prev = h;
const header = h.*;
return .{
.name = std.mem.span(header.name),
.value = std.mem.span(header.value),
};
}
};
const ListHeaderIterator = struct {
index: usize = 0,
list: []const Header,
pub fn next(self: *ListHeaderIterator) ?Header {
const idx = self.index;
if (idx == self.list.len) {
return null;
}
self.index = idx + 1;
return self.list[idx];
}
};
};
const HeaderValue = struct {
value: []const u8,
amount: usize,
};
pub const AuthChallenge = struct {
const Source = enum { server, proxy };
const Scheme = enum { basic, digest };
status: u16,
source: ?Source,
scheme: ?Scheme,
realm: ?[]const u8,
pub fn parse(status: u16, source: Source, value: []const u8) !AuthChallenge {
var ac: AuthChallenge = .{
.status = status,
.source = source,
.realm = null,
.scheme = null,
};
const challenge_value = std.mem.trim(u8, value, std.ascii.whitespace[0..]);
const pos = std.mem.indexOfPos(u8, challenge_value, 0, " ") orelse challenge_value.len;
const _scheme = challenge_value[0..pos];
if (std.ascii.eqlIgnoreCase(_scheme, "basic")) {
ac.scheme = .basic;
} else if (std.ascii.eqlIgnoreCase(_scheme, "digest")) {
ac.scheme = .digest;
} else {
return error.UnknownAuthChallengeScheme;
}
return ac;
}
};
pub const ResponseHead = struct {
// Matches Mime.parse's 255-byte cap
pub const MAX_CONTENT_TYPE_LEN = 255;
status: u16,
url: [*c]const u8,
redirect_count: u32,
_content_type_len: usize = 0,
_content_type: [MAX_CONTENT_TYPE_LEN]u8 = undefined,
pub fn contentType(self: *ResponseHead) ?[]u8 {
if (self._content_type_len == 0) {
return null;
}
return self._content_type[0..self._content_type_len];
}
};
/// Opensocket callback: blocks connections to private/internal IP ranges
/// before TCP SYN, regardless of request origin (JS, HTML resources, redirects, etc.).
/// Called by curl after DNS resolution, before the socket is created.
/// Returns CURL_SOCKET_BAD to block; otherwise creates and returns a real socket fd.
/// clientp is a *const IpFilter passed via CURLOPT_OPENSOCKETDATA.
fn opensocketCallback(
clientp: ?*anyopaque,
_: c_uint,
addr: [*c]libcurl.CurlSockAddr,
) callconv(.c) libcurl.CurlSocket {
const address: *libcurl.CurlSockAddr = @ptrCast(addr);
const filter: *const IpFilter = @ptrCast(@alignCast(clientp orelse return libcurl.CURL_SOCKET_BAD));
if (filter.isBlockedSockaddr(address)) {
if (address.family == posix.AF.INET or address.family == posix.AF.INET6) {
const ip = sys_net.addressFromSockaddr(@ptrCast(@alignCast(&address.addr)));
log.warn(.http, "blocked by IP filter", .{ .ip = ip });
} else {
log.warn(.http, "blocked by IP filter", .{ .family = address.family });
}
return libcurl.CURL_SOCKET_BAD;
}
const fd = sys_net.socket(
@intCast(address.family),
@intCast(address.socktype),
@intCast(address.protocol),
) catch return libcurl.CURL_SOCKET_BAD;
return fd;
}
pub const Connection = struct {
_easy: *libcurl.Curl,
transport: Transport,
node: std.DoublyLinkedList.Node = .{},
// The curl_slist accumulated by addHeader/addRawHeader and handed to
// curl by commitHeaders. Owned here because curl reads it during
// perform; freed on reset/deinit or by the next clearHeaders.
_header_list: ?*libcurl.CurlSList = null,
pub const Transport = union(enum) {
none, // used for cases that manage their own connection, e.g. telemetry
http: *@import("HttpClient.zig").Transfer,
websocket: *@import("../browser/webapi/net/WebSocket.zig"),
};
pub fn init(
certificates: Certificates,
config: *const Config,
ip_filter: ?*const IpFilter,
) !Connection {
const easy = libcurl.curl_easy_init() orelse return error.FailedToInitializeEasy;
var self = Connection{ ._easy = easy, .transport = .none };
errdefer self.deinit();
try self.reset(config, certificates, ip_filter);
return self;
}
pub fn deinit(self: *Connection) void {
self.clearHeaders();
libcurl.curl_easy_cleanup(self._easy);
}
pub fn setURL(self: *const Connection, url: [:0]const u8) !void {
try libcurl.curl_easy_setopt(self._easy, .url, url.ptr);
}
pub fn setTimeout(self: *const Connection, timeout_ms: u32) !void {
try libcurl.curl_easy_setopt(self._easy, .timeout_ms, timeout_ms);
}
// a libcurl request has 2 methods. The first is the method that
// controls how libcurl behaves. This specifically influences how redirects
// are handled. For example, if you do a POST and get a 301, libcurl will
// change that to a GET. But if you do a POST and get a 308, libcurl will
// keep the POST (and re-send the body).
// The second method is the actual string that's included in the request
// headers.
// These two methods can be different - you can tell curl to behave as though
// you made a GET, but include "POST" in the request header.
//
// Here, we're only concerned about the 2nd method. If we want, we'll set
// the first one based on whether or not we have a body.
//
// It's important that, for each use of this connection, we set the 2nd
// method. Else, if we make a HEAD request and re-use the connection, but
// DON'T reset this, it'll keep making HEAD requests.
// (I don't know if it's as important to reset the 1st method, or if libcurl
// can infer that based on the presence of the body, but we also reset it
// to be safe);
pub fn setMethod(self: *const Connection, method: Method) !void {
const easy = self._easy;
const m: [:0]const u8 = switch (method) {
.GET => "GET",
.POST => "POST",
.PUT => "PUT",
.DELETE => "DELETE",
.HEAD => "HEAD",
.OPTIONS => "OPTIONS",
.PATCH => "PATCH",
.PROPFIND => "PROPFIND",
};
try libcurl.curl_easy_setopt(easy, .custom_request, m.ptr);
}
pub fn setBody(self: *const Connection, body: []const u8) !void {
const easy = self._easy;
try libcurl.curl_easy_setopt(easy, .post, true);
try libcurl.curl_easy_setopt(easy, .post_field_size, body.len);
try libcurl.curl_easy_setopt(easy, .copy_post_fields, body.ptr);
}
pub fn setGetMode(self: *const Connection) !void {
try libcurl.curl_easy_setopt(self._easy, .http_get, true);
}
// Appends "name: value" to the connection's pending header list, or
// "name;" when the value is empty — curl reads "name:" as
// remove-this-header and the ";" form as send-an-empty-value. curl
// copies the string, so `allocator` only backs the transient join.
pub fn addHeader(self: *Connection, allocator: std.mem.Allocator, name: []const u8, value: []const u8) !void {
const joined = if (value.len == 0)
try std.fmt.allocPrintSentinel(allocator, "{s};", .{name}, 0)
else
try std.fmt.allocPrintSentinel(allocator, "{s}: {s}", .{ name, value }, 0);
return self.addRawHeader(joined);
}
// `header` is raw curl header syntax, e.g. "Expect:" to suppress a
// curl-generated header. curl copies the value.
pub fn addRawHeader(self: *Connection, header: [*c]const u8) !void {
const updated = libcurl.curl_slist_append(self._header_list, header);
if (updated == null) {
return error.OutOfMemory;
}
self._header_list = updated;
}
pub fn commitHeaders(self: *const Connection) !void {
try libcurl.curl_easy_setopt(self._easy, .http_header, self._header_list);
}
pub fn clearHeaders(self: *Connection) void {
if (self._header_list) |list| {
libcurl.curl_slist_free_all(list);
self._header_list = null;
}
}
pub fn setCookies(self: *const Connection, cookies: [*c]const u8) !void {
try libcurl.curl_easy_setopt(self._easy, .cookie, cookies);
}
pub fn setPrivate(self: *const Connection, ptr: *anyopaque) !void {
try libcurl.curl_easy_setopt(self._easy, .private, ptr);
}
pub fn setProxyCredentials(self: *const Connection, creds: [:0]const u8) !void {
try libcurl.curl_easy_setopt(self._easy, .proxy_user_pwd, creds.ptr);
}
pub fn setCredentials(self: *const Connection, creds: [:0]const u8) !void {
try libcurl.curl_easy_setopt(self._easy, .user_pwd, creds.ptr);
}
pub fn setConnectOnly(self: *const Connection, connect_only: bool) !void {
const value: c_long = if (connect_only) 2 else 0;
try libcurl.curl_easy_setopt(self._easy, .connect_only, value);
}
pub fn setWriteCallback(
self: *Connection,
comptime data_cb: libcurl.CurlWriteFunction,
) !void {
try libcurl.curl_easy_setopt(self._easy, .write_data, self);
try libcurl.curl_easy_setopt(self._easy, .write_function, data_cb);
}
pub fn setReadCallback(
self: *Connection,
comptime data_cb: libcurl.CurlReadFunction,
upload: bool,
) !void {
try libcurl.curl_easy_setopt(self._easy, .read_data, self);
try libcurl.curl_easy_setopt(self._easy, .read_function, data_cb);
if (upload) {
try libcurl.curl_easy_setopt(self._easy, .upload, true);
}
}
pub fn setHeaderCallback(
self: *Connection,
comptime data_cb: libcurl.CurlHeaderFunction,
) !void {
try libcurl.curl_easy_setopt(self._easy, .header_data, self);
try libcurl.curl_easy_setopt(self._easy, .header_function, data_cb);
}
pub fn pause(
self: *Connection,
flags: libcurl.CurlPauseFlags,
) !void {
try libcurl.curl_easy_pause(self._easy, flags);
}
pub fn reset(
self: *Connection,
config: *const Config,
certificates: Certificates,
ip_filter: ?*const IpFilter,
) !void {
libcurl.curl_easy_reset(self._easy);
self.transport = .none;
self.clearHeaders();
// timeouts
try libcurl.curl_easy_setopt(self._easy, .timeout_ms, config.httpTimeout());
try libcurl.curl_easy_setopt(self._easy, .connect_timeout_ms, config.httpConnectTimeout());
// compression, don't remove this. CloudFront will send gzip content
// even if we don't support it, and then it won't be decompressed.
// empty string means: use whatever's available
try libcurl.curl_easy_setopt(self._easy, .accept_encoding, "");
// proxy
const http_proxy = config.httpProxy();
if (http_proxy) |proxy| {
try libcurl.curl_easy_setopt(self._easy, .proxy, proxy.ptr);
} else {
try libcurl.curl_easy_setopt(self._easy, .proxy, null);
}
// TLS.
if (config.tlsVerifyHost()) {
// Provide certificate store to connection's SSL_CTX.
try libcurl.curl_easy_setopt(self._easy, .ssl_ctx_function, &(struct {
fn wrap(
_: *libcurl.Curl,
raw_ssl_ctx: *anyopaque,
raw_x509_store: *anyopaque,
) callconv(.c) libcurl.CurlCode {
const ssl_ctx: *crypto.SSL_CTX = @ptrCast(raw_ssl_ctx);
const store: *crypto.X509_STORE = @ptrCast(raw_x509_store);
const result = crypto.SSL_CTX_set1_verify_cert_store(ssl_ctx, store);
if (result != 1) {
return libcurl.CURLE.ABORTED_BY_CALLBACK;
}
return libcurl.CURLE.OK;
}
}).wrap);
// Pass our store to CURLOPT_SSL_CTX_FUNCTION.
try libcurl.curl_easy_setopt(self._easy, .ssl_ctx_data, certificates.store);
} else {
try libcurl.curl_easy_setopt(self._easy, .ssl_verify_host, false);
try libcurl.curl_easy_setopt(self._easy, .ssl_verify_peer, false);
if (http_proxy != null) {
try libcurl.curl_easy_setopt(self._easy, .proxy_ssl_verify_host, false);
try libcurl.curl_easy_setopt(self._easy, .proxy_ssl_verify_peer, false);
}
}
// debug
if (comptime ENABLE_DEBUG) {
try libcurl.curl_easy_setopt(self._easy, .verbose, true);
// Sometimes the default debug output hides some useful data. You can
// uncomment the following line (BUT KEEP THE LIVE ABOVE AS-IS), to
// get more control over the data (specifically, the `CURLINFO_TEXT`
// can include useful data).
// try libcurl.curl_easy_setopt(easy, .debug_function, debugCallback);
}
// default write callback to prevent libcurl from writing to stdout
try self.setWriteCallback(discardBody);
// IP filter: block private/internal network addresses
if (ip_filter) |filter| {
try libcurl.curl_easy_setopt(self._easy, .opensocket_function, opensocketCallback);
try libcurl.curl_easy_setopt(self._easy, .opensocket_data, @constCast(filter));
}
}
fn discardBody(_: [*]const u8, count: usize, len: usize, _: ?*anyopaque) callconv(.c) usize {
return count * len;
}
pub fn setProxy(self: *const Connection, proxy: ?[:0]const u8) !void {
try libcurl.curl_easy_setopt(self._easy, .proxy, if (proxy) |p| p.ptr else null);
}
pub fn setHttpVersion(self: *const Connection, version: Config.HttpVersion) !void {
const v: libcurl.CurlHttpVersion = switch (version) {
.auto => .none,
.@"1.1" => .v1_1,
};
try libcurl.curl_easy_setopt(self._easy, .http_version, v);
}
pub fn setFollowLocation(self: *const Connection, follow: bool) !void {
try libcurl.curl_easy_setopt(self._easy, .follow_location, @as(c_long, if (follow) 2 else 0));
}
pub fn setTlsVerify(self: *const Connection, verify: bool, use_proxy: bool) !void {
try libcurl.curl_easy_setopt(self._easy, .ssl_verify_host, verify);
try libcurl.curl_easy_setopt(self._easy, .ssl_verify_peer, verify);
if (use_proxy) {
try libcurl.curl_easy_setopt(self._easy, .proxy_ssl_verify_host, verify);
try libcurl.curl_easy_setopt(self._easy, .proxy_ssl_verify_peer, verify);
}
}
pub fn getEffectiveUrl(self: *const Connection) ![*c]const u8 {
var url: [*c]u8 = undefined;
try libcurl.curl_easy_getinfo(self._easy, .effective_url, &url);
return url;
}
pub fn getConnectCode(self: *const Connection) !u16 {
var status: c_long = undefined;
try libcurl.curl_easy_getinfo(self._easy, .connect_code, &status);
if (status < 0 or status > std.math.maxInt(u16)) {
return 0;
}
return @intCast(status);
}
pub fn getResponseCode(self: *const Connection) !u16 {
var status: c_long = undefined;
try libcurl.curl_easy_getinfo(self._easy, .response_code, &status);
if (status < 0 or status > std.math.maxInt(u16)) {
return 0;
}
return @intCast(status);
}
pub fn getRedirectCount(self: *const Connection) !u32 {
var count: c_long = undefined;
try libcurl.curl_easy_getinfo(self._easy, .redirect_count, &count);
return @intCast(count);
}
// -1 when the transfer used no connection.
pub fn getConnId(self: *const Connection) !c_long {
var conn_id: c_long = undefined;
try libcurl.curl_easy_getinfo(self._easy, .conn_id, &conn_id);
return conn_id;
}
pub fn isConnReused(self: *const Connection) !bool {
var opened: c_long = undefined;
try libcurl.curl_easy_getinfo(self._easy, .num_connects, &opened);
return opened == 0;
}
// Total transfer time (name lookup to completion) in microseconds.
pub fn getTotalTimeMicros(self: *const Connection) !c_long {
var micros: c_long = undefined;
try libcurl.curl_easy_getinfo(self._easy, .total_time_t, &micros);
return micros;
}
pub fn getConnectHeader(self: *const Connection, name: [:0]const u8, index: usize) ?HeaderValue {
var hdr: ?*libcurl.CurlHeader = null;
libcurl.curl_easy_header(self._easy, name, index, .connect, -1, &hdr) catch |err| {
// ErrorHeader includes OutOfMemory — rare but real errors from curl internals.
// Logged and returned as null since callers don't expect errors.
log.err(.http, "get response header", .{
.name = name,
.err = err,
});
return null;
};
const h = hdr orelse return null;
return .{
.amount = h.amount,
.value = std.mem.span(h.value),
};
}
pub fn getResponseHeader(self: *const Connection, name: [:0]const u8, index: usize) ?HeaderValue {
var hdr: ?*libcurl.CurlHeader = null;
libcurl.curl_easy_header(self._easy, name, index, .header, -1, &hdr) catch |err| {
// ErrorHeader includes OutOfMemory — rare but real errors from curl internals.
// Logged and returned as null since callers don't expect errors.
log.err(.http, "get response header", .{
.name = name,
.err = err,
});
return null;
};
const h = hdr orelse return null;
return .{
.amount = h.amount,
.value = std.mem.span(h.value),
};
}
// These are headers that may not be send to the users for inteception.
pub fn secretHeaders(self: *Connection, http_headers: *const Config.HttpHeaders) !void {
if (http_headers.proxy_bearer_header) |hdr| {
try self.addRawHeader(hdr);
}
}
// Synchronous transfer that adds no request headers; callers that manage
// their own connection (telemetry) use this leaner path.
pub fn perform(self: *const Connection) !u16 {
try libcurl.curl_easy_perform(self._easy);
return self.getResponseCode();
}
pub fn wsStartFrame(self: *const Connection, frame_type: libcurl.WsFrameType, size: usize) !void {
try libcurl.curl_ws_start_frame(self._easy, frame_type, @intCast(size));
}
pub fn wsMeta(self: *const Connection) ?libcurl.WsFrameMeta {
return libcurl.curl_ws_meta(self._easy);
}
};
pub const Handles = struct {
multi: *libcurl.CurlM,
pub fn init(config: *const Config) !Handles {
const multi = libcurl.curl_multi_init() orelse return error.FailedToInitializeMulti;
errdefer libcurl.curl_multi_cleanup(multi) catch {};
try libcurl.curl_multi_setopt(multi, .max_host_connections, config.httpMaxHostOpen());
return .{ .multi = multi };
}
pub fn deinit(self: *Handles) void {
libcurl.curl_multi_cleanup(self.multi) catch {};
}
pub fn add(self: *Handles, conn: *const Connection) !void {
try libcurl.curl_multi_add_handle(self.multi, conn._easy);
}
pub fn remove(self: *Handles, conn: *const Connection) !void {
try libcurl.curl_multi_remove_handle(self.multi, conn._easy);
}
pub fn perform(self: *Handles) !c_int {
var running: c_int = undefined;
try libcurl.curl_multi_perform(self.multi, &running);
return running;
}
pub fn poll(self: *Handles, extra_fds: []libcurl.CurlWaitFd, timeout_ms: c_int) !void {
try libcurl.curl_multi_poll(self.multi, extra_fds, timeout_ms, null);
}
// Thread-safe wake of a poll() in progress on this multi. Used by
// the Network thread to nudge the worker out of curl_multi_poll
// when it pushes work onto the worker's inbox.
pub fn wakeup(self: *Handles) !void {
try libcurl.curl_multi_wakeup(self.multi);
}
pub const MultiMessage = struct {
conn: *Connection,
err: ?Error,
};
pub fn readMessage(self: *Handles) !?MultiMessage {
var messages_count: c_int = 0;
const msg = libcurl.curl_multi_info_read(self.multi, &messages_count) orelse return null;
return switch (msg.data) {
.done => |err| {
var private: *anyopaque = undefined;
try libcurl.curl_easy_getinfo(msg.easy_handle, .private, &private);
return .{
.conn = @ptrCast(@alignCast(private)),
.err = err,
};
},
else => unreachable,
};
}
};
pub const StatusCategory = enum {
@"2xx",
@"4xx",
@"5xx",
other,
};
pub fn statusCategory(status: u16) StatusCategory {
return switch (status) {
200...299 => .@"2xx",
400...499 => .@"4xx",
500...599 => .@"5xx",
else => .other,
};
}
// https://fetch.spec.whatwg.org/#bad-port
pub fn isBadPort(port: u16) bool {
return switch (port) {
1, // tcpmux
7, // echo
9, // discard
11, // systat
13, // daytime
15, // netstat
17, // qotd
19, // chargen
20, // ftp-data
21, // ftp
22, // ssh
23, // telnet
25, // smtp
37, // time
42, // name
43, // nicname
53, // domain
69, // tftp
77, // priv-rjs
79, // finger
87, // ttylink
95, // supdup
101, // hostriame
102, // iso-tsap
103, // gppitnp
104, // acr-nema
109, // pop2
110, // pop3
111, // sunrpc
113, // auth
115, // sftp
117, // uucp-path
119, // nntp
123, // ntp
135, // loc-srv / epmap
137, // netbios-ns
139, // netbios-ssn
143, // imap2
161, // snmp
179, // bgp
389, // ldap
427, // afp (alternate)
465, // smtp (alternate)
512, // print / exec
513, // login
514, // shell
515, // printer
526, // tempo
530, // courier
531, // chat
532, // netnews
540, // uucp
548, // afp
554, // rtsp
556, // remotefs
563, // nntp+ssl
587, // smtp (outgoing)
601, // syslog-conn
636, // ldap+ssl
989, // ftps-data
990, // ftps
993, // imap+ssl
995, // pop3+ssl
1719, // h323gatestat
1720, // h323hostcall
1723, // pptp
2049, // nfs
3659, // apple-sasl
4045, // lockd
4190, // sieve
5060, // sip
5061, // sips
6000, // x11
6566, // sane-port
6665, // irc (alternate)
6666, // irc (alternate)
6667, // irc (default)
6668, // irc (alternate)
6669, // irc (alternate)
6679, // osaut
6697, // irc+tls
10080, // amanda
=> true,
else => false,
};
}
// Coarse failure classes for the http_error metric. Anything we don't
// explicitly bucket lands in `other`.
pub const ErrorReason = enum {
timeout,
connect,
tls,
too_large,
aborted,
robots_blocked,
other,
};
pub fn errorReason(err: anyerror) ErrorReason {
return switch (err) {
error.OperationTimedout => .timeout,
error.CouldntConnect,
error.CouldntResolveHost,
error.CouldntResolveProxy,
error.NoConnectionAvailable,
=> .connect,
error.SslConnectError,
error.PeerFailedVerification,
error.SslCertproblem,
error.SslCacertBadfile,
error.SslIssuerError,
error.SslPinnedpubkeynotmatch,
error.SslInvalidcertstatus,
error.UseSslFailed,
=> .tls,
error.ResponseTooLarge => .too_large,
error.Abort,
error.TransferCanceled,
error.AbortedByCallback,
error.AbortAuthChallenge,
error.SyncWaitInterrupted,
=> .aborted,
error.RobotsBlocked => .robots_blocked,
else => .other,
};
}
fn debugCallback(_: *libcurl.Curl, msg_type: libcurl.CurlInfoType, raw: [*c]u8, len: usize, _: ?*anyopaque) callconv(.c) c_int {
const data = raw[0..len];
switch (msg_type) {
.text => std.debug.print("libcurl [text]: {s}\n", .{data}),
.header_out => std.debug.print("libcurl [req-h]: {s}\n", .{data}),
.header_in => std.debug.print("libcurl [res-h]: {s}\n", .{data}),
// .data_in => std.debug.print("libcurl [res-b]: {s}\n", .{data}),
else => std.debug.print("libcurl ?? {d}\n", .{msg_type}),
}
return 0;
}
// ── Unit tests for opensocketCallback ────────────────────────────────────────
fn makeSockAddrV4(ip: [4]u8) libcurl.CurlSockAddr {
var sa: posix.sockaddr.in = .{
.port = 0,
.addr = @bitCast(ip),
};
var curl_sa: libcurl.CurlSockAddr = .{
.family = posix.AF.INET,
.socktype = posix.SOCK.STREAM,
.protocol = 0,
.addrlen = @sizeOf(posix.sockaddr.in),
.addr = undefined,
};
@memcpy(std.mem.asBytes(&curl_sa.addr)[0..@sizeOf(posix.sockaddr.in)], std.mem.asBytes(&sa));
return curl_sa;
}
const testing = @import("../testing.zig");
test "isBadPort" {
for ([_]u16{ 1, 22, 25, 143, 6697, 10080 }) |port| {
try testing.expect(isBadPort(port));
}
for ([_]u16{ 0, 80, 443, 8000, 8080, 9584, 65535 }) |port| {
try testing.expect(!isBadPort(port));
}
}
test "Header.parse" {
{
const h = Header.parse("Content-Type: text/html; charset=utf-8").?;
try testing.expectEqualSlices(u8, "Content-Type", h.name);
try testing.expectEqualSlices(u8, "text/html; charset=utf-8", h.value);
}
{
// no space after the colon
const h = Header.parse("X-Custom:value").?;
try testing.expectEqualSlices(u8, "X-Custom", h.name);
try testing.expectEqualSlices(u8, "value", h.value);
}
{
// name and value are trimmed of spaces and tabs
const h = Header.parse(" \tAccept \t: \tapplication/json \t").?;
try testing.expectEqualSlices(u8, "Accept", h.name);
try testing.expectEqualSlices(u8, "application/json", h.value);
}
{
// only the first colon splits; later colons stay in the value
const h = Header.parse("Referer: http://example.com:8080/").?;
try testing.expectEqualSlices(u8, "Referer", h.name);
try testing.expectEqualSlices(u8, "http://example.com:8080/", h.value);
}
{
// empty value
const h = Header.parse("X-Empty:").?;
try testing.expectEqualSlices(u8, "X-Empty", h.name);
try testing.expectEqualSlices(u8, "", h.value);
}
{
// Splitting is all `parse` does; a name that isn't an HTTP token still
// parses. Callers validate what comes back.
const h = Header.parse("Foo Bar: value").?;
try testing.expectEqualSlices(u8, "Foo Bar", h.name);
try testing.expectEqualSlices(u8, "value", h.value);
}
// no colon, no header
try testing.expect(Header.parse("not-a-header") == null);
try testing.expect(Header.parse("") == null);
}
test "Header.firstValue" {
try testing.expectEqualSlices(u8, "attachment", (Header{ .name = "Content-Disposition", .value = "attachment" }).firstValue());
// firstValue trims but preserves case (callers compare case-insensitively).
try testing.expectEqualSlices(u8, "ATTACHMENT", (Header{ .name = "Content-Disposition", .value = " ATTACHMENT ; filename=a.csv" }).firstValue());
try testing.expectEqualSlices(u8, "text/html", (Header{ .name = "Content-Type", .value = "text/html; charset=utf-8" }).firstValue());
try testing.expectEqualSlices(u8, "", (Header{ .name = "X", .value = "" }).firstValue());
}
test "Header.param" {
const h: Header = .{ .name = "Content-Disposition", .value = "attachment; filename=\"r e.csv\"; filename*=UTF-8''e.txt" };
try testing.expectEqualSlices(u8, "r e.csv", h.param("filename").?);
try testing.expectEqualSlices(u8, "r e.csv", h.param("FILENAME").?);
try testing.expectEqualSlices(u8, "UTF-8''e.txt", h.param("filename*").?);
try testing.expect(h.param("missing") == null);
// A bare value with no parameters has nothing to return.
try testing.expect((Header{ .name = "Content-Disposition", .value = "attachment" }).param("filename") == null);
// Empty values are skipped.
try testing.expect((Header{ .name = "Content-Disposition", .value = "attachment; filename=\"\"" }).param("filename") == null);
}
test "opensocketCallback: private IPv4 returns CURL_SOCKET_BAD" {
testing.silenceLog(&.{.http});
const filter = IpFilter.init(true, null);
var sa = makeSockAddrV4(.{ 127, 0, 0, 1 });
const result = opensocketCallback(@ptrCast(@constCast(&filter)), @intFromEnum(libcurl.CurlSockType.ipcxn), &sa);
try testing.expectEqual(libcurl.CURL_SOCKET_BAD, result);
}
test "opensocketCallback: public IPv4 opens a real socket" {
// 8.8.8.8 — not in any blocked range; callback should create a real socket
const filter = IpFilter.init(true, null);
var sa = makeSockAddrV4(.{ 8, 8, 8, 8 });
const fd = opensocketCallback(@ptrCast(@constCast(&filter)), @intFromEnum(libcurl.CurlSockType.ipcxn), &sa);
defer _ = std.c.close(fd);
// A real fd is always >= 0
try testing.expect(fd >= 0);
}
test "opensocketCallback: null clientp returns CURL_SOCKET_BAD (fail-closed)" {
var sa = makeSockAddrV4(.{ 8, 8, 8, 8 });
const result = opensocketCallback(null, @intFromEnum(libcurl.CurlSockType.ipcxn), &sa);
try testing.expectEqual(libcurl.CURL_SOCKET_BAD, result);
}
test "opensocketCallback: block_private=false allows private IP" {
// When block_private is false the filter blocks nothing
const filter = IpFilter.init(false, null);
var sa = makeSockAddrV4(.{ 127, 0, 0, 1 });
const fd = opensocketCallback(@ptrCast(@constCast(&filter)), @intFromEnum(libcurl.CurlSockType.ipcxn), &sa);
defer _ = std.c.close(fd);
try testing.expect(fd >= 0);
}