calog/libs/calogHttp.c

1518 lines
52 KiB
C

// calogHttp.c -- calog HTTP client library (see calogHttp.h). A self-contained HTTP/1.1
// client: parse the URL, connect (getaddrinfo + socket + connect), optionally wrap the socket
// in OpenSSL TLS for https, send the request with "Connection: close", read the entire
// response to EOF, and decode it (status line, lowercased headers into a map, and a body that
// honors Transfer-Encoding: chunked / Content-Length / read-to-close). No shared state: every
// native is a self-contained connection, so the natives are INLINE and there is nothing to
// shut down. https verifies the server certificate + hostname against the system CA store by
// default; httpRequest can pass insecure=true to skip verification (self-signed / dev).
#define _GNU_SOURCE
#include "calogHttp.h"
#include "calogInternal.h"
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <netdb.h>
#include <netinet/in.h>
#include <poll.h>
#include <signal.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>
#include <openssl/ssl.h>
#include <openssl/x509v3.h>
#define HTTP_BUF_INITIAL 256
#define HTTP_READ_CHUNK 16384
#define HTTP_MAX_RESPONSE (64 * 1024 * 1024)
#define HTTP_HOST_MAX 256
#define HTTP_PORT_MAX 16
#define HTTP_PATH_MAX 4096
// Connect, read, and write are all bounded by this single deadline (seconds): a stalled or
// blackholed peer must not hang the calling script thread (or a shutdown joining it) forever.
#define HTTP_TIMEOUT_SEC 30
#define HTTP_PORT_HTTP "80"
#define HTTP_PORT_HTTPS "443"
// Follow 3xx redirects, but stop after this many hops so a redirect loop cannot spin forever.
#define HTTP_MAX_REDIRECTS 16
#define HTTP_METHOD_MAX 16
#define HTTP_URL_MAX (HTTP_HOST_MAX + HTTP_PORT_MAX + HTTP_PATH_MAX + 32)
// A growable output byte buffer for the request and for decoded bodies.
typedef struct HttpBufT {
char *bytes;
size_t length;
size_t cap;
} HttpBufT;
// A parsed absolute URL. host/port/path are NUL-terminated so they double as C strings. ipv6 is
// true when the URL wrote the host as a bracketed literal ("[::1]"); host itself is stored
// WITHOUT the brackets (getaddrinfo/SNI/certificate-hostname all want the bare address), and
// the Host header re-adds them.
typedef struct HttpUrlT {
bool https;
bool ipv6;
char host[HTTP_HOST_MAX];
char port[HTTP_PORT_MAX];
char path[HTTP_PATH_MAX];
} HttpUrlT;
// One connection: a plain socket, or a TLS session layered over it when ssl != NULL. verify
// requests certificate + hostname authentication for an https connection (default; an https
// request can opt out with insecure=true).
typedef struct HttpConnT {
int fd;
SSL *ssl;
SSL_CTX *ctx;
bool verify;
} HttpConnT;
static CalogValueT *httpAggField(CalogAggT *agg, const char *name);
static int32_t httpAppendHeaders(HttpBufT *buf, const CalogAggT *headers);
static int32_t httpBufEnsure(HttpBufT *buf, size_t extra);
static void httpBufFree(HttpBufT *buf);
static int32_t httpBufPutBytes(HttpBufT *buf, const char *bytes, size_t length);
static int32_t httpBufPutStr(HttpBufT *buf, const char *s);
static int32_t httpBuildResult(const char *raw, size_t rawLen, CalogValueT *result);
static void httpConnClose(HttpConnT *conn);
static bool httpConnectTimeout(int fd, const struct sockaddr *addr, socklen_t addrLen, int timeoutSec);
static int32_t httpConnOpen(HttpConnT *conn, const HttpUrlT *url, CalogValueT *result);
static ssize_t httpConnRead(HttpConnT *conn, char *buf, size_t length);
static int32_t httpConnWriteAll(HttpConnT *conn, const char *bytes, size_t length);
static bool httpContainsChunked(const char *bytes, size_t length);
static bool httpCopyField(char *dst, size_t cap, const char *src, size_t length);
static int32_t httpDechunk(const char *bytes, size_t length, HttpBufT *out);
static bool httpDefaultPort(const HttpUrlT *url);
static CalogAggT *httpFilterHeaders(const CalogAggT *headers, bool dropCreds, bool dropContent);
static int32_t httpFollow(const char *method, const char *urlBytes, int64_t urlLen, const CalogAggT *headers, const char *body, int64_t bodyLen, bool verify, int32_t maxRedirects, CalogValueT *result);
static int32_t httpGetNative(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
static bool httpHasBadByte(const char *bytes, size_t length, bool rejectSpace);
static bool httpHeaderIn(const char *name, int64_t len, const char *const *list);
static int32_t httpHexVal(char c);
static bool httpIsRedirect(int32_t code);
static char httpLower(char c);
static int32_t httpMapSetAgg(CalogAggT *map, const char *key, CalogAggT *inner);
static bool httpMethodHasBody(const char *method);
static CalogValueT *httpOptGet(CalogAggT *opts, const char *name, int32_t *status);
static int64_t httpParseInt(const char *bytes, size_t length);
static int32_t httpParseStatus(const char *line, size_t length, int32_t *codeOut);
static int32_t httpParseUrl(const char *url, int64_t urlLen, HttpUrlT *out, CalogValueT *result);
static int32_t httpPerform(const char *method, const char *urlBytes, int64_t urlLen, const CalogAggT *headers, const char *body, int64_t bodyLen, bool verify, CalogValueT *result);
static int32_t httpReadResponse(HttpConnT *conn, HttpBufT *raw);
static int32_t httpRequestNative(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
static bool httpResolveLocation(const HttpUrlT *base, const char *loc, char *out, size_t cap);
static const CalogValueT *httpResultHeader(const CalogValueT *result, const char *name);
static int32_t httpResultStatus(const CalogValueT *result);
static bool httpSameOrigin(const HttpUrlT *a, const HttpUrlT *b);
static void httpSetTimeouts(int fd, int timeoutSec);
static int32_t httpTlsHandshake(HttpConnT *conn, const HttpUrlT *url, CalogValueT *result);
static int httpWriteAuthority(char *out, size_t cap, const char *scheme, const HttpUrlT *base);
int32_t calogHttpRegister(CalogT *calog) {
// SSL_write/SSL_shutdown (and plain send()) can hit a broken pipe if the peer resets or
// closes mid-write; with the default disposition that raises SIGPIPE and kills the whole
// broker process. The plain path already passes MSG_NOSIGNAL, but OpenSSL's socket BIO does
// not, so ignore SIGPIPE process-wide (idempotent; safe to call from every ...Register).
signal(SIGPIPE, SIG_IGN);
calogRegisterInline(calog, "httpGet", httpGetNative, NULL);
calogRegisterInline(calog, "httpRequest", httpRequestNative, NULL);
return calogOkE;
}
// Look up a string-keyed field in an aggregate with a stack key (no allocation). The returned
// pointer aliases the aggregate and is valid only until it is freed or mutated.
static CalogValueT *httpAggField(CalogAggT *agg, const char *name) {
CalogValueT key;
calogValueNil(&key);
key.type = calogStringE;
key.as.s.bytes = (char *)name;
key.as.s.length = (int64_t)strlen(name);
return calogAggGet(agg, &key);
}
static int32_t httpAppendHeaders(HttpBufT *buf, const CalogAggT *headers) {
int64_t index;
int32_t status;
// Only string:string pairs become request headers; anything else is skipped. The bytes are
// binary-safe (length-carried), so a header value may hold arbitrary octets.
for (index = 0; index < headers->pairCount; index++) {
const CalogValueT *key;
const CalogValueT *val;
key = &headers->pairs[index].key;
val = &headers->pairs[index].value;
if (key->type != calogStringE || val->type != calogStringE) {
continue;
}
status = httpBufPutBytes(buf, key->as.s.bytes, (size_t)key->as.s.length);
if (status == calogOkE) {
status = httpBufPutStr(buf, ": ");
}
if (status == calogOkE) {
status = httpBufPutBytes(buf, val->as.s.bytes, (size_t)val->as.s.length);
}
if (status == calogOkE) {
status = httpBufPutStr(buf, "\r\n");
}
if (status != calogOkE) {
return status;
}
}
return calogOkE;
}
static int32_t httpBufEnsure(HttpBufT *buf, size_t extra) {
size_t need;
size_t cap;
char *grown;
need = buf->length + extra;
if (need <= buf->cap) {
return calogOkE;
}
cap = (buf->cap == 0) ? HTTP_BUF_INITIAL : buf->cap;
while (cap < need) {
cap *= CALOG_GROWTH_FACTOR;
}
grown = (char *)realloc(buf->bytes, cap);
if (grown == NULL) {
return calogErrOomE;
}
buf->bytes = grown;
buf->cap = cap;
return calogOkE;
}
static void httpBufFree(HttpBufT *buf) {
free(buf->bytes);
buf->bytes = NULL;
buf->length = 0;
buf->cap = 0;
}
static int32_t httpBufPutBytes(HttpBufT *buf, const char *bytes, size_t length) {
int32_t status;
if (length == 0) {
return calogOkE;
}
status = httpBufEnsure(buf, length);
if (status != calogOkE) {
return status;
}
memcpy(buf->bytes + buf->length, bytes, length);
buf->length += length;
return calogOkE;
}
static int32_t httpBufPutStr(HttpBufT *buf, const char *s) {
return httpBufPutBytes(buf, s, strlen(s));
}
static int32_t httpBuildResult(const char *raw, size_t rawLen, CalogValueT *result) {
const char *sep;
const char *headerBlock;
const char *bodyRaw;
const char *statusEnd;
const char *cursor;
const char *headerEnd;
const char *finalBody;
HttpBufT dechunked;
CalogAggT *map;
CalogAggT *headersMap;
size_t headerLen;
size_t bodyRawLen;
size_t statusLen;
size_t finalLen;
int64_t contentLength;
int32_t code;
int32_t status;
bool haveContentLength;
bool chunked;
calogValueNil(result);
dechunked.bytes = NULL;
dechunked.length = 0;
dechunked.cap = 0;
contentLength = 0;
haveContentLength = false;
chunked = false;
// Split the head from the body at the first blank line.
sep = (const char *)memmem(raw, rawLen, "\r\n\r\n", 4);
if (sep == NULL) {
return calogFail(result, calogErrArgE, "http: malformed response (no header terminator)");
}
headerBlock = raw;
headerLen = (size_t)(sep - raw);
bodyRaw = sep + 4;
bodyRawLen = rawLen - headerLen - 4;
// The status line is the first CRLF-terminated line of the head.
statusEnd = (const char *)memmem(headerBlock, headerLen, "\r\n", 2);
if (statusEnd == NULL) {
statusLen = headerLen;
cursor = headerBlock + headerLen;
} else {
statusLen = (size_t)(statusEnd - headerBlock);
cursor = statusEnd + 2;
}
headerEnd = headerBlock + headerLen;
status = httpParseStatus(headerBlock, statusLen, &code);
if (status != calogOkE) {
return calogFail(result, calogErrArgE, "http: malformed status line");
}
// Header fields -> map keyed by lowercased name; capture the body-framing headers.
status = calogAggCreate(&headersMap, calogMapE);
if (status != calogOkE) {
return calogFail(result, status, "http: out of memory");
}
while (cursor < headerEnd) {
const char *lineEnd;
const char *colon;
size_t lineLen;
lineEnd = (const char *)memmem(cursor, (size_t)(headerEnd - cursor), "\r\n", 2);
if (lineEnd == NULL) {
lineEnd = headerEnd;
}
lineLen = (size_t)(lineEnd - cursor);
colon = (lineLen > 0) ? (const char *)memchr(cursor, ':', lineLen) : NULL;
if (colon != NULL) {
const char *nameStart;
const char *valStart;
char *lower;
size_t nameLen;
size_t valLen;
size_t i;
nameStart = cursor;
nameLen = (size_t)(colon - cursor);
valStart = colon + 1;
valLen = (size_t)(lineEnd - valStart);
while (valLen > 0 && (*valStart == ' ' || *valStart == '\t')) {
valStart++;
valLen--;
}
while (valLen > 0 && (valStart[valLen - 1] == ' ' || valStart[valLen - 1] == '\t')) {
valLen--;
}
lower = (char *)malloc(nameLen + 1);
if (lower == NULL) {
calogAggFree(headersMap);
return calogFail(result, calogErrOomE, "http: out of memory");
}
for (i = 0; i < nameLen; i++) {
lower[i] = httpLower(nameStart[i]);
}
lower[nameLen] = '\0';
status = calogMapSetStr(headersMap, lower, valStart, (int64_t)valLen);
if (status != calogOkE) {
free(lower);
calogAggFree(headersMap);
return calogFail(result, status, "http: out of memory");
}
if (strcmp(lower, "content-length") == 0) {
contentLength = httpParseInt(valStart, valLen);
if (contentLength >= 0) {
haveContentLength = true;
}
} else if (strcmp(lower, "transfer-encoding") == 0) {
if (httpContainsChunked(valStart, valLen)) {
chunked = true;
}
}
free(lower);
}
if (lineEnd >= headerEnd) {
break;
}
cursor = lineEnd + 2;
}
// Decode the body. Chunked takes precedence over Content-Length; absent both, read-to-close
// means the raw remainder is the body.
if (chunked) {
status = httpDechunk(bodyRaw, bodyRawLen, &dechunked);
if (status != calogOkE) {
httpBufFree(&dechunked);
calogAggFree(headersMap);
return calogFail(result, status, "http: malformed chunked body");
}
finalBody = dechunked.bytes;
finalLen = dechunked.length;
} else if (haveContentLength) {
// A body shorter than the promised Content-Length means a read error (connection reset,
// unclean TLS close) was silently mapped to EOF upstream -- surface it as a failure
// rather than delivering a truncated body as a status-200 success.
if (bodyRawLen < (size_t)contentLength) {
httpBufFree(&dechunked);
calogAggFree(headersMap);
return calogFail(result, calogErrArgE, "http: response body shorter than Content-Length");
}
finalBody = bodyRaw;
finalLen = (size_t)contentLength;
} else {
finalBody = bodyRaw;
finalLen = bodyRawLen;
}
// Assemble the outer { status, body, headers } map.
status = calogAggCreate(&map, calogMapE);
if (status != calogOkE) {
httpBufFree(&dechunked);
calogAggFree(headersMap);
return calogFail(result, status, "http: out of memory");
}
status = calogMapSetInt(map, "status", (int64_t)code);
if (status != calogOkE) {
httpBufFree(&dechunked);
calogAggFree(map);
calogAggFree(headersMap);
return calogFail(result, status, "http: out of memory");
}
status = calogMapSetStr(map, "body", (finalBody != NULL) ? finalBody : "", (int64_t)finalLen);
if (status != calogOkE) {
httpBufFree(&dechunked);
calogAggFree(map);
calogAggFree(headersMap);
return calogFail(result, status, "http: out of memory");
}
status = httpMapSetAgg(map, "headers", headersMap);
if (status != calogOkE) {
httpBufFree(&dechunked);
calogAggFree(map);
calogAggFree(headersMap);
return calogFail(result, status, "http: out of memory");
}
// headersMap is now owned by map; only our transient decode buffer remains to free.
httpBufFree(&dechunked);
calogValueAgg(result, map);
return calogOkE;
}
static void httpConnClose(HttpConnT *conn) {
if (conn->ssl != NULL) {
SSL_shutdown(conn->ssl);
SSL_free(conn->ssl);
conn->ssl = NULL;
}
if (conn->ctx != NULL) {
SSL_CTX_free(conn->ctx);
conn->ctx = NULL;
}
// SSL_set_fd installs a BIO_NOCLOSE socket BIO, so SSL_free never closes the fd -- we own it.
if (conn->fd >= 0) {
close(conn->fd);
conn->fd = -1;
}
}
static bool httpConnectTimeout(int fd, const struct sockaddr *addr, socklen_t addrLen, int timeoutSec) {
int flags;
int rc;
int err;
socklen_t errLen;
struct pollfd pfd;
flags = fcntl(fd, F_GETFL, 0);
if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) {
return connect(fd, addr, addrLen) == 0; // best effort: no bounded-connect fallback
}
rc = connect(fd, addr, addrLen);
if (rc == 0) {
fcntl(fd, F_SETFL, flags);
return true;
}
if (errno != EINPROGRESS) {
fcntl(fd, F_SETFL, flags);
return false;
}
pfd.fd = fd;
pfd.events = POLLOUT;
rc = poll(&pfd, 1, timeoutSec * 1000);
if (rc <= 0) {
fcntl(fd, F_SETFL, flags);
return false; // timeout or poll error
}
errLen = sizeof(err);
if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &errLen) < 0 || err != 0) {
fcntl(fd, F_SETFL, flags);
return false;
}
fcntl(fd, F_SETFL, flags);
return true;
}
static int32_t httpConnOpen(HttpConnT *conn, const HttpUrlT *url, CalogValueT *result) {
struct addrinfo hints;
struct addrinfo *res;
struct addrinfo *rp;
int fd;
int rc;
conn->fd = -1;
conn->ssl = NULL;
conn->ctx = NULL;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
rc = getaddrinfo(url->host, url->port, &hints, &res);
if (rc != 0) {
return calogFail(result, calogErrArgE, gai_strerror(rc));
}
fd = -1;
for (rp = res; rp != NULL; rp = rp->ai_next) {
fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
if (fd < 0) {
continue;
}
httpSetTimeouts(fd, HTTP_TIMEOUT_SEC);
if (httpConnectTimeout(fd, rp->ai_addr, rp->ai_addrlen, HTTP_TIMEOUT_SEC)) {
break;
}
close(fd);
fd = -1;
}
freeaddrinfo(res);
if (fd < 0) {
return calogFail(result, calogErrArgE, "http: could not connect to host");
}
conn->fd = fd;
if (url->https) {
int32_t status;
status = httpTlsHandshake(conn, url, result);
if (status != calogOkE) {
httpConnClose(conn);
return status;
}
}
return calogOkE;
}
static ssize_t httpConnRead(HttpConnT *conn, char *buf, size_t length) {
if (conn->ssl != NULL) {
int chunk;
int r;
int err;
chunk = (length > (size_t)INT_MAX) ? INT_MAX : (int)length;
r = SSL_read(conn->ssl, buf, chunk);
if (r > 0) {
return (ssize_t)r;
}
// Distinguish a clean TLS close_notify (end of response, like a plain-socket EOF) from
// any other error (reset, protocol violation, truncated shutdown) -- the latter must NOT
// be treated as a successful end of response.
err = SSL_get_error(conn->ssl, r);
if (err == SSL_ERROR_ZERO_RETURN) {
return 0;
}
return -1;
}
for (;;) {
ssize_t r;
r = recv(conn->fd, buf, length, 0);
if (r < 0 && errno == EINTR) {
continue;
}
return r;
}
}
static int32_t httpConnWriteAll(HttpConnT *conn, const char *bytes, size_t length) {
size_t sent;
sent = 0;
while (sent < length) {
if (conn->ssl != NULL) {
int chunk;
int w;
chunk = (length - sent > (size_t)INT_MAX) ? INT_MAX : (int)(length - sent);
w = SSL_write(conn->ssl, bytes + sent, chunk);
if (w <= 0) {
return calogErrUnsupportedE;
}
sent += (size_t)w;
} else {
ssize_t w;
w = send(conn->fd, bytes + sent, length - sent, MSG_NOSIGNAL);
if (w < 0) {
if (errno == EINTR) {
continue;
}
return calogErrArgE;
}
sent += (size_t)w;
}
}
return calogOkE;
}
static bool httpContainsChunked(const char *bytes, size_t length) {
static const char needle[] = "chunked";
size_t nlen;
size_t i;
nlen = sizeof(needle) - 1;
if (length < nlen) {
return false;
}
for (i = 0; i + nlen <= length; i++) {
size_t j;
bool match;
match = true;
for (j = 0; j < nlen; j++) {
if (httpLower(bytes[i + j]) != needle[j]) {
match = false;
break;
}
}
if (match) {
return true;
}
}
return false;
}
static bool httpCopyField(char *dst, size_t cap, const char *src, size_t length) {
if (length + 1 > cap) {
return false;
}
memcpy(dst, src, length);
dst[length] = '\0';
return true;
}
static int32_t httpDechunk(const char *bytes, size_t length, HttpBufT *out) {
size_t pos;
pos = 0;
while (pos < length) {
size_t size;
bool any;
int32_t status;
size = 0;
any = false;
while (pos < length) {
int32_t digit;
digit = httpHexVal(bytes[pos]);
if (digit < 0) {
break;
}
// Reject a chunk size that would overflow size_t (a hostile server can send a huge
// hex length to wrap the bounds check below).
if (size > (SIZE_MAX - (size_t)digit) / 16) {
return calogErrArgE;
}
size = size * 16 + (size_t)digit;
any = true;
pos++;
}
if (!any) {
return calogErrArgE;
}
// Skip any chunk extension (";name=value") through the CRLF that ends the size line.
while (pos < length && bytes[pos] != '\n') {
pos++;
}
if (pos >= length) {
return calogErrArgE;
}
pos++;
if (size == 0) {
break;
}
if (size > length - pos) { // pos <= length here, so no overflow
return calogErrArgE;
}
status = httpBufPutBytes(out, bytes + pos, size);
if (status != calogOkE) {
return status;
}
pos += size;
if (pos < length && bytes[pos] == '\r') {
pos++;
}
if (pos < length && bytes[pos] == '\n') {
pos++;
}
}
return calogOkE;
}
static bool httpDefaultPort(const HttpUrlT *url) {
if (url->https) {
return strcmp(url->port, HTTP_PORT_HTTPS) == 0;
}
return strcmp(url->port, HTTP_PORT_HTTP) == 0;
}
// Copy headers, dropping credential headers when dropCreds (a redirect crossed to another origin)
// and/or body-describing headers when dropContent (a redirect dropped the request body). Returns
// an owned aggregate, or NULL on out of memory.
static CalogAggT *httpFilterHeaders(const CalogAggT *headers, bool dropCreds, bool dropContent) {
static const char *const creds[] = { "authorization", "cookie", "proxy-authorization", NULL };
static const char *const content[] = { "content-type", "content-length", "content-encoding", "transfer-encoding", NULL };
CalogAggT *out;
int64_t index;
if (calogAggCreate(&out, calogMapE) != calogOkE) {
return NULL;
}
for (index = 0; index < headers->pairCount; index++) {
const CalogValueT *key;
CalogValueT keyCopy;
CalogValueT valCopy;
key = &headers->pairs[index].key;
if (key->type == calogStringE && ((dropCreds && httpHeaderIn(key->as.s.bytes, key->as.s.length, creds)) || (dropContent && httpHeaderIn(key->as.s.bytes, key->as.s.length, content)))) {
continue;
}
if (calogValueCopy(&keyCopy, &headers->pairs[index].key) != calogOkE) {
calogAggFree(out);
return NULL;
}
if (calogValueCopy(&valCopy, &headers->pairs[index].value) != calogOkE) {
calogValueFree(&keyCopy);
calogAggFree(out);
return NULL;
}
if (calogAggSet(out, &keyCopy, &valCopy) != calogOkE) {
calogValueFree(&keyCopy);
calogValueFree(&valCopy);
calogAggFree(out);
return NULL;
}
}
return out;
}
// Perform a request and follow up to maxRedirects 3xx redirects (0 disables following, handing
// back the raw 3xx). Each hop reissues httpPerform against the resolved Location; the response
// map of every non-final hop is freed before the next. Method/body follow the usual rules:
// 303 (and 301/302 for a body-bearing method) drop to GET with no body, 307/308 are preserved.
static int32_t httpFollow(const char *method, const char *urlBytes, int64_t urlLen, const CalogAggT *headers, const char *body, int64_t bodyLen, bool verify, int32_t maxRedirects, CalogValueT *result) {
char curUrl[HTTP_URL_MAX];
char curMethod[HTTP_METHOD_MAX];
const char *curBody;
const CalogAggT *curHeaders;
CalogAggT *filtered; // owned; headers with credential/content headers stripped, or NULL
HttpUrlT origin; // scheme+host+port of the ORIGINAL request, for cross-origin checks
int64_t curBodyLen;
int32_t redirects;
int32_t status;
bool bodyDropped;
calogValueNil(result);
filtered = NULL;
curHeaders = headers;
bodyDropped = false;
if (urlLen < 0 || (size_t)urlLen >= sizeof(curUrl)) {
return calogFail(result, calogErrArgE, "http: URL too long");
}
if (strlen(method) >= sizeof(curMethod)) {
return calogFail(result, calogErrArgE, "http: method too long");
}
memcpy(curUrl, urlBytes, (size_t)urlLen);
curUrl[urlLen] = '\0';
strcpy(curMethod, method);
curBody = body;
curBodyLen = bodyLen;
status = httpParseUrl(curUrl, (int64_t)strlen(curUrl), &origin, result);
if (status != calogOkE) {
return status;
}
for (redirects = 0; ; redirects++) {
int32_t code;
const CalogValueT *location;
HttpUrlT base;
HttpUrlT target;
char locBuf[HTTP_URL_MAX];
char next[HTTP_URL_MAX];
size_t locLen;
status = httpPerform(curMethod, curUrl, (int64_t)strlen(curUrl), curHeaders, curBody, curBodyLen, verify, result);
if (status != calogOkE) {
goto done;
}
code = httpResultStatus(result);
if (maxRedirects <= 0 || !httpIsRedirect(code)) {
goto done; // redirects disabled, or a final (non-3xx) response
}
location = httpResultHeader(result, "location");
if (location == NULL || location->type != calogStringE || location->as.s.length == 0 || memchr(location->as.s.bytes, '\0', (size_t)location->as.s.length) != NULL) {
goto done; // a 3xx with no usable Location (missing, empty, or containing a NUL)
}
if (redirects >= maxRedirects) {
calogValueFree(result);
status = calogFail(result, calogErrArgE, "http: too many redirects");
goto done;
}
// Copy the Location out of the response map, then free the map (which the pointer aliases).
locLen = (size_t)location->as.s.length;
if (locLen >= sizeof(locBuf)) {
calogValueFree(result);
status = calogFail(result, calogErrArgE, "http: redirect Location too long");
goto done;
}
memcpy(locBuf, location->as.s.bytes, locLen);
locBuf[locLen] = '\0';
calogValueFree(result);
// Resolve the (possibly relative) Location against the URL just fetched.
status = httpParseUrl(curUrl, (int64_t)strlen(curUrl), &base, result);
if (status != calogOkE) {
goto done;
}
if (!httpResolveLocation(&base, locBuf, next, sizeof(next))) {
status = calogFail(result, calogErrArgE, "http: could not resolve redirect Location");
goto done;
}
status = httpParseUrl(next, (int64_t)strlen(next), &target, result);
if (status != calogOkE) {
goto done;
}
// 303 (and 301/302 for a body-bearing method) -> GET with no body; 307/308 keep both.
if (code == 303 || ((code == 301 || code == 302) && httpMethodHasBody(curMethod))) {
strcpy(curMethod, "GET");
curBody = NULL;
curBodyLen = 0;
bodyDropped = true;
}
// Choose the headers for the next hop: drop credential headers (Authorization/Cookie/...)
// when the target is a different origin -- so a secret is never resent to another host,
// including over an https->http downgrade -- and drop Content-* once the body is gone.
if (headers != NULL) {
bool dropCreds;
dropCreds = !httpSameOrigin(&origin, &target);
if (dropCreds || bodyDropped) {
if (filtered != NULL) {
calogAggFree(filtered);
}
filtered = httpFilterHeaders(headers, dropCreds, bodyDropped);
if (filtered == NULL) {
status = calogFail(result, calogErrOomE, "http: out of memory");
goto done;
}
curHeaders = filtered;
} else {
curHeaders = headers;
}
}
strcpy(curUrl, next);
}
done:
if (filtered != NULL) {
calogAggFree(filtered);
}
return status;
}
static int32_t httpGetNative(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
(void)userData;
calogValueNil(result);
if (argCount != 1 || args[0].type != calogStringE) {
return calogFail(result, calogErrArgE, "httpGet expects (url)");
}
return httpFollow("GET", args[0].as.s.bytes, args[0].as.s.length, NULL, NULL, 0, true, HTTP_MAX_REDIRECTS, result);
}
static bool httpHasBadByte(const char *bytes, size_t length, bool rejectSpace) {
size_t i;
// CR/LF would let a raw byte string smuggle extra header lines (or a second request) into
// the request line; a method additionally cannot carry a bare space.
for (i = 0; i < length; i++) {
if (bytes[i] == '\r' || bytes[i] == '\n') {
return true;
}
if (rejectSpace && bytes[i] == ' ') {
return true;
}
}
return false;
}
// Case-insensitive membership test of a (length-carried) header name in a NULL-terminated list
// of lowercase names.
static bool httpHeaderIn(const char *name, int64_t len, const char *const *list) {
size_t which;
for (which = 0; list[which] != NULL; which++) {
size_t nameLen;
size_t index;
bool match;
nameLen = strlen(list[which]);
if ((size_t)len != nameLen) {
continue;
}
match = true;
for (index = 0; index < nameLen; index++) {
if (httpLower(name[index]) != list[which][index]) {
match = false;
break;
}
}
if (match) {
return true;
}
}
return false;
}
static int32_t httpHexVal(char c) {
if (c >= '0' && c <= '9') {
return (int32_t)(c - '0');
}
if (c >= 'a' && c <= 'f') {
return (int32_t)(c - 'a' + 10);
}
if (c >= 'A' && c <= 'F') {
return (int32_t)(c - 'A' + 10);
}
return -1;
}
// The 3xx codes that carry a Location this client follows (301/302/303 and 307/308).
static bool httpIsRedirect(int32_t code) {
return code == 301 || code == 302 || code == 303 || code == 307 || code == 308;
}
static char httpLower(char c) {
if (c >= 'A' && c <= 'Z') {
return (char)(c - 'A' + 'a');
}
return c;
}
static int32_t httpMapSetAgg(CalogAggT *map, const char *key, CalogAggT *inner) {
CalogValueT keyValue;
CalogValueT aggValue;
int32_t status;
status = calogValueString(&keyValue, key, (int64_t)strlen(key));
if (status != calogOkE) {
return status;
}
calogValueAgg(&aggValue, inner);
status = calogAggSet(map, &keyValue, &aggValue);
if (status != calogOkE) {
// calogAggSet did not take ownership; free the key but leave `inner` (which aggValue
// only wraps) for the caller to free.
calogValueFree(&keyValue);
}
return status;
}
static bool httpMethodHasBody(const char *method) {
// RFC 9110 8.6: a request with a method that commonly carries content SHOULD send
// Content-Length even when the body is empty; strict servers/proxies otherwise answer
// 411 Length Required. GET/HEAD never carry content, so they alone are excluded.
return strcasecmp(method, "GET") != 0 && strcasecmp(method, "HEAD") != 0;
}
static CalogValueT *httpOptGet(CalogAggT *opts, const char *name, int32_t *status) {
CalogValueT keyValue;
CalogValueT *value;
*status = calogValueString(&keyValue, name, (int64_t)strlen(name));
if (*status != calogOkE) {
return NULL;
}
value = calogAggGet(opts, &keyValue);
calogValueFree(&keyValue);
return value;
}
static int64_t httpParseInt(const char *bytes, size_t length) {
int64_t value;
size_t i;
bool any;
value = 0;
any = false;
for (i = 0; i < length; i++) {
if (bytes[i] < '0' || bytes[i] > '9') {
break;
}
value = value * 10 + (int64_t)(bytes[i] - '0');
any = true;
if (value > (int64_t)HTTP_MAX_RESPONSE) {
break;
}
}
if (!any) {
return -1;
}
return value;
}
static int32_t httpParseStatus(const char *line, size_t length, int32_t *codeOut) {
int32_t code;
size_t i;
size_t digits;
bool any;
i = 0;
// Skip the "HTTP/x.y" token and the space(s) before the status code.
while (i < length && line[i] != ' ') {
i++;
}
while (i < length && line[i] == ' ') {
i++;
}
code = 0;
any = false;
digits = 0;
// RFC 7230 status codes are exactly 3 digits; cap accumulation there so a hostile/garbage
// status line (e.g. 11+ digits) cannot signed-overflow code (matches httpParseInt's guard).
while (i < length && line[i] >= '0' && line[i] <= '9') {
if (digits < 3) {
code = code * 10 + (int32_t)(line[i] - '0');
digits++;
}
any = true;
i++;
}
if (!any) {
return calogErrArgE;
}
*codeOut = code;
return calogOkE;
}
static int32_t httpParseUrl(const char *url, int64_t urlLen, HttpUrlT *out, CalogValueT *result) {
size_t len;
size_t pos;
size_t hostStart;
size_t hostEnd;
len = (size_t)urlLen;
if (len >= 7 && memcmp(url, "http://", 7) == 0) {
out->https = false;
pos = 7;
} else if (len >= 8 && memcmp(url, "https://", 8) == 0) {
out->https = true;
pos = 8;
} else {
return calogFail(result, calogErrArgE, "http: URL must start with http:// or https://");
}
hostStart = pos;
if (pos < len && url[pos] == '[') {
// IPv6 literal host, e.g. "[::1]:8080" -- scan to the matching ']' instead of stopping
// at the first ':' (an IPv6 address is full of colons).
pos++;
hostStart = pos;
while (pos < len && url[pos] != ']') {
pos++;
}
if (pos >= len) {
return calogFail(result, calogErrArgE, "http: URL has an unterminated IPv6 host literal");
}
hostEnd = pos;
pos++; // consume ']'
out->ipv6 = true;
} else {
while (pos < len && url[pos] != ':' && url[pos] != '/') {
pos++;
}
hostEnd = pos;
out->ipv6 = false;
}
if (hostEnd == hostStart) {
return calogFail(result, calogErrArgE, "http: URL has an empty host");
}
if (httpHasBadByte(url + hostStart, hostEnd - hostStart, false)) {
return calogFail(result, calogErrArgE, "http: URL host contains invalid characters");
}
if (!httpCopyField(out->host, sizeof(out->host), url + hostStart, hostEnd - hostStart)) {
return calogFail(result, calogErrArgE, "http: URL host too long");
}
if (pos < len && url[pos] == ':') {
size_t portStart;
pos++;
portStart = pos;
while (pos < len && url[pos] != '/') {
pos++;
}
if (!httpCopyField(out->port, sizeof(out->port), url + portStart, pos - portStart)) {
return calogFail(result, calogErrArgE, "http: URL port too long");
}
if (out->port[0] == '\0') {
return calogFail(result, calogErrArgE, "http: URL has an empty port");
}
} else {
strcpy(out->port, out->https ? HTTP_PORT_HTTPS : HTTP_PORT_HTTP);
}
if (pos < len) {
if (httpHasBadByte(url + pos, len - pos, false)) {
return calogFail(result, calogErrArgE, "http: URL path contains invalid characters");
}
if (!httpCopyField(out->path, sizeof(out->path), url + pos, len - pos)) {
return calogFail(result, calogErrArgE, "http: URL path too long");
}
} else {
strcpy(out->path, "/");
}
return calogOkE;
}
static int32_t httpPerform(const char *method, const char *urlBytes, int64_t urlLen, const CalogAggT *headers, const char *body, int64_t bodyLen, bool verify, CalogValueT *result) {
HttpUrlT url;
HttpConnT conn;
HttpBufT request;
HttpBufT raw;
int32_t status;
calogValueNil(result);
request.bytes = NULL;
request.length = 0;
request.cap = 0;
raw.bytes = NULL;
raw.length = 0;
raw.cap = 0;
status = httpParseUrl(urlBytes, urlLen, &url, result);
if (status != calogOkE) {
return status;
}
// Request line + Host + Connection: close.
status = httpBufPutStr(&request, method);
if (status == calogOkE) {
status = httpBufPutStr(&request, " ");
}
if (status == calogOkE) {
status = httpBufPutStr(&request, url.path);
}
if (status == calogOkE) {
status = httpBufPutStr(&request, " HTTP/1.1\r\nHost: ");
}
if (status == calogOkE && url.ipv6) {
status = httpBufPutStr(&request, "[");
}
if (status == calogOkE) {
status = httpBufPutStr(&request, url.host);
}
if (status == calogOkE && url.ipv6) {
status = httpBufPutStr(&request, "]");
}
if (status == calogOkE && !httpDefaultPort(&url)) {
status = httpBufPutStr(&request, ":");
if (status == calogOkE) {
status = httpBufPutStr(&request, url.port);
}
}
if (status == calogOkE) {
status = httpBufPutStr(&request, "\r\nConnection: close\r\n");
}
if (status != calogOkE) {
httpBufFree(&request);
return calogFail(result, status, "http: out of memory");
}
// Caller-supplied headers.
if (headers != NULL) {
status = httpAppendHeaders(&request, headers);
if (status != calogOkE) {
httpBufFree(&request);
return calogFail(result, status, "http: out of memory");
}
}
// Content-Length + the blank line + body. A method that commonly carries content still gets
// an explicit Content-Length: 0 even with an empty/absent body (see httpMethodHasBody);
// otherwise strict servers/proxies may answer 411 Length Required.
if (bodyLen > 0 || httpMethodHasBody(method)) {
char lengthLine[64];
int n;
n = snprintf(lengthLine, sizeof(lengthLine), "Content-Length: %lld\r\n", (long long)bodyLen);
status = httpBufPutBytes(&request, lengthLine, (size_t)n);
if (status != calogOkE) {
httpBufFree(&request);
return calogFail(result, status, "http: out of memory");
}
}
status = httpBufPutStr(&request, "\r\n");
if (status == calogOkE && bodyLen > 0) {
status = httpBufPutBytes(&request, body, (size_t)bodyLen);
}
if (status != calogOkE) {
httpBufFree(&request);
return calogFail(result, status, "http: out of memory");
}
conn.verify = verify;
status = httpConnOpen(&conn, &url, result);
if (status != calogOkE) {
httpBufFree(&request);
return status;
}
status = httpConnWriteAll(&conn, request.bytes, request.length);
httpBufFree(&request);
if (status != calogOkE) {
httpConnClose(&conn);
return calogFail(result, status, "http: failed to send request");
}
status = httpReadResponse(&conn, &raw);
httpConnClose(&conn);
if (status != calogOkE) {
httpBufFree(&raw);
return calogFail(result, status, "http: failed to read response");
}
status = httpBuildResult((raw.bytes != NULL) ? raw.bytes : "", raw.length, result);
httpBufFree(&raw);
return status;
}
static int32_t httpReadResponse(HttpConnT *conn, HttpBufT *raw) {
char chunk[HTTP_READ_CHUNK];
// "Connection: close" means the server closes at end of message, so reading to EOF yields
// the entire response (head + body).
for (;;) {
ssize_t n;
int32_t status;
n = httpConnRead(conn, chunk, sizeof(chunk));
if (n < 0) {
// A genuine read error (reset, unclean TLS shutdown) is not the same as a clean EOF
// -- do not hand a truncated response to the script as a success.
return calogErrArgE;
}
if (n == 0) {
break;
}
if (raw->length + (size_t)n > (size_t)HTTP_MAX_RESPONSE) {
return calogErrRangeE;
}
status = httpBufPutBytes(raw, chunk, (size_t)n);
if (status != calogOkE) {
return status;
}
}
return calogOkE;
}
static int32_t httpRequestNative(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
CalogAggT *opts;
CalogValueT *urlValue;
CalogValueT *methodValue;
CalogValueT *headersValue;
CalogValueT *bodyValue;
CalogValueT *insecureValue;
CalogValueT *redirectValue;
const CalogAggT *headers;
const char *method;
const char *body;
int64_t bodyLen;
int32_t status;
int32_t maxRedirects;
bool verify;
(void)userData;
calogValueNil(result);
if (argCount != 1 || args[0].type != calogAggE) {
return calogFail(result, calogErrArgE, "httpRequest expects (options)");
}
opts = args[0].as.agg;
// url (required).
status = calogOkE;
urlValue = httpOptGet(opts, "url", &status);
if (status != calogOkE) {
return calogFail(result, status, "httpRequest: out of memory");
}
if (urlValue == NULL || urlValue->type != calogStringE) {
return calogFail(result, calogErrArgE, "httpRequest: options.url must be a string");
}
// method (optional; default GET).
method = "GET";
status = calogOkE;
methodValue = httpOptGet(opts, "method", &status);
if (status != calogOkE) {
return calogFail(result, status, "httpRequest: out of memory");
}
if (methodValue != NULL && methodValue->type == calogStringE) {
method = methodValue->as.s.bytes;
}
if (httpHasBadByte(method, strlen(method), true)) {
return calogFail(result, calogErrArgE, "httpRequest: options.method contains invalid characters");
}
// headers (optional).
headers = NULL;
status = calogOkE;
headersValue = httpOptGet(opts, "headers", &status);
if (status != calogOkE) {
return calogFail(result, status, "httpRequest: out of memory");
}
if (headersValue != NULL && headersValue->type == calogAggE) {
headers = headersValue->as.agg;
}
// body (optional).
body = NULL;
bodyLen = 0;
status = calogOkE;
bodyValue = httpOptGet(opts, "body", &status);
if (status != calogOkE) {
return calogFail(result, status, "httpRequest: out of memory");
}
if (bodyValue != NULL && bodyValue->type == calogStringE) {
body = bodyValue->as.s.bytes;
bodyLen = bodyValue->as.s.length;
}
// insecure (optional): true skips certificate + hostname verification for an https request.
verify = true;
status = calogOkE;
insecureValue = httpOptGet(opts, "insecure", &status);
if (status != calogOkE) {
return calogFail(result, status, "httpRequest: out of memory");
}
if (insecureValue != NULL && insecureValue->type == calogBoolE && insecureValue->as.b) {
verify = false;
}
// maxRedirects (optional): how many 3xx redirects to follow. Default 16; 0 (or negative)
// disables following and returns the raw 3xx. Values above 16 are capped, so a caller can
// never widen the loop guard.
maxRedirects = HTTP_MAX_REDIRECTS;
status = calogOkE;
redirectValue = httpOptGet(opts, "maxRedirects", &status);
if (status != calogOkE) {
return calogFail(result, status, "httpRequest: out of memory");
}
if (redirectValue != NULL && redirectValue->type == calogIntE) {
if (redirectValue->as.i < 0) {
maxRedirects = 0;
} else if (redirectValue->as.i < HTTP_MAX_REDIRECTS) {
maxRedirects = (int32_t)redirectValue->as.i;
}
}
return httpFollow(method, urlValue->as.s.bytes, urlValue->as.s.length, headers, body, bodyLen, verify, maxRedirects, result);
}
// Resolve a redirect Location against the URL just fetched, writing an absolute URL into out.
// Handles an absolute URL, a protocol-relative "//host/path", an absolute path "/path", and a
// relative path (merged against the base path's directory). Leading whitespace is skipped;
// dot-segments (../) are NOT normalized. Returns false on overflow.
static bool httpResolveLocation(const HttpUrlT *base, const char *loc, char *out, size_t cap) {
const char *scheme;
int authority;
while (*loc == ' ' || *loc == '\t') {
loc++;
}
if (strncmp(loc, "http://", 7) == 0 || strncmp(loc, "https://", 8) == 0) {
if (strlen(loc) >= cap) {
return false;
}
strcpy(out, loc);
return true;
}
scheme = base->https ? "https" : "http";
if (loc[0] == '/' && loc[1] == '/') {
int n;
n = snprintf(out, cap, "%s:%s", scheme, loc);
return n > 0 && (size_t)n < cap;
}
authority = httpWriteAuthority(out, cap, scheme, base);
if (authority < 0) {
return false;
}
if (loc[0] == '/') {
if ((size_t)authority + strlen(loc) >= cap) {
return false;
}
strcpy(out + authority, loc);
return true;
}
if (loc[0] == '?' || loc[0] == '#') {
// Query- or fragment-only reference (RFC 3986 5.3): keep the whole base path, replace the
// rest -- NOT the directory-merge below, which would drop the base path's last segment.
size_t basePathLen;
basePathLen = strcspn(base->path, "?#");
if ((size_t)authority + basePathLen + strlen(loc) >= cap) {
return false;
}
memcpy(out + authority, base->path, basePathLen);
strcpy(out + (size_t)authority + basePathLen, loc);
return true;
}
// A relative path: append the base path's directory (up to and including its last '/', within
// the path part before any query/fragment), then loc.
{
size_t pathLen;
size_t dirLen;
size_t index;
pathLen = strcspn(base->path, "?#");
dirLen = 1; // base->path always begins with '/'
for (index = 0; index < pathLen; index++) {
if (base->path[index] == '/') {
dirLen = index + 1;
}
}
if ((size_t)authority + dirLen + strlen(loc) >= cap) {
return false;
}
memcpy(out + authority, base->path, dirLen);
strcpy(out + (size_t)authority + dirLen, loc);
return true;
}
}
// A response header value (name lowercase, as httpBuildResult keys them) from a built result
// map, or NULL. Aliases the map; valid until it is freed.
static const CalogValueT *httpResultHeader(const CalogValueT *result, const char *name) {
CalogValueT *headers;
if (result->type != calogAggE) {
return NULL;
}
headers = httpAggField(result->as.agg, "headers");
if (headers == NULL || headers->type != calogAggE) {
return NULL;
}
return httpAggField(headers->as.agg, name);
}
// The integer status code from a built result map, or -1 if absent/malformed.
static int32_t httpResultStatus(const CalogValueT *result) {
CalogValueT *value;
if (result->type != calogAggE) {
return -1;
}
value = httpAggField(result->as.agg, "status");
if (value == NULL || value->type != calogIntE) {
return -1;
}
return (int32_t)value->as.i;
}
// Same origin = same scheme, host, and port -- the boundary at which credential headers are
// dropped from a followed redirect.
static bool httpSameOrigin(const HttpUrlT *a, const HttpUrlT *b) {
return a->https == b->https && strcmp(a->host, b->host) == 0 && strcmp(a->port, b->port) == 0;
}
static void httpSetTimeouts(int fd, int timeoutSec) {
struct timeval tv;
tv.tv_sec = timeoutSec;
tv.tv_usec = 0;
// Best effort: if the kernel rejects these (unusual), the connection just falls back to no
// read/write deadline rather than failing the request outright.
(void)setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
(void)setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
}
static int32_t httpTlsHandshake(HttpConnT *conn, const HttpUrlT *url, CalogValueT *result) {
conn->ctx = SSL_CTX_new(TLS_client_method());
if (conn->ctx == NULL) {
return calogFail(result, calogErrUnsupportedE, "http: TLS context creation failed");
}
if (conn->verify) {
// Authenticate the server: require a certificate chain to a trusted CA. Load the system
// CA store plus OpenSSL's compiled-in defaults; with no trust anchors the handshake
// fails closed rather than open. (An https request can pass insecure=true to skip this.)
SSL_CTX_set_verify(conn->ctx, SSL_VERIFY_PEER, NULL);
(void)SSL_CTX_set_default_verify_paths(conn->ctx);
(void)SSL_CTX_load_verify_locations(conn->ctx, "/etc/ssl/certs/ca-certificates.crt", "/etc/ssl/certs");
}
conn->ssl = SSL_new(conn->ctx);
if (conn->ssl == NULL) {
return calogFail(result, calogErrUnsupportedE, "http: TLS session creation failed");
}
if (SSL_set_fd(conn->ssl, conn->fd) != 1) {
return calogFail(result, calogErrUnsupportedE, "http: TLS SSL_set_fd failed");
}
// SNI so name-based virtual hosts serve the right certificate.
(void)SSL_set_tlsext_host_name(conn->ssl, url->host);
if (conn->verify) {
// Bind verification to the requested hostname, so a valid certificate issued for a
// DIFFERENT host is still rejected.
SSL_set_hostflags(conn->ssl, X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS);
if (SSL_set1_host(conn->ssl, url->host) != 1) {
return calogFail(result, calogErrUnsupportedE, "http: could not set TLS verification hostname");
}
}
if (SSL_connect(conn->ssl) != 1) {
return calogFail(result, calogErrUnsupportedE, conn->verify ? "http: TLS handshake or certificate verification failed" : "http: TLS handshake failed");
}
return calogOkE;
}
// Write "scheme://host" (bracketing an IPv6 host, and adding ":port" when non-default) into out.
// Returns the number of bytes written (excluding the NUL), or -1 if it does not fit.
static int httpWriteAuthority(char *out, size_t cap, const char *scheme, const HttpUrlT *base) {
int n;
if (base->ipv6 && !httpDefaultPort(base)) {
n = snprintf(out, cap, "%s://[%s]:%s", scheme, base->host, base->port);
} else if (base->ipv6) {
n = snprintf(out, cap, "%s://[%s]", scheme, base->host);
} else if (!httpDefaultPort(base)) {
n = snprintf(out, cap, "%s://%s:%s", scheme, base->host, base->port);
} else {
n = snprintf(out, cap, "%s://%s", scheme, base->host);
}
return (n > 0 && (size_t)n < cap) ? n : -1;
}