// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // 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 . 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, µs); 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); }