From 734cab35a2bc929fdd4e9bf9ea16279698ff97c0 Mon Sep 17 00:00:00 2001 From: HalfSweet Date: Wed, 19 Aug 2026 23:40:47 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(net):=20the=20portable=20C=20network?= =?UTF-8?q?=20core=20=E2=80=94=20HTTP=20client/server,=20WebSocket=20clien?= =?UTF-8?q?t,=20POSIX=20driver,=20TLS=20providers=20(stack=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit engine/net (stacked PR B of three, on top of A's contracts): HTTP/1.1 client and server, the RFC 6455 client, the strict framing profile, bounded receive/send queues with backpressure, the immutable policy (contracts/spec/network-policy.ts), tick queues with a per-tick budget and readable-before-terminal ordering, and the TLS handshake state machine. The only host interfaces are pnet_driver_ops (a BSD/lwIP driver under drivers/posix) and an optional pnet_tls_ops TlsProvider: the OpenSSL reference provider (drivers/openssl) and the conformance harness — pnet_unit_test (framing, URL, policy, JSON, codecs, tick queue), pnet_host_test (both cores over real loopback sockets against scripted peers) and pnet_tls_test (an in-process OpenSSL PKI: valid chain, unknown CA, expired, hostname mismatch, untrusted clock, development-insecure refusal, WSS echo) under ASan/UBSan. --- engine/net/.gitignore | 1 + engine/net/CMakeLists.txt | 83 ++ engine/net/drivers/openssl/pnet_openssl_tls.c | 229 ++++ engine/net/drivers/openssl/pnet_openssl_tls.h | 42 + engine/net/drivers/posix/pnet_posix_driver.c | 567 ++++++++ engine/net/drivers/posix/pnet_posix_driver.h | 53 + engine/net/include/pocketjs/net/driver.h | 146 ++ engine/net/include/pocketjs/net/platform.h | 54 + engine/net/include/pocketjs/net/runtime.h | 193 +++ engine/net/src/pnet_http1.c | 363 +++++ engine/net/src/pnet_http_client.c | 930 +++++++++++++ engine/net/src/pnet_http_server.c | 1218 +++++++++++++++++ engine/net/src/pnet_internal.h | 586 ++++++++ engine/net/src/pnet_json.c | 355 +++++ engine/net/src/pnet_policy.c | 214 +++ engine/net/src/pnet_runtime.c | 837 +++++++++++ engine/net/src/pnet_url.c | 277 ++++ engine/net/src/pnet_util.c | 627 +++++++++ engine/net/src/pnet_ws.c | 1112 +++++++++++++++ engine/net/test/host_test.c | 1205 ++++++++++++++++ engine/net/test/tls_test.c | 495 +++++++ engine/net/test/unit_test.c | 461 +++++++ 22 files changed, 10048 insertions(+) create mode 100644 engine/net/.gitignore create mode 100644 engine/net/CMakeLists.txt create mode 100644 engine/net/drivers/openssl/pnet_openssl_tls.c create mode 100644 engine/net/drivers/openssl/pnet_openssl_tls.h create mode 100644 engine/net/drivers/posix/pnet_posix_driver.c create mode 100644 engine/net/drivers/posix/pnet_posix_driver.h create mode 100644 engine/net/include/pocketjs/net/driver.h create mode 100644 engine/net/include/pocketjs/net/platform.h create mode 100644 engine/net/include/pocketjs/net/runtime.h create mode 100644 engine/net/src/pnet_http1.c create mode 100644 engine/net/src/pnet_http_client.c create mode 100644 engine/net/src/pnet_http_server.c create mode 100644 engine/net/src/pnet_internal.h create mode 100644 engine/net/src/pnet_json.c create mode 100644 engine/net/src/pnet_policy.c create mode 100644 engine/net/src/pnet_runtime.c create mode 100644 engine/net/src/pnet_url.c create mode 100644 engine/net/src/pnet_util.c create mode 100644 engine/net/src/pnet_ws.c create mode 100644 engine/net/test/host_test.c create mode 100644 engine/net/test/tls_test.c create mode 100644 engine/net/test/unit_test.c diff --git a/engine/net/.gitignore b/engine/net/.gitignore new file mode 100644 index 00000000..567609b1 --- /dev/null +++ b/engine/net/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/engine/net/CMakeLists.txt b/engine/net/CMakeLists.txt new file mode 100644 index 00000000..c99dd2e7 --- /dev/null +++ b/engine/net/CMakeLists.txt @@ -0,0 +1,83 @@ +# PocketJS network core — host build (macOS/Linux) for the conformance +# harness. ESP-IDF consumes the same sources through +# hosts/esp-idf/components/pocketjs_net_core. +# +# cmake -S engine/net -B engine/net/build && cmake --build engine/net/build +# ctest --test-dir engine/net/build --output-on-failure + +cmake_minimum_required(VERSION 3.16) +project(pocketjs_net C) + +set(CMAKE_C_STANDARD 99) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS ON) + +set(PNET_CORE_SOURCES + src/pnet_util.c + src/pnet_json.c + src/pnet_url.c + src/pnet_policy.c + src/pnet_http1.c + src/pnet_runtime.c + src/pnet_http_client.c + src/pnet_http_server.c + src/pnet_ws.c) + +add_library(pocketjs_net STATIC ${PNET_CORE_SOURCES}) +target_include_directories(pocketjs_net PUBLIC include PRIVATE src) +target_compile_options(pocketjs_net PRIVATE -Wall -Wextra -Werror -pedantic -Wshadow -Wconversion -Wno-sign-conversion) + +add_library(pocketjs_net_posix STATIC drivers/posix/pnet_posix_driver.c) +target_include_directories(pocketjs_net_posix PUBLIC include drivers/posix) +target_compile_options(pocketjs_net_posix PRIVATE -Wall -Wextra -Werror -Wshadow) +target_link_libraries(pocketjs_net_posix PUBLIC pocketjs_net) + +# Optional OpenSSL TlsProvider + TLS conformance harness (desktop only). +find_package(OpenSSL QUIET) +if(NOT OpenSSL_FOUND AND EXISTS "/opt/homebrew/opt/openssl@3") + set(OPENSSL_ROOT_DIR "/opt/homebrew/opt/openssl@3") + find_package(OpenSSL QUIET) +endif() + +option(PNET_SANITIZE "Build the tests with ASan/UBSan" ON) + +enable_testing() +add_executable(pnet_unit_test test/unit_test.c) +target_include_directories(pnet_unit_test PRIVATE src) +target_link_libraries(pnet_unit_test PRIVATE pocketjs_net) +add_test(NAME unit COMMAND pnet_unit_test) + +add_executable(pnet_host_test test/host_test.c) +target_include_directories(pnet_host_test PRIVATE src) +target_link_libraries(pnet_host_test PRIVATE pocketjs_net_posix pocketjs_net) +find_package(Threads REQUIRED) +target_link_libraries(pnet_host_test PRIVATE Threads::Threads) +add_test(NAME host COMMAND pnet_host_test) + +if(OpenSSL_FOUND) + add_library(pocketjs_net_openssl STATIC drivers/openssl/pnet_openssl_tls.c) + target_include_directories(pocketjs_net_openssl PUBLIC include drivers/openssl PRIVATE src) + target_link_libraries(pocketjs_net_openssl PUBLIC pocketjs_net OpenSSL::SSL OpenSSL::Crypto) + target_compile_options(pocketjs_net_openssl PRIVATE -Wall -Wextra -Werror) + + add_executable(pnet_tls_test test/tls_test.c) + target_include_directories(pnet_tls_test PRIVATE src drivers/openssl) + target_link_libraries(pnet_tls_test PRIVATE pocketjs_net_openssl pocketjs_net_posix pocketjs_net OpenSSL::SSL OpenSSL::Crypto Threads::Threads) + add_test(NAME tls COMMAND pnet_tls_test) +else() + message(STATUS "OpenSSL not found; skipping the TLS provider and tls conformance test") +endif() + +if(OpenSSL_FOUND AND PNET_SANITIZE) + # OpenSSL leak reports from its one-time global init are not our bug. + target_compile_options(pnet_tls_test PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer -g) + target_link_options(pnet_tls_test PRIVATE -fsanitize=address,undefined) + target_compile_options(pocketjs_net_openssl PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer -g) +endif() + +if(PNET_SANITIZE) + foreach(t pocketjs_net pocketjs_net_posix pnet_unit_test pnet_host_test) + target_compile_options(${t} PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer -g) + target_link_options(${t} PRIVATE -fsanitize=address,undefined) + endforeach() +endif() diff --git a/engine/net/drivers/openssl/pnet_openssl_tls.c b/engine/net/drivers/openssl/pnet_openssl_tls.c new file mode 100644 index 00000000..66d4190e --- /dev/null +++ b/engine/net/drivers/openssl/pnet_openssl_tls.c @@ -0,0 +1,229 @@ +/* OpenSSL TlsProvider (see pnet_openssl_tls.h). */ +#include "pnet_openssl_tls.h" + +#include +#include + +#include +#include +#include + +#include "pocketjs/net/spec.h" + +#define MAX_SESSIONS 16 + +typedef struct session { + pnet_sock s; + SSL *ssl; + bool in_use; +} session; + +struct pnet_openssl_tls { + const pnet_driver_ops *driver; + void *driver_ctx; + SSL_CTX *ctx; + session sessions[MAX_SESSIONS]; +}; + +static session *session_for(pnet_openssl_tls *tls, pnet_sock s) { + for (int i = 0; i < MAX_SESSIONS; i++) + if (tls->sessions[i].in_use && tls->sessions[i].s == s) return &tls->sessions[i]; + return NULL; +} + +static session *session_alloc(pnet_openssl_tls *tls, pnet_sock s) { + for (int i = 0; i < MAX_SESSIONS; i++) { + if (!tls->sessions[i].in_use) { + tls->sessions[i].in_use = true; + tls->sessions[i].s = s; + tls->sessions[i].ssl = NULL; + return &tls->sessions[i]; + } + } + return NULL; +} + +pnet_openssl_tls *pnet_openssl_tls_create(const pnet_driver_ops *driver, void *driver_ctx, + const pnet_openssl_tls_config *config) { + if (!driver || !driver->native_handle) return NULL; + pnet_openssl_tls *tls = calloc(1, sizeof *tls); + if (!tls) return NULL; + tls->driver = driver; + tls->driver_ctx = driver_ctx; + tls->ctx = SSL_CTX_new(TLS_client_method()); + if (!tls->ctx) { + free(tls); + return NULL; + } + int min = config && config->min_version ? config->min_version : TLS1_2_VERSION; + SSL_CTX_set_min_proto_version(tls->ctx, min); + SSL_CTX_set_options(tls->ctx, SSL_OP_NO_RENEGOTIATION | SSL_OP_NO_TICKET); + SSL_CTX_set_mode(tls->ctx, SSL_MODE_AUTO_RETRY | SSL_MODE_ENABLE_PARTIAL_WRITE); + SSL_CTX_set_verify(tls->ctx, SSL_VERIFY_PEER, NULL); + if (config && config->ca_pem) { + X509_STORE *store = SSL_CTX_get_cert_store(tls->ctx); + BIO *bio = BIO_new_mem_buf(config->ca_pem, -1); + X509 *cert; + while (bio && (cert = PEM_read_bio_X509(bio, NULL, NULL, NULL)) != NULL) { + X509_STORE_add_cert(store, cert); + X509_free(cert); + } + if (bio) BIO_free(bio); + } else { + SSL_CTX_set_default_verify_paths(tls->ctx); + } + return tls; +} + +void pnet_openssl_tls_destroy(pnet_openssl_tls *tls) { + if (!tls) return; + for (int i = 0; i < MAX_SESSIONS; i++) { + if (tls->sessions[i].in_use && tls->sessions[i].ssl) SSL_free(tls->sessions[i].ssl); + } + if (tls->ctx) SSL_CTX_free(tls->ctx); + free(tls); +} + +void *pnet_openssl_tls_ctx(pnet_openssl_tls *tls) { + return tls; +} + +static int op_start(void *ctx, pnet_sock s, const pnet_tls_policy *policy) { + pnet_openssl_tls *tls = ctx; + int fd = tls->driver->native_handle(tls->driver_ctx, s); + if (fd < 0) return PNET_IO_ERROR; + session *sess = session_alloc(tls, s); + if (!sess) return PNET_IO_NOMEM; + sess->ssl = SSL_new(tls->ctx); + if (!sess->ssl) { + sess->in_use = false; + return PNET_IO_NOMEM; + } + SSL_set_fd(sess->ssl, fd); + SSL_set_connect_state(sess->ssl); + if (policy->server_name && *policy->server_name) { + /* SNI + hostname verification against the authorized name. IP literals + * are set as IP-ID, everything else as DNS-ID. */ + SSL_set_tlsext_host_name(sess->ssl, policy->server_name); + X509_VERIFY_PARAM *param = SSL_get0_param(sess->ssl); + X509_VERIFY_PARAM_set_hostflags(param, X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS); + if (X509_VERIFY_PARAM_set1_ip_asc(param, policy->server_name) != 1) { + X509_VERIFY_PARAM_set1_host(param, policy->server_name, 0); + } + } + if (!policy->verify) { + SSL_set_verify(sess->ssl, SSL_VERIFY_NONE, NULL); + } + if (policy->alpn) { + unsigned char protos[64]; + size_t plen = strlen(policy->alpn); + if (plen < sizeof protos - 1) { + protos[0] = (unsigned char)plen; + memcpy(protos + 1, policy->alpn, plen); + SSL_set_alpn_protos(sess->ssl, protos, (unsigned)(plen + 1)); + } + } + return 0; +} + +static const char *map_verify_failure(long verify_result) { + /* Any peer-certificate verification failure maps to one of two stable + * codes: a name/identity mismatch, or an invalid certificate (chain, + * validity, trust, signature). X509_V_OK means the failure was not a + * verification problem — the caller reports tls_handshake_failed. */ + if (verify_result == X509_V_OK) return NULL; + if (verify_result == X509_V_ERR_HOSTNAME_MISMATCH || verify_result == X509_V_ERR_IP_ADDRESS_MISMATCH || + verify_result == X509_V_ERR_EMAIL_MISMATCH) { + return PNET_ERROR_TLS_HOSTNAME_MISMATCH; + } + return PNET_ERROR_TLS_CERTIFICATE_INVALID; +} + +static int op_step(void *ctx, pnet_sock s, pnet_tls_failure *failure) { + pnet_openssl_tls *tls = ctx; + session *sess = session_for(tls, s); + if (!sess || !sess->ssl) return -1; + ERR_clear_error(); + int rc = SSL_do_handshake(sess->ssl); + if (rc == 1) return 1; + int err = SSL_get_error(sess->ssl, rc); + if (err == SSL_ERROR_WANT_READ || err == SSL_ERROR_WANT_WRITE) return 0; + /* Failure: classify. */ + long verify = SSL_get_verify_result(sess->ssl); + const char *code = map_verify_failure(verify); + if (!code) code = PNET_ERROR_TLS_HANDSHAKE_FAILED; + failure->code = code; + failure->cause = (int)ERR_peek_last_error(); + return -1; +} + +static int map_io(session *sess, int rc) { + int err = SSL_get_error(sess->ssl, rc); + switch (err) { + case SSL_ERROR_WANT_READ: + case SSL_ERROR_WANT_WRITE: + return PNET_IO_AGAIN; + case SSL_ERROR_ZERO_RETURN: + return PNET_IO_EOF; + case SSL_ERROR_SYSCALL: + return rc == 0 ? PNET_IO_EOF : PNET_IO_CLOSED; + default: + return PNET_IO_CLOSED; + } +} + +static int op_read(void *ctx, pnet_sock s, uint8_t *buf, size_t len) { + pnet_openssl_tls *tls = ctx; + session *sess = session_for(tls, s); + if (!sess || !sess->ssl) return PNET_IO_ERROR; + ERR_clear_error(); + int rc = SSL_read(sess->ssl, buf, (int)len); + if (rc > 0) return rc; + return map_io(sess, rc); +} + +static int op_write(void *ctx, pnet_sock s, const uint8_t *buf, size_t len) { + pnet_openssl_tls *tls = ctx; + session *sess = session_for(tls, s); + if (!sess || !sess->ssl) return PNET_IO_ERROR; + ERR_clear_error(); + int rc = SSL_write(sess->ssl, buf, (int)len); + if (rc > 0) return rc; + int mapped = map_io(sess, rc); + return mapped == PNET_IO_AGAIN ? PNET_IO_AGAIN : mapped; +} + +static unsigned op_interest(void *ctx, pnet_sock s) { + pnet_openssl_tls *tls = ctx; + session *sess = session_for(tls, s); + if (!sess || !sess->ssl) return PNET_INTEREST_READ; + /* During the handshake OpenSSL tells us which direction it is blocked on + * through the last want; default to read. */ + return SSL_want_write(sess->ssl) ? PNET_INTEREST_WRITE : PNET_INTEREST_READ; +} + +static void op_close(void *ctx, pnet_sock s) { + pnet_openssl_tls *tls = ctx; + session *sess = session_for(tls, s); + if (!sess) return; + if (sess->ssl) { + /* One non-blocking close_notify attempt; do not block on the peer. */ + SSL_shutdown(sess->ssl); + SSL_free(sess->ssl); + sess->ssl = NULL; + } + sess->in_use = false; +} + +static const pnet_tls_ops OPS = { + .start = op_start, + .step = op_step, + .read = op_read, + .write = op_write, + .interest = op_interest, + .close = op_close, +}; + +const pnet_tls_ops *pnet_openssl_tls_ops(void) { + return &OPS; +} diff --git a/engine/net/drivers/openssl/pnet_openssl_tls.h b/engine/net/drivers/openssl/pnet_openssl_tls.h new file mode 100644 index 00000000..70d0fd3b --- /dev/null +++ b/engine/net/drivers/openssl/pnet_openssl_tls.h @@ -0,0 +1,42 @@ +/* PocketJS network core — OpenSSL TlsProvider (desktop conformance). + * + * One `pnet_tls_ops` over OpenSSL, layered on the driver's plain sockets via + * `native_handle`. It owns a shared SSL_CTX (host trust or a pinned CA), + * runs non-blocking client handshakes with SNI and DNS-ID/IP-ID hostname + * verification, TLS 1.2 minimum, and maps failures onto the four stable + * tls_* codes. It is the reference `NativeTlsProvider` for POSIX hosts and + * the peer against which the portable cores are tested; ESP-IDF uses its own + * ESP-TLS provider. + */ +#ifndef POCKETJS_NET_OPENSSL_TLS_H +#define POCKETJS_NET_OPENSSL_TLS_H + +#include "pocketjs/net/driver.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct pnet_openssl_tls pnet_openssl_tls; + +typedef struct pnet_openssl_tls_config { + /** PEM CA bundle to trust; NULL uses the system default paths. */ + const char *ca_pem; + /** Minimum protocol: 0x0303 = TLS 1.2 (default), 0x0304 = TLS 1.3. */ + int min_version; +} pnet_openssl_tls_config; + +/** Create a provider. `driver`/`driver_ctx` are the same the runtime uses; + * the provider calls `native_handle` to reach the fd. NULL on failure. */ +pnet_openssl_tls *pnet_openssl_tls_create(const pnet_driver_ops *driver, void *driver_ctx, + const pnet_openssl_tls_config *config); +void pnet_openssl_tls_destroy(pnet_openssl_tls *tls); +const pnet_tls_ops *pnet_openssl_tls_ops(void); +/** The ctx to pass as `tls_ctx` to `pnet_runtime_create_tls`. */ +void *pnet_openssl_tls_ctx(pnet_openssl_tls *tls); + +#ifdef __cplusplus +} +#endif + +#endif /* POCKETJS_NET_OPENSSL_TLS_H */ diff --git a/engine/net/drivers/posix/pnet_posix_driver.c b/engine/net/drivers/posix/pnet_posix_driver.c new file mode 100644 index 00000000..bd136e48 --- /dev/null +++ b/engine/net/drivers/posix/pnet_posix_driver.c @@ -0,0 +1,567 @@ +/* BSD-socket NetDriver (see pnet_posix_driver.h). Compiles on POSIX hosts + * and on ESP-IDF (lwIP sockets + newlib). */ +#include "pnet_posix_driver.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(ESP_PLATFORM) +#include "sdkconfig.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +typedef SemaphoreHandle_t drv_mutex_t; +static void mutex_init(drv_mutex_t *m) { *m = xSemaphoreCreateMutex(); } +static void mutex_lock(drv_mutex_t *m) { xSemaphoreTake(*m, portMAX_DELAY); } +static void mutex_unlock(drv_mutex_t *m) { xSemaphoreGive(*m); } +static void mutex_destroy(drv_mutex_t *m) { vSemaphoreDelete(*m); } +#else +#include +typedef pthread_mutex_t drv_mutex_t; +static void mutex_init(drv_mutex_t *m) { pthread_mutex_init(m, NULL); } +static void mutex_lock(drv_mutex_t *m) { pthread_mutex_lock(m); } +static void mutex_unlock(drv_mutex_t *m) { pthread_mutex_unlock(m); } +static void mutex_destroy(drv_mutex_t *m) { pthread_mutex_destroy(m); } +#endif + +#ifndef MSG_NOSIGNAL +#define MSG_NOSIGNAL 0 +#endif + +#define RESOLVE_SLOTS 16 +#define RESOLVE_MAX_ADDRS 8 + +typedef struct sock_slot { + int fd; + unsigned interest; + int connect_error; /* cached SO_ERROR after a failed connect */ + bool in_use; +} sock_slot; + +typedef enum resolve_state { + RS_FREE = 0, + RS_PENDING, + RS_DONE, +} resolve_state; + +typedef struct resolve_slot { + uint32_t req_id; + uint8_t state; + bool cancelled; + char host[256]; + pnet_addr addrs[RESOLVE_MAX_ADDRS]; + size_t count; + int err; +} resolve_slot; + +struct pnet_posix_driver { + sock_slot *slots; + int max_sockets; + int wake_fd; + struct sockaddr_in wake_addr; + drv_mutex_t mutex; + resolve_slot resolves[RESOLVE_SLOTS]; +}; + +static int map_errno(int e) { + switch (e) { + case EAGAIN: +#if EWOULDBLOCK != EAGAIN + case EWOULDBLOCK: +#endif + case EINPROGRESS: + case EALREADY: + return PNET_IO_AGAIN; + case ECONNREFUSED: + case EHOSTUNREACH: + case ENETUNREACH: + case EHOSTDOWN: + case ENETDOWN: + return PNET_IO_REFUSED; + case ETIMEDOUT: + return PNET_IO_TIMEOUT; + case EADDRINUSE: + return PNET_IO_ADDRINUSE; + case ECONNRESET: + case EPIPE: + case ECONNABORTED: + case ENOTCONN: + return PNET_IO_CLOSED; + case ENOMEM: + case ENOBUFS: + case EMFILE: + case ENFILE: + return PNET_IO_NOMEM; + default: + return PNET_IO_ERROR; + } +} + +static void set_nonblock(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + if (flags >= 0) fcntl(fd, F_SETFL, flags | O_NONBLOCK); +#if defined(SO_NOSIGPIPE) + int one = 1; + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof one); +#endif +} + +static int slot_alloc(pnet_posix_driver *d, int fd) { + for (int i = 0; i < d->max_sockets; i++) { + if (!d->slots[i].in_use) { + d->slots[i].in_use = true; + d->slots[i].fd = fd; + d->slots[i].interest = 0; + d->slots[i].connect_error = 0; + return i; + } + } + return -1; +} + +static sock_slot *slot_get(pnet_posix_driver *d, pnet_sock s) { + if (s < 0 || s >= d->max_sockets || !d->slots[s].in_use) return NULL; + return &d->slots[s]; +} + +static bool to_sockaddr(const pnet_addr *addr, struct sockaddr_storage *ss, socklen_t *len) { + memset(ss, 0, sizeof *ss); + if (addr->family == 4) { + struct sockaddr_in *in = (struct sockaddr_in *)ss; + in->sin_family = AF_INET; + in->sin_port = htons(addr->port); + memcpy(&in->sin_addr, addr->addr, 4); + *len = sizeof *in; + return true; + } +#if defined(AF_INET6) && (!defined(ESP_PLATFORM) || defined(CONFIG_LWIP_IPV6)) + if (addr->family == 6) { + struct sockaddr_in6 *in6 = (struct sockaddr_in6 *)ss; + in6->sin6_family = AF_INET6; + in6->sin6_port = htons(addr->port); + memcpy(&in6->sin6_addr, addr->addr, 16); + *len = sizeof *in6; + return true; + } +#endif + return false; +} + +static void from_sockaddr(const struct sockaddr *sa, pnet_addr *out) { + memset(out, 0, sizeof *out); + if (sa->sa_family == AF_INET) { + const struct sockaddr_in *in = (const struct sockaddr_in *)sa; + out->family = 4; + memcpy(out->addr, &in->sin_addr, 4); + out->port = ntohs(in->sin_port); + } +#if defined(AF_INET6) && (!defined(ESP_PLATFORM) || defined(CONFIG_LWIP_IPV6)) + else if (sa->sa_family == AF_INET6) { + const struct sockaddr_in6 *in6 = (const struct sockaddr_in6 *)sa; + out->family = 6; + memcpy(out->addr, &in6->sin6_addr, 16); + out->port = ntohs(in6->sin6_port); + } +#endif +} + +/* ------------------------------------------------------------------------ */ +/* Driver ops */ +/* ------------------------------------------------------------------------ */ + +static int drv_resolve(void *ctx, uint32_t req_id, const char *host) { + pnet_posix_driver *d = ctx; + if (strlen(host) >= sizeof d->resolves[0].host) return PNET_IO_ERROR; + mutex_lock(&d->mutex); + int rc = PNET_IO_NOMEM; + for (int i = 0; i < RESOLVE_SLOTS; i++) { + resolve_slot *r = &d->resolves[i]; + if (r->state == RS_FREE) { + r->req_id = req_id; + r->state = RS_PENDING; + r->cancelled = false; + strcpy(r->host, host); + r->count = 0; + r->err = 0; + rc = 0; + break; + } + } + mutex_unlock(&d->mutex); + if (rc == 0) pnet_posix_driver_wake(d); + return rc; +} + +static void drv_resolve_cancel(void *ctx, uint32_t req_id) { + pnet_posix_driver *d = ctx; + mutex_lock(&d->mutex); + for (int i = 0; i < RESOLVE_SLOTS; i++) { + resolve_slot *r = &d->resolves[i]; + if (r->state != RS_FREE && r->req_id == req_id) { + r->cancelled = true; + if (r->state == RS_DONE) r->state = RS_FREE; + } + } + mutex_unlock(&d->mutex); +} + +static pnet_sock drv_connect(void *ctx, const pnet_addr *addr, int *err) { + pnet_posix_driver *d = ctx; + struct sockaddr_storage ss; + socklen_t len; + if (!to_sockaddr(addr, &ss, &len)) { + *err = PNET_IO_ERROR; + return PNET_SOCK_INVALID; + } + int fd = socket(((struct sockaddr *)&ss)->sa_family, SOCK_STREAM, IPPROTO_TCP); + if (fd < 0) { + *err = map_errno(errno); + return PNET_SOCK_INVALID; + } + set_nonblock(fd); + int one = 1; + setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one); + int slot = slot_alloc(d, fd); + if (slot < 0) { + close(fd); + *err = PNET_IO_NOMEM; + return PNET_SOCK_INVALID; + } + int rc = connect(fd, (struct sockaddr *)&ss, len); + if (rc < 0 && errno != EINPROGRESS) { + int e = map_errno(errno); + if (e != PNET_IO_AGAIN) { + close(fd); + d->slots[slot].in_use = false; + *err = e; + return PNET_SOCK_INVALID; + } + } + *err = 0; + return slot; +} + +static int drv_connect_status(void *ctx, pnet_sock s) { + pnet_posix_driver *d = ctx; + sock_slot *slot = slot_get(d, s); + if (!slot) return PNET_IO_ERROR; + if (slot->connect_error) return slot->connect_error; + /* Probe writability without blocking. */ + fd_set wfds, efds; + FD_ZERO(&wfds); + FD_ZERO(&efds); + FD_SET(slot->fd, &wfds); + FD_SET(slot->fd, &efds); + struct timeval tv = {0, 0}; + int rc = select(slot->fd + 1, NULL, &wfds, &efds, &tv); + if (rc <= 0) return 0; + int soerr = 0; + socklen_t sl = sizeof soerr; + if (getsockopt(slot->fd, SOL_SOCKET, SO_ERROR, &soerr, &sl) < 0) soerr = errno; + if (soerr == 0) return 1; + if (soerr == EINPROGRESS || soerr == EALREADY) return 0; + slot->connect_error = map_errno(soerr); + return slot->connect_error; +} + +static int drv_read(void *ctx, pnet_sock s, uint8_t *buf, size_t len) { + pnet_posix_driver *d = ctx; + sock_slot *slot = slot_get(d, s); + if (!slot) return PNET_IO_ERROR; + ssize_t n = recv(slot->fd, buf, len, 0); + if (n > 0) return (int)n; + if (n == 0) return PNET_IO_EOF; + return map_errno(errno); +} + +static int drv_write(void *ctx, pnet_sock s, const uint8_t *buf, size_t len) { + pnet_posix_driver *d = ctx; + sock_slot *slot = slot_get(d, s); + if (!slot) return PNET_IO_ERROR; + ssize_t n = send(slot->fd, buf, len, MSG_NOSIGNAL); + if (n >= 0) return (int)n; + return map_errno(errno); +} + +static void drv_shutdown_write(void *ctx, pnet_sock s) { + pnet_posix_driver *d = ctx; + sock_slot *slot = slot_get(d, s); + if (slot) shutdown(slot->fd, SHUT_WR); +} + +static void drv_close(void *ctx, pnet_sock s) { + pnet_posix_driver *d = ctx; + sock_slot *slot = slot_get(d, s); + if (!slot) return; + close(slot->fd); + slot->in_use = false; + slot->fd = -1; + slot->interest = 0; +} + +static void drv_interest(void *ctx, pnet_sock s, unsigned flags) { + pnet_posix_driver *d = ctx; + sock_slot *slot = slot_get(d, s); + if (slot) slot->interest = flags; +} + +static pnet_sock drv_listen(void *ctx, const pnet_addr *addr, int backlog, pnet_addr *bound, int *err) { + pnet_posix_driver *d = ctx; + struct sockaddr_storage ss; + socklen_t len; + if (!to_sockaddr(addr, &ss, &len)) { + *err = PNET_IO_ERROR; + return PNET_SOCK_INVALID; + } + int fd = socket(((struct sockaddr *)&ss)->sa_family, SOCK_STREAM, IPPROTO_TCP); + if (fd < 0) { + *err = map_errno(errno); + return PNET_SOCK_INVALID; + } + int one = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + set_nonblock(fd); + if (bind(fd, (struct sockaddr *)&ss, len) < 0 || listen(fd, backlog > 0 ? backlog : 4) < 0) { + *err = map_errno(errno); + close(fd); + return PNET_SOCK_INVALID; + } + struct sockaddr_storage local; + socklen_t llen = sizeof local; + if (getsockname(fd, (struct sockaddr *)&local, &llen) == 0) from_sockaddr((struct sockaddr *)&local, bound); + else *bound = *addr; + int slot = slot_alloc(d, fd); + if (slot < 0) { + close(fd); + *err = PNET_IO_NOMEM; + return PNET_SOCK_INVALID; + } + *err = 0; + return slot; +} + +static pnet_sock drv_accept(void *ctx, pnet_sock listener, pnet_addr *peer, int *err) { + pnet_posix_driver *d = ctx; + sock_slot *slot = slot_get(d, listener); + if (!slot) { + *err = PNET_IO_ERROR; + return PNET_SOCK_INVALID; + } + struct sockaddr_storage ss; + socklen_t len = sizeof ss; + int fd = accept(slot->fd, (struct sockaddr *)&ss, &len); + if (fd < 0) { + *err = map_errno(errno); + return PNET_SOCK_INVALID; + } + set_nonblock(fd); + int one = 1; + setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one); + int ns = slot_alloc(d, fd); + if (ns < 0) { + close(fd); + *err = PNET_IO_NOMEM; + return PNET_SOCK_INVALID; + } + from_sockaddr((struct sockaddr *)&ss, peer); + *err = 0; + return ns; +} + +static int drv_local_addr(void *ctx, pnet_sock s, pnet_addr *out) { + pnet_posix_driver *d = ctx; + sock_slot *slot = slot_get(d, s); + if (!slot) return PNET_IO_ERROR; + struct sockaddr_storage ss; + socklen_t len = sizeof ss; + if (getsockname(slot->fd, (struct sockaddr *)&ss, &len) < 0) return map_errno(errno); + from_sockaddr((struct sockaddr *)&ss, out); + return 0; +} + +static int drv_native_handle(void *ctx, pnet_sock s) { + pnet_posix_driver *d = ctx; + sock_slot *slot = slot_get(d, s); + return slot ? slot->fd : -1; +} + +static const pnet_driver_ops OPS = { + .resolve = drv_resolve, + .resolve_cancel = drv_resolve_cancel, + .connect = drv_connect, + .connect_status = drv_connect_status, + .read = drv_read, + .write = drv_write, + .shutdown_write = drv_shutdown_write, + .close = drv_close, + .interest = drv_interest, + .listen = drv_listen, + .accept = drv_accept, + .local_addr = drv_local_addr, + .native_handle = drv_native_handle, +}; + +const pnet_driver_ops *pnet_posix_driver_ops(void) { + return &OPS; +} + +/* ------------------------------------------------------------------------ */ +/* Lifecycle, wait, dispatch */ +/* ------------------------------------------------------------------------ */ + +pnet_posix_driver *pnet_posix_driver_create(int max_sockets) { + if (max_sockets < 1) max_sockets = 8; + pnet_posix_driver *d = calloc(1, sizeof *d); + if (!d) return NULL; + d->slots = calloc((size_t)max_sockets, sizeof(sock_slot)); + if (!d->slots) { + free(d); + return NULL; + } + for (int i = 0; i < max_sockets; i++) d->slots[i].fd = -1; + d->max_sockets = max_sockets; + mutex_init(&d->mutex); + /* Loopback UDP wake socket. */ + d->wake_fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (d->wake_fd >= 0) { + struct sockaddr_in a; + memset(&a, 0, sizeof a); + a.sin_family = AF_INET; + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + a.sin_port = 0; + if (bind(d->wake_fd, (struct sockaddr *)&a, sizeof a) == 0) { + socklen_t l = sizeof d->wake_addr; + getsockname(d->wake_fd, (struct sockaddr *)&d->wake_addr, &l); + set_nonblock(d->wake_fd); + } else { + close(d->wake_fd); + d->wake_fd = -1; + } + } + return d; +} + +void pnet_posix_driver_destroy(pnet_posix_driver *d) { + if (!d) return; + for (int i = 0; i < d->max_sockets; i++) + if (d->slots[i].in_use) close(d->slots[i].fd); + if (d->wake_fd >= 0) close(d->wake_fd); + mutex_destroy(&d->mutex); + free(d->slots); + free(d); +} + +void pnet_posix_driver_wake(pnet_posix_driver *d) { + if (d->wake_fd < 0) return; + uint8_t byte = 1; + sendto(d->wake_fd, &byte, 1, MSG_NOSIGNAL, (struct sockaddr *)&d->wake_addr, sizeof d->wake_addr); +} + +int pnet_posix_driver_socket_count(pnet_posix_driver *d) { + int n = 0; + for (int i = 0; i < d->max_sockets; i++) + if (d->slots[i].in_use) n++; + return n; +} + +static void run_resolves(pnet_posix_driver *d) { + for (int i = 0; i < RESOLVE_SLOTS; i++) { + mutex_lock(&d->mutex); + resolve_slot *r = &d->resolves[i]; + bool pending = r->state == RS_PENDING && !r->cancelled; + char host[256]; + if (pending) strcpy(host, r->host); + mutex_unlock(&d->mutex); + if (!pending) continue; + struct addrinfo hints; + memset(&hints, 0, sizeof hints); + hints.ai_socktype = SOCK_STREAM; + hints.ai_family = AF_UNSPEC; + struct addrinfo *res = NULL; + int rc = getaddrinfo(host, NULL, &hints, &res); + pnet_addr addrs[RESOLVE_MAX_ADDRS]; + size_t count = 0; + if (rc == 0) { + /* IPv4 first, then IPv6 (v1 modules are IPv4-first). */ + for (int pass = 0; pass < 2 && count < RESOLVE_MAX_ADDRS; pass++) { + for (struct addrinfo *ai = res; ai && count < RESOLVE_MAX_ADDRS; ai = ai->ai_next) { + if ((pass == 0 && ai->ai_family != AF_INET) || (pass == 1 && ai->ai_family == AF_INET)) continue; + pnet_addr a; + from_sockaddr(ai->ai_addr, &a); + if (a.family == 0) continue; + bool dup = false; + for (size_t k = 0; k < count; k++) + if (addrs[k].family == a.family && memcmp(addrs[k].addr, a.addr, 16) == 0) dup = true; + if (!dup) addrs[count++] = a; + } + } + freeaddrinfo(res); + } + mutex_lock(&d->mutex); + if (r->state == RS_PENDING) { + r->state = RS_DONE; + r->err = rc == 0 && count > 0 ? 0 : PNET_IO_ERROR; + r->count = count; + memcpy(r->addrs, addrs, count * sizeof(pnet_addr)); + if (r->cancelled) r->state = RS_FREE; + } + mutex_unlock(&d->mutex); + } +} + +void pnet_posix_driver_wait(pnet_posix_driver *d, int timeout_ms) { + run_resolves(d); + fd_set rfds, wfds; + FD_ZERO(&rfds); + FD_ZERO(&wfds); + int maxfd = -1; + if (d->wake_fd >= 0) { + FD_SET(d->wake_fd, &rfds); + maxfd = d->wake_fd; + } + for (int i = 0; i < d->max_sockets; i++) { + sock_slot *s = &d->slots[i]; + if (!s->in_use || s->fd < 0) continue; + if (s->interest & PNET_INTEREST_READ) FD_SET(s->fd, &rfds); + if (s->interest & PNET_INTEREST_WRITE) FD_SET(s->fd, &wfds); + if (s->interest && s->fd > maxfd) maxfd = s->fd; + } + struct timeval tv; + struct timeval *ptv = NULL; + if (timeout_ms >= 0) { + tv.tv_sec = timeout_ms / 1000; + tv.tv_usec = (timeout_ms % 1000) * 1000; + ptv = &tv; + } + int rc = select(maxfd + 1, &rfds, &wfds, NULL, ptv); + if (rc > 0 && d->wake_fd >= 0 && FD_ISSET(d->wake_fd, &rfds)) { + uint8_t drain[16]; + while (recv(d->wake_fd, drain, sizeof drain, 0) > 0) { + } + } +} + +void pnet_posix_driver_dispatch(pnet_posix_driver *d, pnet_runtime *rt) { + for (int i = 0; i < RESOLVE_SLOTS; i++) { + mutex_lock(&d->mutex); + resolve_slot *r = &d->resolves[i]; + bool done = r->state == RS_DONE; + resolve_slot copy; + if (done) { + copy = *r; + r->state = RS_FREE; + } + mutex_unlock(&d->mutex); + if (done && !copy.cancelled) pnet_runtime_resolve_done(rt, copy.req_id, copy.addrs, copy.count, copy.err); + } +} diff --git a/engine/net/drivers/posix/pnet_posix_driver.h b/engine/net/drivers/posix/pnet_posix_driver.h new file mode 100644 index 00000000..4a725c73 --- /dev/null +++ b/engine/net/drivers/posix/pnet_posix_driver.h @@ -0,0 +1,53 @@ +/* PocketJS network core — BSD-socket NetDriver. + * + * One implementation of pocketjs/net/driver.h over the BSD socket API, used + * by the desktop conformance harness (macOS/Linux) and by ESP-IDF, whose + * lwIP exposes the same calls (socket/connect/select/getaddrinfo). The + * driver owns a bounded socket table, a loopback UDP wake socket and a small + * resolver queue; the host's network task drives it with: + * + * for (;;) { + * lock(); pnet_posix_driver_dispatch(d, rt); pnet_runtime_service(rt); + * timeout = pnet_runtime_next_deadline_ms(rt); unlock(); + * pnet_posix_driver_wait(d, timeout); // blocking DNS + select, no lock held + * } + * + * `resolve()` never blocks the caller: lookups run inside wait() on the + * network task and are handed to the runtime by dispatch(). Owner-thread + * ops that need the network task to look at new work call + * pnet_posix_driver_wake(). + */ +#ifndef POCKETJS_NET_POSIX_DRIVER_H +#define POCKETJS_NET_POSIX_DRIVER_H + +#include + +#include "pocketjs/net/driver.h" +#include "pocketjs/net/runtime.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct pnet_posix_driver pnet_posix_driver; + +/** Create a driver able to track `max_sockets` sockets. NULL on failure. */ +pnet_posix_driver *pnet_posix_driver_create(int max_sockets); +void pnet_posix_driver_destroy(pnet_posix_driver *d); +const pnet_driver_ops *pnet_posix_driver_ops(void); + +/** Wait for I/O, a wake or `timeout_ms` (negative = forever, 0 = poll). + * Runs queued blocking DNS lookups first. Call WITHOUT the runtime lock. */ +void pnet_posix_driver_wait(pnet_posix_driver *d, int timeout_ms); +/** Deliver completed resolver results to the runtime. Call WITH the lock. */ +void pnet_posix_driver_dispatch(pnet_posix_driver *d, pnet_runtime *rt); +/** Interrupt a wait() from any thread. */ +void pnet_posix_driver_wake(pnet_posix_driver *d); +/** Live sockets (for resource reports). */ +int pnet_posix_driver_socket_count(pnet_posix_driver *d); + +#ifdef __cplusplus +} +#endif + +#endif /* POCKETJS_NET_POSIX_DRIVER_H */ diff --git a/engine/net/include/pocketjs/net/driver.h b/engine/net/include/pocketjs/net/driver.h new file mode 100644 index 00000000..1478bf15 --- /dev/null +++ b/engine/net/include/pocketjs/net/driver.h @@ -0,0 +1,146 @@ +/* PocketJS network core — NetDriver interface (plain transport substrate). + * + * The driver is the host's non-blocking socket layer: resolver, byte-stream + * connect/read/write/shutdown/close, listener accept, local/remote address + * metadata and reactor interest. It never sees HTTP, WebSocket or TLS. + * lwIP, BSD sockets, Winsock and + * console SDK sockets all fit this shape. + * + * Threading: the core calls the driver only from inside `pnet_runtime_service` + * and the owner-thread ops, all serialized by the host. The host's network + * task waits on the sockets it created (select/poll/epoll/lwIP select) with a + * timeout of `pnet_runtime_next_deadline_ms`, then calls service(). + */ +#ifndef POCKETJS_NET_DRIVER_H +#define POCKETJS_NET_DRIVER_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** Host socket identity; -1 is invalid. */ +typedef int pnet_sock; +#define PNET_SOCK_INVALID (-1) + +/** Binary IP address; `family` is 4 or 6, `addr` holds 4 or 16 bytes. */ +typedef struct pnet_addr { + uint8_t family; + uint8_t addr[16]; + uint16_t port; +} pnet_addr; + +/** Portable driver error codes (negative). The driver maps errno/lwIP/SDK + * codes onto these before returning; the raw code may travel in `cause`. */ +enum { + PNET_IO_OK = 0, + PNET_IO_AGAIN = -1, /* would block; try again after the reactor wakes */ + PNET_IO_EOF = -2, /* orderly end of stream (read only) */ + PNET_IO_CLOSED = -3, /* connection reset / broken pipe */ + PNET_IO_REFUSED = -4, /* connect refused / unreachable */ + PNET_IO_TIMEOUT = -5, /* driver-level timeout (e.g. TCP connect) */ + PNET_IO_ADDRINUSE = -6, /* bind: address in use */ + PNET_IO_NOMEM = -7, /* out of sockets/buffers */ + PNET_IO_ERROR = -8, /* anything else */ +}; + +/** Reactor interest flags for `interest()`. */ +enum { + PNET_INTEREST_READ = 1u << 0, + PNET_INTEREST_WRITE = 1u << 1, +}; + +typedef struct pnet_driver_ops { + /** Start resolving `host` (ASCII, no port). Completion arrives through + * `pnet_runtime_resolve_done(rt, req_id, ...)`, which the driver may call + * synchronously from inside this function or later from the network task. + * Return < 0 (a PNET_IO_* code) if the request cannot start. */ + int (*resolve)(void *ctx, uint32_t req_id, const char *host); + /** The core no longer needs `req_id`; a later completion is ignored. */ + void (*resolve_cancel)(void *ctx, uint32_t req_id); + + /** Begin a non-blocking TCP connect. Returns the socket, or + * PNET_SOCK_INVALID with *err set. */ + pnet_sock (*connect)(void *ctx, const pnet_addr *addr, int *err); + /** 0 = still connecting, 1 = connected, < 0 = failed (PNET_IO_*). */ + int (*connect_status)(void *ctx, pnet_sock s); + + /** Read up to `len` bytes: > 0 bytes, PNET_IO_EOF, PNET_IO_AGAIN, or an + * error code. */ + int (*read)(void *ctx, pnet_sock s, uint8_t *buf, size_t len); + /** Write up to `len` bytes: >= 0 bytes written (0 = nothing accepted), + * PNET_IO_AGAIN, or an error code. */ + int (*write)(void *ctx, pnet_sock s, const uint8_t *buf, size_t len); + void (*shutdown_write)(void *ctx, pnet_sock s); + void (*close)(void *ctx, pnet_sock s); + /** Register what the core is waiting for on `s` (bitmask of + * PNET_INTEREST_*; 0 clears). Level-triggered semantics are assumed. */ + void (*interest)(void *ctx, pnet_sock s, unsigned flags); + + /** Bind + listen. On success returns the listener socket and fills + * `bound` with the actual local address (port resolved for ephemeral). */ + pnet_sock (*listen)(void *ctx, const pnet_addr *addr, int backlog, pnet_addr *bound, int *err); + /** Accept one connection: the new socket (non-blocking) with `peer` + * filled, or PNET_SOCK_INVALID with *err = PNET_IO_AGAIN / error. */ + pnet_sock (*accept)(void *ctx, pnet_sock listener, pnet_addr *peer, int *err); + + /** Local address of a connected/bound socket; 0 on success. */ + int (*local_addr)(void *ctx, pnet_sock s, pnet_addr *out); + /** The platform handle behind `s` (a file descriptor on BSD sockets), for + * a TlsProvider that layers over the plain stream. Optional. */ + int (*native_handle)(void *ctx, pnet_sock s); +} pnet_driver_ops; + +/* ------------------------------------------------------------------------ */ +/* TlsProvider */ +/* ------------------------------------------------------------------------ */ + +/** What the core asks of one TLS client handshake. `server_name` is the + * authorized hostname: it is both the SNI and the DNS-ID the certificate + * must match. `verify=false` is only ever set for + * `development-insecure` after the runtime's triple opt-in. */ +typedef struct pnet_tls_policy { + const char *server_name; + bool verify; + /** NULL or a single ALPN protocol id ("http/1.1"). */ + const char *alpn; +} pnet_tls_policy; + +/** Why a handshake failed: one of the four stable tls_* codes plus the + * library's raw code for `causeCode`. */ +typedef struct pnet_tls_failure { + const char *code; + int cause; +} pnet_tls_failure; + +/** A TLS client layered over the driver's plain streams. The provider owns + * host trust (system store / bundle), entropy and the wire; the core owns + * the deadline, cancellation and the policy. Never a plaintext fallback. */ +typedef struct pnet_tls_ops { + /** Wrap the connected plain socket `s` and begin the client handshake. + * 0 on success (progress via step), or a PNET_IO_* code. */ + int (*start)(void *ctx, pnet_sock s, const pnet_tls_policy *policy); + /** Drive the handshake: 0 = pending (see interest), 1 = established, + * -1 = failed (`failure` filled). */ + int (*step)(void *ctx, pnet_sock s, pnet_tls_failure *failure); + /** Application data over the established session; same contract as the + * driver's read/write (bytes, PNET_IO_AGAIN, PNET_IO_EOF, errors). */ + int (*read)(void *ctx, pnet_sock s, uint8_t *buf, size_t len); + int (*write)(void *ctx, pnet_sock s, const uint8_t *buf, size_t len); + /** Reactor interest the session currently needs (bitmask of + * PNET_INTEREST_*); 0 means "whatever the application wants". */ + unsigned (*interest)(void *ctx, pnet_sock s); + /** Send close_notify if possible and release the session. Called before + * the driver closes the plain socket; a provider that took ownership of + * the platform handle must tell the driver (see the driver's docs). */ + void (*close)(void *ctx, pnet_sock s); +} pnet_tls_ops; + +#ifdef __cplusplus +} +#endif + +#endif /* POCKETJS_NET_DRIVER_H */ diff --git a/engine/net/include/pocketjs/net/platform.h b/engine/net/include/pocketjs/net/platform.h new file mode 100644 index 00000000..65bcbe6c --- /dev/null +++ b/engine/net/include/pocketjs/net/platform.h @@ -0,0 +1,54 @@ +/* PocketJS network core — platform interface. + * + * The core (engine/net) is portable C99 with no OS headers. Everything it + * needs from the host arrives through this table: a monotonic clock, a + * bounded allocator, entropy and a log sink. The host owns thread + * discipline: the core is not internally synchronized. Every call into a + * runtime (owner-thread ops, `pnet_runtime_service`, `pnet_runtime_begin_tick`) + * must be serialized by the host — one mutex around all of them is the + * reference arrangement, a single-threaded loop calling service() then + * begin_tick() is another. + */ +#ifndef POCKETJS_NET_PLATFORM_H +#define POCKETJS_NET_PLATFORM_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum pnet_log_level { + PNET_LOG_ERROR = 0, + PNET_LOG_WARN = 1, + PNET_LOG_INFO = 2, + PNET_LOG_DEBUG = 3, +} pnet_log_level; + +typedef struct pnet_platform { + void *ctx; + /** Monotonic milliseconds; the core's only clock. */ + uint64_t (*now_ms)(void *ctx); + /** Allocate `size` bytes or return NULL. The core accounts every byte it + * holds against `pnet_runtime_config.max_heap_bytes` before calling. */ + void *(*alloc)(void *ctx, size_t size); + /** Release a block returned by alloc; `size` is what was requested. */ + void (*free)(void *ctx, void *ptr, size_t size); + /** Cryptographic-quality random bytes (WebSocket keys and masks). */ + void (*random)(void *ctx, uint8_t *out, size_t len); + /** Optional diagnostics sink; NULL disables logging. */ + void (*log)(void *ctx, pnet_log_level level, const char *message); + /** Optional: whether the wall clock is trustworthy for certificate + * validity checks (SNTP synced, persisted RTC, provisioning). NULL means + * trusted. While false, a verifying TLS connection fails closed with + * `tls_clock_untrusted` before any I/O. */ + bool (*wall_clock_trusted)(void *ctx); +} pnet_platform; + +#ifdef __cplusplus +} +#endif + +#endif /* POCKETJS_NET_PLATFORM_H */ diff --git a/engine/net/include/pocketjs/net/runtime.h b/engine/net/include/pocketjs/net/runtime.h new file mode 100644 index 00000000..cd33134d --- /dev/null +++ b/engine/net/include/pocketjs/net/runtime.h @@ -0,0 +1,193 @@ +/* PocketJS network core — runtime and module ops. + * + * One `pnet_runtime` holds the Shared Async Runtime (handle tables, timers, + * per-module event queues, tick budgets, the immutable policy) and the three + * protocol cores behind the spec-pinned namespaces: + * + * net HTTP Client contracts/spec/net.ts pnet_http_* + * httpd HTTP Server contracts/spec/httpd.ts pnet_httpd_* + * ws WebSocket contracts/spec/ws.ts pnet_ws_* + * + * Two call sites (both serialized by the host, see platform.h): + * + * network side pnet_runtime_service() after the reactor wakes, + * pnet_runtime_next_deadline_ms() for its timeout, + * pnet_runtime_resolve_done() from the resolver; + * guest side pnet_runtime_begin_tick() right before `frame()`, + * then the module ops the guest binding forwards + * (start/poll/readInto/... — synchronous, no I/O). + * + * Nothing here calls back into the guest. Completions become visible only at + * begin_tick(); poll() returns the visible batch as JSON; payload bytes cross + * only through the *_read_into / *_receive_into copies. + */ +#ifndef POCKETJS_NET_RUNTIME_H +#define POCKETJS_NET_RUNTIME_H + +#include +#include +#include + +#include "pocketjs/net/driver.h" +#include "pocketjs/net/platform.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct pnet_runtime pnet_runtime; + +/** Host-tightened limits. Zero keeps the spec ceiling + * (engine/net/include/pocketjs/net/spec.h); values above the ceiling are + * clamped to it. */ +typedef struct pnet_runtime_config { + /** Total bytes the core may hold (queues, parser buffers, event JSON). */ + size_t max_heap_bytes; + /* --- net --- */ + uint32_t http_max_inflight; + size_t http_max_request_bytes; + size_t http_default_queue_bytes; + size_t http_max_queue_bytes; + size_t http_default_aggregate_bytes; + size_t http_max_aggregate_bytes; + uint32_t http_max_events_per_tick; + size_t http_max_tick_bytes; + uint32_t http_max_headers; + size_t http_max_header_bytes; + uint32_t http_default_timeout_ms; + uint32_t http_max_timeout_ms; + uint32_t http_max_redirects; + /* --- ws --- */ + uint32_t ws_max_sockets; + size_t ws_max_message_bytes; + size_t ws_max_receive_queue_bytes; + uint32_t ws_max_receive_queue_messages; + size_t ws_max_send_queue_bytes; + size_t ws_send_high_water_bytes; + size_t ws_send_low_water_bytes; + uint32_t ws_max_events_per_tick; + size_t ws_max_tick_bytes; + uint32_t ws_default_connect_ms; + uint32_t ws_max_connect_ms; + uint32_t ws_default_close_ms; + /* --- httpd --- */ + uint32_t httpd_max_servers; + uint32_t httpd_max_connections; + uint32_t httpd_max_inflight; + size_t httpd_max_header_bytes; + uint32_t httpd_max_headers; + size_t httpd_max_target_bytes; + size_t httpd_default_request_queue_bytes; + size_t httpd_max_request_queue_bytes; + size_t httpd_max_send_queue_bytes; + size_t httpd_send_high_water_bytes; + size_t httpd_send_low_water_bytes; + uint32_t httpd_max_events_per_tick; + size_t httpd_max_tick_bytes; + /** Bytes read from a socket per read() call (also the segment size). */ + size_t io_chunk_bytes; + /** Development build flag: enables `tls.verification = "development-insecure"` + * when the policy also allows it. Never set in production. */ + bool development_build; +} pnet_runtime_config; + +/** Fill `cfg` with the spec ceilings. */ +void pnet_runtime_config_defaults(pnet_runtime_config *cfg); + +/** Create a runtime. `policy_json` is the immutable Build Plan projection: + * { "connect": [{"protocol":"http","host":"example.com","port":80}], + * "listen": [{"protocol":"http","address":"0.0.0.0","port":8080}], + * "credentials": [], "insecureTransport": true, "localNetwork": true, + * "allowInvalidTlsForDevelopment": false } + * Returns NULL on invalid input or allocation failure. */ +pnet_runtime *pnet_runtime_create(const pnet_platform *platform, + const pnet_driver_ops *driver, void *driver_ctx, + const pnet_runtime_config *config, + const char *policy_json); + +/** Same, with a TlsProvider. When `tls` is non-NULL the host advertises the + * "tls" feature for the HTTP and WebSocket client roles, https:/wss: URLs + * are accepted, and every handshake runs under the core's connect deadline. */ +pnet_runtime *pnet_runtime_create_tls(const pnet_platform *platform, + const pnet_driver_ops *driver, void *driver_ctx, + const pnet_tls_ops *tls, void *tls_ctx, + const pnet_runtime_config *config, + const char *policy_json); +void pnet_runtime_destroy(pnet_runtime *rt); + +/* ------------------------------------------------------------------------ */ +/* Network side */ +/* ------------------------------------------------------------------------ */ + +/** Run every state machine: accept, connect progress, reads, writes, + * timers. Call after the reactor wakes (readable/writable/timeout/command). */ +void pnet_runtime_service(pnet_runtime *rt); +/** Absolute monotonic deadline of the nearest timer, or 0 when none. */ +uint64_t pnet_runtime_next_deadline_ms(pnet_runtime *rt); +/** True when some socket has bytes queued for writing (the host may want to + * service() again before waiting). */ +bool pnet_runtime_has_pending_output(pnet_runtime *rt); +/** Resolver completion. `err` 0 with `count` addresses, else a PNET_IO_* code. */ +void pnet_runtime_resolve_done(pnet_runtime *rt, uint32_t req_id, const pnet_addr *addrs, + size_t count, int err); +/** Quiesce: refuse new + * operations, cancel every live one; terminal events still arrive at + * subsequent ticks. */ +void pnet_runtime_quiesce(pnet_runtime *rt); + +/* ------------------------------------------------------------------------ */ +/* Guest side (owner thread) */ +/* ------------------------------------------------------------------------ */ + +/** Tick boundary: freeze readable watermarks and move completed events into + * the visible sets of every module. Call before every `frame()`. */ +void pnet_runtime_begin_tick(pnet_runtime *rt); + +/** Bytes currently held (for resource reports). */ +size_t pnet_runtime_heap_bytes(pnet_runtime *rt); +/** True while any module has a live handle (the host may skip the guest + * pump registration otherwise; the SDK does its own bookkeeping too). */ +bool pnet_runtime_has_live_handles(pnet_runtime *rt); + +/* --- net: HTTP Client (spec.h PNET_OP_*) --------------------------------- */ + +int pnet_http_start(pnet_runtime *rt, const char *meta_json, const uint8_t *body, size_t body_len); +void pnet_http_cancel(pnet_runtime *rt, int handle); +/** The visible batch as one JSON array, or NULL. The string stays valid + * until the next pnet_http_poll / begin_tick / destroy. */ +const char *pnet_http_poll(pnet_runtime *rt, size_t *len); +const char *pnet_http_last_error(pnet_runtime *rt); +int pnet_http_read_into(pnet_runtime *rt, int handle, uint8_t *dst, size_t len); +const char *pnet_http_limits(pnet_runtime *rt); + +/* --- ws: WebSocket Client (spec.h PWS_OP_*) ------------------------------ */ + +int pnet_ws_connect(pnet_runtime *rt, const char *meta_json); +int pnet_ws_send(pnet_runtime *rt, int handle, int opcode, const uint8_t *payload, size_t len); +int pnet_ws_receive_into(pnet_runtime *rt, int handle, uint8_t *dst, size_t len); +/** code 0 = omitted; reason NULL or UTF-8 <= 123 bytes. */ +int pnet_ws_close(pnet_runtime *rt, int handle, int code, const char *reason, size_t reason_len); +void pnet_ws_terminate(pnet_runtime *rt, int handle); +int pnet_ws_buffered_amount(pnet_runtime *rt, int handle); +const char *pnet_ws_poll(pnet_runtime *rt, size_t *len); +const char *pnet_ws_last_error(pnet_runtime *rt); +const char *pnet_ws_limits(pnet_runtime *rt); + +/* --- httpd: HTTP Server (spec.h PHTTPD_OP_*) ----------------------------- */ + +int pnet_httpd_listen(pnet_runtime *rt, const char *meta_json); +int pnet_httpd_stop(pnet_runtime *rt, int handle, bool graceful, uint32_t timeout_ms); +int pnet_httpd_respond(pnet_runtime *rt, int req, const char *meta_json, const uint8_t *body, size_t body_len); +int pnet_httpd_write(pnet_runtime *rt, int req, const uint8_t *chunk, size_t len); +int pnet_httpd_end_body(pnet_runtime *rt, int req); +int pnet_httpd_read_into(pnet_runtime *rt, int req, uint8_t *dst, size_t len); +void pnet_httpd_abort(pnet_runtime *rt, int req); +const char *pnet_httpd_poll(pnet_runtime *rt, size_t *len); +const char *pnet_httpd_last_error(pnet_runtime *rt); +const char *pnet_httpd_limits(pnet_runtime *rt); + +#ifdef __cplusplus +} +#endif + +#endif /* POCKETJS_NET_RUNTIME_H */ diff --git a/engine/net/src/pnet_http1.c b/engine/net/src/pnet_http1.c new file mode 100644 index 00000000..9bb1ecfc --- /dev/null +++ b/engine/net/src/pnet_http1.c @@ -0,0 +1,363 @@ +/* HTTP/1.1 message syntax (RFC 9112) with a strict framing profile: + * TE+CL rejected, only a single + * `chunked`, a single Content-Length, no obs-fold, bounded head, chunked + * trailers parsed and validated then discarded. Shared by the client and the + * server cores. */ +#include "pnet_internal.h" + +static bool is_ows(char c) { return c == ' ' || c == '\t'; } + +static bool valid_field_value(const char *v, size_t len) { + for (size_t i = 0; i < len; i++) { + unsigned char c = (unsigned char)v[i]; + if (c == '\t') continue; + if (c < 0x20 || c == 0x7f) return false; + } + return true; +} + +const pnet_h1_field *pnet_h1_find(const pnet_h1_head *head, const char *name) { + size_t nl = strlen(name); + for (size_t i = 0; i < head->field_count; i++) { + if (head->fields[i].name_len == nl && memcmp(head->fields[i].name, name, nl) == 0) return &head->fields[i]; + } + return NULL; +} + +/** Iterate comma-separated tokens in a field value; calls fn for each + * trimmed token. */ +static void for_each_token(const char *v, size_t len, void (*fn)(void *ctx, const char *tok, size_t n), void *ctx) { + size_t i = 0; + while (i <= len) { + size_t j = i; + while (j < len && v[j] != ',') j++; + size_t a = i, b = j; + while (a < b && is_ows(v[a])) a++; + while (b > a && is_ows(v[b - 1])) b--; + if (b > a) fn(ctx, v + a, b - a); + if (j >= len) break; + i = j + 1; + } +} + +typedef struct conn_scan { + bool close; + bool keep_alive; + bool upgrade; +} conn_scan; + +static void scan_connection(void *ctx, const char *tok, size_t n) { + conn_scan *s = ctx; + if (pnet_ieq_n(tok, n, "close")) s->close = true; + else if (pnet_ieq_n(tok, n, "keep-alive")) s->keep_alive = true; + else if (pnet_ieq_n(tok, n, "upgrade")) s->upgrade = true; +} + +typedef struct te_scan { + int chunked_count; + int other_count; +} te_scan; + +static void scan_te(void *ctx, const char *tok, size_t n) { + te_scan *s = ctx; + if (pnet_ieq_n(tok, n, "chunked")) s->chunked_count++; + else s->other_count++; +} + +int pnet_h1_parse_head(uint8_t *buf, size_t len, bool request, size_t max_head_bytes, size_t max_fields, + size_t max_target_bytes, pnet_h1_head *out) { + /* Locate the end of the head. */ + size_t limit = len < max_head_bytes + 4 ? len : max_head_bytes + 4; + size_t end = 0; + bool found = false; + for (size_t i = 0; i + 3 < limit; i++) { + if (buf[i] == '\r' && buf[i + 1] == '\n' && buf[i + 2] == '\r' && buf[i + 3] == '\n') { + end = i + 4; + found = true; + break; + } + } + if (!found) return len > max_head_bytes ? PNET_H1_TOO_LARGE : PNET_H1_INCOMPLETE; + if (end - 4 > max_head_bytes) return PNET_H1_TOO_LARGE; + memset(out, 0, sizeof *out); + out->request = request; + out->content_length = -1; + out->head_len = end; + char *s = (char *)buf; + size_t pos = 0; + /* Start line */ + size_t eol = pos; + while (eol + 1 < end && !(s[eol] == '\r' && s[eol + 1] == '\n')) eol++; + if (request) { + size_t sp1 = pos; + while (sp1 < eol && s[sp1] != ' ') sp1++; + if (sp1 == pos || sp1 >= eol) return PNET_H1_ERROR; + size_t sp2 = sp1 + 1; + while (sp2 < eol && s[sp2] != ' ') sp2++; + if (sp2 >= eol || sp2 == sp1 + 1) return PNET_H1_ERROR; + if (!pnet_is_token(s + pos, sp1 - pos)) return PNET_H1_ERROR; + out->method = s + pos; + out->method_len = sp1 - pos; + out->target = s + sp1 + 1; + out->target_len = sp2 - sp1 - 1; + if (out->target_len > max_target_bytes) return PNET_H1_TARGET_TOO_LONG; + for (size_t i = 0; i < out->target_len; i++) { + unsigned char c = (unsigned char)out->target[i]; + if (c <= 0x20 || c == 0x7f) return PNET_H1_ERROR; + } + const char *ver = s + sp2 + 1; + size_t vlen = eol - sp2 - 1; + if (vlen != 8 || memcmp(ver, "HTTP/1.", 7) != 0 || (ver[7] != '0' && ver[7] != '1')) return PNET_H1_ERROR; + out->minor_version = ver[7] - '0'; + } else { + if (eol - pos < 12 || memcmp(s + pos, "HTTP/1.", 7) != 0 || (s[pos + 7] != '0' && s[pos + 7] != '1') || s[pos + 8] != ' ') + return PNET_H1_ERROR; + out->minor_version = s[pos + 7] - '0'; + const char *st = s + pos + 9; + if (st[0] < '0' || st[0] > '9' || st[1] < '0' || st[1] > '9' || st[2] < '0' || st[2] > '9') return PNET_H1_ERROR; + out->status = (st[0] - '0') * 100 + (st[1] - '0') * 10 + (st[2] - '0'); + if (out->status < 100) return PNET_H1_ERROR; + size_t after = pos + 12; + if (after < eol) { + if (s[after] != ' ') return PNET_H1_ERROR; + out->reason = s + after + 1; + out->reason_len = eol - after - 1; + if (!valid_field_value(out->reason, out->reason_len)) return PNET_H1_ERROR; + } else if (after != eol) { + return PNET_H1_ERROR; + } + } + pos = eol + 2; + /* Fields */ + int cl_count = 0; + bool te_present = false; + te_scan te = {0, 0}; + conn_scan cs = {false, false, false}; + while (pos < end - 2) { + eol = pos; + while (eol + 1 < end && !(s[eol] == '\r' && s[eol + 1] == '\n')) eol++; + if (eol == pos) break; /* empty line: end of head */ + if (is_ows(s[pos])) return PNET_H1_ERROR; /* obs-fold */ + size_t colon = pos; + while (colon < eol && s[colon] != ':') colon++; + if (colon >= eol || colon == pos) return PNET_H1_ERROR; + if (!pnet_is_token(s + pos, colon - pos)) return PNET_H1_ERROR; + if (out->field_count >= max_fields || out->field_count >= PNET_H1_MAX_FIELDS) return PNET_H1_TOO_MANY_FIELDS; + size_t va = colon + 1; + size_t vb = eol; + while (va < vb && is_ows(s[va])) va++; + while (vb > va && is_ows(s[vb - 1])) vb--; + if (!valid_field_value(s + va, vb - va)) return PNET_H1_ERROR; + pnet_h1_field *f = &out->fields[out->field_count++]; + f->name = s + pos; + f->name_len = colon - pos; + pnet_lower(f->name, f->name_len); + f->value = s + va; + f->value_len = vb - va; + /* NUL-terminate name/value in place (over the ':'/CR) for C callers: + * the ':' after the name and the CR after the value are ours. */ + f->name[f->name_len] = 0; + f->value[f->value_len] = 0; + /* Framing fields */ + if (f->name_len == 14 && memcmp(f->name, "content-length", 14) == 0) { + cl_count++; + if (memchr(f->value, ',', f->value_len)) return PNET_H1_ERROR; + uint64_t v; + if (!pnet_parse_u64(f->value, f->value_len, &v) || v > (uint64_t)INT64_MAX) return PNET_H1_ERROR; + out->content_length = (int64_t)v; + } else if (f->name_len == 17 && memcmp(f->name, "transfer-encoding", 17) == 0) { + te_present = true; + for_each_token(f->value, f->value_len, scan_te, &te); + } else if (f->name_len == 10 && memcmp(f->name, "connection", 10) == 0) { + for_each_token(f->value, f->value_len, scan_connection, &cs); + } else if (f->name_len == 7 && memcmp(f->name, "upgrade", 7) == 0) { + out->has_upgrade = true; + } else if (f->name_len == 6 && memcmp(f->name, "expect", 6) == 0) { + if (pnet_ieq_n(f->value, f->value_len, "100-continue")) out->expect_continue = true; + else return PNET_H1_ERROR; + } + pos = eol + 2; + } + if (cl_count > 1) return PNET_H1_ERROR; + if (te_present) { + if (te.chunked_count != 1 || te.other_count != 0) return PNET_H1_ERROR; + if (cl_count > 0) return PNET_H1_ERROR; + if (out->minor_version == 0) return PNET_H1_ERROR; + out->chunked = true; + } + out->connection_close = cs.close; + out->connection_keep_alive = cs.keep_alive; + if (cs.upgrade) out->has_upgrade = true; + return PNET_H1_OK; +} + +bool pnet_h1_validate_framing(pnet_h1_head *head) { + if (head->chunked && head->content_length >= 0) return false; + return true; +} + +/* ------------------------------------------------------------------------ */ +/* Body decoding */ +/* ------------------------------------------------------------------------ */ + +enum { + CH_SIZE = 0, /* reading chunk-size line */ + CH_DATA, /* reading chunk data */ + CH_DATA_CRLF, /* expecting CRLF after chunk data */ + CH_TRAILER, /* reading trailer lines */ + CH_DONE, +}; + +#define PNET_H1_MAX_TRAILER_BYTES 4096 +#define PNET_H1_MAX_TRAILER_FIELDS 32 + +void pnet_h1_body_init(pnet_h1_body *b, pnet_h1_body_mode mode, uint64_t length) { + memset(b, 0, sizeof *b); + b->mode = (uint8_t)mode; + b->remaining = mode == PNET_H1_BODY_LENGTH ? length : 0; + b->chunk_state = CH_SIZE; + if (mode == PNET_H1_BODY_NONE || (mode == PNET_H1_BODY_LENGTH && length == 0)) b->done = true; +} + +bool pnet_h1_trailer_field_forbidden(const char *name, size_t len) { + static const char *const forbidden[] = { + "content-length", "transfer-encoding", "host", "connection", "trailer", "upgrade", "authorization", + "proxy-authorization", "content-encoding", "content-type", "content-range", "te", "keep-alive", + "cache-control", "expect", "max-forwards", "pragma", "range", "www-authenticate", "proxy-authenticate", + "set-cookie", "cookie", "age", "expires", "date", "location", "retry-after", "vary", "warning", + }; + for (size_t i = 0; i < sizeof forbidden / sizeof forbidden[0]; i++) { + if (pnet_ieq_n(name, len, forbidden[i])) return true; + } + return false; +} + +/** Validate a complete trailer line (without CRLF). */ +static bool valid_trailer_line(pnet_h1_body *b, const char *line, size_t len) { + if (len == 0) return true; + if (is_ows(line[0])) return false; + const char *colon = memchr(line, ':', len); + if (!colon || colon == line) return false; + size_t nlen = (size_t)(colon - line); + if (!pnet_is_token(line, nlen)) return false; + if (pnet_h1_trailer_field_forbidden(line, nlen)) return false; + if (!valid_field_value(colon + 1, len - nlen - 1)) return false; + b->trailer_fields++; + if (b->trailer_fields > PNET_H1_MAX_TRAILER_FIELDS) return false; + return true; +} + +size_t pnet_h1_body_feed(pnet_h1_body *b, const uint8_t *in, size_t len, + bool (*sink)(void *ctx, const uint8_t *data, size_t len), void *ctx) { + size_t i = 0; + if (b->done || b->error) return 0; + switch (b->mode) { + case PNET_H1_BODY_LENGTH: { + size_t n = len; + if ((uint64_t)n > b->remaining) n = (size_t)b->remaining; + if (n > 0 && !sink(ctx, in, n)) return 0; + b->remaining -= n; + if (b->remaining == 0) b->done = true; + return n; + } + case PNET_H1_BODY_CLOSE: + if (len > 0 && !sink(ctx, in, len)) return 0; + return len; + case PNET_H1_BODY_CHUNKED: + break; + default: + b->done = true; + return 0; + } + while (i < len && !b->done && !b->error) { + switch (b->chunk_state) { + case CH_SIZE: + case CH_TRAILER: { + /* Accumulate a line up to CRLF. */ + uint8_t c = in[i++]; + if (b->line_len >= sizeof b->line - 1) { + b->error = true; + break; + } + b->line[b->line_len++] = (char)c; + if (b->line_len >= 2 && b->line[b->line_len - 2] == '\r' && b->line[b->line_len - 1] == '\n') { + size_t llen = b->line_len - 2; + b->line[llen] = 0; + if (b->chunk_state == CH_SIZE) { + /* chunk-size [;ext] */ + size_t k = 0; + uint64_t size = 0; + size_t digits = 0; + while (k < llen) { + char h = b->line[k]; + int v = (h >= '0' && h <= '9') ? h - '0' : (h >= 'a' && h <= 'f') ? h - 'a' + 10 : (h >= 'A' && h <= 'F') ? h - 'A' + 10 : -1; + if (v < 0) break; + if (digits >= 16) { b->error = true; break; } + size = (size << 4) | (uint64_t)v; + digits++; + k++; + } + if (b->error) break; + if (digits == 0) { b->error = true; break; } + /* Only BWS then ';' extension allowed after the size. */ + size_t e = k; + while (e < llen && is_ows(b->line[e])) e++; + if (e < llen && b->line[e] != ';') { b->error = true; break; } + b->line_len = 0; + if (size == 0) { + b->chunk_state = CH_TRAILER; + b->trailer_bytes = 0; + b->trailer_fields = 0; + } else { + b->remaining = size; + b->chunk_state = CH_DATA; + } + } else { + b->trailer_bytes += b->line_len; + if (b->trailer_bytes > PNET_H1_MAX_TRAILER_BYTES) { b->error = true; break; } + if (llen == 0) { + b->chunk_state = CH_DONE; + b->done = true; + } else if (!valid_trailer_line(b, b->line, llen)) { + b->error = true; + } + b->line_len = 0; + } + } else if (b->line_len >= 2 && b->line[b->line_len - 2] == '\r' && b->line[b->line_len - 1] != '\n') { + b->error = true; /* bare CR */ + } else if (c == '\n' && !(b->line_len >= 2 && b->line[b->line_len - 2] == '\r')) { + b->error = true; /* bare LF */ + } + break; + } + case CH_DATA: { + size_t n = len - i; + if ((uint64_t)n > b->remaining) n = (size_t)b->remaining; + if (n > 0 && !sink(ctx, in + i, n)) return i; + i += n; + b->remaining -= n; + if (b->remaining == 0) { + b->chunk_state = CH_DATA_CRLF; + b->line_len = 0; + } + break; + } + case CH_DATA_CRLF: { + uint8_t c = in[i++]; + if (b->line_len == 0) { + if (c != '\r') { b->error = true; break; } + b->line_len = 1; + } else { + if (c != '\n') { b->error = true; break; } + b->line_len = 0; + b->chunk_state = CH_SIZE; + } + break; + } + default: + b->done = true; + break; + } + } + return i; +} diff --git a/engine/net/src/pnet_http_client.c b/engine/net/src/pnet_http_client.c new file mode 100644 index 00000000..25f3283e --- /dev/null +++ b/engine/net/src/pnet_http_client.c @@ -0,0 +1,930 @@ +/* HTTP Client core (`globalThis.net`, contracts/spec/net.ts v2). + * + * One pnet_http_req per handle: dial → send request → parse response head → + * decode body into a bounded receive queue → `end`. Redirects run inside the + * core with a fresh dial per hop and a policy re-check; every timeout is a + * deadline on the host monotonic clock. Events (`headers`, `readable`, + * `end`, `error`) go to the net queue and reach the guest only after + * begin_tick(); body bytes cross only through pnet_http_read_into. + */ +#include + +#include "pnet_internal.h" + +typedef enum req_state { + RQ_DIALING = 0, + RQ_SENDING, /* request written / being flushed, waiting for the head */ + RQ_BODY, /* head delivered, decoding body */ + RQ_ENDED, /* terminal event pushed; kept until the queue is drained */ +} req_state; + +typedef struct pnet_http_req { + struct pnet_http_req *next; + int handle; + uint8_t state; + bool cancelled; + bool terminal; /* terminal event pushed */ + bool head_pushed; + bool head_only; /* HEAD or a status without a body */ + bool dirty; /* new queued bytes since the last tick */ + bool redirected; + bool live_counted; + pnet_url url; + char *method; + size_t method_len; + pnet_sb user_headers; /* "name: value\r\n" lines */ + uint8_t *body; + size_t body_len; + bool body_dropped; + uint8_t redirect_mode; /* 0 follow, 1 manual, 2 error */ + uint32_t redirects_left; + uint32_t connect_ms, headers_ms, idle_ms; + uint64_t started_at; + uint64_t total_deadline; + uint64_t phase_deadline; + size_t queue_bytes; + size_t max_body_bytes; + size_t body_total; + bool insecure_tls; + pnet_dial dial; + pnet_conn conn; + uint8_t *rx; + size_t rx_len; + size_t rx_cap; + pnet_h1_body decoder; + pnet_bq rxq; + size_t visible_bytes; +} pnet_http_req; + +static const char *const REDIRECT_MODES[] = {"follow", "manual", "error"}; + +/* ------------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------------ */ + +static void req_free(pnet_runtime *rt, pnet_http_req *r) { + pnet_dial_cancel(rt, &r->dial); + pnet_conn_close(rt, &r->conn); + pnet_url_free(rt, &r->url); + if (r->method) pnet_free(rt, r->method, r->method_len + 1); + pnet_sb_free(rt, &r->user_headers); + if (r->body) pnet_free(rt, r->body, r->body_len); + if (r->rx) pnet_free(rt, r->rx, r->rx_cap); + pnet_bq_free(rt, &r->rxq); + pnet_free(rt, r, sizeof *r); +} + +static void req_unlink(pnet_runtime *rt, pnet_http_req *r) { + pnet_http_req **pp = &rt->http_reqs; + while (*pp && *pp != r) pp = &(*pp)->next; + if (*pp) *pp = r->next; + if (r->live_counted && rt->http_live > 0) rt->http_live--; + r->live_counted = false; + req_free(rt, r); +} + +static pnet_http_req *req_find(pnet_runtime *rt, int handle) { + for (pnet_http_req *r = rt->http_reqs; r; r = r->next) + if (r->handle == handle) return r; + return NULL; +} + +/** Terminal failure: one error event, transport released. */ +static void req_fail(pnet_runtime *rt, pnet_http_req *r, const char *code, const char *message, int cause) { + if (r->terminal) return; + r->terminal = true; + r->state = RQ_ENDED; + pnet_dial_cancel(rt, &r->dial); + pnet_conn_close(rt, &r->conn); + pnet_bq_free(rt, &r->rxq); + r->visible_bytes = 0; + r->dirty = false; + char cause_text[16] = {0}; + if (cause) snprintf(cause_text, sizeof cause_text, "io:%d", cause); + pnet_push_error_event(rt, &rt->http_queue, "h", r->handle, code, message, cause ? cause_text : NULL); + if (r->live_counted && rt->http_live > 0) rt->http_live--; + r->live_counted = false; +} + +/** Message end: `end` event; the queue stays readable until drained. */ +static void req_end(pnet_runtime *rt, pnet_http_req *r) { + if (r->terminal) return; + r->terminal = true; + r->state = RQ_ENDED; + pnet_conn_close(rt, &r->conn); + size_t len = 0; + char *json = pnet_event_json(rt, "end", "h", r->handle, NULL, 0, &len); + pnet_queue_push(rt, &rt->http_queue, r->handle, true, 0, json, len); + if (r->live_counted && rt->http_live > 0) rt->http_live--; + r->live_counted = false; +} + +static bool req_is_retirable(const pnet_http_req *r) { + return r->state == RQ_ENDED && r->rxq.bytes == 0; +} + +typedef struct sink_ctx { + pnet_runtime *rt; + pnet_http_req *r; + bool failed; + const char *fail_code; +} sink_ctx; + +static bool sink_into_queue(void *vctx, const uint8_t *data, size_t len) { + sink_ctx *ctx = vctx; + pnet_http_req *r = ctx->r; + if (r->body_total + len > r->max_body_bytes) { + ctx->failed = true; + ctx->fail_code = PNET_ERROR_RESPONSE_TOO_LARGE; + return false; + } + if (!pnet_bq_push(ctx->rt, &r->rxq, data, len, ctx->rt->cfg.io_chunk_bytes)) { + ctx->failed = true; + ctx->fail_code = PNET_ERROR_RESOURCE_LIMIT; + return false; + } + r->body_total += len; + r->dirty = true; + return true; +} + +/* ------------------------------------------------------------------------ */ +/* Request head */ +/* ------------------------------------------------------------------------ */ + +static bool build_request(pnet_runtime *rt, pnet_http_req *r) { + pnet_sb sb; + pnet_sb_init(&sb); + pnet_sb_append(rt, &sb, r->method, r->method_len); + pnet_sb_putc(rt, &sb, ' '); + pnet_sb_append(rt, &sb, r->url.path, r->url.path_len); + pnet_sb_puts(rt, &sb, " HTTP/1.1\r\nHost: "); + if (r->url.host_is_ipv6) pnet_sb_putc(rt, &sb, '['); + pnet_sb_puts(rt, &sb, r->url.host); + if (r->url.host_is_ipv6) pnet_sb_putc(rt, &sb, ']'); + if (r->url.port_explicit) pnet_sb_printf(rt, &sb, ":%u", (unsigned)r->url.port); + pnet_sb_puts(rt, &sb, "\r\n"); + pnet_sb_append(rt, &sb, r->user_headers.data ? r->user_headers.data : "", r->user_headers.len); + bool get_like = pnet_ieq_n(r->method, r->method_len, "GET") || pnet_ieq_n(r->method, r->method_len, "HEAD"); + if (r->body_len > 0 && !r->body_dropped) { + pnet_sb_printf(rt, &sb, "Content-Length: %zu\r\n", r->body_len); + } else if (!get_like && !pnet_ieq_n(r->method, r->method_len, "OPTIONS") && !pnet_ieq_n(r->method, r->method_len, "DELETE")) { + pnet_sb_puts(rt, &sb, "Content-Length: 0\r\n"); + } + pnet_sb_puts(rt, &sb, "Connection: close\r\n\r\n"); + bool ok = !sb.failed && pnet_conn_write(rt, &r->conn, sb.data, sb.len); + if (ok && r->body_len > 0 && !r->body_dropped) ok = pnet_conn_write(rt, &r->conn, r->body, r->body_len); + pnet_sb_free(rt, &sb); + return ok; +} + +/* ------------------------------------------------------------------------ */ +/* Response head processing */ +/* ------------------------------------------------------------------------ */ + +static bool push_headers_event(pnet_runtime *rt, pnet_http_req *r, const pnet_h1_head *head, int64_t length) { + pnet_sb sb; + pnet_sb_init(&sb); + pnet_sb_printf(rt, &sb, ",\"status\":%d,\"url\":", head->status); + pnet_sb tmp; + pnet_sb_init(&tmp); + pnet_url_write(rt, &tmp, &r->url); + pnet_sb_json_string(rt, &sb, pnet_sb_cstr(&tmp), tmp.len); + pnet_sb_free(rt, &tmp); + pnet_sb_puts(rt, &sb, ",\"headers\":{"); + /* Combine repeated names with ", " (Set-Cookie included: the SDK splits it + * back out with getSetCookie() only for values it can separate; keep the + * wire order otherwise). */ + bool first = true; + for (size_t i = 0; i < head->field_count; i++) { + const pnet_h1_field *f = &head->fields[i]; + bool seen = false; + for (size_t k = 0; k < i; k++) { + if (head->fields[k].name_len == f->name_len && memcmp(head->fields[k].name, f->name, f->name_len) == 0) { + seen = true; + break; + } + } + if (seen) continue; + if (!first) pnet_sb_putc(rt, &sb, ','); + first = false; + pnet_sb_json_string(rt, &sb, f->name, f->name_len); + pnet_sb_putc(rt, &sb, ':'); + bool set_cookie = f->name_len == 10 && memcmp(f->name, "set-cookie", 10) == 0; + if (set_cookie) { + /* Set-Cookie values never combine: deliver them as an array. */ + pnet_sb_putc(rt, &sb, '['); + bool firstv = true; + for (size_t k = i; k < head->field_count; k++) { + const pnet_h1_field *g = &head->fields[k]; + if (g->name_len != f->name_len || memcmp(g->name, f->name, f->name_len) != 0) continue; + if (!firstv) pnet_sb_putc(rt, &sb, ','); + firstv = false; + pnet_sb_json_string(rt, &sb, g->value, g->value_len); + } + pnet_sb_putc(rt, &sb, ']'); + continue; + } + /* Gather every value with this name. */ + pnet_sb value; + pnet_sb_init(&value); + bool firstv = true; + for (size_t k = i; k < head->field_count; k++) { + const pnet_h1_field *g = &head->fields[k]; + if (g->name_len != f->name_len || memcmp(g->name, f->name, f->name_len) != 0) continue; + if (!firstv) pnet_sb_puts(rt, &value, ", "); + firstv = false; + pnet_sb_append(rt, &value, g->value, g->value_len); + } + pnet_sb_json_string(rt, &sb, pnet_sb_cstr(&value), value.len); + pnet_sb_free(rt, &value); + } + pnet_sb_puts(rt, &sb, "},\"redirected\":"); + pnet_sb_puts(rt, &sb, r->redirected ? "true" : "false"); + if (length >= 0) pnet_sb_printf(rt, &sb, ",\"length\":%lld", (long long)length); + size_t len = 0; + char *json = sb.failed ? NULL : pnet_event_json(rt, "headers", "h", r->handle, sb.data, sb.len, &len); + size_t weight = sb.len; + pnet_sb_free(rt, &sb); + if (!json) return false; + return pnet_queue_push(rt, &rt->http_queue, r->handle, false, weight, json, len); +} + +/** Apply redirect policy; returns true when a new hop was started (or the + * request failed). false = treat as a normal response. */ +static bool maybe_redirect(pnet_runtime *rt, pnet_http_req *r, const pnet_h1_head *head) { + int st = head->status; + if (st != 301 && st != 302 && st != 303 && st != 307 && st != 308) return false; + const pnet_h1_field *loc = pnet_h1_find(head, "location"); + if (!loc) return false; + if (r->redirect_mode == 1) return false; /* manual: deliver as-is */ + if (r->redirect_mode == 2) { + req_fail(rt, r, PNET_ERROR_REDIRECT, "redirect refused by policy", 0); + return true; + } + if (r->redirects_left == 0) { + req_fail(rt, r, PNET_ERROR_REDIRECT, "too many redirects", 0); + return true; + } + pnet_url next; + if (!pnet_url_resolve(rt, &r->url, loc->value, loc->value_len, &next)) { + req_fail(rt, r, PNET_ERROR_REDIRECT, "invalid Location", 0); + return true; + } + pnet_proto proto = pnet_proto_from_scheme(next.scheme); + if (proto != PNET_PROTO_HTTP && proto != PNET_PROTO_HTTPS) { + pnet_url_free(rt, &next); + req_fail(rt, r, PNET_ERROR_REDIRECT, "redirect to a non-HTTP scheme", 0); + return true; + } + if (proto == PNET_PROTO_HTTPS && !rt->has_features_tls) { + pnet_url_free(rt, &next); + req_fail(rt, r, PNET_ERROR_UNSUPPORTED, "redirect to https without network.http.client.tls", 0); + return true; + } + if (!pnet_policy_allows_connect(&rt->policy, proto, next.host, next.port)) { + pnet_url_free(rt, &next); + req_fail(rt, r, PNET_ERROR_PERMISSION_DENIED, "redirect target is not an allowed endpoint", 0); + return true; + } + /* Method / body rewriting on redirect: 303 (non-HEAD) and 301/302 POST + * become GET, dropping the request body. */ + bool to_get = false; + if (st == 303 && !pnet_ieq_n(r->method, r->method_len, "HEAD")) to_get = true; + if ((st == 301 || st == 302) && pnet_ieq_n(r->method, r->method_len, "POST")) to_get = true; + if (to_get) { + pnet_free(rt, r->method, r->method_len + 1); + r->method = pnet_strdup_n(rt, "GET", 3); + r->method_len = 3; + if (!r->method) { + pnet_url_free(rt, &next); + req_fail(rt, r, PNET_ERROR_RESOURCE_LIMIT, "out of memory", 0); + return true; + } + if (r->body) { + pnet_free(rt, r->body, r->body_len); + r->body = NULL; + } + r->body_len = 0; + r->body_dropped = true; + } + /* Header stripping: cross-origin drops credentials; GET conversion drops + * content headers. Rebuild the user header block line by line. */ + bool cross_origin = !pnet_url_same_origin(&r->url, &next); + if (cross_origin || to_get) { + pnet_sb kept; + pnet_sb_init(&kept); + const char *lines = r->user_headers.data ? r->user_headers.data : ""; + size_t total = r->user_headers.len; + size_t i = 0; + while (i < total) { + size_t j = i; + while (j + 1 < total && !(lines[j] == '\r' && lines[j + 1] == '\n')) j++; + size_t line_len = (j + 1 < total) ? j - i : total - i; + const char *line = lines + i; + const char *colon = memchr(line, ':', line_len); + size_t nl = colon ? (size_t)(colon - line) : line_len; + bool drop = false; + if (cross_origin && (pnet_ieq_n(line, nl, "authorization") || pnet_ieq_n(line, nl, "proxy-authorization") || + pnet_ieq_n(line, nl, "cookie"))) + drop = true; + if (to_get && (pnet_ieq_n(line, nl, "content-type") || pnet_ieq_n(line, nl, "content-encoding") || + pnet_ieq_n(line, nl, "content-language") || pnet_ieq_n(line, nl, "content-location"))) + drop = true; + if (!drop) pnet_sb_append(rt, &kept, line, line_len + 2 <= total - i ? line_len + 2 : line_len); + i = j + 2; + } + pnet_sb_free(rt, &r->user_headers); + r->user_headers = kept; + } + pnet_url_free(rt, &r->url); + r->url = next; + r->redirects_left--; + r->redirected = true; + /* Fresh transport for the next hop. */ + pnet_conn_close(rt, &r->conn); + pnet_conn_init(&r->conn); + r->rx_len = 0; + r->insecure_tls = r->insecure_tls; /* TLS policy carries over across the hop */ + r->state = RQ_DIALING; + r->phase_deadline = rt->now + r->connect_ms; + bool secure = strcmp(r->url.scheme, "https") == 0; + if (!pnet_dial_start(rt, &r->dial, &r->conn, r->url.host, r->url.port, secure, r->url.host, !r->insecure_tls)) { + req_fail(rt, r, r->dial.error_code ? r->dial.error_code : PNET_ERROR_CONNECT, + r->dial.error_message ? r->dial.error_message : "redirect connect failed", r->dial.cause); + } + return true; +} + +/** Handle a complete response head. Returns false when the request reached a + * terminal state (failed or redirected) and the caller must stop. */ +static bool on_head(pnet_runtime *rt, pnet_http_req *r, pnet_h1_head *head) { + if (!pnet_h1_validate_framing(head)) { + req_fail(rt, r, PNET_ERROR_PROTOCOL, "invalid response framing", 0); + return false; + } + if (head->status >= 100 && head->status < 200) { + /* Interim response: skip it and keep parsing (101 cannot happen: we never + * request an upgrade; treat it as a protocol error). */ + if (head->status == 101) { + req_fail(rt, r, PNET_ERROR_PROTOCOL, "unexpected 101 response", 0); + return false; + } + size_t rest = r->rx_len - head->head_len; + memmove(r->rx, r->rx + head->head_len, rest); + r->rx_len = rest; + return true; /* caller re-parses */ + } + if (maybe_redirect(rt, r, head)) return false; + /* Body framing (RFC 9112 §6.3). */ + bool head_only = pnet_ieq_n(r->method, r->method_len, "HEAD") || head->status == 204 || head->status == 304; + int64_t length = -1; + pnet_h1_body_mode mode; + if (head_only) mode = PNET_H1_BODY_NONE; + else if (head->chunked) mode = PNET_H1_BODY_CHUNKED; + else if (head->content_length >= 0) { + mode = PNET_H1_BODY_LENGTH; + length = head->content_length; + } else mode = PNET_H1_BODY_CLOSE; + if (head_only && head->content_length >= 0) length = head->content_length; + if (mode == PNET_H1_BODY_LENGTH && (uint64_t)length > r->max_body_bytes) { + req_fail(rt, r, PNET_ERROR_RESPONSE_TOO_LARGE, "response exceeds maxBodyBytes", 0); + return false; + } + if (!push_headers_event(rt, r, head, length)) { + req_fail(rt, r, PNET_ERROR_RESOURCE_LIMIT, "out of memory", 0); + return false; + } + r->head_pushed = true; + r->head_only = head_only; + pnet_h1_body_init(&r->decoder, mode, (uint64_t)(length < 0 ? 0 : length)); + r->state = RQ_BODY; + r->phase_deadline = rt->now + r->idle_ms; + /* Feed the bytes that followed the head. */ + size_t rest = r->rx_len - head->head_len; + if (rest > 0 && !r->decoder.done) { + sink_ctx ctx = {rt, r, false, NULL}; + size_t consumed = pnet_h1_body_feed(&r->decoder, r->rx + head->head_len, rest, sink_into_queue, &ctx); + (void)consumed; + if (ctx.failed) { + req_fail(rt, r, ctx.fail_code, "response body limit", 0); + return false; + } + if (r->decoder.error) { + req_fail(rt, r, PNET_ERROR_PROTOCOL, "invalid chunked body", 0); + return false; + } + } + r->rx_len = 0; + if (r->decoder.done) req_end(rt, r); + return r->state == RQ_BODY; +} + +/* ------------------------------------------------------------------------ */ +/* Service */ +/* ------------------------------------------------------------------------ */ + +static void req_service_io(pnet_runtime *rt, pnet_http_req *r) { + if (!pnet_conn_flush(rt, &r->conn)) { + req_fail(rt, r, PNET_ERROR_CLOSED, "connection lost while sending", r->conn.last_error); + return; + } + uint8_t scratch[2048]; + for (int rounds = 0; rounds < 8; rounds++) { + if (r->state == RQ_SENDING) { + /* Head phase: accumulate into rx up to the header limit. */ + size_t max_head = rt->cfg.http_max_header_bytes + 512; + if (r->rx_len >= max_head) { + req_fail(rt, r, PNET_ERROR_PROTOCOL, "response head too large", 0); + return; + } + size_t want = sizeof scratch; + if (want > max_head - r->rx_len) want = max_head - r->rx_len; + int n = pnet_conn_read(rt, &r->conn, scratch, want); + if (n == PNET_IO_AGAIN) return; + if (n == PNET_IO_EOF) { + req_fail(rt, r, PNET_ERROR_CLOSED, "connection closed before response head", 0); + return; + } + if (n < 0) { + req_fail(rt, r, PNET_ERROR_CLOSED, "read failed", n); + return; + } + if (r->rx_len + (size_t)n > r->rx_cap) { + size_t cap = r->rx_cap ? r->rx_cap : 1024; + while (cap < r->rx_len + (size_t)n) cap *= 2; + if (cap > max_head + 16) cap = max_head + 16; + uint8_t *next = pnet_alloc(rt, cap); + if (!next) { + req_fail(rt, r, PNET_ERROR_RESOURCE_LIMIT, "out of memory", 0); + return; + } + if (r->rx) { + memcpy(next, r->rx, r->rx_len); + pnet_free(rt, r->rx, r->rx_cap); + } + r->rx = next; + r->rx_cap = cap; + } + memcpy(r->rx + r->rx_len, scratch, (size_t)n); + r->rx_len += (size_t)n; + for (;;) { + pnet_h1_head head; + int rc = pnet_h1_parse_head(r->rx, r->rx_len, false, rt->cfg.http_max_header_bytes, rt->cfg.http_max_headers, + rt->cfg.httpd_max_target_bytes, &head); + if (rc == PNET_H1_INCOMPLETE) break; + if (rc != PNET_H1_OK) { + req_fail(rt, r, PNET_ERROR_PROTOCOL, rc == PNET_H1_TOO_LARGE ? "response head too large" : "malformed response head", 0); + return; + } + int before = r->state; + bool cont = on_head(rt, r, &head); + if (!cont) return; + if (r->state == RQ_BODY || before != RQ_SENDING) break; + /* interim response consumed; loop to parse the next head */ + } + continue; + } + if (r->state == RQ_BODY) { + size_t room = r->queue_bytes > r->rxq.bytes ? r->queue_bytes - r->rxq.bytes : 0; + if (room == 0) { + r->conn.read_wanted = false; + pnet_conn_update_interest(rt, &r->conn); + return; + } + r->conn.read_wanted = true; + size_t want = sizeof scratch < room ? sizeof scratch : room; + int n = pnet_conn_read(rt, &r->conn, scratch, want); + if (n == PNET_IO_AGAIN) { + pnet_conn_update_interest(rt, &r->conn); + return; + } + if (n == PNET_IO_EOF) { + if (r->decoder.mode == PNET_H1_BODY_CLOSE) { + req_end(rt, r); + } else { + req_fail(rt, r, PNET_ERROR_CLOSED, "connection closed before the body ended", 0); + } + return; + } + if (n < 0) { + req_fail(rt, r, PNET_ERROR_CLOSED, "read failed", n); + return; + } + r->phase_deadline = rt->now + r->idle_ms; + sink_ctx ctx = {rt, r, false, NULL}; + pnet_h1_body_feed(&r->decoder, scratch, (size_t)n, sink_into_queue, &ctx); + if (ctx.failed) { + req_fail(rt, r, ctx.fail_code, "response body limit", 0); + return; + } + if (r->decoder.error) { + req_fail(rt, r, PNET_ERROR_PROTOCOL, "invalid chunked body", 0); + return; + } + if (r->decoder.done) { + req_end(rt, r); + return; + } + continue; + } + return; + } +} + +static void req_service(pnet_runtime *rt, pnet_http_req *r) { + if (r->state == RQ_ENDED) return; + if (rt->now >= r->total_deadline) { + req_fail(rt, r, PNET_ERROR_TIMEOUT, "total timeout", 0); + return; + } + if (r->state == RQ_DIALING) { + if (rt->now >= r->phase_deadline) { + req_fail(rt, r, PNET_ERROR_TIMEOUT, "connect timeout", 0); + return; + } + int st = pnet_dial_step(rt, &r->dial, &r->conn); + if (st == PNET_DIAL_FAILED) { + req_fail(rt, r, r->dial.error_code ? r->dial.error_code : PNET_ERROR_CONNECT, "connect failed", r->dial.cause); + return; + } + if (st != PNET_DIAL_OPEN) return; + if (!build_request(rt, r)) { + req_fail(rt, r, PNET_ERROR_RESOURCE_LIMIT, "out of memory", 0); + return; + } + r->state = RQ_SENDING; + r->phase_deadline = rt->now + r->headers_ms; + } + if (r->state == RQ_SENDING && rt->now >= r->phase_deadline) { + req_fail(rt, r, PNET_ERROR_TIMEOUT, "response headers timeout", 0); + return; + } + if (r->state == RQ_BODY && rt->now >= r->phase_deadline) { + req_fail(rt, r, PNET_ERROR_TIMEOUT, "body idle timeout", 0); + return; + } + req_service_io(rt, r); +} + +void pnet_http_service(pnet_runtime *rt) { + pnet_http_req *r = rt->http_reqs; + while (r) { + pnet_http_req *next = r->next; + req_service(rt, r); + if (req_is_retirable(r) && !r->dirty) req_unlink(rt, r); + r = next; + } +} + +uint64_t pnet_http_next_deadline(pnet_runtime *rt) { + uint64_t d = 0; + for (pnet_http_req *r = rt->http_reqs; r; r = r->next) { + if (r->state == RQ_ENDED) continue; + d = pnet_min_deadline(d, r->total_deadline); + d = pnet_min_deadline(d, r->phase_deadline); + } + return d; +} + +bool pnet_http_has_output(pnet_runtime *rt) { + for (pnet_http_req *r = rt->http_reqs; r; r = r->next) + if (r->conn.state == PNET_CONN_OPEN && r->conn.tx.bytes > 0) return true; + return false; +} + +void pnet_http_freeze(pnet_runtime *rt) { + for (pnet_http_req *r = rt->http_reqs; r; r = r->next) { + if (r->dirty && r->head_pushed) { + r->dirty = false; + r->visible_bytes = r->rxq.bytes; + pnet_queue_push_readable(rt, &rt->http_queue, r->handle, "h", r->visible_bytes); + } + } +} + +void pnet_http_quiesce(pnet_runtime *rt) { + for (pnet_http_req *r = rt->http_reqs; r; r = r->next) { + if (!r->terminal) req_fail(rt, r, PNET_ERROR_CANCELLED, "runtime closing", 0); + } +} + +void pnet_http_init(pnet_runtime *rt) { + pnet_sb sb; + pnet_sb_init(&sb); + const pnet_runtime_config *c = &rt->cfg; + pnet_sb_printf(rt, &sb, + "{\"specMajor\":%d,\"specMinor\":%d,\"maxInflight\":%u,\"maxTlsInflight\":%u," + "\"maxRequestBytes\":%zu,\"defaultQueueBytes\":%zu,\"maxQueueBytes\":%zu," + "\"defaultAggregateBytes\":%zu,\"maxAggregateBytes\":%zu,\"maxEventsPerTick\":%u," + "\"maxTickBytes\":%zu,\"maxHeaders\":%u,\"maxHeaderBytes\":%zu,\"defaultTimeoutMs\":%u," + "\"maxTimeoutMs\":%u,\"maxRedirects\":%u,\"tlsMinVersion\":\"%s\",\"features\":[%s]}", + PNET_SPEC_MAJOR, PNET_SPEC_MINOR, c->http_max_inflight, rt->has_features_tls ? c->http_max_inflight : 0, + c->http_max_request_bytes, c->http_default_queue_bytes, c->http_max_queue_bytes, + c->http_default_aggregate_bytes, c->http_max_aggregate_bytes, c->http_max_events_per_tick, + c->http_max_tick_bytes, c->http_max_headers, c->http_max_header_bytes, c->http_default_timeout_ms, + c->http_max_timeout_ms, c->http_max_redirects, PNET_TLS_MIN_VERSION, rt->has_features_tls ? "\"tls\"" : ""); + rt->http_limits_json = sb.failed ? NULL : pnet_strdup_n(rt, sb.data, sb.len); + pnet_sb_free(rt, &sb); +} + +void pnet_http_shutdown(pnet_runtime *rt) { + while (rt->http_reqs) { + pnet_http_req *r = rt->http_reqs; + rt->http_reqs = r->next; + req_free(rt, r); + } + if (rt->http_limits_json) pnet_free_str(rt, rt->http_limits_json); + rt->http_limits_json = NULL; + rt->http_live = 0; +} + +/* ------------------------------------------------------------------------ */ +/* Guest ops */ +/* ------------------------------------------------------------------------ */ + +static int refuse(pnet_runtime *rt, const char *code, const char *message) { + pnet_set_last_error(rt, &rt->http_last_error, code, message); + return -1; +} + +static bool read_timeout(pnet_runtime *rt, const pnet_jdoc *doc, int obj, const char *key, uint32_t fallback, uint32_t *out) { + int node = pnet_json_get(doc, obj, key); + if (node < 0) { + *out = fallback; + return true; + } + int64_t v; + if (!pnet_json_i64(doc, node, &v) || v < 1 || v > (int64_t)rt->cfg.http_max_timeout_ms) return false; + *out = (uint32_t)v; + return true; +} + +int pnet_http_start(pnet_runtime *rt, const char *meta_json, const uint8_t *body, size_t body_len) { + if (rt->quiesced) return refuse(rt, PNET_ERROR_CLOSED, "runtime is closing"); + if (rt->http_live >= rt->cfg.http_max_inflight) return refuse(rt, PNET_ERROR_RESOURCE_LIMIT, "too many requests in flight"); + if (body_len > rt->cfg.http_max_request_bytes) return refuse(rt, PNET_ERROR_RESOURCE_LIMIT, "request body too large"); + if (!meta_json) return refuse(rt, PNET_ERROR_INVALID_REQUEST, "missing metadata"); + size_t meta_len = strlen(meta_json); + int cap = 320; + pnet_jnode *nodes = pnet_alloc(rt, (size_t)cap * sizeof(pnet_jnode)); + if (!nodes) return refuse(rt, PNET_ERROR_RESOURCE_LIMIT, "out of memory"); + pnet_jdoc doc; + int root = pnet_json_parse(&doc, nodes, cap, meta_json, meta_len); + int result = -1; + pnet_http_req *r = NULL; + char buf[520]; + size_t blen; + if (root < 0 || pnet_json_type(&doc, root) != PNET_J_OBJECT) { + refuse(rt, PNET_ERROR_INVALID_REQUEST, "malformed request metadata"); + goto out; + } + r = pnet_zalloc(rt, sizeof *r); + if (!r) { + refuse(rt, PNET_ERROR_RESOURCE_LIMIT, "out of memory"); + goto out; + } + pnet_conn_init(&r->conn); + pnet_bq_init(&r->rxq); + pnet_sb_init(&r->user_headers); + /* url */ + { + int node = pnet_json_get(&doc, root, "url"); + char *url = pnet_json_string_dup(rt, &doc, node, &blen); + if (!url) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "url required"); goto out; } + bool ok = pnet_url_parse(rt, url, blen, &r->url); + pnet_free_str(rt, url); + if (!ok) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid url"); goto out; } + pnet_proto proto = pnet_proto_from_scheme(r->url.scheme); + if (proto != PNET_PROTO_HTTP && proto != PNET_PROTO_HTTPS) { + refuse(rt, PNET_ERROR_INVALID_REQUEST, "url must be http: or https:"); + goto out; + } + if (proto == PNET_PROTO_HTTPS && !rt->has_features_tls) { + refuse(rt, PNET_ERROR_UNSUPPORTED, "this host does not provide network.http.client.tls"); + goto out; + } + if (pnet_proto_is_plaintext(proto) && !rt->policy.insecure_transport) { + refuse(rt, PNET_ERROR_PERMISSION_DENIED, "insecureTransport is not enabled"); + goto out; + } + if (!pnet_policy_allows_connect(&rt->policy, proto, r->url.host, r->url.port)) { + refuse(rt, PNET_ERROR_PERMISSION_DENIED, "endpoint is not an allowed connect rule"); + goto out; + } + } + /* method */ + { + int node = pnet_json_get(&doc, root, "method"); + if (!pnet_json_string(&doc, node, buf, sizeof buf, &blen) || !pnet_is_token(buf, blen)) { + refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid method"); + goto out; + } + static const char *const forbidden[] = PNET_METHODS_FORBIDDEN; + for (size_t i = 0; i < PNET_METHODS_FORBIDDEN_COUNT; i++) { + if (pnet_ieq_n(buf, blen, forbidden[i])) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "method not allowed"); goto out; } + } + if (pnet_ieq_n(buf, blen, "TRACK")) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "method not allowed"); goto out; } + r->method = pnet_strdup_n(rt, buf, blen); + r->method_len = blen; + if (!r->method) { refuse(rt, PNET_ERROR_RESOURCE_LIMIT, "out of memory"); goto out; } + if ((pnet_ieq_n(buf, blen, "GET") || pnet_ieq_n(buf, blen, "HEAD")) && body_len > 0) { + refuse(rt, PNET_ERROR_INVALID_REQUEST, "GET/HEAD cannot carry a body"); + goto out; + } + } + /* headers */ + { + int node = pnet_json_get(&doc, root, "headers"); + uint32_t count = 0; + size_t bytes = 0; + if (node >= 0) { + if (pnet_json_type(&doc, node) != PNET_J_OBJECT) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "headers must be an object"); goto out; } + for (int k = pnet_json_first(&doc, node); k >= 0; k = pnet_json_next(&doc, k)) { + char name[128]; + size_t nlen; + if (!pnet_json_string(&doc, k, name, sizeof name, &nlen) || !pnet_is_token(name, nlen)) { + refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid header name"); + goto out; + } + pnet_lower(name, nlen); + static const char *const owned[] = {"host", "connection", "content-length", "transfer-encoding", "trailer", + "te", "upgrade", "keep-alive", "expect", "proxy-connection"}; + bool skip = false; + for (size_t i = 0; i < sizeof owned / sizeof owned[0]; i++) + if (strcmp(name, owned[i]) == 0) skip = true; + int vnode = doc.nodes[k].first_child; + size_t vlen; + char *value = pnet_json_string_dup(rt, &doc, vnode, &vlen); + if (!value) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid header value"); goto out; } + bool bad = false; + for (size_t i = 0; i < vlen; i++) { + unsigned char c = (unsigned char)value[i]; + if ((c < 0x20 && c != '\t') || c == 0x7f) bad = true; + } + if (bad) { pnet_free_str(rt, value); refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid header value"); goto out; } + if (!skip) { + count++; + bytes += nlen + vlen + 4; + if (count > rt->cfg.http_max_headers || bytes > rt->cfg.http_max_header_bytes) { + pnet_free_str(rt, value); + refuse(rt, PNET_ERROR_RESOURCE_LIMIT, "request headers exceed limits"); + goto out; + } + pnet_sb_append(rt, &r->user_headers, name, nlen); + pnet_sb_puts(rt, &r->user_headers, ": "); + pnet_sb_append(rt, &r->user_headers, value, vlen); + pnet_sb_puts(rt, &r->user_headers, "\r\n"); + } + pnet_free_str(rt, value); + } + if (r->user_headers.failed) { refuse(rt, PNET_ERROR_RESOURCE_LIMIT, "out of memory"); goto out; } + } + } + /* queueBytes / maxBodyBytes */ + { + int64_t v; + int node = pnet_json_get(&doc, root, "queueBytes"); + r->queue_bytes = rt->cfg.http_default_queue_bytes; + if (node >= 0) { + if (!pnet_json_i64(&doc, node, &v) || v < 1 || (uint64_t)v > rt->cfg.http_max_queue_bytes) { + refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid queueBytes"); + goto out; + } + r->queue_bytes = (size_t)v; + } + node = pnet_json_get(&doc, root, "maxBodyBytes"); + r->max_body_bytes = SIZE_MAX; + if (node >= 0) { + if (!pnet_json_i64(&doc, node, &v) || v < 0) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid maxBodyBytes"); goto out; } + r->max_body_bytes = (size_t)v; + } + } + /* timeouts */ + { + int t = pnet_json_get(&doc, root, "timeouts"); + uint32_t total_ms; + if (t >= 0 && pnet_json_type(&doc, t) != PNET_J_OBJECT) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid timeouts"); goto out; } + if (!read_timeout(rt, &doc, t, "connectMs", rt->cfg.http_default_timeout_ms, &r->connect_ms) || + !read_timeout(rt, &doc, t, "headersMs", rt->cfg.http_default_timeout_ms, &r->headers_ms) || + !read_timeout(rt, &doc, t, "idleMs", rt->cfg.http_default_timeout_ms, &r->idle_ms) || + !read_timeout(rt, &doc, t, "totalMs", rt->cfg.http_max_timeout_ms, &total_ms)) { + refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid timeouts"); + goto out; + } + r->started_at = pnet_now(rt); + rt->now = r->started_at; + r->total_deadline = r->started_at + total_ms; + r->phase_deadline = r->started_at + r->connect_ms; + } + /* redirect */ + { + int node = pnet_json_get(&doc, root, "redirect"); + r->redirect_mode = 0; + if (node >= 0) { + if (!pnet_json_string(&doc, node, buf, sizeof buf, &blen)) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid redirect"); goto out; } + bool found = false; + for (int i = 0; i < 3; i++) + if (strcmp(buf, REDIRECT_MODES[i]) == 0) { r->redirect_mode = (uint8_t)i; found = true; } + if (!found) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid redirect"); goto out; } + } + int64_t v; + node = pnet_json_get(&doc, root, "maxRedirects"); + r->redirects_left = rt->cfg.http_max_redirects; + if (node >= 0) { + if (!pnet_json_i64(&doc, node, &v) || v < 0 || v > (int64_t)rt->cfg.http_max_redirects) { + refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid maxRedirects"); + goto out; + } + r->redirects_left = (uint32_t)v; + } + } + /* tls */ + { + int tls = pnet_json_get(&doc, root, "tls"); + if (tls >= 0) { + int v = pnet_json_get(&doc, tls, "verification"); + if (v >= 0) { + if (!pnet_json_string(&doc, v, buf, sizeof buf, &blen)) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid tls.verification"); goto out; } + if (strcmp(buf, "development-insecure") == 0) { + if (!rt->cfg.development_build || !rt->policy.allow_invalid_tls_for_development) { + refuse(rt, PNET_ERROR_UNSUPPORTED, "development-insecure TLS is not enabled"); + goto out; + } + r->insecure_tls = true; + } else if (strcmp(buf, "full") != 0) { + refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid tls.verification"); + goto out; + } + } + } + } + /* body copy */ + if (body_len > 0) { + r->body = pnet_alloc(rt, body_len); + if (!r->body) { refuse(rt, PNET_ERROR_RESOURCE_LIMIT, "out of memory"); goto out; } + memcpy(r->body, body, body_len); + r->body_len = body_len; + } + /* Handle + dial */ + r->handle = rt->http_next_handle++; + if (rt->http_next_handle <= 0) rt->http_next_handle = 1; + r->state = RQ_DIALING; + r->live_counted = true; + rt->http_live++; + r->next = rt->http_reqs; + rt->http_reqs = r; + result = r->handle; + { + bool secure = strcmp(r->url.scheme, "https") == 0; + if (!pnet_dial_start(rt, &r->dial, &r->conn, r->url.host, r->url.port, secure, r->url.host, !r->insecure_tls)) { + /* Asynchronous failure: the terminal error arrives with the next tick. */ + req_fail(rt, r, r->dial.error_code ? r->dial.error_code : PNET_ERROR_CONNECT, + r->dial.error_message ? r->dial.error_message : "connect failed", r->dial.cause); + } + } + r = NULL; +out: + if (r) req_free(rt, r); + pnet_free(rt, nodes, (size_t)cap * sizeof(pnet_jnode)); + return result; +} + +void pnet_http_cancel(pnet_runtime *rt, int handle) { + pnet_http_req *r = req_find(rt, handle); + if (!r) return; + if (r->terminal) { + /* Ended with unread bytes: release them, no further event. */ + if (req_is_retirable(r) || r->state == RQ_ENDED) req_unlink(rt, r); + return; + } + r->cancelled = true; + req_fail(rt, r, PNET_ERROR_CANCELLED, "cancelled", 0); +} + +int pnet_http_read_into(pnet_runtime *rt, int handle, uint8_t *dst, size_t len) { + pnet_http_req *r = req_find(rt, handle); + if (!r || !r->head_pushed) return -1; + if (r->terminal && r->rxq.bytes == 0) return -1; + size_t want = len < r->visible_bytes ? len : r->visible_bytes; + size_t got = pnet_bq_read(rt, &r->rxq, dst, want); + r->visible_bytes -= got; + if (r->state == RQ_BODY && !r->conn.read_wanted && r->rxq.bytes < r->queue_bytes) { + r->conn.read_wanted = true; + pnet_conn_update_interest(rt, &r->conn); + } + if (req_is_retirable(r) && !r->dirty) req_unlink(rt, r); + return (int)got; +} + +const char *pnet_http_poll(pnet_runtime *rt, size_t *len) { + return pnet_queue_poll(rt, &rt->http_queue, len); +} + +const char *pnet_http_last_error(pnet_runtime *rt) { + return pnet_sb_cstr(&rt->http_last_error); +} + +const char *pnet_http_limits(pnet_runtime *rt) { + return rt->http_limits_json ? rt->http_limits_json : "{}"; +} diff --git a/engine/net/src/pnet_http_server.c b/engine/net/src/pnet_http_server.c new file mode 100644 index 00000000..d2ba7141 --- /dev/null +++ b/engine/net/src/pnet_http_server.c @@ -0,0 +1,1218 @@ +/* HTTP Server core (`globalThis.httpd`, contracts/spec/httpd.ts v2). + * + * The core owns listeners, accepted connections, request parsing, response + * encoding, keep-alive and every limit; the guest sees a server handle and + * request ids only. Pipelining is disabled: the next request on a connection + * is parsed only after the previous response completed. Events (`listening`, + * `request`, `readable`, `end`, `drain`, `aborted`, `error`, `closed`) go to + * the httpd queue and reach the guest at begin_tick(); request bodies cross + * only through pnet_httpd_read_into; response bytes enter through + * respond/write/endBody and are written by the network task. + */ +#include + +#include "pnet_internal.h" + +typedef enum server_state { + SV_BINDING = 0, + SV_LISTENING, + SV_STOPPING, + SV_CLOSED, +} server_state; + +typedef enum conn_phase { + CP_HEAD = 0, /* reading the request head */ + CP_REQUEST, /* request delivered: body streaming and/or response pending */ + CP_DRAIN, /* response complete, draining the unread request body */ + CP_CLOSING, /* flush then close */ +} conn_phase; + +typedef struct pnet_httpd_conn { + struct pnet_httpd_conn *next; + struct pnet_httpd_server *server; + pnet_conn conn; + uint8_t phase; + int req; /* current request id, 0 when none */ + bool req_delivered; + bool req_terminal; /* aborted or completed */ + bool head_method; /* HEAD: discard the response body */ + bool keep_alive; + bool close_after; /* close once the response is flushed */ + bool responded; + bool response_complete; + bool response_chunked; + int64_t response_remaining; /* known-length streamed responses */ + bool drain_armed; + uint8_t *rx; + size_t rx_len; + size_t rx_cap; + pnet_h1_body decoder; + bool body_end_pushed; + pnet_bq rxq; + size_t visible_bytes; + bool dirty; + size_t body_total; + uint64_t deadline; + uint8_t deadline_kind; /* 0 header, 1 body idle, 2 handler, 3 keep-alive, 4 close */ +} pnet_httpd_conn; + +typedef struct pnet_httpd_server { + struct pnet_httpd_server *next; + int handle; + uint8_t state; + bool graceful; + uint64_t stop_deadline; + pnet_sock listener; + pnet_addr bind_addr; + pnet_addr bound; + int backlog; + bool accepting; + uint32_t max_connections, max_inflight; + size_t max_header_bytes, max_body_bytes, request_queue_bytes, send_queue_bytes; + uint32_t header_ms, body_idle_ms, handler_ms, keep_alive_ms, close_ms; + pnet_httpd_conn *conns; + uint32_t conn_count; + uint32_t inflight; + bool live_counted; +} pnet_httpd_server; + +/* ------------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------------ */ + +static const char *reason_phrase(int status) { + switch (status) { + case 100: return "Continue"; + case 200: return "OK"; + case 201: return "Created"; + case 202: return "Accepted"; + case 204: return "No Content"; + case 206: return "Partial Content"; + case 301: return "Moved Permanently"; + case 302: return "Found"; + case 303: return "See Other"; + case 304: return "Not Modified"; + case 307: return "Temporary Redirect"; + case 308: return "Permanent Redirect"; + case 400: return "Bad Request"; + case 401: return "Unauthorized"; + case 403: return "Forbidden"; + case 404: return "Not Found"; + case 405: return "Method Not Allowed"; + case 408: return "Request Timeout"; + case 409: return "Conflict"; + case 413: return "Content Too Large"; + case 414: return "URI Too Long"; + case 415: return "Unsupported Media Type"; + case 429: return "Too Many Requests"; + case 431: return "Request Header Fields Too Large"; + case 500: return "Internal Server Error"; + case 501: return "Not Implemented"; + case 502: return "Bad Gateway"; + case 503: return "Service Unavailable"; + case 504: return "Gateway Timeout"; + default: return ""; + } +} + +static void conn_free(pnet_runtime *rt, pnet_httpd_conn *c) { + pnet_conn_close(rt, &c->conn); + if (c->rx) pnet_free(rt, c->rx, c->rx_cap); + pnet_bq_free(rt, &c->rxq); + pnet_free(rt, c, sizeof *c); +} + +static void server_unlink_conn(pnet_runtime *rt, pnet_httpd_conn *c) { + pnet_httpd_server *s = c->server; + pnet_httpd_conn **pp = &s->conns; + while (*pp && *pp != c) pp = &(*pp)->next; + if (*pp) *pp = c->next; + if (s->conn_count > 0) s->conn_count--; + conn_free(rt, c); +} + +static pnet_httpd_server *server_find(pnet_runtime *rt, int handle) { + for (pnet_httpd_server *s = rt->httpd_servers; s; s = s->next) + if (s->handle == handle) return s; + return NULL; +} + +static pnet_httpd_conn *req_find(pnet_runtime *rt, int req, pnet_httpd_server **out_server) { + if (req <= 0) return NULL; + for (pnet_httpd_server *s = rt->httpd_servers; s; s = s->next) { + for (pnet_httpd_conn *c = s->conns; c; c = c->next) { + if (c->req == req && c->req_delivered && !c->req_terminal) { + if (out_server) *out_server = s; + return c; + } + } + } + return NULL; +} + +static void push_req_event(pnet_runtime *rt, const char *t, int req, const char *tail, size_t tail_len, bool terminal, + size_t weight) { + size_t len = 0; + char *json = pnet_event_json(rt, t, "req", req, tail, tail_len, &len); + pnet_queue_push(rt, &rt->httpd_queue, req, terminal, weight, json, len); +} + +/* Server-level events share the httpd queue with request events; the queue + * orders readable insertions by its numeric key, so server keys are negated + * to keep them apart from request ids. */ +static void push_server_event(pnet_runtime *rt, const char *t, int handle, const char *tail, size_t tail_len, + bool terminal) { + size_t len = 0; + char *json = pnet_event_json(rt, t, "h", handle, tail, tail_len, &len); + pnet_queue_push(rt, &rt->httpd_queue, -handle, terminal, 0, json, len); +} + +static void push_server_error(pnet_runtime *rt, int handle, const char *code, const char *message, const char *cause) { + pnet_sb sb; + pnet_sb_init(&sb); + pnet_sb_puts(rt, &sb, ",\"code\":"); + pnet_sb_json_string(rt, &sb, code, strlen(code)); + pnet_sb_puts(rt, &sb, ",\"message\":"); + pnet_sb_json_string(rt, &sb, message, strlen(message)); + if (cause) { + pnet_sb_puts(rt, &sb, ",\"causeCode\":"); + pnet_sb_json_string(rt, &sb, cause, strlen(cause)); + } + if (!sb.failed) push_server_event(rt, "error", handle, sb.data, sb.len, false); + pnet_sb_free(rt, &sb); +} + +/** Terminate the current request with `aborted{code}` (no response will be + * sent by the app any more). */ +static void req_abort(pnet_runtime *rt, pnet_httpd_conn *c, const char *code) { + if (!c->req_delivered || c->req_terminal) return; + c->req_terminal = true; + if (c->server->inflight > 0) c->server->inflight--; + pnet_bq_free(rt, &c->rxq); + c->visible_bytes = 0; + c->dirty = false; + char tail[64]; + int n = snprintf(tail, sizeof tail, ",\"code\":\"%s\"", code); + push_req_event(rt, "aborted", c->req, tail, (size_t)n, true, 0); +} + +/** Queue a canned response and close after flushing. */ +static void conn_reject(pnet_runtime *rt, pnet_httpd_conn *c, int status) { + char head[160]; + int n = snprintf(head, sizeof head, "HTTP/1.1 %d %s\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", status, + reason_phrase(status)); + pnet_conn_write(rt, &c->conn, head, (size_t)n); + c->close_after = true; + c->phase = CP_CLOSING; + c->conn.read_wanted = false; + pnet_conn_update_interest(rt, &c->conn); + c->deadline = rt->now + c->server->close_ms; + c->deadline_kind = 4; +} + +static void conn_start_head(pnet_runtime *rt, pnet_httpd_conn *c) { + c->phase = CP_HEAD; + c->req = 0; + c->req_delivered = false; + c->req_terminal = false; + c->head_method = false; + c->responded = false; + c->response_complete = false; + c->response_chunked = false; + c->response_remaining = -1; + c->drain_armed = false; + c->body_end_pushed = false; + c->body_total = 0; + c->visible_bytes = 0; + c->dirty = false; + pnet_bq_free(rt, &c->rxq); + c->conn.read_wanted = true; + pnet_conn_update_interest(rt, &c->conn); + /* A keep-alive connection waits keepAliveMs for the next head; a fresh + * connection gets headerMs. */ + c->deadline = rt->now + (c->keep_alive ? c->server->keep_alive_ms : c->server->header_ms); + c->deadline_kind = c->keep_alive ? 3 : 0; +} + +/* ------------------------------------------------------------------------ */ +/* Request head processing */ +/* ------------------------------------------------------------------------ */ + +typedef struct body_sink_ctx { + pnet_runtime *rt; + pnet_httpd_conn *c; + bool too_large; + bool oom; +} body_sink_ctx; + +static bool request_body_sink(void *vctx, const uint8_t *data, size_t len) { + body_sink_ctx *ctx = vctx; + pnet_httpd_conn *c = ctx->c; + if (c->req_terminal) return true; /* discard after abort */ + if (c->body_total + len > c->server->max_body_bytes) { + ctx->too_large = true; + return false; + } + if (c->phase == CP_DRAIN) { + c->body_total += len; /* discard the drained bytes */ + return true; + } + if (!pnet_bq_push(ctx->rt, &c->rxq, data, len, ctx->rt->cfg.io_chunk_bytes)) { + ctx->oom = true; + return false; + } + c->body_total += len; + c->dirty = true; + return true; +} + +static void body_finished(pnet_runtime *rt, pnet_httpd_conn *c) { + if (c->body_end_pushed) return; + c->body_end_pushed = true; + /* `end` is a queue barrier: the tick boundary inserts the request's + * `readable` ahead of it so the guest sees the last bytes before EOF. */ + if (c->req_delivered && !c->req_terminal) push_req_event(rt, "end", c->req, NULL, 0, true, 0); +} + +static bool deliver_request(pnet_runtime *rt, pnet_httpd_conn *c, const pnet_h1_head *head, bool secure) { + pnet_httpd_server *s = c->server; + pnet_sb sb; + pnet_sb_init(&sb); + pnet_sb_printf(rt, &sb, ",\"req\":%d,\"method\":", c->req); + pnet_sb_json_string(rt, &sb, head->method, head->method_len); + pnet_sb_puts(rt, &sb, ",\"target\":"); + pnet_sb_json_string(rt, &sb, head->target, head->target_len); + pnet_sb_puts(rt, &sb, ",\"headers\":{"); + bool first = true; + for (size_t i = 0; i < head->field_count; i++) { + const pnet_h1_field *f = &head->fields[i]; + bool seen = false; + for (size_t k = 0; k < i; k++) + if (head->fields[k].name_len == f->name_len && memcmp(head->fields[k].name, f->name, f->name_len) == 0) seen = true; + if (seen) continue; + if (!first) pnet_sb_putc(rt, &sb, ','); + first = false; + pnet_sb_json_string(rt, &sb, f->name, f->name_len); + pnet_sb_putc(rt, &sb, ':'); + bool cookie = f->name_len == 6 && memcmp(f->name, "cookie", 6) == 0; + pnet_sb value; + pnet_sb_init(&value); + bool firstv = true; + for (size_t k = i; k < head->field_count; k++) { + const pnet_h1_field *g = &head->fields[k]; + if (g->name_len != f->name_len || memcmp(g->name, f->name, f->name_len) != 0) continue; + if (!firstv) pnet_sb_puts(rt, &value, cookie ? "; " : ", "); + firstv = false; + pnet_sb_append(rt, &value, g->value, g->value_len); + } + pnet_sb_json_string(rt, &sb, pnet_sb_cstr(&value), value.len); + pnet_sb_free(rt, &value); + } + char addr[48]; + pnet_format_addr(&c->conn.remote, addr, sizeof addr); + pnet_sb_printf(rt, &sb, "},\"remote\":{\"address\":\"%s\",\"port\":%u}", addr, (unsigned)c->conn.remote.port); + if (head->content_length >= 0) pnet_sb_printf(rt, &sb, ",\"length\":%lld", (long long)head->content_length); + pnet_sb_puts(rt, &sb, secure ? ",\"secure\":true" : ",\"secure\":false"); + size_t len = 0; + size_t weight = sb.len; + char *json = sb.failed ? NULL : pnet_event_json(rt, "request", "h", s->handle, sb.data, sb.len, &len); + pnet_sb_free(rt, &sb); + if (!json) return false; + return pnet_queue_push(rt, &rt->httpd_queue, c->req, false, weight, json, len); +} + +static void on_request_head(pnet_runtime *rt, pnet_httpd_conn *c, pnet_h1_head *head) { + pnet_httpd_server *s = c->server; + if (!pnet_h1_validate_framing(head)) { + conn_reject(rt, c, 400); + return; + } + /* Host header is mandatory in HTTP/1.1. */ + if (head->minor_version == 1 && !pnet_h1_find(head, "host")) { + conn_reject(rt, c, 400); + return; + } + if (head->has_upgrade) { + /* No upgrade support in this role: answer plainly and let the client + * fall back (RFC 9110 §7.8 permits ignoring Upgrade). */ + } + if (s->inflight >= s->max_inflight || s->state != SV_LISTENING) { + conn_reject(rt, c, 503); + return; + } + /* Body framing */ + pnet_h1_body_mode mode = PNET_H1_BODY_NONE; + uint64_t length = 0; + if (head->chunked) mode = PNET_H1_BODY_CHUNKED; + else if (head->content_length > 0) { + mode = PNET_H1_BODY_LENGTH; + length = (uint64_t)head->content_length; + if (length > s->max_body_bytes) { + conn_reject(rt, c, 413); + return; + } + } + c->keep_alive = head->minor_version == 1 ? !head->connection_close : head->connection_keep_alive; + c->head_method = pnet_ieq_n(head->method, head->method_len, "HEAD"); + c->req = rt->httpd_next_req++; + if (rt->httpd_next_req <= 0) rt->httpd_next_req = 1; + if (!deliver_request(rt, c, head, false)) { + conn_reject(rt, c, 503); + return; + } + c->req_delivered = true; + s->inflight++; + c->phase = CP_REQUEST; + pnet_h1_body_init(&c->decoder, mode, length); + if (head->expect_continue && mode != PNET_H1_BODY_NONE) { + static const char cont[] = "HTTP/1.1 100 Continue\r\n\r\n"; + pnet_conn_write(rt, &c->conn, cont, sizeof cont - 1); + } + c->deadline = rt->now + s->handler_ms; + c->deadline_kind = 2; + /* Bytes after the head belong to the body. */ + size_t rest = c->rx_len - head->head_len; + if (rest > 0) { + if (mode == PNET_H1_BODY_NONE) { + /* Pipelined bytes: pipelining is disabled; keep them for the next head + * only after this response completes. */ + memmove(c->rx, c->rx + head->head_len, rest); + c->rx_len = rest; + } else { + body_sink_ctx ctx = {rt, c, false, false}; + size_t used = pnet_h1_body_feed(&c->decoder, c->rx + head->head_len, rest, request_body_sink, &ctx); + if (ctx.too_large) { + req_abort(rt, c, PNET_ERROR_RESPONSE_TOO_LARGE); + conn_reject(rt, c, 413); + return; + } + if (ctx.oom) { + req_abort(rt, c, PNET_ERROR_RESOURCE_LIMIT); + conn_reject(rt, c, 503); + return; + } + if (c->decoder.error) { + req_abort(rt, c, PNET_ERROR_CLOSED); + conn_reject(rt, c, 400); + return; + } + size_t leftover = rest - used; + memmove(c->rx, c->rx + head->head_len + used, leftover); + c->rx_len = leftover; + } + } else { + c->rx_len = 0; + } + if (c->decoder.done) body_finished(rt, c); +} + +/* ------------------------------------------------------------------------ */ +/* Response completion / keep-alive */ +/* ------------------------------------------------------------------------ */ + +static void response_finished(pnet_runtime *rt, pnet_httpd_conn *c) { + c->response_complete = true; + c->req_terminal = true; + if (c->server->inflight > 0) c->server->inflight--; + pnet_bq_free(rt, &c->rxq); + c->visible_bytes = 0; + c->dirty = false; + if (!c->keep_alive || c->close_after || c->server->state != SV_LISTENING) { + c->close_after = true; + c->phase = CP_CLOSING; + c->conn.read_wanted = false; + pnet_conn_update_interest(rt, &c->conn); + c->deadline = rt->now + c->server->close_ms; + c->deadline_kind = 4; + return; + } + if (c->decoder.done) { + conn_start_head(rt, c); + return; + } + /* Unread request body remains on the wire: drain a bounded amount before + * reusing the connection, else close. */ + if (c->decoder.mode == PNET_H1_BODY_LENGTH && c->decoder.remaining > c->server->request_queue_bytes) { + c->close_after = true; + c->phase = CP_CLOSING; + c->conn.read_wanted = false; + pnet_conn_update_interest(rt, &c->conn); + c->deadline = rt->now + c->server->close_ms; + c->deadline_kind = 4; + return; + } + c->phase = CP_DRAIN; + c->conn.read_wanted = true; + pnet_conn_update_interest(rt, &c->conn); + c->deadline = rt->now + c->server->body_idle_ms; + c->deadline_kind = 1; +} + +/* ------------------------------------------------------------------------ */ +/* Service */ +/* ------------------------------------------------------------------------ */ + +static void conn_read_head(pnet_runtime *rt, pnet_httpd_conn *c) { + pnet_httpd_server *s = c->server; + size_t max_head = s->max_header_bytes + rt->cfg.httpd_max_target_bytes + 64; + for (;;) { + /* Parse whatever we already have first (leftover from a previous request). */ + if (c->rx_len > 0) { + pnet_h1_head head; + int rc = pnet_h1_parse_head(c->rx, c->rx_len, true, s->max_header_bytes, rt->cfg.httpd_max_headers, + rt->cfg.httpd_max_target_bytes, &head); + if (rc == PNET_H1_OK) { + on_request_head(rt, c, &head); + return; + } + if (rc == PNET_H1_TOO_LARGE || rc == PNET_H1_TOO_MANY_FIELDS) { conn_reject(rt, c, 431); return; } + if (rc == PNET_H1_TARGET_TOO_LONG) { conn_reject(rt, c, 414); return; } + if (rc == PNET_H1_ERROR) { conn_reject(rt, c, 400); return; } + } + if (c->rx_len >= max_head) { + conn_reject(rt, c, 431); + return; + } + if (c->rx_cap < c->rx_len + 512) { + size_t cap = c->rx_cap ? c->rx_cap * 2 : 1024; + if (cap > max_head + 16) cap = max_head + 16; + uint8_t *next = pnet_alloc(rt, cap); + if (!next) { conn_reject(rt, c, 503); return; } + if (c->rx) { memcpy(next, c->rx, c->rx_len); pnet_free(rt, c->rx, c->rx_cap); } + c->rx = next; + c->rx_cap = cap; + } + size_t want = c->rx_cap - c->rx_len; + if (want > max_head - c->rx_len) want = max_head - c->rx_len; + int n = pnet_conn_read(rt, &c->conn, c->rx + c->rx_len, want); + if (n == PNET_IO_AGAIN) return; + if (n <= 0) { + /* EOF or error before a complete head: drop the connection silently. */ + c->phase = CP_CLOSING; + c->close_after = true; + c->deadline = rt->now; + return; + } + c->rx_len += (size_t)n; + /* An idle keep-alive connection that starts sending switches to the + * header deadline. */ + if (c->deadline_kind == 3) { + c->deadline = rt->now + s->header_ms; + c->deadline_kind = 0; + } + } +} + +static void conn_read_body(pnet_runtime *rt, pnet_httpd_conn *c) { + pnet_httpd_server *s = c->server; + uint8_t scratch[2048]; + for (int rounds = 0; rounds < 8; rounds++) { + if (c->decoder.done || c->decoder.error) return; + if (c->phase == CP_REQUEST) { + size_t room = s->request_queue_bytes > c->rxq.bytes ? s->request_queue_bytes - c->rxq.bytes : 0; + if (room == 0) { + c->conn.read_wanted = false; + pnet_conn_update_interest(rt, &c->conn); + return; + } + } + c->conn.read_wanted = true; + /* Leftover bytes from the head buffer are consumed first. */ + const uint8_t *src; + size_t src_len; + bool from_rx = c->rx_len > 0; + if (from_rx) { + src = c->rx; + src_len = c->rx_len; + } else { + int n = pnet_conn_read(rt, &c->conn, scratch, sizeof scratch); + if (n == PNET_IO_AGAIN) { + pnet_conn_update_interest(rt, &c->conn); + return; + } + if (n == PNET_IO_EOF || n < 0) { + if (c->decoder.mode == PNET_H1_BODY_CLOSE) { + body_finished(rt, c); + } else { + req_abort(rt, c, PNET_ERROR_CLOSED); + c->phase = CP_CLOSING; + c->close_after = true; + c->deadline = rt->now; + } + return; + } + src = scratch; + src_len = (size_t)n; + if (c->deadline_kind == 1) c->deadline = rt->now + s->body_idle_ms; + } + body_sink_ctx ctx = {rt, c, false, false}; + size_t used = pnet_h1_body_feed(&c->decoder, src, src_len, request_body_sink, &ctx); + if (from_rx) { + memmove(c->rx, c->rx + used, c->rx_len - used); + c->rx_len -= used; + } else if (used < src_len) { + /* Bytes past the message end (pipelining) are kept for the next head. */ + size_t extra = src_len - used; + if (c->rx_cap < extra) { + uint8_t *next = pnet_alloc(rt, extra + 512); + if (!next) { conn_reject(rt, c, 503); return; } + if (c->rx) pnet_free(rt, c->rx, c->rx_cap); + c->rx = next; + c->rx_cap = extra + 512; + } + memcpy(c->rx, src + used, extra); + c->rx_len = extra; + } + if (ctx.too_large) { + req_abort(rt, c, PNET_ERROR_RESPONSE_TOO_LARGE); + conn_reject(rt, c, 413); + return; + } + if (ctx.oom) { + req_abort(rt, c, PNET_ERROR_RESOURCE_LIMIT); + conn_reject(rt, c, 503); + return; + } + if (c->decoder.error) { + req_abort(rt, c, PNET_ERROR_CLOSED); + conn_reject(rt, c, 400); + return; + } + if (c->decoder.done) { + body_finished(rt, c); + if (c->phase == CP_DRAIN) conn_start_head(rt, c); + return; + } + if (from_rx && c->rx_len == 0) continue; + } +} + +/** While a delivered request waits for its response (body already read), + * keep watching the socket so a peer disconnect aborts the request; bytes + * that arrive are the next (pipelined) request and are held for later. */ +static void conn_watch_peer(pnet_runtime *rt, pnet_httpd_conn *c) { + pnet_httpd_server *s = c->server; + size_t max_head = s->max_header_bytes + rt->cfg.httpd_max_target_bytes + 64; + c->conn.read_wanted = c->rx_len < max_head; + pnet_conn_update_interest(rt, &c->conn); + if (!c->conn.read_wanted) return; + if (c->rx_cap < c->rx_len + 256) { + size_t cap = c->rx_cap ? c->rx_cap * 2 : 1024; + if (cap > max_head + 16) cap = max_head + 16; + uint8_t *next = pnet_alloc(rt, cap); + if (!next) return; + if (c->rx) { memcpy(next, c->rx, c->rx_len); pnet_free(rt, c->rx, c->rx_cap); } + c->rx = next; + c->rx_cap = cap; + } + int n = pnet_conn_read(rt, &c->conn, c->rx + c->rx_len, c->rx_cap - c->rx_len); + if (n == PNET_IO_AGAIN) return; + if (n <= 0) { + if (c->req_delivered && !c->req_terminal) req_abort(rt, c, PNET_ERROR_CLOSED); + c->phase = CP_CLOSING; + c->close_after = true; + c->deadline = 0; + return; + } + c->rx_len += (size_t)n; +} + +static void conn_service(pnet_runtime *rt, pnet_httpd_conn *c) { + pnet_httpd_server *s = c->server; + /* Flush pending output first. */ + if (!pnet_conn_flush(rt, &c->conn)) { + if (c->req_delivered && !c->req_terminal) req_abort(rt, c, PNET_ERROR_CLOSED); + c->phase = CP_CLOSING; + c->close_after = true; + c->deadline = rt->now; + } + if (c->drain_armed && c->conn.tx.bytes < rt->cfg.httpd_send_low_water_bytes && c->req_delivered && !c->req_terminal) { + c->drain_armed = false; + push_req_event(rt, "drain", c->req, NULL, 0, false, 0); + } + /* Deadlines */ + if (c->deadline && rt->now >= c->deadline) { + switch (c->deadline_kind) { + case 0: /* header */ + case 3: /* keep-alive idle */ + c->phase = CP_CLOSING; + c->close_after = true; + c->deadline = 0; + break; + case 1: /* body idle */ + if (c->req_delivered && !c->req_terminal) req_abort(rt, c, PNET_ERROR_TIMEOUT); + c->phase = CP_CLOSING; + c->close_after = true; + c->deadline = 0; + break; + case 2: /* handler */ + if (!c->responded) { + req_abort(rt, c, PNET_ERROR_TIMEOUT); + conn_reject(rt, c, 503); + } else { + c->deadline = 0; + } + break; + case 4: /* close flush */ + c->deadline = 0; + break; + default: + c->deadline = 0; + } + } + switch (c->phase) { + case CP_HEAD: + conn_read_head(rt, c); + break; + case CP_REQUEST: + if (c->responded && c->response_complete) { + /* handled by response_finished */ + } else if (!c->decoder.done && !c->decoder.error) { + conn_read_body(rt, c); + } else { + conn_watch_peer(rt, c); + } + break; + case CP_DRAIN: + conn_read_body(rt, c); + break; + default: + break; + } + if (c->phase == CP_CLOSING && (c->conn.tx.bytes == 0 || c->conn.tx_error || c->deadline == 0)) { + server_unlink_conn(rt, c); + return; + } + (void)s; +} + +static void server_accept(pnet_runtime *rt, pnet_httpd_server *s) { + if (s->state != SV_LISTENING) return; + for (int i = 0; i < 4; i++) { + if (s->conn_count >= s->max_connections) { + if (s->accepting) { + s->accepting = false; + rt->driver.interest(rt->driver_ctx, s->listener, 0); + } + return; + } + if (!s->accepting) { + s->accepting = true; + rt->driver.interest(rt->driver_ctx, s->listener, PNET_INTEREST_READ); + } + pnet_addr peer; + int err = 0; + pnet_sock ns = rt->driver.accept(rt->driver_ctx, s->listener, &peer, &err); + if (ns == PNET_SOCK_INVALID) return; + pnet_httpd_conn *c = pnet_zalloc(rt, sizeof *c); + if (!c) { + rt->driver.close(rt->driver_ctx, ns); + return; + } + c->server = s; + pnet_conn_init(&c->conn); + pnet_bq_init(&c->rxq); + pnet_conn_adopt(rt, &c->conn, ns, &peer); + c->next = s->conns; + s->conns = c; + s->conn_count++; + conn_start_head(rt, c); + } +} + +static void server_close(pnet_runtime *rt, pnet_httpd_server *s) { + if (s->state == SV_CLOSED) return; + s->state = SV_CLOSED; + if (s->listener != PNET_SOCK_INVALID) { + rt->driver.close(rt->driver_ctx, s->listener); + s->listener = PNET_SOCK_INVALID; + } + while (s->conns) { + pnet_httpd_conn *c = s->conns; + if (c->req_delivered && !c->req_terminal) req_abort(rt, c, PNET_ERROR_CLOSED); + server_unlink_conn(rt, c); + } + push_server_event(rt, "closed", s->handle, NULL, 0, true); + if (s->live_counted && rt->httpd_live > 0) rt->httpd_live--; + s->live_counted = false; +} + +static void server_service(pnet_runtime *rt, pnet_httpd_server *s) { + if (s->state == SV_BINDING) { + int err = 0; + pnet_addr bound; + pnet_sock l = rt->driver.listen(rt->driver_ctx, &s->bind_addr, s->backlog, &bound, &err); + if (l == PNET_SOCK_INVALID) { + const char *code = pnet_io_error_code(err); + char cause[16]; + snprintf(cause, sizeof cause, "io:%d", err); + push_server_error(rt, s->handle, code, "bind failed", cause); + s->state = SV_CLOSED; + if (s->live_counted && rt->httpd_live > 0) rt->httpd_live--; + s->live_counted = false; + return; + } + s->listener = l; + s->bound = bound; + s->state = SV_LISTENING; + s->accepting = true; + rt->driver.interest(rt->driver_ctx, l, PNET_INTEREST_READ); + char addr[48]; + pnet_format_addr(&bound, addr, sizeof addr); + char tail[96]; + int n = snprintf(tail, sizeof tail, ",\"address\":\"%s\",\"port\":%u", addr, (unsigned)bound.port); + push_server_event(rt, "listening", s->handle, tail, (size_t)n, false); + } + if (s->state == SV_LISTENING) server_accept(rt, s); + pnet_httpd_conn *c = s->conns; + while (c) { + pnet_httpd_conn *next = c->next; + conn_service(rt, c); + c = next; + } + if (s->state == SV_STOPPING) { + bool inflight_left = false; + for (pnet_httpd_conn *k = s->conns; k; k = k->next) { + if (k->req_delivered && !k->req_terminal) inflight_left = true; + else if (k->phase == CP_HEAD) { + /* idle: close now */ + k->phase = CP_CLOSING; + k->close_after = true; + k->deadline = 0; + } + } + if (!s->graceful || !inflight_left || rt->now >= s->stop_deadline) server_close(rt, s); + else { + /* close idle connections eagerly */ + pnet_httpd_conn *k = s->conns; + while (k) { + pnet_httpd_conn *nk = k->next; + if (k->phase == CP_CLOSING) server_unlink_conn(rt, k); + k = nk; + } + } + } +} + +void pnet_httpd_service(pnet_runtime *rt) { + pnet_httpd_server *s = rt->httpd_servers; + while (s) { + pnet_httpd_server *next = s->next; + server_service(rt, s); + s = next; + } + /* Retire closed servers. */ + pnet_httpd_server **pp = &rt->httpd_servers; + while (*pp) { + pnet_httpd_server *cur = *pp; + if (cur->state == SV_CLOSED && cur->conns == NULL) { + *pp = cur->next; + pnet_free(rt, cur, sizeof *cur); + continue; + } + pp = &cur->next; + } +} + +uint64_t pnet_httpd_next_deadline(pnet_runtime *rt) { + uint64_t d = 0; + for (pnet_httpd_server *s = rt->httpd_servers; s; s = s->next) { + if (s->state == SV_STOPPING) d = pnet_min_deadline(d, s->stop_deadline); + for (pnet_httpd_conn *c = s->conns; c; c = c->next) + if (c->deadline) d = pnet_min_deadline(d, c->deadline); + } + return d; +} + +bool pnet_httpd_has_output(pnet_runtime *rt) { + for (pnet_httpd_server *s = rt->httpd_servers; s; s = s->next) + for (pnet_httpd_conn *c = s->conns; c; c = c->next) + if (c->conn.state == PNET_CONN_OPEN && c->conn.tx.bytes > 0) return true; + return false; +} + +void pnet_httpd_freeze(pnet_runtime *rt) { + for (pnet_httpd_server *s = rt->httpd_servers; s; s = s->next) { + for (pnet_httpd_conn *c = s->conns; c; c = c->next) { + if (c->dirty && c->req_delivered && !c->req_terminal) { + c->dirty = false; + c->visible_bytes = c->rxq.bytes; + pnet_queue_push_readable(rt, &rt->httpd_queue, c->req, "req", c->visible_bytes); + } + } + } +} + +void pnet_httpd_quiesce(pnet_runtime *rt) { + for (pnet_httpd_server *s = rt->httpd_servers; s; s = s->next) { + if (s->state == SV_BINDING) { + s->state = SV_CLOSED; + push_server_error(rt, s->handle, PNET_ERROR_CLOSED, "runtime closing", NULL); + } else if (s->state != SV_CLOSED) { + server_close(rt, s); + } + } +} + +void pnet_httpd_init(pnet_runtime *rt) { + pnet_sb sb; + pnet_sb_init(&sb); + const pnet_runtime_config *c = &rt->cfg; + pnet_sb_printf(rt, &sb, + "{\"specMajor\":%d,\"specMinor\":%d,\"maxServers\":%u,\"maxConnections\":%u,\"maxInflight\":%u," + "\"maxTlsInflight\":0,\"maxHeaders\":%u,\"maxHeaderBytes\":%zu,\"maxTargetBytes\":%zu," + "\"defaultRequestQueueBytes\":%zu,\"maxRequestQueueBytes\":%zu,\"maxSendQueueBytes\":%zu," + "\"sendHighWaterBytes\":%zu,\"sendLowWaterBytes\":%zu,\"maxEventsPerTick\":%u,\"maxTickBytes\":%zu," + "\"defaultHeaderMs\":%u,\"defaultBodyIdleMs\":%u,\"defaultHandlerMs\":%u,\"defaultKeepAliveMs\":%u," + "\"defaultCloseMs\":%u,\"maxTimeoutMs\":%u,\"tlsMinVersion\":\"%s\",\"features\":[]}", + PHTTPD_SPEC_MAJOR, PHTTPD_SPEC_MINOR, c->httpd_max_servers, c->httpd_max_connections, c->httpd_max_inflight, + c->httpd_max_headers, c->httpd_max_header_bytes, c->httpd_max_target_bytes, + c->httpd_default_request_queue_bytes, c->httpd_max_request_queue_bytes, c->httpd_max_send_queue_bytes, + c->httpd_send_high_water_bytes, c->httpd_send_low_water_bytes, c->httpd_max_events_per_tick, + c->httpd_max_tick_bytes, PHTTPD_DEFAULT_HEADER_MS, PHTTPD_DEFAULT_BODY_IDLE_MS, PHTTPD_DEFAULT_HANDLER_MS, + PHTTPD_DEFAULT_KEEP_ALIVE_MS, PHTTPD_DEFAULT_CLOSE_MS, PHTTPD_MAX_TIMEOUT_MS, PNET_TLS_MIN_VERSION); + rt->httpd_limits_json = sb.failed ? NULL : pnet_strdup_n(rt, sb.data, sb.len); + pnet_sb_free(rt, &sb); +} + +void pnet_httpd_shutdown(pnet_runtime *rt) { + while (rt->httpd_servers) { + pnet_httpd_server *s = rt->httpd_servers; + rt->httpd_servers = s->next; + if (s->listener != PNET_SOCK_INVALID) rt->driver.close(rt->driver_ctx, s->listener); + while (s->conns) { + pnet_httpd_conn *c = s->conns; + s->conns = c->next; + conn_free(rt, c); + } + pnet_free(rt, s, sizeof *s); + } + if (rt->httpd_limits_json) pnet_free_str(rt, rt->httpd_limits_json); + rt->httpd_limits_json = NULL; + rt->httpd_live = 0; +} + +/* ------------------------------------------------------------------------ */ +/* Guest ops */ +/* ------------------------------------------------------------------------ */ + +static int refuse(pnet_runtime *rt, const char *code, const char *message) { + pnet_set_last_error(rt, &rt->httpd_last_error, code, message); + return -1; +} + +static bool read_limit(const pnet_jdoc *doc, int obj, const char *key, size_t fallback, size_t max, size_t *out) { + int node = pnet_json_get(doc, obj, key); + if (node < 0) { + *out = fallback; + return true; + } + int64_t v; + if (!pnet_json_i64(doc, node, &v) || v < 1 || (uint64_t)v > max) return false; + *out = (size_t)v; + return true; +} + +static bool read_ms(const pnet_jdoc *doc, int obj, const char *key, uint32_t fallback, uint32_t *out) { + int node = pnet_json_get(doc, obj, key); + if (node < 0) { + *out = fallback; + return true; + } + int64_t v; + if (!pnet_json_i64(doc, node, &v) || v < 1 || v > PHTTPD_MAX_TIMEOUT_MS) return false; + *out = (uint32_t)v; + return true; +} + +int pnet_httpd_listen(pnet_runtime *rt, const char *meta_json) { + if (rt->quiesced) return refuse(rt, PNET_ERROR_CLOSED, "runtime is closing"); + if (rt->httpd_live >= rt->cfg.httpd_max_servers) return refuse(rt, PNET_ERROR_RESOURCE_LIMIT, "too many servers"); + if (!meta_json) return refuse(rt, PNET_ERROR_INVALID_REQUEST, "missing metadata"); + int cap = 128; + pnet_jnode *nodes = pnet_alloc(rt, (size_t)cap * sizeof(pnet_jnode)); + if (!nodes) return refuse(rt, PNET_ERROR_RESOURCE_LIMIT, "out of memory"); + pnet_jdoc doc; + int root = pnet_json_parse(&doc, nodes, cap, meta_json, strlen(meta_json)); + int result = -1; + pnet_httpd_server *s = NULL; + char buf[128]; + size_t blen; + int64_t v; + if (root < 0 || pnet_json_type(&doc, root) != PNET_J_OBJECT) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "malformed listen metadata"); goto out; } + s = pnet_zalloc(rt, sizeof *s); + if (!s) { refuse(rt, PNET_ERROR_RESOURCE_LIMIT, "out of memory"); goto out; } + s->listener = PNET_SOCK_INVALID; + if (!pnet_json_string(&doc, pnet_json_get(&doc, root, "address"), buf, sizeof buf, &blen) || + !pnet_parse_ip_literal(buf, blen, &s->bind_addr)) { + refuse(rt, PNET_ERROR_INVALID_REQUEST, "address must be an IP literal"); + goto out; + } + if (!pnet_json_i64(&doc, pnet_json_get(&doc, root, "port"), &v) || v < 0 || v > 65535) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid port"); goto out; } + s->bind_addr.port = (uint16_t)v; + if (pnet_json_get(&doc, root, "tls") >= 0) { refuse(rt, PNET_ERROR_UNSUPPORTED, "this host does not provide network.http.server.tls"); goto out; } + if (!rt->policy.insecure_transport) { refuse(rt, PNET_ERROR_PERMISSION_DENIED, "insecureTransport is not enabled"); goto out; } + if (!pnet_policy_allows_listen(&rt->policy, PNET_PROTO_HTTP, &s->bind_addr, s->bind_addr.port)) { + refuse(rt, PNET_ERROR_PERMISSION_DENIED, "address/port is not an allowed listen rule"); + goto out; + } + s->backlog = 4; + { + int node = pnet_json_get(&doc, root, "backlog"); + if (node >= 0) { + if (!pnet_json_i64(&doc, node, &v) || v < 1 || v > PHTTPD_MAX_BACKLOG) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid backlog"); goto out; } + s->backlog = (int)v; + } + } + { + int lim = pnet_json_get(&doc, root, "limits"); + if (lim >= 0 && pnet_json_type(&doc, lim) != PNET_J_OBJECT) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid limits"); goto out; } + size_t tmp; + if (!read_limit(&doc, lim, "maxConnections", rt->cfg.httpd_max_connections, rt->cfg.httpd_max_connections, &tmp)) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid limits.maxConnections"); goto out; } + s->max_connections = (uint32_t)tmp; + if (!read_limit(&doc, lim, "maxInflight", rt->cfg.httpd_max_inflight, rt->cfg.httpd_max_inflight, &tmp)) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid limits.maxInflight"); goto out; } + s->max_inflight = (uint32_t)tmp; + if (!read_limit(&doc, lim, "maxHeaderBytes", rt->cfg.httpd_max_header_bytes, rt->cfg.httpd_max_header_bytes, &s->max_header_bytes)) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid limits.maxHeaderBytes"); goto out; } + if (!read_limit(&doc, lim, "requestQueueBytes", rt->cfg.httpd_default_request_queue_bytes, rt->cfg.httpd_max_request_queue_bytes, &s->request_queue_bytes)) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid limits.requestQueueBytes"); goto out; } + if (!read_limit(&doc, lim, "sendQueueBytes", rt->cfg.httpd_max_send_queue_bytes, rt->cfg.httpd_max_send_queue_bytes, &s->send_queue_bytes)) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid limits.sendQueueBytes"); goto out; } + int mb = pnet_json_get(&doc, lim, "maxBodyBytes"); + s->max_body_bytes = SIZE_MAX; + if (mb >= 0) { + if (!pnet_json_i64(&doc, mb, &v) || v < 0) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid limits.maxBodyBytes"); goto out; } + s->max_body_bytes = (size_t)v; + } + } + { + int t = pnet_json_get(&doc, root, "timeouts"); + if (t >= 0 && pnet_json_type(&doc, t) != PNET_J_OBJECT) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid timeouts"); goto out; } + if (!read_ms(&doc, t, "headerMs", PHTTPD_DEFAULT_HEADER_MS, &s->header_ms) || + !read_ms(&doc, t, "bodyIdleMs", PHTTPD_DEFAULT_BODY_IDLE_MS, &s->body_idle_ms) || + !read_ms(&doc, t, "handlerMs", PHTTPD_DEFAULT_HANDLER_MS, &s->handler_ms) || + !read_ms(&doc, t, "keepAliveMs", PHTTPD_DEFAULT_KEEP_ALIVE_MS, &s->keep_alive_ms) || + !read_ms(&doc, t, "closeMs", PHTTPD_DEFAULT_CLOSE_MS, &s->close_ms)) { + refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid timeouts"); + goto out; + } + } + s->handle = rt->httpd_next_handle++; + if (rt->httpd_next_handle <= 0) rt->httpd_next_handle = 1; + s->state = SV_BINDING; + s->live_counted = true; + rt->httpd_live++; + s->next = rt->httpd_servers; + rt->httpd_servers = s; + result = s->handle; + s = NULL; +out: + if (s) pnet_free(rt, s, sizeof *s); + pnet_free(rt, nodes, (size_t)cap * sizeof(pnet_jnode)); + return result; +} + +int pnet_httpd_stop(pnet_runtime *rt, int handle, bool graceful, uint32_t timeout_ms) { + pnet_httpd_server *s = server_find(rt, handle); + if (!s || s->state == SV_STOPPING || s->state == SV_CLOSED) return -1; + if (s->state == SV_BINDING) { + /* Never listened: close immediately at the next service pass. */ + s->state = SV_STOPPING; + s->graceful = false; + s->stop_deadline = pnet_now(rt); + return 0; + } + s->state = SV_STOPPING; + s->graceful = graceful; + s->stop_deadline = pnet_now(rt) + (timeout_ms ? timeout_ms : s->close_ms); + s->accepting = false; + if (s->listener != PNET_SOCK_INVALID) { + rt->driver.close(rt->driver_ctx, s->listener); + s->listener = PNET_SOCK_INVALID; + } + return 0; +} + +static bool header_owned(const char *name, size_t len) { + static const char *const owned[] = {"connection", "content-length", "transfer-encoding", "keep-alive", "upgrade", + "trailer", "te"}; + for (size_t i = 0; i < sizeof owned / sizeof owned[0]; i++) + if (pnet_ieq_n(name, len, owned[i])) return true; + return false; +} + +int pnet_httpd_respond(pnet_runtime *rt, int req, const char *meta_json, const uint8_t *body, size_t body_len) { + pnet_httpd_conn *c = req_find(rt, req, NULL); + if (!c || c->responded) return PHTTPD_SEND_INVALID_REQUEST; + if (!meta_json) return PHTTPD_SEND_INVALID; + int cap = 200; + pnet_jnode *nodes = pnet_alloc(rt, (size_t)cap * sizeof(pnet_jnode)); + if (!nodes) return PHTTPD_SEND_INVALID; + pnet_jdoc doc; + int root = pnet_json_parse(&doc, nodes, cap, meta_json, strlen(meta_json)); + int result = PHTTPD_SEND_INVALID; + pnet_sb sb; + pnet_sb_init(&sb); + int64_t status; + if (root < 0 || !pnet_json_i64(&doc, pnet_json_get(&doc, root, "status"), &status) || status < 200 || status > 599) goto out; + bool end = true; + int endnode = pnet_json_get(&doc, root, "end"); + if (endnode >= 0) { + if (pnet_json_type(&doc, endnode) != PNET_J_BOOL) goto out; + end = doc.nodes[endnode].truthy; + } + bool no_body_status = status == 204 || status == 304 || (status >= 100 && status < 200); + if (no_body_status && (body_len > 0 || !end)) goto out; + int64_t content_length = -1; + int cl = pnet_json_get(&doc, root, "contentLength"); + if (cl >= 0) { + if (!pnet_json_i64(&doc, cl, &content_length) || content_length < 0) goto out; + if (end && (uint64_t)content_length != body_len) goto out; + } + if (end && c->conn.tx.bytes + body_len > c->server->send_queue_bytes) { + c->drain_armed = true; + result = PHTTPD_SEND_BACKPRESSURE; + goto out; + } + char reason[128] = {0}; + int st = pnet_json_get(&doc, root, "statusText"); + if (st >= 0) { + size_t rl; + if (!pnet_json_string(&doc, st, reason, sizeof reason, &rl)) goto out; + for (size_t i = 0; i < rl; i++) { + unsigned char ch = (unsigned char)reason[i]; + if ((ch < 0x20 && ch != '\t') || ch == 0x7f) goto out; + } + } + if (!reason[0]) snprintf(reason, sizeof reason, "%s", reason_phrase((int)status)); + pnet_sb_printf(rt, &sb, "HTTP/1.1 %d %s\r\n", (int)status, reason); + int headers = pnet_json_get(&doc, root, "headers"); + if (headers >= 0) { + if (pnet_json_type(&doc, headers) != PNET_J_OBJECT) goto out; + uint32_t count = 0; + for (int k = pnet_json_first(&doc, headers); k >= 0; k = pnet_json_next(&doc, k)) { + char name[128]; + size_t nl; + if (!pnet_json_string(&doc, k, name, sizeof name, &nl) || !pnet_is_token(name, nl)) goto out; + if (header_owned(name, nl)) continue; + size_t vl; + char *value = pnet_json_string_dup(rt, &doc, doc.nodes[k].first_child, &vl); + if (!value) goto out; + bool bad = false; + for (size_t i = 0; i < vl; i++) { + unsigned char ch = (unsigned char)value[i]; + if ((ch < 0x20 && ch != '\t') || ch == 0x7f) bad = true; + } + if (bad || ++count > rt->cfg.httpd_max_headers) { + pnet_free_str(rt, value); + goto out; + } + pnet_sb_append(rt, &sb, name, nl); + pnet_sb_puts(rt, &sb, ": "); + pnet_sb_append(rt, &sb, value, vl); + pnet_sb_puts(rt, &sb, "\r\n"); + pnet_free_str(rt, value); + } + } + bool chunked = false; + if (end) { + if (!no_body_status || body_len > 0) pnet_sb_printf(rt, &sb, "Content-Length: %zu\r\n", body_len); + } else if (content_length >= 0) { + pnet_sb_printf(rt, &sb, "Content-Length: %lld\r\n", (long long)content_length); + } else { + pnet_sb_puts(rt, &sb, "Transfer-Encoding: chunked\r\n"); + chunked = true; + } + bool keep = c->keep_alive && c->server->state == SV_LISTENING; + pnet_sb_puts(rt, &sb, keep ? "Connection: keep-alive\r\n\r\n" : "Connection: close\r\n\r\n"); + if (sb.failed) goto out; + if (!pnet_conn_write(rt, &c->conn, sb.data, sb.len)) goto out; + if (body_len > 0 && !c->head_method) { + if (!pnet_conn_write(rt, &c->conn, body, body_len)) goto out; + } + c->responded = true; + c->response_chunked = chunked; + c->response_remaining = content_length; + if (!keep) c->close_after = true; + c->deadline = 0; + result = PHTTPD_SEND_ACCEPTED; + if (end) response_finished(rt, c); +out: + pnet_sb_free(rt, &sb); + pnet_free(rt, nodes, (size_t)cap * sizeof(pnet_jnode)); + return result; +} + +int pnet_httpd_write(pnet_runtime *rt, int req, const uint8_t *chunk, size_t len) { + pnet_httpd_conn *c = req_find(rt, req, NULL); + if (!c || !c->responded || c->response_complete) return PHTTPD_SEND_INVALID_REQUEST; + if (len > c->server->send_queue_bytes) return PHTTPD_SEND_INVALID; + if (c->response_remaining >= 0 && (int64_t)len > c->response_remaining) return PHTTPD_SEND_INVALID; + size_t overhead = c->response_chunked ? 20 : 0; + if (c->conn.tx.bytes + len + overhead > c->server->send_queue_bytes) { + c->drain_armed = true; + return PHTTPD_SEND_BACKPRESSURE; + } + if (len == 0) return PHTTPD_SEND_ACCEPTED; + if (!c->head_method) { + if (c->response_chunked) { + char size_line[24]; + int n = snprintf(size_line, sizeof size_line, "%zx\r\n", len); + if (!pnet_conn_write(rt, &c->conn, size_line, (size_t)n)) return PHTTPD_SEND_INVALID_REQUEST; + } + if (!pnet_conn_write(rt, &c->conn, chunk, len)) return PHTTPD_SEND_INVALID_REQUEST; + if (c->response_chunked && !pnet_conn_write(rt, &c->conn, "\r\n", 2)) return PHTTPD_SEND_INVALID_REQUEST; + } + if (c->response_remaining >= 0) c->response_remaining -= (int64_t)len; + return PHTTPD_SEND_ACCEPTED; +} + +int pnet_httpd_end_body(pnet_runtime *rt, int req) { + pnet_httpd_conn *c = req_find(rt, req, NULL); + if (!c || !c->responded || c->response_complete) return -1; + if (c->response_remaining > 0) { + /* Short body: the framing promise cannot be kept; close the connection. */ + req_abort(rt, c, PNET_ERROR_CANCELLED); + c->phase = CP_CLOSING; + c->close_after = true; + c->deadline = pnet_now(rt); + return -1; + } + if (c->response_chunked && !c->head_method) pnet_conn_write(rt, &c->conn, "0\r\n\r\n", 5); + response_finished(rt, c); + return 0; +} + +int pnet_httpd_read_into(pnet_runtime *rt, int req, uint8_t *dst, size_t len) { + pnet_httpd_conn *c = req_find(rt, req, NULL); + if (!c) return -1; + size_t want = len < c->visible_bytes ? len : c->visible_bytes; + size_t got = pnet_bq_read(rt, &c->rxq, dst, want); + c->visible_bytes -= got; + if (c->phase == CP_REQUEST && !c->conn.read_wanted && c->rxq.bytes < c->server->request_queue_bytes) { + c->conn.read_wanted = true; + pnet_conn_update_interest(rt, &c->conn); + } + return (int)got; +} + +void pnet_httpd_abort(pnet_runtime *rt, int req) { + pnet_httpd_conn *c = req_find(rt, req, NULL); + if (!c) return; + req_abort(rt, c, PNET_ERROR_CANCELLED); + c->phase = CP_CLOSING; + c->close_after = true; + c->deadline = pnet_now(rt) + c->server->close_ms; + c->deadline_kind = 4; + c->conn.read_wanted = false; + pnet_conn_update_interest(rt, &c->conn); +} + +const char *pnet_httpd_poll(pnet_runtime *rt, size_t *len) { + return pnet_queue_poll(rt, &rt->httpd_queue, len); +} + +const char *pnet_httpd_last_error(pnet_runtime *rt) { + return pnet_sb_cstr(&rt->httpd_last_error); +} + +const char *pnet_httpd_limits(pnet_runtime *rt) { + return rt->httpd_limits_json ? rt->httpd_limits_json : "{}"; +} diff --git a/engine/net/src/pnet_internal.h b/engine/net/src/pnet_internal.h new file mode 100644 index 00000000..5400ebee --- /dev/null +++ b/engine/net/src/pnet_internal.h @@ -0,0 +1,586 @@ +/* Internal definitions shared by the network core sources. Not installed. */ +#ifndef PNET_INTERNAL_H +#define PNET_INTERNAL_H + +#include +#include +#include +#include + +#include "pocketjs/net/driver.h" +#include "pocketjs/net/platform.h" +#include "pocketjs/net/runtime.h" +#include "pocketjs/net/spec.h" + +/* ------------------------------------------------------------------------ */ +/* Allocation */ +/* ------------------------------------------------------------------------ */ + +void *pnet_alloc(pnet_runtime *rt, size_t size); +void *pnet_zalloc(pnet_runtime *rt, size_t size); +void pnet_free(pnet_runtime *rt, void *ptr, size_t size); +char *pnet_strdup_n(pnet_runtime *rt, const char *s, size_t len); +static inline void pnet_free_str(pnet_runtime *rt, char *s) { + if (s) pnet_free(rt, s, strlen(s) + 1); +} +void pnet_logf(pnet_runtime *rt, pnet_log_level level, const char *fmt, ...); + +/* ------------------------------------------------------------------------ */ +/* String builder */ +/* ------------------------------------------------------------------------ */ + +typedef struct pnet_sb { + char *data; + size_t len; + size_t cap; + bool failed; +} pnet_sb; + +void pnet_sb_init(pnet_sb *sb); +void pnet_sb_free(pnet_runtime *rt, pnet_sb *sb); +bool pnet_sb_reserve(pnet_runtime *rt, pnet_sb *sb, size_t extra); +void pnet_sb_append(pnet_runtime *rt, pnet_sb *sb, const void *data, size_t len); +void pnet_sb_puts(pnet_runtime *rt, pnet_sb *sb, const char *s); +void pnet_sb_putc(pnet_runtime *rt, pnet_sb *sb, char c); +void pnet_sb_printf(pnet_runtime *rt, pnet_sb *sb, const char *fmt, ...); +/** Append a JSON string literal (with quotes), escaping as needed. Invalid + * UTF-8 bytes are replaced by U+FFFD so the batch is always valid JSON. */ +void pnet_sb_json_string(pnet_runtime *rt, pnet_sb *sb, const char *s, size_t len); +/** Reset length to zero, keeping the buffer. */ +static inline void pnet_sb_clear(pnet_sb *sb) { + sb->len = 0; + sb->failed = false; + if (sb->data) sb->data[0] = 0; +} +/** NUL-terminated view (always valid, "" when empty). */ +const char *pnet_sb_cstr(pnet_sb *sb); + +/* ------------------------------------------------------------------------ */ +/* Byte queue (segment list) */ +/* ------------------------------------------------------------------------ */ + +typedef struct pnet_seg { + struct pnet_seg *next; + size_t cap; + size_t len; + size_t off; + uint8_t data[1]; +} pnet_seg; + +typedef struct pnet_bq { + pnet_seg *head; + pnet_seg *tail; + size_t bytes; +} pnet_bq; + +void pnet_bq_init(pnet_bq *q); +void pnet_bq_free(pnet_runtime *rt, pnet_bq *q); +/** Append bytes (allocates segments of `seg_bytes` or larger). false on OOM. */ +bool pnet_bq_push(pnet_runtime *rt, pnet_bq *q, const void *data, size_t len, size_t seg_bytes); +/** Copy out and consume up to `len` bytes; returns the count. */ +size_t pnet_bq_read(pnet_runtime *rt, pnet_bq *q, uint8_t *dst, size_t len); +/** Peek at the head contiguous span (for write() calls); 0 when empty. */ +size_t pnet_bq_peek(pnet_bq *q, const uint8_t **ptr); +/** Consume `n` bytes from the head. */ +void pnet_bq_consume(pnet_runtime *rt, pnet_bq *q, size_t n); +static inline size_t pnet_bq_bytes(const pnet_bq *q) { return q->bytes; } + +/* ------------------------------------------------------------------------ */ +/* Codecs and small helpers */ +/* ------------------------------------------------------------------------ */ + +bool pnet_utf8_valid(const uint8_t *s, size_t len); +/** Incremental UTF-8 validator state (for fragmented WebSocket text). */ +typedef struct pnet_utf8_state { + uint32_t need; /* continuation bytes still expected */ + uint32_t cp; /* code point accumulator */ + uint32_t lower; /* minimum code point for the sequence (overlong check) */ +} pnet_utf8_state; +void pnet_utf8_state_init(pnet_utf8_state *st); +bool pnet_utf8_feed(pnet_utf8_state *st, const uint8_t *s, size_t len); +static inline bool pnet_utf8_complete(const pnet_utf8_state *st) { return st->need == 0; } + +size_t pnet_base64_encode(const uint8_t *in, size_t len, char *out, size_t cap); +void pnet_sha1(const uint8_t *data, size_t len, uint8_t out[20]); + +bool pnet_is_token(const char *s, size_t len); +bool pnet_ieq_n(const char *a, size_t alen, const char *b); /* case-insensitive equals a C string */ +void pnet_lower(char *s, size_t len); +bool pnet_parse_u64(const char *s, size_t len, uint64_t *out); +bool pnet_parse_ipv4(const char *s, size_t len, uint8_t out[4]); +bool pnet_parse_ipv6(const char *s, size_t len, uint8_t out[16]); +/** Parse "1.2.3.4" or "[::1]"/"::1" into an address; false if not a literal. */ +bool pnet_parse_ip_literal(const char *s, size_t len, pnet_addr *out); +/** Format an address (without port) into `out` (>= 46 bytes). */ +void pnet_format_addr(const pnet_addr *addr, char *out, size_t cap); +/** Loopback / link-local / private / multicast / unspecified classification. */ +bool pnet_addr_is_public(const pnet_addr *addr); +bool pnet_addr_is_multicast(const pnet_addr *addr); + +/* ------------------------------------------------------------------------ */ +/* JSON reader */ +/* ------------------------------------------------------------------------ */ + +typedef enum pnet_jtype { + PNET_J_NULL, + PNET_J_BOOL, + PNET_J_NUMBER, + PNET_J_STRING, + PNET_J_ARRAY, + PNET_J_OBJECT, +} pnet_jtype; + +typedef struct pnet_jnode { + uint8_t type; + bool truthy; /* for bool */ + const char *raw; /* string body (without quotes, still escaped) / number text / key */ + size_t raw_len; + int first_child; /* array element / object member (member = key node with one child) */ + int next; /* next sibling */ +} pnet_jnode; + +typedef struct pnet_jdoc { + pnet_jnode *nodes; + int count; + int cap; +} pnet_jdoc; + +/** Parse `text` into `nodes` (caller-provided, `cap` entries). Returns the + * root index or -1. Objects: children are KEY nodes (type STRING) whose + * first_child is the value. */ +int pnet_json_parse(pnet_jdoc *doc, pnet_jnode *nodes, int cap, const char *text, size_t len); +/** Object member value by key, or -1. */ +int pnet_json_get(const pnet_jdoc *doc, int object, const char *key); +/** Unescape a STRING node into out (NUL-terminated); false if it does not fit + * or the escape sequence is invalid. */ +bool pnet_json_string(const pnet_jdoc *doc, int node, char *out, size_t cap, size_t *out_len); +/** Allocate an unescaped copy of a STRING node. */ +char *pnet_json_string_dup(pnet_runtime *rt, const pnet_jdoc *doc, int node, size_t *out_len); +/** Number as int64 (integers only); false if not integral. */ +bool pnet_json_i64(const pnet_jdoc *doc, int node, int64_t *out); +static inline pnet_jtype pnet_json_type(const pnet_jdoc *doc, int node) { + return node < 0 ? PNET_J_NULL : (pnet_jtype)doc->nodes[node].type; +} +/** Iterate members: returns key node index; value = nodes[key].first_child. */ +static inline int pnet_json_first(const pnet_jdoc *doc, int container) { + return container < 0 ? -1 : doc->nodes[container].first_child; +} +static inline int pnet_json_next(const pnet_jdoc *doc, int node) { + return node < 0 ? -1 : doc->nodes[node].next; +} +bool pnet_json_key_is(const pnet_jdoc *doc, int key, const char *name); + +/* ------------------------------------------------------------------------ */ +/* URL */ +/* ------------------------------------------------------------------------ */ + +typedef struct pnet_url { + char scheme[8]; /* lowercase: http, https, ws, wss */ + char *host; /* lowercase hostname or IP literal without brackets */ + bool host_is_ipv6; + uint16_t port; /* effective port */ + bool port_explicit; + char *path; /* "/path?query" (request-target); at least "/" */ + size_t path_len; +} pnet_url; + +/** Parse an absolute http(s)/ws(s) URL. Returns false on syntax error. Fields + * are allocated from the runtime; free with pnet_url_free. */ +bool pnet_url_parse(pnet_runtime *rt, const char *text, size_t len, pnet_url *out); +/** Resolve `location` (absolute or relative) against `base`; a new URL. */ +bool pnet_url_resolve(pnet_runtime *rt, const pnet_url *base, const char *location, size_t len, pnet_url *out); +void pnet_url_free(pnet_runtime *rt, pnet_url *url); +/** Serialize "scheme://host[:port]path" into sb. */ +void pnet_url_write(pnet_runtime *rt, pnet_sb *sb, const pnet_url *url); +/** Same scheme+host+port. */ +bool pnet_url_same_origin(const pnet_url *a, const pnet_url *b); +static inline bool pnet_url_is_tls(const pnet_url *u) { + return strcmp(u->scheme, "https") == 0 || strcmp(u->scheme, "wss") == 0; +} +static inline uint16_t pnet_url_default_port(const char *scheme) { + return (strcmp(scheme, "https") == 0 || strcmp(scheme, "wss") == 0) ? 443 : 80; +} + +/* ------------------------------------------------------------------------ */ +/* Policy */ +/* ------------------------------------------------------------------------ */ + +typedef enum pnet_proto { + PNET_PROTO_HTTP = 0, + PNET_PROTO_HTTPS, + PNET_PROTO_WS, + PNET_PROTO_WSS, + PNET_PROTO_COUNT, +} pnet_proto; + +typedef struct pnet_rule { + uint8_t proto; + char *host; /* normalized DNS name (may start with "*." ) or IP literal text */ + pnet_addr ip; /* valid when is_ip */ + bool is_ip; + bool wildcard; + bool ephemeral; /* listen only */ + uint16_t port_min; + uint16_t port_max; +} pnet_rule; + +typedef struct pnet_policy { + pnet_rule *connect; + size_t connect_count; + pnet_rule *listen; + size_t listen_count; + char **credentials; + size_t credential_count; + bool insecure_transport; + bool local_network; + bool allow_invalid_tls_for_development; +} pnet_policy; + +bool pnet_policy_parse(pnet_runtime *rt, pnet_policy *policy, const char *json); +void pnet_policy_free(pnet_runtime *rt, pnet_policy *policy); +/** Endpoint tuple check (before DNS). */ +bool pnet_policy_allows_connect(const pnet_policy *p, pnet_proto proto, const char *host, uint16_t port); +/** Per-address check after DNS. */ +bool pnet_policy_allows_address(const pnet_policy *p, const pnet_addr *addr); +bool pnet_policy_allows_listen(const pnet_policy *p, pnet_proto proto, const pnet_addr *addr, uint16_t port); +bool pnet_policy_has_credential(const pnet_policy *p, const char *id); +pnet_proto pnet_proto_from_scheme(const char *scheme); +bool pnet_proto_is_plaintext(pnet_proto proto); + +/* ------------------------------------------------------------------------ */ +/* HTTP/1.1 wire */ +/* ------------------------------------------------------------------------ */ + +#define PNET_H1_MAX_FIELDS 64 + +typedef struct pnet_h1_field { + char *name; /* lowercased in place */ + size_t name_len; + char *value; + size_t value_len; +} pnet_h1_field; + +typedef struct pnet_h1_head { + bool request; + /* request */ + char *method; + size_t method_len; + char *target; + size_t target_len; + /* response */ + int status; + char *reason; + size_t reason_len; + /* both */ + int minor_version; /* 0 or 1 */ + pnet_h1_field fields[PNET_H1_MAX_FIELDS]; + size_t field_count; + int64_t content_length; /* -1 = absent */ + bool chunked; + bool connection_close; + bool connection_keep_alive; + bool has_upgrade; + bool expect_continue; + size_t head_len; /* bytes consumed by the head incl. CRLFCRLF */ +} pnet_h1_head; + +enum { + PNET_H1_OK = 0, + PNET_H1_INCOMPLETE = 1, + PNET_H1_ERROR = -1, /* malformed / framing violation */ + PNET_H1_TOO_LARGE = -2, /* header block over the limit */ + PNET_H1_TARGET_TOO_LONG = -3, + PNET_H1_TOO_MANY_FIELDS = -4, +}; + +/** Parse a head in place from buf[0..len). On PNET_H1_OK, `out` points into + * buf (names lowercased, values trimmed). max_head_bytes bounds the search + * for CRLFCRLF; the caller stops feeding when len exceeds it. */ +int pnet_h1_parse_head(uint8_t *buf, size_t len, bool request, size_t max_head_bytes, + size_t max_fields, size_t max_target_bytes, pnet_h1_head *out); +const pnet_h1_field *pnet_h1_find(const pnet_h1_head *head, const char *name); +/** Framing validation of the parsed field set (TE/CL rules). false = reject. */ +bool pnet_h1_validate_framing(pnet_h1_head *head); + +typedef enum pnet_h1_body_mode { + PNET_H1_BODY_NONE = 0, + PNET_H1_BODY_LENGTH, + PNET_H1_BODY_CHUNKED, + PNET_H1_BODY_CLOSE, +} pnet_h1_body_mode; + +typedef struct pnet_h1_body { + uint8_t mode; + uint8_t chunk_state; + bool done; + bool error; + uint64_t remaining; /* LENGTH: bytes left; CHUNKED: bytes left in chunk */ + size_t line_len; + char line[512]; /* chunk-size line / trailer line accumulator */ + size_t trailer_bytes; + size_t trailer_fields; +} pnet_h1_body; + +void pnet_h1_body_init(pnet_h1_body *b, pnet_h1_body_mode mode, uint64_t length); +/** Feed input; body bytes are reported through `sink` (may be called several + * times). Returns bytes consumed from `in` (may stop early when done). Sets + * b->done at message end, b->error on framing violation. `sink` returns + * false to stop (backpressure); the caller re-feeds later. */ +size_t pnet_h1_body_feed(pnet_h1_body *b, const uint8_t *in, size_t len, + bool (*sink)(void *ctx, const uint8_t *data, size_t len), void *ctx); +/** Field names that may not appear in a trailer (protocol error). */ +bool pnet_h1_trailer_field_forbidden(const char *name, size_t len); + +/* ------------------------------------------------------------------------ */ +/* Events / tick queue */ +/* ------------------------------------------------------------------------ */ + +typedef struct pnet_event { + struct pnet_event *next; + uint64_t seq; + int handle; /* h or req */ + bool terminal; /* barrier: a frozen `readable` for the handle is inserted before it */ + bool readable; /* a frozen `readable` announcement */ + size_t weight; /* bytes charged to the tick budget */ + char *json; + size_t json_len; +} pnet_event; + +typedef struct pnet_queue { + pnet_event *pending_head; + pnet_event *pending_tail; + size_t pending_count; + pnet_event *visible_head; + pnet_event *visible_tail; + size_t visible_count; + pnet_sb poll_buf; + uint32_t max_events; + size_t max_bytes; +} pnet_queue; + +void pnet_queue_init(pnet_queue *q, uint32_t max_events, size_t max_bytes); +void pnet_queue_free(pnet_runtime *rt, pnet_queue *q); +/** Take ownership of `json` (allocated with pnet_alloc, len+1 bytes). */ +bool pnet_queue_push(pnet_runtime *rt, pnet_queue *q, int handle, bool terminal, size_t weight, + char *json, size_t json_len); +/** Push a `readable` for `handle` ahead of its terminal event (or at the + * end); called from begin_tick. */ +bool pnet_queue_push_readable(pnet_runtime *rt, pnet_queue *q, int handle, const char *field, + size_t avail); +/** Move pending events into the visible set under the budget. */ +void pnet_queue_freeze(pnet_runtime *rt, pnet_queue *q); +/** Render and consume the visible set; NULL when empty. */ +const char *pnet_queue_poll(pnet_runtime *rt, pnet_queue *q, size_t *len); +/** Drop every event of a handle (guest cancel of a terminal-less handle). */ +void pnet_queue_drop_handle(pnet_runtime *rt, pnet_queue *q, int handle); + +/** Build an event JSON object: `fmt` is appended after `{"t":"","h":` / + * `"req":`; the caller supplies the remaining `,"k":v` pairs and this + * closes the object. Returns an allocated string (or NULL). */ +char *pnet_event_json(pnet_runtime *rt, const char *t, const char *id_key, int id, const char *tail, + size_t tail_len, size_t *out_len); +/** Convenience: `{"t":"error","h":n,"code":"...","message":"..."[,"causeCode":"..."]}`. */ +bool pnet_push_error_event(pnet_runtime *rt, pnet_queue *q, const char *id_key, int id, const char *code, + const char *message, const char *cause); + +/* ------------------------------------------------------------------------ */ +/* Connection */ +/* ------------------------------------------------------------------------ */ + +typedef enum pnet_conn_state { + PNET_CONN_IDLE = 0, + PNET_CONN_CONNECTING, + PNET_CONN_OPEN, + PNET_CONN_CLOSED, +} pnet_conn_state; + +typedef enum pnet_tls_phase { + PNET_TLS_NONE = 0, /* plaintext connection */ + PNET_TLS_HANDSHAKE, /* TLS handshake in progress */ + PNET_TLS_UP, /* TLS session established */ + PNET_TLS_ERROR, /* handshake failed (failure captured) */ +} pnet_tls_phase; + +typedef struct pnet_conn { + pnet_sock sock; + uint8_t state; + bool read_wanted; /* protocol wants to read (queue not full) */ + bool eof; /* peer finished writing */ + bool write_shutdown; /* shutdown requested; performed once tx drains */ + bool shutdown_done; + bool tx_error; + int last_error; /* PNET_IO_* */ + unsigned interest; + pnet_bq tx; + pnet_addr remote; + /* TLS (client). `tls` is the runtime's provider or NULL. */ + const pnet_tls_ops *tls; + void *tls_ctx; + uint8_t tls_phase; + bool secure; + pnet_tls_failure tls_failure; + char server_name[256]; + bool tls_verify; +} pnet_conn; + +void pnet_conn_init(pnet_conn *c); +/** Arm this connection for TLS: once the plain socket connects, a handshake + * runs before the connection reports open. `server_name` is SNI + DNS-ID. */ +void pnet_conn_set_tls(pnet_conn *c, const pnet_tls_ops *tls, void *tls_ctx, const char *server_name, bool verify); +/** Drive the TLS handshake after the plain connect completed: 0 pending, + * 1 established, <0 failed (c->tls_failure set). */ +int pnet_conn_tls_step(pnet_runtime *rt, pnet_conn *c); +/** Start a non-blocking connect; false when the driver refused. */ +bool pnet_conn_connect(pnet_runtime *rt, pnet_conn *c, const pnet_addr *addr, int *err); +/** Adopt an accepted socket. */ +void pnet_conn_adopt(pnet_runtime *rt, pnet_conn *c, pnet_sock s, const pnet_addr *peer); +/** Poll connect completion: 0 pending, 1 open, <0 error. */ +int pnet_conn_connect_status(pnet_runtime *rt, pnet_conn *c); +/** Queue outbound bytes. */ +bool pnet_conn_write(pnet_runtime *rt, pnet_conn *c, const void *data, size_t len); +/** Push queued bytes to the driver; returns false on transport error. */ +bool pnet_conn_flush(pnet_runtime *rt, pnet_conn *c); +/** Read available bytes into buf: > 0, PNET_IO_AGAIN, PNET_IO_EOF, or error. */ +int pnet_conn_read(pnet_runtime *rt, pnet_conn *c, uint8_t *buf, size_t len); +void pnet_conn_update_interest(pnet_runtime *rt, pnet_conn *c); +void pnet_conn_shutdown_write(pnet_runtime *rt, pnet_conn *c); +void pnet_conn_close(pnet_runtime *rt, pnet_conn *c); +static inline bool pnet_conn_is_open(const pnet_conn *c) { return c->state == PNET_CONN_OPEN; } +static inline size_t pnet_conn_tx_bytes(const pnet_conn *c) { return c->tx.bytes; } + +/* ------------------------------------------------------------------------ */ +/* Dialer: resolve + candidate connect with policy */ +/* ------------------------------------------------------------------------ */ + +#define PNET_DIAL_MAX_CANDIDATES 8 + +typedef enum pnet_dial_state { + PNET_DIAL_IDLE = 0, + PNET_DIAL_RESOLVING, + PNET_DIAL_CONNECTING, + PNET_DIAL_OPEN, + PNET_DIAL_FAILED, +} pnet_dial_state; + +typedef struct pnet_dial { + uint8_t state; + uint32_t resolve_req; + pnet_addr candidates[PNET_DIAL_MAX_CANDIDATES]; + size_t candidate_count; + size_t next_candidate; + uint16_t port; + const char *error_code; /* stable code on failure */ + const char *error_message; /* set for TLS/clock failures */ + int cause; /* PNET_IO_* */ + bool filtered_all; /* every address rejected by policy */ + bool secure; /* run a TLS handshake before reporting open */ + bool tls_up; /* handshake completed */ +} pnet_dial; + +/** Begin: literal IPs skip the resolver. false = synchronous failure + * (error_code set). When `secure`, the connection is armed for TLS with + * `server_name` (SNI/DNS-ID) and the handshake runs before PNET_DIAL_OPEN. */ +bool pnet_dial_start(pnet_runtime *rt, pnet_dial *d, pnet_conn *c, const char *host, uint16_t port, + bool secure, const char *server_name, bool verify); +/** Advance (call from service or after resolve_done). Returns the state. */ +int pnet_dial_step(pnet_runtime *rt, pnet_dial *d, pnet_conn *c); +void pnet_dial_resolved(pnet_runtime *rt, pnet_dial *d, const pnet_addr *addrs, size_t count, int err); +void pnet_dial_cancel(pnet_runtime *rt, pnet_dial *d); + +/* ------------------------------------------------------------------------ */ +/* Runtime */ +/* ------------------------------------------------------------------------ */ + +typedef struct pnet_resolve_slot { + uint32_t req_id; + pnet_dial *dial; +} pnet_resolve_slot; + +#define PNET_RESOLVE_SLOTS 16 + +struct pnet_http_req; +struct pnet_ws_sock; +struct pnet_httpd_server; + +struct pnet_runtime { + pnet_platform platform; + pnet_driver_ops driver; + void *driver_ctx; + const pnet_tls_ops *tls; + void *tls_ctx; + pnet_runtime_config cfg; + pnet_policy policy; + size_t heap_bytes; + size_t heap_high_water; + uint64_t seq; + uint64_t now; /* cached at service()/begin_tick() */ + bool quiesced; + bool has_features_tls; + uint32_t next_resolve_id; + pnet_resolve_slot resolves[PNET_RESOLVE_SLOTS]; + + /* net */ + pnet_queue http_queue; + struct pnet_http_req *http_reqs; /* linked list */ + uint32_t http_live; + int http_next_handle; + pnet_sb http_last_error; + char *http_limits_json; + + /* ws */ + pnet_queue ws_queue; + struct pnet_ws_sock *ws_socks; + uint32_t ws_live; + int ws_next_handle; + pnet_sb ws_last_error; + char *ws_limits_json; + + /* httpd */ + pnet_queue httpd_queue; + struct pnet_httpd_server *httpd_servers; + uint32_t httpd_live; + int httpd_next_handle; + int httpd_next_req; + pnet_sb httpd_last_error; + char *httpd_limits_json; +}; + +uint64_t pnet_now(pnet_runtime *rt); +static inline const pnet_driver_ops *pnet_drv(pnet_runtime *rt) { return &rt->driver; } +/** Set `: ` for a module's lastError. */ +void pnet_set_last_error(pnet_runtime *rt, pnet_sb *sb, const char *code, const char *message); +const char *pnet_io_error_code(int io_err); + +/* Module hooks used by the runtime */ +void pnet_http_init(pnet_runtime *rt); +void pnet_http_shutdown(pnet_runtime *rt); +void pnet_http_service(pnet_runtime *rt); +void pnet_http_freeze(pnet_runtime *rt); +uint64_t pnet_http_next_deadline(pnet_runtime *rt); +bool pnet_http_has_output(pnet_runtime *rt); +void pnet_http_quiesce(pnet_runtime *rt); + +void pnet_ws_init(pnet_runtime *rt); +void pnet_ws_shutdown(pnet_runtime *rt); +void pnet_ws_service(pnet_runtime *rt); +void pnet_ws_freeze(pnet_runtime *rt); +uint64_t pnet_ws_next_deadline(pnet_runtime *rt); +bool pnet_ws_has_output(pnet_runtime *rt); +void pnet_ws_quiesce(pnet_runtime *rt); + +void pnet_httpd_init(pnet_runtime *rt); +void pnet_httpd_shutdown(pnet_runtime *rt); +void pnet_httpd_service(pnet_runtime *rt); +void pnet_httpd_freeze(pnet_runtime *rt); +uint64_t pnet_httpd_next_deadline(pnet_runtime *rt); +bool pnet_httpd_has_output(pnet_runtime *rt); +void pnet_httpd_quiesce(pnet_runtime *rt); + +/* Deadline helper */ +static inline uint64_t pnet_min_deadline(uint64_t a, uint64_t b) { + if (a == 0) return b; + if (b == 0) return a; + return a < b ? a : b; +} + +#endif /* PNET_INTERNAL_H */ diff --git a/engine/net/src/pnet_json.c b/engine/net/src/pnet_json.c new file mode 100644 index 00000000..5ec46b6b --- /dev/null +++ b/engine/net/src/pnet_json.c @@ -0,0 +1,355 @@ +/* Minimal JSON reader for the guest metadata objects (start/connect/listen/ + * respond meta and the policy). Nodes live in a caller-provided array, so a + * parse costs no heap; strings are unescaped on demand. Depth is bounded by + * the node array. */ +#include "pnet_internal.h" + +typedef struct jparser { + const char *s; + size_t len; + size_t pos; + pnet_jdoc *doc; + int depth; +} jparser; + +static void skip_ws(jparser *p) { + while (p->pos < p->len) { + char c = p->s[p->pos]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') p->pos++; + else break; + } +} + +static int new_node(jparser *p, pnet_jtype type) { + if (p->doc->count >= p->doc->cap) return -1; + int idx = p->doc->count++; + pnet_jnode *n = &p->doc->nodes[idx]; + n->type = (uint8_t)type; + n->truthy = false; + n->raw = NULL; + n->raw_len = 0; + n->first_child = -1; + n->next = -1; + return idx; +} + +static int parse_value(jparser *p); + +static bool parse_string_raw(jparser *p, const char **raw, size_t *raw_len) { + if (p->pos >= p->len || p->s[p->pos] != '"') return false; + p->pos++; + size_t start = p->pos; + while (p->pos < p->len) { + char c = p->s[p->pos]; + if (c == '"') { + *raw = p->s + start; + *raw_len = p->pos - start; + p->pos++; + return true; + } + if (c == '\\') { + p->pos++; + if (p->pos >= p->len) return false; + char e = p->s[p->pos]; + if (e == 'u') { + if (p->pos + 4 >= p->len) return false; + for (int k = 1; k <= 4; k++) { + char h = p->s[p->pos + k]; + if (!((h >= '0' && h <= '9') || (h >= 'a' && h <= 'f') || (h >= 'A' && h <= 'F'))) return false; + } + p->pos += 5; + continue; + } + if (!strchr("\"\\/bfnrt", e)) return false; + p->pos++; + continue; + } + if ((unsigned char)c < 0x20) return false; + p->pos++; + } + return false; +} + +static int parse_object(jparser *p) { + int obj = new_node(p, PNET_J_OBJECT); + if (obj < 0) return -1; + p->pos++; /* { */ + skip_ws(p); + int last = -1; + if (p->pos < p->len && p->s[p->pos] == '}') { + p->pos++; + return obj; + } + for (;;) { + skip_ws(p); + const char *raw; + size_t raw_len; + if (!parse_string_raw(p, &raw, &raw_len)) return -1; + int key = new_node(p, PNET_J_STRING); + if (key < 0) return -1; + p->doc->nodes[key].raw = raw; + p->doc->nodes[key].raw_len = raw_len; + skip_ws(p); + if (p->pos >= p->len || p->s[p->pos] != ':') return -1; + p->pos++; + int value = parse_value(p); + if (value < 0) return -1; + p->doc->nodes[key].first_child = value; + if (last < 0) p->doc->nodes[obj].first_child = key; + else p->doc->nodes[last].next = key; + last = key; + skip_ws(p); + if (p->pos >= p->len) return -1; + if (p->s[p->pos] == ',') { + p->pos++; + continue; + } + if (p->s[p->pos] == '}') { + p->pos++; + return obj; + } + return -1; + } +} + +static int parse_array(jparser *p) { + int arr = new_node(p, PNET_J_ARRAY); + if (arr < 0) return -1; + p->pos++; /* [ */ + skip_ws(p); + int last = -1; + if (p->pos < p->len && p->s[p->pos] == ']') { + p->pos++; + return arr; + } + for (;;) { + int value = parse_value(p); + if (value < 0) return -1; + if (last < 0) p->doc->nodes[arr].first_child = value; + else p->doc->nodes[last].next = value; + last = value; + skip_ws(p); + if (p->pos >= p->len) return -1; + if (p->s[p->pos] == ',') { + p->pos++; + continue; + } + if (p->s[p->pos] == ']') { + p->pos++; + return arr; + } + return -1; + } +} + +static int parse_value(jparser *p) { + skip_ws(p); + if (p->pos >= p->len) return -1; + if (++p->depth > 32) return -1; + int result = -1; + char c = p->s[p->pos]; + if (c == '{') result = parse_object(p); + else if (c == '[') result = parse_array(p); + else if (c == '"') { + const char *raw; + size_t raw_len; + if (parse_string_raw(p, &raw, &raw_len)) { + result = new_node(p, PNET_J_STRING); + if (result >= 0) { + p->doc->nodes[result].raw = raw; + p->doc->nodes[result].raw_len = raw_len; + } + } + } else if (c == 't' && p->pos + 4 <= p->len && memcmp(p->s + p->pos, "true", 4) == 0) { + result = new_node(p, PNET_J_BOOL); + if (result >= 0) p->doc->nodes[result].truthy = true; + p->pos += 4; + } else if (c == 'f' && p->pos + 5 <= p->len && memcmp(p->s + p->pos, "false", 5) == 0) { + result = new_node(p, PNET_J_BOOL); + p->pos += 5; + } else if (c == 'n' && p->pos + 4 <= p->len && memcmp(p->s + p->pos, "null", 4) == 0) { + result = new_node(p, PNET_J_NULL); + p->pos += 4; + } else if (c == '-' || (c >= '0' && c <= '9')) { + size_t start = p->pos; + if (c == '-') p->pos++; + size_t digits = 0; + while (p->pos < p->len && p->s[p->pos] >= '0' && p->s[p->pos] <= '9') { p->pos++; digits++; } + if (digits == 0) return -1; + if (p->pos < p->len && p->s[p->pos] == '.') { + p->pos++; + digits = 0; + while (p->pos < p->len && p->s[p->pos] >= '0' && p->s[p->pos] <= '9') { p->pos++; digits++; } + if (digits == 0) return -1; + } + if (p->pos < p->len && (p->s[p->pos] == 'e' || p->s[p->pos] == 'E')) { + p->pos++; + if (p->pos < p->len && (p->s[p->pos] == '+' || p->s[p->pos] == '-')) p->pos++; + digits = 0; + while (p->pos < p->len && p->s[p->pos] >= '0' && p->s[p->pos] <= '9') { p->pos++; digits++; } + if (digits == 0) return -1; + } + result = new_node(p, PNET_J_NUMBER); + if (result >= 0) { + p->doc->nodes[result].raw = p->s + start; + p->doc->nodes[result].raw_len = p->pos - start; + } + } + p->depth--; + return result; +} + +int pnet_json_parse(pnet_jdoc *doc, pnet_jnode *nodes, int cap, const char *text, size_t len) { + doc->nodes = nodes; + doc->count = 0; + doc->cap = cap; + jparser p = {.s = text, .len = len, .pos = 0, .doc = doc, .depth = 0}; + int root = parse_value(&p); + if (root < 0) return -1; + skip_ws(&p); + if (p.pos != p.len) return -1; + return root; +} + +bool pnet_json_key_is(const pnet_jdoc *doc, int key, const char *name) { + const pnet_jnode *n = &doc->nodes[key]; + size_t nl = strlen(name); + /* Keys with escapes never match our plain identifiers. */ + return n->raw_len == nl && memcmp(n->raw, name, nl) == 0; +} + +int pnet_json_get(const pnet_jdoc *doc, int object, const char *key) { + if (object < 0 || doc->nodes[object].type != PNET_J_OBJECT) return -1; + for (int k = doc->nodes[object].first_child; k >= 0; k = doc->nodes[k].next) { + if (pnet_json_key_is(doc, k, key)) return doc->nodes[k].first_child; + } + return -1; +} + +static bool put_utf8(char *out, size_t cap, size_t *o, uint32_t cp) { + char tmp[4]; + size_t n; + if (cp < 0x80) { tmp[0] = (char)cp; n = 1; } + else if (cp < 0x800) { tmp[0] = (char)(0xc0 | (cp >> 6)); tmp[1] = (char)(0x80 | (cp & 63)); n = 2; } + else if (cp < 0x10000) { + tmp[0] = (char)(0xe0 | (cp >> 12)); tmp[1] = (char)(0x80 | ((cp >> 6) & 63)); tmp[2] = (char)(0x80 | (cp & 63)); n = 3; + } else { + tmp[0] = (char)(0xf0 | (cp >> 18)); tmp[1] = (char)(0x80 | ((cp >> 12) & 63)); + tmp[2] = (char)(0x80 | ((cp >> 6) & 63)); tmp[3] = (char)(0x80 | (cp & 63)); n = 4; + } + if (*o + n >= cap) return false; + memcpy(out + *o, tmp, n); + *o += n; + return true; +} + +static int hex4(const char *s) { + int v = 0; + for (int i = 0; i < 4; i++) { + char c = s[i]; + int h = (c >= '0' && c <= '9') ? c - '0' : (c >= 'a' && c <= 'f') ? c - 'a' + 10 : (c >= 'A' && c <= 'F') ? c - 'A' + 10 : -1; + if (h < 0) return -1; + v = (v << 4) | h; + } + return v; +} + +bool pnet_json_string(const pnet_jdoc *doc, int node, char *out, size_t cap, size_t *out_len) { + if (node < 0 || doc->nodes[node].type != PNET_J_STRING || cap == 0) return false; + const char *s = doc->nodes[node].raw; + size_t len = doc->nodes[node].raw_len; + size_t o = 0; + for (size_t i = 0; i < len;) { + char c = s[i]; + if (c != '\\') { + if (o + 1 >= cap) return false; + out[o++] = c; + i++; + continue; + } + i++; + if (i >= len) return false; + char e = s[i++]; + uint32_t cp; + switch (e) { + case '"': cp = '"'; break; + case '\\': cp = '\\'; break; + case '/': cp = '/'; break; + case 'b': cp = '\b'; break; + case 'f': cp = '\f'; break; + case 'n': cp = '\n'; break; + case 'r': cp = '\r'; break; + case 't': cp = '\t'; break; + case 'u': { + if (i + 4 > len) return false; + int v = hex4(s + i); + if (v < 0) return false; + i += 4; + cp = (uint32_t)v; + if (cp >= 0xd800 && cp <= 0xdbff) { + if (i + 6 <= len && s[i] == '\\' && s[i + 1] == 'u') { + int lo = hex4(s + i + 2); + if (lo >= 0xdc00 && lo <= 0xdfff) { + cp = 0x10000 + ((cp - 0xd800) << 10) + ((uint32_t)lo - 0xdc00); + i += 6; + } else { + cp = 0xfffd; + } + } else { + cp = 0xfffd; + } + } else if (cp >= 0xdc00 && cp <= 0xdfff) { + cp = 0xfffd; + } + break; + } + default: + return false; + } + if (!put_utf8(out, cap, &o, cp)) return false; + } + out[o] = 0; + if (out_len) *out_len = o; + return true; +} + +char *pnet_json_string_dup(pnet_runtime *rt, const pnet_jdoc *doc, int node, size_t *out_len) { + if (node < 0 || doc->nodes[node].type != PNET_J_STRING) return NULL; + size_t cap = doc->nodes[node].raw_len + 1; + char *out = pnet_alloc(rt, cap); + if (!out) return NULL; + size_t len = 0; + if (!pnet_json_string(doc, node, out, cap, &len)) { + pnet_free(rt, out, cap); + return NULL; + } + if (out_len) *out_len = len; + /* Shrink bookkeeping: keep the block as allocated (cap bytes). Callers free + * with pnet_free_str which uses strlen+1; keep exact only when equal. */ + if (len + 1 != cap) { + char *exact = pnet_strdup_n(rt, out, len); + pnet_free(rt, out, cap); + return exact; + } + return out; +} + +bool pnet_json_i64(const pnet_jdoc *doc, int node, int64_t *out) { + if (node < 0 || doc->nodes[node].type != PNET_J_NUMBER) return false; + const char *s = doc->nodes[node].raw; + size_t len = doc->nodes[node].raw_len; + bool neg = false; + size_t i = 0; + if (i < len && s[i] == '-') { neg = true; i++; } + int64_t v = 0; + size_t digits = 0; + for (; i < len; i++) { + if (s[i] < '0' || s[i] > '9') return false; /* fraction/exponent: not integral */ + if (v > (INT64_MAX - 9) / 10) return false; + v = v * 10 + (s[i] - '0'); + digits++; + } + if (digits == 0) return false; + *out = neg ? -v : v; + return true; +} diff --git a/engine/net/src/pnet_policy.c b/engine/net/src/pnet_policy.c new file mode 100644 index 00000000..a64028d8 --- /dev/null +++ b/engine/net/src/pnet_policy.c @@ -0,0 +1,214 @@ +/* Immutable network policy: the Build Plan projection the host hands to the + * runtime at creation. Endpoint tuples + * are matched before DNS, each candidate address after DNS, and listen + * tuples before bind. The guest can never widen it. */ +#include "pnet_internal.h" + +static const char *PROTO_NAMES[PNET_PROTO_COUNT] = {"http", "https", "ws", "wss"}; + +pnet_proto pnet_proto_from_scheme(const char *scheme) { + for (int i = 0; i < PNET_PROTO_COUNT; i++) + if (strcmp(scheme, PROTO_NAMES[i]) == 0) return (pnet_proto)i; + return PNET_PROTO_COUNT; +} + +bool pnet_proto_is_plaintext(pnet_proto proto) { + return proto == PNET_PROTO_HTTP || proto == PNET_PROTO_WS; +} + +static bool parse_rule(pnet_runtime *rt, const pnet_jdoc *doc, int obj, bool listen, pnet_rule *rule) { + memset(rule, 0, sizeof *rule); + char buf[264]; + int proto = pnet_json_get(doc, obj, "protocol"); + if (!pnet_json_string(doc, proto, buf, sizeof buf, NULL)) return false; + pnet_proto p = pnet_proto_from_scheme(buf); + if (p == PNET_PROTO_COUNT) return false; + if (listen && (p == PNET_PROTO_WS || p == PNET_PROTO_WSS)) { + /* ws/wss listen tuples belong to the (staged) WebSocket server; accept + * them for forward compatibility but they never match an HTTP listen. */ + } + rule->proto = (uint8_t)p; + int host = pnet_json_get(doc, obj, listen ? "address" : "host"); + size_t host_len; + if (!pnet_json_string(doc, host, buf, sizeof buf, &host_len) || host_len == 0) return false; + pnet_lower(buf, host_len); + if (buf[host_len - 1] == '.' && host_len > 1) buf[--host_len] = 0; + if (pnet_parse_ip_literal(buf, host_len, &rule->ip)) { + rule->is_ip = true; + } else if (listen) { + return false; /* listen addresses are IP literals */ + } else if (host_len > 2 && buf[0] == '*' && buf[1] == '.') { + rule->wildcard = true; + } + rule->host = pnet_strdup_n(rt, buf, host_len); + if (!rule->host) return false; + int port = pnet_json_get(doc, obj, "port"); + int64_t v; + if (pnet_json_type(doc, port) == PNET_J_NUMBER) { + if (!pnet_json_i64(doc, port, &v) || v < 0 || v > 65535) return false; + if (v == 0) return false; + rule->port_min = rule->port_max = (uint16_t)v; + } else if (pnet_json_type(doc, port) == PNET_J_STRING) { + if (!listen || !pnet_json_string(doc, port, buf, sizeof buf, NULL) || strcmp(buf, "ephemeral") != 0) return false; + rule->ephemeral = true; + } else if (pnet_json_type(doc, port) == PNET_J_OBJECT) { + int64_t lo, hi; + if (!pnet_json_i64(doc, pnet_json_get(doc, port, "min"), &lo) || !pnet_json_i64(doc, pnet_json_get(doc, port, "max"), &hi)) + return false; + if (lo < 1 || hi > 65535 || lo > hi) return false; + rule->port_min = (uint16_t)lo; + rule->port_max = (uint16_t)hi; + } else { + return false; + } + return true; +} + +static bool parse_rules(pnet_runtime *rt, const pnet_jdoc *doc, int arr, bool listen, pnet_rule **out, size_t *count) { + *out = NULL; + *count = 0; + if (arr < 0) return true; + if (pnet_json_type(doc, arr) != PNET_J_ARRAY) return false; + size_t n = 0; + for (int e = pnet_json_first(doc, arr); e >= 0; e = pnet_json_next(doc, e)) n++; + if (n == 0) return true; + pnet_rule *rules = pnet_zalloc(rt, n * sizeof(pnet_rule)); + if (!rules) return false; + size_t i = 0; + for (int e = pnet_json_first(doc, arr); e >= 0; e = pnet_json_next(doc, e)) { + if (!parse_rule(rt, doc, e, listen, &rules[i])) { + for (size_t k = 0; k <= i; k++) + if (rules[k].host) pnet_free_str(rt, rules[k].host); + pnet_free(rt, rules, n * sizeof(pnet_rule)); + return false; + } + i++; + } + *out = rules; + *count = n; + return true; +} + +bool pnet_policy_parse(pnet_runtime *rt, pnet_policy *policy, const char *json) { + memset(policy, 0, sizeof *policy); + if (!json) return false; + size_t len = strlen(json); + int cap = 512; + pnet_jnode *nodes = pnet_alloc(rt, (size_t)cap * sizeof(pnet_jnode)); + if (!nodes) return false; + pnet_jdoc doc; + int root = pnet_json_parse(&doc, nodes, cap, json, len); + bool ok = root >= 0 && pnet_json_type(&doc, root) == PNET_J_OBJECT; + if (ok) ok = parse_rules(rt, &doc, pnet_json_get(&doc, root, "connect"), false, &policy->connect, &policy->connect_count); + if (ok) ok = parse_rules(rt, &doc, pnet_json_get(&doc, root, "listen"), true, &policy->listen, &policy->listen_count); + if (ok) { + int creds = pnet_json_get(&doc, root, "credentials"); + if (creds >= 0) { + if (pnet_json_type(&doc, creds) != PNET_J_ARRAY) ok = false; + else { + size_t n = 0; + for (int e = pnet_json_first(&doc, creds); e >= 0; e = pnet_json_next(&doc, e)) n++; + if (n) { + policy->credentials = pnet_zalloc(rt, n * sizeof(char *)); + if (!policy->credentials) ok = false; + else { + policy->credential_count = n; + size_t i = 0; + for (int e = pnet_json_first(&doc, creds); e >= 0 && ok; e = pnet_json_next(&doc, e)) { + policy->credentials[i] = pnet_json_string_dup(rt, &doc, e, NULL); + if (!policy->credentials[i]) ok = false; + i++; + } + } + } + } + } + } + if (ok) { + int f = pnet_json_get(&doc, root, "insecureTransport"); + policy->insecure_transport = f >= 0 && pnet_json_type(&doc, f) == PNET_J_BOOL && doc.nodes[f].truthy; + f = pnet_json_get(&doc, root, "localNetwork"); + policy->local_network = f >= 0 && pnet_json_type(&doc, f) == PNET_J_BOOL && doc.nodes[f].truthy; + f = pnet_json_get(&doc, root, "allowInvalidTlsForDevelopment"); + policy->allow_invalid_tls_for_development = f >= 0 && pnet_json_type(&doc, f) == PNET_J_BOOL && doc.nodes[f].truthy; + } + pnet_free(rt, nodes, (size_t)cap * sizeof(pnet_jnode)); + if (!ok) pnet_policy_free(rt, policy); + return ok; +} + +void pnet_policy_free(pnet_runtime *rt, pnet_policy *policy) { + for (size_t i = 0; i < policy->connect_count; i++) + if (policy->connect[i].host) pnet_free_str(rt, policy->connect[i].host); + if (policy->connect) pnet_free(rt, policy->connect, policy->connect_count * sizeof(pnet_rule)); + for (size_t i = 0; i < policy->listen_count; i++) + if (policy->listen[i].host) pnet_free_str(rt, policy->listen[i].host); + if (policy->listen) pnet_free(rt, policy->listen, policy->listen_count * sizeof(pnet_rule)); + for (size_t i = 0; i < policy->credential_count; i++) + if (policy->credentials[i]) pnet_free_str(rt, policy->credentials[i]); + if (policy->credentials) pnet_free(rt, policy->credentials, policy->credential_count * sizeof(char *)); + memset(policy, 0, sizeof *policy); +} + +static bool host_matches(const pnet_rule *rule, const char *host) { + if (rule->is_ip) { + pnet_addr a; + if (!pnet_parse_ip_literal(host, strlen(host), &a)) return false; + return a.family == rule->ip.family && memcmp(a.addr, rule->ip.addr, a.family == 4 ? 4 : 16) == 0; + } + if (rule->wildcard) { + /* "*.example.com" matches exactly one non-empty label. */ + const char *suffix = rule->host + 1; /* ".example.com" */ + size_t hl = strlen(host), sl = strlen(suffix); + if (hl <= sl) return false; + if (strcmp(host + (hl - sl), suffix) != 0) return false; + size_t label = hl - sl; + if (label == 0) return false; + for (size_t i = 0; i < label; i++) + if (host[i] == '.') return false; + return true; + } + return strcmp(rule->host, host) == 0; +} + +static bool port_matches(const pnet_rule *rule, uint16_t port) { + if (rule->ephemeral) return port == 0; + return port >= rule->port_min && port <= rule->port_max; +} + +bool pnet_policy_allows_connect(const pnet_policy *p, pnet_proto proto, const char *host, uint16_t port) { + if (pnet_proto_is_plaintext(proto) && !p->insecure_transport) return false; + for (size_t i = 0; i < p->connect_count; i++) { + const pnet_rule *r = &p->connect[i]; + if (r->proto != (uint8_t)proto) continue; + if (!port_matches(r, port)) continue; + if (host_matches(r, host)) return true; + } + return false; +} + +bool pnet_policy_allows_address(const pnet_policy *p, const pnet_addr *addr) { + if (pnet_addr_is_multicast(addr)) return false; + if (pnet_addr_is_public(addr)) return true; + return p->local_network; +} + +bool pnet_policy_allows_listen(const pnet_policy *p, pnet_proto proto, const pnet_addr *addr, uint16_t port) { + if (pnet_proto_is_plaintext(proto) && !p->insecure_transport) return false; + for (size_t i = 0; i < p->listen_count; i++) { + const pnet_rule *r = &p->listen[i]; + if (r->proto != (uint8_t)proto) continue; + if (!port_matches(r, port)) continue; + if (!r->is_ip) continue; + if (r->ip.family != addr->family) continue; + if (memcmp(r->ip.addr, addr->addr, addr->family == 4 ? 4 : 16) != 0) continue; + return true; + } + return false; +} + +bool pnet_policy_has_credential(const pnet_policy *p, const char *id) { + for (size_t i = 0; i < p->credential_count; i++) + if (strcmp(p->credentials[i], id) == 0) return true; + return false; +} diff --git a/engine/net/src/pnet_runtime.c b/engine/net/src/pnet_runtime.c new file mode 100644 index 00000000..efb04ea2 --- /dev/null +++ b/engine/net/src/pnet_runtime.c @@ -0,0 +1,837 @@ +/* Shared Async Runtime: creation, policy, tick queues, connection and dialer + * helpers, service dispatch and the tick boundary. Protocol behaviour lives + * in pnet_http_client.c, pnet_http_server.c and pnet_ws.c. */ +#include + +#include "pnet_internal.h" + +/* ------------------------------------------------------------------------ */ +/* Config */ +/* ------------------------------------------------------------------------ */ + +void pnet_runtime_config_defaults(pnet_runtime_config *cfg) { + memset(cfg, 0, sizeof *cfg); + cfg->max_heap_bytes = 0; + cfg->http_max_inflight = PNET_MAX_INFLIGHT; + cfg->http_max_request_bytes = PNET_MAX_REQUEST_BYTES; + cfg->http_default_queue_bytes = PNET_DEFAULT_QUEUE_BYTES; + cfg->http_max_queue_bytes = PNET_MAX_QUEUE_BYTES; + cfg->http_default_aggregate_bytes = PNET_DEFAULT_AGGREGATE_BYTES; + cfg->http_max_aggregate_bytes = PNET_MAX_AGGREGATE_BYTES; + cfg->http_max_events_per_tick = PNET_MAX_EVENTS_PER_TICK; + cfg->http_max_tick_bytes = PNET_MAX_TICK_BYTES; + cfg->http_max_headers = PNET_MAX_HEADERS; + cfg->http_max_header_bytes = PNET_MAX_HEADER_BYTES; + cfg->http_default_timeout_ms = PNET_DEFAULT_TIMEOUT_MS; + cfg->http_max_timeout_ms = PNET_MAX_TIMEOUT_MS; + cfg->http_max_redirects = PNET_MAX_REDIRECTS; + cfg->ws_max_sockets = PWS_MAX_SOCKETS; + cfg->ws_max_message_bytes = PWS_MAX_MESSAGE_BYTES; + cfg->ws_max_receive_queue_bytes = PWS_MAX_RECEIVE_QUEUE_BYTES; + cfg->ws_max_receive_queue_messages = PWS_MAX_RECEIVE_QUEUE_MESSAGES; + cfg->ws_max_send_queue_bytes = PWS_MAX_SEND_QUEUE_BYTES; + cfg->ws_send_high_water_bytes = PWS_SEND_HIGH_WATER_BYTES; + cfg->ws_send_low_water_bytes = PWS_SEND_LOW_WATER_BYTES; + cfg->ws_max_events_per_tick = PWS_MAX_EVENTS_PER_TICK; + cfg->ws_max_tick_bytes = PWS_MAX_TICK_BYTES; + cfg->ws_default_connect_ms = PWS_DEFAULT_CONNECT_MS; + cfg->ws_max_connect_ms = PWS_MAX_CONNECT_MS; + cfg->ws_default_close_ms = PWS_DEFAULT_CLOSE_MS; + cfg->httpd_max_servers = PHTTPD_MAX_SERVERS; + cfg->httpd_max_connections = PHTTPD_MAX_CONNECTIONS; + cfg->httpd_max_inflight = PHTTPD_MAX_INFLIGHT; + cfg->httpd_max_header_bytes = PHTTPD_MAX_HEADER_BYTES; + cfg->httpd_max_headers = PHTTPD_MAX_HEADERS; + cfg->httpd_max_target_bytes = PHTTPD_MAX_TARGET_BYTES; + cfg->httpd_default_request_queue_bytes = PHTTPD_DEFAULT_REQUEST_QUEUE_BYTES; + cfg->httpd_max_request_queue_bytes = PHTTPD_MAX_REQUEST_QUEUE_BYTES; + cfg->httpd_max_send_queue_bytes = PHTTPD_MAX_SEND_QUEUE_BYTES; + cfg->httpd_send_high_water_bytes = PHTTPD_SEND_HIGH_WATER_BYTES; + cfg->httpd_send_low_water_bytes = PHTTPD_SEND_LOW_WATER_BYTES; + cfg->httpd_max_events_per_tick = PHTTPD_MAX_EVENTS_PER_TICK; + cfg->httpd_max_tick_bytes = PHTTPD_MAX_TICK_BYTES; + cfg->io_chunk_bytes = 2048; + cfg->development_build = false; +} + +#define CLAMP_U32(field, ceiling) \ + do { if (cfg->field == 0 || cfg->field > (ceiling)) cfg->field = (ceiling); } while (0) +#define CLAMP_SZ(field, ceiling) \ + do { if (cfg->field == 0 || cfg->field > (ceiling)) cfg->field = (ceiling); } while (0) + +static void clamp_config(pnet_runtime_config *cfg) { + CLAMP_U32(http_max_inflight, PNET_MAX_INFLIGHT); + CLAMP_SZ(http_max_request_bytes, PNET_MAX_REQUEST_BYTES); + CLAMP_SZ(http_max_queue_bytes, PNET_MAX_QUEUE_BYTES); + CLAMP_SZ(http_default_queue_bytes, cfg->http_max_queue_bytes); + CLAMP_SZ(http_max_aggregate_bytes, PNET_MAX_AGGREGATE_BYTES); + CLAMP_SZ(http_default_aggregate_bytes, cfg->http_max_aggregate_bytes); + CLAMP_U32(http_max_events_per_tick, PNET_MAX_EVENTS_PER_TICK); + CLAMP_SZ(http_max_tick_bytes, PNET_MAX_TICK_BYTES); + CLAMP_U32(http_max_headers, PNET_MAX_HEADERS); + CLAMP_SZ(http_max_header_bytes, PNET_MAX_HEADER_BYTES); + CLAMP_U32(http_max_timeout_ms, PNET_MAX_TIMEOUT_MS); + CLAMP_U32(http_default_timeout_ms, cfg->http_max_timeout_ms); + CLAMP_U32(http_max_redirects, PNET_MAX_REDIRECTS); + CLAMP_U32(ws_max_sockets, PWS_MAX_SOCKETS); + CLAMP_SZ(ws_max_message_bytes, PWS_MAX_MESSAGE_BYTES); + CLAMP_SZ(ws_max_receive_queue_bytes, PWS_MAX_RECEIVE_QUEUE_BYTES); + CLAMP_U32(ws_max_receive_queue_messages, PWS_MAX_RECEIVE_QUEUE_MESSAGES); + CLAMP_SZ(ws_max_send_queue_bytes, PWS_MAX_SEND_QUEUE_BYTES); + CLAMP_SZ(ws_send_high_water_bytes, cfg->ws_max_send_queue_bytes); + CLAMP_SZ(ws_send_low_water_bytes, cfg->ws_send_high_water_bytes); + CLAMP_U32(ws_max_events_per_tick, PWS_MAX_EVENTS_PER_TICK); + CLAMP_SZ(ws_max_tick_bytes, PWS_MAX_TICK_BYTES); + CLAMP_U32(ws_max_connect_ms, PWS_MAX_CONNECT_MS); + CLAMP_U32(ws_default_connect_ms, cfg->ws_max_connect_ms); + CLAMP_U32(ws_default_close_ms, cfg->ws_max_connect_ms); + CLAMP_U32(httpd_max_servers, PHTTPD_MAX_SERVERS); + CLAMP_U32(httpd_max_connections, PHTTPD_MAX_CONNECTIONS); + CLAMP_U32(httpd_max_inflight, PHTTPD_MAX_INFLIGHT); + CLAMP_SZ(httpd_max_header_bytes, PHTTPD_MAX_HEADER_BYTES); + CLAMP_U32(httpd_max_headers, PHTTPD_MAX_HEADERS); + CLAMP_SZ(httpd_max_target_bytes, PHTTPD_MAX_TARGET_BYTES); + CLAMP_SZ(httpd_max_request_queue_bytes, PHTTPD_MAX_REQUEST_QUEUE_BYTES); + CLAMP_SZ(httpd_default_request_queue_bytes, cfg->httpd_max_request_queue_bytes); + CLAMP_SZ(httpd_max_send_queue_bytes, PHTTPD_MAX_SEND_QUEUE_BYTES); + CLAMP_SZ(httpd_send_high_water_bytes, cfg->httpd_max_send_queue_bytes); + CLAMP_SZ(httpd_send_low_water_bytes, cfg->httpd_send_high_water_bytes); + CLAMP_U32(httpd_max_events_per_tick, PHTTPD_MAX_EVENTS_PER_TICK); + CLAMP_SZ(httpd_max_tick_bytes, PHTTPD_MAX_TICK_BYTES); + if (cfg->io_chunk_bytes < 256) cfg->io_chunk_bytes = 256; + if (cfg->io_chunk_bytes > 65536) cfg->io_chunk_bytes = 65536; +} + +/* ------------------------------------------------------------------------ */ +/* Runtime lifecycle */ +/* ------------------------------------------------------------------------ */ + +uint64_t pnet_now(pnet_runtime *rt) { + return rt->platform.now_ms(rt->platform.ctx); +} + +const char *pnet_io_error_code(int io_err) { + switch (io_err) { + case PNET_IO_REFUSED: + case PNET_IO_TIMEOUT: + return PNET_ERROR_CONNECT; + case PNET_IO_ADDRINUSE: + return PNET_ERROR_ADDRESS_IN_USE; + case PNET_IO_NOMEM: + return PNET_ERROR_RESOURCE_LIMIT; + case PNET_IO_CLOSED: + case PNET_IO_EOF: + return PNET_ERROR_CLOSED; + default: + return PNET_ERROR_OTHER; + } +} + +void pnet_set_last_error(pnet_runtime *rt, pnet_sb *sb, const char *code, const char *message) { + pnet_sb_clear(sb); + pnet_sb_puts(rt, sb, code); + pnet_sb_puts(rt, sb, ": "); + pnet_sb_puts(rt, sb, message); +} + +pnet_runtime *pnet_runtime_create(const pnet_platform *platform, const pnet_driver_ops *driver, void *driver_ctx, + const pnet_runtime_config *config, const char *policy_json) { + return pnet_runtime_create_tls(platform, driver, driver_ctx, NULL, NULL, config, policy_json); +} + +pnet_runtime *pnet_runtime_create_tls(const pnet_platform *platform, const pnet_driver_ops *driver, void *driver_ctx, + const pnet_tls_ops *tls, void *tls_ctx, const pnet_runtime_config *config, + const char *policy_json) { + if (!platform || !platform->alloc || !platform->free || !platform->now_ms || !platform->random || !driver) return NULL; + pnet_runtime *rt = platform->alloc(platform->ctx, sizeof *rt); + if (!rt) return NULL; + memset(rt, 0, sizeof *rt); + rt->platform = *platform; + rt->driver = *driver; + rt->driver_ctx = driver_ctx; + rt->tls = tls; + rt->tls_ctx = tls_ctx; + rt->has_features_tls = tls != NULL; + if (config) rt->cfg = *config; + else pnet_runtime_config_defaults(&rt->cfg); + clamp_config(&rt->cfg); + rt->heap_bytes = sizeof *rt; + rt->heap_high_water = rt->heap_bytes; + rt->next_resolve_id = 1; + rt->http_next_handle = 1; + rt->ws_next_handle = 1; + rt->httpd_next_handle = 1; + rt->httpd_next_req = 1; + pnet_sb_init(&rt->http_last_error); + pnet_sb_init(&rt->ws_last_error); + pnet_sb_init(&rt->httpd_last_error); + pnet_queue_init(&rt->http_queue, rt->cfg.http_max_events_per_tick, rt->cfg.http_max_tick_bytes); + pnet_queue_init(&rt->ws_queue, rt->cfg.ws_max_events_per_tick, rt->cfg.ws_max_tick_bytes); + pnet_queue_init(&rt->httpd_queue, rt->cfg.httpd_max_events_per_tick, rt->cfg.httpd_max_tick_bytes); + if (!pnet_policy_parse(rt, &rt->policy, policy_json)) { + pnet_logf(rt, PNET_LOG_ERROR, "pnet: invalid policy JSON"); + platform->free(platform->ctx, rt, sizeof *rt); + return NULL; + } + pnet_http_init(rt); + pnet_ws_init(rt); + pnet_httpd_init(rt); + rt->now = pnet_now(rt); + return rt; +} + +void pnet_runtime_destroy(pnet_runtime *rt) { + if (!rt) return; + pnet_http_shutdown(rt); + pnet_ws_shutdown(rt); + pnet_httpd_shutdown(rt); + pnet_queue_free(rt, &rt->http_queue); + pnet_queue_free(rt, &rt->ws_queue); + pnet_queue_free(rt, &rt->httpd_queue); + pnet_sb_free(rt, &rt->http_last_error); + pnet_sb_free(rt, &rt->ws_last_error); + pnet_sb_free(rt, &rt->httpd_last_error); + pnet_policy_free(rt, &rt->policy); + for (int i = 0; i < PNET_RESOLVE_SLOTS; i++) { + if (rt->resolves[i].req_id && rt->driver.resolve_cancel) rt->driver.resolve_cancel(rt->driver_ctx, rt->resolves[i].req_id); + } + pnet_platform p = rt->platform; + p.free(p.ctx, rt, sizeof *rt); +} + +void pnet_runtime_service(pnet_runtime *rt) { + rt->now = pnet_now(rt); + pnet_http_service(rt); + pnet_ws_service(rt); + pnet_httpd_service(rt); +} + +uint64_t pnet_runtime_next_deadline_ms(pnet_runtime *rt) { + uint64_t d = pnet_http_next_deadline(rt); + d = pnet_min_deadline(d, pnet_ws_next_deadline(rt)); + d = pnet_min_deadline(d, pnet_httpd_next_deadline(rt)); + return d; +} + +bool pnet_runtime_has_pending_output(pnet_runtime *rt) { + return pnet_http_has_output(rt) || pnet_ws_has_output(rt) || pnet_httpd_has_output(rt); +} + +void pnet_runtime_quiesce(pnet_runtime *rt) { + rt->quiesced = true; + pnet_http_quiesce(rt); + pnet_ws_quiesce(rt); + pnet_httpd_quiesce(rt); +} + +void pnet_runtime_begin_tick(pnet_runtime *rt) { + rt->now = pnet_now(rt); + pnet_http_freeze(rt); + pnet_ws_freeze(rt); + pnet_httpd_freeze(rt); + pnet_queue_freeze(rt, &rt->http_queue); + pnet_queue_freeze(rt, &rt->ws_queue); + pnet_queue_freeze(rt, &rt->httpd_queue); +} + +size_t pnet_runtime_heap_bytes(pnet_runtime *rt) { + return rt->heap_bytes; +} + +bool pnet_runtime_has_live_handles(pnet_runtime *rt) { + return rt->http_live > 0 || rt->ws_live > 0 || rt->httpd_live > 0; +} + +/* ------------------------------------------------------------------------ */ +/* Resolver slots */ +/* ------------------------------------------------------------------------ */ + +static uint32_t resolve_register(pnet_runtime *rt, pnet_dial *dial) { + for (int i = 0; i < PNET_RESOLVE_SLOTS; i++) { + if (rt->resolves[i].req_id == 0) { + uint32_t id = rt->next_resolve_id++; + if (rt->next_resolve_id == 0) rt->next_resolve_id = 1; + rt->resolves[i].req_id = id; + rt->resolves[i].dial = dial; + return id; + } + } + return 0; +} + +static void resolve_unregister(pnet_runtime *rt, uint32_t req_id) { + for (int i = 0; i < PNET_RESOLVE_SLOTS; i++) { + if (rt->resolves[i].req_id == req_id) { + rt->resolves[i].req_id = 0; + rt->resolves[i].dial = NULL; + } + } +} + +void pnet_runtime_resolve_done(pnet_runtime *rt, uint32_t req_id, const pnet_addr *addrs, size_t count, int err) { + for (int i = 0; i < PNET_RESOLVE_SLOTS; i++) { + if (rt->resolves[i].req_id == req_id) { + pnet_dial *dial = rt->resolves[i].dial; + rt->resolves[i].req_id = 0; + rt->resolves[i].dial = NULL; + if (dial) pnet_dial_resolved(rt, dial, addrs, count, err); + return; + } + } +} + +/* ------------------------------------------------------------------------ */ +/* Events */ +/* ------------------------------------------------------------------------ */ + +void pnet_queue_init(pnet_queue *q, uint32_t max_events, size_t max_bytes) { + memset(q, 0, sizeof *q); + pnet_sb_init(&q->poll_buf); + q->max_events = max_events; + q->max_bytes = max_bytes; +} + +static void event_free(pnet_runtime *rt, pnet_event *e) { + if (e->json) pnet_free(rt, e->json, e->json_len + 1); + pnet_free(rt, e, sizeof *e); +} + +void pnet_queue_free(pnet_runtime *rt, pnet_queue *q) { + pnet_event *e = q->pending_head; + while (e) { + pnet_event *n = e->next; + event_free(rt, e); + e = n; + } + e = q->visible_head; + while (e) { + pnet_event *n = e->next; + event_free(rt, e); + e = n; + } + pnet_sb_free(rt, &q->poll_buf); + q->pending_head = q->pending_tail = NULL; + q->visible_head = q->visible_tail = NULL; + q->pending_count = q->visible_count = 0; +} + +static void pending_append(pnet_queue *q, pnet_event *e) { + e->next = NULL; + if (q->pending_tail) q->pending_tail->next = e; + else q->pending_head = e; + q->pending_tail = e; + q->pending_count++; +} + +bool pnet_queue_push(pnet_runtime *rt, pnet_queue *q, int handle, bool terminal, size_t weight, char *json, + size_t json_len) { + if (!json) return false; + pnet_event *e = pnet_alloc(rt, sizeof *e); + if (!e) { + pnet_free(rt, json, json_len + 1); + return false; + } + e->seq = ++rt->seq; + e->handle = handle; + e->terminal = terminal; + e->readable = false; + e->weight = weight; + e->json = json; + e->json_len = json_len; + pending_append(q, e); + return true; +} + +bool pnet_queue_push_readable(pnet_runtime *rt, pnet_queue *q, int handle, const char *field, size_t avail) { + char buf[96]; + int n = snprintf(buf, sizeof buf, "{\"t\":\"readable\",\"%s\":%d,\"avail\":%zu}", field, handle, avail); + if (n <= 0) return false; + char *json = pnet_strdup_n(rt, buf, (size_t)n); + if (!json) return false; + pnet_event *e = pnet_alloc(rt, sizeof *e); + if (!e) { + pnet_free(rt, json, (size_t)n + 1); + return false; + } + e->seq = ++rt->seq; + e->handle = handle; + e->terminal = false; + e->readable = true; + e->weight = avail; + e->json = json; + e->json_len = (size_t)n; + /* Insert before the handle's terminal event when one is already pending + * so the guest observes readable before end/error. */ + pnet_event *prev = NULL; + for (pnet_event *cur = q->pending_head; cur; prev = cur, cur = cur->next) { + if (cur->handle == handle && cur->terminal) { + e->next = cur; + if (prev) prev->next = e; + else q->pending_head = e; + q->pending_count++; + /* Sequence order: give it the terminal's slot ordering by keeping the + * list order authoritative (poll renders list order). */ + return true; + } + } + pending_append(q, e); + return true; +} + +void pnet_queue_freeze(pnet_runtime *rt, pnet_queue *q) { + (void)rt; + size_t events = 0; + size_t bytes = 0; + while (q->pending_head) { + pnet_event *e = q->pending_head; + if (events > 0 && (events >= q->max_events || bytes + e->weight > q->max_bytes)) break; + q->pending_head = e->next; + if (!q->pending_head) q->pending_tail = NULL; + q->pending_count--; + e->next = NULL; + if (q->visible_tail) q->visible_tail->next = e; + else q->visible_head = e; + q->visible_tail = e; + q->visible_count++; + events++; + bytes += e->weight; + } +} + +const char *pnet_queue_poll(pnet_runtime *rt, pnet_queue *q, size_t *len) { + if (!q->visible_head) { + if (len) *len = 0; + return NULL; + } + pnet_sb_clear(&q->poll_buf); + pnet_sb_putc(rt, &q->poll_buf, '['); + bool first = true; + while (q->visible_head) { + pnet_event *e = q->visible_head; + q->visible_head = e->next; + if (!q->visible_head) q->visible_tail = NULL; + q->visible_count--; + if (!first) pnet_sb_putc(rt, &q->poll_buf, ','); + first = false; + pnet_sb_append(rt, &q->poll_buf, e->json, e->json_len); + event_free(rt, e); + } + pnet_sb_putc(rt, &q->poll_buf, ']'); + if (q->poll_buf.failed) { + /* Out of memory while rendering: report an empty batch; the events are + * gone, but every handle keeps its own terminal accounting so the guest + * learns about it through the next terminal event or its own timeout. */ + pnet_logf(rt, PNET_LOG_ERROR, "pnet: poll batch allocation failed"); + if (len) *len = 0; + return NULL; + } + if (len) *len = q->poll_buf.len; + return pnet_sb_cstr(&q->poll_buf); +} + +void pnet_queue_drop_handle(pnet_runtime *rt, pnet_queue *q, int handle) { + pnet_event **pp = &q->pending_head; + pnet_event *prev = NULL; + while (*pp) { + pnet_event *e = *pp; + if (e->handle == handle) { + *pp = e->next; + if (q->pending_tail == e) q->pending_tail = prev; + q->pending_count--; + event_free(rt, e); + continue; + } + prev = e; + pp = &e->next; + } +} + +char *pnet_event_json(pnet_runtime *rt, const char *t, const char *id_key, int id, const char *tail, size_t tail_len, + size_t *out_len) { + pnet_sb sb; + pnet_sb_init(&sb); + pnet_sb_printf(rt, &sb, "{\"t\":\"%s\",\"%s\":%d", t, id_key, id); + if (tail_len) pnet_sb_append(rt, &sb, tail, tail_len); + pnet_sb_putc(rt, &sb, '}'); + if (sb.failed) { + pnet_sb_free(rt, &sb); + return NULL; + } + /* Hand the buffer over with exact accounting (len+1). */ + char *out = pnet_strdup_n(rt, sb.data, sb.len); + if (out_len) *out_len = sb.len; + pnet_sb_free(rt, &sb); + return out; +} + +bool pnet_push_error_event(pnet_runtime *rt, pnet_queue *q, const char *id_key, int id, const char *code, + const char *message, const char *cause) { + pnet_sb sb; + pnet_sb_init(&sb); + pnet_sb_puts(rt, &sb, ",\"code\":"); + pnet_sb_json_string(rt, &sb, code, strlen(code)); + pnet_sb_puts(rt, &sb, ",\"message\":"); + pnet_sb_json_string(rt, &sb, message ? message : "", message ? strlen(message) : 0); + if (cause) { + pnet_sb_puts(rt, &sb, ",\"causeCode\":"); + pnet_sb_json_string(rt, &sb, cause, strlen(cause)); + } + size_t len = 0; + char *json = sb.failed ? NULL : pnet_event_json(rt, "error", id_key, id, sb.data, sb.len, &len); + pnet_sb_free(rt, &sb); + if (!json) return false; + return pnet_queue_push(rt, q, id, true, 0, json, len); +} + +/* ------------------------------------------------------------------------ */ +/* Connection */ +/* ------------------------------------------------------------------------ */ + +void pnet_conn_init(pnet_conn *c) { + memset(c, 0, sizeof *c); + c->sock = PNET_SOCK_INVALID; + c->state = PNET_CONN_IDLE; + c->read_wanted = true; + pnet_bq_init(&c->tx); +} + +bool pnet_conn_connect(pnet_runtime *rt, pnet_conn *c, const pnet_addr *addr, int *err) { + int e = 0; + pnet_sock s = rt->driver.connect(rt->driver_ctx, addr, &e); + if (s == PNET_SOCK_INVALID) { + if (err) *err = e ? e : PNET_IO_ERROR; + return false; + } + c->sock = s; + c->state = PNET_CONN_CONNECTING; + c->remote = *addr; + c->interest = 0; + c->eof = false; + c->write_shutdown = false; + c->tx_error = false; + pnet_conn_update_interest(rt, c); + return true; +} + +void pnet_conn_adopt(pnet_runtime *rt, pnet_conn *c, pnet_sock s, const pnet_addr *peer) { + c->sock = s; + c->state = PNET_CONN_OPEN; + c->remote = *peer; + c->interest = 0; + pnet_conn_update_interest(rt, c); +} + +int pnet_conn_connect_status(pnet_runtime *rt, pnet_conn *c) { + if (c->state != PNET_CONN_CONNECTING) return c->state == PNET_CONN_OPEN ? 1 : PNET_IO_ERROR; + int st = rt->driver.connect_status(rt->driver_ctx, c->sock); + if (st == 1) { + c->state = PNET_CONN_OPEN; + pnet_conn_update_interest(rt, c); + } else if (st < 0) { + c->last_error = st; + } + return st; +} + +bool pnet_conn_write(pnet_runtime *rt, pnet_conn *c, const void *data, size_t len) { + if (c->state == PNET_CONN_CLOSED || c->write_shutdown) return false; + if (!pnet_bq_push(rt, &c->tx, data, len, rt->cfg.io_chunk_bytes)) return false; + pnet_conn_update_interest(rt, c); + return true; +} + +bool pnet_conn_flush(pnet_runtime *rt, pnet_conn *c) { + if (c->state != PNET_CONN_OPEN) return c->state == PNET_CONN_CONNECTING; + if (c->secure && c->tls_phase != PNET_TLS_UP) return true; /* handshake pending */ + while (c->tx.bytes > 0) { + const uint8_t *ptr; + size_t n = pnet_bq_peek(&c->tx, &ptr); + if (n == 0) break; + int w = c->secure ? c->tls->write(c->tls_ctx, c->sock, ptr, n) : rt->driver.write(rt->driver_ctx, c->sock, ptr, n); + if (w == PNET_IO_AGAIN || w == 0) break; + if (w < 0) { + c->tx_error = true; + c->last_error = w; + pnet_conn_update_interest(rt, c); + return false; + } + pnet_bq_consume(rt, &c->tx, (size_t)w); + } + if (c->write_shutdown && !c->shutdown_done && c->tx.bytes == 0) { + c->shutdown_done = true; + if (rt->driver.shutdown_write) rt->driver.shutdown_write(rt->driver_ctx, c->sock); + } + pnet_conn_update_interest(rt, c); + return true; +} + +int pnet_conn_read(pnet_runtime *rt, pnet_conn *c, uint8_t *buf, size_t len) { + if (c->state != PNET_CONN_OPEN) return PNET_IO_AGAIN; + if (c->secure && c->tls_phase != PNET_TLS_UP) return PNET_IO_AGAIN; + if (c->eof) return PNET_IO_EOF; + int r = c->secure ? c->tls->read(c->tls_ctx, c->sock, buf, len) : rt->driver.read(rt->driver_ctx, c->sock, buf, len); + if (r == PNET_IO_EOF) c->eof = true; + else if (r < 0 && r != PNET_IO_AGAIN) c->last_error = r; + return r; +} + +void pnet_conn_update_interest(pnet_runtime *rt, pnet_conn *c) { + if (c->sock == PNET_SOCK_INVALID || c->state == PNET_CONN_CLOSED) return; + unsigned want = 0; + if (c->state == PNET_CONN_CONNECTING) want = PNET_INTEREST_WRITE; + else if (c->secure && c->tls_phase == PNET_TLS_HANDSHAKE) { + want = c->tls->interest ? c->tls->interest(c->tls_ctx, c->sock) : (PNET_INTEREST_READ | PNET_INTEREST_WRITE); + if (want == 0) want = PNET_INTEREST_READ; + } else { + if (c->read_wanted && !c->eof) want |= PNET_INTEREST_READ; + if (c->tx.bytes > 0 && !c->tx_error) want |= PNET_INTEREST_WRITE; + /* A TLS session may hold buffered records: keep read interest so the + * provider can flush them even when the app is momentarily satisfied. */ + if (c->secure && c->tls_phase == PNET_TLS_UP && c->read_wanted) want |= PNET_INTEREST_READ; + } + if (want != c->interest) { + c->interest = want; + rt->driver.interest(rt->driver_ctx, c->sock, want); + } +} + +void pnet_conn_shutdown_write(pnet_runtime *rt, pnet_conn *c) { + if (c->state != PNET_CONN_OPEN || c->write_shutdown) return; + c->write_shutdown = true; + if (c->tx.bytes == 0) { + c->shutdown_done = true; + if (rt->driver.shutdown_write) rt->driver.shutdown_write(rt->driver_ctx, c->sock); + } +} + +void pnet_conn_set_tls(pnet_conn *c, const pnet_tls_ops *tls, void *tls_ctx, const char *server_name, bool verify) { + c->tls = tls; + c->tls_ctx = tls_ctx; + c->secure = tls != NULL; + c->tls_verify = verify; + size_t n = server_name ? strlen(server_name) : 0; + if (n >= sizeof c->server_name) n = sizeof c->server_name - 1; + if (n) memcpy(c->server_name, server_name, n); + c->server_name[n] = 0; +} + +int pnet_conn_tls_step(pnet_runtime *rt, pnet_conn *c) { + (void)rt; + if (!c->secure || !c->tls) return 1; + if (c->tls_phase == PNET_TLS_UP) return 1; + if (c->tls_phase == PNET_TLS_ERROR) return -1; + if (c->tls_phase == PNET_TLS_NONE) { + pnet_tls_policy policy = {.server_name = c->server_name, .verify = c->tls_verify, .alpn = "http/1.1"}; + int rc = c->tls->start(c->tls_ctx, c->sock, &policy); + if (rc != 0) { + c->tls_phase = PNET_TLS_ERROR; + c->tls_failure.code = PNET_ERROR_TLS_HANDSHAKE_FAILED; + c->tls_failure.cause = rc; + return -1; + } + c->tls_phase = PNET_TLS_HANDSHAKE; + } + int rc = c->tls->step(c->tls_ctx, c->sock, &c->tls_failure); + if (rc == 1) { + c->tls_phase = PNET_TLS_UP; + pnet_conn_update_interest(rt, c); + return 1; + } + if (rc < 0) { + c->tls_phase = PNET_TLS_ERROR; + if (!c->tls_failure.code) c->tls_failure.code = PNET_ERROR_TLS_HANDSHAKE_FAILED; + return -1; + } + /* pending: the provider dictates interest */ + pnet_conn_update_interest(rt, c); + return 0; +} + +void pnet_conn_close(pnet_runtime *rt, pnet_conn *c) { + /* Release the TLS session for any started handshake (up, in progress or + * failed) so the provider's per-socket slot cannot outlive the socket and + * collide with a reused socket id. */ + if (c->tls && c->tls_phase != PNET_TLS_NONE && c->sock != PNET_SOCK_INVALID) { + c->tls->close(c->tls_ctx, c->sock); + } + c->tls_phase = PNET_TLS_NONE; + if (c->sock != PNET_SOCK_INVALID) { + rt->driver.close(rt->driver_ctx, c->sock); + c->sock = PNET_SOCK_INVALID; + } + pnet_bq_free(rt, &c->tx); + c->state = PNET_CONN_CLOSED; + c->interest = 0; +} + +/* ------------------------------------------------------------------------ */ +/* Dialer */ +/* ------------------------------------------------------------------------ */ + +static void dial_try_next(pnet_runtime *rt, pnet_dial *d, pnet_conn *c) { + while (d->next_candidate < d->candidate_count) { + pnet_addr *addr = &d->candidates[d->next_candidate++]; + addr->port = d->port; + if (!pnet_policy_allows_address(&rt->policy, addr)) { + d->filtered_all = d->filtered_all && true; + continue; + } + d->filtered_all = false; + int err = 0; + if (pnet_conn_connect(rt, c, addr, &err)) { + d->state = PNET_DIAL_CONNECTING; + return; + } + d->cause = err; + } + d->state = PNET_DIAL_FAILED; + if (d->filtered_all) { + d->error_code = PNET_ERROR_PERMISSION_DENIED; + } else if (!d->error_code) { + /* Every failure while establishing the transport is `connect`; the + * driver's code (refused/reset/unreachable/no memory) rides in cause. */ + d->error_code = d->cause == PNET_IO_NOMEM ? PNET_ERROR_RESOURCE_LIMIT : PNET_ERROR_CONNECT; + } +} + +bool pnet_dial_start(pnet_runtime *rt, pnet_dial *d, pnet_conn *c, const char *host, uint16_t port, bool secure, + const char *server_name, bool verify) { + memset(d, 0, sizeof *d); + d->port = port; + d->filtered_all = true; + d->secure = secure; + if (secure) { + if (!rt->tls) { + d->state = PNET_DIAL_FAILED; + d->error_code = PNET_ERROR_UNSUPPORTED; + return false; + } + /* Fail closed before any I/O when the wall clock is not trusted and the + * certificate's validity must be checked. */ + if (verify && rt->platform.wall_clock_trusted && !rt->platform.wall_clock_trusted(rt->platform.ctx)) { + d->state = PNET_DIAL_FAILED; + d->error_code = PNET_ERROR_TLS_CLOCK_UNTRUSTED; + d->error_message = "wall clock is not trusted for certificate validation"; + return false; + } + pnet_conn_set_tls(c, rt->tls, rt->tls_ctx, server_name, verify); + } + pnet_addr literal; + if (pnet_parse_ip_literal(host, strlen(host), &literal)) { + d->candidates[0] = literal; + d->candidate_count = 1; + dial_try_next(rt, d, c); + return d->state != PNET_DIAL_FAILED; + } + size_t hl = strlen(host); + if (hl >= 6 && strcmp(host + hl - 6, ".local") == 0) { + d->state = PNET_DIAL_FAILED; + d->error_code = PNET_ERROR_UNSUPPORTED; + return false; + } + if (!rt->driver.resolve) { + d->state = PNET_DIAL_FAILED; + d->error_code = PNET_ERROR_DNS; + return false; + } + uint32_t id = resolve_register(rt, d); + if (id == 0) { + d->state = PNET_DIAL_FAILED; + d->error_code = PNET_ERROR_RESOURCE_LIMIT; + return false; + } + d->resolve_req = id; + d->state = PNET_DIAL_RESOLVING; + int rc = rt->driver.resolve(rt->driver_ctx, id, host); + if (rc < 0) { + resolve_unregister(rt, id); + d->resolve_req = 0; + d->state = PNET_DIAL_FAILED; + d->error_code = PNET_ERROR_DNS; + d->cause = rc; + return false; + } + /* The driver may have completed synchronously (state advanced). */ + if (d->state == PNET_DIAL_RESOLVING) return true; + if (d->state == PNET_DIAL_FAILED) return false; + /* resolved synchronously: connect attempts already started or scheduled */ + if (d->state == PNET_DIAL_IDLE) dial_try_next(rt, d, c); + return d->state != PNET_DIAL_FAILED; +} + +void pnet_dial_resolved(pnet_runtime *rt, pnet_dial *d, const pnet_addr *addrs, size_t count, int err) { + (void)rt; + d->resolve_req = 0; + if (d->state != PNET_DIAL_RESOLVING) return; + if (err != 0 || count == 0) { + d->state = PNET_DIAL_FAILED; + d->error_code = PNET_ERROR_DNS; + d->cause = err; + return; + } + size_t n = count < PNET_DIAL_MAX_CANDIDATES ? count : PNET_DIAL_MAX_CANDIDATES; + memcpy(d->candidates, addrs, n * sizeof(pnet_addr)); + d->candidate_count = n; + d->next_candidate = 0; + /* Connect attempts start on the next step (the caller's service pass) so + * that a synchronous resolver does not recurse into the connect path with + * the caller's state half-initialized. */ + d->state = PNET_DIAL_IDLE; +} + +int pnet_dial_step(pnet_runtime *rt, pnet_dial *d, pnet_conn *c) { + switch (d->state) { + case PNET_DIAL_IDLE: + if (d->candidate_count == 0) return PNET_DIAL_IDLE; + dial_try_next(rt, d, c); + if (d->state != PNET_DIAL_CONNECTING) return d->state; + /* fall through */ + case PNET_DIAL_CONNECTING: { + if (!d->tls_up) { + int st = pnet_conn_connect_status(rt, c); + if (st < 0) { + d->cause = st; + bool was_secure = c->secure; + const pnet_tls_ops *tls = c->tls; + void *tls_ctx = c->tls_ctx; + char sni[256]; + bool verify = c->tls_verify; + memcpy(sni, c->server_name, sizeof sni); + pnet_conn_close(rt, c); + pnet_conn_init(c); + if (was_secure) pnet_conn_set_tls(c, tls, tls_ctx, sni, verify); + dial_try_next(rt, d, c); + return d->state; + } + if (st != 1) return d->state; /* plain connect still pending */ + if (!d->secure) { + d->state = PNET_DIAL_OPEN; + return d->state; + } + } + /* TLS handshake over the connected socket. */ + int hs = pnet_conn_tls_step(rt, c); + if (hs == 1) { + d->tls_up = true; + d->state = PNET_DIAL_OPEN; + } else if (hs < 0) { + d->error_code = c->tls_failure.code ? c->tls_failure.code : PNET_ERROR_TLS_HANDSHAKE_FAILED; + d->cause = c->tls_failure.cause; + d->state = PNET_DIAL_FAILED; + } + return d->state; + } + default: + return d->state; + } +} + +void pnet_dial_cancel(pnet_runtime *rt, pnet_dial *d) { + if (d->resolve_req) { + resolve_unregister(rt, d->resolve_req); + if (rt->driver.resolve_cancel) rt->driver.resolve_cancel(rt->driver_ctx, d->resolve_req); + d->resolve_req = 0; + } + d->state = PNET_DIAL_FAILED; + if (!d->error_code) d->error_code = PNET_ERROR_CANCELLED; +} diff --git a/engine/net/src/pnet_url.c b/engine/net/src/pnet_url.c new file mode 100644 index 00000000..9d830825 --- /dev/null +++ b/engine/net/src/pnet_url.c @@ -0,0 +1,277 @@ +/* Absolute URL parsing for the schemes the modules speak (http, https, ws, + * wss) plus Location resolution for redirects. The SDK already normalized + * the request URL (lowercase scheme/host, no credentials, percent-encoded + * path); this parser re-checks what the wire needs and rejects the rest. */ +#include "pnet_internal.h" + +static bool valid_host_char(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || + c == '_'; +} + +static bool set_path(pnet_runtime *rt, pnet_url *out, const char *path, size_t len) { + /* Drop a fragment; keep path + query. */ + size_t n = 0; + while (n < len && path[n] != '#') n++; + if (n == 0) { + out->path = pnet_strdup_n(rt, "/", 1); + out->path_len = 1; + return out->path != NULL; + } + for (size_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)path[i]; + if (c <= 0x20 || c == 0x7f) return false; + } + if (path[0] == '?') { + out->path = pnet_alloc(rt, n + 2); + if (!out->path) return false; + out->path[0] = '/'; + memcpy(out->path + 1, path, n); + out->path[n + 1] = 0; + out->path_len = n + 1; + return true; + } + if (path[0] != '/') return false; + out->path = pnet_strdup_n(rt, path, n); + out->path_len = n; + return out->path != NULL; +} + +bool pnet_url_parse(pnet_runtime *rt, const char *text, size_t len, pnet_url *out) { + memset(out, 0, sizeof *out); + const char *colon = memchr(text, ':', len); + if (!colon) return false; + size_t scheme_len = (size_t)(colon - text); + if (scheme_len == 0 || scheme_len >= sizeof out->scheme) return false; + for (size_t i = 0; i < scheme_len; i++) { + char c = text[i]; + if (c >= 'A' && c <= 'Z') c = (char)(c + 32); + out->scheme[i] = c; + } + out->scheme[scheme_len] = 0; + if (strcmp(out->scheme, "http") && strcmp(out->scheme, "https") && strcmp(out->scheme, "ws") && + strcmp(out->scheme, "wss")) + return false; + size_t i = scheme_len + 1; + if (i + 2 > len || text[i] != '/' || text[i + 1] != '/') return false; + i += 2; + size_t auth_start = i; + while (i < len && text[i] != '/' && text[i] != '?' && text[i] != '#') i++; + size_t auth_len = i - auth_start; + const char *auth = text + auth_start; + if (memchr(auth, '@', auth_len)) return false; /* credentials are refused */ + const char *host; + size_t host_len; + const char *port_text = NULL; + size_t port_len = 0; + if (auth_len > 0 && auth[0] == '[') { + const char *close = memchr(auth, ']', auth_len); + if (!close) return false; + host = auth + 1; + host_len = (size_t)(close - auth) - 1; + size_t rest = auth_len - (size_t)(close - auth) - 1; + if (rest > 0) { + if (close[1] != ':') return false; + port_text = close + 2; + port_len = rest - 1; + } + out->host_is_ipv6 = true; + pnet_addr tmp; + if (!pnet_parse_ip_literal(host, host_len, &tmp) || tmp.family != 6) return false; + } else { + const char *c = NULL; + for (size_t k = 0; k < auth_len; k++) + if (auth[k] == ':') c = auth + k; + if (c) { + host = auth; + host_len = (size_t)(c - auth); + port_text = c + 1; + port_len = auth_len - host_len - 1; + } else { + host = auth; + host_len = auth_len; + } + if (host_len == 0 || host_len > 253) return false; + for (size_t k = 0; k < host_len; k++) + if (!valid_host_char(host[k])) return false; + if (host[0] == '.' || host[host_len - 1] == '-') return false; + } + out->host = pnet_strdup_n(rt, host, host_len); + if (!out->host) return false; + pnet_lower(out->host, host_len); + /* Trailing root dot normalizes away. */ + if (!out->host_is_ipv6 && host_len > 1 && out->host[host_len - 1] == '.') out->host[host_len - 1] = 0; + out->port = pnet_url_default_port(out->scheme); + if (port_text) { + uint64_t p; + if (port_len == 0 || !pnet_parse_u64(port_text, port_len, &p) || p > 65535) { + pnet_url_free(rt, out); + return false; + } + out->port = (uint16_t)p; + out->port_explicit = out->port != pnet_url_default_port(out->scheme); + } + if (!set_path(rt, out, text + i, len - i)) { + pnet_url_free(rt, out); + return false; + } + return true; +} + +void pnet_url_free(pnet_runtime *rt, pnet_url *url) { + if (url->host) pnet_free_str(rt, url->host); + if (url->path) pnet_free_str(rt, url->path); + url->host = NULL; + url->path = NULL; +} + +void pnet_url_write(pnet_runtime *rt, pnet_sb *sb, const pnet_url *url) { + pnet_sb_puts(rt, sb, url->scheme); + pnet_sb_puts(rt, sb, "://"); + if (url->host_is_ipv6) pnet_sb_putc(rt, sb, '['); + pnet_sb_puts(rt, sb, url->host); + if (url->host_is_ipv6) pnet_sb_putc(rt, sb, ']'); + if (url->port_explicit) pnet_sb_printf(rt, sb, ":%u", (unsigned)url->port); + pnet_sb_append(rt, sb, url->path, url->path_len); +} + +bool pnet_url_same_origin(const pnet_url *a, const pnet_url *b) { + return strcmp(a->scheme, b->scheme) == 0 && strcmp(a->host, b->host) == 0 && a->port == b->port; +} + +/** Remove dot segments from a path (RFC 3986 5.2.4). `path` is rewritten in + * place; the result always starts with '/'. Paths with more than 128 + * segments are left untouched apart from the leading slash. */ +static size_t remove_dot_segments(char *path, size_t len) { + enum { MAX_SEGS = 128 }; + const char *seg_ptr[MAX_SEGS]; + size_t seg_len[MAX_SEGS]; + size_t count = 0; + bool trailing = false; + size_t i = 0; + if (i < len && path[i] == '/') i++; + if (len == 0) { + path[0] = '/'; + return 1; + } + bool overflow = false; + while (i <= len) { + size_t j = i; + while (j < len && path[j] != '/') j++; + size_t n = j - i; + bool last = j >= len; + if (n == 1 && path[i] == '.') { + trailing = last; + } else if (n == 2 && path[i] == '.' && path[i + 1] == '.') { + if (count > 0) count--; + trailing = last; + } else if (n == 0) { + trailing = last; /* empty last segment: path ended with '/' */ + if (!last) { /* "//" inside path: keep an empty segment */ + if (count < MAX_SEGS) { seg_ptr[count] = path + i; seg_len[count] = 0; count++; } else overflow = true; + } + } else { + if (count < MAX_SEGS) { seg_ptr[count] = path + i; seg_len[count] = n; count++; } else overflow = true; + trailing = false; + } + if (last) break; + i = j + 1; + } + if (overflow) { + if (path[0] != '/') { memmove(path + 1, path, len); path[0] = '/'; len++; } + return len; + } + /* Rebuild into a scratch copy: segments point into `path`, so build a + * temporary on the stack (paths here are bounded by the target limit). */ + char tmp[2048]; + size_t o = 0; + for (size_t k = 0; k < count; k++) { + if (o + 1 + seg_len[k] >= sizeof tmp) break; + tmp[o++] = '/'; + memcpy(tmp + o, seg_ptr[k], seg_len[k]); + o += seg_len[k]; + } + if (count == 0 || trailing) { + if (o + 1 < sizeof tmp) tmp[o++] = '/'; + } + memcpy(path, tmp, o); + return o; +} + +bool pnet_url_resolve(pnet_runtime *rt, const pnet_url *base, const char *location, size_t len, pnet_url *out) { + memset(out, 0, sizeof *out); + /* Trim whitespace. */ + while (len > 0 && (location[0] == ' ' || location[0] == '\t')) { location++; len--; } + while (len > 0 && (location[len - 1] == ' ' || location[len - 1] == '\t')) len--; + if (len == 0) return false; + /* Absolute? scheme ":" */ + size_t k = 0; + while (k < len && ((location[k] >= 'a' && location[k] <= 'z') || (location[k] >= 'A' && location[k] <= 'Z') || + (k > 0 && ((location[k] >= '0' && location[k] <= '9') || location[k] == '+' || location[k] == '-' || location[k] == '.')))) + k++; + if (k > 0 && k < len && location[k] == ':') return pnet_url_parse(rt, location, len, out); + /* Scheme-relative //host/path */ + if (len >= 2 && location[0] == '/' && location[1] == '/') { + pnet_sb sb; + pnet_sb_init(&sb); + pnet_sb_puts(rt, &sb, base->scheme); + pnet_sb_putc(rt, &sb, ':'); + pnet_sb_append(rt, &sb, location, len); + bool ok = !sb.failed && pnet_url_parse(rt, sb.data, sb.len, out); + pnet_sb_free(rt, &sb); + return ok; + } + strcpy(out->scheme, base->scheme); + out->host = pnet_strdup_n(rt, base->host, strlen(base->host)); + if (!out->host) return false; + out->host_is_ipv6 = base->host_is_ipv6; + out->port = base->port; + out->port_explicit = base->port_explicit; + /* Path part: absolute-path, query-only, fragment-only, or relative. */ + const char *bpath = base->path; + size_t bpath_len = base->path_len; + size_t bq = 0; + while (bq < bpath_len && bpath[bq] != '?') bq++; /* base path without query */ + pnet_sb sb; + pnet_sb_init(&sb); + size_t frag = 0; + while (frag < len && location[frag] != '#') frag++; + len = frag; + if (len == 0) { + pnet_sb_append(rt, &sb, bpath, bpath_len); + } else if (location[0] == '/') { + pnet_sb_append(rt, &sb, location, len); + } else if (location[0] == '?') { + pnet_sb_append(rt, &sb, bpath, bq); + pnet_sb_append(rt, &sb, location, len); + } else { + size_t slash = bq; + while (slash > 0 && bpath[slash - 1] != '/') slash--; + pnet_sb_append(rt, &sb, bpath, slash); + pnet_sb_append(rt, &sb, location, len); + } + if (sb.failed) { + pnet_sb_free(rt, &sb); + pnet_url_free(rt, out); + return false; + } + /* Normalize dot segments in the path portion only. */ + size_t q = 0; + while (q < sb.len && sb.data[q] != '?') q++; + char *tmp = pnet_alloc(rt, sb.len + 2); + if (!tmp) { + pnet_sb_free(rt, &sb); + pnet_url_free(rt, out); + return false; + } + memcpy(tmp, sb.data, q); + size_t plen = remove_dot_segments(tmp, q); + memcpy(tmp + plen, sb.data + q, sb.len - q); + size_t total = plen + (sb.len - q); + tmp[total] = 0; + bool ok = set_path(rt, out, tmp, total); + pnet_free(rt, tmp, sb.len + 2); + pnet_sb_free(rt, &sb); + if (!ok) pnet_url_free(rt, out); + return ok; +} diff --git a/engine/net/src/pnet_util.c b/engine/net/src/pnet_util.c new file mode 100644 index 00000000..37fc8f56 --- /dev/null +++ b/engine/net/src/pnet_util.c @@ -0,0 +1,627 @@ +/* Allocation accounting, string builder, byte queue, codecs and address + * helpers for the network core. Portable C99; no OS headers. */ +#include +#include + +#include "pnet_internal.h" + +/* ------------------------------------------------------------------------ */ +/* Allocation */ +/* ------------------------------------------------------------------------ */ + +void *pnet_alloc(pnet_runtime *rt, size_t size) { + if (size == 0) size = 1; + if (rt->cfg.max_heap_bytes && rt->heap_bytes + size > rt->cfg.max_heap_bytes) return NULL; + void *p = rt->platform.alloc(rt->platform.ctx, size); + if (!p) return NULL; + rt->heap_bytes += size; + if (rt->heap_bytes > rt->heap_high_water) rt->heap_high_water = rt->heap_bytes; + return p; +} + +void *pnet_zalloc(pnet_runtime *rt, size_t size) { + void *p = pnet_alloc(rt, size); + if (p) memset(p, 0, size ? size : 1); + return p; +} + +void pnet_free(pnet_runtime *rt, void *ptr, size_t size) { + if (!ptr) return; + if (size == 0) size = 1; + rt->platform.free(rt->platform.ctx, ptr, size); + rt->heap_bytes = rt->heap_bytes >= size ? rt->heap_bytes - size : 0; +} + +char *pnet_strdup_n(pnet_runtime *rt, const char *s, size_t len) { + char *out = pnet_alloc(rt, len + 1); + if (!out) return NULL; + memcpy(out, s, len); + out[len] = 0; + return out; +} + +void pnet_logf(pnet_runtime *rt, pnet_log_level level, const char *fmt, ...) { + if (!rt->platform.log) return; + char buf[192]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof buf, fmt, ap); + va_end(ap); + rt->platform.log(rt->platform.ctx, level, buf); +} + +/* ------------------------------------------------------------------------ */ +/* String builder */ +/* ------------------------------------------------------------------------ */ + +void pnet_sb_init(pnet_sb *sb) { + sb->data = NULL; + sb->len = 0; + sb->cap = 0; + sb->failed = false; +} + +void pnet_sb_free(pnet_runtime *rt, pnet_sb *sb) { + if (sb->data) pnet_free(rt, sb->data, sb->cap); + pnet_sb_init(sb); +} + +bool pnet_sb_reserve(pnet_runtime *rt, pnet_sb *sb, size_t extra) { + if (sb->failed) return false; + size_t need = sb->len + extra + 1; + if (need <= sb->cap) return true; + size_t cap = sb->cap ? sb->cap : 64; + while (cap < need) cap = cap < 4096 ? cap * 2 : cap + cap / 2; + char *next = pnet_alloc(rt, cap); + if (!next) { + sb->failed = true; + return false; + } + if (sb->data) { + memcpy(next, sb->data, sb->len); + pnet_free(rt, sb->data, sb->cap); + } + sb->data = next; + sb->cap = cap; + sb->data[sb->len] = 0; + return true; +} + +void pnet_sb_append(pnet_runtime *rt, pnet_sb *sb, const void *data, size_t len) { + if (!pnet_sb_reserve(rt, sb, len)) return; + memcpy(sb->data + sb->len, data, len); + sb->len += len; + sb->data[sb->len] = 0; +} + +void pnet_sb_puts(pnet_runtime *rt, pnet_sb *sb, const char *s) { + pnet_sb_append(rt, sb, s, strlen(s)); +} + +void pnet_sb_putc(pnet_runtime *rt, pnet_sb *sb, char c) { + pnet_sb_append(rt, sb, &c, 1); +} + +void pnet_sb_printf(pnet_runtime *rt, pnet_sb *sb, const char *fmt, ...) { + char buf[256]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(buf, sizeof buf, fmt, ap); + va_end(ap); + if (n < 0) return; + if ((size_t)n < sizeof buf) { + pnet_sb_append(rt, sb, buf, (size_t)n); + return; + } + if (!pnet_sb_reserve(rt, sb, (size_t)n)) return; + va_start(ap, fmt); + vsnprintf(sb->data + sb->len, (size_t)n + 1, fmt, ap); + va_end(ap); + sb->len += (size_t)n; +} + +static const char HEX[] = "0123456789abcdef"; + +void pnet_sb_json_string(pnet_runtime *rt, pnet_sb *sb, const char *s, size_t len) { + pnet_sb_putc(rt, sb, '"'); + const uint8_t *p = (const uint8_t *)s; + size_t i = 0; + while (i < len) { + uint8_t c = p[i]; + if (c == '"' || c == '\\') { + char esc[2] = {'\\', (char)c}; + pnet_sb_append(rt, sb, esc, 2); + i++; + } else if (c < 0x20) { + if (c == '\n') pnet_sb_append(rt, sb, "\\n", 2); + else if (c == '\r') pnet_sb_append(rt, sb, "\\r", 2); + else if (c == '\t') pnet_sb_append(rt, sb, "\\t", 2); + else { + char esc[6] = {'\\', 'u', '0', '0', HEX[c >> 4], HEX[c & 15]}; + pnet_sb_append(rt, sb, esc, 6); + } + i++; + } else if (c < 0x80) { + pnet_sb_putc(rt, sb, (char)c); + i++; + } else { + /* Copy one UTF-8 sequence if valid, else U+FFFD. */ + size_t n = 0; + uint32_t cp = 0; + uint32_t lower = 0; + if ((c & 0xe0) == 0xc0) { n = 1; cp = c & 0x1f; lower = 0x80; } + else if ((c & 0xf0) == 0xe0) { n = 2; cp = c & 0x0f; lower = 0x800; } + else if ((c & 0xf8) == 0xf0) { n = 3; cp = c & 0x07; lower = 0x10000; } + bool ok = n > 0 && i + n < len + 1 && i + n <= len; + if (ok) { + for (size_t k = 1; k <= n; k++) { + uint8_t cc = p[i + k]; + if ((cc & 0xc0) != 0x80) { ok = false; break; } + cp = (cp << 6) | (cc & 0x3f); + } + } + if (ok && (cp < lower || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff))) ok = false; + if (ok) { + pnet_sb_append(rt, sb, p + i, n + 1); + i += n + 1; + } else { + pnet_sb_append(rt, sb, "\xEF\xBF\xBD", 3); + i++; + } + } + } + pnet_sb_putc(rt, sb, '"'); +} + +const char *pnet_sb_cstr(pnet_sb *sb) { + return sb->data ? sb->data : ""; +} + +/* ------------------------------------------------------------------------ */ +/* Byte queue */ +/* ------------------------------------------------------------------------ */ + +void pnet_bq_init(pnet_bq *q) { + q->head = q->tail = NULL; + q->bytes = 0; +} + +static void seg_free(pnet_runtime *rt, pnet_seg *s) { + pnet_free(rt, s, sizeof(pnet_seg) + s->cap); +} + +void pnet_bq_free(pnet_runtime *rt, pnet_bq *q) { + pnet_seg *s = q->head; + while (s) { + pnet_seg *next = s->next; + seg_free(rt, s); + s = next; + } + pnet_bq_init(q); +} + +bool pnet_bq_push(pnet_runtime *rt, pnet_bq *q, const void *data, size_t len, size_t seg_bytes) { + const uint8_t *src = data; + while (len > 0) { + pnet_seg *tail = q->tail; + if (tail && tail->off + tail->len < tail->cap) { + size_t room = tail->cap - (tail->off + tail->len); + size_t n = len < room ? len : room; + memcpy(tail->data + tail->off + tail->len, src, n); + tail->len += n; + q->bytes += n; + src += n; + len -= n; + continue; + } + size_t cap = seg_bytes ? seg_bytes : 1024; + if (len > cap) cap = len; + pnet_seg *s = pnet_alloc(rt, sizeof(pnet_seg) + cap); + if (!s) return false; + s->next = NULL; + s->cap = cap; + s->len = 0; + s->off = 0; + if (q->tail) q->tail->next = s; + else q->head = s; + q->tail = s; + } + return true; +} + +size_t pnet_bq_read(pnet_runtime *rt, pnet_bq *q, uint8_t *dst, size_t len) { + size_t copied = 0; + while (copied < len && q->head) { + pnet_seg *s = q->head; + size_t n = s->len < len - copied ? s->len : len - copied; + memcpy(dst + copied, s->data + s->off, n); + copied += n; + s->off += n; + s->len -= n; + q->bytes -= n; + if (s->len == 0) { + q->head = s->next; + if (!q->head) q->tail = NULL; + seg_free(rt, s); + } + } + return copied; +} + +size_t pnet_bq_peek(pnet_bq *q, const uint8_t **ptr) { + if (!q->head) { + *ptr = NULL; + return 0; + } + *ptr = q->head->data + q->head->off; + return q->head->len; +} + +void pnet_bq_consume(pnet_runtime *rt, pnet_bq *q, size_t n) { + while (n > 0 && q->head) { + pnet_seg *s = q->head; + size_t take = s->len < n ? s->len : n; + s->off += take; + s->len -= take; + q->bytes -= take; + n -= take; + if (s->len == 0) { + q->head = s->next; + if (!q->head) q->tail = NULL; + seg_free(rt, s); + } + } +} + +/* ------------------------------------------------------------------------ */ +/* UTF-8 */ +/* ------------------------------------------------------------------------ */ + +void pnet_utf8_state_init(pnet_utf8_state *st) { + st->need = 0; + st->cp = 0; + st->lower = 0; +} + +bool pnet_utf8_feed(pnet_utf8_state *st, const uint8_t *s, size_t len) { + for (size_t i = 0; i < len; i++) { + uint8_t c = s[i]; + if (st->need == 0) { + if (c < 0x80) continue; + if ((c & 0xe0) == 0xc0) { st->need = 1; st->cp = c & 0x1f; st->lower = 0x80; } + else if ((c & 0xf0) == 0xe0) { st->need = 2; st->cp = c & 0x0f; st->lower = 0x800; } + else if ((c & 0xf8) == 0xf0) { st->need = 3; st->cp = c & 0x07; st->lower = 0x10000; } + else return false; + /* Early rejects that do not need the full sequence. */ + if (c == 0xc0 || c == 0xc1 || c > 0xf4) return false; + } else { + if ((c & 0xc0) != 0x80) return false; + st->cp = (st->cp << 6) | (c & 0x3f); + st->need--; + if (st->need == 0) { + if (st->cp < st->lower || st->cp > 0x10ffff || (st->cp >= 0xd800 && st->cp <= 0xdfff)) return false; + } else if (st->need == 2 && st->lower == 0x10000) { + /* after first continuation of a 4-byte seq: cp holds 5+6 bits */ + if (st->cp > 0x10f) return false; + if (st->cp < 0x10) return false; + } else if (st->need == 1 && st->lower == 0x800) { + if (st->cp < 0x20) return false; + if (st->cp >= 0x360 && st->cp <= 0x37f) return false; /* surrogates */ + } + } + } + return true; +} + +bool pnet_utf8_valid(const uint8_t *s, size_t len) { + pnet_utf8_state st; + pnet_utf8_state_init(&st); + return pnet_utf8_feed(&st, s, len) && st.need == 0; +} + +/* ------------------------------------------------------------------------ */ +/* Base64 / SHA-1 */ +/* ------------------------------------------------------------------------ */ + +size_t pnet_base64_encode(const uint8_t *in, size_t len, char *out, size_t cap) { + static const char T[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + size_t need = ((len + 2) / 3) * 4; + if (cap < need + 1) return 0; + size_t o = 0; + for (size_t i = 0; i < len; i += 3) { + uint32_t a = in[i]; + uint32_t b = i + 1 < len ? in[i + 1] : 0; + uint32_t c = i + 2 < len ? in[i + 2] : 0; + out[o++] = T[a >> 2]; + out[o++] = T[((a & 3) << 4) | (b >> 4)]; + out[o++] = i + 1 < len ? T[((b & 15) << 2) | (c >> 6)] : '='; + out[o++] = i + 2 < len ? T[c & 63] : '='; + } + out[o] = 0; + return o; +} + +static uint32_t rol(uint32_t v, int b) { return (v << b) | (v >> (32 - b)); } + +void pnet_sha1(const uint8_t *data, size_t len, uint8_t out[20]) { + uint32_t h0 = 0x67452301, h1 = 0xEFCDAB89, h2 = 0x98BADCFE, h3 = 0x10325476, h4 = 0xC3D2E1F0; + uint64_t total_bits = (uint64_t)len * 8; + uint8_t block[64]; + size_t i = 0; + bool padded_one = false; + bool finished = false; + while (!finished) { + size_t n = 0; + if (len - i >= 64) { + memcpy(block, data + i, 64); + i += 64; + n = 64; + } else { + size_t rem = len - i; + memcpy(block, data + i, rem); + i += rem; + n = rem; + if (!padded_one) { + block[n++] = 0x80; + padded_one = true; + } + if (n <= 56) { + memset(block + n, 0, 56 - n); + for (int k = 0; k < 8; k++) block[56 + k] = (uint8_t)(total_bits >> (56 - 8 * k)); + finished = true; + } else { + memset(block + n, 0, 64 - n); + } + } + uint32_t w[80]; + for (int t = 0; t < 16; t++) { + w[t] = ((uint32_t)block[t * 4] << 24) | ((uint32_t)block[t * 4 + 1] << 16) | + ((uint32_t)block[t * 4 + 2] << 8) | block[t * 4 + 3]; + } + for (int t = 16; t < 80; t++) w[t] = rol(w[t - 3] ^ w[t - 8] ^ w[t - 14] ^ w[t - 16], 1); + uint32_t a = h0, b = h1, c = h2, d = h3, e = h4; + for (int t = 0; t < 80; t++) { + uint32_t f, k; + if (t < 20) { f = (b & c) | (~b & d); k = 0x5A827999; } + else if (t < 40) { f = b ^ c ^ d; k = 0x6ED9EBA1; } + else if (t < 60) { f = (b & c) | (b & d) | (c & d); k = 0x8F1BBCDC; } + else { f = b ^ c ^ d; k = 0xCA62C1D6; } + uint32_t temp = rol(a, 5) + f + e + k + w[t]; + e = d; + d = c; + c = rol(b, 30); + b = a; + a = temp; + } + h0 += a; h1 += b; h2 += c; h3 += d; h4 += e; + } + uint32_t hs[5] = {h0, h1, h2, h3, h4}; + for (int k = 0; k < 5; k++) { + out[k * 4] = (uint8_t)(hs[k] >> 24); + out[k * 4 + 1] = (uint8_t)(hs[k] >> 16); + out[k * 4 + 2] = (uint8_t)(hs[k] >> 8); + out[k * 4 + 3] = (uint8_t)hs[k]; + } +} + +/* ------------------------------------------------------------------------ */ +/* Tokens, numbers, case */ +/* ------------------------------------------------------------------------ */ + +bool pnet_is_token(const char *s, size_t len) { + if (len == 0) return false; + for (size_t i = 0; i < len; i++) { + unsigned char c = (unsigned char)s[i]; + if (c <= 0x20 || c >= 0x7f) return false; + if (strchr("()<>@,;:\\\"/[]?={}", c)) return false; + } + return true; +} + +bool pnet_ieq_n(const char *a, size_t alen, const char *b) { + size_t blen = strlen(b); + if (alen != blen) return false; + for (size_t i = 0; i < alen; i++) { + unsigned char x = (unsigned char)a[i], y = (unsigned char)b[i]; + if (x >= 'A' && x <= 'Z') x = (unsigned char)(x + 32); + if (y >= 'A' && y <= 'Z') y = (unsigned char)(y + 32); + if (x != y) return false; + } + return true; +} + +void pnet_lower(char *s, size_t len) { + for (size_t i = 0; i < len; i++) + if (s[i] >= 'A' && s[i] <= 'Z') s[i] = (char)(s[i] + 32); +} + +bool pnet_parse_u64(const char *s, size_t len, uint64_t *out) { + if (len == 0 || len > 19) return false; + uint64_t v = 0; + for (size_t i = 0; i < len; i++) { + if (s[i] < '0' || s[i] > '9') return false; + v = v * 10 + (uint64_t)(s[i] - '0'); + } + *out = v; + return true; +} + +/* ------------------------------------------------------------------------ */ +/* Addresses */ +/* ------------------------------------------------------------------------ */ + +bool pnet_parse_ipv4(const char *s, size_t len, uint8_t out[4]) { + size_t i = 0; + for (int part = 0; part < 4; part++) { + if (i >= len) return false; + uint32_t v = 0; + size_t digits = 0; + while (i < len && s[i] >= '0' && s[i] <= '9') { + v = v * 10 + (uint32_t)(s[i] - '0'); + if (v > 255) return false; + i++; + digits++; + } + if (digits == 0 || digits > 3) return false; + out[part] = (uint8_t)v; + if (part < 3) { + if (i >= len || s[i] != '.') return false; + i++; + } + } + return i == len; +} + +static int hexval(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +bool pnet_parse_ipv6(const char *s, size_t len, uint8_t out[16]) { + uint16_t groups[8]; + int count = 0; + int gap = -1; + size_t i = 0; + if (len >= 2 && s[0] == ':' && s[1] == ':') { + gap = 0; + i = 2; + } else if (len >= 1 && s[0] == ':') { + return false; + } + while (i < len) { + if (count >= 8) return false; + /* embedded IPv4 tail */ + size_t j = i; + bool dotted = false; + while (j < len && s[j] != ':') { + if (s[j] == '.') dotted = true; + j++; + } + if (dotted) { + uint8_t v4[4]; + if (!pnet_parse_ipv4(s + i, j - i, v4) || j != len || count > 6) return false; + groups[count++] = (uint16_t)((v4[0] << 8) | v4[1]); + groups[count++] = (uint16_t)((v4[2] << 8) | v4[3]); + i = j; + break; + } + if (j == i) return false; + if (j - i > 4) return false; + uint32_t v = 0; + for (size_t k = i; k < j; k++) { + int h = hexval(s[k]); + if (h < 0) return false; + v = (v << 4) | (uint32_t)h; + } + groups[count++] = (uint16_t)v; + i = j; + if (i < len) { + if (s[i] != ':') return false; + i++; + if (i < len && s[i] == ':') { + if (gap >= 0) return false; + gap = count; + i++; + if (i == len) break; + } else if (i == len) { + return false; + } + } + } + if (gap < 0 && count != 8) return false; + if (gap >= 0 && count >= 8) return false; + memset(out, 0, 16); + int fill = 8 - count; + int gi = 0; + for (int g = 0; g < 8; g++) { + if (gap >= 0 && g >= gap && g < gap + fill) continue; + out[g * 2] = (uint8_t)(groups[gi] >> 8); + out[g * 2 + 1] = (uint8_t)groups[gi]; + gi++; + } + return true; +} + +bool pnet_parse_ip_literal(const char *s, size_t len, pnet_addr *out) { + memset(out, 0, sizeof *out); + if (len >= 2 && s[0] == '[' && s[len - 1] == ']') { + s++; + len -= 2; + } + if (memchr(s, ':', len)) { + if (!pnet_parse_ipv6(s, len, out->addr)) return false; + out->family = 6; + return true; + } + if (pnet_parse_ipv4(s, len, out->addr)) { + out->family = 4; + return true; + } + return false; +} + +void pnet_format_addr(const pnet_addr *addr, char *out, size_t cap) { + if (addr->family == 4) { + snprintf(out, cap, "%u.%u.%u.%u", addr->addr[0], addr->addr[1], addr->addr[2], addr->addr[3]); + return; + } + /* IPv6: longest run of zero groups compressed. */ + uint16_t g[8]; + for (int i = 0; i < 8; i++) g[i] = (uint16_t)((addr->addr[i * 2] << 8) | addr->addr[i * 2 + 1]); + int best = -1, best_len = 0; + for (int i = 0; i < 8;) { + if (g[i] != 0) { i++; continue; } + int j = i; + while (j < 8 && g[j] == 0) j++; + if (j - i > best_len && j - i >= 2) { best = i; best_len = j - i; } + i = j; + } + size_t o = 0; + for (int i = 0; i < 8; i++) { + if (i == best) { + if (o + 2 < cap) { out[o++] = ':'; if (i == 0) out[o++] = ':'; } + i += best_len - 1; + if (i == 7 && o < cap) out[o] = 0; + continue; + } + int n = snprintf(out + o, cap > o ? cap - o : 0, "%x%s", g[i], i < 7 ? ":" : ""); + if (n > 0) o += (size_t)n; + } + if (o < cap) out[o] = 0; + else out[cap - 1] = 0; +} + +bool pnet_addr_is_multicast(const pnet_addr *addr) { + if (addr->family == 4) return (addr->addr[0] & 0xf0) == 0xe0; + return addr->addr[0] == 0xff; +} + +bool pnet_addr_is_public(const pnet_addr *addr) { + const uint8_t *a = addr->addr; + if (addr->family == 4) { + if (a[0] == 0) return false; /* unspecified / this network */ + if (a[0] == 10) return false; /* private */ + if (a[0] == 127) return false; /* loopback */ + if (a[0] == 169 && a[1] == 254) return false; /* link-local */ + if (a[0] == 172 && (a[1] & 0xf0) == 16) return false; /* private */ + if (a[0] == 192 && a[1] == 168) return false; /* private */ + if (a[0] == 100 && (a[1] & 0xc0) == 64) return false; /* CGNAT */ + if ((a[0] & 0xf0) == 0xe0) return false; /* multicast */ + if (a[0] == 255 && a[1] == 255 && a[2] == 255 && a[3] == 255) return false; + return true; + } + static const uint8_t zero[16] = {0}; + if (memcmp(a, zero, 15) == 0 && (a[15] == 0 || a[15] == 1)) return false; /* :: and ::1 */ + if (a[0] == 0xfe && (a[1] & 0xc0) == 0x80) return false; /* fe80::/10 link-local */ + if ((a[0] & 0xfe) == 0xfc) return false; /* fc00::/7 ULA */ + if (a[0] == 0xff) return false; /* multicast */ + /* IPv4-mapped ::ffff:a.b.c.d */ + if (memcmp(a, zero, 10) == 0 && a[10] == 0xff && a[11] == 0xff) { + pnet_addr v4 = {.family = 4}; + memcpy(v4.addr, a + 12, 4); + return pnet_addr_is_public(&v4); + } + return true; +} diff --git a/engine/net/src/pnet_ws.c b/engine/net/src/pnet_ws.c new file mode 100644 index 00000000..46f51f0e --- /dev/null +++ b/engine/net/src/pnet_ws.c @@ -0,0 +1,1112 @@ +/* WebSocket Client core (`globalThis.ws`, contracts/spec/ws.ts v2). + * + * One pnet_ws_sock per handle: dial → HTTP/1.1 upgrade handshake → RFC 6455 + * framing (client frames masked, server frames must not be), fragment + * reassembly, control frames (pings answered natively), bounded receive and + * send queues with drain, close handshake with a deadline, terminate. + * Events (`open`, `message`, `ping`, `pong`, `drain`, `error`, `close`) go + * to the ws queue; binary payloads cross only through pnet_ws_receive_into. + */ +#include + +#include "pnet_internal.h" + +#define WS_GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +typedef enum ws_state { + WS_DIALING = 0, + WS_HANDSHAKE, + WS_OPEN, + WS_CLOSING, /* close frame sent, waiting for the peer's or the deadline */ + WS_CLOSED, /* terminal event pushed */ +} ws_state; + +typedef enum frame_state { + FR_HEAD = 0, /* collecting the 2..14 header bytes */ + FR_PAYLOAD, +} frame_state; + +typedef struct ws_message { + struct ws_message *next; + size_t len; + uint8_t *data; +} ws_message; + +typedef struct pnet_ws_sock { + struct pnet_ws_sock *next; + int handle; + uint8_t state; + bool terminal; + bool live_counted; + pnet_url url; + pnet_sb request_head; + char key_b64[32]; + char *protocols; /* comma-joined request list, or NULL */ + char *selected_protocol; + uint32_t connect_ms, close_ms; + uint64_t deadline; + size_t max_message_bytes, receive_queue_bytes, send_queue_bytes; + uint32_t receive_queue_messages; + pnet_dial dial; + pnet_conn conn; + /* handshake head / frame input */ + uint8_t *rx; + size_t rx_len; + size_t rx_cap; + /* frame parser */ + uint8_t frame_state; + uint8_t hdr[14]; + size_t hdr_len; + size_t hdr_need; + uint64_t payload_len; + uint64_t payload_got; + bool fin; + uint8_t opcode; + /* message assembly */ + uint8_t *msg; + size_t msg_len; + size_t msg_cap; + uint8_t msg_opcode; + bool in_message; + pnet_utf8_state utf8; + uint8_t ctl[PWS_CONTROL_PAYLOAD_MAX]; + size_t ctl_len; + /* receive accounting */ + ws_message *binary_head; + ws_message *binary_tail; + size_t queued_bytes; /* undelivered message bytes (text until the next tick, binary until dequeued) */ + uint32_t queued_msgs; + size_t text_bytes_pending; /* text bytes counted in queued_bytes, released at freeze */ + uint32_t text_msgs_pending; + /* send accounting */ + bool drain_armed; + /* close */ + bool close_sent; + bool close_received; + bool local_close; + int close_code; + char close_reason[124]; + size_t close_reason_len; + const char *pending_error; /* error code to report before close, or NULL */ + const char *pending_error_msg; +} pnet_ws_sock; + +/* ------------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------------ */ + +static void ws_free_messages(pnet_runtime *rt, pnet_ws_sock *s) { + ws_message *m = s->binary_head; + while (m) { + ws_message *n = m->next; + pnet_free(rt, m->data, m->len ? m->len : 1); + pnet_free(rt, m, sizeof *m); + m = n; + } + s->binary_head = s->binary_tail = NULL; +} + +static void ws_free(pnet_runtime *rt, pnet_ws_sock *s) { + pnet_dial_cancel(rt, &s->dial); + pnet_conn_close(rt, &s->conn); + pnet_url_free(rt, &s->url); + pnet_sb_free(rt, &s->request_head); + if (s->protocols) pnet_free_str(rt, s->protocols); + if (s->selected_protocol) pnet_free_str(rt, s->selected_protocol); + if (s->rx) pnet_free(rt, s->rx, s->rx_cap); + if (s->msg) pnet_free(rt, s->msg, s->msg_cap); + ws_free_messages(rt, s); + pnet_free(rt, s, sizeof *s); +} + +static void ws_unlink(pnet_runtime *rt, pnet_ws_sock *s) { + pnet_ws_sock **pp = &rt->ws_socks; + while (*pp && *pp != s) pp = &(*pp)->next; + if (*pp) *pp = s->next; + if (s->live_counted && rt->ws_live > 0) rt->ws_live--; + s->live_counted = false; + ws_free(rt, s); +} + +static pnet_ws_sock *ws_find(pnet_runtime *rt, int handle) { + for (pnet_ws_sock *s = rt->ws_socks; s; s = s->next) + if (s->handle == handle) return s; + return NULL; +} + +static void ws_push(pnet_runtime *rt, pnet_ws_sock *s, const char *t, const char *tail, size_t tail_len, bool terminal, + size_t weight) { + size_t len = 0; + char *json = pnet_event_json(rt, t, "h", s->handle, tail, tail_len, &len); + pnet_queue_push(rt, &rt->ws_queue, s->handle, terminal, weight, json, len); +} + +/** Pre-open failure: terminal `error`. */ +static void ws_fail(pnet_runtime *rt, pnet_ws_sock *s, const char *code, const char *message, int status) { + if (s->terminal) return; + s->terminal = true; + s->state = WS_CLOSED; + pnet_dial_cancel(rt, &s->dial); + pnet_conn_close(rt, &s->conn); + pnet_sb sb; + pnet_sb_init(&sb); + pnet_sb_puts(rt, &sb, ",\"code\":"); + pnet_sb_json_string(rt, &sb, code, strlen(code)); + pnet_sb_puts(rt, &sb, ",\"message\":"); + pnet_sb_json_string(rt, &sb, message, strlen(message)); + if (status > 0) pnet_sb_printf(rt, &sb, ",\"status\":%d", status); + if (!sb.failed) ws_push(rt, s, "error", sb.data, sb.len, true, 0); + pnet_sb_free(rt, &sb); + if (s->live_counted && rt->ws_live > 0) rt->ws_live--; + s->live_counted = false; +} + +/** Post-open termination: optional `error` then terminal `close`. */ +static void ws_closed(pnet_runtime *rt, pnet_ws_sock *s, int code, const char *reason, size_t reason_len, bool clean, + bool local) { + if (s->terminal) return; + s->terminal = true; + s->state = WS_CLOSED; + pnet_conn_close(rt, &s->conn); + if (s->pending_error) { + pnet_sb eb; + pnet_sb_init(&eb); + pnet_sb_puts(rt, &eb, ",\"code\":"); + pnet_sb_json_string(rt, &eb, s->pending_error, strlen(s->pending_error)); + pnet_sb_puts(rt, &eb, ",\"message\":"); + const char *msg = s->pending_error_msg ? s->pending_error_msg : ""; + pnet_sb_json_string(rt, &eb, msg, strlen(msg)); + if (!eb.failed) ws_push(rt, s, "error", eb.data, eb.len, false, 0); + pnet_sb_free(rt, &eb); + s->pending_error = NULL; + } + pnet_sb sb; + pnet_sb_init(&sb); + pnet_sb_printf(rt, &sb, ",\"code\":%d,\"reason\":", code); + pnet_sb_json_string(rt, &sb, reason ? reason : "", reason_len); + pnet_sb_printf(rt, &sb, ",\"clean\":%s,\"local\":%s", clean ? "true" : "false", local ? "true" : "false"); + if (!sb.failed) ws_push(rt, s, "close", sb.data, sb.len, true, 0); + pnet_sb_free(rt, &sb); + if (s->live_counted && rt->ws_live > 0) rt->ws_live--; + s->live_counted = false; +} + +/* --- frame writer -------------------------------------------------------- */ + +static bool ws_write_frame(pnet_runtime *rt, pnet_ws_sock *s, uint8_t opcode, const uint8_t *payload, size_t len) { + uint8_t head[14]; + size_t hl = 0; + head[hl++] = (uint8_t)(0x80 | (opcode & 0x0f)); + if (len < 126) head[hl++] = (uint8_t)(0x80 | len); + else if (len <= 0xffff) { + head[hl++] = 0x80 | 126; + head[hl++] = (uint8_t)(len >> 8); + head[hl++] = (uint8_t)len; + } else { + head[hl++] = 0x80 | 127; + for (int i = 7; i >= 0; i--) head[hl++] = (uint8_t)((uint64_t)len >> (8 * i)); + } + uint8_t mask[4]; + rt->platform.random(rt->platform.ctx, mask, 4); + memcpy(head + hl, mask, 4); + hl += 4; + if (!pnet_conn_write(rt, &s->conn, head, hl)) return false; + /* Mask in bounded chunks. */ + uint8_t chunk[512]; + for (size_t off = 0; off < len; off += sizeof chunk) { + size_t n = len - off < sizeof chunk ? len - off : sizeof chunk; + for (size_t i = 0; i < n; i++) chunk[i] = payload[off + i] ^ mask[(off + i) & 3]; + if (!pnet_conn_write(rt, &s->conn, chunk, n)) return false; + } + return true; +} + +static void ws_send_close_frame(pnet_runtime *rt, pnet_ws_sock *s, int code, const char *reason, size_t reason_len) { + if (s->close_sent) return; + s->close_sent = true; + uint8_t payload[125]; + size_t len = 0; + if (code > 0) { + payload[len++] = (uint8_t)(code >> 8); + payload[len++] = (uint8_t)code; + if (reason_len > 123) reason_len = 123; + memcpy(payload + len, reason, reason_len); + len += reason_len; + } + ws_write_frame(rt, s, 8, payload, len); + pnet_conn_shutdown_write(rt, &s->conn); +} + +/** Local protocol/limit close: send Close(code), report error later. */ +static void ws_protocol_close(pnet_runtime *rt, pnet_ws_sock *s, int code, const char *error_code, const char *message) { + if (s->state != WS_OPEN && s->state != WS_CLOSING) return; + if (s->pending_error) return; /* the first violation wins */ + s->pending_error = error_code; + s->pending_error_msg = message; + s->local_close = true; + s->close_code = code; + s->close_reason_len = 0; + ws_send_close_frame(rt, s, code, "", 0); + s->state = WS_CLOSING; + s->deadline = rt->now + s->close_ms; +} + +/* --- receive queue -------------------------------------------------------- */ + +static bool ws_enqueue_binary(pnet_runtime *rt, pnet_ws_sock *s, uint8_t *data, size_t len) { + ws_message *m = pnet_alloc(rt, sizeof *m); + if (!m) return false; + m->next = NULL; + m->len = len; + m->data = data; + if (s->binary_tail) s->binary_tail->next = m; + else s->binary_head = m; + s->binary_tail = m; + return true; +} + +static void ws_update_read_interest(pnet_runtime *rt, pnet_ws_sock *s) { + bool full = s->queued_bytes >= s->receive_queue_bytes || s->queued_msgs >= s->receive_queue_messages; + s->conn.read_wanted = !full; + pnet_conn_update_interest(rt, &s->conn); +} + +/** A complete data message arrived (`s->msg`). */ +static bool ws_deliver_message(pnet_runtime *rt, pnet_ws_sock *s) { + size_t len = s->msg_len; + if (s->queued_bytes + len > s->receive_queue_bytes || s->queued_msgs + 1 > s->receive_queue_messages) { + ws_protocol_close(rt, s, 1013, PNET_ERROR_RESOURCE_LIMIT, "receive queue full"); + return false; + } + if (s->msg_opcode == 1) { + pnet_sb sb; + pnet_sb_init(&sb); + pnet_sb_puts(rt, &sb, ",\"kind\":\"text\",\"text\":"); + pnet_sb_json_string(rt, &sb, (const char *)s->msg, len); + if (sb.failed) { + pnet_sb_free(rt, &sb); + return false; + } + ws_push(rt, s, "message", sb.data, sb.len, false, len); + pnet_sb_free(rt, &sb); + s->text_bytes_pending += len; + s->text_msgs_pending++; + } else { + uint8_t *data = pnet_alloc(rt, len ? len : 1); + if (!data) return false; + memcpy(data, s->msg, len); + if (!ws_enqueue_binary(rt, s, data, len)) { + pnet_free(rt, data, len ? len : 1); + return false; + } + char tail[48]; + int n = snprintf(tail, sizeof tail, ",\"kind\":\"binary\",\"bytes\":%zu", len); + ws_push(rt, s, "message", tail, (size_t)n, false, len); + } + s->queued_bytes += len; + s->queued_msgs++; + s->msg_len = 0; + s->in_message = false; + ws_update_read_interest(rt, s); + return true; +} + +static void ws_push_control_event(pnet_runtime *rt, pnet_ws_sock *s, const char *t, const uint8_t *payload, size_t len) { + char b64[176]; + pnet_base64_encode(payload, len, b64, sizeof b64); + pnet_sb sb; + pnet_sb_init(&sb); + pnet_sb_printf(rt, &sb, ",\"payload\":{\"%s\":\"%s\"}", PWS_BLOB_KEY, b64); + if (!sb.failed) ws_push(rt, s, t, sb.data, sb.len, false, len); + pnet_sb_free(rt, &sb); +} + +/* --- frame parser ---------------------------------------------------------- */ + +static bool valid_close_code(int code) { + if (code >= 3000 && code <= 4999) return true; + switch (code) { + case 1000: case 1001: case 1002: case 1003: case 1007: case 1008: case 1009: case 1010: case 1011: + return true; + default: + return false; + } +} + +/** Handle one complete frame whose payload is in `payload`. */ +static void ws_on_frame(pnet_runtime *rt, pnet_ws_sock *s, uint8_t opcode, bool fin, const uint8_t *payload, size_t len) { + /* After our Close frame only control frames matter (RFC 6455 §7.1.1). */ + if (s->close_sent && opcode < 0x8) return; + switch (opcode) { + case 0x8: { /* close */ + int code = 1005; + const char *reason = ""; + size_t reason_len = 0; + if (len == 1) { + ws_protocol_close(rt, s, 1002, PNET_ERROR_WEBSOCKET_PROTOCOL_ERROR, "invalid close payload"); + return; + } + if (len >= 2) { + code = (payload[0] << 8) | payload[1]; + if (!valid_close_code(code)) { + ws_protocol_close(rt, s, 1002, PNET_ERROR_WEBSOCKET_PROTOCOL_ERROR, "invalid close code"); + return; + } + reason = (const char *)payload + 2; + reason_len = len - 2; + if (!pnet_utf8_valid((const uint8_t *)reason, reason_len)) { + ws_protocol_close(rt, s, 1007, PNET_ERROR_WEBSOCKET_PROTOCOL_ERROR, "invalid close reason"); + return; + } + } + s->close_received = true; + if (s->close_sent) { + /* Our close was answered: clean handshake. */ + ws_closed(rt, s, s->local_close ? s->close_code : code, s->local_close ? s->close_reason : reason, + s->local_close ? s->close_reason_len : reason_len, true, s->local_close); + return; + } + /* Peer-initiated: echo and finish. */ + ws_send_close_frame(rt, s, code == 1005 ? 0 : code, "", 0); + s->state = WS_CLOSING; + ws_closed(rt, s, code, reason, reason_len, true, false); + return; + } + case 0x9: /* ping */ + if (s->state == WS_OPEN) ws_write_frame(rt, s, 0xA, payload, len); + ws_push_control_event(rt, s, "ping", payload, len); + return; + case 0xA: /* pong */ + ws_push_control_event(rt, s, "pong", payload, len); + return; + case 0x0: + case 0x1: + case 0x2: { + if (opcode == 0 && !s->in_message) { + ws_protocol_close(rt, s, 1002, PNET_ERROR_WEBSOCKET_PROTOCOL_ERROR, "continuation without a message"); + return; + } + if (opcode != 0 && s->in_message) { + ws_protocol_close(rt, s, 1002, PNET_ERROR_WEBSOCKET_PROTOCOL_ERROR, "new message inside a fragmented one"); + return; + } + if (opcode != 0) { + s->in_message = true; + s->msg_opcode = opcode; + s->msg_len = 0; + pnet_utf8_state_init(&s->utf8); + } + if (s->msg_len + len > s->max_message_bytes) { + ws_protocol_close(rt, s, 1009, PNET_ERROR_MESSAGE_TOO_LARGE, "message exceeds maxMessageBytes"); + return; + } + if (s->msg_opcode == 1 && !pnet_utf8_feed(&s->utf8, payload, len)) { + ws_protocol_close(rt, s, 1007, PNET_ERROR_WEBSOCKET_PROTOCOL_ERROR, "invalid UTF-8 in text message"); + return; + } + if (len > 0) { + if (s->msg_cap < s->msg_len + len) { + size_t cap = s->msg_cap ? s->msg_cap : 1024; + while (cap < s->msg_len + len) cap *= 2; + if (cap > s->max_message_bytes) cap = s->max_message_bytes; + uint8_t *next = pnet_alloc(rt, cap); + if (!next) { + ws_protocol_close(rt, s, 1013, PNET_ERROR_RESOURCE_LIMIT, "out of memory"); + return; + } + if (s->msg) { + memcpy(next, s->msg, s->msg_len); + pnet_free(rt, s->msg, s->msg_cap); + } + s->msg = next; + s->msg_cap = cap; + } + memcpy(s->msg + s->msg_len, payload, len); + s->msg_len += len; + } + if (fin) { + if (s->msg_opcode == 1 && !pnet_utf8_complete(&s->utf8)) { + ws_protocol_close(rt, s, 1007, PNET_ERROR_WEBSOCKET_PROTOCOL_ERROR, "truncated UTF-8 in text message"); + return; + } + ws_deliver_message(rt, s); + } + return; + } + default: + ws_protocol_close(rt, s, 1002, PNET_ERROR_WEBSOCKET_PROTOCOL_ERROR, "reserved opcode"); + return; + } +} + +/** Feed inbound bytes through the frame parser. Returns false when the socket + * left the OPEN/CLOSING states. */ +static bool ws_feed(pnet_runtime *rt, pnet_ws_sock *s, const uint8_t *in, size_t len) { + size_t i = 0; + while (i < len && (s->state == WS_OPEN || s->state == WS_CLOSING) && !s->terminal) { + if (s->frame_state == FR_HEAD) { + s->hdr[s->hdr_len++] = in[i++]; + if (s->hdr_len == 2) { + uint8_t b0 = s->hdr[0], b1 = s->hdr[1]; + if (b0 & 0x70) { + ws_protocol_close(rt, s, 1002, PNET_ERROR_WEBSOCKET_PROTOCOL_ERROR, "reserved bits set"); + return false; + } + if (b1 & 0x80) { + ws_protocol_close(rt, s, 1002, PNET_ERROR_WEBSOCKET_PROTOCOL_ERROR, "masked server frame"); + return false; + } + s->fin = (b0 & 0x80) != 0; + s->opcode = b0 & 0x0f; + uint8_t l7 = b1 & 0x7f; + if (s->opcode >= 0x8) { + if (!s->fin || l7 > 125) { + ws_protocol_close(rt, s, 1002, PNET_ERROR_WEBSOCKET_PROTOCOL_ERROR, "invalid control frame"); + return false; + } + } + if (l7 < 126) { + s->payload_len = l7; + s->hdr_need = 2; + } else if (l7 == 126) { + s->hdr_need = 4; + } else { + s->hdr_need = 10; + } + } + if (s->hdr_len >= 2 && s->hdr_len == s->hdr_need) { + if (s->hdr_need == 4) s->payload_len = ((uint64_t)s->hdr[2] << 8) | s->hdr[3]; + else if (s->hdr_need == 10) { + uint64_t v = 0; + for (int k = 2; k < 10; k++) v = (v << 8) | s->hdr[k]; + if (v >> 63) { + ws_protocol_close(rt, s, 1002, PNET_ERROR_WEBSOCKET_PROTOCOL_ERROR, "invalid payload length"); + return false; + } + s->payload_len = v; + } + if (s->opcode < 0x8 && s->payload_len > s->max_message_bytes) { + ws_protocol_close(rt, s, 1009, PNET_ERROR_MESSAGE_TOO_LARGE, "frame exceeds maxMessageBytes"); + return false; + } + s->payload_got = 0; + s->ctl_len = 0; + s->frame_state = FR_PAYLOAD; + if (s->payload_len == 0) { + ws_on_frame(rt, s, s->opcode, s->fin, s->ctl, 0); + s->frame_state = FR_HEAD; + s->hdr_len = 0; + } + } + continue; + } + /* payload */ + size_t remaining = (size_t)(s->payload_len - s->payload_got); + size_t n = len - i < remaining ? len - i : remaining; + if (s->opcode >= 0x8) { + memcpy(s->ctl + s->ctl_len, in + i, n); + s->ctl_len += n; + s->payload_got += n; + i += n; + if (s->payload_got == s->payload_len) { + ws_on_frame(rt, s, s->opcode, true, s->ctl, s->ctl_len); + s->frame_state = FR_HEAD; + s->hdr_len = 0; + } + continue; + } + /* Data frame payload: append to the message assembly directly (final + * validation happens per chunk; `fin` is applied on the last byte). */ + bool last = s->payload_got + n == s->payload_len; + ws_on_frame(rt, s, s->payload_got == 0 ? s->opcode : 0, last && s->fin, in + i, n); + if (!last && s->payload_got == 0 && s->opcode != 0) { + /* subsequent chunks of this frame continue the message */ + } + s->payload_got += n; + i += n; + if (last) { + s->frame_state = FR_HEAD; + s->hdr_len = 0; + } + } + return s->state == WS_OPEN || s->state == WS_CLOSING; +} + +/* ------------------------------------------------------------------------ */ +/* Handshake */ +/* ------------------------------------------------------------------------ */ + +static bool ws_build_request(pnet_runtime *rt, pnet_ws_sock *s, const char *user_headers, size_t user_len) { + uint8_t key[16]; + rt->platform.random(rt->platform.ctx, key, sizeof key); + pnet_base64_encode(key, sizeof key, s->key_b64, sizeof s->key_b64); + pnet_sb *sb = &s->request_head; + pnet_sb_puts(rt, sb, "GET "); + pnet_sb_append(rt, sb, s->url.path, s->url.path_len); + pnet_sb_puts(rt, sb, " HTTP/1.1\r\nHost: "); + if (s->url.host_is_ipv6) pnet_sb_putc(rt, sb, '['); + pnet_sb_puts(rt, sb, s->url.host); + if (s->url.host_is_ipv6) pnet_sb_putc(rt, sb, ']'); + if (s->url.port_explicit) pnet_sb_printf(rt, sb, ":%u", (unsigned)s->url.port); + pnet_sb_puts(rt, sb, "\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: "); + pnet_sb_puts(rt, sb, s->key_b64); + pnet_sb_puts(rt, sb, "\r\nSec-WebSocket-Version: 13\r\n"); + if (s->protocols) { + pnet_sb_puts(rt, sb, "Sec-WebSocket-Protocol: "); + pnet_sb_puts(rt, sb, s->protocols); + pnet_sb_puts(rt, sb, "\r\n"); + } + if (user_len) pnet_sb_append(rt, sb, user_headers, user_len); + pnet_sb_puts(rt, sb, "\r\n"); + return !sb->failed; +} + +static bool protocol_requested(const pnet_ws_sock *s, const char *value, size_t len) { + if (!s->protocols) return false; + const char *p = s->protocols; + while (*p) { + const char *end = strchr(p, ','); + size_t n = end ? (size_t)(end - p) : strlen(p); + while (n > 0 && p[0] == ' ') { p++; n--; } + if (n == len && memcmp(p, value, len) == 0) return true; + if (!end) break; + p = end + 1; + } + return false; +} + +static void ws_on_handshake_head(pnet_runtime *rt, pnet_ws_sock *s, pnet_h1_head *head) { + if (head->status != 101) { + char msg[64]; + snprintf(msg, sizeof msg, "handshake answered %d", head->status); + ws_fail(rt, s, PNET_ERROR_WEBSOCKET_HANDSHAKE_FAILED, msg, head->status); + return; + } + const pnet_h1_field *up = pnet_h1_find(head, "upgrade"); + if (!up || !pnet_ieq_n(up->value, up->value_len, "websocket")) { + ws_fail(rt, s, PNET_ERROR_WEBSOCKET_HANDSHAKE_FAILED, "missing Upgrade: websocket", 101); + return; + } + const pnet_h1_field *conn = pnet_h1_find(head, "connection"); + bool has_upgrade_token = false; + if (conn) { + const char *v = conn->value; + size_t l = conn->value_len; + size_t i = 0; + while (i <= l) { + size_t j = i; + while (j < l && v[j] != ',') j++; + size_t a = i, b = j; + while (a < b && (v[a] == ' ' || v[a] == '\t')) a++; + while (b > a && (v[b - 1] == ' ' || v[b - 1] == '\t')) b--; + if (pnet_ieq_n(v + a, b - a, "upgrade")) has_upgrade_token = true; + if (j >= l) break; + i = j + 1; + } + } + if (!has_upgrade_token) { + ws_fail(rt, s, PNET_ERROR_WEBSOCKET_HANDSHAKE_FAILED, "missing Connection: Upgrade", 101); + return; + } + const pnet_h1_field *accept = pnet_h1_find(head, "sec-websocket-accept"); + char concat[96]; + snprintf(concat, sizeof concat, "%s%s", s->key_b64, WS_GUID); + uint8_t digest[20]; + pnet_sha1((const uint8_t *)concat, strlen(concat), digest); + char expected[32]; + pnet_base64_encode(digest, 20, expected, sizeof expected); + if (!accept || accept->value_len != strlen(expected) || memcmp(accept->value, expected, accept->value_len) != 0) { + ws_fail(rt, s, PNET_ERROR_WEBSOCKET_HANDSHAKE_FAILED, "Sec-WebSocket-Accept mismatch", 101); + return; + } + if (pnet_h1_find(head, "sec-websocket-extensions")) { + ws_fail(rt, s, PNET_ERROR_WEBSOCKET_HANDSHAKE_FAILED, "unrequested extension", 101); + return; + } + const pnet_h1_field *proto = pnet_h1_find(head, "sec-websocket-protocol"); + if (proto) { + if (!protocol_requested(s, proto->value, proto->value_len)) { + ws_fail(rt, s, PNET_ERROR_WEBSOCKET_HANDSHAKE_FAILED, "unrequested subprotocol", 101); + return; + } + s->selected_protocol = pnet_strdup_n(rt, proto->value, proto->value_len); + } + /* Open. */ + s->state = WS_OPEN; + s->deadline = 0; + s->frame_state = FR_HEAD; + s->hdr_len = 0; + pnet_sb sb; + pnet_sb_init(&sb); + pnet_sb_puts(rt, &sb, ",\"protocol\":"); + const char *sel = s->selected_protocol ? s->selected_protocol : ""; + pnet_sb_json_string(rt, &sb, sel, strlen(sel)); + if (!sb.failed) ws_push(rt, s, "open", sb.data, sb.len, false, 0); + pnet_sb_free(rt, &sb); + /* Bytes after the head are frames. */ + size_t rest = s->rx_len - head->head_len; + if (rest > 0) { + uint8_t *tmp = pnet_alloc(rt, rest); + if (tmp) { + memcpy(tmp, s->rx + head->head_len, rest); + s->rx_len = 0; + ws_feed(rt, s, tmp, rest); + pnet_free(rt, tmp, rest); + } + } + s->rx_len = 0; + ws_update_read_interest(rt, s); +} + +/* ------------------------------------------------------------------------ */ +/* Service */ +/* ------------------------------------------------------------------------ */ + +static void ws_service_one(pnet_runtime *rt, pnet_ws_sock *s) { + if (s->state == WS_CLOSED) return; + if (s->state == WS_DIALING || s->state == WS_HANDSHAKE) { + if (rt->now >= s->deadline) { + ws_fail(rt, s, PNET_ERROR_TIMEOUT, "connect timeout", 0); + return; + } + } + if (s->state == WS_DIALING) { + int st = pnet_dial_step(rt, &s->dial, &s->conn); + if (st == PNET_DIAL_FAILED) { + ws_fail(rt, s, s->dial.error_code ? s->dial.error_code : PNET_ERROR_CONNECT, "connect failed", 0); + return; + } + if (st != PNET_DIAL_OPEN) return; + if (!pnet_conn_write(rt, &s->conn, s->request_head.data, s->request_head.len)) { + ws_fail(rt, s, PNET_ERROR_RESOURCE_LIMIT, "out of memory", 0); + return; + } + pnet_sb_free(rt, &s->request_head); + s->state = WS_HANDSHAKE; + } + if (!pnet_conn_flush(rt, &s->conn)) { + if (s->state == WS_HANDSHAKE) ws_fail(rt, s, PNET_ERROR_CLOSED, "connection lost during handshake", 0); + else { + s->pending_error = PNET_ERROR_CLOSED; + s->pending_error_msg = "connection lost"; + ws_closed(rt, s, 1006, "", 0, false, s->local_close); + } + return; + } + if (s->drain_armed && s->conn.tx.bytes < rt->cfg.ws_send_low_water_bytes && s->state == WS_OPEN) { + s->drain_armed = false; + ws_push(rt, s, "drain", NULL, 0, false, 0); + } + uint8_t scratch[2048]; + if (s->state == WS_HANDSHAKE) { + size_t max_head = rt->cfg.http_max_header_bytes + 512; + if (s->rx_len >= max_head) { + ws_fail(rt, s, PNET_ERROR_WEBSOCKET_HANDSHAKE_FAILED, "handshake response too large", 0); + return; + } + if (s->rx_cap < s->rx_len + 512) { + size_t cap = s->rx_cap ? s->rx_cap * 2 : 1024; + if (cap > max_head + 16) cap = max_head + 16; + uint8_t *next = pnet_alloc(rt, cap); + if (!next) { ws_fail(rt, s, PNET_ERROR_RESOURCE_LIMIT, "out of memory", 0); return; } + if (s->rx) { memcpy(next, s->rx, s->rx_len); pnet_free(rt, s->rx, s->rx_cap); } + s->rx = next; + s->rx_cap = cap; + } + int n = pnet_conn_read(rt, &s->conn, s->rx + s->rx_len, s->rx_cap - s->rx_len); + if (n == PNET_IO_AGAIN) return; + if (n <= 0) { + ws_fail(rt, s, PNET_ERROR_CLOSED, "connection closed during handshake", 0); + return; + } + s->rx_len += (size_t)n; + pnet_h1_head head; + int rc = pnet_h1_parse_head(s->rx, s->rx_len, false, rt->cfg.http_max_header_bytes, PWS_MAX_HANDSHAKE_HEADERS, 2048, &head); + if (rc == PNET_H1_INCOMPLETE) return; + if (rc != PNET_H1_OK) { + ws_fail(rt, s, PNET_ERROR_WEBSOCKET_HANDSHAKE_FAILED, "malformed handshake response", 0); + return; + } + ws_on_handshake_head(rt, s, &head); + return; + } + if (s->state == WS_OPEN || s->state == WS_CLOSING) { + if (s->state == WS_CLOSING && s->deadline && rt->now >= s->deadline) { + /* The peer never answered our close: report as an unclean local close. */ + ws_closed(rt, s, s->close_code ? s->close_code : 1006, s->close_reason, s->close_reason_len, false, true); + return; + } + for (int rounds = 0; rounds < 8; rounds++) { + if (!s->conn.read_wanted) return; + int n = pnet_conn_read(rt, &s->conn, scratch, sizeof scratch); + if (n == PNET_IO_AGAIN) return; + if (n <= 0) { + if (s->close_sent && s->close_received) return; + if (s->state == WS_CLOSING && s->close_sent) { + /* Peer closed the transport after our Close frame without answering. */ + ws_closed(rt, s, s->close_code ? s->close_code : 1006, s->close_reason, s->close_reason_len, false, s->local_close); + return; + } + s->pending_error = PNET_ERROR_CLOSED; + s->pending_error_msg = "connection lost"; + ws_closed(rt, s, 1006, "", 0, false, false); + return; + } + if (!ws_feed(rt, s, scratch, (size_t)n)) return; + if (s->terminal) return; + } + } +} + +static bool ws_retirable(const pnet_ws_sock *s) { + return s->terminal && s->binary_head == NULL; +} + +void pnet_ws_service(pnet_runtime *rt) { + pnet_ws_sock *s = rt->ws_socks; + while (s) { + pnet_ws_sock *next = s->next; + ws_service_one(rt, s); + if (ws_retirable(s)) ws_unlink(rt, s); + s = next; + } +} + +uint64_t pnet_ws_next_deadline(pnet_runtime *rt) { + uint64_t d = 0; + for (pnet_ws_sock *s = rt->ws_socks; s; s = s->next) + if (!s->terminal && s->deadline) d = pnet_min_deadline(d, s->deadline); + return d; +} + +bool pnet_ws_has_output(pnet_runtime *rt) { + for (pnet_ws_sock *s = rt->ws_socks; s; s = s->next) + if (s->conn.state == PNET_CONN_OPEN && s->conn.tx.bytes > 0) return true; + return false; +} + +void pnet_ws_freeze(pnet_runtime *rt) { + /* Text messages become visible now: release their receive-queue share. */ + for (pnet_ws_sock *s = rt->ws_socks; s; s = s->next) { + if (s->text_msgs_pending) { + s->queued_bytes = s->queued_bytes >= s->text_bytes_pending ? s->queued_bytes - s->text_bytes_pending : 0; + s->queued_msgs = s->queued_msgs >= s->text_msgs_pending ? s->queued_msgs - s->text_msgs_pending : 0; + s->text_bytes_pending = 0; + s->text_msgs_pending = 0; + if (s->state == WS_OPEN) ws_update_read_interest(rt, s); + } + } +} + +void pnet_ws_quiesce(pnet_runtime *rt) { + for (pnet_ws_sock *s = rt->ws_socks; s; s = s->next) { + if (s->terminal) continue; + if (s->state == WS_OPEN || s->state == WS_CLOSING) { + s->local_close = true; + ws_closed(rt, s, 1001, "going away", 10, false, true); + } else { + ws_fail(rt, s, PNET_ERROR_CANCELLED, "runtime closing", 0); + } + } +} + +void pnet_ws_init(pnet_runtime *rt) { + pnet_sb sb; + pnet_sb_init(&sb); + const pnet_runtime_config *c = &rt->cfg; + pnet_sb_printf(rt, &sb, + "{\"specMajor\":%d,\"specMinor\":%d,\"maxSockets\":%u,\"maxTlsInflight\":0,\"maxMessageBytes\":%zu," + "\"maxReceiveQueueBytes\":%zu,\"maxReceiveQueueMessages\":%u,\"maxSendQueueBytes\":%zu," + "\"sendHighWaterBytes\":%zu,\"sendLowWaterBytes\":%zu,\"maxHandshakeHeaders\":%d," + "\"maxHandshakeHeaderBytes\":%zu,\"maxEventsPerTick\":%u,\"maxTickBytes\":%zu,\"defaultConnectMs\":%u," + "\"maxConnectMs\":%u,\"defaultCloseMs\":%u,\"tlsMinVersion\":\"%s\",\"features\":[]}", + PWS_SPEC_MAJOR, PWS_SPEC_MINOR, c->ws_max_sockets, c->ws_max_message_bytes, c->ws_max_receive_queue_bytes, + c->ws_max_receive_queue_messages, c->ws_max_send_queue_bytes, c->ws_send_high_water_bytes, + c->ws_send_low_water_bytes, PWS_MAX_HANDSHAKE_HEADERS, c->http_max_header_bytes, c->ws_max_events_per_tick, + c->ws_max_tick_bytes, c->ws_default_connect_ms, c->ws_max_connect_ms, c->ws_default_close_ms, + PNET_TLS_MIN_VERSION); + rt->ws_limits_json = sb.failed ? NULL : pnet_strdup_n(rt, sb.data, sb.len); + pnet_sb_free(rt, &sb); +} + +void pnet_ws_shutdown(pnet_runtime *rt) { + while (rt->ws_socks) { + pnet_ws_sock *s = rt->ws_socks; + rt->ws_socks = s->next; + ws_free(rt, s); + } + if (rt->ws_limits_json) pnet_free_str(rt, rt->ws_limits_json); + rt->ws_limits_json = NULL; + rt->ws_live = 0; +} + +/* ------------------------------------------------------------------------ */ +/* Guest ops */ +/* ------------------------------------------------------------------------ */ + +static int refuse(pnet_runtime *rt, const char *code, const char *message) { + pnet_set_last_error(rt, &rt->ws_last_error, code, message); + return -1; +} + +int pnet_ws_connect(pnet_runtime *rt, const char *meta_json) { + if (rt->quiesced) return refuse(rt, PNET_ERROR_CLOSED, "runtime is closing"); + if (rt->ws_live >= rt->cfg.ws_max_sockets) return refuse(rt, PNET_ERROR_RESOURCE_LIMIT, "too many sockets"); + if (!meta_json) return refuse(rt, PNET_ERROR_INVALID_REQUEST, "missing metadata"); + int cap = 256; + pnet_jnode *nodes = pnet_alloc(rt, (size_t)cap * sizeof(pnet_jnode)); + if (!nodes) return refuse(rt, PNET_ERROR_RESOURCE_LIMIT, "out of memory"); + pnet_jdoc doc; + int root = pnet_json_parse(&doc, nodes, cap, meta_json, strlen(meta_json)); + int result = -1; + pnet_ws_sock *s = NULL; + pnet_sb user_headers; + pnet_sb_init(&user_headers); + char buf[520]; + size_t blen; + int64_t v; + if (root < 0 || pnet_json_type(&doc, root) != PNET_J_OBJECT) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "malformed connect metadata"); goto out; } + s = pnet_zalloc(rt, sizeof *s); + if (!s) { refuse(rt, PNET_ERROR_RESOURCE_LIMIT, "out of memory"); goto out; } + pnet_conn_init(&s->conn); + pnet_sb_init(&s->request_head); + { + char *url = pnet_json_string_dup(rt, &doc, pnet_json_get(&doc, root, "url"), &blen); + if (!url) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "url required"); goto out; } + bool ok = pnet_url_parse(rt, url, blen, &s->url); + pnet_free_str(rt, url); + if (!ok) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid url"); goto out; } + pnet_proto proto = pnet_proto_from_scheme(s->url.scheme); + if (proto != PNET_PROTO_WS && proto != PNET_PROTO_WSS) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "url must be ws: or wss:"); goto out; } + if (proto == PNET_PROTO_WSS && !rt->has_features_tls) { refuse(rt, PNET_ERROR_UNSUPPORTED, "this host does not provide network.websocket.client.tls"); goto out; } + if (pnet_proto_is_plaintext(proto) && !rt->policy.insecure_transport) { refuse(rt, PNET_ERROR_PERMISSION_DENIED, "insecureTransport is not enabled"); goto out; } + if (!pnet_policy_allows_connect(&rt->policy, proto, s->url.host, s->url.port)) { refuse(rt, PNET_ERROR_PERMISSION_DENIED, "endpoint is not an allowed connect rule"); goto out; } + } + { + int protos = pnet_json_get(&doc, root, "protocols"); + if (protos >= 0) { + if (pnet_json_type(&doc, protos) != PNET_J_ARRAY) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "protocols must be an array"); goto out; } + pnet_sb sb; + pnet_sb_init(&sb); + for (int e = pnet_json_first(&doc, protos); e >= 0; e = pnet_json_next(&doc, e)) { + if (!pnet_json_string(&doc, e, buf, sizeof buf, &blen) || !pnet_is_token(buf, blen) || protocol_requested(s, buf, blen)) { + pnet_sb_free(rt, &sb); + refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid subprotocol"); + goto out; + } + if (sb.len) pnet_sb_puts(rt, &sb, ", "); + pnet_sb_append(rt, &sb, buf, blen); + /* Keep the running list visible to protocol_requested() for dup checks. */ + if (s->protocols) pnet_free_str(rt, s->protocols); + s->protocols = pnet_strdup_n(rt, pnet_sb_cstr(&sb), sb.len); + } + pnet_sb_free(rt, &sb); + } + } + { + int headers = pnet_json_get(&doc, root, "headers"); + if (headers >= 0) { + if (pnet_json_type(&doc, headers) != PNET_J_OBJECT) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "headers must be an object"); goto out; } + static const char *const forbidden[] = PWS_FORBIDDEN_HEADERS; + uint32_t count = 0; + for (int k = pnet_json_first(&doc, headers); k >= 0; k = pnet_json_next(&doc, k)) { + char name[128]; + size_t nl; + if (!pnet_json_string(&doc, k, name, sizeof name, &nl) || !pnet_is_token(name, nl)) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid header name"); goto out; } + pnet_lower(name, nl); + for (size_t i = 0; i < PWS_FORBIDDEN_HEADERS_COUNT; i++) + if (strcmp(name, forbidden[i]) == 0) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "header owned by the core"); goto out; } + size_t vl; + char *value = pnet_json_string_dup(rt, &doc, doc.nodes[k].first_child, &vl); + if (!value) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid header value"); goto out; } + bool bad = false; + for (size_t i = 0; i < vl; i++) { + unsigned char ch = (unsigned char)value[i]; + if ((ch < 0x20 && ch != '\t') || ch == 0x7f) bad = true; + } + if (bad || ++count > PWS_MAX_HANDSHAKE_HEADERS) { pnet_free_str(rt, value); refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid header"); goto out; } + pnet_sb_append(rt, &user_headers, name, nl); + pnet_sb_puts(rt, &user_headers, ": "); + pnet_sb_append(rt, &user_headers, value, vl); + pnet_sb_puts(rt, &user_headers, "\r\n"); + pnet_free_str(rt, value); + } + if (user_headers.len > rt->cfg.http_max_header_bytes) { refuse(rt, PNET_ERROR_RESOURCE_LIMIT, "handshake headers exceed limits"); goto out; } + } + } + { + int t = pnet_json_get(&doc, root, "timeouts"); + s->connect_ms = rt->cfg.ws_default_connect_ms; + s->close_ms = rt->cfg.ws_default_close_ms; + if (t >= 0) { + if (pnet_json_type(&doc, t) != PNET_J_OBJECT) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid timeouts"); goto out; } + int n = pnet_json_get(&doc, t, "connectMs"); + if (n >= 0) { + if (!pnet_json_i64(&doc, n, &v) || v < 1 || v > (int64_t)rt->cfg.ws_max_connect_ms) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid timeouts.connectMs"); goto out; } + s->connect_ms = (uint32_t)v; + } + n = pnet_json_get(&doc, t, "closeMs"); + if (n >= 0) { + if (!pnet_json_i64(&doc, n, &v) || v < 1 || v > (int64_t)rt->cfg.ws_max_connect_ms) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid timeouts.closeMs"); goto out; } + s->close_ms = (uint32_t)v; + } + } + } + { + int lim = pnet_json_get(&doc, root, "limits"); + s->max_message_bytes = rt->cfg.ws_max_message_bytes; + s->receive_queue_bytes = rt->cfg.ws_max_receive_queue_bytes; + s->receive_queue_messages = rt->cfg.ws_max_receive_queue_messages; + s->send_queue_bytes = rt->cfg.ws_max_send_queue_bytes; + if (lim >= 0) { + if (pnet_json_type(&doc, lim) != PNET_J_OBJECT) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid limits"); goto out; } + struct { const char *key; size_t *out; size_t max; } fields[] = { + {"maxMessageBytes", &s->max_message_bytes, rt->cfg.ws_max_message_bytes}, + {"receiveQueueBytes", &s->receive_queue_bytes, rt->cfg.ws_max_receive_queue_bytes}, + {"sendQueueBytes", &s->send_queue_bytes, rt->cfg.ws_max_send_queue_bytes}, + }; + for (size_t i = 0; i < 3; i++) { + int n = pnet_json_get(&doc, lim, fields[i].key); + if (n < 0) continue; + if (!pnet_json_i64(&doc, n, &v) || v < 1 || (uint64_t)v > fields[i].max) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid limits"); goto out; } + *fields[i].out = (size_t)v; + } + int n = pnet_json_get(&doc, lim, "receiveQueueMessages"); + if (n >= 0) { + if (!pnet_json_i64(&doc, n, &v) || v < 1 || v > (int64_t)rt->cfg.ws_max_receive_queue_messages) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid limits.receiveQueueMessages"); goto out; } + s->receive_queue_messages = (uint32_t)v; + } + } + } + { + int tls = pnet_json_get(&doc, root, "tls"); + if (tls >= 0) { + int vn = pnet_json_get(&doc, tls, "verification"); + if (vn >= 0) { + if (!pnet_json_string(&doc, vn, buf, sizeof buf, &blen)) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid tls.verification"); goto out; } + if (strcmp(buf, "development-insecure") == 0) { + if (!rt->cfg.development_build || !rt->policy.allow_invalid_tls_for_development) { refuse(rt, PNET_ERROR_UNSUPPORTED, "development-insecure TLS is not enabled"); goto out; } + } else if (strcmp(buf, "full") != 0) { + refuse(rt, PNET_ERROR_INVALID_REQUEST, "invalid tls.verification"); + goto out; + } + } + } + } + if (!ws_build_request(rt, s, user_headers.data ? user_headers.data : "", user_headers.len)) { refuse(rt, PNET_ERROR_RESOURCE_LIMIT, "out of memory"); goto out; } + s->handle = rt->ws_next_handle++; + if (rt->ws_next_handle <= 0) rt->ws_next_handle = 1; + s->state = WS_DIALING; + rt->now = pnet_now(rt); + s->deadline = rt->now + s->connect_ms; + s->live_counted = true; + rt->ws_live++; + s->next = rt->ws_socks; + rt->ws_socks = s; + result = s->handle; + { + bool secure = strcmp(s->url.scheme, "wss") == 0; + if (!pnet_dial_start(rt, &s->dial, &s->conn, s->url.host, s->url.port, secure, s->url.host, true)) { + ws_fail(rt, s, s->dial.error_code ? s->dial.error_code : PNET_ERROR_CONNECT, + s->dial.error_message ? s->dial.error_message : "connect failed", 0); + } + } + s = NULL; +out: + pnet_sb_free(rt, &user_headers); + if (s) ws_free(rt, s); + pnet_free(rt, nodes, (size_t)cap * sizeof(pnet_jnode)); + return result; +} + +int pnet_ws_send(pnet_runtime *rt, int handle, int opcode, const uint8_t *payload, size_t len) { + pnet_ws_sock *s = ws_find(rt, handle); + if (!s || s->state != WS_OPEN || s->terminal) return PWS_SEND_CLOSED; + if (opcode == PWS_OPCODE_PING || opcode == PWS_OPCODE_PONG) { + if (len > PWS_CONTROL_PAYLOAD_MAX) return PWS_SEND_INVALID; + } else if (opcode == PWS_OPCODE_TEXT || opcode == PWS_OPCODE_BINARY) { + if (len > s->max_message_bytes) return PWS_SEND_INVALID; + if (opcode == PWS_OPCODE_TEXT && !pnet_utf8_valid(payload, len)) return PWS_SEND_INVALID; + } else { + return PWS_SEND_INVALID; + } + size_t framed = len + 14; + if (s->conn.tx.bytes + framed > s->send_queue_bytes) { + s->drain_armed = true; + return PWS_SEND_BACKPRESSURE; + } + if (!ws_write_frame(rt, s, (uint8_t)opcode, payload, len)) { + s->drain_armed = true; + return PWS_SEND_BACKPRESSURE; + } + if (s->conn.tx.bytes > rt->cfg.ws_send_high_water_bytes) { + s->drain_armed = true; + return PWS_SEND_ACCEPTED_HIGH_WATER; + } + return PWS_SEND_ACCEPTED; +} + +int pnet_ws_receive_into(pnet_runtime *rt, int handle, uint8_t *dst, size_t len) { + pnet_ws_sock *s = ws_find(rt, handle); + if (!s || !s->binary_head) return -1; + ws_message *m = s->binary_head; + if (len < m->len) return -1; + memcpy(dst, m->data, m->len); + s->binary_head = m->next; + if (!s->binary_head) s->binary_tail = NULL; + int n = (int)m->len; + s->queued_bytes = s->queued_bytes >= m->len ? s->queued_bytes - m->len : 0; + if (s->queued_msgs) s->queued_msgs--; + pnet_free(rt, m->data, m->len ? m->len : 1); + pnet_free(rt, m, sizeof *m); + if (s->state == WS_OPEN) ws_update_read_interest(rt, s); + if (ws_retirable(s)) ws_unlink(rt, s); + return n; +} + +int pnet_ws_close(pnet_runtime *rt, int handle, int code, const char *reason, size_t reason_len) { + pnet_ws_sock *s = ws_find(rt, handle); + if (!s || s->state != WS_OPEN || s->terminal) return -1; + if (code != 0 && code != 1000 && (code < 3000 || code > 4999)) return PWS_SEND_INVALID; + if (reason_len > 123 || (reason_len && !pnet_utf8_valid((const uint8_t *)reason, reason_len))) return PWS_SEND_INVALID; + s->local_close = true; + s->close_code = code ? code : 1005; + s->close_reason_len = reason_len; + if (reason_len) memcpy(s->close_reason, reason, reason_len); + ws_send_close_frame(rt, s, code, reason, reason_len); + s->state = WS_CLOSING; + s->deadline = pnet_now(rt) + s->close_ms; + return 0; +} + +void pnet_ws_terminate(pnet_runtime *rt, int handle) { + pnet_ws_sock *s = ws_find(rt, handle); + if (!s) return; + if (s->terminal) { + ws_unlink(rt, s); + return; + } + if (s->state == WS_OPEN || s->state == WS_CLOSING) { + ws_closed(rt, s, 1006, "", 0, false, true); + } else { + ws_fail(rt, s, PNET_ERROR_CANCELLED, "terminated", 0); + } +} + +int pnet_ws_buffered_amount(pnet_runtime *rt, int handle) { + pnet_ws_sock *s = ws_find(rt, handle); + if (!s || s->terminal) return -1; + return (int)s->conn.tx.bytes; +} + +const char *pnet_ws_poll(pnet_runtime *rt, size_t *len) { + return pnet_queue_poll(rt, &rt->ws_queue, len); +} + +const char *pnet_ws_last_error(pnet_runtime *rt) { + return pnet_sb_cstr(&rt->ws_last_error); +} + +const char *pnet_ws_limits(pnet_runtime *rt) { + return rt->ws_limits_json ? rt->ws_limits_json : "{}"; +} diff --git a/engine/net/test/host_test.c b/engine/net/test/host_test.c new file mode 100644 index 00000000..ca0daf9b --- /dev/null +++ b/engine/net/test/host_test.c @@ -0,0 +1,1205 @@ +/* Socket-level harness for the network core on a POSIX host: the runtime + * runs with the BSD-socket driver on a network thread while the owner thread + * ticks it (begin_tick + poll) the way a guest host does. Peers are plain + * blocking sockets in this process, so every framing case is deterministic: + * a scripted HTTP server for the client core, and a raw client for the + * server core. */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pnet_internal.h" +#include "pnet_posix_driver.h" +#include "pocketjs/net/runtime.h" + +static int failures = 0; +static int checks = 0; +#define CHECK(cond) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + } \ + } while (0) + +/* --- platform ----------------------------------------------------------- */ + +static uint64_t now_ms(void *ctx) { + (void)ctx; + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000 + (uint64_t)ts.tv_nsec / 1000000; +} +static void *plat_alloc(void *ctx, size_t size) { (void)ctx; return malloc(size); } +static void plat_free(void *ctx, void *ptr, size_t size) { (void)ctx; (void)size; free(ptr); } +static void plat_random(void *ctx, uint8_t *out, size_t len) { + (void)ctx; + for (size_t i = 0; i < len; i++) out[i] = (uint8_t)rand(); +} +static void plat_log(void *ctx, pnet_log_level level, const char *msg) { + (void)ctx; + if (level <= PNET_LOG_WARN) fprintf(stderr, "[pnet] %s\n", msg); +} + +/* --- runtime + network thread ------------------------------------------- */ + +typedef struct harness { + pnet_runtime *rt; + pnet_posix_driver *driver; + pthread_mutex_t lock; + pthread_t thread; + volatile int stop; +} harness; + +static void *net_thread(void *arg) { + harness *h = arg; + while (!h->stop) { + pthread_mutex_lock(&h->lock); + pnet_posix_driver_dispatch(h->driver, h->rt); + pnet_runtime_service(h->rt); + uint64_t deadline = pnet_runtime_next_deadline_ms(h->rt); + bool more = pnet_runtime_has_pending_output(h->rt); + pthread_mutex_unlock(&h->lock); + int timeout = 50; + if (deadline) { + uint64_t now = now_ms(NULL); + timeout = deadline > now ? (int)(deadline - now) : 0; + if (timeout > 50) timeout = 50; + } + if (more) timeout = 0; + pnet_posix_driver_wait(h->driver, timeout); + } + return NULL; +} + +static void harness_start(harness *h, const char *policy) { + pnet_platform plat = {NULL, now_ms, plat_alloc, plat_free, plat_random, plat_log}; + pnet_runtime_config cfg; + pnet_runtime_config_defaults(&cfg); + cfg.io_chunk_bytes = 1024; + h->driver = pnet_posix_driver_create(32); + h->rt = pnet_runtime_create(&plat, pnet_posix_driver_ops(), h->driver, &cfg, policy); + pthread_mutex_init(&h->lock, NULL); + h->stop = 0; + pthread_create(&h->thread, NULL, net_thread, h); +} + +static void harness_stop(harness *h) { + h->stop = 1; + pnet_posix_driver_wake(h->driver); + pthread_join(h->thread, NULL); + pnet_runtime_destroy(h->rt); + pnet_posix_driver_destroy(h->driver); + pthread_mutex_destroy(&h->lock); +} + +/* One guest tick: begin_tick then poll of the named module. Returns a + * malloc'd copy of the batch or NULL. */ +typedef const char *(*poll_fn)(pnet_runtime *, size_t *); + +static char *tick(harness *h, poll_fn poll) { + pthread_mutex_lock(&h->lock); + pnet_runtime_begin_tick(h->rt); + size_t len = 0; + const char *batch = poll(h->rt, &len); + char *copy = batch ? strdup(batch) : NULL; + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + return copy; +} + +/* Tick until `needle` appears in a batch or the timeout elapses; every batch + * is appended to `log` (caller-provided buffer). */ +static bool wait_for(harness *h, poll_fn poll, const char *needle, int timeout_ms, char *log, size_t log_cap) { + uint64_t end = now_ms(NULL) + (uint64_t)timeout_ms; + size_t used = strlen(log); + while (now_ms(NULL) < end) { + char *batch = tick(h, poll); + if (batch) { + size_t n = strlen(batch); + if (used + n + 2 < log_cap) { + memcpy(log + used, batch, n); + used += n; + log[used++] = '\n'; + log[used] = 0; + } + bool hit = strstr(batch, needle) != NULL; + free(batch); + if (hit) return true; + } + usleep(5000); + } + return false; +} + +/* --- scripted HTTP peer ------------------------------------------------- */ + +typedef struct peer { + int listen_fd; + uint16_t port; + pthread_t thread; + volatile int stop; + volatile int connections; + char last_request[4096]; +} peer; + +static ssize_t read_head(int fd, char *buf, size_t cap) { + size_t len = 0; + while (len + 1 < cap) { + ssize_t n = recv(fd, buf + len, 1, 0); + if (n <= 0) return -1; + len += (size_t)n; + buf[len] = 0; + if (len >= 4 && strcmp(buf + len - 4, "\r\n\r\n") == 0) return (ssize_t)len; + } + return -1; +} + +static void send_all(int fd, const char *data, size_t len) { + while (len > 0) { + ssize_t n = send(fd, data, len, 0); + if (n <= 0) return; + data += n; + len -= (size_t)n; + } +} + +static void *peer_thread(void *arg) { + peer *p = arg; + while (!p->stop) { + struct sockaddr_in a; + socklen_t al = sizeof a; + int fd = accept(p->listen_fd, (struct sockaddr *)&a, &al); + if (fd < 0) { + if (p->stop) break; + continue; + } + p->connections++; + char head[4096]; + ssize_t hl = read_head(fd, head, sizeof head); + if (hl < 0) { + close(fd); + continue; + } + strncpy(p->last_request, head, sizeof p->last_request - 1); + char target[256] = {0}; + sscanf(head, "%*s %255s", target); + /* Content-Length of the request, if any: read the body. */ + const char *cl = strcasestr(head, "content-length:"); + size_t body_len = cl ? (size_t)atoi(cl + 15) : 0; + char body[512] = {0}; + if (body_len > 0 && body_len < sizeof body) { + size_t got = 0; + while (got < body_len) { + ssize_t n = recv(fd, body + got, body_len - got, 0); + if (n <= 0) break; + got += (size_t)n; + } + } + if (strcmp(target, "/hello") == 0) { + const char *r = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nX-Peer: 1\r\nSet-Cookie: a=1\r\nSet-Cookie: b=2\r\nContent-Length: 5\r\n\r\nhello"; + send_all(fd, r, strlen(r)); + } else if (strcmp(target, "/chunked") == 0) { + const char *r = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"; + send_all(fd, r, strlen(r)); + usleep(20000); + send_all(fd, "5\r\nchunk\r\n", 10); + usleep(20000); + send_all(fd, "3\r\ned!\r\n0\r\nX-Trailer: ok\r\n\r\n", 26); + } else if (strcmp(target, "/close-delimited") == 0) { + const char *r = "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nuntil-close"; + send_all(fd, r, strlen(r)); + } else if (strcmp(target, "/redirect") == 0) { + const char *r = "HTTP/1.1 302 Found\r\nLocation: /hello\r\nContent-Length: 0\r\n\r\n"; + send_all(fd, r, strlen(r)); + } else if (strcmp(target, "/redirect-loop") == 0) { + const char *r = "HTTP/1.1 302 Found\r\nLocation: /redirect-loop\r\nContent-Length: 0\r\n\r\n"; + send_all(fd, r, strlen(r)); + } else if (strcmp(target, "/redirect-post") == 0) { + const char *r = "HTTP/1.1 303 See Other\r\nLocation: /echo-method\r\nContent-Length: 0\r\n\r\n"; + send_all(fd, r, strlen(r)); + } else if (strcmp(target, "/echo-method") == 0) { + char method[16] = {0}; + sscanf(head, "%15s", method); + char r[128]; + int n = snprintf(r, sizeof r, "HTTP/1.1 200 OK\r\nContent-Length: %zu\r\n\r\n%s", strlen(method), method); + send_all(fd, r, (size_t)n); + } else if (strcmp(target, "/te-cl") == 0) { + const char *r = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Length: 5\r\n\r\n0\r\n\r\n"; + send_all(fd, r, strlen(r)); + } else if (strcmp(target, "/big") == 0) { + char h[128]; + int n = snprintf(h, sizeof h, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n", 100000); + send_all(fd, h, (size_t)n); + char chunk[1000]; + for (int i = 0; i < 100; i++) { + memset(chunk, 'a' + (i % 26), sizeof chunk); + send_all(fd, chunk, sizeof chunk); + } + } else if (strcmp(target, "/slow") == 0) { + usleep(700000); + const char *r = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"; + send_all(fd, r, strlen(r)); + } else if (strcmp(target, "/post") == 0) { + char r[600]; + int n = snprintf(r, sizeof r, "HTTP/1.1 201 Created\r\nContent-Length: %zu\r\n\r\n%s", body_len, body); + send_all(fd, r, (size_t)n); + } else if (strcmp(target, "/head") == 0) { + const char *r = "HTTP/1.1 200 OK\r\nContent-Length: 42\r\n\r\n"; + send_all(fd, r, strlen(r)); + } else if (strcmp(target, "/truncated") == 0) { + const char *r = "HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nabc"; + send_all(fd, r, strlen(r)); + } else if (strcmp(target, "/nocontent") == 0) { + const char *r = "HTTP/1.1 204 No Content\r\n\r\n"; + send_all(fd, r, strlen(r)); + } else { + const char *r = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"; + send_all(fd, r, strlen(r)); + } + usleep(10000); + close(fd); + } + return NULL; +} + +static bool peer_start(peer *p) { + memset(p, 0, sizeof *p); + p->listen_fd = socket(AF_INET, SOCK_STREAM, 0); + int one = 1; + setsockopt(p->listen_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + struct sockaddr_in a; + memset(&a, 0, sizeof a); + a.sin_family = AF_INET; + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + a.sin_port = 0; + if (bind(p->listen_fd, (struct sockaddr *)&a, sizeof a) < 0 || listen(p->listen_fd, 8) < 0) return false; + socklen_t al = sizeof a; + getsockname(p->listen_fd, (struct sockaddr *)&a, &al); + p->port = ntohs(a.sin_port); + pthread_create(&p->thread, NULL, peer_thread, p); + return true; +} + +static void peer_stop(peer *p) { + p->stop = 1; + shutdown(p->listen_fd, SHUT_RDWR); + close(p->listen_fd); + pthread_join(p->thread, NULL); +} + +/* --- HTTP client tests -------------------------------------------------- */ + +static int start_get(harness *h, uint16_t port, const char *path, const char *extra) { + char meta[512]; + snprintf(meta, sizeof meta, "{\"url\":\"http://127.0.0.1:%u%s\",\"method\":\"GET\",\"headers\":{\"x-test\":\"1\"}%s}", port, path, + extra ? extra : ""); + pthread_mutex_lock(&h->lock); + int handle = pnet_http_start(h->rt, meta, NULL, 0); + if (handle < 0) fprintf(stderr, "start refused: %s\n", pnet_http_last_error(h->rt)); + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + return handle; +} + +/* Read the whole body of a handle across ticks into buf; returns bytes. */ +static size_t read_body(harness *h, int handle, char *buf, size_t cap, int timeout_ms) { + size_t total = 0; + uint64_t end = now_ms(NULL) + (uint64_t)timeout_ms; + bool ended = false; + while (now_ms(NULL) < end && !ended) { + char *batch = tick(h, pnet_http_poll); + if (batch) { + if (strstr(batch, "\"t\":\"end\"")) ended = true; + if (strstr(batch, "\"t\":\"error\"")) { + free(batch); + break; + } + free(batch); + } + pthread_mutex_lock(&h->lock); + for (;;) { + if (total >= cap) break; + int n = pnet_http_read_into(h->rt, handle, (uint8_t *)buf + total, cap - total); + if (n <= 0) break; + total += (size_t)n; + } + pthread_mutex_unlock(&h->lock); + if (!ended) usleep(3000); + } + return total; +} + +static void test_client(harness *h, peer *p) { + char log[16384]; + char body[128 * 1024]; + + /* 1. Plain GET: headers event, body, end; Set-Cookie delivered as an array. */ + log[0] = 0; + int handle = start_get(h, p->port, "/hello", NULL); + CHECK(handle > 0); + CHECK(wait_for(h, pnet_http_poll, "\"t\":\"headers\"", 2000, log, sizeof log)); + CHECK(strstr(log, "\"status\":200") != NULL); + CHECK(strstr(log, "\"set-cookie\":[\"a=1\",\"b=2\"]") != NULL); + CHECK(strstr(log, "\"length\":5") != NULL); + CHECK(strstr(log, "\"redirected\":false") != NULL); + size_t n = read_body(h, handle, body, sizeof body, 2000); + CHECK(n == 5 && memcmp(body, "hello", 5) == 0); + CHECK(strstr(p->last_request, "GET /hello HTTP/1.1\r\nHost: 127.0.0.1:") != NULL); + CHECK(strstr(p->last_request, "x-test: 1\r\n") != NULL); + CHECK(strstr(p->last_request, "Connection: close\r\n") != NULL); + pthread_mutex_lock(&h->lock); + CHECK(!pnet_runtime_has_live_handles(h->rt)); + pthread_mutex_unlock(&h->lock); + + /* 2. Chunked with trailer, split across sends. */ + handle = start_get(h, p->port, "/chunked", NULL); + n = read_body(h, handle, body, sizeof body, 3000); + CHECK(n == 8 && memcmp(body, "chunked!", 8) == 0); + + /* 3. Close-delimited. */ + handle = start_get(h, p->port, "/close-delimited", NULL); + n = read_body(h, handle, body, sizeof body, 3000); + CHECK(n == 11 && memcmp(body, "until-close", 11) == 0); + + /* 4. Redirect followed; final URL and redirected flag reported. */ + log[0] = 0; + handle = start_get(h, p->port, "/redirect", NULL); + CHECK(wait_for(h, pnet_http_poll, "\"t\":\"headers\"", 3000, log, sizeof log)); + CHECK(strstr(log, "\"redirected\":true") != NULL); + CHECK(strstr(log, "/hello\"") != NULL); + n = read_body(h, handle, body, sizeof body, 2000); + CHECK(n == 5); + /* manual: the 302 itself is delivered */ + log[0] = 0; + handle = start_get(h, p->port, "/redirect", ",\"redirect\":\"manual\""); + CHECK(wait_for(h, pnet_http_poll, "\"status\":302", 3000, log, sizeof log)); + read_body(h, handle, body, sizeof body, 1000); + /* error mode */ + log[0] = 0; + handle = start_get(h, p->port, "/redirect", ",\"redirect\":\"error\""); + CHECK(wait_for(h, pnet_http_poll, "\"code\":\"redirect\"", 3000, log, sizeof log)); + /* loop exhausts maxRedirects */ + log[0] = 0; + handle = start_get(h, p->port, "/redirect-loop", ",\"maxRedirects\":2"); + CHECK(wait_for(h, pnet_http_poll, "\"code\":\"redirect\"", 5000, log, sizeof log)); + /* 303 rewrites POST to GET and drops the body */ + { + char meta[256]; + snprintf(meta, sizeof meta, "{\"url\":\"http://127.0.0.1:%u/redirect-post\",\"method\":\"POST\",\"headers\":{\"content-type\":\"text/plain\"}}", p->port); + pthread_mutex_lock(&h->lock); + handle = pnet_http_start(h->rt, meta, (const uint8_t *)"payload", 7); + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + CHECK(handle > 0); + n = read_body(h, handle, body, sizeof body, 3000); + CHECK(n == 3 && memcmp(body, "GET", 3) == 0); + CHECK(strstr(p->last_request, "content-type") == NULL); + CHECK(strstr(p->last_request, "Content-Length") == NULL); /* GET carries no body framing */ + } + + /* 5. Framing violation → protocol. */ + log[0] = 0; + handle = start_get(h, p->port, "/te-cl", NULL); + CHECK(wait_for(h, pnet_http_poll, "\"code\":\"protocol\"", 3000, log, sizeof log)); + + /* 6. Backpressure: small queue, slow reader, 100 KB body arrives intact. */ + handle = start_get(h, p->port, "/big", ",\"queueBytes\":4096"); + { + size_t total = 0; + uint64_t end = now_ms(NULL) + 8000; + bool ended = false; + int max_avail = 0; + while (now_ms(NULL) < end && !ended) { + char *batch = tick(h, pnet_http_poll); + if (batch) { + const char *r = strstr(batch, "\"avail\":"); + if (r) { + int avail = atoi(r + 8); + if (avail > max_avail) max_avail = avail; + } + if (strstr(batch, "\"t\":\"end\"")) ended = true; + free(batch); + } + pthread_mutex_lock(&h->lock); + int got = pnet_http_read_into(h->rt, handle, (uint8_t *)body, 1500); + pthread_mutex_unlock(&h->lock); + if (got > 0) { + for (int i = 0; i < got; i++) { + if (body[i] != 'a' + (int)((total + (size_t)i) / 1000 % 26)) { + CHECK(!"body content mismatch"); + break; + } + } + total += (size_t)got; + } + usleep(2000); + } + /* Drain what is left after end. */ + for (;;) { + pthread_mutex_lock(&h->lock); + int got = pnet_http_read_into(h->rt, handle, (uint8_t *)body, sizeof body); + pthread_mutex_unlock(&h->lock); + if (got <= 0) break; + total += (size_t)got; + } + CHECK(total == 100000); + CHECK(max_avail <= 4096 + 2048); /* never far past the queue window */ + } + + /* 7. Timeouts: headers timeout on a slow peer. */ + log[0] = 0; + handle = start_get(h, p->port, "/slow", ",\"timeouts\":{\"headersMs\":200}"); + CHECK(wait_for(h, pnet_http_poll, "\"code\":\"timeout\"", 3000, log, sizeof log)); + + /* 8. POST body echo. */ + { + char meta[256]; + snprintf(meta, sizeof meta, "{\"url\":\"http://127.0.0.1:%u/post\",\"method\":\"POST\",\"headers\":{}}", p->port); + pthread_mutex_lock(&h->lock); + handle = pnet_http_start(h->rt, meta, (const uint8_t *)"body-bytes", 10); + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + log[0] = 0; + CHECK(wait_for(h, pnet_http_poll, "\"status\":201", 3000, log, sizeof log)); + n = read_body(h, handle, body, sizeof body, 2000); + CHECK(n == 10 && memcmp(body, "body-bytes", 10) == 0); + CHECK(strstr(p->last_request, "Content-Length: 10\r\n") != NULL); + } + + /* 9. HEAD: headers carry length, then end without a body. */ + { + char meta[256]; + snprintf(meta, sizeof meta, "{\"url\":\"http://127.0.0.1:%u/head\",\"method\":\"HEAD\",\"headers\":{}}", p->port); + pthread_mutex_lock(&h->lock); + handle = pnet_http_start(h->rt, meta, NULL, 0); + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + log[0] = 0; + CHECK(wait_for(h, pnet_http_poll, "\"t\":\"end\"", 3000, log, sizeof log)); + CHECK(strstr(log, "\"length\":42") != NULL); + CHECK(strstr(log, "\"t\":\"readable\"") == NULL); + } + /* 204 */ + log[0] = 0; + handle = start_get(h, p->port, "/nocontent", NULL); + CHECK(wait_for(h, pnet_http_poll, "\"t\":\"end\"", 3000, log, sizeof log)); + CHECK(strstr(log, "\"status\":204") != NULL); + + /* 10. Truncated body → closed. */ + log[0] = 0; + handle = start_get(h, p->port, "/truncated", NULL); + CHECK(wait_for(h, pnet_http_poll, "\"code\":\"closed\"", 3000, log, sizeof log)); + + /* 11. Cancel: the terminal error arrives at the next tick. */ + handle = start_get(h, p->port, "/slow", NULL); + usleep(50000); + pthread_mutex_lock(&h->lock); + pnet_http_cancel(h->rt, handle); + pthread_mutex_unlock(&h->lock); + log[0] = 0; + CHECK(wait_for(h, pnet_http_poll, "\"code\":\"cancelled\"", 2000, log, sizeof log)); + + /* 12. Connection refused → connect. */ + log[0] = 0; + handle = start_get(h, 1, "/x", NULL); /* port 1: nothing listens */ + CHECK(handle > 0); + CHECK(wait_for(h, pnet_http_poll, "\"code\":\"connect\"", 3000, log, sizeof log)); + + /* 13. Policy: an endpoint outside the rules is refused synchronously. */ + { + char meta[256]; + snprintf(meta, sizeof meta, "{\"url\":\"http://127.0.0.2:%u/hello\",\"method\":\"GET\",\"headers\":{}}", p->port); + pthread_mutex_lock(&h->lock); + int rc = pnet_http_start(h->rt, meta, NULL, 0); + CHECK(rc == -1 && strncmp(pnet_http_last_error(h->rt), "permission_denied", 17) == 0); + pthread_mutex_unlock(&h->lock); + } + pthread_mutex_lock(&h->lock); + CHECK(!pnet_runtime_has_live_handles(h->rt)); + size_t heap = pnet_runtime_heap_bytes(h->rt); + pthread_mutex_unlock(&h->lock); + CHECK(heap < 64 * 1024); +} + +/* --- HTTP server tests -------------------------------------------------- */ + +static int connect_client(uint16_t port) { + int fd = socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_in a; + memset(&a, 0, sizeof a); + a.sin_family = AF_INET; + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + a.sin_port = htons(port); + if (connect(fd, (struct sockaddr *)&a, sizeof a) < 0) { + close(fd); + return -1; + } + struct timeval tv = {3, 0}; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + return fd; +} + +/* Read a full response (head + Content-Length or chunked body) from fd. */ +static size_t read_response(int fd, char *buf, size_t cap) { + size_t len = 0; + size_t head_len = 0; + while (len + 1 < cap) { + ssize_t n = recv(fd, buf + len, 1, 0); + if (n <= 0) break; + len += (size_t)n; + buf[len] = 0; + if (head_len == 0 && len >= 4 && strcmp(buf + len - 4, "\r\n\r\n") == 0) { + head_len = len; + const char *cl = strcasestr(buf, "content-length:"); + bool chunked = strcasestr(buf, "transfer-encoding: chunked") != NULL; + if (cl) { + size_t want = (size_t)atoi(cl + 15); + size_t got = 0; + while (got < want && len + 1 < cap) { + ssize_t m = recv(fd, buf + len, want - got < cap - len - 1 ? want - got : cap - len - 1, 0); + if (m <= 0) break; + got += (size_t)m; + len += (size_t)m; + } + buf[len] = 0; + return len; + } + if (chunked) { + /* read until the terminating 0-chunk */ + while (len + 1 < cap) { + ssize_t m = recv(fd, buf + len, 1, 0); + if (m <= 0) break; + len += (size_t)m; + buf[len] = 0; + if (len >= 5 && strcmp(buf + len - 5, "0\r\n\r\n") == 0) return len; + } + return len; + } + return len; + } + } + return len; +} + +static int extract_int(const char *json, const char *key) { + const char *p = strstr(json, key); + if (!p) return -1; + return atoi(p + strlen(key)); +} + +static void test_server(harness *h) { + char log[16384]; + char resp[65536]; + /* Listen on an ephemeral port. */ + pthread_mutex_lock(&h->lock); + int server = pnet_httpd_listen(h->rt, "{\"address\":\"127.0.0.1\",\"port\":0,\"timeouts\":{\"handlerMs\":500,\"keepAliveMs\":300}}"); + if (server < 0) fprintf(stderr, "listen refused: %s\n", pnet_httpd_last_error(h->rt)); + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + CHECK(server > 0); + log[0] = 0; + CHECK(wait_for(h, pnet_httpd_poll, "\"t\":\"listening\"", 2000, log, sizeof log)); + int port = extract_int(log, "\"port\":"); + CHECK(port > 0); + + /* 1. Simple GET answered with respond(end=true); keep-alive second request. */ + int fd = connect_client((uint16_t)port); + CHECK(fd >= 0); + const char *req1 = "GET /hello?x=1 HTTP/1.1\r\nHost: unit\r\nX-A: 1\r\nX-A: 2\r\n\r\n"; + send_all(fd, req1, strlen(req1)); + log[0] = 0; + CHECK(wait_for(h, pnet_httpd_poll, "\"t\":\"request\"", 2000, log, sizeof log)); + CHECK(strstr(log, "\"method\":\"GET\"") != NULL); + CHECK(strstr(log, "\"target\":\"/hello?x=1\"") != NULL); + CHECK(strstr(log, "\"x-a\":\"1, 2\"") != NULL); + CHECK(strstr(log, "\"t\":\"end\"") != NULL); /* no body: end follows in the same tick */ + int req = extract_int(log, "\"req\":"); + CHECK(req > 0); + pthread_mutex_lock(&h->lock); + int rc = pnet_httpd_respond(h->rt, req, "{\"status\":200,\"headers\":{\"content-type\":\"text/plain\",\"connection\":\"evil\"}}", + (const uint8_t *)"hi there", 8); + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + CHECK(rc == 0); + size_t n = read_response(fd, resp, sizeof resp); + CHECK(n > 0); + CHECK(strncmp(resp, "HTTP/1.1 200 OK\r\n", 17) == 0); + CHECK(strstr(resp, "content-type: text/plain\r\n") != NULL); + CHECK(strstr(resp, "Content-Length: 8\r\n") != NULL); + CHECK(strstr(resp, "Connection: keep-alive\r\n") != NULL); + CHECK(strstr(resp, "connection: evil") == NULL); + CHECK(strcmp(resp + n - 8, "hi there") == 0); + /* second request on the same connection */ + const char *req2 = "POST /echo HTTP/1.1\r\nHost: unit\r\nContent-Length: 11\r\n\r\nhello world"; + send_all(fd, req2, strlen(req2)); + log[0] = 0; + CHECK(wait_for(h, pnet_httpd_poll, "\"t\":\"end\"", 2000, log, sizeof log)); + CHECK(strstr(log, "\"method\":\"POST\"") != NULL); + CHECK(strstr(log, "\"length\":11") != NULL); + CHECK(strstr(log, "\"t\":\"readable\"") != NULL); + /* readable must precede end so the guest reads the bytes before EOF */ + CHECK(strstr(log, "\"t\":\"readable\"") < strstr(log, "\"t\":\"end\"")); + req = extract_int(log, "\"req\":"); + char body[64]; + pthread_mutex_lock(&h->lock); + int got = pnet_httpd_read_into(h->rt, req, (uint8_t *)body, sizeof body); + pthread_mutex_unlock(&h->lock); + CHECK(got == 11 && memcmp(body, "hello world", 11) == 0); + /* streamed response: respond(end=false) + write + endBody, chunked */ + pthread_mutex_lock(&h->lock); + rc = pnet_httpd_respond(h->rt, req, "{\"status\":200,\"end\":false}", NULL, 0); + CHECK(rc == 0); + CHECK(pnet_httpd_write(h->rt, req, (const uint8_t *)"abc", 3) == 0); + CHECK(pnet_httpd_write(h->rt, req, (const uint8_t *)"defg", 4) == 0); + CHECK(pnet_httpd_end_body(h->rt, req) == 0); + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + n = read_response(fd, resp, sizeof resp); + CHECK(strstr(resp, "Transfer-Encoding: chunked\r\n") != NULL); + CHECK(strstr(resp, "\r\n\r\n3\r\nabc\r\n4\r\ndefg\r\n0\r\n\r\n") != NULL); + close(fd); + + /* 2. HEAD request: body discarded, Content-Length kept. */ + fd = connect_client((uint16_t)port); + const char *req3 = "HEAD /h HTTP/1.1\r\nHost: unit\r\nConnection: close\r\n\r\n"; + send_all(fd, req3, strlen(req3)); + log[0] = 0; + CHECK(wait_for(h, pnet_httpd_poll, "\"t\":\"request\"", 2000, log, sizeof log)); + req = extract_int(log, "\"req\":"); + pthread_mutex_lock(&h->lock); + rc = pnet_httpd_respond(h->rt, req, "{\"status\":200}", (const uint8_t *)"12345", 5); + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + CHECK(rc == 0); + { + size_t total = 0; + for (;;) { + ssize_t m = recv(fd, resp + total, sizeof resp - 1 - total, 0); + if (m <= 0) break; + total += (size_t)m; + } + resp[total] = 0; + CHECK(strstr(resp, "Content-Length: 5\r\n") != NULL); + CHECK(strstr(resp, "Connection: close\r\n") != NULL); + CHECK(strstr(resp, "\r\n\r\n12345") == NULL); + CHECK(total > 0 && strcmp(resp + total - 4, "\r\n\r\n") == 0); + } + close(fd); + + /* 3. Handler timeout: no respond within handlerMs → 503 + aborted{timeout}. */ + fd = connect_client((uint16_t)port); + send_all(fd, req1, strlen(req1)); + log[0] = 0; + CHECK(wait_for(h, pnet_httpd_poll, "\"code\":\"timeout\"", 3000, log, sizeof log)); + n = read_response(fd, resp, sizeof resp); + CHECK(strncmp(resp, "HTTP/1.1 503", 12) == 0); + close(fd); + + /* 4. Peer disconnect before the response → aborted{closed}; late respond is refused. */ + fd = connect_client((uint16_t)port); + send_all(fd, req1, strlen(req1)); + log[0] = 0; + CHECK(wait_for(h, pnet_httpd_poll, "\"t\":\"request\"", 2000, log, sizeof log)); + req = extract_int(log, "\"req\":"); + close(fd); + log[0] = 0; + CHECK(wait_for(h, pnet_httpd_poll, "\"code\":\"closed\"", 2000, log, sizeof log)); + pthread_mutex_lock(&h->lock); + CHECK(pnet_httpd_respond(h->rt, req, "{\"status\":200}", NULL, 0) == -1); + pthread_mutex_unlock(&h->lock); + + /* 5. Framing violations answered 400 without delivery; oversized target 414. */ + fd = connect_client((uint16_t)port); + const char *bad = "GET / HTTP/1.1\r\nHost: unit\r\nContent-Length: 1\r\nContent-Length: 1\r\n\r\n"; + send_all(fd, bad, strlen(bad)); + n = read_response(fd, resp, sizeof resp); + CHECK(strncmp(resp, "HTTP/1.1 400", 12) == 0); + close(fd); + fd = connect_client((uint16_t)port); + { + char big[4096]; + size_t o = (size_t)snprintf(big, sizeof big, "GET /"); + while (o < 3000) big[o++] = 'a'; + o += (size_t)snprintf(big + o, sizeof big - o, " HTTP/1.1\r\nHost: unit\r\n\r\n"); + send_all(fd, big, o); + } + n = read_response(fd, resp, sizeof resp); + CHECK(strncmp(resp, "HTTP/1.1 414", 12) == 0); + close(fd); + /* Missing Host */ + fd = connect_client((uint16_t)port); + const char *nohost = "GET / HTTP/1.1\r\n\r\n"; + send_all(fd, nohost, strlen(nohost)); + n = read_response(fd, resp, sizeof resp); + CHECK(strncmp(resp, "HTTP/1.1 400", 12) == 0); + close(fd); + + /* 6. Expect: 100-continue gets an interim response; chunked request body. */ + fd = connect_client((uint16_t)port); + const char *expect = "POST /up HTTP/1.1\r\nHost: unit\r\nExpect: 100-continue\r\nTransfer-Encoding: chunked\r\n\r\n"; + send_all(fd, expect, strlen(expect)); + { + char interim[64]; + ssize_t m = recv(fd, interim, sizeof interim - 1, 0); + CHECK(m > 0); + if (m > 0) { + interim[m] = 0; + CHECK(strncmp(interim, "HTTP/1.1 100 Continue\r\n\r\n", 25) == 0); + } + } + send_all(fd, "4\r\nWiki\r\n5\r\npedia\r\n0\r\n\r\n", 24); + log[0] = 0; + CHECK(wait_for(h, pnet_httpd_poll, "\"t\":\"end\"", 2000, log, sizeof log)); + req = extract_int(log, "\"req\":"); + pthread_mutex_lock(&h->lock); + got = pnet_httpd_read_into(h->rt, req, (uint8_t *)body, sizeof body); + CHECK(got == 9 && memcmp(body, "Wikipedia", 9) == 0); + rc = pnet_httpd_respond(h->rt, req, "{\"status\":204}", NULL, 0); + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + CHECK(rc == 0); + n = read_response(fd, resp, sizeof resp); + CHECK(strncmp(resp, "HTTP/1.1 204 No Content\r\n", 25) == 0); + close(fd); + + /* 7. abort(req) closes the connection and reports aborted{cancelled}. */ + fd = connect_client((uint16_t)port); + send_all(fd, req1, strlen(req1)); + log[0] = 0; + CHECK(wait_for(h, pnet_httpd_poll, "\"t\":\"request\"", 2000, log, sizeof log)); + req = extract_int(log, "\"req\":"); + pthread_mutex_lock(&h->lock); + pnet_httpd_abort(h->rt, req); + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + log[0] = 0; + CHECK(wait_for(h, pnet_httpd_poll, "\"code\":\"cancelled\"", 2000, log, sizeof log)); + { + ssize_t m = recv(fd, resp, sizeof resp, 0); + CHECK(m == 0); /* EOF: closed without a response */ + } + close(fd); + + /* 8. stop(graceful) with an inflight request: closes after the response. */ + fd = connect_client((uint16_t)port); + send_all(fd, req1, strlen(req1)); + log[0] = 0; + CHECK(wait_for(h, pnet_httpd_poll, "\"t\":\"request\"", 2000, log, sizeof log)); + req = extract_int(log, "\"req\":"); + pthread_mutex_lock(&h->lock); + CHECK(pnet_httpd_stop(h->rt, server, true, 2000) == 0); + CHECK(pnet_httpd_stop(h->rt, server, true, 2000) == -1); + rc = pnet_httpd_respond(h->rt, req, "{\"status\":200}", (const uint8_t *)"bye", 3); + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + CHECK(rc == 0); + n = read_response(fd, resp, sizeof resp); + CHECK(strstr(resp, "Connection: close\r\n") != NULL); + CHECK(strcmp(resp + n - 3, "bye") == 0); + close(fd); + log[0] = 0; + CHECK(wait_for(h, pnet_httpd_poll, "\"t\":\"closed\"", 3000, log, sizeof log)); + CHECK(connect_client((uint16_t)port) < 0); + pthread_mutex_lock(&h->lock); + CHECK(!pnet_runtime_has_live_handles(h->rt)); + pthread_mutex_unlock(&h->lock); +} + + +/* --- scripted WebSocket peer ------------------------------------------- */ + +typedef struct ws_peer { + int listen_fd; + uint16_t port; + pthread_t thread; + volatile int stop; + volatile int pongs_seen; + volatile int closes_seen; + volatile int last_close_code; +} ws_peer; + +static void ws_peer_send_frame(int fd, uint8_t opcode, bool fin, const uint8_t *payload, size_t len) { + uint8_t head[10]; + size_t hl = 0; + head[hl++] = (uint8_t)((fin ? 0x80 : 0) | opcode); + if (len < 126) head[hl++] = (uint8_t)len; + else if (len <= 0xffff) { + head[hl++] = 126; + head[hl++] = (uint8_t)(len >> 8); + head[hl++] = (uint8_t)len; + } else { + head[hl++] = 127; + for (int i = 7; i >= 0; i--) head[hl++] = (uint8_t)((uint64_t)len >> (8 * i)); + } + send_all(fd, (const char *)head, hl); + if (len) send_all(fd, (const char *)payload, len); +} + +static bool recv_exact(int fd, uint8_t *buf, size_t len) { + size_t got = 0; + while (got < len) { + ssize_t n = recv(fd, buf + got, len - got, 0); + if (n <= 0) return false; + got += (size_t)n; + } + return true; +} + +/* Read one masked client frame; returns opcode or -1. */ +static int ws_peer_recv_frame(int fd, uint8_t *payload, size_t cap, size_t *out_len, bool *fin) { + uint8_t h[2]; + if (!recv_exact(fd, h, 2)) return -1; + int opcode = h[0] & 0x0f; + *fin = (h[0] & 0x80) != 0; + if (!(h[1] & 0x80)) return -1; /* client frames must be masked */ + uint64_t len = h[1] & 0x7f; + if (len == 126) { + uint8_t e[2]; + if (!recv_exact(fd, e, 2)) return -1; + len = ((uint64_t)e[0] << 8) | e[1]; + } else if (len == 127) { + uint8_t e[8]; + if (!recv_exact(fd, e, 8)) return -1; + len = 0; + for (int i = 0; i < 8; i++) len = (len << 8) | e[i]; + } + uint8_t mask[4]; + if (!recv_exact(fd, mask, 4)) return -1; + if (len > cap) return -1; + if (!recv_exact(fd, payload, (size_t)len)) return -1; + for (size_t i = 0; i < len; i++) payload[i] ^= mask[i & 3]; + *out_len = (size_t)len; + return opcode; +} + +static void *ws_peer_thread(void *arg) { + ws_peer *p = arg; + while (!p->stop) { + struct sockaddr_in a; + socklen_t al = sizeof a; + int fd = accept(p->listen_fd, (struct sockaddr *)&a, &al); + if (fd < 0) { + if (p->stop) break; + continue; + } + char head[4096]; + if (read_head(fd, head, sizeof head) < 0) { + close(fd); + continue; + } + char target[256] = {0}; + sscanf(head, "%*s %255s", target); + const char *keyh = strcasestr(head, "sec-websocket-key:"); + char key[64] = {0}; + if (keyh) sscanf(keyh + 18, " %63s", key); + if (strcmp(target, "/deny") == 0) { + const char *r = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n"; + send_all(fd, r, strlen(r)); + close(fd); + continue; + } + char concat[128]; + snprintf(concat, sizeof concat, "%s258EAFA5-E914-47DA-95CA-C5AB0DC85B11", key); + uint8_t digest[20]; + pnet_sha1((const uint8_t *)concat, strlen(concat), digest); + char accept_key[32]; + pnet_base64_encode(digest, 20, accept_key, sizeof accept_key); + char resp[512]; + const char *proto_line = strcasestr(head, "sec-websocket-protocol:") ? "Sec-WebSocket-Protocol: chat.v1\r\n" : ""; + if (strcmp(target, "/badaccept") == 0) accept_key[0] = accept_key[0] == 'A' ? 'B' : 'A'; + int n = snprintf(resp, sizeof resp, + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + "Sec-WebSocket-Accept: %s\r\n%s\r\n", accept_key, proto_line); + send_all(fd, resp, (size_t)n); + if (strcmp(target, "/fragments") == 0) { + /* server → client: a fragmented text message with a ping in between */ + ws_peer_send_frame(fd, 1, false, (const uint8_t *)"Hel", 3); + ws_peer_send_frame(fd, 9, true, (const uint8_t *)"pp", 2); + ws_peer_send_frame(fd, 0, false, (const uint8_t *)"lo ", 3); + ws_peer_send_frame(fd, 0, true, (const uint8_t *)"\xE4\xB8\x96\xE7\x95\x8C", 6); + /* expect the pong echo */ + uint8_t buf[256]; + size_t len; + bool fin; + int op = ws_peer_recv_frame(fd, buf, sizeof buf, &len, &fin); + if (op == 10 && len == 2 && memcmp(buf, "pp", 2) == 0) p->pongs_seen++; + /* then a server-initiated close */ + uint8_t cl[16] = {0x03, 0xE9, 'b', 'y', 'e'}; + ws_peer_send_frame(fd, 8, true, cl, 5); + op = ws_peer_recv_frame(fd, buf, sizeof buf, &len, &fin); + if (op == 8) p->closes_seen++; + close(fd); + continue; + } + if (strcmp(target, "/oversized") == 0) { + uint8_t big[300]; + memset(big, 'x', sizeof big); + ws_peer_send_frame(fd, 2, true, big, sizeof big); + uint8_t buf[256]; + size_t len; + bool fin; + int op = ws_peer_recv_frame(fd, buf, sizeof buf, &len, &fin); + if (op == 8 && len >= 2) { + p->last_close_code = (buf[0] << 8) | buf[1]; + ws_peer_send_frame(fd, 8, true, buf, 2); + } + close(fd); + continue; + } + if (strcmp(target, "/badutf8") == 0) { + ws_peer_send_frame(fd, 1, true, (const uint8_t *)"\xff\xfe", 2); + uint8_t buf[256]; + size_t len; + bool fin; + int op = ws_peer_recv_frame(fd, buf, sizeof buf, &len, &fin); + if (op == 8 && len >= 2) { + p->last_close_code = (buf[0] << 8) | buf[1]; + ws_peer_send_frame(fd, 8, true, buf, 2); + } + close(fd); + continue; + } + if (strcmp(target, "/masked") == 0) { + /* server frame with the mask bit set: protocol error 1002 */ + uint8_t bad[8] = {0x81, 0x81, 1, 2, 3, 4, 'x' ^ 1}; + send_all(fd, (const char *)bad, 7); + uint8_t buf[256]; + size_t len; + bool fin; + int op = ws_peer_recv_frame(fd, buf, sizeof buf, &len, &fin); + if (op == 8 && len >= 2) p->last_close_code = (buf[0] << 8) | buf[1]; + close(fd); + continue; + } + if (strcmp(target, "/drop") == 0) { + usleep(50000); + close(fd); + continue; + } + /* echo server: echo data frames, answer pings, mirror close */ + for (;;) { + uint8_t buf[70000]; + size_t len; + bool fin; + int op = ws_peer_recv_frame(fd, buf, sizeof buf, &len, &fin); + if (op < 0) break; + if (op == 1 || op == 2) ws_peer_send_frame(fd, (uint8_t)op, true, buf, len); + else if (op == 9) ws_peer_send_frame(fd, 10, true, buf, len); + else if (op == 10) p->pongs_seen++; + else if (op == 8) { + p->closes_seen++; + if (len >= 2) p->last_close_code = (buf[0] << 8) | buf[1]; + ws_peer_send_frame(fd, 8, true, buf, len); + break; + } + } + usleep(10000); + close(fd); + } + return NULL; +} + +static bool ws_peer_start(ws_peer *p) { + memset(p, 0, sizeof *p); + p->listen_fd = socket(AF_INET, SOCK_STREAM, 0); + int one = 1; + setsockopt(p->listen_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + struct sockaddr_in a; + memset(&a, 0, sizeof a); + a.sin_family = AF_INET; + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (bind(p->listen_fd, (struct sockaddr *)&a, sizeof a) < 0 || listen(p->listen_fd, 8) < 0) return false; + socklen_t al = sizeof a; + getsockname(p->listen_fd, (struct sockaddr *)&a, &al); + p->port = ntohs(a.sin_port); + pthread_create(&p->thread, NULL, ws_peer_thread, p); + return true; +} + +static void ws_peer_stop(ws_peer *p) { + p->stop = 1; + shutdown(p->listen_fd, SHUT_RDWR); + close(p->listen_fd); + pthread_join(p->thread, NULL); +} + +static int ws_connect(harness *h, uint16_t port, const char *path, const char *extra) { + char meta[512]; + snprintf(meta, sizeof meta, "{\"url\":\"ws://127.0.0.1:%u%s\"%s}", port, path, extra ? extra : ""); + pthread_mutex_lock(&h->lock); + int handle = pnet_ws_connect(h->rt, meta); + if (handle < 0) fprintf(stderr, "ws connect refused: %s\n", pnet_ws_last_error(h->rt)); + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + return handle; +} + +static void test_websocket(harness *h, ws_peer *p) { + char log[16384]; + + /* 1. Handshake with subprotocol; text and binary echo. */ + log[0] = 0; + int handle = ws_connect(h, p->port, "/echo", ",\"protocols\":[\"chat.v1\",\"other\"],\"headers\":{\"origin\":\"pocket\"}"); + CHECK(handle > 0); + CHECK(wait_for(h, pnet_ws_poll, "\"t\":\"open\"", 3000, log, sizeof log)); + CHECK(strstr(log, "\"protocol\":\"chat.v1\"") != NULL); + pthread_mutex_lock(&h->lock); + CHECK(pnet_ws_send(h->rt, handle, 1, (const uint8_t *)"h\xC3\xA9llo", 6) == 0); + CHECK(pnet_ws_send(h->rt, handle, 2, (const uint8_t *)"\x01\x02\x03", 3) == 0); + CHECK(pnet_ws_send(h->rt, handle, 1, (const uint8_t *)"\xff", 1) == PWS_SEND_INVALID); /* invalid UTF-8 text */ + CHECK(pnet_ws_send(h->rt, handle, 9, (const uint8_t *)"ping!", 5) == 0); + CHECK(pnet_ws_send(h->rt, handle, 9, (const uint8_t *)log, 126) == PWS_SEND_INVALID); + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + log[0] = 0; + CHECK(wait_for(h, pnet_ws_poll, "\"t\":\"pong\"", 3000, log, sizeof log)); + CHECK(strstr(log, "\"kind\":\"text\",\"text\":\"h\xC3\xA9llo\"") != NULL); + CHECK(strstr(log, "\"kind\":\"binary\",\"bytes\":3") != NULL); + CHECK(strstr(log, "\"payload\":{\"$b\":\"cGluZyE=\"}") != NULL); + { + uint8_t buf[8]; + pthread_mutex_lock(&h->lock); + CHECK(pnet_ws_receive_into(h->rt, handle, buf, 2) == -1); /* too small: nothing dequeued */ + int got = pnet_ws_receive_into(h->rt, handle, buf, sizeof buf); + CHECK(got == 3 && buf[0] == 1 && buf[2] == 3); + CHECK(pnet_ws_receive_into(h->rt, handle, buf, sizeof buf) == -1); + CHECK(pnet_ws_buffered_amount(h->rt, handle) >= 0); + pthread_mutex_unlock(&h->lock); + } + /* client-initiated close: clean, local, code echoed */ + pthread_mutex_lock(&h->lock); + CHECK(pnet_ws_close(h->rt, handle, 4001, "done", 4) == 0); + CHECK(pnet_ws_close(h->rt, handle, 1000, NULL, 0) == -1); + CHECK(pnet_ws_send(h->rt, handle, 1, (const uint8_t *)"x", 1) == PWS_SEND_CLOSED); + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + log[0] = 0; + CHECK(wait_for(h, pnet_ws_poll, "\"t\":\"close\"", 3000, log, sizeof log)); + CHECK(strstr(log, "\"code\":4001,\"reason\":\"done\",\"clean\":true,\"local\":true") != NULL); + CHECK(p->last_close_code == 4001); + + /* 2. Fragmented server message + interleaved ping (auto pong) + server close. */ + log[0] = 0; + handle = ws_connect(h, p->port, "/fragments", NULL); + CHECK(wait_for(h, pnet_ws_poll, "\"t\":\"close\"", 3000, log, sizeof log)); + CHECK(strstr(log, "\"text\":\"Hello \xE4\xB8\x96\xE7\x95\x8C\"") != NULL); + CHECK(strstr(log, "\"t\":\"ping\"") != NULL); + CHECK(strstr(log, "\"code\":1001,\"reason\":\"bye\",\"clean\":true,\"local\":false") != NULL); + usleep(50000); + CHECK(p->pongs_seen >= 1); + + /* 3. Oversized message → error message_too_large + close 1009. */ + log[0] = 0; + handle = ws_connect(h, p->port, "/oversized", ",\"limits\":{\"maxMessageBytes\":100}"); + CHECK(wait_for(h, pnet_ws_poll, "\"t\":\"close\"", 3000, log, sizeof log)); + CHECK(strstr(log, "\"code\":\"message_too_large\"") != NULL); + CHECK(strstr(log, "\"code\":1009") != NULL); + CHECK(p->last_close_code == 1009); + + /* 4. Invalid UTF-8 → 1007; masked server frame → 1002. */ + log[0] = 0; + handle = ws_connect(h, p->port, "/badutf8", NULL); + CHECK(wait_for(h, pnet_ws_poll, "\"t\":\"close\"", 3000, log, sizeof log)); + CHECK(strstr(log, "\"code\":\"websocket_protocol_error\"") != NULL && strstr(log, "\"code\":1007") != NULL); + log[0] = 0; + handle = ws_connect(h, p->port, "/masked", NULL); + CHECK(wait_for(h, pnet_ws_poll, "\"t\":\"close\"", 3000, log, sizeof log)); + CHECK(strstr(log, "\"code\":1002") != NULL); + + /* 5. Transport loss → error closed + close 1006. */ + log[0] = 0; + handle = ws_connect(h, p->port, "/drop", NULL); + CHECK(wait_for(h, pnet_ws_poll, "\"t\":\"close\"", 3000, log, sizeof log)); + CHECK(strstr(log, "\"code\":\"closed\"") != NULL && strstr(log, "\"code\":1006") != NULL); + + /* 6. Handshake failures: 403 and a bad accept key. */ + log[0] = 0; + handle = ws_connect(h, p->port, "/deny", NULL); + CHECK(wait_for(h, pnet_ws_poll, "\"code\":\"websocket_handshake_failed\"", 3000, log, sizeof log)); + CHECK(strstr(log, "\"status\":403") != NULL); + log[0] = 0; + handle = ws_connect(h, p->port, "/badaccept", NULL); + CHECK(wait_for(h, pnet_ws_poll, "\"code\":\"websocket_handshake_failed\"", 3000, log, sizeof log)); + + /* 7. Terminate: close 1006 local without a Close frame. */ + log[0] = 0; + handle = ws_connect(h, p->port, "/echo", NULL); + CHECK(wait_for(h, pnet_ws_poll, "\"t\":\"open\"", 3000, log, sizeof log)); + pthread_mutex_lock(&h->lock); + pnet_ws_terminate(h->rt, handle); + pthread_mutex_unlock(&h->lock); + log[0] = 0; + CHECK(wait_for(h, pnet_ws_poll, "\"t\":\"close\"", 3000, log, sizeof log)); + CHECK(strstr(log, "\"code\":1006,\"reason\":\"\",\"clean\":false,\"local\":true") != NULL); + + /* 8. Backpressure and drain with a tiny send queue. */ + log[0] = 0; + handle = ws_connect(h, p->port, "/echo", ",\"limits\":{\"sendQueueBytes\":64}"); + CHECK(wait_for(h, pnet_ws_poll, "\"t\":\"open\"", 3000, log, sizeof log)); + { + uint8_t payload[40]; + memset(payload, 'z', sizeof payload); + pthread_mutex_lock(&h->lock); + int rc1 = pnet_ws_send(h->rt, handle, 2, payload, sizeof payload); + int rc2 = pnet_ws_send(h->rt, handle, 2, payload, sizeof payload); + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + CHECK(rc1 == 0 || rc1 == 1); + CHECK(rc2 == PWS_SEND_BACKPRESSURE); + log[0] = 0; + CHECK(wait_for(h, pnet_ws_poll, "\"t\":\"drain\"", 3000, log, sizeof log)); + } + pthread_mutex_lock(&h->lock); + pnet_ws_close(h->rt, handle, 1000, NULL, 0); + pthread_mutex_unlock(&h->lock); + log[0] = 0; + CHECK(wait_for(h, pnet_ws_poll, "\"t\":\"close\"", 3000, log, sizeof log)); + + /* 9. Refusals. */ + pthread_mutex_lock(&h->lock); + CHECK(pnet_ws_connect(h->rt, "{\"url\":\"wss://127.0.0.1/\"}") == -1); + CHECK(strncmp(pnet_ws_last_error(h->rt), "unsupported", 11) == 0); + CHECK(pnet_ws_connect(h->rt, "{\"url\":\"ws://127.0.0.1/\",\"headers\":{\"Host\":\"x\"}}") == -1); + CHECK(pnet_ws_connect(h->rt, "{\"url\":\"ws://127.0.0.1/\",\"protocols\":[\"a\",\"a\"]}") == -1); + CHECK(pnet_ws_connect(h->rt, "{\"url\":\"ws://127.0.0.2/\"}") == -1); + CHECK(strncmp(pnet_ws_last_error(h->rt), "permission_denied", 17) == 0); + CHECK(!pnet_runtime_has_live_handles(h->rt)); + pthread_mutex_unlock(&h->lock); +} + +int main(void) { + srand(1234); + peer p; + CHECK(peer_start(&p)); + ws_peer wp; + CHECK(ws_peer_start(&wp)); + char policy[512]; + snprintf(policy, sizeof policy, + "{\"connect\":[{\"protocol\":\"http\",\"host\":\"127.0.0.1\",\"port\":{\"min\":1,\"max\":65535}}," + "{\"protocol\":\"ws\",\"host\":\"127.0.0.1\",\"port\":{\"min\":1,\"max\":65535}}]," + "\"listen\":[{\"protocol\":\"http\",\"address\":\"127.0.0.1\",\"port\":\"ephemeral\"}]," + "\"insecureTransport\":true,\"localNetwork\":true}"); + harness h; + harness_start(&h, policy); + CHECK(h.rt != NULL); + if (h.rt) { + test_client(&h, &p); + test_server(&h); + test_websocket(&h, &wp); + } + harness_stop(&h); + peer_stop(&p); + ws_peer_stop(&wp); + printf("host: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/engine/net/test/tls_test.c b/engine/net/test/tls_test.c new file mode 100644 index 00000000..6dae7d75 --- /dev/null +++ b/engine/net/test/tls_test.c @@ -0,0 +1,495 @@ +/* TLS conformance harness for the HTTP client and WebSocket client cores. + * + * An in-process OpenSSL PKI (one CA, several leaf certs) and OpenSSL server + * threads stand in for an independent TLS peer. The core runs with the + * OpenSSL TlsProvider trusting only the test CA, so every case exercises the + * real handshake: a valid chain, an unknown CA, an expired cert, a hostname + * mismatch, an untrusted wall clock (fail-closed before I/O), no plaintext + * fallback, and a working WSS echo. */ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "pnet_openssl_tls.h" +#include "pnet_posix_driver.h" +#include "pocketjs/net/runtime.h" + +static int failures = 0; +static int checks = 0; +#define CHECK(cond) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + } \ + } while (0) + +static uint64_t now_ms(void *ctx) { + (void)ctx; + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000 + (uint64_t)ts.tv_nsec / 1000000; +} +static void *plat_alloc(void *ctx, size_t size) { (void)ctx; return malloc(size); } +static void plat_free(void *ctx, void *ptr, size_t size) { (void)ctx; (void)size; free(ptr); } +static void plat_random(void *ctx, uint8_t *out, size_t len) { (void)ctx; for (size_t i = 0; i < len; i++) out[i] = (uint8_t)rand(); } +static void plat_log(void *ctx, pnet_log_level level, const char *msg) { (void)ctx; if (level <= PNET_LOG_WARN) fprintf(stderr, "[pnet] %s\n", msg); } + +static bool g_clock_trusted = true; +static bool wall_clock_trusted(void *ctx) { (void)ctx; return g_clock_trusted; } + +/* --- PKI ---------------------------------------------------------------- */ + +typedef struct pki { + EVP_PKEY *ca_key; + X509 *ca_cert; + char *ca_pem; +} pki; + +static EVP_PKEY *gen_key(void) { + return EVP_RSA_gen(2048); +} + +static void add_ext(X509 *cert, X509 *issuer, int nid, const char *value) { + X509V3_CTX ctx; + X509V3_set_ctx(&ctx, issuer, cert, NULL, NULL, 0); + X509_EXTENSION *ext = X509V3_EXT_conf_nid(NULL, &ctx, nid, value); + if (ext) { + X509_add_ext(cert, ext, -1); + X509_EXTENSION_free(ext); + } +} + +static X509 *make_cert(EVP_PKEY *key, EVP_PKEY *issuer_key, X509 *issuer, const char *cn, const char *san, + long not_before_days, long not_after_days, bool is_ca) { + X509 *cert = X509_new(); + X509_set_version(cert, 2); + ASN1_INTEGER_set(X509_get_serialNumber(cert), rand()); + X509_gmtime_adj(X509_get_notBefore(cert), not_before_days * 86400); + X509_gmtime_adj(X509_get_notAfter(cert), not_after_days * 86400); + X509_set_pubkey(cert, key); + X509_NAME *name = X509_get_subject_name(cert); + X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC, (const unsigned char *)cn, -1, -1, 0); + X509_set_issuer_name(cert, issuer ? X509_get_subject_name(issuer) : name); + if (is_ca) add_ext(cert, issuer ? issuer : cert, NID_basic_constraints, "critical,CA:TRUE"); + else { + add_ext(cert, issuer, NID_basic_constraints, "CA:FALSE"); + if (san) add_ext(cert, issuer, NID_subject_alt_name, san); + } + X509_sign(cert, issuer_key, EVP_sha256()); + return cert; +} + +static char *cert_pem(X509 *cert) { + BIO *bio = BIO_new(BIO_s_mem()); + PEM_write_bio_X509(bio, cert); + char *data; + long n = BIO_get_mem_data(bio, &data); + char *out = malloc((size_t)n + 1); + memcpy(out, data, (size_t)n); + out[n] = 0; + BIO_free(bio); + return out; +} + +static pki make_pki(void) { + pki p; + p.ca_key = gen_key(); + static int ca_seq = 0; + char cn[64]; + snprintf(cn, sizeof cn, "PocketJS Test CA %d", ca_seq++); + p.ca_cert = make_cert(p.ca_key, p.ca_key, NULL, cn, NULL, -1, 3650, true); + p.ca_pem = cert_pem(p.ca_cert); + return p; +} + +/* --- HTTPS/WSS peer ----------------------------------------------------- */ + +typedef enum peer_kind { PEER_HTTP, PEER_WS } peer_kind; + +typedef struct tls_peer { + int listen_fd; + uint16_t port; + SSL_CTX *ctx; + peer_kind kind; + pthread_t thread; + volatile int stop; +} tls_peer; + +static SSL_CTX *server_ctx(EVP_PKEY *key, X509 *cert, X509 *ca) { + SSL_CTX *ctx = SSL_CTX_new(TLS_server_method()); + SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION); + SSL_CTX_use_certificate(ctx, cert); + if (ca) SSL_CTX_add_extra_chain_cert(ctx, X509_dup(ca)); + SSL_CTX_use_PrivateKey(ctx, key); + return ctx; +} + +static void ws_accept_key(const char *key, char *out) { + char concat[128]; + snprintf(concat, sizeof concat, "%s258EAFA5-E914-47DA-95CA-C5AB0DC85B11", key); + unsigned char digest[SHA_DIGEST_LENGTH]; + SHA1((const unsigned char *)concat, strlen(concat), digest); + EVP_EncodeBlock((unsigned char *)out, digest, SHA_DIGEST_LENGTH); +} + +static void *peer_thread(void *arg) { + tls_peer *p = arg; + while (!p->stop) { + struct sockaddr_in a; + socklen_t al = sizeof a; + int fd = accept(p->listen_fd, (struct sockaddr *)&a, &al); + if (fd < 0) { + if (p->stop) break; + continue; + } + SSL *ssl = SSL_new(p->ctx); + SSL_set_fd(ssl, fd); + if (SSL_accept(ssl) != 1) { + SSL_free(ssl); + close(fd); + continue; + } + char head[2048] = {0}; + size_t len = 0; + while (len + 1 < sizeof head) { + int n = SSL_read(ssl, head + len, 1); + if (n <= 0) break; + len += (size_t)n; + if (len >= 4 && memcmp(head + len - 4, "\r\n\r\n", 4) == 0) break; + } + if (p->kind == PEER_HTTP) { + const char *r = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 9\r\n\r\nsecure ok"; + SSL_write(ssl, r, (int)strlen(r)); + } else { + const char *keyh = strcasestr(head, "sec-websocket-key:"); + char key[64] = {0}; + if (keyh) sscanf(keyh + 18, " %63s", key); + char accept_key[64]; + ws_accept_key(key, accept_key); + char resp[256]; + int n = snprintf(resp, sizeof resp, + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: %s\r\n\r\n", + accept_key); + SSL_write(ssl, resp, n); + /* Echo one masked client frame back unmasked. */ + unsigned char h[2]; + if (SSL_read(ssl, h, 2) == 2 && (h[1] & 0x80)) { + size_t plen = h[1] & 0x7f; + unsigned char mask[4], payload[256]; + SSL_read(ssl, mask, 4); + if (plen <= sizeof payload && SSL_read(ssl, payload, (int)plen) == (int)plen) { + for (size_t i = 0; i < plen; i++) payload[i] ^= mask[i & 3]; + unsigned char out[260]; + out[0] = 0x81; + out[1] = (unsigned char)plen; + memcpy(out + 2, payload, plen); + SSL_write(ssl, out, (int)(plen + 2)); + } + } + usleep(50000); + } + SSL_shutdown(ssl); + SSL_free(ssl); + close(fd); + } + return NULL; +} + +static tls_peer *peer_start(SSL_CTX *ctx, peer_kind kind) { + tls_peer *p = calloc(1, sizeof *p); + p->ctx = ctx; + p->kind = kind; + p->listen_fd = socket(AF_INET, SOCK_STREAM, 0); + int one = 1; + setsockopt(p->listen_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + struct sockaddr_in a = {.sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK)}; + bind(p->listen_fd, (struct sockaddr *)&a, sizeof a); + listen(p->listen_fd, 8); + socklen_t al = sizeof a; + getsockname(p->listen_fd, (struct sockaddr *)&a, &al); + p->port = ntohs(a.sin_port); + pthread_create(&p->thread, NULL, peer_thread, p); + return p; +} + +static void peer_stop(tls_peer *p) { + p->stop = 1; + shutdown(p->listen_fd, SHUT_RDWR); + close(p->listen_fd); + pthread_join(p->thread, NULL); + SSL_CTX_free(p->ctx); + free(p); +} + +/* --- runtime harness ---------------------------------------------------- */ + +typedef struct harness { + pnet_runtime *rt; + pnet_posix_driver *driver; + pnet_openssl_tls *tls; + pthread_mutex_t lock; + pthread_t thread; + volatile int stop; +} harness; + +static void *net_thread(void *arg) { + harness *h = arg; + while (!h->stop) { + pthread_mutex_lock(&h->lock); + pnet_posix_driver_dispatch(h->driver, h->rt); + pnet_runtime_service(h->rt); + uint64_t deadline = pnet_runtime_next_deadline_ms(h->rt); + bool more = pnet_runtime_has_pending_output(h->rt); + pthread_mutex_unlock(&h->lock); + int timeout = 50; + if (deadline) { + uint64_t now = now_ms(NULL); + timeout = deadline > now ? (int)(deadline - now) : 0; + if (timeout > 50) timeout = 50; + } + if (more) timeout = 0; + pnet_posix_driver_wait(h->driver, timeout); + } + return NULL; +} + +static void harness_start(harness *h, const char *policy, const char *ca_pem) { + memset(h, 0, sizeof *h); + pnet_platform plat = {NULL, now_ms, plat_alloc, plat_free, plat_random, plat_log, wall_clock_trusted}; + pnet_runtime_config cfg; + pnet_runtime_config_defaults(&cfg); + cfg.io_chunk_bytes = 1024; + h->driver = pnet_posix_driver_create(32); + pnet_openssl_tls_config tcfg = {.ca_pem = ca_pem, .min_version = 0}; + h->tls = pnet_openssl_tls_create(pnet_posix_driver_ops(), h->driver, &tcfg); + h->rt = pnet_runtime_create_tls(&plat, pnet_posix_driver_ops(), h->driver, pnet_openssl_tls_ops(), + pnet_openssl_tls_ctx(h->tls), &cfg, policy); + pthread_mutex_init(&h->lock, NULL); + pthread_create(&h->thread, NULL, net_thread, h); +} + +static void harness_stop(harness *h) { + h->stop = 1; + pnet_posix_driver_wake(h->driver); + pthread_join(h->thread, NULL); + pnet_runtime_destroy(h->rt); + pnet_openssl_tls_destroy(h->tls); + pnet_posix_driver_destroy(h->driver); + pthread_mutex_destroy(&h->lock); +} + +typedef const char *(*poll_fn)(pnet_runtime *, size_t *); + +static char *tick(harness *h, poll_fn poll) { + pthread_mutex_lock(&h->lock); + pnet_runtime_begin_tick(h->rt); + size_t len = 0; + const char *batch = poll(h->rt, &len); + char *copy = batch ? strdup(batch) : NULL; + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + return copy; +} + +static bool wait_for(harness *h, poll_fn poll, const char *needle, int timeout_ms, char *log, size_t cap) { + uint64_t end = now_ms(NULL) + (uint64_t)timeout_ms; + size_t used = strlen(log); + while (now_ms(NULL) < end) { + char *batch = tick(h, poll); + if (batch) { + size_t n = strlen(batch); + if (used + n + 2 < cap) { + memcpy(log + used, batch, n); + used += n; + log[used++] = '\n'; + log[used] = 0; + } + bool hit = strstr(batch, needle) != NULL; + free(batch); + if (hit) return true; + } + usleep(3000); + } + return false; +} + +static int https_get(harness *h, uint16_t port, const char *extra) { + char meta[512]; + snprintf(meta, sizeof meta, "{\"url\":\"https://127.0.0.1:%u/x\",\"method\":\"GET\",\"headers\":{}%s}", port, + extra ? extra : ""); + pthread_mutex_lock(&h->lock); + int handle = pnet_http_start(h->rt, meta, NULL, 0); + if (handle < 0) fprintf(stderr, "https start refused: %s\n", pnet_http_last_error(h->rt)); + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + return handle; +} + +int main(void) { + srand(4321); + SSL_library_init(); + pki p = make_pki(); + + EVP_PKEY *leaf_key = gen_key(); + X509 *valid = make_cert(leaf_key, p.ca_key, p.ca_cert, "127.0.0.1", "IP:127.0.0.1,DNS:localhost", -1, 365, false); + X509 *expired = make_cert(leaf_key, p.ca_key, p.ca_cert, "127.0.0.1", "IP:127.0.0.1", -10, -1, false); + X509 *wronghost = make_cert(leaf_key, p.ca_key, p.ca_cert, "other.test", "DNS:other.test", -1, 365, false); + /* A leaf signed by a CA the provider does not trust. */ + pki rogue = make_pki(); + X509 *unknown = make_cert(leaf_key, rogue.ca_key, rogue.ca_cert, "127.0.0.1", "IP:127.0.0.1", -1, 365, false); + + tls_peer *valid_peer = peer_start(server_ctx(leaf_key, valid, p.ca_cert), PEER_HTTP); + tls_peer *expired_peer = peer_start(server_ctx(leaf_key, expired, p.ca_cert), PEER_HTTP); + tls_peer *wrong_peer = peer_start(server_ctx(leaf_key, wronghost, p.ca_cert), PEER_HTTP); + tls_peer *unknown_peer = peer_start(server_ctx(leaf_key, unknown, rogue.ca_cert), PEER_HTTP); + tls_peer *wss_peer = peer_start(server_ctx(leaf_key, valid, p.ca_cert), PEER_WS); + + char policy[512]; + snprintf(policy, sizeof policy, + "{\"connect\":[{\"protocol\":\"https\",\"host\":\"127.0.0.1\",\"port\":{\"min\":1,\"max\":65535}}," + "{\"protocol\":\"wss\",\"host\":\"127.0.0.1\",\"port\":{\"min\":1,\"max\":65535}}," + "{\"protocol\":\"http\",\"host\":\"127.0.0.1\",\"port\":{\"min\":1,\"max\":65535}}]," + "\"insecureTransport\":true,\"localNetwork\":true}"); + harness h; + harness_start(&h, policy, p.ca_pem); + CHECK(h.rt != NULL); + char log[8192]; + + /* limits advertise tls now */ + pthread_mutex_lock(&h.lock); + CHECK(strstr(pnet_http_limits(h.rt), "\"features\":[\"tls\"]") != NULL); + pthread_mutex_unlock(&h.lock); + + /* 1. valid chain + hostname (IP-ID) → 200 over TLS. */ + log[0] = 0; + int handle = https_get(&h, valid_peer->port, NULL); + CHECK(handle > 0); + CHECK(wait_for(&h, pnet_http_poll, "\"status\":200", 4000, log, sizeof log)); + { + char body[64]; + size_t total = 0; + uint64_t end = now_ms(NULL) + 2000; + bool done = false; + while (now_ms(NULL) < end && !done) { + char *b = tick(&h, pnet_http_poll); + if (b) { if (strstr(b, "\"t\":\"end\"")) done = true; free(b); } + pthread_mutex_lock(&h.lock); + int n = pnet_http_read_into(h.rt, handle, (uint8_t *)body + total, sizeof body - total); + pthread_mutex_unlock(&h.lock); + if (n > 0) total += (size_t)n; + usleep(2000); + } + CHECK(total == 9 && memcmp(body, "secure ok", 9) == 0); + } + + /* 2. unknown CA → tls_certificate_invalid. */ + log[0] = 0; + https_get(&h, unknown_peer->port, NULL); + CHECK(wait_for(&h, pnet_http_poll, "\"code\":\"tls_certificate_invalid\"", 4000, log, sizeof log)); + + /* 3. expired cert → tls_certificate_invalid. */ + log[0] = 0; + https_get(&h, expired_peer->port, NULL); + CHECK(wait_for(&h, pnet_http_poll, "\"code\":\"tls_certificate_invalid\"", 4000, log, sizeof log)); + + /* 4. hostname mismatch → tls_hostname_mismatch. */ + log[0] = 0; + https_get(&h, wrong_peer->port, NULL); + CHECK(wait_for(&h, pnet_http_poll, "\"code\":\"tls_hostname_mismatch\"", 4000, log, sizeof log)); + + /* 5. no plaintext fallback: a TLS failure never retries as http. The + * unknown-CA peer only speaks TLS; the request ended in a tls_* error, + * never a plain 200 — already asserted in case 2. Re-check the handle + * count is clean. */ + pthread_mutex_lock(&h.lock); + CHECK(!pnet_runtime_has_live_handles(h.rt)); + pthread_mutex_unlock(&h.lock); + + /* 6. WSS: handshake over TLS, echo, clean close. */ + { + char meta[256]; + snprintf(meta, sizeof meta, "{\"url\":\"wss://127.0.0.1:%u/echo\"}", wss_peer->port); + pthread_mutex_lock(&h.lock); + int ws = pnet_ws_connect(h.rt, meta); + pthread_mutex_unlock(&h.lock); + pnet_posix_driver_wake(h.driver); + CHECK(ws > 0); + log[0] = 0; + CHECK(wait_for(&h, pnet_ws_poll, "\"t\":\"open\"", 4000, log, sizeof log)); + pthread_mutex_lock(&h.lock); + CHECK(pnet_ws_send(h.rt, ws, 1, (const uint8_t *)"tls-ws", 6) == 0); + pthread_mutex_unlock(&h.lock); + pnet_posix_driver_wake(h.driver); + log[0] = 0; + CHECK(wait_for(&h, pnet_ws_poll, "\"text\":\"tls-ws\"", 4000, log, sizeof log)); + pthread_mutex_lock(&h.lock); + pnet_ws_close(h.rt, ws, 1000, NULL, 0); + pthread_mutex_unlock(&h.lock); + log[0] = 0; + CHECK(wait_for(&h, pnet_ws_poll, "\"t\":\"close\"", 4000, log, sizeof log)); + } + + harness_stop(&h); + + /* 7. Untrusted wall clock: fail-closed before any I/O, no server touched. */ + g_clock_trusted = false; + harness h2; + harness_start(&h2, policy, p.ca_pem); + log[0] = 0; + https_get(&h2, valid_peer->port, NULL); + CHECK(wait_for(&h2, pnet_http_poll, "\"code\":\"tls_clock_untrusted\"", 3000, log, sizeof log)); + harness_stop(&h2); + g_clock_trusted = true; + + /* 8. development-insecure still refused without the triple opt-in. */ + harness h3; + harness_start(&h3, policy, p.ca_pem); + log[0] = 0; + https_get(&h3, unknown_peer->port, ",\"tls\":{\"verification\":\"development-insecure\"}"); + { + pthread_mutex_lock(&h3.lock); + /* The runtime was not created as a development build, so start() refuses + * synchronously. */ + char meta[256]; + snprintf(meta, sizeof meta, "{\"url\":\"https://127.0.0.1:%u/x\",\"method\":\"GET\",\"headers\":{},\"tls\":{\"verification\":\"development-insecure\"}}", unknown_peer->port); + int rc = pnet_http_start(h3.rt, meta, NULL, 0); + CHECK(rc == -1 && strstr(pnet_http_last_error(h3.rt), "unsupported") != NULL); + pthread_mutex_unlock(&h3.lock); + } + harness_stop(&h3); + + peer_stop(valid_peer); + peer_stop(expired_peer); + peer_stop(wrong_peer); + peer_stop(unknown_peer); + peer_stop(wss_peer); + X509_free(valid); + X509_free(expired); + X509_free(wronghost); + X509_free(unknown); + EVP_PKEY_free(leaf_key); + EVP_PKEY_free(p.ca_key); + X509_free(p.ca_cert); + free(p.ca_pem); + EVP_PKEY_free(rogue.ca_key); + X509_free(rogue.ca_cert); + free(rogue.ca_pem); + + printf("tls: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/engine/net/test/unit_test.c b/engine/net/test/unit_test.c new file mode 100644 index 00000000..5cf2c452 --- /dev/null +++ b/engine/net/test/unit_test.c @@ -0,0 +1,461 @@ +/* Unit tests for the portable pieces of the network core: HTTP/1.1 head and + * body parsing (strict framing profile), URL parsing/resolution, policy + * matching, JSON reading, UTF-8, base64/SHA-1 and the tick queue. Runs on + * the host with a plain-heap platform; no sockets. */ +#include +#include +#include + +#include "pnet_internal.h" + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + } \ + } while (0) + +/* --- test platform ------------------------------------------------------ */ + +static uint64_t fake_now = 1000; +static uint64_t now_ms(void *ctx) { (void)ctx; return fake_now; } +static void *plat_alloc(void *ctx, size_t size) { (void)ctx; return malloc(size); } +static void plat_free(void *ctx, void *ptr, size_t size) { (void)ctx; (void)size; free(ptr); } +static void plat_random(void *ctx, uint8_t *out, size_t len) { + (void)ctx; + for (size_t i = 0; i < len; i++) out[i] = (uint8_t)(i * 31 + 7); +} +static void plat_log(void *ctx, pnet_log_level level, const char *msg) { + (void)ctx; + (void)level; + fprintf(stderr, "[pnet] %s\n", msg); +} + +static int stub_resolve(void *ctx, uint32_t req_id, const char *host) { (void)ctx; (void)req_id; (void)host; return PNET_IO_ERROR; } +static void stub_resolve_cancel(void *ctx, uint32_t id) { (void)ctx; (void)id; } +static pnet_sock stub_connect(void *ctx, const pnet_addr *addr, int *err) { (void)ctx; (void)addr; *err = PNET_IO_REFUSED; return PNET_SOCK_INVALID; } +static int stub_status(void *ctx, pnet_sock s) { (void)ctx; (void)s; return PNET_IO_ERROR; } +static int stub_read(void *ctx, pnet_sock s, uint8_t *b, size_t l) { (void)ctx; (void)s; (void)b; (void)l; return PNET_IO_ERROR; } +static int stub_write(void *ctx, pnet_sock s, const uint8_t *b, size_t l) { (void)ctx; (void)s; (void)b; (void)l; return PNET_IO_ERROR; } +static void stub_shutdown(void *ctx, pnet_sock s) { (void)ctx; (void)s; } +static void stub_close(void *ctx, pnet_sock s) { (void)ctx; (void)s; } +static void stub_interest(void *ctx, pnet_sock s, unsigned f) { (void)ctx; (void)s; (void)f; } +static pnet_sock stub_listen(void *ctx, const pnet_addr *a, int b, pnet_addr *bound, int *err) { (void)ctx; (void)a; (void)b; (void)bound; *err = PNET_IO_ERROR; return PNET_SOCK_INVALID; } +static pnet_sock stub_accept(void *ctx, pnet_sock l, pnet_addr *p, int *err) { (void)ctx; (void)l; (void)p; *err = PNET_IO_AGAIN; return PNET_SOCK_INVALID; } +static int stub_local(void *ctx, pnet_sock s, pnet_addr *o) { (void)ctx; (void)s; (void)o; return PNET_IO_ERROR; } + +static const pnet_driver_ops STUB_DRIVER = { + stub_resolve, stub_resolve_cancel, stub_connect, stub_status, stub_read, stub_write, + stub_shutdown, stub_close, stub_interest, stub_listen, stub_accept, stub_local, NULL, +}; + +static pnet_runtime *make_runtime(const char *policy) { + pnet_platform plat = {NULL, now_ms, plat_alloc, plat_free, plat_random, plat_log}; + pnet_runtime_config cfg; + pnet_runtime_config_defaults(&cfg); + return pnet_runtime_create(&plat, &STUB_DRIVER, NULL, &cfg, policy); +} + +static const char *POLICY = + "{\"connect\":[{\"protocol\":\"http\",\"host\":\"example.test\",\"port\":80}," + "{\"protocol\":\"http\",\"host\":\"*.devices.test\",\"port\":{\"min\":8000,\"max\":8100}}," + "{\"protocol\":\"http\",\"host\":\"192.168.1.20\",\"port\":8080}," + "{\"protocol\":\"ws\",\"host\":\"echo.test\",\"port\":80}]," + "\"listen\":[{\"protocol\":\"http\",\"address\":\"0.0.0.0\",\"port\":8080}," + "{\"protocol\":\"http\",\"address\":\"127.0.0.1\",\"port\":\"ephemeral\"}]," + "\"credentials\":[\"device-cert\"],\"insecureTransport\":true,\"localNetwork\":true}"; + +/* --- HTTP/1.1 head ------------------------------------------------------ */ + +static void test_h1_head(void) { + char raw[] = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nSet-Cookie: a=1\r\nset-cookie: b=2\r\n" + "Content-Length: 5\r\nConnection: keep-alive\r\n\r\nhello"; + pnet_h1_head head; + int rc = pnet_h1_parse_head((uint8_t *)raw, sizeof raw - 1, false, 8192, 64, 2048, &head); + CHECK(rc == PNET_H1_OK); + CHECK(head.status == 200); + CHECK(head.reason_len == 2 && memcmp(head.reason, "OK", 2) == 0); + CHECK(head.field_count == 5); + CHECK(head.content_length == 5); + CHECK(!head.chunked); + CHECK(head.connection_keep_alive && !head.connection_close); + CHECK(head.head_len == sizeof raw - 1 - 5); + const pnet_h1_field *ct = pnet_h1_find(&head, "content-type"); + CHECK(ct && strcmp(ct->value, "text/plain") == 0); + CHECK(pnet_h1_validate_framing(&head)); + + /* Incomplete */ + char partial[] = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n"; + CHECK(pnet_h1_parse_head((uint8_t *)partial, sizeof partial - 1, false, 8192, 64, 2048, &head) == PNET_H1_INCOMPLETE); + + /* TE + CL rejected */ + char both[] = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Length: 5\r\n\r\n"; + CHECK(pnet_h1_parse_head((uint8_t *)both, sizeof both - 1, false, 8192, 64, 2048, &head) == PNET_H1_ERROR); + /* Duplicate CL rejected even when equal */ + char dup[] = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nContent-Length: 5\r\n\r\n"; + CHECK(pnet_h1_parse_head((uint8_t *)dup, sizeof dup - 1, false, 8192, 64, 2048, &head) == PNET_H1_ERROR); + /* CL comma list rejected */ + char comma[] = "HTTP/1.1 200 OK\r\nContent-Length: 5, 5\r\n\r\n"; + CHECK(pnet_h1_parse_head((uint8_t *)comma, sizeof comma - 1, false, 8192, 64, 2048, &head) == PNET_H1_ERROR); + /* Unknown coding / combined codings rejected */ + char gz[] = "HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip, chunked\r\n\r\n"; + CHECK(pnet_h1_parse_head((uint8_t *)gz, sizeof gz - 1, false, 8192, 64, 2048, &head) == PNET_H1_ERROR); + char twice[] = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nTransfer-Encoding: chunked\r\n\r\n"; + CHECK(pnet_h1_parse_head((uint8_t *)twice, sizeof twice - 1, false, 8192, 64, 2048, &head) == PNET_H1_ERROR); + /* obs-fold rejected */ + char fold[] = "HTTP/1.1 200 OK\r\nX-A: 1\r\n 2\r\n\r\n"; + CHECK(pnet_h1_parse_head((uint8_t *)fold, sizeof fold - 1, false, 8192, 64, 2048, &head) == PNET_H1_ERROR); + /* Header block over limit */ + char big[] = "HTTP/1.1 200 OK\r\nX-A: 0123456789012345678901234567890123456789\r\n\r\n"; + CHECK(pnet_h1_parse_head((uint8_t *)big, sizeof big - 1, false, 32, 64, 2048, &head) == PNET_H1_TOO_LARGE); + /* Chunked ok */ + char ch[] = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"; + CHECK(pnet_h1_parse_head((uint8_t *)ch, sizeof ch - 1, false, 8192, 64, 2048, &head) == PNET_H1_OK && head.chunked); + /* Request line */ + char req[] = "POST /a/b?c=1 HTTP/1.1\r\nHost: h\r\nExpect: 100-continue\r\n\r\n"; + CHECK(pnet_h1_parse_head((uint8_t *)req, sizeof req - 1, true, 8192, 64, 2048, &head) == PNET_H1_OK); + CHECK(head.method_len == 4 && memcmp(head.method, "POST", 4) == 0); + CHECK(head.target_len == 8 && memcmp(head.target, "/a/b?c=1", 8) == 0); + CHECK(head.expect_continue); + char longtarget[] = "GET /0123456789 HTTP/1.1\r\n\r\n"; + CHECK(pnet_h1_parse_head((uint8_t *)longtarget, sizeof longtarget - 1, true, 8192, 64, 4, &head) == PNET_H1_TARGET_TOO_LONG); + char badver[] = "GET / HTTP/2.0\r\n\r\n"; + CHECK(pnet_h1_parse_head((uint8_t *)badver, sizeof badver - 1, true, 8192, 64, 2048, &head) == PNET_H1_ERROR); + char toomany[] = "GET / HTTP/1.1\r\nA: 1\r\nB: 2\r\nC: 3\r\n\r\n"; + CHECK(pnet_h1_parse_head((uint8_t *)toomany, sizeof toomany - 1, true, 8192, 2, 2048, &head) == PNET_H1_TOO_MANY_FIELDS); +} + +/* --- body decoding ------------------------------------------------------ */ + +typedef struct collect { + uint8_t out[512]; + size_t len; +} collect; + +static bool collect_sink(void *ctx, const uint8_t *data, size_t len) { + collect *c = ctx; + if (c->len + len > sizeof c->out) return false; + memcpy(c->out + c->len, data, len); + c->len += len; + return true; +} + +static void test_h1_body(void) { + pnet_h1_body b; + collect c = {{0}, 0}; + pnet_h1_body_init(&b, PNET_H1_BODY_LENGTH, 5); + const uint8_t in[] = "hello world"; + size_t used = pnet_h1_body_feed(&b, in, 11, collect_sink, &c); + CHECK(used == 5 && b.done && c.len == 5 && memcmp(c.out, "hello", 5) == 0); + + /* Chunked, split across feeds, with extension and trailer. */ + const char *chunks = "4;ext=1\r\nWiki\r\n5\r\npedia\r\nE\r\n in\r\n\r\nchunks.\r\n0\r\nX-Trailer: ok\r\n\r\nNEXT"; + size_t total = strlen(chunks); + pnet_h1_body_init(&b, PNET_H1_BODY_CHUNKED, 0); + c.len = 0; + size_t pos = 0; + while (pos < total && !b.done && !b.error) { + size_t step = pos % 3 + 1; + if (pos + step > total) step = total - pos; + size_t n = pnet_h1_body_feed(&b, (const uint8_t *)chunks + pos, step, collect_sink, &c); + pos += n; + if (n == 0) break; + } + CHECK(b.done && !b.error); + CHECK(c.len == 23 && memcmp(c.out, "Wikipedia in\r\n\r\nchunks.", 23) == 0); + CHECK(total - pos == 4); /* "NEXT" left unconsumed */ + + /* Forbidden trailer field */ + const char *badtrailer = "0\r\nContent-Length: 3\r\n\r\n"; + pnet_h1_body_init(&b, PNET_H1_BODY_CHUNKED, 0); + c.len = 0; + pnet_h1_body_feed(&b, (const uint8_t *)badtrailer, strlen(badtrailer), collect_sink, &c); + CHECK(b.error); + /* Bad chunk size */ + const char *badsize = "zz\r\n"; + pnet_h1_body_init(&b, PNET_H1_BODY_CHUNKED, 0); + pnet_h1_body_feed(&b, (const uint8_t *)badsize, 4, collect_sink, &c); + CHECK(b.error); + /* Missing CRLF after data */ + const char *badcrlf = "3\r\nabcX"; + pnet_h1_body_init(&b, PNET_H1_BODY_CHUNKED, 0); + c.len = 0; + pnet_h1_body_feed(&b, (const uint8_t *)badcrlf, 7, collect_sink, &c); + CHECK(b.error); + /* Close-delimited passes everything through */ + pnet_h1_body_init(&b, PNET_H1_BODY_CLOSE, 0); + c.len = 0; + CHECK(pnet_h1_body_feed(&b, in, 11, collect_sink, &c) == 11 && !b.done && c.len == 11); +} + +/* --- URL ---------------------------------------------------------------- */ + +static void test_url(pnet_runtime *rt) { + pnet_url u; + CHECK(pnet_url_parse(rt, "HTTP://Example.TEST:80/a?b=1#frag", 33, &u)); + CHECK(strcmp(u.scheme, "http") == 0); + CHECK(strcmp(u.host, "example.test") == 0); + CHECK(u.port == 80 && !u.port_explicit); + CHECK(strcmp(u.path, "/a?b=1") == 0); + pnet_url r; + CHECK(pnet_url_resolve(rt, &u, "../x/./y", 8, &r)); + CHECK(strcmp(r.path, "/x/y") == 0); + pnet_url_free(rt, &r); + CHECK(pnet_url_resolve(rt, &u, "//other.test:8080/z", 19, &r)); + CHECK(strcmp(r.host, "other.test") == 0 && r.port == 8080 && r.port_explicit); + pnet_url_free(rt, &r); + CHECK(pnet_url_resolve(rt, &u, "?q", 2, &r)); + CHECK(strcmp(r.path, "/a?q") == 0); + pnet_url_free(rt, &r); + CHECK(pnet_url_resolve(rt, &u, "https://s.test/p", 16, &r)); + CHECK(strcmp(r.scheme, "https") == 0 && r.port == 443); + pnet_url_free(rt, &r); + pnet_url_free(rt, &u); + CHECK(!pnet_url_parse(rt, "ftp://x/", 8, &u)); + CHECK(!pnet_url_parse(rt, "http://u:p@x/", 13, &u)); + CHECK(!pnet_url_parse(rt, "http:///", 8, &u)); + CHECK(pnet_url_parse(rt, "http://[::1]:8080/", 18, &u)); + CHECK(u.host_is_ipv6 && strcmp(u.host, "::1") == 0 && u.port == 8080); + pnet_url_free(rt, &u); + CHECK(pnet_url_parse(rt, "ws://echo.test", 14, &u)); + CHECK(strcmp(u.path, "/") == 0 && u.port == 80); + pnet_url_free(rt, &u); +} + +/* --- policy ------------------------------------------------------------- */ + +static void test_policy(pnet_runtime *rt) { + const pnet_policy *p = &rt->policy; + CHECK(p->connect_count == 4 && p->listen_count == 2 && p->credential_count == 1); + CHECK(pnet_policy_allows_connect(p, PNET_PROTO_HTTP, "example.test", 80)); + CHECK(!pnet_policy_allows_connect(p, PNET_PROTO_HTTP, "example.test", 81)); + CHECK(!pnet_policy_allows_connect(p, PNET_PROTO_HTTPS, "example.test", 80)); + CHECK(pnet_policy_allows_connect(p, PNET_PROTO_HTTP, "a.devices.test", 8050)); + CHECK(!pnet_policy_allows_connect(p, PNET_PROTO_HTTP, "a.b.devices.test", 8050)); + CHECK(!pnet_policy_allows_connect(p, PNET_PROTO_HTTP, "devices.test", 8050)); + CHECK(pnet_policy_allows_connect(p, PNET_PROTO_HTTP, "192.168.1.20", 8080)); + CHECK(!pnet_policy_allows_connect(p, PNET_PROTO_HTTP, "192.168.1.21", 8080)); + CHECK(pnet_policy_allows_connect(p, PNET_PROTO_WS, "echo.test", 80)); + pnet_addr any = {4, {0, 0, 0, 0}, 0}; + pnet_addr lo = {4, {127, 0, 0, 1}, 0}; + CHECK(pnet_policy_allows_listen(p, PNET_PROTO_HTTP, &any, 8080)); + CHECK(!pnet_policy_allows_listen(p, PNET_PROTO_HTTP, &any, 8081)); + CHECK(pnet_policy_allows_listen(p, PNET_PROTO_HTTP, &lo, 0)); + CHECK(!pnet_policy_allows_listen(p, PNET_PROTO_HTTP, &lo, 8080)); + CHECK(pnet_policy_has_credential(p, "device-cert") && !pnet_policy_has_credential(p, "other")); + pnet_addr priv = {4, {10, 0, 0, 5}, 0}; + pnet_addr pub = {4, {93, 184, 216, 34}, 0}; + pnet_addr mc = {4, {224, 0, 0, 1}, 0}; + CHECK(pnet_policy_allows_address(p, &priv)); /* localNetwork: true */ + CHECK(pnet_policy_allows_address(p, &pub)); + CHECK(!pnet_policy_allows_address(p, &mc)); + CHECK(!pnet_addr_is_public(&lo)); + pnet_addr v6lo = {6, {0}, 0}; + v6lo.addr[15] = 1; + CHECK(!pnet_addr_is_public(&v6lo)); + + pnet_runtime *strict = make_runtime("{\"connect\":[{\"protocol\":\"http\",\"host\":\"h.test\",\"port\":80}],\"insecureTransport\":false}"); + CHECK(strict != NULL); + if (strict) { + CHECK(!pnet_policy_allows_connect(&strict->policy, PNET_PROTO_HTTP, "h.test", 80)); + CHECK(!pnet_policy_allows_address(&strict->policy, &priv)); + pnet_runtime_destroy(strict); + } + CHECK(make_runtime("{\"connect\":[{\"protocol\":\"gopher\",\"host\":\"h\",\"port\":1}]}") == NULL); + CHECK(make_runtime("not json") == NULL); +} + +/* --- JSON --------------------------------------------------------------- */ + +static void test_json(pnet_runtime *rt) { + pnet_jnode nodes[64]; + pnet_jdoc doc; + const char *text = "{\"url\":\"http://x/\\u00e9\\n\",\"n\":42,\"neg\":-7,\"arr\":[1,\"two\",true,null],\"o\":{\"k\":false}}"; + int root = pnet_json_parse(&doc, nodes, 64, text, strlen(text)); + CHECK(root >= 0); + char buf[64]; + size_t len; + CHECK(pnet_json_string(&doc, pnet_json_get(&doc, root, "url"), buf, sizeof buf, &len)); + CHECK(len == 12 && memcmp(buf, "http://x/\xC3\xA9\n", 12) == 0); + int64_t v; + CHECK(pnet_json_i64(&doc, pnet_json_get(&doc, root, "n"), &v) && v == 42); + CHECK(pnet_json_i64(&doc, pnet_json_get(&doc, root, "neg"), &v) && v == -7); + int arr = pnet_json_get(&doc, root, "arr"); + CHECK(pnet_json_type(&doc, arr) == PNET_J_ARRAY); + int e = pnet_json_first(&doc, arr); + CHECK(pnet_json_type(&doc, e) == PNET_J_NUMBER); + e = pnet_json_next(&doc, e); + CHECK(pnet_json_type(&doc, e) == PNET_J_STRING); + e = pnet_json_next(&doc, e); + CHECK(pnet_json_type(&doc, e) == PNET_J_BOOL && doc.nodes[e].truthy); + e = pnet_json_next(&doc, e); + CHECK(pnet_json_type(&doc, e) == PNET_J_NULL); + CHECK(pnet_json_next(&doc, e) == -1); + int o = pnet_json_get(&doc, root, "o"); + CHECK(pnet_json_type(&doc, pnet_json_get(&doc, o, "k")) == PNET_J_BOOL); + CHECK(pnet_json_get(&doc, root, "missing") == -1); + char *dup = pnet_json_string_dup(rt, &doc, pnet_json_get(&doc, root, "url"), &len); + CHECK(dup && len == 12); + pnet_free_str(rt, dup); + CHECK(pnet_json_parse(&doc, nodes, 64, "{\"a\":}", 6) < 0); + CHECK(pnet_json_parse(&doc, nodes, 64, "[1,2", 4) < 0); + CHECK(pnet_json_parse(&doc, nodes, 4, "[1,2,3,4,5,6]", 13) < 0); /* node cap */ + CHECK(pnet_json_parse(&doc, nodes, 64, "\"a\\qb\"", 6) < 0); /* bad escape */ + /* Writer escaping */ + pnet_sb sb; + pnet_sb_init(&sb); + pnet_sb_json_string(rt, &sb, "a\"b\\c\n\x01\xC3\xA9\xff", 10); + CHECK(strcmp(pnet_sb_cstr(&sb), "\"a\\\"b\\\\c\\n\\u0001\xC3\xA9\xEF\xBF\xBD\"") == 0); + pnet_sb_free(rt, &sb); +} + +/* --- codecs ------------------------------------------------------------- */ + +static void test_codecs(void) { + CHECK(pnet_utf8_valid((const uint8_t *)"h\xC3\xA9llo \xE2\x82\xAC \xF0\x9F\x98\x80", 15)); + CHECK(!pnet_utf8_valid((const uint8_t *)"\xC0\x80", 2)); /* overlong */ + CHECK(!pnet_utf8_valid((const uint8_t *)"\xED\xA0\x80", 3)); /* surrogate */ + CHECK(!pnet_utf8_valid((const uint8_t *)"\xF4\x90\x80\x80", 4)); /* > U+10FFFF */ + CHECK(!pnet_utf8_valid((const uint8_t *)"\xE2\x82", 2)); /* truncated */ + pnet_utf8_state st; + pnet_utf8_state_init(&st); + CHECK(pnet_utf8_feed(&st, (const uint8_t *)"\xE2\x82", 2) && !pnet_utf8_complete(&st)); + CHECK(pnet_utf8_feed(&st, (const uint8_t *)"\xAC", 1) && pnet_utf8_complete(&st)); + char b64[64]; + CHECK(pnet_base64_encode((const uint8_t *)"Man", 3, b64, sizeof b64) == 4 && strcmp(b64, "TWFu") == 0); + CHECK(pnet_base64_encode((const uint8_t *)"Ma", 2, b64, sizeof b64) == 4 && strcmp(b64, "TWE=") == 0); + /* RFC 6455 §1.3 example: key "dGhlIHNhbXBsZSBub25jZQ==" -> accept "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" */ + const char *concat = "dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + uint8_t digest[20]; + pnet_sha1((const uint8_t *)concat, strlen(concat), digest); + pnet_base64_encode(digest, 20, b64, sizeof b64); + CHECK(strcmp(b64, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") == 0); + /* SHA-1("abc") */ + pnet_sha1((const uint8_t *)"abc", 3, digest); + static const uint8_t abc[20] = {0xa9, 0x99, 0x3e, 0x36, 0x47, 0x06, 0x81, 0x6a, 0xba, 0x3e, + 0x25, 0x71, 0x78, 0x50, 0xc2, 0x6c, 0x9c, 0xd0, 0xd8, 0x9d}; + CHECK(memcmp(digest, abc, 20) == 0); + /* 64-byte boundary message */ + char sixtyfour[64]; + memset(sixtyfour, 'a', 64); + pnet_sha1((const uint8_t *)sixtyfour, 64, digest); + static const uint8_t a64[20] = {0x00, 0x98, 0xba, 0x82, 0x4b, 0x5c, 0x16, 0x42, 0x7b, 0xd7, + 0xa1, 0x12, 0x2a, 0x5a, 0x44, 0x2a, 0x25, 0xec, 0x64, 0x4d}; + CHECK(memcmp(digest, a64, 20) == 0); + pnet_addr a; + CHECK(pnet_parse_ip_literal("192.168.1.2", 11, &a) && a.family == 4 && a.addr[3] == 2); + CHECK(!pnet_parse_ip_literal("192.168.1", 9, &a)); + CHECK(!pnet_parse_ip_literal("256.1.1.1", 9, &a)); + CHECK(pnet_parse_ip_literal("fe80::1", 7, &a) && a.family == 6 && a.addr[0] == 0xfe && a.addr[15] == 1); + CHECK(pnet_parse_ip_literal("[::ffff:1.2.3.4]", 16, &a) && a.family == 6 && a.addr[10] == 0xff && a.addr[15] == 4); + CHECK(!pnet_parse_ip_literal("1::2::3", 7, &a)); + char text[48]; + pnet_addr v6 = {6, {0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, 0}; + pnet_format_addr(&v6, text, sizeof text); + CHECK(strcmp(text, "2001:db8::1") == 0); + pnet_addr v4 = {4, {10, 0, 0, 7}, 0}; + pnet_format_addr(&v4, text, sizeof text); + CHECK(strcmp(text, "10.0.0.7") == 0); + CHECK(pnet_is_token("X-Custom_1", 10) && !pnet_is_token("bad name", 8) && !pnet_is_token("", 0)); +} + +/* --- queue -------------------------------------------------------------- */ + +static void test_queue(pnet_runtime *rt) { + pnet_queue q; + pnet_queue_init(&q, 3, 100); + size_t len; + char *j1 = pnet_event_json(rt, "headers", "h", 1, ",\"status\":200", 13, &len); + CHECK(pnet_queue_push(rt, &q, 1, false, 10, j1, len)); + char *j2 = pnet_event_json(rt, "end", "h", 1, NULL, 0, &len); + CHECK(pnet_queue_push(rt, &q, 1, true, 0, j2, len)); + /* readable inserted before the terminal event of handle 1 */ + CHECK(pnet_queue_push_readable(rt, &q, 1, "h", 5)); + CHECK(pnet_push_error_event(rt, &q, "h", 2, "dns", "no host", NULL)); + CHECK(pnet_queue_poll(rt, &q, &len) == NULL); /* nothing visible before freeze */ + pnet_queue_freeze(rt, &q); + const char *batch = pnet_queue_poll(rt, &q, &len); + CHECK(batch != NULL); + CHECK(strcmp(batch, "[{\"t\":\"headers\",\"h\":1,\"status\":200},{\"t\":\"readable\",\"h\":1,\"avail\":5},{\"t\":\"end\",\"h\":1}]") == 0); + pnet_queue_freeze(rt, &q); /* the 4th event (over the 3-event budget) follows */ + batch = pnet_queue_poll(rt, &q, &len); + CHECK(batch && strstr(batch, "\"t\":\"error\",\"h\":2") != NULL); + CHECK(pnet_queue_poll(rt, &q, &len) == NULL); + /* byte budget: a single over-budget event still goes alone */ + char *big = pnet_event_json(rt, "headers", "h", 3, NULL, 0, &len); + CHECK(pnet_queue_push(rt, &q, 3, false, 500, big, len)); + char *small = pnet_event_json(rt, "end", "h", 3, NULL, 0, &len); + CHECK(pnet_queue_push(rt, &q, 3, true, 1, small, len)); + pnet_queue_freeze(rt, &q); + CHECK(q.visible_count == 1 && q.pending_count == 1); + pnet_queue_drop_handle(rt, &q, 3); + CHECK(q.pending_count == 0); + pnet_queue_free(rt, &q); +} + +/* --- HTTP client refusals (no I/O) -------------------------------------- */ + +static void test_http_refusals(pnet_runtime *rt) { + CHECK(pnet_http_start(rt, "{\"url\":\"http://nope.test/\",\"method\":\"GET\",\"headers\":{}}", NULL, 0) == -1); + CHECK(strncmp(pnet_http_last_error(rt), "permission_denied", 17) == 0); + CHECK(pnet_http_start(rt, "{\"url\":\"https://example.test/\",\"method\":\"GET\",\"headers\":{}}", NULL, 0) == -1); + CHECK(strncmp(pnet_http_last_error(rt), "unsupported", 11) == 0); + CHECK(pnet_http_start(rt, "{\"url\":\"http://example.test/\",\"method\":\"TRACE\",\"headers\":{}}", NULL, 0) == -1); + CHECK(strncmp(pnet_http_last_error(rt), "invalid_request", 15) == 0); + CHECK(pnet_http_start(rt, "{\"url\":\"http://example.test/\",\"method\":\"GET\",\"headers\":{\"x\":\"a\\nb\"}}", NULL, 0) == -1); + CHECK(pnet_http_start(rt, "{\"url\":\"http://example.test/\",\"method\":\"GET\",\"headers\":{},\"queueBytes\":0}", NULL, 0) == -1); + CHECK(pnet_http_start(rt, "{\"url\":\"http://example.test/\",\"method\":\"GET\",\"headers\":{},\"redirect\":\"maybe\"}", NULL, 0) == -1); + CHECK(pnet_http_start(rt, "{\"url\":\"http://example.test/\",\"method\":\"GET\",\"headers\":{},\"tls\":{\"verification\":\"development-insecure\"}}", NULL, 0) == -1); + CHECK(strncmp(pnet_http_last_error(rt), "unsupported", 11) == 0); + CHECK(pnet_http_start(rt, "{\"url\":\"http://example.test/\",\"method\":\"GET\",\"headers\":{}}", (const uint8_t *)"x", 1) == -1); + /* .local names fail before any I/O with unsupported */ + pnet_runtime *rt2 = make_runtime("{\"connect\":[{\"protocol\":\"http\",\"host\":\"printer.local\",\"port\":80}],\"insecureTransport\":true,\"localNetwork\":true}"); + CHECK(rt2 != NULL); + if (rt2) { + int h = pnet_http_start(rt2, "{\"url\":\"http://printer.local/\",\"method\":\"GET\",\"headers\":{}}", NULL, 0); + CHECK(h > 0); /* accepted synchronously; the terminal error arrives next tick */ + pnet_runtime_begin_tick(rt2); + size_t len; + const char *batch = pnet_http_poll(rt2, &len); + CHECK(batch && strstr(batch, "\"code\":\"unsupported\"") != NULL); + CHECK(pnet_runtime_heap_bytes(rt2) > 0); + pnet_runtime_destroy(rt2); + } + /* The stub driver refuses every connect: the request fails asynchronously + * with connect (the literal address skips DNS). */ + int h = pnet_http_start(rt, "{\"url\":\"http://192.168.1.20:8080/x\",\"method\":\"GET\",\"headers\":{}}", NULL, 0); + CHECK(h > 0); + pnet_runtime_service(rt); + pnet_runtime_begin_tick(rt); + size_t len; + const char *batch = pnet_http_poll(rt, &len); + CHECK(batch && strstr(batch, "\"code\":\"connect\"") != NULL); + CHECK(pnet_http_read_into(rt, h, (uint8_t[8]){0}, 8) == -1); + CHECK(!pnet_runtime_has_live_handles(rt)); + /* limits JSON is well-formed and reports the spec major */ + CHECK(strstr(pnet_http_limits(rt), "\"specMajor\":2") != NULL); +} + +int main(void) { + pnet_runtime *rt = make_runtime(POLICY); + CHECK(rt != NULL); + if (!rt) return 1; + test_h1_head(); + test_h1_body(); + test_url(rt); + test_policy(rt); + test_json(rt); + test_codecs(); + test_queue(rt); + test_http_refusals(rt); + size_t before_destroy = pnet_runtime_heap_bytes(rt); + pnet_runtime_destroy(rt); + (void)before_destroy; + printf("unit: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} From 85be925dccc8a891f97b576bf69268daad98d6d4 Mon Sep 17 00:00:00 2001 From: HalfSweet Date: Wed, 19 Aug 2026 23:40:47 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(net):=20review=20round=20=E2=80=94=20tr?= =?UTF-8?q?ansactional=20poll,=20resolver=20worker,=20spec-pinned=20semant?= =?UTF-8?q?ics,=20policy=20parser=20parity=20(stack=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review fixes for the C core (stack B): - poll is transactional (reviewer item 2): the batch is sized and reserved before a single visible event is dequeued; an allocation failure leaves the whole visible set in place and the next poll retries, so a handle's terminal end/error is never lost to the heap cap (the ESP host caps the core at 1 MiB). pnet_*_poll_render / _consume expose the two phases for hosts that marshal the batch into a guest value. Unit tests pin the deferred batch, idempotent render and consume-exactly-the-rendered set. - getaddrinfo runs on the driver's resolver worker (item 4): a pthread on desktop, the pnet-dns task on ESP-IDF; the network task's select loop, the sockets and the core's deadlines keep running during a lookup (connectMs covers DNS). The host test drives a hostname through the worker, checks NXDOMAIN → dns, and shows a literal-address exchange completing while a lookup is in flight. - Spec-pinned semantics (item 9): the client uses PNET_METHODS_FORBIDDEN (TRACK included) and PNET_HTTP_CORE_OWNED_REQUEST_HEADERS, framing uses pnet_status_is_bodyless, the redirect plan (pnet_http_redirect_plan) uses the spec's tables, the server refuses content on every null-body status (205 included); pnet_unit_test runs contracts/spec/vectors/ http-semantics.json. - Policy parser parity (item 1/3): pnet_policy_parse accepts exactly the canonical ResolvedNetworkPolicy (version 1, A-label hostnames, no bare wildcard, no wildcard over an IP literal, no leading-zero IPv4 octets) and runs contracts/spec/vectors/network-policy.json; pnet_json records container source spans so a sub-document can be handed to another parser. --- engine/net/CMakeLists.txt | 3 + engine/net/drivers/posix/pnet_posix_driver.c | 219 +++++++++++--- engine/net/drivers/posix/pnet_posix_driver.h | 20 +- engine/net/include/pocketjs/net/runtime.h | 18 +- engine/net/src/pnet_http_client.c | 25 +- engine/net/src/pnet_http_server.c | 12 +- engine/net/src/pnet_internal.h | 29 +- engine/net/src/pnet_json.c | 13 +- engine/net/src/pnet_policy.c | 43 ++- engine/net/src/pnet_runtime.c | 73 ++++- engine/net/src/pnet_util.c | 65 ++++ engine/net/src/pnet_ws.c | 8 + engine/net/test/host_test.c | 54 +++- engine/net/test/unit_test.c | 293 +++++++++++++++++++ 14 files changed, 790 insertions(+), 85 deletions(-) diff --git a/engine/net/CMakeLists.txt b/engine/net/CMakeLists.txt index c99dd2e7..f6627f44 100644 --- a/engine/net/CMakeLists.txt +++ b/engine/net/CMakeLists.txt @@ -45,6 +45,9 @@ enable_testing() add_executable(pnet_unit_test test/unit_test.c) target_include_directories(pnet_unit_test PRIVATE src) target_link_libraries(pnet_unit_test PRIVATE pocketjs_net) +# The shared conformance vectors (TypeScript reference, C core, Rust core). +get_filename_component(PNET_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +target_compile_definitions(pnet_unit_test PRIVATE "PNET_VECTORS_DIR=\"${PNET_REPO_ROOT}/contracts/spec/vectors\"") add_test(NAME unit COMMAND pnet_unit_test) add_executable(pnet_host_test test/host_test.c) diff --git a/engine/net/drivers/posix/pnet_posix_driver.c b/engine/net/drivers/posix/pnet_posix_driver.c index bd136e48..00a776c4 100644 --- a/engine/net/drivers/posix/pnet_posix_driver.c +++ b/engine/net/drivers/posix/pnet_posix_driver.c @@ -20,11 +20,24 @@ #include "sdkconfig.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" +#include "freertos/task.h" typedef SemaphoreHandle_t drv_mutex_t; static void mutex_init(drv_mutex_t *m) { *m = xSemaphoreCreateMutex(); } static void mutex_lock(drv_mutex_t *m) { xSemaphoreTake(*m, portMAX_DELAY); } static void mutex_unlock(drv_mutex_t *m) { xSemaphoreGive(*m); } static void mutex_destroy(drv_mutex_t *m) { vSemaphoreDelete(*m); } +/* Resolver worker signal: a binary semaphore the worker blocks on. */ +typedef SemaphoreHandle_t drv_signal_t; +static bool signal_init(drv_signal_t *s) { *s = xSemaphoreCreateBinary(); return *s != NULL; } +static void signal_post(drv_signal_t *s) { xSemaphoreGive(*s); } +static void signal_wait(drv_signal_t *s) { xSemaphoreTake(*s, portMAX_DELAY); } +static void signal_destroy(drv_signal_t *s) { vSemaphoreDelete(*s); } +#ifndef PNET_POSIX_RESOLVER_STACK +#define PNET_POSIX_RESOLVER_STACK 6144 +#endif +#ifndef PNET_POSIX_RESOLVER_PRIORITY +#define PNET_POSIX_RESOLVER_PRIORITY 6 +#endif #else #include typedef pthread_mutex_t drv_mutex_t; @@ -32,6 +45,32 @@ static void mutex_init(drv_mutex_t *m) { pthread_mutex_init(m, NULL); } static void mutex_lock(drv_mutex_t *m) { pthread_mutex_lock(m); } static void mutex_unlock(drv_mutex_t *m) { pthread_mutex_unlock(m); } static void mutex_destroy(drv_mutex_t *m) { pthread_mutex_destroy(m); } +/* Resolver worker signal: a counting flag under its own mutex + condvar. */ +typedef struct drv_signal { + pthread_mutex_t m; + pthread_cond_t c; + int pending; +} drv_signal_t; +static bool signal_init(drv_signal_t *s) { + s->pending = 0; + return pthread_mutex_init(&s->m, NULL) == 0 && pthread_cond_init(&s->c, NULL) == 0; +} +static void signal_post(drv_signal_t *s) { + pthread_mutex_lock(&s->m); + s->pending = 1; + pthread_cond_signal(&s->c); + pthread_mutex_unlock(&s->m); +} +static void signal_wait(drv_signal_t *s) { + pthread_mutex_lock(&s->m); + while (!s->pending) pthread_cond_wait(&s->c, &s->m); + s->pending = 0; + pthread_mutex_unlock(&s->m); +} +static void signal_destroy(drv_signal_t *s) { + pthread_cond_destroy(&s->c); + pthread_mutex_destroy(&s->m); +} #endif #ifndef MSG_NOSIGNAL @@ -41,6 +80,9 @@ static void mutex_destroy(drv_mutex_t *m) { pthread_mutex_destroy(m); } #define RESOLVE_SLOTS 16 #define RESOLVE_MAX_ADDRS 8 +static void resolver_start(pnet_posix_driver *d); +static void resolver_stop(pnet_posix_driver *d); + typedef struct sock_slot { int fd; unsigned interest; @@ -71,6 +113,20 @@ struct pnet_posix_driver { struct sockaddr_in wake_addr; drv_mutex_t mutex; resolve_slot resolves[RESOLVE_SLOTS]; + /* Resolver worker: getaddrinfo() blocks, so it runs on its own thread/ + * task and never on the network task (whose select loop must keep + * servicing sockets and deadlines). `resolver_inline` is the fallback + * when the worker could not be started: lookups then run inside wait(). */ + drv_signal_t resolver_signal; + volatile bool resolver_stop; + volatile bool resolver_exited; + bool resolver_running; + bool resolver_inline; +#if defined(ESP_PLATFORM) + TaskHandle_t resolver_task; +#else + pthread_t resolver_thread; +#endif }; static int map_errno(int e) { @@ -198,7 +254,10 @@ static int drv_resolve(void *ctx, uint32_t req_id, const char *host) { } } mutex_unlock(&d->mutex); - if (rc == 0) pnet_posix_driver_wake(d); + if (rc == 0) { + if (d->resolver_running) signal_post(&d->resolver_signal); + else pnet_posix_driver_wake(d); /* inline fallback: resolved in wait() */ + } return rc; } @@ -448,11 +507,13 @@ pnet_posix_driver *pnet_posix_driver_create(int max_sockets) { d->wake_fd = -1; } } + resolver_start(d); return d; } void pnet_posix_driver_destroy(pnet_posix_driver *d) { if (!d) return; + resolver_stop(d); for (int i = 0; i < d->max_sockets; i++) if (d->slots[i].in_use) close(d->slots[i].fd); if (d->wake_fd >= 0) close(d->wake_fd); @@ -474,53 +535,131 @@ int pnet_posix_driver_socket_count(pnet_posix_driver *d) { return n; } -static void run_resolves(pnet_posix_driver *d) { +/* Resolve the first pending slot (one blocking getaddrinfo); false when no + * slot is pending. Results land in the slot under the mutex; the network + * task hands them to the runtime in dispatch(). */ +static bool resolve_one(pnet_posix_driver *d) { + int idx = -1; + char host[256]; + mutex_lock(&d->mutex); for (int i = 0; i < RESOLVE_SLOTS; i++) { - mutex_lock(&d->mutex); resolve_slot *r = &d->resolves[i]; - bool pending = r->state == RS_PENDING && !r->cancelled; - char host[256]; - if (pending) strcpy(host, r->host); - mutex_unlock(&d->mutex); - if (!pending) continue; - struct addrinfo hints; - memset(&hints, 0, sizeof hints); - hints.ai_socktype = SOCK_STREAM; - hints.ai_family = AF_UNSPEC; - struct addrinfo *res = NULL; - int rc = getaddrinfo(host, NULL, &hints, &res); - pnet_addr addrs[RESOLVE_MAX_ADDRS]; - size_t count = 0; - if (rc == 0) { - /* IPv4 first, then IPv6 (v1 modules are IPv4-first). */ - for (int pass = 0; pass < 2 && count < RESOLVE_MAX_ADDRS; pass++) { - for (struct addrinfo *ai = res; ai && count < RESOLVE_MAX_ADDRS; ai = ai->ai_next) { - if ((pass == 0 && ai->ai_family != AF_INET) || (pass == 1 && ai->ai_family == AF_INET)) continue; - pnet_addr a; - from_sockaddr(ai->ai_addr, &a); - if (a.family == 0) continue; - bool dup = false; - for (size_t k = 0; k < count; k++) - if (addrs[k].family == a.family && memcmp(addrs[k].addr, a.addr, 16) == 0) dup = true; - if (!dup) addrs[count++] = a; - } - } - freeaddrinfo(res); + if (r->state == RS_PENDING && !r->cancelled) { + idx = i; + strcpy(host, r->host); + break; } - mutex_lock(&d->mutex); - if (r->state == RS_PENDING) { - r->state = RS_DONE; - r->err = rc == 0 && count > 0 ? 0 : PNET_IO_ERROR; - r->count = count; - memcpy(r->addrs, addrs, count * sizeof(pnet_addr)); - if (r->cancelled) r->state = RS_FREE; + if (r->state == RS_PENDING && r->cancelled) r->state = RS_FREE; /* cancelled before it ran */ + } + mutex_unlock(&d->mutex); + if (idx < 0) return false; + struct addrinfo hints; + memset(&hints, 0, sizeof hints); + hints.ai_socktype = SOCK_STREAM; + hints.ai_family = AF_UNSPEC; + struct addrinfo *res = NULL; + int rc = getaddrinfo(host, NULL, &hints, &res); + pnet_addr addrs[RESOLVE_MAX_ADDRS]; + size_t count = 0; + if (rc == 0) { + /* IPv4 first, then IPv6 (v1 modules are IPv4-first). */ + for (int pass = 0; pass < 2 && count < RESOLVE_MAX_ADDRS; pass++) { + for (struct addrinfo *ai = res; ai && count < RESOLVE_MAX_ADDRS; ai = ai->ai_next) { + if ((pass == 0 && ai->ai_family != AF_INET) || (pass == 1 && ai->ai_family == AF_INET)) continue; + pnet_addr a; + from_sockaddr(ai->ai_addr, &a); + if (a.family == 0) continue; + bool dup = false; + for (size_t k = 0; k < count; k++) + if (addrs[k].family == a.family && memcmp(addrs[k].addr, a.addr, 16) == 0) dup = true; + if (!dup) addrs[count++] = a; + } } - mutex_unlock(&d->mutex); + freeaddrinfo(res); + } + mutex_lock(&d->mutex); + resolve_slot *r = &d->resolves[idx]; + if (r->state == RS_PENDING) { + r->state = RS_DONE; + r->err = rc == 0 && count > 0 ? 0 : PNET_IO_ERROR; + r->count = count; + memcpy(r->addrs, addrs, count * sizeof(pnet_addr)); + if (r->cancelled) r->state = RS_FREE; } + mutex_unlock(&d->mutex); + return true; +} + +static void resolver_loop(pnet_posix_driver *d) { + for (;;) { + signal_wait(&d->resolver_signal); + if (d->resolver_stop) break; + bool any = false; + while (!d->resolver_stop && resolve_one(d)) any = true; + /* Results are ready: interrupt the network task's select so dispatch() + * delivers them now rather than at its next timeout. */ + if (any) pnet_posix_driver_wake(d); + } + d->resolver_exited = true; +} + +#if defined(ESP_PLATFORM) +static void resolver_task(void *arg) { + resolver_loop(arg); + vTaskDelete(NULL); +} +#else +static void *resolver_thread(void *arg) { + resolver_loop(arg); + return NULL; +} +#endif + +static void resolver_start(pnet_posix_driver *d) { + if (!signal_init(&d->resolver_signal)) { + d->resolver_inline = true; + return; + } +#if defined(ESP_PLATFORM) + if (xTaskCreate(resolver_task, "pnet-dns", PNET_POSIX_RESOLVER_STACK, d, PNET_POSIX_RESOLVER_PRIORITY, + &d->resolver_task) != pdPASS) { + signal_destroy(&d->resolver_signal); + d->resolver_inline = true; + return; + } +#else + if (pthread_create(&d->resolver_thread, NULL, resolver_thread, d) != 0) { + signal_destroy(&d->resolver_signal); + d->resolver_inline = true; + return; + } +#endif + d->resolver_running = true; +} + +static void resolver_stop(pnet_posix_driver *d) { + if (!d->resolver_running) return; + d->resolver_stop = true; + signal_post(&d->resolver_signal); +#if defined(ESP_PLATFORM) + /* The task deletes itself after setting resolver_exited; a lookup in + * flight delays this by at most the resolver's own timeout. */ + while (!d->resolver_exited) vTaskDelay(pdMS_TO_TICKS(5)); + vTaskDelay(pdMS_TO_TICKS(5)); /* let the idle task reclaim the TCB */ +#else + pthread_join(d->resolver_thread, NULL); +#endif + signal_destroy(&d->resolver_signal); + d->resolver_running = false; } void pnet_posix_driver_wait(pnet_posix_driver *d, int timeout_ms) { - run_resolves(d); + /* Lookups normally run on the resolver worker; only the fallback (worker + * unavailable) resolves here, blocking this call like the v1 driver did. */ + if (d->resolver_inline) { + while (resolve_one(d)) { + } + } fd_set rfds, wfds; FD_ZERO(&rfds); FD_ZERO(&wfds); diff --git a/engine/net/drivers/posix/pnet_posix_driver.h b/engine/net/drivers/posix/pnet_posix_driver.h index 4a725c73..583ec1b8 100644 --- a/engine/net/drivers/posix/pnet_posix_driver.h +++ b/engine/net/drivers/posix/pnet_posix_driver.h @@ -3,19 +3,23 @@ * One implementation of pocketjs/net/driver.h over the BSD socket API, used * by the desktop conformance harness (macOS/Linux) and by ESP-IDF, whose * lwIP exposes the same calls (socket/connect/select/getaddrinfo). The - * driver owns a bounded socket table, a loopback UDP wake socket and a small - * resolver queue; the host's network task drives it with: + * driver owns a bounded socket table, a loopback UDP wake socket, a small + * resolver queue and the resolver worker; the host's network task drives it + * with: * * for (;;) { * lock(); pnet_posix_driver_dispatch(d, rt); pnet_runtime_service(rt); * timeout = pnet_runtime_next_deadline_ms(rt); unlock(); - * pnet_posix_driver_wait(d, timeout); // blocking DNS + select, no lock held + * pnet_posix_driver_wait(d, timeout); // select only, no lock held * } * - * `resolve()` never blocks the caller: lookups run inside wait() on the - * network task and are handed to the runtime by dispatch(). Owner-thread - * ops that need the network task to look at new work call - * pnet_posix_driver_wake(). + * `resolve()` never blocks anyone: getaddrinfo() runs on the driver's own + * resolver thread (pthread) / task ("pnet-dns" on ESP-IDF), so a slow or + * unanswered lookup stalls neither the sockets nor the core's deadlines + * (`connectMs` covers DNS); the worker wakes the network task and dispatch() + * hands the result to the runtime. Only when the worker cannot be created + * does wait() fall back to resolving inline. Owner-thread ops that need the + * network task to look at new work call pnet_posix_driver_wake(). */ #ifndef POCKETJS_NET_POSIX_DRIVER_H #define POCKETJS_NET_POSIX_DRIVER_H @@ -37,7 +41,7 @@ void pnet_posix_driver_destroy(pnet_posix_driver *d); const pnet_driver_ops *pnet_posix_driver_ops(void); /** Wait for I/O, a wake or `timeout_ms` (negative = forever, 0 = poll). - * Runs queued blocking DNS lookups first. Call WITHOUT the runtime lock. */ + * Call WITHOUT the runtime lock. */ void pnet_posix_driver_wait(pnet_posix_driver *d, int timeout_ms); /** Deliver completed resolver results to the runtime. Call WITH the lock. */ void pnet_posix_driver_dispatch(pnet_posix_driver *d, pnet_runtime *rt); diff --git a/engine/net/include/pocketjs/net/runtime.h b/engine/net/include/pocketjs/net/runtime.h index cd33134d..1685b943 100644 --- a/engine/net/include/pocketjs/net/runtime.h +++ b/engine/net/include/pocketjs/net/runtime.h @@ -153,9 +153,19 @@ bool pnet_runtime_has_live_handles(pnet_runtime *rt); int pnet_http_start(pnet_runtime *rt, const char *meta_json, const uint8_t *body, size_t body_len); void pnet_http_cancel(pnet_runtime *rt, int handle); -/** The visible batch as one JSON array, or NULL. The string stays valid - * until the next pnet_http_poll / begin_tick / destroy. */ +/** The visible batch as one JSON array, or NULL (nothing visible, or the + * batch could not be allocated — then nothing is consumed and the next poll + * retries). The string stays valid until the next poll / render / destroy. + * Transactional: events leave the visible set only after the batch text + * exists; resource exhaustion can delay a batch, never drop one. */ const char *pnet_http_poll(pnet_runtime *rt, size_t *len); +/** Two-phase poll for hosts that marshal the batch into a guest value: + * `render` returns the batch without consuming it (calling it again returns + * the same text); `consume` releases it once the guest holds a copy. A host + * whose marshalling failed simply does not consume — the batch is rendered + * again next tick. pnet_http_poll = render + consume. */ +const char *pnet_http_poll_render(pnet_runtime *rt, size_t *len); +void pnet_http_poll_consume(pnet_runtime *rt); const char *pnet_http_last_error(pnet_runtime *rt); int pnet_http_read_into(pnet_runtime *rt, int handle, uint8_t *dst, size_t len); const char *pnet_http_limits(pnet_runtime *rt); @@ -170,6 +180,8 @@ int pnet_ws_close(pnet_runtime *rt, int handle, int code, const char *reason, si void pnet_ws_terminate(pnet_runtime *rt, int handle); int pnet_ws_buffered_amount(pnet_runtime *rt, int handle); const char *pnet_ws_poll(pnet_runtime *rt, size_t *len); +const char *pnet_ws_poll_render(pnet_runtime *rt, size_t *len); +void pnet_ws_poll_consume(pnet_runtime *rt); const char *pnet_ws_last_error(pnet_runtime *rt); const char *pnet_ws_limits(pnet_runtime *rt); @@ -183,6 +195,8 @@ int pnet_httpd_end_body(pnet_runtime *rt, int req); int pnet_httpd_read_into(pnet_runtime *rt, int req, uint8_t *dst, size_t len); void pnet_httpd_abort(pnet_runtime *rt, int req); const char *pnet_httpd_poll(pnet_runtime *rt, size_t *len); +const char *pnet_httpd_poll_render(pnet_runtime *rt, size_t *len); +void pnet_httpd_poll_consume(pnet_runtime *rt); const char *pnet_httpd_last_error(pnet_runtime *rt); const char *pnet_httpd_limits(pnet_runtime *rt); diff --git a/engine/net/src/pnet_http_client.c b/engine/net/src/pnet_http_client.c index 25f3283e..4d8a6267 100644 --- a/engine/net/src/pnet_http_client.c +++ b/engine/net/src/pnet_http_client.c @@ -254,7 +254,8 @@ static bool push_headers_event(pnet_runtime *rt, pnet_http_req *r, const pnet_h1 * request failed). false = treat as a normal response. */ static bool maybe_redirect(pnet_runtime *rt, pnet_http_req *r, const pnet_h1_head *head) { int st = head->status; - if (st != 301 && st != 302 && st != 303 && st != 307 && st != 308) return false; + bool to_get = false; + if (!pnet_http_redirect_plan(st, r->method, r->method_len, &to_get)) return false; const pnet_h1_field *loc = pnet_h1_find(head, "location"); if (!loc) return false; if (r->redirect_mode == 1) return false; /* manual: deliver as-is */ @@ -287,11 +288,7 @@ static bool maybe_redirect(pnet_runtime *rt, pnet_http_req *r, const pnet_h1_hea req_fail(rt, r, PNET_ERROR_PERMISSION_DENIED, "redirect target is not an allowed endpoint", 0); return true; } - /* Method / body rewriting on redirect: 303 (non-HEAD) and 301/302 POST - * become GET, dropping the request body. */ - bool to_get = false; - if (st == 303 && !pnet_ieq_n(r->method, r->method_len, "HEAD")) to_get = true; - if ((st == 301 || st == 302) && pnet_ieq_n(r->method, r->method_len, "POST")) to_get = true; + /* Method / body rewriting on redirect (pnet_http_redirect_plan). */ if (to_get) { pnet_free(rt, r->method, r->method_len + 1); r->method = pnet_strdup_n(rt, "GET", 3); @@ -377,7 +374,7 @@ static bool on_head(pnet_runtime *rt, pnet_http_req *r, pnet_h1_head *head) { } if (maybe_redirect(rt, r, head)) return false; /* Body framing (RFC 9112 §6.3). */ - bool head_only = pnet_ieq_n(r->method, r->method_len, "HEAD") || head->status == 204 || head->status == 304; + bool head_only = pnet_ieq_n(r->method, r->method_len, "HEAD") || pnet_status_is_bodyless(head->status); int64_t length = -1; pnet_h1_body_mode mode; if (head_only) mode = PNET_H1_BODY_NONE; @@ -724,7 +721,6 @@ int pnet_http_start(pnet_runtime *rt, const char *meta_json, const uint8_t *body for (size_t i = 0; i < PNET_METHODS_FORBIDDEN_COUNT; i++) { if (pnet_ieq_n(buf, blen, forbidden[i])) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "method not allowed"); goto out; } } - if (pnet_ieq_n(buf, blen, "TRACK")) { refuse(rt, PNET_ERROR_INVALID_REQUEST, "method not allowed"); goto out; } r->method = pnet_strdup_n(rt, buf, blen); r->method_len = blen; if (!r->method) { refuse(rt, PNET_ERROR_RESOURCE_LIMIT, "out of memory"); goto out; } @@ -748,10 +744,9 @@ int pnet_http_start(pnet_runtime *rt, const char *meta_json, const uint8_t *body goto out; } pnet_lower(name, nlen); - static const char *const owned[] = {"host", "connection", "content-length", "transfer-encoding", "trailer", - "te", "upgrade", "keep-alive", "expect", "proxy-connection"}; + static const char *const owned[] = PNET_HTTP_CORE_OWNED_REQUEST_HEADERS; bool skip = false; - for (size_t i = 0; i < sizeof owned / sizeof owned[0]; i++) + for (size_t i = 0; i < PNET_HTTP_CORE_OWNED_REQUEST_HEADERS_COUNT; i++) if (strcmp(name, owned[i]) == 0) skip = true; int vnode = doc.nodes[k].first_child; size_t vlen; @@ -921,6 +916,14 @@ const char *pnet_http_poll(pnet_runtime *rt, size_t *len) { return pnet_queue_poll(rt, &rt->http_queue, len); } +const char *pnet_http_poll_render(pnet_runtime *rt, size_t *len) { + return pnet_queue_render(rt, &rt->http_queue, len); +} + +void pnet_http_poll_consume(pnet_runtime *rt) { + pnet_queue_consume(rt, &rt->http_queue); +} + const char *pnet_http_last_error(pnet_runtime *rt) { return pnet_sb_cstr(&rt->http_last_error); } diff --git a/engine/net/src/pnet_http_server.c b/engine/net/src/pnet_http_server.c index d2ba7141..8a339ba9 100644 --- a/engine/net/src/pnet_http_server.c +++ b/engine/net/src/pnet_http_server.c @@ -1058,7 +1058,9 @@ int pnet_httpd_respond(pnet_runtime *rt, int req, const char *meta_json, const u if (pnet_json_type(&doc, endnode) != PNET_J_BOOL) goto out; end = doc.nodes[endnode].truthy; } - bool no_body_status = status == 204 || status == 304 || (status >= 100 && status < 200); + /* A null-body status (Fetch: 101/103/204/205/304) never carries content; + * 1xx cannot be sent through respond at all (status >= 200 above). */ + bool no_body_status = pnet_status_is_null_body((int)status); if (no_body_status && (body_len > 0 || !end)) goto out; int64_t content_length = -1; int cl = pnet_json_get(&doc, root, "contentLength"); @@ -1209,6 +1211,14 @@ const char *pnet_httpd_poll(pnet_runtime *rt, size_t *len) { return pnet_queue_poll(rt, &rt->httpd_queue, len); } +const char *pnet_httpd_poll_render(pnet_runtime *rt, size_t *len) { + return pnet_queue_render(rt, &rt->httpd_queue, len); +} + +void pnet_httpd_poll_consume(pnet_runtime *rt) { + pnet_queue_consume(rt, &rt->httpd_queue); +} + const char *pnet_httpd_last_error(pnet_runtime *rt) { return pnet_sb_cstr(&rt->httpd_last_error); } diff --git a/engine/net/src/pnet_internal.h b/engine/net/src/pnet_internal.h index 5400ebee..893b155e 100644 --- a/engine/net/src/pnet_internal.h +++ b/engine/net/src/pnet_internal.h @@ -114,6 +114,15 @@ bool pnet_parse_ip_literal(const char *s, size_t len, pnet_addr *out); /** Format an address (without port) into `out` (>= 46 bytes). */ void pnet_format_addr(const pnet_addr *addr, char *out, size_t cap); /** Loopback / link-local / private / multicast / unspecified classification. */ +bool pnet_hostname_valid(const char *s, size_t len); +/** Shared HTTP status semantics (spec.h): membership, RFC 9112 bodyless + * framing (1xx/204/304), Fetch null-body statuses. */ +bool pnet_status_in(int status, const int *list, size_t count); +bool pnet_status_is_bodyless(int status); +bool pnet_status_is_null_body(int status); +/** Redirect plan for `status`: false = not a followed redirect; true with + * *to_get = the method becomes GET (body dropped) per the spec table. */ +bool pnet_http_redirect_plan(int status, const char *method, size_t method_len, bool *to_get); bool pnet_addr_is_public(const pnet_addr *addr); bool pnet_addr_is_multicast(const pnet_addr *addr); @@ -133,7 +142,8 @@ typedef enum pnet_jtype { typedef struct pnet_jnode { uint8_t type; bool truthy; /* for bool */ - const char *raw; /* string body (without quotes, still escaped) / number text / key */ + const char *raw; /* string body (without quotes, still escaped) / number text / key; + for objects and arrays the whole source span `{…}` / `[…]` */ size_t raw_len; int first_child; /* array element / object member (member = key node with one child) */ int next; /* next sibling */ @@ -354,6 +364,12 @@ typedef struct pnet_queue { pnet_event *visible_tail; size_t visible_count; pnet_sb poll_buf; + /** A batch is rendered in poll_buf and not yet consumed (two-phase poll); + * rendered_count = the visible events it covers. */ + bool rendered; + size_t rendered_count; + /** Logged once per out-of-memory episode. */ + bool starved; uint32_t max_events; size_t max_bytes; } pnet_queue; @@ -369,7 +385,16 @@ bool pnet_queue_push_readable(pnet_runtime *rt, pnet_queue *q, int handle, const size_t avail); /** Move pending events into the visible set under the budget. */ void pnet_queue_freeze(pnet_runtime *rt, pnet_queue *q); -/** Render and consume the visible set; NULL when empty. */ +/** Render the visible set into the queue's buffer WITHOUT consuming it (NULL + * when empty or when the batch cannot be allocated right now — the events + * stay visible and the next render retries). Calling it again before + * consume returns the same batch. */ +const char *pnet_queue_render(pnet_runtime *rt, pnet_queue *q, size_t *len); +/** Consume the rendered batch: dequeue and free exactly the events it + * carries. No-op without a rendered batch. */ +void pnet_queue_consume(pnet_runtime *rt, pnet_queue *q); +/** render + consume: the single-call poll. The text stays valid until the + * next render. Transactional: an allocation failure consumes nothing. */ const char *pnet_queue_poll(pnet_runtime *rt, pnet_queue *q, size_t *len); /** Drop every event of a handle (guest cancel of a terminal-less handle). */ void pnet_queue_drop_handle(pnet_runtime *rt, pnet_queue *q, int handle); diff --git a/engine/net/src/pnet_json.c b/engine/net/src/pnet_json.c index 5ec46b6b..4c678b20 100644 --- a/engine/net/src/pnet_json.c +++ b/engine/net/src/pnet_json.c @@ -148,9 +148,16 @@ static int parse_value(jparser *p) { if (++p->depth > 32) return -1; int result = -1; char c = p->s[p->pos]; - if (c == '{') result = parse_object(p); - else if (c == '[') result = parse_array(p); - else if (c == '"') { + if (c == '{' || c == '[') { + /* Containers record their source span so a caller can hand a + * sub-document (a nested policy, a vector) to another parser verbatim. */ + size_t start = p->pos; + result = c == '{' ? parse_object(p) : parse_array(p); + if (result >= 0) { + p->doc->nodes[result].raw = p->s + start; + p->doc->nodes[result].raw_len = p->pos - start; + } + } else if (c == '"') { const char *raw; size_t raw_len; if (parse_string_raw(p, &raw, &raw_len)) { diff --git a/engine/net/src/pnet_policy.c b/engine/net/src/pnet_policy.c index a64028d8..a036114e 100644 --- a/engine/net/src/pnet_policy.c +++ b/engine/net/src/pnet_policy.c @@ -1,9 +1,16 @@ -/* Immutable network policy: the Build Plan projection the host hands to the - * runtime at creation. Endpoint tuples - * are matched before DNS, each candidate address after DNS, and listen - * tuples before bind. The guest can never widen it. */ +/* Immutable network policy: the canonical ResolvedNetworkPolicy JSON of the + * application's Build Plan (contracts/spec/network-policy.ts, version 1), + * handed to the runtime at creation by the host — which derives it from the + * plan (HostBuildInputs.network.policyJson) and never authors one. Endpoint + * tuples are matched before DNS, each candidate address after DNS, and + * listen tuples before bind; redirects re-run the endpoint check. The guest + * can never widen it. The parser accepts exactly the shapes the TypeScript + * reference produces; contracts/spec/vectors/network-policy.json pins the + * parse and match decisions shared with the Rust core. */ #include "pnet_internal.h" +#define PNET_POLICY_VERSION 1 + static const char *PROTO_NAMES[PNET_PROTO_COUNT] = {"http", "https", "ws", "wss"}; pnet_proto pnet_proto_from_scheme(const char *scheme) { @@ -31,14 +38,31 @@ static bool parse_rule(pnet_runtime *rt, const pnet_jdoc *doc, int obj, bool lis int host = pnet_json_get(doc, obj, listen ? "address" : "host"); size_t host_len; if (!pnet_json_string(doc, host, buf, sizeof buf, &host_len) || host_len == 0) return false; + for (size_t i = 0; i < host_len; i++) { + unsigned char ch = (unsigned char)buf[i]; + if (ch <= 0x20 || ch >= 0x7f) return false; /* ASCII (A-label) names only */ + } pnet_lower(buf, host_len); if (buf[host_len - 1] == '.' && host_len > 1) buf[--host_len] = 0; if (pnet_parse_ip_literal(buf, host_len, &rule->ip)) { rule->is_ip = true; + /* Canonical storage: the literal's text form is irrelevant, matching + * compares the binary address. */ } else if (listen) { return false; /* listen addresses are IP literals */ - } else if (host_len > 2 && buf[0] == '*' && buf[1] == '.') { - rule->wildcard = true; + } else { + const char *name = buf; + size_t name_len = host_len; + if (buf[0] == '*') { + /* `*.suffix`: exactly one label; a bare `*` or `*.` is refused. */ + if (host_len < 3 || buf[1] != '.') return false; + rule->wildcard = true; + name = buf + 2; + name_len = host_len - 2; + pnet_addr tmp; + if (pnet_parse_ip_literal(name, name_len, &tmp)) return false; + } + if (!pnet_hostname_valid(name, name_len)) return false; } rule->host = pnet_strdup_n(rt, buf, host_len); if (!rule->host) return false; @@ -99,6 +123,13 @@ bool pnet_policy_parse(pnet_runtime *rt, pnet_policy *policy, const char *json) pnet_jdoc doc; int root = pnet_json_parse(&doc, nodes, cap, json, len); bool ok = root >= 0 && pnet_json_type(&doc, root) == PNET_J_OBJECT; + if (ok) { + /* `version` is the contract version of the document; absent means 1 + * (host-authored test policies), anything else is a different contract. */ + int ver = pnet_json_get(&doc, root, "version"); + int64_t v; + if (ver >= 0 && (!pnet_json_i64(&doc, ver, &v) || v != PNET_POLICY_VERSION)) ok = false; + } if (ok) ok = parse_rules(rt, &doc, pnet_json_get(&doc, root, "connect"), false, &policy->connect, &policy->connect_count); if (ok) ok = parse_rules(rt, &doc, pnet_json_get(&doc, root, "listen"), true, &policy->listen, &policy->listen_count); if (ok) { diff --git a/engine/net/src/pnet_runtime.c b/engine/net/src/pnet_runtime.c index efb04ea2..70cf1624 100644 --- a/engine/net/src/pnet_runtime.c +++ b/engine/net/src/pnet_runtime.c @@ -313,6 +313,8 @@ void pnet_queue_free(pnet_runtime *rt, pnet_queue *q) { q->pending_head = q->pending_tail = NULL; q->visible_head = q->visible_tail = NULL; q->pending_count = q->visible_count = 0; + q->rendered = false; + q->rendered_count = 0; } static void pending_append(pnet_queue *q, pnet_event *e) { @@ -398,37 +400,86 @@ void pnet_queue_freeze(pnet_runtime *rt, pnet_queue *q) { } } -const char *pnet_queue_poll(pnet_runtime *rt, pnet_queue *q, size_t *len) { +const char *pnet_queue_render(pnet_runtime *rt, pnet_queue *q, size_t *len) { + if (q->rendered) { + /* A batch rendered but not yet consumed (two-phase host): hand it out + * again unchanged; the visible set is untouched. */ + if (len) *len = q->poll_buf.len; + return pnet_sb_cstr(&q->poll_buf); + } if (!q->visible_head) { if (len) *len = 0; return NULL; } + /* Transactional: size the batch — '[' + json joined by ',' + ']' — and + * reserve it up front. Nothing is dequeued until the buffer is certain, so + * memory pressure can delay a batch (the next poll retries) but can never + * drop a visible event, least of all a terminal one. */ + size_t need = 2; + size_t count = 0; + for (pnet_event *e = q->visible_head; e; e = e->next) { + need += e->json_len; + count++; + } + need += count - 1; pnet_sb_clear(&q->poll_buf); + if (q->poll_buf.cap < need + 1) { + /* Release the undersized buffer before growing: the contents are stale + * and keeping both halves alive is what pushes a tight heap over. */ + pnet_sb_free(rt, &q->poll_buf); + } + if (!pnet_sb_reserve(rt, &q->poll_buf, need)) { + if (!q->starved) { + q->starved = true; + pnet_logf(rt, PNET_LOG_WARN, "pnet: poll batch of %zu bytes deferred (out of memory); events stay visible", need); + } + if (len) *len = 0; + return NULL; + } + q->starved = false; pnet_sb_putc(rt, &q->poll_buf, '['); bool first = true; - while (q->visible_head) { - pnet_event *e = q->visible_head; - q->visible_head = e->next; - if (!q->visible_head) q->visible_tail = NULL; - q->visible_count--; + for (pnet_event *e = q->visible_head; e; e = e->next) { if (!first) pnet_sb_putc(rt, &q->poll_buf, ','); first = false; pnet_sb_append(rt, &q->poll_buf, e->json, e->json_len); - event_free(rt, e); } pnet_sb_putc(rt, &q->poll_buf, ']'); + /* Reserved exactly: rendering cannot have failed. */ if (q->poll_buf.failed) { - /* Out of memory while rendering: report an empty batch; the events are - * gone, but every handle keeps its own terminal accounting so the guest - * learns about it through the next terminal event or its own timeout. */ - pnet_logf(rt, PNET_LOG_ERROR, "pnet: poll batch allocation failed"); + pnet_logf(rt, PNET_LOG_ERROR, "pnet: poll batch render failed after reservation"); + pnet_sb_clear(&q->poll_buf); if (len) *len = 0; return NULL; } + q->rendered = true; + q->rendered_count = count; if (len) *len = q->poll_buf.len; return pnet_sb_cstr(&q->poll_buf); } +void pnet_queue_consume(pnet_runtime *rt, pnet_queue *q) { + if (!q->rendered) return; + /* Exactly the events the rendered batch carries; anything a later freeze + * appended behind them stays visible for the next render. */ + while (q->rendered_count > 0 && q->visible_head) { + pnet_event *e = q->visible_head; + q->visible_head = e->next; + if (!q->visible_head) q->visible_tail = NULL; + q->visible_count--; + q->rendered_count--; + event_free(rt, e); + } + q->rendered = false; + q->rendered_count = 0; +} + +const char *pnet_queue_poll(pnet_runtime *rt, pnet_queue *q, size_t *len) { + const char *batch = pnet_queue_render(rt, q, len); + if (batch) pnet_queue_consume(rt, q); + return batch; /* the rendered text stays valid until the next render */ +} + void pnet_queue_drop_handle(pnet_runtime *rt, pnet_queue *q, int handle) { pnet_event **pp = &q->pending_head; pnet_event *prev = NULL; diff --git a/engine/net/src/pnet_util.c b/engine/net/src/pnet_util.c index 37fc8f56..9204be5e 100644 --- a/engine/net/src/pnet_util.c +++ b/engine/net/src/pnet_util.c @@ -463,6 +463,9 @@ bool pnet_parse_ipv4(const char *s, size_t len, uint8_t out[4]) { digits++; } if (digits == 0 || digits > 3) return false; + /* No leading zeros: "010" is octal to some resolvers and decimal to + * others, so it is not a literal here (nor a valid hostname). */ + if (digits > 1 && s[i - digits] == '0') return false; out[part] = (uint8_t)v; if (part < 3) { if (i >= len || s[i] != '.') return false; @@ -593,6 +596,68 @@ void pnet_format_addr(const pnet_addr *addr, char *out, size_t cap) { else out[cap - 1] = 0; } +bool pnet_status_in(int status, const int *list, size_t count) { + for (size_t i = 0; i < count; i++) + if (list[i] == status) return true; + return false; +} + +bool pnet_status_is_bodyless(int status) { + /* RFC 9112 §6.3 rule 1: 1xx, 204 and 304 carry no body whatever the + * framing headers say (PNET_HTTP_BODYLESS_STATUS + the 1xx range). */ + if (status >= 100 && status < 200) return true; + return pnet_status_in(status, (const int[])PNET_HTTP_BODYLESS_STATUS, PNET_HTTP_BODYLESS_STATUS_COUNT); +} + +bool pnet_http_redirect_plan(int status, const char *method, size_t method_len, bool *to_get) { + /* The shared redirect table (spec.h): which statuses a client follows and + * how the method is rewritten — 303 turns every method but HEAD into a + * GET without a body, 301/302 turn POST into GET, 307/308 keep both. */ + *to_get = false; + if (!pnet_status_in(status, (const int[])PNET_HTTP_REDIRECT_STATUS, PNET_HTTP_REDIRECT_STATUS_COUNT)) return false; + if (pnet_status_in(status, (const int[])PNET_HTTP_REDIRECT_ANY_TO_GET_STATUS, PNET_HTTP_REDIRECT_ANY_TO_GET_STATUS_COUNT) && + !pnet_ieq_n(method, method_len, "HEAD")) + *to_get = true; + if (pnet_status_in(status, (const int[])PNET_HTTP_REDIRECT_POST_TO_GET_STATUS, PNET_HTTP_REDIRECT_POST_TO_GET_STATUS_COUNT) && + pnet_ieq_n(method, method_len, "POST")) + *to_get = true; + return true; +} + +bool pnet_status_is_null_body(int status) { + /* Fetch null-body statuses: a response that may not carry content. */ + return pnet_status_in(status, (const int[])PNET_HTTP_NULL_BODY_STATUS, PNET_HTTP_NULL_BODY_STATUS_COUNT); +} + +bool pnet_hostname_valid(const char *s, size_t len) { + /* Lowercase ASCII DNS name: labels of [a-z0-9-], 1..63 bytes, not starting + * or ending with '-', whole name <= 253 bytes. Mirrors + * normalizeNetworkHostname() in contracts/spec/network-policy.ts. */ + if (len == 0 || len > 253) return false; + /* A name whose last label is all digits is a (malformed) IPv4 literal, + * never a DNS name (WHATWG URL "ends in a number"). */ + size_t last = len; + while (last > 0 && s[last - 1] != '.') last--; + bool numeric = last < len; + for (size_t i = last; i < len; i++) + if (s[i] < '0' || s[i] > '9') numeric = false; + if (numeric) return false; + size_t label = 0; + for (size_t i = 0; i <= len; i++) { + if (i == len || s[i] == '.') { + if (label == 0 || label > 63) return false; + if (s[i - 1] == '-' || s[i - label] == '-') return false; + label = 0; + continue; + } + char c = s[i]; + bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'; + if (!ok) return false; + label++; + } + return true; +} + bool pnet_addr_is_multicast(const pnet_addr *addr) { if (addr->family == 4) return (addr->addr[0] & 0xf0) == 0xe0; return addr->addr[0] == 0xff; diff --git a/engine/net/src/pnet_ws.c b/engine/net/src/pnet_ws.c index 46f51f0e..114953c4 100644 --- a/engine/net/src/pnet_ws.c +++ b/engine/net/src/pnet_ws.c @@ -1103,6 +1103,14 @@ const char *pnet_ws_poll(pnet_runtime *rt, size_t *len) { return pnet_queue_poll(rt, &rt->ws_queue, len); } +const char *pnet_ws_poll_render(pnet_runtime *rt, size_t *len) { + return pnet_queue_render(rt, &rt->ws_queue, len); +} + +void pnet_ws_poll_consume(pnet_runtime *rt) { + pnet_queue_consume(rt, &rt->ws_queue); +} + const char *pnet_ws_last_error(pnet_runtime *rt) { return pnet_sb_cstr(&rt->ws_last_error); } diff --git a/engine/net/test/host_test.c b/engine/net/test/host_test.c index ca0daf9b..a40fc7c7 100644 --- a/engine/net/test/host_test.c +++ b/engine/net/test/host_test.c @@ -597,6 +597,55 @@ static int extract_int(const char *json, const char *key) { return atoi(p + strlen(key)); } +/* The resolver path: a hostname goes through getaddrinfo on the driver's + * resolver worker, the worker wakes the network task, dispatch hands the + * candidates to the dialer, and the exchange completes over the first + * permitted address. An unresolvable name reports `dns` without touching a + * socket — and while that lookup is in flight the network task keeps + * serving another exchange (a literal-address request completes even + * though a resolve is pending). */ +static void test_resolver(harness *h, peer *p) { + char log[16384]; + char body[1024]; + log[0] = 0; + char meta[512]; + snprintf(meta, sizeof meta, "{\"url\":\"http://localhost:%u/hello\",\"method\":\"GET\",\"headers\":{}}", p->port); + pthread_mutex_lock(&h->lock); + int handle = pnet_http_start(h->rt, meta, NULL, 0); + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + CHECK(handle > 0); + CHECK(wait_for(h, pnet_http_poll, "\"t\":\"headers\"", 4000, log, sizeof log)); + size_t n = read_body(h, handle, body, sizeof body, 2000); + CHECK(n == 5 && memcmp(body, "hello", 5) == 0); + + /* NXDOMAIN: `dns`, no socket. */ + log[0] = 0; + pthread_mutex_lock(&h->lock); + int bad = pnet_http_start(h->rt, "{\"url\":\"http://nope.invalid/x\",\"method\":\"GET\",\"headers\":{}}", NULL, 0); + /* Concurrently a literal-address request must not wait for the lookup. */ + char meta2[256]; + snprintf(meta2, sizeof meta2, "{\"url\":\"http://127.0.0.1:%u/hello\",\"method\":\"GET\",\"headers\":{}}", p->port); + int literal = pnet_http_start(h->rt, meta2, NULL, 0); + pthread_mutex_unlock(&h->lock); + pnet_posix_driver_wake(h->driver); + CHECK(bad > 0 && literal > 0); + char needle[64]; + snprintf(needle, sizeof needle, "\"t\":\"headers\",\"h\":%d", literal); + CHECK(wait_for(h, pnet_http_poll, needle, 4000, log, sizeof log)); + /* The lookup's failure may already sit in the accumulated log (the + * batches are shared), otherwise keep ticking for it. */ + snprintf(needle, sizeof needle, "\"h\":%d,\"code\":\"dns\"", bad); + bool got_dns = strstr(log, needle) != NULL || wait_for(h, pnet_http_poll, needle, 10000, log, sizeof log); + if (!got_dns) fprintf(stderr, "resolver log:\n%s\n", log); + CHECK(got_dns); + n = read_body(h, literal, body, sizeof body, 2000); + CHECK(n == 5); + pthread_mutex_lock(&h->lock); + CHECK(!pnet_runtime_has_live_handles(h->rt)); + pthread_mutex_unlock(&h->lock); +} + static void test_server(harness *h) { char log[16384]; char resp[65536]; @@ -1186,7 +1235,9 @@ int main(void) { char policy[512]; snprintf(policy, sizeof policy, "{\"connect\":[{\"protocol\":\"http\",\"host\":\"127.0.0.1\",\"port\":{\"min\":1,\"max\":65535}}," - "{\"protocol\":\"ws\",\"host\":\"127.0.0.1\",\"port\":{\"min\":1,\"max\":65535}}]," + "{\"protocol\":\"ws\",\"host\":\"127.0.0.1\",\"port\":{\"min\":1,\"max\":65535}}," + "{\"protocol\":\"http\",\"host\":\"localhost\",\"port\":{\"min\":1,\"max\":65535}}," + "{\"protocol\":\"http\",\"host\":\"*.invalid\",\"port\":{\"min\":1,\"max\":65535}}]," "\"listen\":[{\"protocol\":\"http\",\"address\":\"127.0.0.1\",\"port\":\"ephemeral\"}]," "\"insecureTransport\":true,\"localNetwork\":true}"); harness h; @@ -1194,6 +1245,7 @@ int main(void) { CHECK(h.rt != NULL); if (h.rt) { test_client(&h, &p); + test_resolver(&h, &p); test_server(&h); test_websocket(&h, &wp); } diff --git a/engine/net/test/unit_test.c b/engine/net/test/unit_test.c index 5cf2c452..4470b6e0 100644 --- a/engine/net/test/unit_test.c +++ b/engine/net/test/unit_test.c @@ -270,6 +270,161 @@ static void test_policy(pnet_runtime *rt) { CHECK(make_runtime("not json") == NULL); } +/* --- shared policy vectors -------------------------------------------------- */ + +/* contracts/spec/vectors/network-policy.json: the same documents and + * decisions the TypeScript reference and the Rust core run. The path comes + * from CMake (PNET_VECTORS_DIR). */ +#ifndef PNET_VECTORS_DIR +#define PNET_VECTORS_DIR "../../contracts/spec/vectors" +#endif + +static char *read_file(const char *path, size_t *len) { + FILE *f = fopen(path, "rb"); + if (!f) return NULL; + fseek(f, 0, SEEK_END); + long n = ftell(f); + fseek(f, 0, SEEK_SET); + char *buf = malloc((size_t)n + 1); + if (!buf) { fclose(f); return NULL; } + if (fread(buf, 1, (size_t)n, f) != (size_t)n) { fclose(f); free(buf); return NULL; } + fclose(f); + buf[n] = 0; + *len = (size_t)n; + return buf; +} + +static pnet_runtime *vector_runtime(const pnet_jdoc *doc, int policies, const char *name) { + int node = pnet_json_get(doc, policies, name); + if (node < 0) return NULL; + size_t len = doc->nodes[node].raw_len; + char *text = malloc(len + 1); + memcpy(text, doc->nodes[node].raw, len); + text[len] = 0; + pnet_runtime *rt = make_runtime(text); + free(text); + return rt; +} + +static void test_policy_vectors(void) { + size_t len = 0; + char *text = read_file(PNET_VECTORS_DIR "/network-policy.json", &len); + CHECK(text != NULL); + if (!text) return; + enum { CAP = 4096 }; + pnet_jnode *nodes = malloc(sizeof(pnet_jnode) * CAP); + pnet_jdoc doc; + int root = pnet_json_parse(&doc, nodes, CAP, text, len); + CHECK(root >= 0); + if (root < 0) { free(nodes); free(text); return; } + int policies = pnet_json_get(&doc, root, "policies"); + CHECK(policies >= 0); + + /* Every named policy parses. */ + for (int k = doc.nodes[policies].first_child; k >= 0; k = doc.nodes[k].next) { + char name[64]; + size_t nl; + pnet_json_string(&doc, k, name, sizeof name, &nl); + pnet_runtime *rt = vector_runtime(&doc, policies, name); + if (!rt) fprintf(stderr, "vector policy %s did not parse\n", name); + CHECK(rt != NULL); + if (rt) pnet_runtime_destroy(rt); + } + + /* Invalid documents are refused. */ + int invalid = pnet_json_get(&doc, root, "invalid"); + for (int e = pnet_json_first(&doc, invalid); e >= 0; e = pnet_json_next(&doc, e)) { + char name[96]; + pnet_json_string(&doc, pnet_json_get(&doc, e, "name"), name, sizeof name, NULL); + int pol = pnet_json_get(&doc, e, "policy"); + size_t plen = doc.nodes[pol].raw_len; + char *ptext = malloc(plen + 1); + memcpy(ptext, doc.nodes[pol].raw, plen); + ptext[plen] = 0; + pnet_runtime *rt = make_runtime(ptext); + if (rt) fprintf(stderr, "invalid vector accepted: %s\n", name); + CHECK(rt == NULL); + if (rt) pnet_runtime_destroy(rt); + free(ptext); + } + + /* Connect decisions. */ + int connect = pnet_json_get(&doc, root, "connect"); + for (int e = pnet_json_first(&doc, connect); e >= 0; e = pnet_json_next(&doc, e)) { + char pname[64], proto[8], host[256]; + int64_t port; + pnet_json_string(&doc, pnet_json_get(&doc, e, "policy"), pname, sizeof pname, NULL); + pnet_json_string(&doc, pnet_json_get(&doc, e, "protocol"), proto, sizeof proto, NULL); + pnet_json_string(&doc, pnet_json_get(&doc, e, "host"), host, sizeof host, NULL); + pnet_json_i64(&doc, pnet_json_get(&doc, e, "port"), &port); + int allowed_node = pnet_json_get(&doc, e, "allowed"); + bool allowed = doc.nodes[allowed_node].truthy; + pnet_runtime *rt = vector_runtime(&doc, policies, pname); + CHECK(rt != NULL); + if (!rt) continue; + /* The core sees URL hosts the way pnet_url hands them over: lowercase, + * brackets stripped, trailing dot removed. */ + char norm[256]; + size_t hl = strlen(host); + const char *h = host; + if (hl >= 2 && host[0] == '[' && host[hl - 1] == ']') { h = host + 1; hl -= 2; } + memcpy(norm, h, hl); + norm[hl] = 0; + pnet_lower(norm, hl); + if (hl > 1 && norm[hl - 1] == '.') norm[--hl] = 0; + bool got = pnet_policy_allows_connect(&rt->policy, pnet_proto_from_scheme(proto), norm, (uint16_t)port); + if (got != allowed) fprintf(stderr, "connect vector mismatch: %s %s %s %lld -> %d\n", pname, proto, host, (long long)port, got); + CHECK(got == allowed); + pnet_runtime_destroy(rt); + } + + /* Address classification + the localNetwork gate. */ + pnet_runtime *open = vector_runtime(&doc, policies, "standard"); + pnet_runtime *closed = vector_runtime(&doc, policies, "secure-only"); + CHECK(open && closed); + int address = pnet_json_get(&doc, root, "address"); + for (int e = pnet_json_first(&doc, address); e >= 0 && open && closed; e = pnet_json_next(&doc, e)) { + char lit[64]; + pnet_json_string(&doc, pnet_json_get(&doc, e, "address"), lit, sizeof lit, NULL); + bool is_public = doc.nodes[pnet_json_get(&doc, e, "public")].truthy; + bool is_multicast = doc.nodes[pnet_json_get(&doc, e, "multicast")].truthy; + pnet_addr a; + bool parsed = pnet_parse_ip_literal(lit, strlen(lit), &a); + CHECK(parsed); + if (!parsed) continue; + if (pnet_addr_is_public(&a) != is_public) fprintf(stderr, "address vector public mismatch: %s\n", lit); + CHECK(pnet_addr_is_public(&a) == is_public); + CHECK(pnet_addr_is_multicast(&a) == is_multicast); + CHECK(pnet_policy_allows_address(&closed->policy, &a) == is_public); + CHECK(pnet_policy_allows_address(&open->policy, &a) == !is_multicast); + } + if (open) pnet_runtime_destroy(open); + if (closed) pnet_runtime_destroy(closed); + + /* Listen decisions. */ + int listen = pnet_json_get(&doc, root, "listen"); + for (int e = pnet_json_first(&doc, listen); e >= 0; e = pnet_json_next(&doc, e)) { + char pname[64], proto[8], lit[64]; + int64_t port; + pnet_json_string(&doc, pnet_json_get(&doc, e, "policy"), pname, sizeof pname, NULL); + pnet_json_string(&doc, pnet_json_get(&doc, e, "protocol"), proto, sizeof proto, NULL); + pnet_json_string(&doc, pnet_json_get(&doc, e, "address"), lit, sizeof lit, NULL); + pnet_json_i64(&doc, pnet_json_get(&doc, e, "port"), &port); + bool allowed = doc.nodes[pnet_json_get(&doc, e, "allowed")].truthy; + pnet_runtime *rt = vector_runtime(&doc, policies, pname); + CHECK(rt != NULL); + if (!rt) continue; + pnet_addr a; + CHECK(pnet_parse_ip_literal(lit, strlen(lit), &a)); + bool got = pnet_policy_allows_listen(&rt->policy, pnet_proto_from_scheme(proto), &a, (uint16_t)port); + if (got != allowed) fprintf(stderr, "listen vector mismatch: %s %s %s %lld -> %d\n", pname, proto, lit, (long long)port, got); + CHECK(got == allowed); + pnet_runtime_destroy(rt); + } + free(nodes); + free(text); +} + /* --- JSON --------------------------------------------------------------- */ static void test_json(pnet_runtime *rt) { @@ -396,6 +551,52 @@ static void test_queue(pnet_runtime *rt) { pnet_queue_drop_handle(rt, &q, 3); CHECK(q.pending_count == 0); pnet_queue_free(rt, &q); + + /* Transactional poll: with the heap capped at its current usage the batch + * cannot be allocated — nothing is consumed, the terminal event survives, + * and the next poll (memory back) delivers the whole batch. */ + pnet_queue tq; + pnet_queue_init(&tq, 64, 65536); + char *e1 = pnet_event_json(rt, "headers", "h", 7, ",\"status\":200", 13, &len); + CHECK(pnet_queue_push(rt, &tq, 7, false, 10, e1, len)); + CHECK(pnet_queue_push_readable(rt, &tq, 7, "h", 5)); + char *e2 = pnet_event_json(rt, "end", "h", 7, NULL, 0, &len); + CHECK(pnet_queue_push(rt, &tq, 7, true, 0, e2, len)); + pnet_queue_freeze(rt, &tq); + CHECK(tq.visible_count == 3); + size_t saved_cap = rt->cfg.max_heap_bytes; + rt->cfg.max_heap_bytes = pnet_runtime_heap_bytes(rt); + CHECK(pnet_queue_poll(rt, &tq, &len) == NULL); + CHECK(tq.visible_count == 3); /* nothing consumed */ + CHECK(pnet_queue_poll(rt, &tq, &len) == NULL); + CHECK(tq.visible_count == 3); + rt->cfg.max_heap_bytes = saved_cap; + batch = pnet_queue_poll(rt, &tq, &len); + CHECK(batch != NULL); + CHECK(batch && strstr(batch, "\"t\":\"end\",\"h\":7") != NULL); + CHECK(batch && strstr(batch, "\"t\":\"readable\",\"h\":7") != NULL); + CHECK(tq.visible_count == 0); + CHECK(pnet_queue_poll(rt, &tq, &len) == NULL); + + /* Two-phase poll: render is idempotent until consume; a freeze in between + * keeps its new events visible for the next render. */ + char *e3 = pnet_event_json(rt, "headers", "h", 8, NULL, 0, &len); + CHECK(pnet_queue_push(rt, &tq, 8, false, 1, e3, len)); + pnet_queue_freeze(rt, &tq); + const char *r1 = pnet_queue_render(rt, &tq, &len); + CHECK(r1 && strstr(r1, "\"h\":8") != NULL); + const char *r2 = pnet_queue_render(rt, &tq, &len); + CHECK(r2 == r1 && tq.visible_count == 1); + char *e4 = pnet_event_json(rt, "end", "h", 8, NULL, 0, &len); + CHECK(pnet_queue_push(rt, &tq, 8, true, 0, e4, len)); + pnet_queue_freeze(rt, &tq); /* appended behind the rendered batch */ + CHECK(tq.visible_count == 2); + pnet_queue_consume(rt, &tq); + CHECK(tq.visible_count == 1); + batch = pnet_queue_poll(rt, &tq, &len); + CHECK(batch && strstr(batch, "\"t\":\"end\",\"h\":8") != NULL && strstr(batch, "headers") == NULL); + pnet_queue_consume(rt, &tq); /* no-op without a rendered batch */ + pnet_queue_free(rt, &tq); } /* --- HTTP client refusals (no I/O) -------------------------------------- */ @@ -441,6 +642,96 @@ static void test_http_refusals(pnet_runtime *rt) { CHECK(strstr(pnet_http_limits(rt), "\"specMajor\":2") != NULL); } +/* --- shared HTTP semantics vectors ------------------------------------------ */ + +static void test_http_semantics_vectors(void) { + size_t len = 0; + char *text = read_file(PNET_VECTORS_DIR "/http-semantics.json", &len); + CHECK(text != NULL); + if (!text) return; + enum { CAP = 2048 }; + pnet_jnode *nodes = malloc(sizeof(pnet_jnode) * CAP); + pnet_jdoc doc; + int root = pnet_json_parse(&doc, nodes, CAP, text, len); + CHECK(root >= 0); + if (root < 0) { free(nodes); free(text); return; } + pnet_runtime *rt = make_runtime("{\"connect\":[{\"protocol\":\"http\",\"host\":\"192.168.1.20\",\"port\":8080}],\"insecureTransport\":true,\"localNetwork\":true}"); + CHECK(rt != NULL); + if (!rt) { free(nodes); free(text); return; } + + /* Methods: start() accepts or refuses the token. */ + int methods = pnet_json_get(&doc, root, "methods"); + for (int e = pnet_json_first(&doc, methods); e >= 0; e = pnet_json_next(&doc, e)) { + char method[64]; + pnet_json_string(&doc, pnet_json_get(&doc, e, "method"), method, sizeof method, NULL); + bool accepted = doc.nodes[pnet_json_get(&doc, e, "accepted")].truthy; + char meta[256]; + /* Escape is unnecessary: the vectors' method tokens contain no quotes. */ + snprintf(meta, sizeof meta, "{\"url\":\"http://192.168.1.20:8080/\",\"method\":\"%s\",\"headers\":{}}", method); + int h = pnet_http_start(rt, meta, NULL, 0); + if ((h > 0) != accepted) fprintf(stderr, "method vector mismatch: %s -> %d\n", method, h); + CHECK((h > 0) == accepted); + if (h > 0) pnet_http_cancel(rt, h); + pnet_runtime_service(rt); + pnet_runtime_begin_tick(rt); + pnet_http_poll(rt, &len); + } + + /* Status classification. */ + int status = pnet_json_get(&doc, root, "status"); + for (int e = pnet_json_first(&doc, status); e >= 0; e = pnet_json_next(&doc, e)) { + int64_t st; + pnet_json_i64(&doc, pnet_json_get(&doc, e, "status"), &st); + bool framing = doc.nodes[pnet_json_get(&doc, e, "bodylessFraming")].truthy; + bool null_body = doc.nodes[pnet_json_get(&doc, e, "nullBody")].truthy; + CHECK(pnet_status_is_bodyless((int)st) == framing); + CHECK(pnet_status_is_null_body((int)st) == null_body); + } + + /* Redirect plan. */ + int redirect = pnet_json_get(&doc, root, "redirect"); + for (int e = pnet_json_first(&doc, redirect); e >= 0; e = pnet_json_next(&doc, e)) { + int64_t st; + char method[16], next[16] = {0}; + pnet_json_i64(&doc, pnet_json_get(&doc, e, "status"), &st); + pnet_json_string(&doc, pnet_json_get(&doc, e, "method"), method, sizeof method, NULL); + bool followed = doc.nodes[pnet_json_get(&doc, e, "followed")].truthy; + bool to_get = false; + bool got = pnet_http_redirect_plan((int)st, method, strlen(method), &to_get); + CHECK(got == followed); + if (followed) { + pnet_json_string(&doc, pnet_json_get(&doc, e, "nextMethod"), next, sizeof next, NULL); + bool keep_body = doc.nodes[pnet_json_get(&doc, e, "keepBody")].truthy; + const char *expect_method = to_get ? "GET" : method; + if (strcmp(expect_method, next) != 0 || keep_body == to_get) + fprintf(stderr, "redirect vector mismatch: %lld %s -> %s keepBody=%d\n", (long long)st, method, next, keep_body); + CHECK(strcmp(expect_method, next) == 0); + CHECK(keep_body == !to_get); + } + } + + /* Core-owned request headers are stripped, others pass. The request is + * serialized into the connection tx queue on start; the stub driver never + * connects, so the head sits in the queue where we can read it back. */ + int headers = pnet_json_get(&doc, root, "requestHeaders"); + for (int e = pnet_json_first(&doc, headers); e >= 0; e = pnet_json_next(&doc, e)) { + char name[64]; + pnet_json_string(&doc, pnet_json_get(&doc, e, "name"), name, sizeof name, NULL); + bool owned = doc.nodes[pnet_json_get(&doc, e, "coreOwned")].truthy; + char lower[64]; + strcpy(lower, name); + pnet_lower(lower, strlen(lower)); + static const char *const list[] = PNET_HTTP_CORE_OWNED_REQUEST_HEADERS; + bool in_list = false; + for (size_t i = 0; i < PNET_HTTP_CORE_OWNED_REQUEST_HEADERS_COUNT; i++) + if (strcmp(lower, list[i]) == 0) in_list = true; + CHECK(in_list == owned); + } + pnet_runtime_destroy(rt); + free(nodes); + free(text); +} + int main(void) { pnet_runtime *rt = make_runtime(POLICY); CHECK(rt != NULL); @@ -449,6 +740,8 @@ int main(void) { test_h1_body(); test_url(rt); test_policy(rt); + test_policy_vectors(); + test_http_semantics_vectors(); test_json(rt); test_codecs(); test_queue(rt);