diff --git a/contracts/schema/pocket-3.json b/contracts/schema/pocket-3.json new file mode 100644 index 00000000..5be74ac6 --- /dev/null +++ b/contracts/schema/pocket-3.json @@ -0,0 +1,387 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pocketjs.dev/schema/pocket-3.json", + "title": "Pocket application manifest, format 3", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "pocket", + "id", + "name", + "title", + "version", + "engine", + "app" + ], + "properties": { + "$schema": { + "const": "https://pocketjs.dev/schema/pocket-3.json" + }, + "pocket": { + "const": 3 + }, + "id": { + "type": "string", + "minLength": 3, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "version": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$" + }, + "execution": { + "type": "object", + "additionalProperties": false, + "required": [ + "classes" + ], + "properties": { + "classes": { + "type": "array", + "items": { + "enum": [ + "guest", + "aot" + ] + }, + "minItems": 1, + "uniqueItems": true + } + } + }, + "engine": { + "type": "object", + "additionalProperties": false, + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "type": "object", + "additionalProperties": false, + "required": [ + "requires" + ], + "properties": { + "requires": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$" + }, + "minItems": 1, + "uniqueItems": true + }, + "enhances": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$" + }, + "uniqueItems": true + } + } + } + } + }, + "app": { + "type": "object", + "additionalProperties": false, + "required": [ + "entry", + "framework", + "viewport" + ], + "properties": { + "entry": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+\\.tsx?$" + }, + "output": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$" + }, + "framework": { + "enum": [ + "solid", + "vue-vapor", + "octane" + ] + }, + "companions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$" + }, + "uniqueItems": true + }, + "viewport": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "logical", + "presentation" + ], + "properties": { + "logical": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "minItems": 2, + "maxItems": 2 + }, + "presentation": { + "enum": [ + "fill", + "fit", + "integer-fit", + "native", + "stretch" + ] + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "fixed": { + "type": "object", + "additionalProperties": false, + "required": [ + "logical", + "presentation" + ], + "properties": { + "logical": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "minItems": 2, + "maxItems": 2 + }, + "presentation": { + "enum": [ + "fill", + "fit", + "integer-fit", + "native", + "stretch" + ] + } + } + }, + "dynamic": { + "type": "object", + "additionalProperties": false, + "required": [ + "default" + ], + "properties": { + "default": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "minItems": 2, + "maxItems": 2 + }, + "min": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "minItems": 2, + "maxItems": 2 + }, + "max": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "minItems": 2, + "maxItems": 2 + } + } + } + } + } + ] + } + } + }, + "permissions": { + "type": "object", + "additionalProperties": false, + "properties": { + "network": { + "type": "object", + "additionalProperties": false, + "properties": { + "connect": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", + "host", + "port" + ], + "properties": { + "protocol": { + "enum": [ + "http", + "https", + "ws", + "wss" + ] + }, + "host": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "port": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "min", + "max" + ], + "properties": { + "min": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "max": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + } + } + ] + } + } + } + }, + "listen": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", + "address", + "port" + ], + "properties": { + "protocol": { + "enum": [ + "http", + "https", + "ws", + "wss" + ] + }, + "address": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "port": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "min", + "max" + ], + "properties": { + "min": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "max": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + } + }, + { + "const": "ephemeral" + } + ] + } + } + } + }, + "credentials": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$" + }, + "uniqueItems": true + }, + "localNetwork": { + "type": "boolean" + }, + "insecureTransport": { + "type": "boolean" + }, + "allowInvalidTlsForDevelopment": { + "type": "boolean" + } + } + } + } + } + } +} diff --git a/contracts/spec/gen-c.ts b/contracts/spec/gen-c.ts new file mode 100644 index 00000000..60efc695 --- /dev/null +++ b/contracts/spec/gen-c.ts @@ -0,0 +1,251 @@ +// Deterministic codegen: contracts/spec/{net,ws,httpd}.ts -> +// engine/net/include/pocketjs/net/spec.h — the C mirror of the network +// module boundaries consumed by the portable C core (engine/net) and every C +// host that mounts `globalThis.net` / `ws` / `httpd`. +// +// Run from PocketJS/: bun contracts/spec/gen-c.ts (or `bun run gen`) +// +// tests/contract.ts imports generateC() and byte-compares its output against +// the committed header, so the generated file can never drift from the spec. +// Keep this generator deterministic (insertion order only, no dates/env). + +import { + HTTPD_DEFAULT_BODY_IDLE_MS, + HTTPD_DEFAULT_CLOSE_MS, + HTTPD_DEFAULT_HANDLER_MS, + HTTPD_DEFAULT_HEADER_MS, + HTTPD_DEFAULT_KEEP_ALIVE_MS, + HTTPD_DEFAULT_REQUEST_QUEUE_BYTES, + HTTPD_EVENT, + HTTPD_MAX_BACKLOG, + HTTPD_MAX_CONNECTIONS, + HTTPD_MAX_EVENTS_PER_TICK, + HTTPD_MAX_HEADERS, + HTTPD_MAX_HEADER_BYTES, + HTTPD_MAX_INFLIGHT, + HTTPD_MAX_REQUEST_QUEUE_BYTES, + HTTPD_MAX_SEND_QUEUE_BYTES, + HTTPD_MAX_SERVERS, + HTTPD_MAX_TARGET_BYTES, + HTTPD_MAX_TICK_BYTES, + HTTPD_MAX_TIMEOUT_MS, + HTTPD_OP, + HTTPD_SEND_ACCEPTED, + HTTPD_SEND_BACKPRESSURE, + HTTPD_SEND_HIGH_WATER_BYTES, + HTTPD_SEND_INVALID, + HTTPD_SEND_INVALID_REQUEST, + HTTPD_SEND_LOW_WATER_BYTES, + HTTPD_SPEC_MAJOR, + HTTPD_SPEC_MINOR, +} from "./httpd.ts"; +import { + HTTP_BODYLESS_STATUS, + HTTP_CORE_OWNED_REQUEST_HEADERS, + HTTP_NULL_BODY_STATUS, + HTTP_REDIRECT_ANY_TO_GET_STATUS, + HTTP_REDIRECT_POST_TO_GET_STATUS, + HTTP_REDIRECT_STATUS, + NET_DEFAULT_AGGREGATE_BYTES, + NET_DEFAULT_QUEUE_BYTES, + NET_DEFAULT_TIMEOUT_MS, + NET_ERROR, + NET_EVENT, + NET_MAX_AGGREGATE_BYTES, + NET_MAX_EVENTS_PER_TICK, + NET_MAX_HEADER_BYTES, + NET_MAX_HEADERS, + NET_MAX_INFLIGHT, + NET_MAX_QUEUE_BYTES, + NET_MAX_REDIRECTS, + NET_MAX_REQUEST_BYTES, + NET_MAX_TICK_BYTES, + NET_MAX_TIMEOUT_MS, + NET_METHODS_FORBIDDEN, + NET_OP, + NET_SPEC_MAJOR, + NET_SPEC_MINOR, + NET_TLS_MIN_VERSION, +} from "./net.ts"; +import { + WS_BLOB_KEY, + WS_CONTROL_PAYLOAD_MAX, + WS_DEFAULT_CLOSE_MS, + WS_DEFAULT_CONNECT_MS, + WS_EVENT, + WS_FORBIDDEN_HEADERS, + WS_MAX_CONNECT_MS, + WS_MAX_EVENTS_PER_TICK, + WS_MAX_HANDSHAKE_HEADERS, + WS_MAX_HANDSHAKE_HEADER_BYTES, + WS_MAX_MESSAGE_BYTES, + WS_MAX_RECEIVE_QUEUE_BYTES, + WS_MAX_RECEIVE_QUEUE_MESSAGES, + WS_MAX_SEND_QUEUE_BYTES, + WS_MAX_SOCKETS, + WS_MAX_TICK_BYTES, + WS_OP, + WS_OPCODE, + WS_SEND_ACCEPTED, + WS_SEND_ACCEPTED_HIGH_WATER, + WS_SEND_BACKPRESSURE, + WS_SEND_CLOSED, + WS_SEND_HIGH_WATER_BYTES, + WS_SEND_INVALID, + WS_SEND_LOW_WATER_BYTES, + WS_SPEC_MAJOR, + WS_SPEC_MINOR, +} from "./ws.ts"; + +/** camelCase -> SCREAMING_SNAKE_CASE. */ +function screaming(name: string): string { + return name.replace(/([A-Z])/g, "_$1").toUpperCase(); +} + +function cstr(s: string): string { + return JSON.stringify(s); +} + +export function generateC(): string { + const L: string[] = []; + const put = (s: string) => L.push(s); + + put("/* GENERATED — do not edit; run `bun contracts/spec/gen-c.ts`. */"); + put("/* C mirror of contracts/spec/{net,ws,httpd}.ts: the guest boundaries of the"); + put(" * network modules (`globalThis.net` / `ws` / `httpd`). Every value here is a"); + put(" * portable ceiling or a wire-visible constant; a host's limits() may only"); + put(" * tighten the ceilings. tests/contract.ts byte-compares this file. */"); + put("#ifndef POCKETJS_NET_SPEC_H"); + put("#define POCKETJS_NET_SPEC_H"); + put(""); + + // --- net ------------------------------------------------------------------- + put("/* --- net: HTTP Client (`globalThis.net`) --- */"); + put(`#define PNET_SPEC_MAJOR ${NET_SPEC_MAJOR}`); + put(`#define PNET_SPEC_MINOR ${NET_SPEC_MINOR}`); + for (const [name, v] of Object.entries(NET_OP)) { + put(`#define PNET_OP_${screaming(name)} ${v}`); + } + put(`#define PNET_MAX_INFLIGHT ${NET_MAX_INFLIGHT}`); + put(`#define PNET_MAX_REQUEST_BYTES ${NET_MAX_REQUEST_BYTES}`); + put(`#define PNET_DEFAULT_QUEUE_BYTES ${NET_DEFAULT_QUEUE_BYTES}`); + put(`#define PNET_MAX_QUEUE_BYTES ${NET_MAX_QUEUE_BYTES}`); + put(`#define PNET_DEFAULT_AGGREGATE_BYTES ${NET_DEFAULT_AGGREGATE_BYTES}`); + put(`#define PNET_MAX_AGGREGATE_BYTES ${NET_MAX_AGGREGATE_BYTES}`); + put(`#define PNET_MAX_EVENTS_PER_TICK ${NET_MAX_EVENTS_PER_TICK}`); + put(`#define PNET_MAX_TICK_BYTES ${NET_MAX_TICK_BYTES}`); + put(`#define PNET_MAX_HEADERS ${NET_MAX_HEADERS}`); + put(`#define PNET_MAX_HEADER_BYTES ${NET_MAX_HEADER_BYTES}`); + put(`#define PNET_DEFAULT_TIMEOUT_MS ${NET_DEFAULT_TIMEOUT_MS}`); + put(`#define PNET_MAX_TIMEOUT_MS ${NET_MAX_TIMEOUT_MS}`); + put(`#define PNET_MAX_REDIRECTS ${NET_MAX_REDIRECTS}`); + put(`#define PNET_TLS_MIN_VERSION ${cstr(NET_TLS_MIN_VERSION)}`); + put(`#define PNET_METHODS_FORBIDDEN_COUNT ${NET_METHODS_FORBIDDEN.length}`); + put( + `#define PNET_METHODS_FORBIDDEN { ${NET_METHODS_FORBIDDEN.map(cstr).join(", ")} }`, + ); + put("/* HTTP semantics shared by client, server and SDK (see net.ts). */"); + put(`#define PNET_HTTP_CORE_OWNED_REQUEST_HEADERS_COUNT ${HTTP_CORE_OWNED_REQUEST_HEADERS.length}`); + put(`#define PNET_HTTP_CORE_OWNED_REQUEST_HEADERS { ${HTTP_CORE_OWNED_REQUEST_HEADERS.map(cstr).join(", ")} }`); + put(`#define PNET_HTTP_BODYLESS_STATUS_COUNT ${HTTP_BODYLESS_STATUS.length}`); + put(`#define PNET_HTTP_BODYLESS_STATUS { ${HTTP_BODYLESS_STATUS.join(", ")} }`); + put(`#define PNET_HTTP_NULL_BODY_STATUS_COUNT ${HTTP_NULL_BODY_STATUS.length}`); + put(`#define PNET_HTTP_NULL_BODY_STATUS { ${HTTP_NULL_BODY_STATUS.join(", ")} }`); + put(`#define PNET_HTTP_REDIRECT_STATUS_COUNT ${HTTP_REDIRECT_STATUS.length}`); + put(`#define PNET_HTTP_REDIRECT_STATUS { ${HTTP_REDIRECT_STATUS.join(", ")} }`); + put(`#define PNET_HTTP_REDIRECT_POST_TO_GET_STATUS_COUNT ${HTTP_REDIRECT_POST_TO_GET_STATUS.length}`); + put(`#define PNET_HTTP_REDIRECT_POST_TO_GET_STATUS { ${HTTP_REDIRECT_POST_TO_GET_STATUS.join(", ")} }`); + put(`#define PNET_HTTP_REDIRECT_ANY_TO_GET_STATUS_COUNT ${HTTP_REDIRECT_ANY_TO_GET_STATUS.length}`); + put(`#define PNET_HTTP_REDIRECT_ANY_TO_GET_STATUS { ${HTTP_REDIRECT_ANY_TO_GET_STATUS.join(", ")} }`); + for (const [name, v] of Object.entries(NET_EVENT)) { + put(`#define PNET_EVENT_${screaming(name)} ${cstr(v)}`); + } + put("/* Error vocabulary shared by net, ws and httpd. */"); + for (const [name, v] of Object.entries(NET_ERROR)) { + put(`#define PNET_ERROR_${screaming(name)} ${cstr(v)}`); + } + put(""); + + // --- ws -------------------------------------------------------------------- + put("/* --- ws: WebSocket Client (`globalThis.ws`) --- */"); + put(`#define PWS_SPEC_MAJOR ${WS_SPEC_MAJOR}`); + put(`#define PWS_SPEC_MINOR ${WS_SPEC_MINOR}`); + for (const [name, v] of Object.entries(WS_OP)) { + put(`#define PWS_OP_${screaming(name)} ${v}`); + } + put(`#define PWS_SEND_ACCEPTED ${WS_SEND_ACCEPTED}`); + put(`#define PWS_SEND_ACCEPTED_HIGH_WATER ${WS_SEND_ACCEPTED_HIGH_WATER}`); + put(`#define PWS_SEND_CLOSED (${WS_SEND_CLOSED})`); + put(`#define PWS_SEND_BACKPRESSURE (${WS_SEND_BACKPRESSURE})`); + put(`#define PWS_SEND_INVALID (${WS_SEND_INVALID})`); + for (const [name, v] of Object.entries(WS_OPCODE)) { + put(`#define PWS_OPCODE_${screaming(name)} ${v}`); + } + for (const [name, v] of Object.entries(WS_EVENT)) { + put(`#define PWS_EVENT_${screaming(name)} ${cstr(v)}`); + } + put(`#define PWS_BLOB_KEY ${cstr(WS_BLOB_KEY)}`); + put(`#define PWS_FORBIDDEN_HEADERS_COUNT ${WS_FORBIDDEN_HEADERS.length}`); + put(`#define PWS_FORBIDDEN_HEADERS { ${WS_FORBIDDEN_HEADERS.map(cstr).join(", ")} }`); + put(`#define PWS_MAX_SOCKETS ${WS_MAX_SOCKETS}`); + put(`#define PWS_MAX_MESSAGE_BYTES ${WS_MAX_MESSAGE_BYTES}`); + put(`#define PWS_MAX_RECEIVE_QUEUE_BYTES ${WS_MAX_RECEIVE_QUEUE_BYTES}`); + put(`#define PWS_MAX_RECEIVE_QUEUE_MESSAGES ${WS_MAX_RECEIVE_QUEUE_MESSAGES}`); + put(`#define PWS_MAX_SEND_QUEUE_BYTES ${WS_MAX_SEND_QUEUE_BYTES}`); + put(`#define PWS_SEND_HIGH_WATER_BYTES ${WS_SEND_HIGH_WATER_BYTES}`); + put(`#define PWS_SEND_LOW_WATER_BYTES ${WS_SEND_LOW_WATER_BYTES}`); + put(`#define PWS_MAX_HANDSHAKE_HEADERS ${WS_MAX_HANDSHAKE_HEADERS}`); + put(`#define PWS_MAX_HANDSHAKE_HEADER_BYTES ${WS_MAX_HANDSHAKE_HEADER_BYTES}`); + put(`#define PWS_MAX_EVENTS_PER_TICK ${WS_MAX_EVENTS_PER_TICK}`); + put(`#define PWS_MAX_TICK_BYTES ${WS_MAX_TICK_BYTES}`); + put(`#define PWS_DEFAULT_CONNECT_MS ${WS_DEFAULT_CONNECT_MS}`); + put(`#define PWS_MAX_CONNECT_MS ${WS_MAX_CONNECT_MS}`); + put(`#define PWS_DEFAULT_CLOSE_MS ${WS_DEFAULT_CLOSE_MS}`); + put(`#define PWS_CONTROL_PAYLOAD_MAX ${WS_CONTROL_PAYLOAD_MAX}`); + put(""); + + // --- httpd ----------------------------------------------------------------- + put("/* --- httpd: HTTP Server (`globalThis.httpd`) --- */"); + put(`#define PHTTPD_SPEC_MAJOR ${HTTPD_SPEC_MAJOR}`); + put(`#define PHTTPD_SPEC_MINOR ${HTTPD_SPEC_MINOR}`); + for (const [name, v] of Object.entries(HTTPD_OP)) { + put(`#define PHTTPD_OP_${screaming(name)} ${v}`); + } + put(`#define PHTTPD_SEND_ACCEPTED ${HTTPD_SEND_ACCEPTED}`); + put(`#define PHTTPD_SEND_INVALID_REQUEST (${HTTPD_SEND_INVALID_REQUEST})`); + put(`#define PHTTPD_SEND_BACKPRESSURE (${HTTPD_SEND_BACKPRESSURE})`); + put(`#define PHTTPD_SEND_INVALID (${HTTPD_SEND_INVALID})`); + for (const [name, v] of Object.entries(HTTPD_EVENT)) { + put(`#define PHTTPD_EVENT_${screaming(name)} ${cstr(v)}`); + } + put(`#define PHTTPD_MAX_SERVERS ${HTTPD_MAX_SERVERS}`); + put(`#define PHTTPD_MAX_CONNECTIONS ${HTTPD_MAX_CONNECTIONS}`); + put(`#define PHTTPD_MAX_INFLIGHT ${HTTPD_MAX_INFLIGHT}`); + put(`#define PHTTPD_MAX_BACKLOG ${HTTPD_MAX_BACKLOG}`); + put(`#define PHTTPD_MAX_HEADERS ${HTTPD_MAX_HEADERS}`); + put(`#define PHTTPD_MAX_HEADER_BYTES ${HTTPD_MAX_HEADER_BYTES}`); + put(`#define PHTTPD_MAX_TARGET_BYTES ${HTTPD_MAX_TARGET_BYTES}`); + put(`#define PHTTPD_DEFAULT_REQUEST_QUEUE_BYTES ${HTTPD_DEFAULT_REQUEST_QUEUE_BYTES}`); + put(`#define PHTTPD_MAX_REQUEST_QUEUE_BYTES ${HTTPD_MAX_REQUEST_QUEUE_BYTES}`); + put(`#define PHTTPD_MAX_SEND_QUEUE_BYTES ${HTTPD_MAX_SEND_QUEUE_BYTES}`); + put(`#define PHTTPD_SEND_HIGH_WATER_BYTES ${HTTPD_SEND_HIGH_WATER_BYTES}`); + put(`#define PHTTPD_SEND_LOW_WATER_BYTES ${HTTPD_SEND_LOW_WATER_BYTES}`); + put(`#define PHTTPD_MAX_EVENTS_PER_TICK ${HTTPD_MAX_EVENTS_PER_TICK}`); + put(`#define PHTTPD_MAX_TICK_BYTES ${HTTPD_MAX_TICK_BYTES}`); + put(`#define PHTTPD_DEFAULT_HEADER_MS ${HTTPD_DEFAULT_HEADER_MS}`); + put(`#define PHTTPD_DEFAULT_BODY_IDLE_MS ${HTTPD_DEFAULT_BODY_IDLE_MS}`); + put(`#define PHTTPD_DEFAULT_HANDLER_MS ${HTTPD_DEFAULT_HANDLER_MS}`); + put(`#define PHTTPD_DEFAULT_KEEP_ALIVE_MS ${HTTPD_DEFAULT_KEEP_ALIVE_MS}`); + put(`#define PHTTPD_DEFAULT_CLOSE_MS ${HTTPD_DEFAULT_CLOSE_MS}`); + put(`#define PHTTPD_MAX_TIMEOUT_MS ${HTTPD_MAX_TIMEOUT_MS}`); + put(""); + put("#endif /* POCKETJS_NET_SPEC_H */"); + + return L.join("\n") + "\n"; +} + +if (import.meta.main) { + const out = new URL("../../engine/net/include/pocketjs/net/spec.h", import.meta.url).pathname; + await Bun.write(out, generateC()); + console.log(`wrote ${out}`); +} diff --git a/contracts/spec/gen-rust.ts b/contracts/spec/gen-rust.ts index 3fd2935c..116e7f16 100644 --- a/contracts/spec/gen-rust.ts +++ b/contracts/spec/gen-rust.ts @@ -1,4 +1,4 @@ -// Deterministic codegen: contracts/spec/{spec,audio,db,net}.ts -> engine/core/src/spec.rs. +// Deterministic codegen: contracts/spec/{spec,audio,db,fs,net,ws,httpd}.ts -> engine/core/src/spec.rs. // // Run from PocketJS/: bun contracts/spec/gen-rust.ts // @@ -36,20 +36,92 @@ import { FS_WRITE_TRUNCATE, } from "./fs.ts"; import { - NET_DEFAULT_RESPONSE_BYTES, + HTTPD_DEFAULT_BODY_IDLE_MS, + HTTPD_DEFAULT_CLOSE_MS, + HTTPD_DEFAULT_HANDLER_MS, + HTTPD_DEFAULT_HEADER_MS, + HTTPD_DEFAULT_KEEP_ALIVE_MS, + HTTPD_DEFAULT_REQUEST_QUEUE_BYTES, + HTTPD_EVENT, + HTTPD_MAX_BACKLOG, + HTTPD_MAX_CONNECTIONS, + HTTPD_MAX_EVENTS_PER_TICK, + HTTPD_MAX_HEADERS, + HTTPD_MAX_HEADER_BYTES, + HTTPD_MAX_INFLIGHT, + HTTPD_MAX_REQUEST_QUEUE_BYTES, + HTTPD_MAX_SEND_QUEUE_BYTES, + HTTPD_MAX_SERVERS, + HTTPD_MAX_TARGET_BYTES, + HTTPD_MAX_TICK_BYTES, + HTTPD_MAX_TIMEOUT_MS, + HTTPD_OP, + HTTPD_SEND_ACCEPTED, + HTTPD_SEND_BACKPRESSURE, + HTTPD_SEND_HIGH_WATER_BYTES, + HTTPD_SEND_INVALID, + HTTPD_SEND_INVALID_REQUEST, + HTTPD_SEND_LOW_WATER_BYTES, + HTTPD_SPEC_MAJOR, + HTTPD_SPEC_MINOR, +} from "./httpd.ts"; +import { + NET_DEFAULT_AGGREGATE_BYTES, + NET_DEFAULT_QUEUE_BYTES, NET_DEFAULT_TIMEOUT_MS, NET_ERROR, NET_EVENT, + NET_MAX_AGGREGATE_BYTES, + NET_MAX_EVENTS_PER_TICK, NET_MAX_HEADER_BYTES, NET_MAX_HEADERS, NET_MAX_INFLIGHT, + NET_MAX_QUEUE_BYTES, NET_MAX_REDIRECTS, NET_MAX_REQUEST_BYTES, - NET_MAX_RESPONSE_BYTES, + NET_MAX_TICK_BYTES, NET_MAX_TIMEOUT_MS, - NET_METHODS, + HTTP_BODYLESS_STATUS, + HTTP_CORE_OWNED_REQUEST_HEADERS, + HTTP_NULL_BODY_STATUS, + HTTP_REDIRECT_ANY_TO_GET_STATUS, + HTTP_REDIRECT_POST_TO_GET_STATUS, + HTTP_REDIRECT_STATUS, + NET_METHODS_FORBIDDEN, NET_OP, + NET_SPEC_MAJOR, + NET_SPEC_MINOR, + NET_TLS_MIN_VERSION, } from "./net.ts"; +import { + WS_BLOB_KEY, + WS_CONTROL_PAYLOAD_MAX, + WS_DEFAULT_CLOSE_MS, + WS_DEFAULT_CONNECT_MS, + WS_EVENT, + WS_FORBIDDEN_HEADERS, + WS_MAX_CONNECT_MS, + WS_MAX_EVENTS_PER_TICK, + WS_MAX_HANDSHAKE_HEADERS, + WS_MAX_HANDSHAKE_HEADER_BYTES, + WS_MAX_MESSAGE_BYTES, + WS_MAX_RECEIVE_QUEUE_BYTES, + WS_MAX_RECEIVE_QUEUE_MESSAGES, + WS_MAX_SEND_QUEUE_BYTES, + WS_MAX_SOCKETS, + WS_MAX_TICK_BYTES, + WS_OP, + WS_OPCODE, + WS_SEND_ACCEPTED, + WS_SEND_ACCEPTED_HIGH_WATER, + WS_SEND_BACKPRESSURE, + WS_SEND_CLOSED, + WS_SEND_HIGH_WATER_BYTES, + WS_SEND_INVALID, + WS_SEND_LOW_WATER_BYTES, + WS_SPEC_MAJOR, + WS_SPEC_MINOR, +} from "./ws.ts"; import { ANALOG_CENTER, ANIMATABLE, @@ -552,30 +624,124 @@ export function generateRust(): string { put("}"); put(""); - // --- net module --------------------------------------------------------------- - put("/// NET module boundary (contracts/spec/net.ts — `globalThis.net`)."); - put("/// Bounded whole-response HTTP; completions batch to tick boundaries."); + // --- net module (HTTP Client) ------------------------------------------------ + put("/// NET module boundary (contracts/spec/net.ts — `globalThis.net`, spec v2)."); + put("/// Streaming HTTP/1.1 client; completions batch to tick boundaries."); put("pub mod net {"); + put(` pub const SPEC_MAJOR: u32 = ${NET_SPEC_MAJOR};`); + put(` pub const SPEC_MINOR: u32 = ${NET_SPEC_MINOR};`); for (const [name, v] of Object.entries(NET_OP)) { put(` pub const OP_${screaming(name)}: u8 = ${v};`); } put(` pub const MAX_INFLIGHT: usize = ${NET_MAX_INFLIGHT};`); put(` pub const MAX_REQUEST_BYTES: usize = ${NET_MAX_REQUEST_BYTES};`); - put(` pub const DEFAULT_RESPONSE_BYTES: usize = ${NET_DEFAULT_RESPONSE_BYTES};`); - put(` pub const MAX_RESPONSE_BYTES: usize = ${NET_MAX_RESPONSE_BYTES};`); + put(` pub const DEFAULT_QUEUE_BYTES: usize = ${NET_DEFAULT_QUEUE_BYTES};`); + put(` pub const MAX_QUEUE_BYTES: usize = ${NET_MAX_QUEUE_BYTES};`); + put(` pub const DEFAULT_AGGREGATE_BYTES: usize = ${NET_DEFAULT_AGGREGATE_BYTES};`); + put(` pub const MAX_AGGREGATE_BYTES: usize = ${NET_MAX_AGGREGATE_BYTES};`); + put(` pub const MAX_EVENTS_PER_TICK: usize = ${NET_MAX_EVENTS_PER_TICK};`); + put(` pub const MAX_TICK_BYTES: usize = ${NET_MAX_TICK_BYTES};`); put(` pub const MAX_HEADERS: usize = ${NET_MAX_HEADERS};`); put(` pub const MAX_HEADER_BYTES: usize = ${NET_MAX_HEADER_BYTES};`); put(` pub const DEFAULT_TIMEOUT_MS: u32 = ${NET_DEFAULT_TIMEOUT_MS};`); put(` pub const MAX_TIMEOUT_MS: u32 = ${NET_MAX_TIMEOUT_MS};`); put(` pub const MAX_REDIRECTS: usize = ${NET_MAX_REDIRECTS};`); - put(` pub const METHODS: [&str; ${NET_METHODS.length}] = [${NET_METHODS.map((method) => JSON.stringify(method)).join(", ")}];`); + put(` pub const TLS_MIN_VERSION: &str = ${JSON.stringify(NET_TLS_MIN_VERSION)};`); + put(` pub const METHODS_FORBIDDEN: [&str; ${NET_METHODS_FORBIDDEN.length}] = [${NET_METHODS_FORBIDDEN.map((method) => JSON.stringify(method)).join(", ")}];`); + put(" /// HTTP semantics shared by client, server and SDK (see net.ts)."); + put(` pub const HTTP_CORE_OWNED_REQUEST_HEADERS: [&str; ${HTTP_CORE_OWNED_REQUEST_HEADERS.length}] = [${HTTP_CORE_OWNED_REQUEST_HEADERS.map((name) => JSON.stringify(name)).join(", ")}];`); + put(` pub const HTTP_BODYLESS_STATUS: [u16; ${HTTP_BODYLESS_STATUS.length}] = [${HTTP_BODYLESS_STATUS.join(", ")}];`); + put(` pub const HTTP_NULL_BODY_STATUS: [u16; ${HTTP_NULL_BODY_STATUS.length}] = [${HTTP_NULL_BODY_STATUS.join(", ")}];`); + put(` pub const HTTP_REDIRECT_STATUS: [u16; ${HTTP_REDIRECT_STATUS.length}] = [${HTTP_REDIRECT_STATUS.join(", ")}];`); + put(` pub const HTTP_REDIRECT_POST_TO_GET_STATUS: [u16; ${HTTP_REDIRECT_POST_TO_GET_STATUS.length}] = [${HTTP_REDIRECT_POST_TO_GET_STATUS.join(", ")}];`); + put(` pub const HTTP_REDIRECT_ANY_TO_GET_STATUS: [u16; ${HTTP_REDIRECT_ANY_TO_GET_STATUS.length}] = [${HTTP_REDIRECT_ANY_TO_GET_STATUS.join(", ")}];`); for (const [name, v] of Object.entries(NET_EVENT)) { put(` pub const EVENT_${screaming(name)}: &str = ${JSON.stringify(v)};`); } + put(" /// Error vocabulary shared by net, ws and httpd."); for (const [name, v] of Object.entries(NET_ERROR)) { put(` pub const ERROR_${screaming(name)}: &str = ${JSON.stringify(v)};`); } put("}"); + put(""); + + // --- ws module (WebSocket Client) -------------------------------------------- + put("/// WS module boundary (contracts/spec/ws.ts — `globalThis.ws`, spec v2)."); + put("/// RFC 6455 client; messages batch to tick boundaries."); + put("pub mod ws {"); + put(` pub const SPEC_MAJOR: u32 = ${WS_SPEC_MAJOR};`); + put(` pub const SPEC_MINOR: u32 = ${WS_SPEC_MINOR};`); + for (const [name, v] of Object.entries(WS_OP)) { + put(` pub const OP_${screaming(name)}: u8 = ${v};`); + } + put(` pub const SEND_ACCEPTED: i32 = ${WS_SEND_ACCEPTED};`); + put(` pub const SEND_ACCEPTED_HIGH_WATER: i32 = ${WS_SEND_ACCEPTED_HIGH_WATER};`); + put(` pub const SEND_CLOSED: i32 = ${WS_SEND_CLOSED};`); + put(` pub const SEND_BACKPRESSURE: i32 = ${WS_SEND_BACKPRESSURE};`); + put(` pub const SEND_INVALID: i32 = ${WS_SEND_INVALID};`); + for (const [name, v] of Object.entries(WS_OPCODE)) { + put(` pub const OPCODE_${screaming(name)}: u8 = ${v};`); + } + for (const [name, v] of Object.entries(WS_EVENT)) { + put(` pub const EVENT_${screaming(name)}: &str = ${JSON.stringify(v)};`); + } + put(` pub const BLOB_KEY: &str = ${JSON.stringify(WS_BLOB_KEY)};`); + put(` pub const FORBIDDEN_HEADERS: [&str; ${WS_FORBIDDEN_HEADERS.length}] = [${WS_FORBIDDEN_HEADERS.map((h) => JSON.stringify(h)).join(", ")}];`); + put(` pub const MAX_SOCKETS: usize = ${WS_MAX_SOCKETS};`); + put(` pub const MAX_MESSAGE_BYTES: usize = ${WS_MAX_MESSAGE_BYTES};`); + put(` pub const MAX_RECEIVE_QUEUE_BYTES: usize = ${WS_MAX_RECEIVE_QUEUE_BYTES};`); + put(` pub const MAX_RECEIVE_QUEUE_MESSAGES: usize = ${WS_MAX_RECEIVE_QUEUE_MESSAGES};`); + put(` pub const MAX_SEND_QUEUE_BYTES: usize = ${WS_MAX_SEND_QUEUE_BYTES};`); + put(` pub const SEND_HIGH_WATER_BYTES: usize = ${WS_SEND_HIGH_WATER_BYTES};`); + put(` pub const SEND_LOW_WATER_BYTES: usize = ${WS_SEND_LOW_WATER_BYTES};`); + put(` pub const MAX_HANDSHAKE_HEADERS: usize = ${WS_MAX_HANDSHAKE_HEADERS};`); + put(` pub const MAX_HANDSHAKE_HEADER_BYTES: usize = ${WS_MAX_HANDSHAKE_HEADER_BYTES};`); + put(` pub const MAX_EVENTS_PER_TICK: usize = ${WS_MAX_EVENTS_PER_TICK};`); + put(` pub const MAX_TICK_BYTES: usize = ${WS_MAX_TICK_BYTES};`); + put(` pub const DEFAULT_CONNECT_MS: u32 = ${WS_DEFAULT_CONNECT_MS};`); + put(` pub const MAX_CONNECT_MS: u32 = ${WS_MAX_CONNECT_MS};`); + put(` pub const DEFAULT_CLOSE_MS: u32 = ${WS_DEFAULT_CLOSE_MS};`); + put(` pub const CONTROL_PAYLOAD_MAX: usize = ${WS_CONTROL_PAYLOAD_MAX};`); + put("}"); + put(""); + + // --- httpd module (HTTP Server) ---------------------------------------------- + put("/// HTTPD module boundary (contracts/spec/httpd.ts — `globalThis.httpd`, spec v2)."); + put("/// HTTP/1.1 server; requests batch to tick boundaries."); + put("pub mod httpd {"); + put(` pub const SPEC_MAJOR: u32 = ${HTTPD_SPEC_MAJOR};`); + put(` pub const SPEC_MINOR: u32 = ${HTTPD_SPEC_MINOR};`); + for (const [name, v] of Object.entries(HTTPD_OP)) { + put(` pub const OP_${screaming(name)}: u8 = ${v};`); + } + put(` pub const SEND_ACCEPTED: i32 = ${HTTPD_SEND_ACCEPTED};`); + put(` pub const SEND_INVALID_REQUEST: i32 = ${HTTPD_SEND_INVALID_REQUEST};`); + put(` pub const SEND_BACKPRESSURE: i32 = ${HTTPD_SEND_BACKPRESSURE};`); + put(` pub const SEND_INVALID: i32 = ${HTTPD_SEND_INVALID};`); + for (const [name, v] of Object.entries(HTTPD_EVENT)) { + put(` pub const EVENT_${screaming(name)}: &str = ${JSON.stringify(v)};`); + } + put(` pub const MAX_SERVERS: usize = ${HTTPD_MAX_SERVERS};`); + put(` pub const MAX_CONNECTIONS: usize = ${HTTPD_MAX_CONNECTIONS};`); + put(` pub const MAX_INFLIGHT: usize = ${HTTPD_MAX_INFLIGHT};`); + put(` pub const MAX_BACKLOG: usize = ${HTTPD_MAX_BACKLOG};`); + put(` pub const MAX_HEADERS: usize = ${HTTPD_MAX_HEADERS};`); + put(` pub const MAX_HEADER_BYTES: usize = ${HTTPD_MAX_HEADER_BYTES};`); + put(` pub const MAX_TARGET_BYTES: usize = ${HTTPD_MAX_TARGET_BYTES};`); + put(` pub const DEFAULT_REQUEST_QUEUE_BYTES: usize = ${HTTPD_DEFAULT_REQUEST_QUEUE_BYTES};`); + put(` pub const MAX_REQUEST_QUEUE_BYTES: usize = ${HTTPD_MAX_REQUEST_QUEUE_BYTES};`); + put(` pub const MAX_SEND_QUEUE_BYTES: usize = ${HTTPD_MAX_SEND_QUEUE_BYTES};`); + put(` pub const SEND_HIGH_WATER_BYTES: usize = ${HTTPD_SEND_HIGH_WATER_BYTES};`); + put(` pub const SEND_LOW_WATER_BYTES: usize = ${HTTPD_SEND_LOW_WATER_BYTES};`); + put(` pub const MAX_EVENTS_PER_TICK: usize = ${HTTPD_MAX_EVENTS_PER_TICK};`); + put(` pub const MAX_TICK_BYTES: usize = ${HTTPD_MAX_TICK_BYTES};`); + put(` pub const DEFAULT_HEADER_MS: u32 = ${HTTPD_DEFAULT_HEADER_MS};`); + put(` pub const DEFAULT_BODY_IDLE_MS: u32 = ${HTTPD_DEFAULT_BODY_IDLE_MS};`); + put(` pub const DEFAULT_HANDLER_MS: u32 = ${HTTPD_DEFAULT_HANDLER_MS};`); + put(` pub const DEFAULT_KEEP_ALIVE_MS: u32 = ${HTTPD_DEFAULT_KEEP_ALIVE_MS};`); + put(` pub const DEFAULT_CLOSE_MS: u32 = ${HTTPD_DEFAULT_CLOSE_MS};`); + put(` pub const MAX_TIMEOUT_MS: u32 = ${HTTPD_MAX_TIMEOUT_MS};`); + put("}"); return L.join("\n") + "\n"; } diff --git a/contracts/spec/gen-web.ts b/contracts/spec/gen-web.ts new file mode 100644 index 00000000..d1c1cd05 --- /dev/null +++ b/contracts/spec/gen-web.ts @@ -0,0 +1,73 @@ +// Deterministic codegen: contracts/spec/net.ts -> hosts/web/net-spec.js — the +// plain-ESM mirror of the HTTP Client boundary for the browser dev host. +// hosts/web/*.js is served to the browser as-is (no bundler, no TypeScript), +// so the host cannot import the spec directly; it imports this generated +// module instead and tests/contract.ts byte-compares it. +// +// Run from PocketJS/: bun contracts/spec/gen-web.ts (or `bun run gen`) + +import { + HTTP_CORE_OWNED_REQUEST_HEADERS, + HTTP_NULL_BODY_STATUS, + HTTP_REDIRECT_STATUS, + NET_DEFAULT_AGGREGATE_BYTES, + NET_DEFAULT_QUEUE_BYTES, + NET_DEFAULT_TIMEOUT_MS, + NET_ERROR, + NET_EVENT, + NET_MAX_AGGREGATE_BYTES, + NET_MAX_EVENTS_PER_TICK, + NET_MAX_HEADER_BYTES, + NET_MAX_HEADERS, + NET_MAX_INFLIGHT, + NET_MAX_QUEUE_BYTES, + NET_MAX_REDIRECTS, + NET_MAX_REQUEST_BYTES, + NET_MAX_TICK_BYTES, + NET_MAX_TIMEOUT_MS, + NET_METHODS_FORBIDDEN, + NET_SPEC_MAJOR, + NET_SPEC_MINOR, + NET_TLS_MIN_VERSION, +} from "./net.ts"; + +function js(value: unknown): string { + return JSON.stringify(value); +} + +export function generateWeb(): string { + const L: string[] = []; + const put = (s: string) => L.push(s); + put("// GENERATED — do not edit; run `bun contracts/spec/gen-web.ts`."); + put("// Plain-ESM mirror of contracts/spec/net.ts for the browser dev host"); + put("// (hosts/web/net.js). tests/contract.ts byte-compares this file."); + put(`export const NET_SPEC_MAJOR = ${NET_SPEC_MAJOR};`); + put(`export const NET_SPEC_MINOR = ${NET_SPEC_MINOR};`); + put(`export const NET_MAX_INFLIGHT = ${NET_MAX_INFLIGHT};`); + put(`export const NET_MAX_REQUEST_BYTES = ${NET_MAX_REQUEST_BYTES};`); + put(`export const NET_DEFAULT_QUEUE_BYTES = ${NET_DEFAULT_QUEUE_BYTES};`); + put(`export const NET_MAX_QUEUE_BYTES = ${NET_MAX_QUEUE_BYTES};`); + put(`export const NET_DEFAULT_AGGREGATE_BYTES = ${NET_DEFAULT_AGGREGATE_BYTES};`); + put(`export const NET_MAX_AGGREGATE_BYTES = ${NET_MAX_AGGREGATE_BYTES};`); + put(`export const NET_MAX_EVENTS_PER_TICK = ${NET_MAX_EVENTS_PER_TICK};`); + put(`export const NET_MAX_TICK_BYTES = ${NET_MAX_TICK_BYTES};`); + put(`export const NET_MAX_HEADERS = ${NET_MAX_HEADERS};`); + put(`export const NET_MAX_HEADER_BYTES = ${NET_MAX_HEADER_BYTES};`); + put(`export const NET_DEFAULT_TIMEOUT_MS = ${NET_DEFAULT_TIMEOUT_MS};`); + put(`export const NET_MAX_TIMEOUT_MS = ${NET_MAX_TIMEOUT_MS};`); + put(`export const NET_MAX_REDIRECTS = ${NET_MAX_REDIRECTS};`); + put(`export const NET_TLS_MIN_VERSION = ${js(NET_TLS_MIN_VERSION)};`); + put(`export const NET_METHODS_FORBIDDEN = ${js(NET_METHODS_FORBIDDEN)};`); + put(`export const HTTP_CORE_OWNED_REQUEST_HEADERS = ${js(HTTP_CORE_OWNED_REQUEST_HEADERS)};`); + put(`export const HTTP_NULL_BODY_STATUS = ${js(HTTP_NULL_BODY_STATUS)};`); + put(`export const HTTP_REDIRECT_STATUS = ${js(HTTP_REDIRECT_STATUS)};`); + put(`export const NET_EVENT = ${js(NET_EVENT)};`); + put(`export const NET_ERROR = ${js(NET_ERROR)};`); + return L.join("\n") + "\n"; +} + +if (import.meta.main) { + const out = new URL("../../hosts/web/net-spec.js", import.meta.url).pathname; + await Bun.write(out, generateWeb()); + console.log(`wrote ${out}`); +} diff --git a/contracts/spec/httpd.ts b/contracts/spec/httpd.ts new file mode 100644 index 00000000..20b5fa90 --- /dev/null +++ b/contracts/spec/httpd.ts @@ -0,0 +1,204 @@ +// PocketJS httpd spec v2 — the boundary of the HTTP Server module +// (`globalThis.httpd`). +// +// The public SDK is `serve()` in `@pocketjs/framework/net/http`. This file +// fixes the guest ↔ core boundary underneath it. HTTP Server is its own +// module (spec, core, capability `network.http.server` / `.tls`, namespace); +// it shares the native HTTP/1.1 parser, transport/TLS/queue substrate, policy +// input and error vocabulary (contracts/spec/net.ts NET_ERROR) with the HTTP +// Client. The guest sees a server handle and request ids, never connections: +// keep-alive, the pipelining ban, `Expect: 100-continue` and HEAD body +// discard are core rules. +// +// Frame contract: identical to net — completions become visible at +// `begin_tick()`, `poll()` runs once per tick, `respond`/`write` only place +// bytes in the connection's bounded send queue and the network task writes +// them out as soon as `frame()` returns. +// +// If you change ANY value here: run `bun run gen` and commit the regenerated +// engine/core/src/spec.rs and engine/net/include/pocketjs/net/spec.h. + +export const HTTPD_SPEC_MAJOR = 2; +export const HTTPD_SPEC_MINOR = 0; + +// --------------------------------------------------------------------------- +// Ops (guest -> core, all synchronous; codes append-only) +// --------------------------------------------------------------------------- +// +// listen(metaJson) -> handle | -1 +// Static validation only (capability, (protocol, address, port) listen +// rule, insecureTransport, credential id, limits, server count). +// bind/listen happen on the network task: `listening` on success, +// terminal `error` on failure. +// stop(handle, graceful, timeoutMs) -> 0 | -1 +// Stop accepting and close idle connections; graceful waits for +// inflight requests until timeoutMs, then forces the rest. Terminal +// `closed{h}` follows; forced requests each get `aborted{code:"closed"}`. +// respond(req, metaJson, body:ArrayBuffer|null) -> 0 | -1 | -2 | -3 +// Send the response head; with meta.end=true (default) the body +// completes the response, else `write`/`endBody` stream it. -1 unknown/ +// answered/aborted req; -2 body does not fit the send queue (nothing +// accepted, `drain` armed — use end=false + write); -3 invalid meta. +// write(req, chunk:ArrayBuffer) -> 0 | -1 | -2 | -3 +// Append a body chunk after respond(end=false); accepted whole or not +// at all. -2 queue full (`drain` armed); -3 chunk > maxSendQueueBytes. +// endBody(req) -> 0 | -1 +// Finish a streamed response (writes the terminating chunk); the req id +// is invalid afterwards. +// readInto(req, into:ArrayBuffer, offset, length) -> bytes | -1 +// Read request-body bytes visible at the tick boundary; same semantics +// as net.readInto. EOF is `end{req}`. +// abort(req) +// Give the request up: the core closes the connection (or ends the body +// if a response started); next tick delivers `aborted{req, +// code:"cancelled"}`. No-op on a terminal req. +// poll() -> string | undefined +// lastError() -> string +// limits() -> string + +export const HTTPD_OP = { + listen: 1, + stop: 2, + respond: 3, + write: 4, + endBody: 5, + readInto: 6, + abort: 7, + poll: 8, + lastError: 9, + limits: 10, +} as const; + +/** `respond`/`write` return values. */ +export const HTTPD_SEND_ACCEPTED = 0; +export const HTTPD_SEND_INVALID_REQUEST = -1; +export const HTTPD_SEND_BACKPRESSURE = -2; +export const HTTPD_SEND_INVALID = -3; + +// --------------------------------------------------------------------------- +// Events (core -> guest, one JSON array per tick, sequence order) +// --------------------------------------------------------------------------- +// +// {"t":"listening","h":n,"address":"192.168.1.20","port":8080} +// {"t":"closed","h":n} +// {"t":"error","h":n,"code":"address_in_use","message":"…","causeCode":"…"} +// {"t":"request","h":n,"req":r,"method":"GET","target":"/a?b=1","headers":{…}, +// "remote":{"address":"…","port":51234},"length":12,"secure":false} +// {"t":"readable","req":r,"avail":12} +// {"t":"end","req":r} +// {"t":"drain","req":r} +// {"t":"aborted","req":r,"code":"closed"} +// +// Per server: `error` (before listening, terminal) or +// `listening → request* → [error →] closed`. Per req the terminal is either +// the application completing the response (respond end=true / endBody) or +// exactly one `aborted{code}` with code closed | timeout | response_too_large +// | cancelled. `request` is delivered only when an inflight slot and the +// per-tick event budget allow it. + +export const HTTPD_EVENT = { + listening: "listening", + closed: "closed", + error: "error", + request: "request", + readable: "readable", + end: "end", + drain: "drain", + aborted: "aborted", +} as const; + +// --------------------------------------------------------------------------- +// Data contract +// --------------------------------------------------------------------------- + +export interface HttpdListenMeta { + address: string; + /** 0 = ephemeral; must match a listen rule with port "ephemeral". */ + port: number; + backlog?: number; + tls?: { credential: string }; + limits?: { + maxConnections?: number; + maxInflight?: number; + maxHeaderBytes?: number; + maxBodyBytes?: number; + requestQueueBytes?: number; + sendQueueBytes?: number; + }; + timeouts?: { + headerMs?: number; + bodyIdleMs?: number; + handlerMs?: number; + keepAliveMs?: number; + closeMs?: number; + }; +} + +export interface HttpdRespondMeta { + status: number; + /** Reason phrase; empty selects the RFC 9110 default. */ + statusText?: string; + headers?: Record; + /** Known body length for a streamed response; omitted = chunked. */ + contentLength?: number; + /** false = stream the body with write/endBody. Default true. */ + end?: boolean; +} + +export interface HttpdLimits { + specMajor: number; + specMinor: number; + maxServers: number; + maxConnections: number; + maxInflight: number; + maxTlsInflight: number; + maxHeaders: number; + maxHeaderBytes: number; + maxTargetBytes: number; + defaultRequestQueueBytes: number; + maxRequestQueueBytes: number; + maxSendQueueBytes: number; + sendHighWaterBytes: number; + sendLowWaterBytes: number; + maxEventsPerTick: number; + maxTickBytes: number; + defaultHeaderMs: number; + defaultBodyIdleMs: number; + defaultHandlerMs: number; + defaultKeepAliveMs: number; + defaultCloseMs: number; + maxTimeoutMs: number; + tlsMinVersion: string; + features: readonly string[]; +} + +// --------------------------------------------------------------------------- +// Portable limits (ceilings; hosts only tighten) +// --------------------------------------------------------------------------- + +/** Listeners alive at once. */ +export const HTTPD_MAX_SERVERS = 2; +/** Per server: open connections / delivered-but-unanswered requests. */ +export const HTTPD_MAX_CONNECTIONS = 16; +export const HTTPD_MAX_INFLIGHT = 8; +export const HTTPD_MAX_BACKLOG = 16; +/** Request head: header count, total header bytes, request-target bytes; + * exceeding answers 431 / 414 and closes without delivering `request`. */ +export const HTTPD_MAX_HEADERS = 64; +export const HTTPD_MAX_HEADER_BYTES = 16 * 1024; +export const HTTPD_MAX_TARGET_BYTES = 2048; +/** Per-request native receive queue (backpressure window). */ +export const HTTPD_DEFAULT_REQUEST_QUEUE_BYTES = 32 * 1024; +export const HTTPD_MAX_REQUEST_QUEUE_BYTES = 256 * 1024; +/** Per-connection send queue and its `drain` thresholds. */ +export const HTTPD_MAX_SEND_QUEUE_BYTES = 256 * 1024; +export const HTTPD_SEND_HIGH_WATER_BYTES = 128 * 1024; +export const HTTPD_SEND_LOW_WATER_BYTES = 32 * 1024; +export const HTTPD_MAX_EVENTS_PER_TICK = 128; +export const HTTPD_MAX_TICK_BYTES = 256 * 1024; +export const HTTPD_DEFAULT_HEADER_MS = 10_000; +export const HTTPD_DEFAULT_BODY_IDLE_MS = 30_000; +export const HTTPD_DEFAULT_HANDLER_MS = 30_000; +export const HTTPD_DEFAULT_KEEP_ALIVE_MS = 15_000; +export const HTTPD_DEFAULT_CLOSE_MS = 5_000; +export const HTTPD_MAX_TIMEOUT_MS = 120_000; diff --git a/contracts/spec/net.ts b/contracts/spec/net.ts index 974c4bdb..395bfa9f 100644 --- a/contracts/spec/net.ts +++ b/contracts/spec/net.ts @@ -1,123 +1,303 @@ -// PocketJS net spec — the boundary of the NET module (`globalThis.net`). +// PocketJS net spec v2 — the boundary of the HTTP Client module (`globalThis.net`). // -// This module deliberately exposes one bounded HTTP client primitive, not a -// browser networking stack. The public SDK is `fetch()`; the native boundary -// below stays smaller so embedded transports (ESP-IDF, ureq, platform HTTP) -// can implement it without reproducing WHATWG Request/Response/Streams. +// The public SDK is `@pocketjs/framework/net/http` (`fetch`, `Headers`, +// `Request`, `Response`, `BodyStream`). This file fixes the guest ↔ core +// boundary underneath it: numeric op codes, event names, the JSON data +// contract, portable limits and the shared error vocabulary. // // The four parts of the boundary: // // ops guest -> core intent (numeric codes below, append-only) -// events core -> guest facts (one JSON batch per tick) -// data contract request metadata JSON + borrowed request body + taken body +// events core -> guest facts (one JSON batch per tick, sequence order) +// data contract request metadata JSON + borrowed request body + copied +// response bytes (`readInto`) // frame contract transport never enters QuickJS; completions become visible -// only at a host tick boundary and Promise reactions run in -// that guest turn's normal microtask drain +// only at a host tick boundary (`begin_tick`), the framework +// service pump calls `poll` exactly once per frame, and +// Promise reactions run in that guest turn's job drain // // Ownership: -// start() BORROWS the request ArrayBuffer for the synchronous call. The host -// copies it before returning. take() BORROWS an exactly-sized destination, -// copies one completed response body into it, and succeeds at most once. +// start() BORROWS the request ArrayBuffer for the synchronous call; the host +// copies it before returning. readInto() BORROWS a destination ArrayBuffer +// and copies bytes that became visible at the last tick boundary into it, +// releasing the corresponding native queue space. // -// If you change ANY value here: run `bun contracts/spec/gen-rust.ts`, commit -// the regenerated engine/core/src/spec.rs (tests/contract.ts byte-compares). +// Host obligations: +// - `begin_tick()` before every `frame()`: swap transport completions into +// the visible set and freeze each handle's `readable` watermark; +// - no network task or callback ever calls QuickJS; +// - TLS (when the host advertises the "tls" feature): system trust store, +// SNI = authorized hostname, DNS-ID hostname verification, TLS 1.2 +// minimum, renegotiation and 0-RTT off, trusted wall clock or +// `tls_clock_untrusted`, never a plaintext fallback. +// +// If you change ANY value here: run `bun run gen` (contracts/spec/gen-rust.ts, +// contracts/spec/gen-c.ts), commit the regenerated engine/core/src/spec.rs and +// engine/net/include/pocketjs/net/spec.h (tests/contract.ts byte-compares). // --------------------------------------------------------------------------- -// Net ops (the `net.*` native contract) +// Spec version — the host reports it from `limits()` (specMajor/specMinor); +// the SDK refuses a major mismatch with `unsupported`. +// --------------------------------------------------------------------------- + +export const NET_SPEC_MAJOR = 2; +export const NET_SPEC_MINOR = 0; + +// --------------------------------------------------------------------------- +// Net ops (the `net.*` native contract; codes append-only, 2 is retired) // --------------------------------------------------------------------------- // // Signatures (authoritative; hosts marshal them however they like): -// start(metaJson:string, body:ArrayBuffer) -> handle | -1 -// metaJson = {url, method, headers, timeoutMs, maxBytes} -// The request is accepted or refused synchronously. Read lastError() on -// -1. A successful request completes asynchronously through poll(). -// take(handle, into:ArrayBuffer) -> bytesCopied | -1 -// Copy the completed response body exactly once. `into.byteLength` must -// equal the `bytes` field of the handle's done event. +// start(metaJson:string, body:ArrayBuffer|null) -> handle | -1 +// metaJson: see NetStartMeta below. Static validation only (URL, method, +// header syntax, scheme/capability, endpoint rule, insecureTransport, +// limits, inflight); the body is copied before the call returns. Read +// lastError() on -1. Checks that need DNS fail asynchronously with an +// `error` event. // cancel(handle) -// Best-effort transport cancellation and unconditional core cleanup. +// Best-effort transport close plus core cleanup; the handle's terminal +// `error{code:"cancelled"}` arrives with the next tick's batch. No-op on +// a handle that already reached its terminal event. // poll() -> string | undefined -// Drain the ENTIRE event batch visible at this tick as one JSON array. -// The SDK calls this once per tick only while requests are pending. +// The whole event batch visible at this tick as one JSON array, ordered +// by sequence. The SDK calls it exactly once per tick and only while at +// least one handle is live. // lastError() -> string // Portable `code: message` for the most recent synchronous refusal. +// readInto(handle, into:ArrayBuffer, offset, length) -> bytes | -1 +// Copy up to `length` visible unread body bytes into into[offset..] and +// release that queue space. 0 = no visible bytes right now (wait for the +// next `readable`); EOF is signalled by the `end` event; -1 = unknown or +// terminal handle. +// limits() -> string +// Read-only JSON with this host's effective limits and features (see +// NetLimits below). +// write(handle, chunk:ArrayBuffer) -> accepted | -1 (phase 2, reserved) +// endBody(handle) (phase 2, reserved) export const NET_OP = { start: 1, + /** v1 `take` — retired code, never reused. */ take: 2, cancel: 3, poll: 4, lastError: 5, + readInto: 6, + limits: 7, + write: 8, + endBody: 9, } as const; // --------------------------------------------------------------------------- // Events (core -> guest facts; all events for a tick in one JSON array) // --------------------------------------------------------------------------- // -// {"t":"done","h":n,"status":200,"url":"https://…","headers":{…},"bytes":5} -// {"t":"error","h":n,"code":"timeout","message":"…"} +// {"t":"headers","h":n,"status":200,"url":"http://…","headers":{…},"redirected":false,"length":5} +// {"t":"readable","h":n,"avail":1234} +// {"t":"end","h":n} +// {"t":"error","h":n,"code":"timeout","message":"…","causeCode":"…"} +// {"t":"drain","h":n} (phase 2) // -// A done event guarantees take(h, exactlySizedBuffer) is available. An error -// event guarantees no response body remains. Every accepted handle produces -// at most one terminal event unless the guest cancels it first. +// Per handle the sequence is `headers → readable* → end` or `… → error`; +// nothing follows `error`. `readable.avail` is the total visible unread byte +// count at the tick boundary and is sent at most once per handle per tick. +// `end` may arrive while visible bytes remain unread; the SDK drains them +// first. HTTP 4xx/5xx are successful exchanges; HEAD/204/304 produce +// `headers` + `end`. export const NET_EVENT = { - done: "done", + headers: "headers", + readable: "readable", + end: "end", error: "error", + drain: "drain", } as const; -/** Common application HTTP methods. CONNECT and TRACE are intentionally not - * client-app operations; custom methods are outside the portable v1 surface. */ -export const NET_METHODS = [ - "GET", - "HEAD", - "POST", - "PUT", - "PATCH", - "DELETE", - "OPTIONS", -] as const; +// --------------------------------------------------------------------------- +// Data contract +// --------------------------------------------------------------------------- + +/** `start` metadata. `queueBytes` is the native receive-queue capacity + * (backpressure window); `maxBodyBytes` is an optional total cap. Timeouts + * use the host monotonic clock: `connectMs` covers DNS + TCP + TLS, + * `headersMs` request-sent → response headers, `idleMs` body inactivity, + * `totalMs` the whole exchange. */ +export interface NetStartMeta { + url: string; + method: string; + headers: Record; + queueBytes?: number; + maxBodyBytes?: number; + timeouts?: { connectMs?: number; headersMs?: number; idleMs?: number; totalMs?: number }; + redirect?: "follow" | "manual" | "error"; + maxRedirects?: number; + tls?: { verification?: "full" | "development-insecure" }; +} -export type NetMethod = (typeof NET_METHODS)[number]; +/** `limits()` payload. Spec constants are portable ceilings; hosts only + * tighten, and this reports the tightened values. */ +export interface NetLimits { + specMajor: number; + specMinor: number; + maxInflight: number; + maxTlsInflight: number; + maxRequestBytes: number; + defaultQueueBytes: number; + maxQueueBytes: number; + defaultAggregateBytes: number; + maxAggregateBytes: number; + maxEventsPerTick: number; + maxTickBytes: number; + maxHeaders: number; + maxHeaderBytes: number; + defaultTimeoutMs: number; + maxTimeoutMs: number; + maxRedirects: number; + tlsMinVersion: string; + features: readonly string[]; +} // --------------------------------------------------------------------------- -// Bounded whole-response contract +// Portable limits (ceilings; a host's limits() may be smaller, never larger) // --------------------------------------------------------------------------- -/** Two concurrent requests cover the common app pattern while bounding - * transport state, TLS buffers and completed bodies on small hosts. */ -export const NET_MAX_INFLIGHT = 2; - +/** Concurrent live handles per runtime. */ +export const NET_MAX_INFLIGHT = 8; /** Request bodies are copied out of the guest during start(). */ -export const NET_MAX_REQUEST_BYTES = 64 * 1024; +export const NET_MAX_REQUEST_BYTES = 256 * 1024; +/** Per-handle native receive queue (backpressure window). */ +export const NET_DEFAULT_QUEUE_BYTES = 32 * 1024; +export const NET_MAX_QUEUE_BYTES = 256 * 1024; +/** SDK aggregate helpers (`text()`/`json()`/`arrayBuffer()`): total bytes + * before the SDK cancels the handle with `response_too_large`. */ +export const NET_DEFAULT_AGGREGATE_BYTES = 1024 * 1024; +export const NET_MAX_AGGREGATE_BYTES = 8 * 1024 * 1024; +/** Visible-set budget per tick: events and newly visible bytes across all + * handles. Excess stays queued natively and follows in sequence order. */ +export const NET_MAX_EVENTS_PER_TICK = 128; +export const NET_MAX_TICK_BYTES = 256 * 1024; -/** Default and absolute response-body limits. Transports should stop reading - * as soon as the selected limit is exceeded; the core checks again before a - * body becomes visible to the guest. */ -export const NET_DEFAULT_RESPONSE_BYTES = 128 * 1024; -export const NET_MAX_RESPONSE_BYTES = 256 * 1024; - -export const NET_MAX_HEADERS = 32; -export const NET_MAX_HEADER_BYTES = 8 * 1024; +export const NET_MAX_HEADERS = 64; +export const NET_MAX_HEADER_BYTES = 16 * 1024; export const NET_DEFAULT_TIMEOUT_MS = 30_000; export const NET_MAX_TIMEOUT_MS = 120_000; -export const NET_MAX_REDIRECTS = 3; +/** Default and maximum redirect hops; applications can only lower it. */ +export const NET_MAX_REDIRECTS = 5; +export const NET_TLS_MIN_VERSION = "1.2"; + +// --------------------------------------------------------------------------- +// HTTP semantics shared by every implementation (SDK, sim, browser host, C +// core, Rust core). These are the wire-visible rules that used to live as +// folklore in each layer; contracts/spec/vectors/http-semantics.json pins +// them and every implementation runs the same vectors. +// --------------------------------------------------------------------------- + +/** Methods that are never client-app operations: RFC 9110 CONNECT and TRACE, + * plus TRACK (the legacy Microsoft TRACE alias, refused for the same + * cross-site-tracing reason). Matching is case-insensitive; any other RFC + * 9110 token is accepted verbatim. */ +export const NET_METHODS_FORBIDDEN = ["CONNECT", "TRACE", "TRACK"] as const; + +/** Request headers the core owns (framing, connection control, upgrade). + * The SDK refuses them on a Request; a core strips them if they arrive. */ +export const HTTP_CORE_OWNED_REQUEST_HEADERS = [ + "host", + "connection", + "content-length", + "transfer-encoding", + "trailer", + "te", + "upgrade", + "keep-alive", + "expect", + "proxy-connection", +] as const; + +/** Response statuses whose message never has a body regardless of the + * framing headers (RFC 9112 §6.3 rule 1); every 1xx status and the response + * to a HEAD request are bodyless the same way. A client parses these as + * head-only and reports `length` from Content-Length when present. */ +export const HTTP_BODYLESS_STATUS = [204, 304] as const; + +/** Statuses a Response may not carry content for: the Fetch "null body + * status" set (101, 103, 204, 205, 304). The SDK Response refuses a body + * init, a server refuses to emit content, a client surfaces a null body. */ +export const HTTP_NULL_BODY_STATUS = [101, 103, 204, 205, 304] as const; + +/** Redirect statuses a client follows under `redirect: "follow"`; any other + * 3xx is an ordinary response. */ +export const HTTP_REDIRECT_STATUS = [301, 302, 303, 307, 308] as const; +/** On these statuses a POST becomes a GET and the body is dropped (RFC 9110 + * §15.4.2-3 common practice); other methods are kept. */ +export const HTTP_REDIRECT_POST_TO_GET_STATUS = [301, 302] as const; +/** On these statuses any method except HEAD becomes a GET without a body. */ +export const HTTP_REDIRECT_ANY_TO_GET_STATUS = [303] as const; + +// --------------------------------------------------------------------------- +// Errors — the vocabulary shared by net, ws and httpd. A core +// maps platform/library failures into these codes before crossing the +// boundary; the raw code may travel in `causeCode`. +// --------------------------------------------------------------------------- -/** Portable errors. A transport maps platform/library failures into these - * codes before crossing the module boundary. */ export const NET_ERROR = { - unavailable: "unavailable", + // synchronous refusal / runtime invalidRequest: "invalid_request", + invalidState: "invalid_state", + unsupported: "unsupported", + permissionDenied: "permission_denied", busy: "busy", + resourceLimit: "resource_limit", + // resolver / transport dns: "dns", connect: "connect", - tls: "tls", + addressInUse: "address_in_use", + closed: "closed", timeout: "timeout", + // tls + tlsCertificateInvalid: "tls_certificate_invalid", + tlsHostnameMismatch: "tls_hostname_mismatch", + tlsHandshakeFailed: "tls_handshake_failed", + tlsClockUntrusted: "tls_clock_untrusted", + // http redirect: "redirect", responseTooLarge: "response_too_large", protocol: "protocol", + // websocket + websocketHandshakeFailed: "websocket_handshake_failed", + websocketProtocolError: "websocket_protocol_error", + messageTooLarge: "message_too_large", + // other cancelled: "cancelled", other: "other", + /** SDK-only: the namespace is not mounted on this host. */ + unavailable: "unavailable", } as const; export type NetErrorCode = (typeof NET_ERROR)[keyof typeof NET_ERROR]; + +/** `NetworkError.category` is derived from the code, never sent by a host. */ +export function netErrorCategory( + code: string, +): "runtime" | "resolver" | "transport" | "tls" | "protocol" { + switch (code) { + case NET_ERROR.dns: + return "resolver"; + case NET_ERROR.connect: + case NET_ERROR.addressInUse: + return "transport"; + case NET_ERROR.tlsCertificateInvalid: + case NET_ERROR.tlsHostnameMismatch: + case NET_ERROR.tlsHandshakeFailed: + case NET_ERROR.tlsClockUntrusted: + return "tls"; + case NET_ERROR.redirect: + case NET_ERROR.responseTooLarge: + case NET_ERROR.protocol: + case NET_ERROR.websocketHandshakeFailed: + case NET_ERROR.websocketProtocolError: + case NET_ERROR.messageTooLarge: + return "protocol"; + default: + return "runtime"; + } +} diff --git a/contracts/spec/network-policy.ts b/contracts/spec/network-policy.ts new file mode 100644 index 00000000..f67f86ad --- /dev/null +++ b/contracts/spec/network-policy.ts @@ -0,0 +1,685 @@ +// PocketJS network policy — the typed contract between the application +// manifest (format 3, `permissions.network`), the Build Plan +// (`ResolvedBuildPlan.network`) and every network host. +// +// Ownership: +// manifest `permissions.network` app intent: which endpoints it may +// connect to / listen on, which host +// credential ids it may name, and the +// plaintext / local-network / dev-TLS +// switches +// resolver resolveNetworkPolicy() normalizes the intent into one canonical +// ResolvedNetworkPolicy and writes it into +// the plan (so it is covered by planHash) +// host canonicalNetworkPolicyJson(plan.network) +// is the immutable policy JSON a host hands +// to its network core at runtime creation +// (engine/net `pnet_runtime_create`, the +// Rust `NetPolicy::parse`, the sim hosts) +// +// A host never authors or widens this policy; it enforces it on every +// command (connect rule before DNS, every candidate address after DNS, +// listen rule before bind, again on redirects). The matcher below is the +// reference semantics; the C and Rust cores implement the same rules and +// the shared vectors (contracts/spec/vectors/network-policy.json) pin them. + +import type { JsonSchema } from "./pocket-manifest.ts"; + +export const NETWORK_POLICY_VERSION = 1 as const; + +/** The protocols the v1 modules speak; listen rules take the same tokens. */ +export const NETWORK_POLICY_PROTOCOLS = ["http", "https", "ws", "wss"] as const; +export type NetworkPolicyProtocol = (typeof NETWORK_POLICY_PROTOCOLS)[number]; + +/** Plaintext protocols: refused unless the policy sets `insecureTransport`. */ +export const NETWORK_PLAINTEXT_PROTOCOLS: readonly NetworkPolicyProtocol[] = ["http", "ws"]; + +export const NETWORK_DEFAULT_PORTS: Readonly> = { + http: 80, + https: 443, + ws: 80, + wss: 443, +}; + +// --------------------------------------------------------------------------- +// Manifest intent (`permissions.network`) +// --------------------------------------------------------------------------- + +/** A single port or an inclusive range. */ +export type NetworkPortRule = number | { readonly min: number; readonly max: number }; + +export interface NetworkConnectRule { + readonly protocol: NetworkPolicyProtocol; + /** DNS name (lowercase ASCII / IDNA A-label), `*.suffix` (exactly one + * label), or an IP literal. Never a bare `*`. */ + readonly host: string; + readonly port: NetworkPortRule; +} + +export interface NetworkListenRule { + readonly protocol: NetworkPolicyProtocol; + /** A bind address: IP literal only. */ + readonly address: string; + /** A port, a range, or `"ephemeral"` (bind port 0; the host checks the + * OS-assigned port against its own ephemeral range). */ + readonly port: NetworkPortRule | "ephemeral"; +} + +export interface NetworkPermissions { + readonly connect?: readonly NetworkConnectRule[]; + readonly listen?: readonly NetworkListenRule[]; + /** Host credential ids the app may reference (`TlsOptions.credential`); + * never key material. */ + readonly credentials?: readonly string[]; + /** Allow matched endpoints to resolve to loopback / link-local / private / + * CGNAT / ULA addresses. Default false: a public hostname that resolves to + * such an address is refused (`permission_denied`). */ + readonly localNetwork?: boolean; + /** Allow plaintext `http:` / `ws:` rules to be used. Default false. */ + readonly insecureTransport?: boolean; + /** Let a development build skip certificate verification when the caller + * also asks for it per request. Refused outside development builds. */ + readonly allowInvalidTlsForDevelopment?: boolean; +} + +const portRuleSchema = { + anyOf: [ + { type: "integer", minimum: 1, maximum: 65535 }, + { + type: "object", + additionalProperties: false, + required: ["min", "max"], + properties: { + min: { type: "integer", minimum: 1, maximum: 65535 }, + max: { type: "integer", minimum: 1, maximum: 65535 }, + }, + }, + ], +} as const satisfies JsonSchema; + +/** Schema fragment for `permissions.network` (format 3 manifests). Shape + * only; hostname / address / range / duplicate semantics are the resolver's + * (`resolveNetworkPolicy`). */ +export const networkPermissionsSchema = { + type: "object", + additionalProperties: false, + properties: { + connect: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["protocol", "host", "port"], + properties: { + protocol: { enum: NETWORK_POLICY_PROTOCOLS }, + host: { type: "string", minLength: 1, maxLength: 255 }, + port: portRuleSchema, + }, + }, + }, + listen: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["protocol", "address", "port"], + properties: { + protocol: { enum: NETWORK_POLICY_PROTOCOLS }, + address: { type: "string", minLength: 1, maxLength: 64 }, + port: { anyOf: [...portRuleSchema.anyOf, { const: "ephemeral" }] }, + }, + }, + }, + credentials: { + type: "array", + items: { type: "string", minLength: 1, maxLength: 64, pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$" }, + uniqueItems: true, + }, + localNetwork: { type: "boolean" }, + insecureTransport: { type: "boolean" }, + allowInvalidTlsForDevelopment: { type: "boolean" }, + }, +} as const satisfies JsonSchema; + +// --------------------------------------------------------------------------- +// Resolved policy (the plan's `network` field) +// --------------------------------------------------------------------------- + +export interface ResolvedNetworkPolicy { + readonly version: typeof NETWORK_POLICY_VERSION; + /** Sorted, deduplicated, hosts lowercase, single-port ranges collapsed. */ + readonly connect: readonly NetworkConnectRule[]; + /** Sorted, deduplicated, addresses canonical (RFC 5952 for IPv6). */ + readonly listen: readonly NetworkListenRule[]; + /** Sorted unique host credential ids. */ + readonly credentials: readonly string[]; + readonly localNetwork: boolean; + readonly insecureTransport: boolean; + readonly allowInvalidTlsForDevelopment: boolean; +} + +/** The policy of a manifest without `permissions.network` (format 2 or + * omitted): no endpoint is reachable, nothing can listen. */ +export const DENY_ALL_NETWORK_POLICY: ResolvedNetworkPolicy = Object.freeze({ + version: NETWORK_POLICY_VERSION, + connect: Object.freeze([]) as readonly NetworkConnectRule[], + listen: Object.freeze([]) as readonly NetworkListenRule[], + credentials: Object.freeze([]) as readonly string[], + localNetwork: false, + insecureTransport: false, + allowInvalidTlsForDevelopment: false, +}); + +export interface NetworkPolicyDiagnostic { + readonly code: string; + /** RFC 6901 JSON Pointer below the caller's prefix. */ + readonly path: string; + readonly message: string; +} + +export interface ResolveNetworkPolicyOptions { + /** JSON Pointer prefix of the permissions object (default + * `/permissions/network`). */ + readonly path?: string; + /** A development build admits `allowInvalidTlsForDevelopment: true`; + * production admission refuses it. Default false. */ + readonly development?: boolean; +} + +export type ResolveNetworkPolicyResult = + | { readonly ok: true; readonly policy: ResolvedNetworkPolicy } + | { readonly ok: false; readonly diagnostics: readonly NetworkPolicyDiagnostic[] }; + +// --- addresses -------------------------------------------------------------- + +export interface NetworkAddressLiteral { + readonly family: 4 | 6; + /** 4 or 16 bytes. */ + readonly bytes: Uint8Array; +} + +function parseIPv4(text: string): Uint8Array | null { + const parts = text.split("."); + if (parts.length !== 4) return null; + const out = new Uint8Array(4); + for (let i = 0; i < 4; i++) { + const part = parts[i]; + if (!/^(0|[1-9][0-9]{0,2})$/.test(part)) return null; + const value = Number(part); + if (value > 255) return null; + out[i] = value; + } + return out; +} + +function parseIPv6(text: string): Uint8Array | null { + // RFC 4291 text form: up to 8 hex groups, one `::` gap, optional dotted + // IPv4 tail (the same grammar engine/net's pnet_parse_ipv6 accepts). + if (text.length === 0) return null; + const groups: number[] = []; + let gap = -1; + let i = 0; + if (text.startsWith("::")) { + gap = 0; + i = 2; + } else if (text.startsWith(":")) { + return null; + } + while (i < text.length) { + if (groups.length >= 8) return null; + let j = i; + let dotted = false; + while (j < text.length && text[j] !== ":") { + if (text[j] === ".") dotted = true; + j++; + } + if (dotted) { + if (j !== text.length || groups.length > 6) return null; + const v4 = parseIPv4(text.slice(i)); + if (!v4) return null; + groups.push((v4[0] << 8) | v4[1], (v4[2] << 8) | v4[3]); + i = j; + break; + } + if (j === i || j - i > 4 || !/^[0-9a-fA-F]+$/.test(text.slice(i, j))) return null; + groups.push(parseInt(text.slice(i, j), 16)); + i = j; + if (i < text.length) { + i++; // ':' + if (i < text.length && text[i] === ":") { + if (gap >= 0) return null; + gap = groups.length; + i++; + if (i === text.length) break; + } else if (i === text.length) { + return null; + } + } + } + if (gap < 0 && groups.length !== 8) return null; + if (gap >= 0 && groups.length >= 8) return null; + const out = new Uint8Array(16); + const fill = 8 - groups.length; + let gi = 0; + for (let g = 0; g < 8; g++) { + if (gap >= 0 && g >= gap && g < gap + fill) continue; + out[g * 2] = groups[gi] >> 8; + out[g * 2 + 1] = groups[gi] & 0xff; + gi++; + } + return out; +} + +/** Parse an IP literal (`1.2.3.4`, `::1`, `[::1]`); null when it is not one. */ +export function parseNetworkAddress(text: string): NetworkAddressLiteral | null { + let body = text; + if (body.length >= 2 && body.startsWith("[") && body.endsWith("]")) body = body.slice(1, -1); + if (body.includes(":")) { + const bytes = parseIPv6(body); + return bytes ? { family: 6, bytes } : null; + } + const bytes = parseIPv4(body); + return bytes ? { family: 4, bytes } : null; +} + +/** Canonical text: dotted quad, or RFC 5952 IPv6 (lowercase hex, longest + * zero run of two or more groups compressed, no dotted tail). */ +export function formatNetworkAddress(addr: NetworkAddressLiteral): string { + if (addr.family === 4) return Array.from(addr.bytes).join("."); + const groups: number[] = []; + for (let i = 0; i < 8; i++) groups.push((addr.bytes[i * 2] << 8) | addr.bytes[i * 2 + 1]); + let best = -1; + let bestLen = 0; + for (let i = 0; i < 8;) { + if (groups[i] !== 0) { + i++; + continue; + } + let j = i; + while (j < 8 && groups[j] === 0) j++; + if (j - i > bestLen && j - i >= 2) { + best = i; + bestLen = j - i; + } + i = j; + } + let out = ""; + for (let i = 0; i < 8; i++) { + if (i === best) { + out += "::"; // the group before the run wrote no separator + i += bestLen - 1; + continue; + } + out += groups[i].toString(16); + if (i < 7 && i + 1 !== best) out += ":"; + } + return out; +} + +export function networkAddressIsMulticast(addr: NetworkAddressLiteral): boolean { + if (addr.family === 4) return (addr.bytes[0] & 0xf0) === 0xe0; + return addr.bytes[0] === 0xff; +} + +/** Public (globally routable unicast) classification shared with the C core + * (`pnet_addr_is_public`): false for unspecified, loopback, RFC 1918, + * link-local, CGNAT, multicast, broadcast, `::`/`::1`, fe80::/10, fc00::/7 + * and IPv4-mapped addresses whose IPv4 part is not public. */ +export function networkAddressIsPublic(addr: NetworkAddressLiteral): boolean { + const a = addr.bytes; + if (addr.family === 4) { + if (a[0] === 0) return false; + if (a[0] === 10) return false; + if (a[0] === 127) return false; + if (a[0] === 169 && a[1] === 254) return false; + if (a[0] === 172 && (a[1] & 0xf0) === 16) return false; + if (a[0] === 192 && a[1] === 168) return false; + if (a[0] === 100 && (a[1] & 0xc0) === 64) return false; + if ((a[0] & 0xf0) === 0xe0) return false; + if (a[0] === 255 && a[1] === 255 && a[2] === 255 && a[3] === 255) return false; + return true; + } + let leadingZero = true; + for (let i = 0; i < 15; i++) if (a[i] !== 0) leadingZero = false; + if (leadingZero && (a[15] === 0 || a[15] === 1)) return false; + if (a[0] === 0xfe && (a[1] & 0xc0) === 0x80) return false; + if ((a[0] & 0xfe) === 0xfc) return false; + if (a[0] === 0xff) return false; + let mapped = true; + for (let i = 0; i < 10; i++) if (a[i] !== 0) mapped = false; + if (mapped && a[10] === 0xff && a[11] === 0xff) { + return networkAddressIsPublic({ family: 4, bytes: a.subarray(12, 16) }); + } + return true; +} + +// --- hostnames -------------------------------------------------------------- + +const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +/** Lowercase an ASCII hostname and drop one trailing root dot; null when it + * is not a valid ASCII (A-label) hostname. */ +export function normalizeNetworkHostname(host: string): string | null { + if (!/^[\x21-\x7e]+$/.test(host)) return null; + let lower = host.toLowerCase(); + if (lower.length > 1 && lower.endsWith(".")) lower = lower.slice(0, -1); + if (lower.length === 0 || lower.length > 253) return null; + const labels = lower.split("."); + if (!labels.every((label) => HOST_LABEL.test(label))) return null; + // A name whose last label is all digits is a malformed IPv4 literal, never + // a DNS name (WHATWG URL "ends in a number"); leading-zero octets such as + // 192.168.001.020 are refused by the literal parser on purpose. + if (/^[0-9]+$/.test(labels[labels.length - 1])) return null; + return lower; +} + +/** `*.example.com` matches exactly one non-empty label (`a.example.com`), + * never the suffix itself nor `a.b.example.com`; plain names compare + * case-insensitively; IP literals compare by canonical address. */ +export function networkHostMatches(rule: string, host: string): boolean { + const ruleAddr = parseNetworkAddress(rule); + if (ruleAddr) { + const hostAddr = parseNetworkAddress(host); + return hostAddr !== null && formatNetworkAddress(hostAddr) === formatNetworkAddress(ruleAddr); + } + const target = normalizeNetworkHostname(host); + if (target === null) return false; + if (rule.startsWith("*.")) { + const suffix = rule.slice(1); // ".example.com" + if (target.length <= suffix.length || !target.endsWith(suffix)) return false; + const label = target.slice(0, target.length - suffix.length); + return label.length > 0 && !label.includes("."); + } + return rule === target; +} + +// --- ports ------------------------------------------------------------------ + +function portBounds(rule: NetworkPortRule): readonly [number, number] { + return typeof rule === "number" ? [rule, rule] : [rule.min, rule.max]; +} + +export function networkPortMatches(rule: NetworkPortRule | "ephemeral", port: number): boolean { + if (rule === "ephemeral") return port === 0; + const [min, max] = portBounds(rule); + return port >= min && port <= max; +} + +// --- resolution ------------------------------------------------------------- + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalizePortRule( + value: unknown, + allowEphemeral: boolean, + path: string, + diagnostics: NetworkPolicyDiagnostic[], +): NetworkPortRule | "ephemeral" | null { + if (value === "ephemeral") { + if (allowEphemeral) return "ephemeral"; + diagnostics.push({ code: "network.ephemeralConnect", path, message: "connect rules take a port or a range, not \"ephemeral\"" }); + return null; + } + if (typeof value === "number") { + if (!Number.isInteger(value) || value < 1 || value > 65535) { + diagnostics.push({ code: "network.invalidPort", path, message: "port must be an integer from 1 through 65535" }); + return null; + } + return value; + } + if (isRecord(value) && Number.isInteger(value.min) && Number.isInteger(value.max)) { + const min = value.min as number; + const max = value.max as number; + if (min < 1 || max > 65535) { + diagnostics.push({ code: "network.invalidPort", path, message: "port range must stay within 1 through 65535" }); + return null; + } + if (min > max) { + diagnostics.push({ code: "network.reversedPortRange", path, message: `port range ${min}-${max} is reversed` }); + return null; + } + return min === max ? min : { min, max }; + } + diagnostics.push({ code: "network.invalidPort", path, message: "port must be an integer or {min, max}" }); + return null; +} + +function portKey(rule: NetworkPortRule | "ephemeral"): string { + if (rule === "ephemeral") return "ephemeral"; + const [min, max] = portBounds(rule); + return `${String(min).padStart(5, "0")}-${String(max).padStart(5, "0")}`; +} + +function ruleKey(protocol: string, host: string, port: NetworkPortRule | "ephemeral"): string { + return `${protocol}${host}${portKey(port)}`; +} + +/** + * Normalize `permissions.network` into the canonical ResolvedNetworkPolicy: + * hostnames lowercase without a trailing dot, IP literals in canonical text, + * single-port ranges collapsed, rules and credentials sorted, exact + * duplicates refused, `allowInvalidTlsForDevelopment` refused outside + * development builds. `undefined` resolves to DENY_ALL_NETWORK_POLICY. + */ +export function resolveNetworkPolicy( + permissions: NetworkPermissions | undefined, + options: ResolveNetworkPolicyOptions = {}, +): ResolveNetworkPolicyResult { + if (permissions === undefined) return { ok: true, policy: DENY_ALL_NETWORK_POLICY }; + const prefix = options.path ?? "/permissions/network"; + const diagnostics: NetworkPolicyDiagnostic[] = []; + + const connect: NetworkConnectRule[] = []; + const connectKeys = new Map(); + (permissions.connect ?? []).forEach((rule, index) => { + const path = `${prefix}/connect/${index}`; + if (!NETWORK_POLICY_PROTOCOLS.includes(rule.protocol)) { + diagnostics.push({ code: "network.unknownProtocol", path: `${path}/protocol`, message: `unknown protocol ${JSON.stringify(rule.protocol)}` }); + return; + } + let host: string | null = null; + const literal = typeof rule.host === "string" ? parseNetworkAddress(rule.host) : null; + if (literal) { + host = formatNetworkAddress(literal); + } else if (typeof rule.host === "string" && rule.host.startsWith("*.")) { + const suffix = normalizeNetworkHostname(rule.host.slice(2)); + if (suffix !== null && !parseNetworkAddress(suffix)) host = `*.${suffix}`; + } else if (typeof rule.host === "string") { + host = normalizeNetworkHostname(rule.host); + } + if (host === null) { + diagnostics.push({ + code: "network.invalidHost", + path: `${path}/host`, + message: "host must be a lowercase ASCII hostname, a single-label wildcard (*.example.com) or an IP literal", + }); + return; + } + const port = normalizePortRule(rule.port, false, `${path}/port`, diagnostics); + if (port === null) return; + const key = ruleKey(rule.protocol, host, port); + const previous = connectKeys.get(key); + if (previous) { + diagnostics.push({ code: "network.duplicateRule", path, message: `rule was already declared at ${previous}` }); + return; + } + connectKeys.set(key, path); + connect.push({ protocol: rule.protocol, host, port: port as NetworkPortRule }); + }); + + const listen: NetworkListenRule[] = []; + const listenKeys = new Map(); + (permissions.listen ?? []).forEach((rule, index) => { + const path = `${prefix}/listen/${index}`; + if (!NETWORK_POLICY_PROTOCOLS.includes(rule.protocol)) { + diagnostics.push({ code: "network.unknownProtocol", path: `${path}/protocol`, message: `unknown protocol ${JSON.stringify(rule.protocol)}` }); + return; + } + const literal = typeof rule.address === "string" ? parseNetworkAddress(rule.address) : null; + if (!literal) { + diagnostics.push({ code: "network.invalidAddress", path: `${path}/address`, message: "listen address must be an IP literal" }); + return; + } + const address = formatNetworkAddress(literal); + const port = normalizePortRule(rule.port, true, `${path}/port`, diagnostics); + if (port === null) return; + const key = ruleKey(rule.protocol, address, port); + const previous = listenKeys.get(key); + if (previous) { + diagnostics.push({ code: "network.duplicateRule", path, message: `rule was already declared at ${previous}` }); + return; + } + listenKeys.set(key, path); + listen.push({ protocol: rule.protocol, address, port }); + }); + + const credentials = [...new Set(permissions.credentials ?? [])].sort(); + if (credentials.length !== (permissions.credentials ?? []).length) { + diagnostics.push({ code: "network.duplicateCredential", path: `${prefix}/credentials`, message: "credential ids must be unique" }); + } + + const allowInvalidTls = permissions.allowInvalidTlsForDevelopment === true; + if (allowInvalidTls && !options.development) { + diagnostics.push({ + code: "network.developmentOnly", + path: `${prefix}/allowInvalidTlsForDevelopment`, + message: "allowInvalidTlsForDevelopment is admitted only by development builds", + }); + } + + if (diagnostics.length > 0) return { ok: false, diagnostics }; + + const byKey = (hostOf: (rule: T) => string) => + (left: T, right: T) => { + const l = ruleKey(left.protocol, hostOf(left), left.port); + const r = ruleKey(right.protocol, hostOf(right), right.port); + return l < r ? -1 : l > r ? 1 : 0; + }; + connect.sort(byKey((rule) => rule.host)); + listen.sort(byKey((rule) => rule.address)); + + return { + ok: true, + policy: { + version: NETWORK_POLICY_VERSION, + connect, + listen, + credentials, + localNetwork: permissions.localNetwork === true, + insecureTransport: permissions.insecureTransport === true, + allowInvalidTlsForDevelopment: allowInvalidTls, + }, + }; +} + +// --- enforcement (reference semantics) --------------------------------------- + +export function networkPolicyAllowsConnect( + policy: ResolvedNetworkPolicy, + protocol: string, + host: string, + port: number, +): boolean { + if (NETWORK_PLAINTEXT_PROTOCOLS.includes(protocol as NetworkPolicyProtocol) && !policy.insecureTransport) return false; + return policy.connect.some( + (rule) => rule.protocol === protocol && networkPortMatches(rule.port, port) && networkHostMatches(rule.host, host), + ); +} + +/** A resolved candidate address is usable when it is public, or when the + * policy grants `localNetwork`; multicast never is. */ +export function networkPolicyAllowsAddress(policy: ResolvedNetworkPolicy, addr: NetworkAddressLiteral): boolean { + if (networkAddressIsMulticast(addr)) return false; + if (networkAddressIsPublic(addr)) return true; + return policy.localNetwork; +} + +export function networkPolicyAllowsListen( + policy: ResolvedNetworkPolicy, + protocol: string, + address: string, + port: number, +): boolean { + if (NETWORK_PLAINTEXT_PROTOCOLS.includes(protocol as NetworkPolicyProtocol) && !policy.insecureTransport) return false; + const addr = parseNetworkAddress(address); + if (!addr) return false; + const canonical = formatNetworkAddress(addr); + return policy.listen.some( + (rule) => rule.protocol === protocol && rule.address === canonical && networkPortMatches(rule.port, port), + ); +} + +export function networkPolicyHasCredential(policy: ResolvedNetworkPolicy, id: string): boolean { + return policy.credentials.includes(id); +} + +// --- canonical JSON (what a host hands to its core) -------------------------- + +function canonical(value: unknown): string { + if (value === null || typeof value === "boolean" || typeof value === "string") return JSON.stringify(value); + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new TypeError("network policy contains a non-finite number"); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; + } + throw new TypeError(`network policy contains non-JSON value ${typeof value}`); +} + +/** RFC 8785-shaped canonical JSON of a resolved policy: sorted keys, no + * whitespace. Byte-identical for equal policies; this string is the native + * policy input of every host. */ +export function canonicalNetworkPolicyJson(policy: ResolvedNetworkPolicy): string { + return canonical({ + version: policy.version, + connect: policy.connect, + listen: policy.listen, + credentials: policy.credentials, + localNetwork: policy.localNetwork, + insecureTransport: policy.insecureTransport, + allowInvalidTlsForDevelopment: policy.allowInvalidTlsForDevelopment, + }); +} + +/** Parse canonical (or any) policy JSON back into a ResolvedNetworkPolicy: + * hosts that receive the JSON (sim, browser dev host) use this and then the + * matcher above. Throws on a malformed or unsupported document. */ +export function parseNetworkPolicyJson(json: string, options: ResolveNetworkPolicyOptions = {}): ResolvedNetworkPolicy { + const parsed: unknown = JSON.parse(json); + if (!isRecord(parsed)) throw new TypeError("network policy must be a JSON object"); + if (parsed.version !== undefined && parsed.version !== NETWORK_POLICY_VERSION) { + throw new TypeError(`unsupported network policy version ${String(parsed.version)}`); + } + const { version: _version, ...permissions } = parsed; + for (const key of ["connect", "listen", "credentials"] as const) { + if (permissions[key] !== undefined && !Array.isArray(permissions[key])) { + throw new TypeError(`network policy ${key} must be an array`); + } + } + for (const key of ["localNetwork", "insecureTransport", "allowInvalidTlsForDevelopment"] as const) { + if (permissions[key] !== undefined && typeof permissions[key] !== "boolean") { + throw new TypeError(`network policy ${key} must be a boolean`); + } + } + for (const key of Object.keys(permissions)) { + if (!["connect", "listen", "credentials", "localNetwork", "insecureTransport", "allowInvalidTlsForDevelopment"].includes(key)) { + throw new TypeError(`network policy has an unknown field ${JSON.stringify(key)}`); + } + } + for (const rule of [...(permissions.connect as unknown[] ?? []), ...(permissions.listen as unknown[] ?? [])]) { + if (!isRecord(rule)) throw new TypeError("network policy rules must be objects"); + } + for (const id of (permissions.credentials as unknown[] ?? [])) { + if (typeof id !== "string" || id.length === 0) throw new TypeError("network policy credential ids must be non-empty strings"); + } + const result = resolveNetworkPolicy(permissions as NetworkPermissions, { path: "", development: true, ...options }); + if (!result.ok) { + throw new TypeError(`invalid network policy: ${result.diagnostics.map((d) => `${d.path || "/"}: ${d.message}`).join("; ")}`); + } + return result.policy; +} diff --git a/contracts/spec/platforms.ts b/contracts/spec/platforms.ts index ee0f73d9..f45cf4f1 100644 --- a/contracts/spec/platforms.ts +++ b/contracts/spec/platforms.ts @@ -144,11 +144,24 @@ export const POCKET_CAPABILITIES = defineCapabilityRegistry([ // appends the id to its profile only when its native host ships the module // (the ring/thread discipline to copy is hosts/psp/src/audio.rs). "audio.pcm", - // Bounded whole-response HTTP through `fetch()` and the net module's own - // namespace (`globalThis.net`, contracts/spec/net.ts). Transport adapters - // remain host-owned; the browser dev host, deterministic sim and reference - // core exercise the contract without granting network access to every host. - "net.http", + // Network capabilities are split by protocol, role and TLS. + // Each id names one module + // boundary: `network.http.client` is `fetch()` over `globalThis.net` + // (contracts/spec/net.ts), `network.http.server` is `serve()` over + // `globalThis.httpd` (contracts/spec/httpd.ts), `network.websocket.client` + // is `connect()` over `globalThis.ws` (contracts/spec/ws.ts); the `.tls` + // ids are the TLS roles a host admits separately. Registered ahead of any + // stock TARGET advertising them: the sim hosts, the browser dev host and + // the reference cores (engine/net, engine/crates/pocket-net) implement and + // test the contracts, so apps can already declare the requirement and fail + // admission where the modules are absent. A device target appends an id to + // its profile only when its native host ships and tests the module. + "network.http.client", + "network.http.client.tls", + "network.http.server", + "network.http.server.tls", + "network.websocket.client", + "network.websocket.client.tls", // SQLite behind the db module's own namespace (`globalThis.db`, // contracts/spec/db.ts): five synchronous ops, rows as one JSON line per // query() call, per-app storage the host confines. Registered ahead of any diff --git a/contracts/spec/pocket-manifest.ts b/contracts/spec/pocket-manifest.ts index 9ef229f6..fce830d1 100644 --- a/contracts/spec/pocket-manifest.ts +++ b/contracts/spec/pocket-manifest.ts @@ -1,3 +1,4 @@ +import { networkPermissionsSchema, type NetworkPermissions } from "./network-policy.ts"; import { EXECUTION_CLASSES, PRESENTATION_MODES, @@ -6,8 +7,16 @@ import { type Viewport, } from "./platforms.ts"; +/** Format 2: the capability/viewport manifest. */ export const POCKET_MANIFEST_VERSION = 2 as const; export const POCKET_MANIFEST_SCHEMA_ID = "https://pocketjs.dev/schema/pocket-2.json"; +/** Format 3: format 2 plus the top-level `permissions` block (network + * endpoint permissions, contracts/spec/network-policy.ts). Both formats are + * accepted by the validator; the resolver produces the same plan shape for + * both (a format-2 manifest resolves to a deny-all network policy). */ +export const POCKET_MANIFEST_V3_VERSION = 3 as const; +export const POCKET_MANIFEST_V3_SCHEMA_ID = "https://pocketjs.dev/schema/pocket-3.json"; +export const POCKET_MANIFEST_VERSIONS = [POCKET_MANIFEST_VERSION, POCKET_MANIFEST_V3_VERSION] as const; export type JsonPrimitive = boolean | number | string; export type JsonValue = JsonPrimitive | null | JsonValue[] | { [key: string]: JsonValue }; @@ -77,6 +86,19 @@ export interface PocketManifestV2 { }; } +/** Format 3: format 2 fields plus `permissions`. */ +export interface PocketManifestV3 extends Omit { + readonly $schema: typeof POCKET_MANIFEST_V3_SCHEMA_ID; + readonly pocket: typeof POCKET_MANIFEST_V3_VERSION; + /** What this build may reach. Omitted blocks deny everything. */ + readonly permissions?: { + readonly network?: NetworkPermissions; + }; +} + +/** Either accepted manifest format. */ +export type PocketManifest = PocketManifestV2 | PocketManifestV3; + /** A fixed-screen viewport declaration (takeover/kiosk/embedded targets). */ export interface FixedViewportSpec { readonly logical: Viewport; @@ -108,165 +130,194 @@ const capabilityIdSchema = { pattern: "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$", } as const satisfies JsonSchema; -/** Strict format-2 application intent. Platform facts stay in target profiles. */ -export const pocketManifestV2Schema = { - $schema: "https://json-schema.org/draft/2020-12/schema", - $id: POCKET_MANIFEST_SCHEMA_ID, - title: "Pocket application manifest, format 2", - type: "object", - additionalProperties: false, - required: ["$schema", "pocket", "id", "name", "title", "version", "engine", "app"], - properties: { - $schema: { const: POCKET_MANIFEST_SCHEMA_ID }, - pocket: { const: POCKET_MANIFEST_VERSION }, - id: { - type: "string", - minLength: 3, - pattern: "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$", - }, - name: { - type: "string", - minLength: 1, - maxLength: 64, - pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", - }, - title: { type: "string", minLength: 1, maxLength: 128 }, - version: { - type: "string", - pattern: "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$", - }, - execution: { - type: "object", - additionalProperties: false, - required: ["classes"], - properties: { - classes: { - type: "array", - items: { enum: EXECUTION_CLASSES }, - minItems: 1, - uniqueItems: true, - }, +/** Properties shared by every manifest format (identity, capabilities, app). */ +const manifestProperties = { + id: { + type: "string", + minLength: 3, + pattern: "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$", + }, + name: { + type: "string", + minLength: 1, + maxLength: 64, + pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", + }, + title: { type: "string", minLength: 1, maxLength: 128 }, + version: { + type: "string", + pattern: "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$", + }, + execution: { + type: "object", + additionalProperties: false, + required: ["classes"], + properties: { + classes: { + type: "array", + items: { enum: EXECUTION_CLASSES }, + minItems: 1, + uniqueItems: true, }, }, - engine: { - type: "object", - additionalProperties: false, - required: ["capabilities"], - properties: { - capabilities: { - type: "object", - additionalProperties: false, - required: ["requires"], - properties: { - requires: { - type: "array", - items: capabilityIdSchema, - minItems: 1, - uniqueItems: true, - }, - enhances: { - type: "array", - items: capabilityIdSchema, - uniqueItems: true, - }, + }, + engine: { + type: "object", + additionalProperties: false, + required: ["capabilities"], + properties: { + capabilities: { + type: "object", + additionalProperties: false, + required: ["requires"], + properties: { + requires: { + type: "array", + items: capabilityIdSchema, + minItems: 1, + uniqueItems: true, + }, + enhances: { + type: "array", + items: capabilityIdSchema, + uniqueItems: true, }, }, }, }, - app: { - type: "object", - additionalProperties: false, - required: ["entry", "framework", "viewport"], - properties: { - entry: { - type: "string", - minLength: 1, - pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+\\.tsx?$", - }, - output: { + }, + app: { + type: "object", + additionalProperties: false, + required: ["entry", "framework", "viewport"], + properties: { + entry: { + type: "string", + minLength: 1, + pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+\\.tsx?$", + }, + output: { + type: "string", + minLength: 1, + maxLength: 64, + pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", + }, + framework: { enum: ["solid", "vue-vapor", "octane"] }, + companions: { + type: "array", + items: { type: "string", minLength: 1, maxLength: 64, pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", }, - framework: { enum: ["solid", "vue-vapor", "octane"] }, - companions: { - type: "array", - items: { - type: "string", - minLength: 1, - maxLength: 64, - pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", - }, - uniqueItems: true, - }, - viewport: { - anyOf: [ - // Shorthand: a bare fixed viewport (format-2 compatibility). - { - type: "object", - additionalProperties: false, - required: ["logical", "presentation"], - properties: { - logical: { - type: "array", - items: { type: "integer", minimum: 1 }, - minItems: 2, - maxItems: 2, - }, - presentation: { enum: PRESENTATION_MODES }, + uniqueItems: true, + }, + viewport: { + anyOf: [ + // Shorthand: a bare fixed viewport (format-2 compatibility). + { + type: "object", + additionalProperties: false, + required: ["logical", "presentation"], + properties: { + logical: { + type: "array", + items: { type: "integer", minimum: 1 }, + minItems: 2, + maxItems: 2, }, + presentation: { enum: PRESENTATION_MODES }, }, - // Policy variants: fixed and/or dynamic. An empty object is - // schema-valid but semantically caught by the resolver - // (viewport.fixedRequired / viewport.dynamicRequired). - { - type: "object", - additionalProperties: false, - properties: { - fixed: { - type: "object", - additionalProperties: false, - required: ["logical", "presentation"], - properties: { - logical: { - type: "array", - items: { type: "integer", minimum: 1 }, - minItems: 2, - maxItems: 2, - }, - presentation: { enum: PRESENTATION_MODES }, + }, + // Policy variants: fixed and/or dynamic. An empty object is + // schema-valid but semantically caught by the resolver + // (viewport.fixedRequired / viewport.dynamicRequired). + { + type: "object", + additionalProperties: false, + properties: { + fixed: { + type: "object", + additionalProperties: false, + required: ["logical", "presentation"], + properties: { + logical: { + type: "array", + items: { type: "integer", minimum: 1 }, + minItems: 2, + maxItems: 2, }, + presentation: { enum: PRESENTATION_MODES }, }, - dynamic: { - type: "object", - additionalProperties: false, - required: ["default"], - properties: { - default: { - type: "array", - items: { type: "integer", minimum: 1 }, - minItems: 2, - maxItems: 2, - }, - min: { - type: "array", - items: { type: "integer", minimum: 1 }, - minItems: 2, - maxItems: 2, - }, - max: { - type: "array", - items: { type: "integer", minimum: 1 }, - minItems: 2, - maxItems: 2, - }, + }, + dynamic: { + type: "object", + additionalProperties: false, + required: ["default"], + properties: { + default: { + type: "array", + items: { type: "integer", minimum: 1 }, + minItems: 2, + maxItems: 2, + }, + min: { + type: "array", + items: { type: "integer", minimum: 1 }, + minItems: 2, + maxItems: 2, + }, + max: { + type: "array", + items: { type: "integer", minimum: 1 }, + minItems: 2, + maxItems: 2, }, }, }, }, - ], - }, + }, + ], + }, + }, + }, +} as const satisfies Readonly>; + +const manifestRequired = ["$schema", "pocket", "id", "name", "title", "version", "engine", "app"] as const; + +/** Strict format-2 application intent. Platform facts stay in target profiles. */ +export const pocketManifestV2Schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: POCKET_MANIFEST_SCHEMA_ID, + title: "Pocket application manifest, format 2", + type: "object", + additionalProperties: false, + required: manifestRequired, + properties: { + $schema: { const: POCKET_MANIFEST_SCHEMA_ID }, + pocket: { const: POCKET_MANIFEST_VERSION }, + ...manifestProperties, + }, +} as const satisfies JsonSchema; + +/** Format 3: format 2 plus `permissions` (the network endpoint policy). */ +export const pocketManifestV3Schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: POCKET_MANIFEST_V3_SCHEMA_ID, + title: "Pocket application manifest, format 3", + type: "object", + additionalProperties: false, + required: manifestRequired, + properties: { + $schema: { const: POCKET_MANIFEST_V3_SCHEMA_ID }, + pocket: { const: POCKET_MANIFEST_V3_VERSION }, + ...manifestProperties, + permissions: { + type: "object", + additionalProperties: false, + properties: { + network: networkPermissionsSchema, }, }, }, @@ -275,3 +326,7 @@ export const pocketManifestV2Schema = { export function generatePocketManifestV2Schema(): string { return JSON.stringify(pocketManifestV2Schema, null, 2) + "\n"; } + +export function generatePocketManifestV3Schema(): string { + return JSON.stringify(pocketManifestV3Schema, null, 2) + "\n"; +} diff --git a/contracts/spec/vectors/http-semantics.json b/contracts/spec/vectors/http-semantics.json new file mode 100644 index 00000000..60bc3b4f --- /dev/null +++ b/contracts/spec/vectors/http-semantics.json @@ -0,0 +1,74 @@ +{ + "$comment": "Shared conformance vectors for the HTTP semantics pinned in contracts/spec/net.ts (NET_METHODS_FORBIDDEN, HTTP_CORE_OWNED_REQUEST_HEADERS, HTTP_BODYLESS_STATUS, HTTP_NULL_BODY_STATUS, HTTP_REDIRECT_*). The SDK, the sim host, the browser host, the C core and the Rust core run the same file: `methods` are start()-time decisions (accepted = a request with this method token may start), `requestHeaders` says whether the SDK/core treats a header as core-owned (refused on a Request, stripped by a core), `status` gives the framing/null-body classification, `redirect` gives the method/body rewrite when following a redirect.", + "methods": [ + { "method": "GET", "accepted": true }, + { "method": "get", "accepted": true }, + { "method": "HEAD", "accepted": true }, + { "method": "POST", "accepted": true }, + { "method": "PUT", "accepted": true }, + { "method": "PATCH", "accepted": true }, + { "method": "DELETE", "accepted": true }, + { "method": "OPTIONS", "accepted": true }, + { "method": "PURGE", "accepted": true }, + { "method": "M-SEARCH", "accepted": true }, + { "method": "CONNECT", "accepted": false }, + { "method": "connect", "accepted": false }, + { "method": "TRACE", "accepted": false }, + { "method": "Trace", "accepted": false }, + { "method": "TRACK", "accepted": false }, + { "method": "track", "accepted": false }, + { "method": "BAD METHOD", "accepted": false }, + { "method": "GET/", "accepted": false }, + { "method": "", "accepted": false } + ], + "requestHeaders": [ + { "name": "host", "coreOwned": true }, + { "name": "Host", "coreOwned": true }, + { "name": "connection", "coreOwned": true }, + { "name": "content-length", "coreOwned": true }, + { "name": "transfer-encoding", "coreOwned": true }, + { "name": "trailer", "coreOwned": true }, + { "name": "te", "coreOwned": true }, + { "name": "upgrade", "coreOwned": true }, + { "name": "keep-alive", "coreOwned": true }, + { "name": "expect", "coreOwned": true }, + { "name": "proxy-connection", "coreOwned": true }, + { "name": "content-type", "coreOwned": false }, + { "name": "cookie", "coreOwned": false }, + { "name": "origin", "coreOwned": false }, + { "name": "user-agent", "coreOwned": false }, + { "name": "authorization", "coreOwned": false }, + { "name": "x-custom", "coreOwned": false } + ], + "status": [ + { "status": 100, "bodylessFraming": true, "nullBody": false }, + { "status": 101, "bodylessFraming": true, "nullBody": true }, + { "status": 103, "bodylessFraming": true, "nullBody": true }, + { "status": 200, "bodylessFraming": false, "nullBody": false }, + { "status": 201, "bodylessFraming": false, "nullBody": false }, + { "status": 204, "bodylessFraming": true, "nullBody": true }, + { "status": 205, "bodylessFraming": false, "nullBody": true }, + { "status": 206, "bodylessFraming": false, "nullBody": false }, + { "status": 301, "bodylessFraming": false, "nullBody": false }, + { "status": 304, "bodylessFraming": true, "nullBody": true }, + { "status": 404, "bodylessFraming": false, "nullBody": false }, + { "status": 500, "bodylessFraming": false, "nullBody": false } + ], + "redirect": [ + { "status": 301, "method": "GET", "followed": true, "nextMethod": "GET", "keepBody": true }, + { "status": 301, "method": "POST", "followed": true, "nextMethod": "GET", "keepBody": false }, + { "status": 301, "method": "PUT", "followed": true, "nextMethod": "PUT", "keepBody": true }, + { "status": 302, "method": "POST", "followed": true, "nextMethod": "GET", "keepBody": false }, + { "status": 302, "method": "DELETE", "followed": true, "nextMethod": "DELETE", "keepBody": true }, + { "status": 303, "method": "POST", "followed": true, "nextMethod": "GET", "keepBody": false }, + { "status": 303, "method": "PUT", "followed": true, "nextMethod": "GET", "keepBody": false }, + { "status": 303, "method": "GET", "followed": true, "nextMethod": "GET", "keepBody": false }, + { "status": 303, "method": "HEAD", "followed": true, "nextMethod": "HEAD", "keepBody": true }, + { "status": 307, "method": "POST", "followed": true, "nextMethod": "POST", "keepBody": true }, + { "status": 308, "method": "POST", "followed": true, "nextMethod": "POST", "keepBody": true }, + { "status": 300, "method": "GET", "followed": false }, + { "status": 304, "method": "GET", "followed": false }, + { "status": 305, "method": "GET", "followed": false }, + { "status": 306, "method": "GET", "followed": false } + ] +} diff --git a/contracts/spec/vectors/network-policy.json b/contracts/spec/vectors/network-policy.json new file mode 100644 index 00000000..ecdecda3 --- /dev/null +++ b/contracts/spec/vectors/network-policy.json @@ -0,0 +1,151 @@ +{ + "$comment": "Shared conformance vectors for the network policy (contracts/spec/network-policy.ts). Every policy parser and matcher — the TypeScript reference, engine/net (C), engine/crates/pocket-net (Rust) — must reproduce these decisions exactly. `policies` are canonical ResolvedNetworkPolicy documents; `invalid` documents must be refused by every parser; `connect`, `address` and `listen` are enforcement decisions.", + "version": 1, + "policies": { + "deny-all": { + "version": 1, + "connect": [], + "listen": [], + "credentials": [], + "localNetwork": false, + "insecureTransport": false, + "allowInvalidTlsForDevelopment": false + }, + "standard": { + "version": 1, + "connect": [ + {"protocol": "http", "host": "192.168.1.20", "port": 8080}, + {"protocol": "http", "host": "localhost", "port": {"min": 8000, "max": 8100}}, + {"protocol": "https", "host": "*.devices.example.com", "port": 443}, + {"protocol": "https", "host": "api.example.com", "port": 443}, + {"protocol": "ws", "host": "echo.example.com", "port": 80} + ], + "listen": [ + {"protocol": "http", "address": "0.0.0.0", "port": 8080}, + {"protocol": "http", "address": "127.0.0.1", "port": "ephemeral"} + ], + "credentials": ["device-cert"], + "localNetwork": true, + "insecureTransport": true, + "allowInvalidTlsForDevelopment": false + }, + "secure-only": { + "version": 1, + "connect": [ + {"protocol": "http", "host": "api.example.com", "port": 80}, + {"protocol": "https", "host": "api.example.com", "port": 443}, + {"protocol": "ws", "host": "api.example.com", "port": 80} + ], + "listen": [], + "credentials": [], + "localNetwork": false, + "insecureTransport": false, + "allowInvalidTlsForDevelopment": false + }, + "ipv6": { + "version": 1, + "connect": [ + {"protocol": "https", "host": "2001:db8::1", "port": 443} + ], + "listen": [ + {"protocol": "https", "address": "::", "port": 8443} + ], + "credentials": [], + "localNetwork": false, + "insecureTransport": false, + "allowInvalidTlsForDevelopment": false + } + }, + "invalid": [ + {"name": "bare wildcard host", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "*", "port": 443}]}}, + {"name": "wildcard over an IP literal", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "*.1.2.3.4", "port": 443}]}}, + {"name": "wildcard without a suffix", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "*.", "port": 443}]}}, + {"name": "empty host", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "", "port": 443}]}}, + {"name": "non-ASCII host", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "bücher.example", "port": 443}]}}, + {"name": "host with a space", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "api example.com", "port": 443}]}}, + {"name": "label starting with a hyphen", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "-api.example.com", "port": 443}]}}, + {"name": "empty label", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "api..example.com", "port": 443}]}}, + {"name": "IPv4 literal with leading zeros", "policy": {"version": 1, "connect": [{"protocol": "http", "host": "192.168.001.020", "port": 8080}]}}, + {"name": "hostname whose last label is numeric", "policy": {"version": 1, "connect": [{"protocol": "http", "host": "host.123", "port": 8080}]}}, + {"name": "unknown protocol", "policy": {"version": 1, "connect": [{"protocol": "ftp", "host": "files.example.com", "port": 21}]}}, + {"name": "connect port zero", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "api.example.com", "port": 0}]}}, + {"name": "connect port above 65535", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "api.example.com", "port": 65536}]}}, + {"name": "reversed port range", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "api.example.com", "port": {"min": 9000, "max": 8000}}]}}, + {"name": "ephemeral connect port", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "api.example.com", "port": "ephemeral"}]}}, + {"name": "listen on a hostname", "policy": {"version": 1, "listen": [{"protocol": "http", "address": "localhost", "port": 8080}]}}, + {"name": "listen port above 65535", "policy": {"version": 1, "listen": [{"protocol": "http", "address": "0.0.0.0", "port": 70000}]}}, + {"name": "listen port zero spelled as a number", "policy": {"version": 1, "listen": [{"protocol": "http", "address": "0.0.0.0", "port": 0}]}}, + {"name": "unsupported version", "policy": {"version": 2, "connect": []}}, + {"name": "connect is not an array", "policy": {"version": 1, "connect": {"protocol": "https", "host": "api.example.com", "port": 443}}} + ], + "connect": [ + {"policy": "standard", "protocol": "https", "host": "api.example.com", "port": 443, "allowed": true}, + {"policy": "standard", "protocol": "https", "host": "API.EXAMPLE.COM.", "port": 443, "allowed": true}, + {"policy": "standard", "protocol": "https", "host": "api.example.com", "port": 8443, "allowed": false}, + {"policy": "standard", "protocol": "http", "host": "api.example.com", "port": 443, "allowed": false}, + {"policy": "standard", "protocol": "https", "host": "a.devices.example.com", "port": 443, "allowed": true}, + {"policy": "standard", "protocol": "https", "host": "devices.example.com", "port": 443, "allowed": false}, + {"policy": "standard", "protocol": "https", "host": "a.b.devices.example.com", "port": 443, "allowed": false}, + {"policy": "standard", "protocol": "https", "host": "xdevices.example.com", "port": 443, "allowed": false}, + {"policy": "standard", "protocol": "https", "host": ".devices.example.com", "port": 443, "allowed": false}, + {"policy": "standard", "protocol": "http", "host": "192.168.1.20", "port": 8080, "allowed": true}, + {"policy": "standard", "protocol": "http", "host": "192.168.001.020", "port": 8080, "allowed": false}, + {"policy": "standard", "protocol": "http", "host": "192.168.1.20", "port": 80, "allowed": false}, + {"policy": "standard", "protocol": "http", "host": "localhost", "port": 8000, "allowed": true}, + {"policy": "standard", "protocol": "http", "host": "localhost", "port": 8050, "allowed": true}, + {"policy": "standard", "protocol": "http", "host": "LOCALHOST", "port": 8100, "allowed": true}, + {"policy": "standard", "protocol": "http", "host": "localhost", "port": 8101, "allowed": false}, + {"policy": "standard", "protocol": "http", "host": "localhost", "port": 7999, "allowed": false}, + {"policy": "standard", "protocol": "ws", "host": "echo.example.com", "port": 80, "allowed": true}, + {"policy": "standard", "protocol": "wss", "host": "echo.example.com", "port": 443, "allowed": false}, + {"policy": "standard", "protocol": "ws", "host": "echo.example.com", "port": 8080, "allowed": false}, + {"policy": "secure-only", "protocol": "https", "host": "api.example.com", "port": 443, "allowed": true}, + {"policy": "secure-only", "protocol": "http", "host": "api.example.com", "port": 80, "allowed": false}, + {"policy": "secure-only", "protocol": "ws", "host": "api.example.com", "port": 80, "allowed": false}, + {"policy": "deny-all", "protocol": "https", "host": "api.example.com", "port": 443, "allowed": false}, + {"policy": "ipv6", "protocol": "https", "host": "2001:db8::1", "port": 443, "allowed": true}, + {"policy": "ipv6", "protocol": "https", "host": "[2001:DB8:0:0:0:0:0:1]", "port": 443, "allowed": true}, + {"policy": "ipv6", "protocol": "https", "host": "2001:db8::2", "port": 443, "allowed": false} + ], + "address": [ + {"address": "8.8.8.8", "public": true, "multicast": false}, + {"address": "0.0.0.0", "public": false, "multicast": false}, + {"address": "10.0.0.1", "public": false, "multicast": false}, + {"address": "127.0.0.1", "public": false, "multicast": false}, + {"address": "169.254.1.1", "public": false, "multicast": false}, + {"address": "172.16.0.1", "public": false, "multicast": false}, + {"address": "172.31.255.255", "public": false, "multicast": false}, + {"address": "172.32.0.1", "public": true, "multicast": false}, + {"address": "192.168.0.1", "public": false, "multicast": false}, + {"address": "100.64.0.1", "public": false, "multicast": false}, + {"address": "100.127.255.255", "public": false, "multicast": false}, + {"address": "100.128.0.1", "public": true, "multicast": false}, + {"address": "224.0.0.1", "public": false, "multicast": true}, + {"address": "239.255.255.250", "public": false, "multicast": true}, + {"address": "255.255.255.255", "public": false, "multicast": false}, + {"address": "::1", "public": false, "multicast": false}, + {"address": "::", "public": false, "multicast": false}, + {"address": "fe80::1", "public": false, "multicast": false}, + {"address": "febf::1", "public": false, "multicast": false}, + {"address": "fec0::1", "public": true, "multicast": false}, + {"address": "fc00::1", "public": false, "multicast": false}, + {"address": "fd12:3456::1", "public": false, "multicast": false}, + {"address": "ff02::1", "public": false, "multicast": true}, + {"address": "2001:db8::1", "public": true, "multicast": false}, + {"address": "::ffff:10.0.0.1", "public": false, "multicast": false}, + {"address": "::ffff:8.8.8.8", "public": true, "multicast": false} + ], + "listen": [ + {"policy": "standard", "protocol": "http", "address": "0.0.0.0", "port": 8080, "allowed": true}, + {"policy": "standard", "protocol": "http", "address": "0.0.0.0", "port": 8081, "allowed": false}, + {"policy": "standard", "protocol": "http", "address": "127.0.0.1", "port": 0, "allowed": true}, + {"policy": "standard", "protocol": "http", "address": "127.0.0.1", "port": 8080, "allowed": false}, + {"policy": "standard", "protocol": "https", "address": "0.0.0.0", "port": 8080, "allowed": false}, + {"policy": "standard", "protocol": "http", "address": "::", "port": 8080, "allowed": false}, + {"policy": "secure-only", "protocol": "http", "address": "0.0.0.0", "port": 8080, "allowed": false}, + {"policy": "ipv6", "protocol": "https", "address": "::", "port": 8443, "allowed": true}, + {"policy": "ipv6", "protocol": "https", "address": "0:0:0:0:0:0:0:0", "port": 8443, "allowed": true}, + {"policy": "ipv6", "protocol": "https", "address": "::1", "port": 8443, "allowed": false}, + {"policy": "ipv6", "protocol": "http", "address": "::", "port": 8443, "allowed": false} + ] +} diff --git a/contracts/spec/ws.ts b/contracts/spec/ws.ts new file mode 100644 index 00000000..5f298f6a --- /dev/null +++ b/contracts/spec/ws.ts @@ -0,0 +1,195 @@ +// PocketJS ws spec v2 — the boundary of the WebSocket Client module +// (`globalThis.ws`). +// +// The public SDK is `@pocketjs/framework/net/websocket` (`connect`). This file +// fixes the guest ↔ core boundary underneath it. WebSocket is its own module: +// its own spec, core, capability (`network.websocket.client` / `.tls`) and +// namespace, mounted only by hosts that ship it. It shares the transport/TLS/ +// queue substrate, the policy input, the frame contract and the error +// vocabulary (contracts/spec/net.ts NET_ERROR) with the HTTP modules. +// +// Frame contract: identical to net — completions become visible at +// `begin_tick()`, `poll()` runs once per tick from the framework service pump, +// handlers run synchronously inside that pump and return void, Promise +// reactions run in the same tick's job drain. The core answers pings itself +// (RFC 6455 §5.5.3) and never sends keepalive pings on its own. +// +// If you change ANY value here: run `bun run gen` and commit the regenerated +// engine/core/src/spec.rs and engine/net/include/pocketjs/net/spec.h. + +export const WS_SPEC_MAJOR = 2; +export const WS_SPEC_MINOR = 0; + +// --------------------------------------------------------------------------- +// Ops (guest -> core, all synchronous; codes append-only) +// --------------------------------------------------------------------------- +// +// connect(metaJson) -> handle | -1 +// Static validation only (`ws:`/`wss:` scheme + capability, endpoint +// rule, insecureTransport, header syntax + forbidden headers, protocol +// tokens, limits, socket count). DNS/filtering/TCP/TLS/handshake happen +// asynchronously: success arrives as `open`, failure as `error`. +// send(handle, opcode, payload:string|ArrayBuffer|null) -> status +// opcode uses the RFC 6455 values: 1 text, 2 binary, 9 ping, 10 pong. +// The payload is snapshotted into the bounded send queue inside the +// call; a message is accepted whole or not at all. Returns +// WS_SEND_ACCEPTED (0), WS_SEND_ACCEPTED_HIGH_WATER (1), +// WS_SEND_CLOSED (-1), WS_SEND_BACKPRESSURE (-2, `drain` armed), +// WS_SEND_INVALID (-3: over maxMessageBytes, control payload > 125, +// bad opcode). +// receiveInto(handle, into:ArrayBuffer, offset, length) -> bytes | -1 +// Dequeue the head BINARY message into into[offset..]. `length` must be +// >= the message's `bytes`, else -1 and nothing is dequeued. +// close(handle, code?, reason?) -> 0 | -1 | -3 +// Start the close handshake after the accepted messages; the terminal +// `close` event follows once the peer answers or closeMs elapses. code +// is omitted, 1000 or 3000–4999; reason is UTF-8 <= 123 bytes (else -3). +// -1 when not open or already closing. +// terminate(handle) +// Abort the transport without a Close frame; next tick delivers +// `close{code:1006, clean:false, local:true}` (or `error{cancelled}` if +// the handshake never completed). No-op on a terminal handle. +// bufferedAmount(handle) -> bytes | -1 +// Payload bytes accepted by the core and not yet handed to transport. +// poll() -> string | undefined +// lastError() -> string +// limits() -> string + +export const WS_OP = { + connect: 1, + send: 2, + receiveInto: 3, + close: 4, + terminate: 5, + bufferedAmount: 6, + poll: 7, + lastError: 8, + limits: 9, +} as const; + +/** `send` return values. */ +export const WS_SEND_ACCEPTED = 0; +export const WS_SEND_ACCEPTED_HIGH_WATER = 1; +export const WS_SEND_CLOSED = -1; +export const WS_SEND_BACKPRESSURE = -2; +export const WS_SEND_INVALID = -3; + +/** RFC 6455 opcodes accepted by `send`. */ +export const WS_OPCODE = { + text: 1, + binary: 2, + ping: 9, + pong: 10, +} as const; + +// --------------------------------------------------------------------------- +// Events (core -> guest, one JSON array per tick, sequence order) +// --------------------------------------------------------------------------- +// +// {"t":"open","h":n,"protocol":"telemetry.v1"} +// {"t":"message","h":n,"kind":"text","text":"…"} +// {"t":"message","h":n,"kind":"binary","bytes":1234} -> receiveInto +// {"t":"ping","h":n,"payload":{"$b":"base64"}} (already answered) +// {"t":"pong","h":n,"payload":{"$b":"base64"}} +// {"t":"drain","h":n} +// {"t":"error","h":n,"code":"…","message":"…","causeCode":"…","status":403} +// {"t":"close","h":n,"code":1000,"reason":"","clean":true,"local":false} +// +// Per handle: `error` (before open, terminal) or +// `open → (message | ping | pong | drain)* → [error →] close`; nothing +// follows `close`. Fragmented messages are reassembled natively; oversized +// inbound messages close with 1009, an unqueueable message with 1013, +// protocol violations with 1002, invalid UTF-8 with 1007 — all reported as +// `error{…} → close`. + +export const WS_EVENT = { + open: "open", + message: "message", + ping: "ping", + pong: "pong", + drain: "drain", + error: "error", + close: "close", +} as const; + +/** Marker key for a bytes payload inside event JSON (db/fs blob spelling). */ +export const WS_BLOB_KEY = "$b"; + +// --------------------------------------------------------------------------- +// Data contract +// --------------------------------------------------------------------------- + +export interface WsConnectMeta { + url: string; + protocols?: readonly string[]; + headers?: Record; + timeouts?: { connectMs?: number; closeMs?: number }; + limits?: { + maxMessageBytes?: number; + receiveQueueBytes?: number; + receiveQueueMessages?: number; + sendQueueBytes?: number; + }; + tls?: { verification?: "full" | "development-insecure" }; +} + +export interface WsLimits { + specMajor: number; + specMinor: number; + maxSockets: number; + maxTlsInflight: number; + maxMessageBytes: number; + maxReceiveQueueBytes: number; + maxReceiveQueueMessages: number; + maxSendQueueBytes: number; + sendHighWaterBytes: number; + sendLowWaterBytes: number; + maxHandshakeHeaders: number; + maxHandshakeHeaderBytes: number; + maxEventsPerTick: number; + maxTickBytes: number; + defaultConnectMs: number; + maxConnectMs: number; + defaultCloseMs: number; + tlsMinVersion: string; + features: readonly string[]; +} + +/** Request headers the guest may not set; the core owns them. */ +export const WS_FORBIDDEN_HEADERS = [ + "host", + "connection", + "upgrade", + "content-length", + "sec-websocket-key", + "sec-websocket-version", + "sec-websocket-protocol", + "sec-websocket-extensions", + "sec-websocket-accept", +] as const; + +// --------------------------------------------------------------------------- +// Portable limits (ceilings; hosts only tighten) +// --------------------------------------------------------------------------- + +/** Sockets alive at once, including handshaking and closing ones. */ +export const WS_MAX_SOCKETS = 8; +/** One message, inbound or outbound; fragment reassembly is bounded by it. */ +export const WS_MAX_MESSAGE_BYTES = 1024 * 1024; +/** Reassembled, undelivered inbound messages per socket. */ +export const WS_MAX_RECEIVE_QUEUE_BYTES = 1024 * 1024; +export const WS_MAX_RECEIVE_QUEUE_MESSAGES = 64; +/** Accepted, unsent outbound payload per socket. */ +export const WS_MAX_SEND_QUEUE_BYTES = 1024 * 1024; +/** `send` returns 1 above the high mark; `drain` fires below the low mark. */ +export const WS_SEND_HIGH_WATER_BYTES = 256 * 1024; +export const WS_SEND_LOW_WATER_BYTES = 64 * 1024; +export const WS_MAX_HANDSHAKE_HEADERS = 64; +export const WS_MAX_HANDSHAKE_HEADER_BYTES = 16 * 1024; +export const WS_MAX_EVENTS_PER_TICK = 128; +export const WS_MAX_TICK_BYTES = 256 * 1024; +export const WS_DEFAULT_CONNECT_MS = 30_000; +export const WS_MAX_CONNECT_MS = 120_000; +export const WS_DEFAULT_CLOSE_MS = 5_000; +/** RFC 6455 control-frame payload ceiling. */ +export const WS_CONTROL_PAYLOAD_MAX = 125; diff --git a/docs/NET.md b/docs/NET.md index dd16c767..fa451707 100644 --- a/docs/NET.md +++ b/docs/NET.md @@ -1,139 +1,209 @@ -# NET module +# Network modules -The NET module gives a guest one bounded HTTP client API: +PocketJS networking is a set of explicitly imported modules over three +spec-pinned guest boundaries. Applications import from +`@pocketjs/framework/net/*`; hosts mount `globalThis.net`, `globalThis.ws` +and `globalThis.httpd`; the cores in between own the wire, the limits and +the tick-boundary delivery. The pinned boundaries live in +`contracts/spec/net.ts`, `contracts/spec/ws.ts` and `contracts/spec/httpd.ts`. ```ts -import { fetch } from "@pocketjs/framework/net"; +import { fetch, serve, Response } from "@pocketjs/framework/net/http"; +import { connect } from "@pocketjs/framework/net/websocket"; +import { AbortController, NetworkError, URL, getNetworkLimits } from "@pocketjs/framework/net"; -const response = await fetch("https://api.example.com/items", { +const response = await fetch("http://api.example.test/items", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "Pocket" }), - timeoutMs: 5_000, - maxBytes: 64 * 1024, + timeouts: { headersMs: 5_000 }, }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); -const value = await response.json(); -``` - -This is fetch-shaped, not the complete browser Fetch standard. V1 includes -`fetch`, common application methods, string/byte request bodies, headers, -timeouts, a response-size limit, and buffered `text()`, `json()`, `bytes()` -and `arrayBuffer()` reads. It does not include `Request`, `Headers`, streams, -cookies, cache, proxy configuration, `AbortSignal`, WebSocket, servers, or raw -sockets. +for await (const chunk of response.body!) consume(chunk); // or response.json() -## Module ownership +const server = await serve({ + hostname: "0.0.0.0", + port: 8080, + fetch: (request) => new Response(`hello ${new URL(request.url).pathname}`), +}); -| Layer | Upstream artifact | Owns | -| --- | --- | --- | -| SDK | `framework/src/net-api.ts` | `fetch`, `PocketResponse`, validation, lazy Promise delivery | -| Spec | `contracts/spec/net.ts` | five ops, two event shapes, buffer ownership, limits, portable errors, tick timing | -| Core | `engine/crates/pocket-net` | handles, request lifecycle, limits, event batches, completed bodies, transport interface | -| Deterministic host | `hosts/sim/net.ts` | fixture routes and virtual-tick completions for conformance tests | -| Browser host | `hosts/web/net.js` | browser `fetch` transport, bounded streaming read, redirects, tick staging | - -The physical HTTP implementation belongs to the host that owns the network -resource. PocketJS does not choose one transport library for every runtime. -A desktop runtime can adapt `ureq`, an ESP runtime can adapt -`esp_http_client`, and an Apple host can adapt `URLSession`; none of those -libraries become part of the guest contract or the transport-neutral core. - -A product runtime outside this repository keeps its adapter in that runtime's -repository. An adapter belongs under `hosts//` here only when PocketJS -itself owns and tests that host. The framework SDK, canonical spec, reference -core, and deterministic sim stay upstream because every host must agree on -them. - -## Native transport boundary - -`pocket-net` asks the host for only three operations: - -```rust -pub trait HttpTransport { - fn start(&mut self, request: HttpRequest) -> Result<(), NetFailure>; - fn cancel(&mut self, handle: i32); - fn drain(&mut self, completions: &mut Vec); -} +const socket = await connect("ws://broker.example.test/telemetry", { + protocols: ["telemetry.v1"], + socket: { message: (socket, data) => socket.send(data) }, +}); ``` -`start` hands an owned request to a worker or native async facility and must -return promptly. `drain` is non-blocking and is called by the host once at a -tick boundary. Network threads never call QuickJS. The reference core turns -drained completions into one JSON event batch; the guest consumes that batch -during its next normal turn. - -`NetSurface` — the one-line `globalThis.net` install on `pocket-mod` hosts — -is the crate's `mount` feature (default). A host with its own QuickJS wiring -depends with `default-features = false` and drives `NetCore` directly, so the -MCU build never compiles an engine it doesn't use (the `pocket-fs` pattern). - -For a runtime using `NetSurface`, the host loop is: - -```text -transport threads work independently - ↓ -net.begin_tick() drain completed transport work - ↓ -guest.frame(...) framework service pump calls net.poll() if needed - ↓ -guest job drain fetch Promise reactions run +## Modules and capabilities + +| Import | Boundary | Capability | Status | +| --- | --- | --- | --- | +| `@pocketjs/framework/net` | none (support module: types, `AbortController`, `AbortSignal`, `URL`, `NetworkError`, `getNetworkLimits`) | — | delivered | +| `@pocketjs/framework/net/http` `fetch`, `Headers`, `Request`, `Response` | `globalThis.net` — `contracts/spec/net.ts` | `network.http.client` (+ `.tls`) | plaintext + TLS; C/Rust cores, sim/web/ESP-IDF hosts | +| `@pocketjs/framework/net/http` `serve` | `globalThis.httpd` — `contracts/spec/httpd.ts` | `network.http.server` (+ `.tls`) | staged contract, implemented in the C core and sim host | +| `@pocketjs/framework/net/websocket` `connect` | `globalThis.ws` — `contracts/spec/ws.ts` | `network.websocket.client` (+ `.tls`) | implemented in the C core and the sim and ESP-IDF hosts | + +The capability ids are registered in `contracts/spec/platforms.ts`. **No stock +target advertises them yet**: a target appends an id only when its native host +ships and tests the module. Importing a module never grants access: every +command is checked against the application's network policy, which the +Build Plan owns (next section). + +## Network policy: manifest → plan → host + +The policy has one author, the application manifest, and one carrier, the +Build Plan. A **format 3** `pocket.json` (`"pocket": 3`, +`https://pocketjs.dev/schema/pocket-3.json`) declares it under +`permissions.network`: + +```json +"permissions": { + "network": { + "connect": [ + { "protocol": "https", "host": "api.example.com", "port": 443 }, + { "protocol": "https", "host": "*.devices.example.com", "port": { "min": 8443, "max": 8443 } }, + { "protocol": "http", "host": "192.168.1.20", "port": 8080 } + ], + "listen": [{ "protocol": "http", "address": "0.0.0.0", "port": 8080 }], + "credentials": ["device-cert"], + "localNetwork": false, + "insecureTransport": false, + "allowInvalidTlsForDevelopment": false + } +} ``` -There is no idle native polling. The framework service-pump set is normally -empty. The first pending `fetch` registers the NET pump; the final completion -removes it. While requests are pending there is one `poll()` FFI call per -guest tick, and that call drains the whole visible batch rather than one event -per crossing. - -## Bounded whole responses - -V1 resolves `fetch` only after the response body is complete. The transport -still reads incrementally and must stop as soon as `maxBytes` is exceeded; -the reference core checks the final size again before making it visible. -Consequently a slow or large response does not block the guest and cannot -grow without bound, but V1 is not suitable for media downloads or other -payloads that fundamentally require streaming. - -| Limit | V1 value | -| --- | ---: | -| Concurrent requests | 2 | -| Request body | 64 KiB | -| Response body default | 128 KiB | -| Response body absolute maximum | 256 KiB | -| Headers | 32 fields / 8 KiB | -| Timeout | 30 s default / 120 s maximum | -| Redirects | 3 | - -Two concurrent requests bound TLS buffers, worker state, and completed-body -memory while covering the usual foreground request plus asset/config request. -The response cap is selected per call so a small JSON endpoint can use a much -tighter budget than the global ceiling. - -## Method set - -V1 accepts `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, and `OPTIONS`. -These are the common application methods that portable embedded HTTP clients -can express. `CONNECT` creates a tunnel and `TRACE` has distinct security and -proxy semantics, so neither belongs in an app-level fetch module. Arbitrary -extension methods can be added later only when more than one real host needs -them; keeping a closed set today lets every target make the same promise. - -## Body ownership - -The request body is borrowed only for the synchronous `net.start` call and is -copied into host-owned memory before that call returns. A done event includes -the exact response byte count. The guest allocates one exactly-sized -`ArrayBuffer`, then `net.take(handle, buffer)` copies into it and deletes the -core's copy. This makes ownership explicit and keeps the ABI independent of a -specific QuickJS wrapper's object-lifetime rules. - -## Errors and HTTP status - -Transport failures reject with `NetError` and a portable `code` such as -`dns`, `connect`, `tls`, `timeout`, `redirect`, or `response_too_large`. -An HTTP 404 or 500 is a successful HTTP exchange: `fetch` resolves, -`response.status` carries the code, and `response.ok` is false. This preserves -the useful part of browser fetch behavior without importing its larger object -model. +`contracts/spec/network-policy.ts` is the typed contract: the rule shapes, the +normalization the resolver applies (lowercase A-label hostnames, canonical IP +literals, single-port ranges collapsed, rules sorted, exact duplicates and +reversed ranges refused, `allowInvalidTlsForDevelopment` refused outside a +development build), the reference matcher, and the canonical JSON. The +resolver writes the normalized policy into `ResolvedBuildPlan.network`, so +`planHash` covers it; a format-2 manifest resolves to the deny-all policy. +`extractHostBuildInputs()` hands custom hosts `network.policyJson` (also +`POCKETJS_NETWORK_POLICY` in the host build environment) — **the exact string +a host passes to its core** (`pnet_runtime_create` in C, `NetPolicy::parse` +in Rust, the sim hosts' `policy` option). A host never authors or widens a +policy; the ESP-IDF smoke firmware embeds the projection its plan produced +(`tools/esp-idf.ts`). + +Enforcement is the same in every core, and the shared vectors +(`contracts/spec/vectors/network-policy.json`, run by the TypeScript +reference, `pnet_unit_test` and the Rust tests) pin it: the connect rule and +`insecureTransport` before DNS; every resolved candidate address after DNS +(loopback, link-local, RFC 1918, CGNAT, ULA only with `localNetwork`, +multicast never); the listen rule before bind; the endpoint rule again on +every redirect hop. In the Rust core these wire-side decisions go through +the `PolicyGate` the backend receives with each request, and the core +refuses a response whose URL the gate did not authorize. + +## Ownership + +| Layer | Artifact | Owns | +| --- | --- | --- | +| SDK | `framework/src/net/*.ts` | Fetch-shaped objects, body locking, `BodyStream` over `readInto`, the per-module guest binding (one `poll` per tick from the service pump), Promise settlement, `NetworkError` | +| Spec | `contracts/spec/{net,ws,httpd}.ts` | op codes (append-only), event shapes, metadata JSON, portable ceilings, the shared error vocabulary; generated mirrors `engine/core/src/spec.rs` and `engine/net/include/pocketjs/net/spec.h` (drift-guarded by `tests/contract.ts`) | +| Spec vectors | `contracts/spec/vectors/*.json` | the policy and HTTP-semantics decisions (methods, core-owned headers, bodyless / null-body statuses, redirect rewrites) every implementation reproduces | +| C core | `engine/net` | HTTP/1.1 client and server, RFC 6455 client, strict framing, bounded queues, policy, tick queues (transactional `poll`), and the TLS handshake state machine; a `pnet_driver_ops` socket driver (`drivers/posix`, with its own resolver worker so `getaddrinfo` never blocks the network task) and an optional `pnet_tls_ops` TLS provider (`drivers/openssl`, ESP-TLS) are the only host interfaces | +| Rust core | `engine/crates/pocket-net` | The HTTP Client core for Rust hosts over an `HttpClientBackend` that receives a `PolicyGate` (address / redirect / TLS authority); `mount` installs the six v2 ops through rquickjs | +| Deterministic hosts | `hosts/sim/{net,ws,httpd}.ts` | fixture routes/peers/injected requests with virtual-tick visibility for the SDK tests | +| Browser host | `hosts/web/net.js` | browser `fetch` behind the v2 ops (Browser profile: no redirect following, TLS by the browser) | +| ESP-IDF host | `hosts/esp-idf` | QuickJS-ng owner task, network task, bindings, AtomS3R/Tab5 bring-up, the hardware smoke | + +## TLS + +TLS is an add-on capability per protocol role (`network.http.client.tls`, +`network.websocket.client.tls`, …). A host advertises the `"tls"` feature — +and `https:`/`wss:` become usable — only when it supplies a **TlsProvider** +(`pnet_tls_ops`) to `pnet_runtime_create_tls`; there is never a plaintext +fallback. The core owns the connect deadline, cancellation and the policy; +the provider owns host trust, entropy and the wire. `serverName` equals the +authorized hostname and is both the SNI sent and the DNS-ID/IP-ID the +certificate must match (TLS 1.2 minimum, renegotiation and 0-RTT off). +Before any I/O, a verifying connection fails closed with +`tls_clock_untrusted` when the platform reports the wall clock untrusted. +"Trusted" is a state the platform maintains, not a date check: the ESP-IDF +board layer latches it when an SNTP sync completes (and on every re-sync) or +when the product asserts it from a validated RTC; until then TLS fails +closed. Handshake failures map to the +four stable codes `tls_certificate_invalid`, `tls_hostname_mismatch`, +`tls_handshake_failed` and `tls_clock_untrusted`. + +Providers in the tree: `engine/net/drivers/openssl` (the reference +`NativeTlsProvider` for POSIX, and the peer for the conformance suite) and +`hosts/esp-idf/components/pocketjs_net_esptls` (ESP-TLS + the IDF certificate +bundle). The desktop conformance harness (`engine/net/test/tls_test.c`) +covers a valid chain, unknown CA, expired cert, hostname mismatch, an +untrusted clock, the development-insecure refusal and a WSS echo against an +in-process OpenSSL PKI. + +## Delivery + +Network facts enter the guest only at frame boundaries. The host runs the +tick boundary (`pnet_runtime_begin_tick()` in C, `NetCore::begin_tick()` in +Rust, `beginFrame()` in the browser host) **before** each `frame()`; that +freezes the visible set: completed events plus one `readable` watermark per +handle with new bytes, inserted ahead of that handle's `end`. Inside +`frame()` the framework service pump calls each mounted module's `poll()` +once, and the SDK copies body bytes with `readInto` in the same call graph. +Promise reactions run in the same tick's job drain. **The upper bound for a +network round trip to reach application code is one frame period**; the +per-tick budget (`maxEventsPerTick`, `maxTickBytes`) leaves excess events +queued natively in sequence order for the next tick. `poll()` is +transactional: the core sizes and reserves the batch before it dequeues a +single event, so memory pressure can delay a batch (the next poll retries) +but never drops one — a handle's terminal `end`/`error` is never lost to an +allocation failure. Hosts that marshal the batch into a guest value use the +two-phase `*_poll_render` / `*_poll_consume` and consume only once the guest +holds its copy. + +Body bytes never live in JS until read: the native receive queue +(`queueBytes`, default 32 KiB, host-tightened on MCUs) is the backpressure +window and a **hard bound** — the core reads at most the free space, and +when the queue is full it stops reading the socket so TCP flow control +holds the peer. `clone()` is a bounded tee: a branch's backlog never exceeds +the aggregate limit (each pull is sized to the remaining room). `text()`, +`json()` and `arrayBuffer()` are SDK helpers over the same path with an +aggregate cap (`response_too_large`). The browser dev host holds the same +bound through a BYOB reader where the body is a byte stream; its default +reader fallback can overshoot by one browser chunk. + +## Errors + +Every failure is a `NetworkError` with a stable `code` from +`contracts/spec/net.ts` and a derived `category`: + +| Category | Codes | +| --- | --- | +| runtime | `cancelled` `timeout` `closed` `invalid_request` `invalid_state` `busy` `resource_limit` `unsupported` `permission_denied` `unavailable` | +| resolver | `dns` | +| transport | `connect` `address_in_use` | +| tls | `tls_certificate_invalid` `tls_hostname_mismatch` `tls_handshake_failed` `tls_clock_untrusted` | +| protocol | `redirect` `response_too_large` `protocol` `websocket_handshake_failed` `websocket_protocol_error` `message_too_large` | + +Platform codes travel in `causeCode`; HTTP status of a failed WebSocket +handshake in `reasonCode`. HTTP 4xx/5xx are successful exchanges. + +## Limits + +Spec constants are portable ceilings; each host reports its tightened values +through `limits()` (`getNetworkLimits()` in the SDK). The ESP-IDF host +defaults to 4 HTTP handles, 16 KiB receive queues (64 KiB max), 256 KiB +aggregate default, 64 KiB per-tick bytes, 4 WebSocket sockets with 64 KiB +messages, 8 server connections / 4 inflight requests, and a 1 MiB core heap +cap; the measured smoke steady state is documented in +[hosts/esp-idf/README.md](../hosts/esp-idf/README.md). + +## Headless hosts + +A host without a UI still ticks `frame()`. `mountHeadless()` from +`@pocketjs/framework/headless` installs the frame transaction prefix +(virtual clock → service pumps → effect delivery → optional app hook) +without a renderer, so a display-less device runs the same network delivery +model. The ESP-IDF smoke firmware uses it. + +## Testing + +- `bun test tests/net.test.ts tests/net-httpd.test.ts tests/net-websocket.test.ts tests/net-web.test.js` — SDK against the deterministic hosts. +- `cmake -S engine/net -B engine/net/build && cmake --build engine/net/build && ctest --test-dir engine/net/build` — C core unit tests, the socket harness, and (when OpenSSL is present) the TLS conformance suite, all under ASan/UBSan. +- `cargo test -p pocket-net --manifest-path engine/Cargo.toml` — Rust core. +- `bun tools/net-peer.ts` + `hosts/esp-idf/examples/net-smoke` — the hardware gate against an independent peer and board-to-board. diff --git a/docs/RUNTIMES.md b/docs/RUNTIMES.md index 81211004..7b809f3b 100644 --- a/docs/RUNTIMES.md +++ b/docs/RUNTIMES.md @@ -146,7 +146,7 @@ The grammar is implemented once, as infrastructure every runtime reuses: | Crate | Role | | --- | --- | | `pocket-mod` | Guest hosting: QuickJS realm lifecycle, surface mounting (`mount("ui", ops)`), per-tick pump (frame call + job drain + timers), console, hot reload. The "mod runtime" capability, as a library. | -| `pocket-net` | Transport-neutral NET core and `globalThis.net` surface: validates bounded HTTP requests, owns handles/bodies and tick event batches, and accepts a host-owned `HttpTransport` adapter. See [NET.md](./NET.md). | +| `pocket-net` | The Rust HTTP Client core behind `globalThis.net` (spec v2): validates requests against the immutable policy, owns handles, bounded receive queues, the tick-boundary visible set and `readInto`, and drives a host-owned `HttpClientBackend`. The C twin for MCU hosts is `engine/net`. See [NET.md](./NET.md). | | `pocket-ui-wgpu` | The `ui` surface, desktop edition: feeds paks to `pocketjs-core`, exposes the 17 `HostOps` ops to the guest, renders the DrawList through wgpu into any render target — a window (standalone app host) or an overlay pass over a 3D scene (game HUD). | | `pocket-widget` | The desktop-widget capability (WIDGET.md): a widget window shell whose guest ticks at a fixed rate while GPU frames render on demand, embedded `ui` surfaces bound onto meshes, and cursor-ray part picking mapped to declared inputs. `pocket-stage` is the first runtime on it; its bundled PSP stage runs admitted fixed-viewport apps unmodified. | | `pocketjs-core` | The 2D UI core (unchanged; now viewport-parameterized). | diff --git a/engine/core/src/spec.rs b/engine/core/src/spec.rs index a1420e62..e58dc48b 100644 --- a/engine/core/src/spec.rs +++ b/engine/core/src/spec.rs @@ -550,36 +550,168 @@ pub mod fs { pub const MAX_DIR_ENTRIES: usize = 256; } -/// NET module boundary (contracts/spec/net.ts — `globalThis.net`). -/// Bounded whole-response HTTP; completions batch to tick boundaries. +/// NET module boundary (contracts/spec/net.ts — `globalThis.net`, spec v2). +/// Streaming HTTP/1.1 client; completions batch to tick boundaries. pub mod net { + pub const SPEC_MAJOR: u32 = 2; + pub const SPEC_MINOR: u32 = 0; pub const OP_START: u8 = 1; pub const OP_TAKE: u8 = 2; pub const OP_CANCEL: u8 = 3; pub const OP_POLL: u8 = 4; pub const OP_LAST_ERROR: u8 = 5; - pub const MAX_INFLIGHT: usize = 2; - pub const MAX_REQUEST_BYTES: usize = 65536; - pub const DEFAULT_RESPONSE_BYTES: usize = 131072; - pub const MAX_RESPONSE_BYTES: usize = 262144; - pub const MAX_HEADERS: usize = 32; - pub const MAX_HEADER_BYTES: usize = 8192; + pub const OP_READ_INTO: u8 = 6; + pub const OP_LIMITS: u8 = 7; + pub const OP_WRITE: u8 = 8; + pub const OP_END_BODY: u8 = 9; + pub const MAX_INFLIGHT: usize = 8; + pub const MAX_REQUEST_BYTES: usize = 262144; + pub const DEFAULT_QUEUE_BYTES: usize = 32768; + pub const MAX_QUEUE_BYTES: usize = 262144; + pub const DEFAULT_AGGREGATE_BYTES: usize = 1048576; + pub const MAX_AGGREGATE_BYTES: usize = 8388608; + pub const MAX_EVENTS_PER_TICK: usize = 128; + pub const MAX_TICK_BYTES: usize = 262144; + pub const MAX_HEADERS: usize = 64; + pub const MAX_HEADER_BYTES: usize = 16384; pub const DEFAULT_TIMEOUT_MS: u32 = 30000; pub const MAX_TIMEOUT_MS: u32 = 120000; - pub const MAX_REDIRECTS: usize = 3; - pub const METHODS: [&str; 7] = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]; - pub const EVENT_DONE: &str = "done"; + pub const MAX_REDIRECTS: usize = 5; + pub const TLS_MIN_VERSION: &str = "1.2"; + pub const METHODS_FORBIDDEN: [&str; 3] = ["CONNECT", "TRACE", "TRACK"]; + /// HTTP semantics shared by client, server and SDK (see net.ts). + pub const HTTP_CORE_OWNED_REQUEST_HEADERS: [&str; 10] = ["host", "connection", "content-length", "transfer-encoding", "trailer", "te", "upgrade", "keep-alive", "expect", "proxy-connection"]; + pub const HTTP_BODYLESS_STATUS: [u16; 2] = [204, 304]; + pub const HTTP_NULL_BODY_STATUS: [u16; 5] = [101, 103, 204, 205, 304]; + pub const HTTP_REDIRECT_STATUS: [u16; 5] = [301, 302, 303, 307, 308]; + pub const HTTP_REDIRECT_POST_TO_GET_STATUS: [u16; 2] = [301, 302]; + pub const HTTP_REDIRECT_ANY_TO_GET_STATUS: [u16; 1] = [303]; + pub const EVENT_HEADERS: &str = "headers"; + pub const EVENT_READABLE: &str = "readable"; + pub const EVENT_END: &str = "end"; pub const EVENT_ERROR: &str = "error"; - pub const ERROR_UNAVAILABLE: &str = "unavailable"; + pub const EVENT_DRAIN: &str = "drain"; + /// Error vocabulary shared by net, ws and httpd. pub const ERROR_INVALID_REQUEST: &str = "invalid_request"; + pub const ERROR_INVALID_STATE: &str = "invalid_state"; + pub const ERROR_UNSUPPORTED: &str = "unsupported"; + pub const ERROR_PERMISSION_DENIED: &str = "permission_denied"; pub const ERROR_BUSY: &str = "busy"; + pub const ERROR_RESOURCE_LIMIT: &str = "resource_limit"; pub const ERROR_DNS: &str = "dns"; pub const ERROR_CONNECT: &str = "connect"; - pub const ERROR_TLS: &str = "tls"; + pub const ERROR_ADDRESS_IN_USE: &str = "address_in_use"; + pub const ERROR_CLOSED: &str = "closed"; pub const ERROR_TIMEOUT: &str = "timeout"; + pub const ERROR_TLS_CERTIFICATE_INVALID: &str = "tls_certificate_invalid"; + pub const ERROR_TLS_HOSTNAME_MISMATCH: &str = "tls_hostname_mismatch"; + pub const ERROR_TLS_HANDSHAKE_FAILED: &str = "tls_handshake_failed"; + pub const ERROR_TLS_CLOCK_UNTRUSTED: &str = "tls_clock_untrusted"; pub const ERROR_REDIRECT: &str = "redirect"; pub const ERROR_RESPONSE_TOO_LARGE: &str = "response_too_large"; pub const ERROR_PROTOCOL: &str = "protocol"; + pub const ERROR_WEBSOCKET_HANDSHAKE_FAILED: &str = "websocket_handshake_failed"; + pub const ERROR_WEBSOCKET_PROTOCOL_ERROR: &str = "websocket_protocol_error"; + pub const ERROR_MESSAGE_TOO_LARGE: &str = "message_too_large"; pub const ERROR_CANCELLED: &str = "cancelled"; pub const ERROR_OTHER: &str = "other"; + pub const ERROR_UNAVAILABLE: &str = "unavailable"; +} + +/// WS module boundary (contracts/spec/ws.ts — `globalThis.ws`, spec v2). +/// RFC 6455 client; messages batch to tick boundaries. +pub mod ws { + pub const SPEC_MAJOR: u32 = 2; + pub const SPEC_MINOR: u32 = 0; + pub const OP_CONNECT: u8 = 1; + pub const OP_SEND: u8 = 2; + pub const OP_RECEIVE_INTO: u8 = 3; + pub const OP_CLOSE: u8 = 4; + pub const OP_TERMINATE: u8 = 5; + pub const OP_BUFFERED_AMOUNT: u8 = 6; + pub const OP_POLL: u8 = 7; + pub const OP_LAST_ERROR: u8 = 8; + pub const OP_LIMITS: u8 = 9; + pub const SEND_ACCEPTED: i32 = 0; + pub const SEND_ACCEPTED_HIGH_WATER: i32 = 1; + pub const SEND_CLOSED: i32 = -1; + pub const SEND_BACKPRESSURE: i32 = -2; + pub const SEND_INVALID: i32 = -3; + pub const OPCODE_TEXT: u8 = 1; + pub const OPCODE_BINARY: u8 = 2; + pub const OPCODE_PING: u8 = 9; + pub const OPCODE_PONG: u8 = 10; + pub const EVENT_OPEN: &str = "open"; + pub const EVENT_MESSAGE: &str = "message"; + pub const EVENT_PING: &str = "ping"; + pub const EVENT_PONG: &str = "pong"; + pub const EVENT_DRAIN: &str = "drain"; + pub const EVENT_ERROR: &str = "error"; + pub const EVENT_CLOSE: &str = "close"; + pub const BLOB_KEY: &str = "$b"; + pub const FORBIDDEN_HEADERS: [&str; 9] = ["host", "connection", "upgrade", "content-length", "sec-websocket-key", "sec-websocket-version", "sec-websocket-protocol", "sec-websocket-extensions", "sec-websocket-accept"]; + pub const MAX_SOCKETS: usize = 8; + pub const MAX_MESSAGE_BYTES: usize = 1048576; + pub const MAX_RECEIVE_QUEUE_BYTES: usize = 1048576; + pub const MAX_RECEIVE_QUEUE_MESSAGES: usize = 64; + pub const MAX_SEND_QUEUE_BYTES: usize = 1048576; + pub const SEND_HIGH_WATER_BYTES: usize = 262144; + pub const SEND_LOW_WATER_BYTES: usize = 65536; + pub const MAX_HANDSHAKE_HEADERS: usize = 64; + pub const MAX_HANDSHAKE_HEADER_BYTES: usize = 16384; + pub const MAX_EVENTS_PER_TICK: usize = 128; + pub const MAX_TICK_BYTES: usize = 262144; + pub const DEFAULT_CONNECT_MS: u32 = 30000; + pub const MAX_CONNECT_MS: u32 = 120000; + pub const DEFAULT_CLOSE_MS: u32 = 5000; + pub const CONTROL_PAYLOAD_MAX: usize = 125; +} + +/// HTTPD module boundary (contracts/spec/httpd.ts — `globalThis.httpd`, spec v2). +/// HTTP/1.1 server; requests batch to tick boundaries. +pub mod httpd { + pub const SPEC_MAJOR: u32 = 2; + pub const SPEC_MINOR: u32 = 0; + pub const OP_LISTEN: u8 = 1; + pub const OP_STOP: u8 = 2; + pub const OP_RESPOND: u8 = 3; + pub const OP_WRITE: u8 = 4; + pub const OP_END_BODY: u8 = 5; + pub const OP_READ_INTO: u8 = 6; + pub const OP_ABORT: u8 = 7; + pub const OP_POLL: u8 = 8; + pub const OP_LAST_ERROR: u8 = 9; + pub const OP_LIMITS: u8 = 10; + pub const SEND_ACCEPTED: i32 = 0; + pub const SEND_INVALID_REQUEST: i32 = -1; + pub const SEND_BACKPRESSURE: i32 = -2; + pub const SEND_INVALID: i32 = -3; + pub const EVENT_LISTENING: &str = "listening"; + pub const EVENT_CLOSED: &str = "closed"; + pub const EVENT_ERROR: &str = "error"; + pub const EVENT_REQUEST: &str = "request"; + pub const EVENT_READABLE: &str = "readable"; + pub const EVENT_END: &str = "end"; + pub const EVENT_DRAIN: &str = "drain"; + pub const EVENT_ABORTED: &str = "aborted"; + pub const MAX_SERVERS: usize = 2; + pub const MAX_CONNECTIONS: usize = 16; + pub const MAX_INFLIGHT: usize = 8; + pub const MAX_BACKLOG: usize = 16; + pub const MAX_HEADERS: usize = 64; + pub const MAX_HEADER_BYTES: usize = 16384; + pub const MAX_TARGET_BYTES: usize = 2048; + pub const DEFAULT_REQUEST_QUEUE_BYTES: usize = 32768; + pub const MAX_REQUEST_QUEUE_BYTES: usize = 262144; + pub const MAX_SEND_QUEUE_BYTES: usize = 262144; + pub const SEND_HIGH_WATER_BYTES: usize = 131072; + pub const SEND_LOW_WATER_BYTES: usize = 32768; + pub const MAX_EVENTS_PER_TICK: usize = 128; + pub const MAX_TICK_BYTES: usize = 262144; + pub const DEFAULT_HEADER_MS: u32 = 10000; + pub const DEFAULT_BODY_IDLE_MS: u32 = 30000; + pub const DEFAULT_HANDLER_MS: u32 = 30000; + pub const DEFAULT_KEEP_ALIVE_MS: u32 = 15000; + pub const DEFAULT_CLOSE_MS: u32 = 5000; + pub const MAX_TIMEOUT_MS: u32 = 120000; } diff --git a/engine/crates/pocket-net/src/lib.rs b/engine/crates/pocket-net/src/lib.rs index 21d41688..f61d6ce4 100644 --- a/engine/crates/pocket-net/src/lib.rs +++ b/engine/crates/pocket-net/src/lib.rs @@ -1,65 +1,105 @@ -//! `pocket-net` — the transport-neutral core and mounted surface for the -//! PocketJS NET module (`contracts/spec/net.ts`). +//! pocket-net — the reference HTTP Client core behind `globalThis.net` +//! (contracts/spec/net.ts v2) for Rust hosts. //! -//! This crate owns handles, validation, limits, tick-boundary event batches, -//! response-body ownership and portable errors. It deliberately owns no DNS, -//! socket, TLS, HTTP parser, executor or thread. A runtime supplies an -//! [`HttpTransport`] implemented with the platform facility it already owns -//! (for example ESP-IDF HTTP, ureq, NSURLSession, or an application service). -//! The transport may work on other threads, but [`NetCore::begin_tick`] is -//! the only point at which its completions enter the single-threaded core. +//! The crate owns the guest-visible policy of the module — handle table, +//! immutable endpoint policy, per-handle bounded receive queues, the tick +//! boundary that freezes the visible set (`begin_tick`), the one `poll` batch +//! per tick, `readInto`, cancellation and the stable error vocabulary — and +//! delegates the wire to a host-supplied [`HttpClientBackend`]. A backend +//! implements HTTP/1.1 (or wraps a platform client) and reports streaming +//! completions; it never sees QuickJS. The `mount` feature installs the six +//! v2 ops on a `pocket_mod::Guest`. //! -//! Feature `mount` (default) adds [`NetSurface`], the pocket-mod adapter that -//! installs the five ops as `globalThis.net`. A host with its own QuickJS -//! wiring turns it off (`default-features = false`) and drives [`NetCore`] -//! directly — the MCU build then never compiles an engine it doesn't use. +//! Frame contract: the host calls +//! [`NetCore::begin_tick`] before every guest `frame()`; the framework +//! service pump then calls `poll` exactly once; completions that arrive +//! after `begin_tick` wait for the next tick. +//! +//! Security authority: the core owns the policy decisions. It checks the +//! endpoint rule and insecureTransport before the backend sees a request, +//! classifies literal addresses, and hands the backend a [`PolicyGate`] that +//! decides every wire-side question — each resolved address, each redirect +//! hop (with the spec's rewrite table and hop budget), the TLS verification +//! mode — and records what it authorized; the response URL a backend reports +//! must be one the gate authorized for that handle or the exchange fails +//! with `permission_denied`. The portable C implementation of the same +//! boundary (engine/net) applies the same rules inside its own dialer; +//! contracts/spec/vectors pin both. + +pub mod policy; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, VecDeque}; use pocketjs_core::spec::net as spec; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; + +pub use policy::{ + address_is_multicast, address_is_public, hostname_valid, parse_address, resolve_url, ConnectRule, HostRule, + ListenRule, NetPolicy, PolicyGate, PortRule, Protocol, RedirectPlan, TlsVerification, +}; -/// Fully validated request handed to a host-owned transport. The body is an -/// owned copy; a transport may move it to a worker without retaining JS data. +/// A request handed to the backend after the core validated it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct HttpRequest { pub handle: i32, pub url: String, pub method: String, + /// Lowercased names; framing/connection headers already removed. pub headers: BTreeMap, pub body: Vec, - pub timeout_ms: u32, - pub max_bytes: usize, - pub max_redirects: usize, + pub connect_ms: u32, + pub headers_ms: u32, + pub idle_ms: u32, + pub total_ms: u32, + pub redirect: RedirectMode, + pub max_redirects: u32, + pub max_body_bytes: Option, } -/// Normalized failure crossing from a host transport into the core. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RedirectMode { + Follow, + Manual, + Error, +} + +/// A stable failure: `code` is clamped onto the spec vocabulary. #[derive(Clone, Debug, PartialEq, Eq)] pub struct NetFailure { pub code: String, pub message: String, + pub cause: Option, } impl NetFailure { pub fn new(code: impl Into, message: impl Into) -> Self { + let code = code.into(); Self { - code: normalize_error_code(&code.into()).to_string(), + code: normalize_error_code(&code).to_string(), message: message.into(), + cause: None, } } } -/// A transport completion. Response headers must already be normalized to -/// lowercase, with repeated fields combined according to that transport's -/// HTTP implementation. Cookie storage is outside the v1 contract. +/// Streaming completions a backend produces for a handle, in order: +/// `Headers → Body* → End` or `… → Error`. #[derive(Clone, Debug, PartialEq, Eq)] -pub enum TransportCompletion { - Done { +pub enum BackendEvent { + Headers { handle: i32, status: u16, url: String, headers: BTreeMap, - body: Vec, + redirected: bool, + length: Option, + }, + Body { + handle: i32, + chunk: Vec, + }, + End { + handle: i32, }, Error { handle: i32, @@ -67,616 +107,1265 @@ pub enum TransportCompletion { }, } -/// The only host-specific boundary in the reference implementation. +/// The host-specific wire layer. The core calls it only from the owner +/// thread's `start`/`cancel`/`begin_tick`; a backend that runs I/O elsewhere +/// hands results over through `drain` at the tick boundary. /// -/// `start` must return promptly after handing work to its native async -/// mechanism or worker. `drain` is called once at a host tick boundary and -/// must not block. Neither method may call into QuickJS. -pub trait HttpTransport { - fn start(&mut self, request: HttpRequest) -> std::result::Result<(), NetFailure>; +/// The `gate` is the policy authority for everything that happens on the +/// wire side: the backend must call `gate.authorize_address` for every +/// candidate address before connecting, `gate.authorize_redirect` for every +/// redirect response before following it (and follow exactly its plan), and +/// apply `gate.tls_verification` to every TLS connection. It never decides +/// those itself; the core rejects a response whose URL the gate did not +/// authorize. +pub trait HttpClientBackend { + /// Begin the exchange; refusal is synchronous (`resource_limit` etc.). + fn start(&mut self, request: HttpRequest, gate: PolicyGate) -> Result<(), NetFailure>; + /// Best-effort cancellation; a later completion for the handle is dropped. fn cancel(&mut self, handle: i32); - fn drain(&mut self, completions: &mut Vec); + /// Move every completed event into `out` (tick boundary). + fn drain(&mut self, out: &mut Vec); + /// Whether the transport can carry TLS (advertises the "tls" feature). + fn supports_tls(&self) -> bool { + false + } + /// The backend stops reading a handle whose queue is at capacity and + /// resumes when told; the default ignores backpressure hints. + fn set_paused(&mut self, _handle: i32, _paused: bool) {} +} + +// --------------------------------------------------------------------------- +// Limits +// --------------------------------------------------------------------------- + +/// Host-tightened limits; `default()` is the spec ceiling. +#[derive(Clone, Debug)] +pub struct NetLimits { + pub max_inflight: usize, + pub max_request_bytes: usize, + pub default_queue_bytes: usize, + pub max_queue_bytes: usize, + pub default_aggregate_bytes: usize, + pub max_aggregate_bytes: usize, + pub max_events_per_tick: usize, + pub max_tick_bytes: usize, + pub max_headers: usize, + pub max_header_bytes: usize, + pub default_timeout_ms: u32, + pub max_timeout_ms: u32, + pub max_redirects: u32, +} + +impl Default for NetLimits { + fn default() -> Self { + Self { + max_inflight: spec::MAX_INFLIGHT, + max_request_bytes: spec::MAX_REQUEST_BYTES, + default_queue_bytes: spec::DEFAULT_QUEUE_BYTES, + max_queue_bytes: spec::MAX_QUEUE_BYTES, + default_aggregate_bytes: spec::DEFAULT_AGGREGATE_BYTES, + max_aggregate_bytes: spec::MAX_AGGREGATE_BYTES, + max_events_per_tick: spec::MAX_EVENTS_PER_TICK, + max_tick_bytes: spec::MAX_TICK_BYTES, + max_headers: spec::MAX_HEADERS, + max_header_bytes: spec::MAX_HEADER_BYTES, + default_timeout_ms: spec::DEFAULT_TIMEOUT_MS, + max_timeout_ms: spec::MAX_TIMEOUT_MS, + max_redirects: spec::MAX_REDIRECTS as u32, + } + } +} + +impl NetLimits { + fn clamp(mut self) -> Self { + let d = NetLimits::default(); + self.max_inflight = self.max_inflight.clamp(1, d.max_inflight); + self.max_request_bytes = self.max_request_bytes.clamp(1, d.max_request_bytes); + self.max_queue_bytes = self.max_queue_bytes.clamp(1, d.max_queue_bytes); + self.default_queue_bytes = self.default_queue_bytes.clamp(1, self.max_queue_bytes); + self.max_aggregate_bytes = self.max_aggregate_bytes.clamp(1, d.max_aggregate_bytes); + self.default_aggregate_bytes = self.default_aggregate_bytes.clamp(1, self.max_aggregate_bytes); + self.max_events_per_tick = self.max_events_per_tick.clamp(1, d.max_events_per_tick); + self.max_tick_bytes = self.max_tick_bytes.clamp(1, d.max_tick_bytes); + self.max_headers = self.max_headers.clamp(1, d.max_headers); + self.max_header_bytes = self.max_header_bytes.clamp(1, d.max_header_bytes); + self.max_timeout_ms = self.max_timeout_ms.clamp(1, d.max_timeout_ms); + self.default_timeout_ms = self.default_timeout_ms.clamp(1, self.max_timeout_ms); + self.max_redirects = self.max_redirects.min(d.max_redirects); + self + } } +// --------------------------------------------------------------------------- +// Core +// --------------------------------------------------------------------------- + #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RequestMeta { +struct StartMeta { url: String, method: String, + #[serde(default)] headers: BTreeMap, - timeout_ms: u32, - max_bytes: usize, + #[serde(default)] + queue_bytes: Option, + #[serde(default)] + max_body_bytes: Option, + #[serde(default)] + timeouts: Option, + #[serde(default)] + redirect: Option, + #[serde(default)] + max_redirects: Option, + #[serde(default)] + tls: Option, } -#[derive(Serialize)] -#[serde(tag = "t")] -enum GuestEvent { - #[serde(rename = "done")] - Done { - #[serde(rename = "h")] - handle: i32, - status: u16, - url: String, - headers: BTreeMap, - bytes: usize, - }, - #[serde(rename = "error")] - Error { - #[serde(rename = "h")] - handle: i32, - code: String, - message: String, - }, +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Timeouts { + connect_ms: Option, + headers_ms: Option, + idle_ms: Option, + total_ms: Option, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TlsMeta { + verification: Option, +} + +/// One rendered event waiting for a tick boundary or a poll. +struct QueuedEvent { + handle: i32, + /// `readable` insertions go before this event for the same handle. + barrier: bool, + weight: usize, + json: String, } -struct Inflight { - max_bytes: usize, +struct Handle { + head_pushed: bool, + terminal: bool, + queue_bytes: usize, + max_body_bytes: Option, + body_total: usize, + queue: VecDeque, + visible_bytes: usize, + dirty: bool, + paused: bool, } -/// Transport-neutral NET state machine. It is intentionally independent of -/// QuickJS; [`NetSurface`] below is only the namespace adapter. -pub struct NetCore { - transport: T, - inflight: HashMap, - bodies: HashMap>, - visible: Vec, +pub struct NetCore { + backend: B, + policy: NetPolicy, + gate: PolicyGate, + limits: NetLimits, + development_build: bool, + handles: BTreeMap, next_handle: i32, + pending: VecDeque, + visible: VecDeque, last_error: String, + limits_json: String, } -impl NetCore { - pub fn new(transport: T) -> Self { - Self { - transport, - inflight: HashMap::new(), - bodies: HashMap::new(), - visible: Vec::new(), +impl NetCore { + pub fn new(backend: B, policy: NetPolicy) -> Self { + Self::with_limits(backend, policy, NetLimits::default()) + } + + pub fn with_limits(backend: B, policy: NetPolicy, limits: NetLimits) -> Self { + let limits = limits.clamp(); + let gate = PolicyGate::new(policy.clone(), backend.supports_tls()); + let mut core = Self { + backend, + policy, + gate, + limits, + development_build: false, + handles: BTreeMap::new(), next_handle: 1, + pending: VecDeque::new(), + visible: VecDeque::new(), last_error: String::new(), - } + limits_json: String::new(), + }; + core.limits_json = core.render_limits(); + core + } + + /// Enable `tls.verification = "development-insecure"` when the policy + /// also allows it (never in production builds). + pub fn set_development_build(&mut self, enabled: bool) { + self.development_build = enabled; + self.gate.set_development_build(enabled); + } + + /// The policy gate (a clone is cheap): what the backend consults. + pub fn gate(&self) -> PolicyGate { + self.gate.clone() + } + + pub fn backend_mut(&mut self) -> &mut B { + &mut self.backend + } + + fn render_limits(&self) -> String { + let l = &self.limits; + let features = if self.backend.supports_tls() { "[\"tls\"]" } else { "[]" }; + format!( + "{{\"specMajor\":{},\"specMinor\":{},\"maxInflight\":{},\"maxTlsInflight\":{},\"maxRequestBytes\":{},\ + \"defaultQueueBytes\":{},\"maxQueueBytes\":{},\"defaultAggregateBytes\":{},\"maxAggregateBytes\":{},\ + \"maxEventsPerTick\":{},\"maxTickBytes\":{},\"maxHeaders\":{},\"maxHeaderBytes\":{},\ + \"defaultTimeoutMs\":{},\"maxTimeoutMs\":{},\"maxRedirects\":{},\"tlsMinVersion\":\"{}\",\"features\":{}}}", + spec::SPEC_MAJOR, + spec::SPEC_MINOR, + l.max_inflight, + if self.backend.supports_tls() { l.max_inflight } else { 0 }, + l.max_request_bytes, + l.default_queue_bytes, + l.max_queue_bytes, + l.default_aggregate_bytes, + l.max_aggregate_bytes, + l.max_events_per_tick, + l.max_tick_bytes, + l.max_headers, + l.max_header_bytes, + l.default_timeout_ms, + l.max_timeout_ms, + l.max_redirects, + spec::TLS_MIN_VERSION, + features + ) + } + + /// `limits()` op: read-only JSON. + pub fn limits(&self) -> &str { + &self.limits_json + } + + /// `lastError()` op. + pub fn last_error(&self) -> &str { + &self.last_error + } + + /// Live handles (for hosts deciding whether to keep ticking the pump). + pub fn live(&self) -> usize { + self.handles.values().filter(|h| !h.terminal).count() } - /// Mutable transport access is for host wiring and tests (for example to - /// push channel-backed completions); it never exposes guest state. - pub fn transport_mut(&mut self) -> &mut T { - &mut self.transport + fn refuse(&mut self, code: &str, message: impl Into) -> i32 { + self.last_error = format!("{}: {}", normalize_error_code(code), message.into()); + -1 } + /// `start(metaJson, body)` op: -1 with `lastError()` on refusal. pub fn start(&mut self, meta_json: &str, body: &[u8]) -> i32 { - match self.try_start(meta_json, body) { - Ok(handle) => handle, - Err(failure) => { - self.last_error = format!("{}: {}", failure.code, failure.message); - -1 + if self.live() >= self.limits.max_inflight { + return self.refuse(spec::ERROR_RESOURCE_LIMIT, "too many requests in flight"); + } + if body.len() > self.limits.max_request_bytes { + return self.refuse(spec::ERROR_RESOURCE_LIMIT, "request body too large"); + } + let meta: StartMeta = match serde_json::from_str(meta_json) { + Ok(meta) => meta, + Err(_) => return self.refuse(spec::ERROR_INVALID_REQUEST, "malformed request metadata"), + }; + let (scheme, host, port) = match parse_url(&meta.url) { + Some(parts) => parts, + None => return self.refuse(spec::ERROR_INVALID_REQUEST, "invalid url"), + }; + if scheme != "http" && scheme != "https" { + return self.refuse(spec::ERROR_INVALID_REQUEST, "url must be http: or https:"); + } + if scheme == "https" && !self.backend.supports_tls() { + return self.refuse(spec::ERROR_UNSUPPORTED, "this host does not provide network.http.client.tls"); + } + if !self.policy.allows_connect(scheme, &host, port) { + return self.refuse(spec::ERROR_PERMISSION_DENIED, "endpoint is not an allowed connect rule"); + } + // A literal address skips DNS: classify it now; the refusal arrives as + // the asynchronous error event the dialer would raise (the C core + // filters candidates the same way after its own resolve). + let literal_refused = policy::parse_address(&host).is_some_and(|addr| !self.policy.allows_address(addr)); + if !is_token(&meta.method) { + return self.refuse(spec::ERROR_INVALID_REQUEST, "invalid method"); + } + let upper = meta.method.to_ascii_uppercase(); + if spec::METHODS_FORBIDDEN.contains(&upper.as_str()) { + return self.refuse(spec::ERROR_INVALID_REQUEST, "method not allowed"); + } + if (upper == "GET" || upper == "HEAD") && !body.is_empty() { + return self.refuse(spec::ERROR_INVALID_REQUEST, "GET/HEAD cannot carry a body"); + } + let mut headers = BTreeMap::new(); + let mut header_bytes = 0usize; + for (name, value) in &meta.headers { + let lower = name.to_ascii_lowercase(); + if !is_token(&lower) || value.bytes().any(|b| (b < 0x20 && b != b'\t') || b == 0x7f) { + return self.refuse(spec::ERROR_INVALID_REQUEST, format!("invalid header {name}")); + } + if spec::HTTP_CORE_OWNED_REQUEST_HEADERS.contains(&lower.as_str()) { + continue; + } + header_bytes += lower.len() + value.len() + 4; + headers.insert(lower, value.clone()); + if headers.len() > self.limits.max_headers || header_bytes > self.limits.max_header_bytes { + return self.refuse(spec::ERROR_RESOURCE_LIMIT, "request headers exceed limits"); } } - } - - fn try_start(&mut self, meta_json: &str, body: &[u8]) -> std::result::Result { - if self.inflight.len() >= spec::MAX_INFLIGHT { - return Err(NetFailure::new( - spec::ERROR_BUSY, - format!("at most {} requests may be in flight", spec::MAX_INFLIGHT), - )); + let queue_bytes = meta.queue_bytes.unwrap_or(self.limits.default_queue_bytes); + if queue_bytes == 0 || queue_bytes > self.limits.max_queue_bytes { + return self.refuse(spec::ERROR_INVALID_REQUEST, "invalid queueBytes"); } - if body.len() > spec::MAX_REQUEST_BYTES { - return Err(invalid("request body exceeds 64 KiB")); + let timeouts = meta.timeouts.unwrap_or_default(); + let bounded = |value: Option, fallback: u32| -> Option { + match value { + None => Some(fallback), + Some(v) if v >= 1 && v <= self.limits.max_timeout_ms => Some(v), + Some(_) => None, + } + }; + let (Some(connect_ms), Some(headers_ms), Some(idle_ms), Some(total_ms)) = ( + bounded(timeouts.connect_ms, self.limits.default_timeout_ms), + bounded(timeouts.headers_ms, self.limits.default_timeout_ms), + bounded(timeouts.idle_ms, self.limits.default_timeout_ms), + bounded(timeouts.total_ms, self.limits.max_timeout_ms), + ) else { + return self.refuse(spec::ERROR_INVALID_REQUEST, "invalid timeouts"); + }; + let redirect = match meta.redirect.as_deref() { + None | Some("follow") => RedirectMode::Follow, + Some("manual") => RedirectMode::Manual, + Some("error") => RedirectMode::Error, + Some(_) => return self.refuse(spec::ERROR_INVALID_REQUEST, "invalid redirect"), + }; + let max_redirects = meta.max_redirects.unwrap_or(self.limits.max_redirects); + if max_redirects > self.limits.max_redirects { + return self.refuse(spec::ERROR_INVALID_REQUEST, "invalid maxRedirects"); + } + if let Some(tls) = &meta.tls { + match tls.verification.as_deref() { + None | Some("full") => {} + Some("development-insecure") => { + if !self.development_build || !self.policy.allow_invalid_tls_for_development { + return self.refuse(spec::ERROR_UNSUPPORTED, "development-insecure TLS is not enabled"); + } + } + Some(_) => return self.refuse(spec::ERROR_INVALID_REQUEST, "invalid tls.verification"), + } } - let meta: RequestMeta = - serde_json::from_str(meta_json).map_err(|_| invalid("malformed request metadata"))?; - validate_meta(&meta, body)?; - let handle = self.allocate_handle(); let request = HttpRequest { handle, url: meta.url, method: meta.method, - headers: meta.headers, + headers, body: body.to_vec(), - timeout_ms: meta.timeout_ms, - max_bytes: meta.max_bytes, - max_redirects: spec::MAX_REDIRECTS, + connect_ms, + headers_ms, + idle_ms, + total_ms, + redirect, + max_redirects, + max_body_bytes: meta.max_body_bytes, }; - let max_bytes = request.max_bytes; - // Reserve before submit so a transport that queues work immediately - // cannot race the accounting boundary. Roll back on refusal. - self.inflight.insert(handle, Inflight { max_bytes }); - if let Err(failure) = self.transport.start(request) { - self.inflight.remove(&handle); - return Err(failure); + // Reserve the handle before the backend sees it so a completion + // draining in the same tick cannot race the insertion. + self.handles.insert( + handle, + Handle { + head_pushed: false, + terminal: false, + queue_bytes, + max_body_bytes: meta.max_body_bytes, + body_total: 0, + queue: VecDeque::new(), + visible_bytes: 0, + dirty: false, + paused: false, + }, + ); + self.gate.begin(handle, &request.url); + if literal_refused { + self.fail(handle, NetFailure::new(spec::ERROR_PERMISSION_DENIED, "resolved address is not permitted by the policy")); + return handle; + } + if let Err(failure) = self.backend.start(request, self.gate.clone()) { + self.handles.remove(&handle); + self.gate.forget(handle); + return self.refuse(&failure.code, failure.message); } - Ok(handle) + handle } fn allocate_handle(&mut self) -> i32 { loop { let handle = self.next_handle; - self.next_handle = if self.next_handle == i32::MAX { - 1 - } else { - self.next_handle + 1 - }; - if !self.inflight.contains_key(&handle) && !self.bodies.contains_key(&handle) { + self.next_handle = if handle == i32::MAX { 1 } else { handle + 1 }; + if !self.handles.contains_key(&handle) { return handle; } } } - /// Drain non-blocking transport completions at a host tick boundary. - /// Call before the corresponding guest `frame()`; `poll()` during that - /// turn sees the resulting batch and never sees mid-tick transport state. + /// `cancel(handle)` op: the terminal `error{cancelled}` arrives at the + /// next tick; a handle that already ended releases its unread bytes. + pub fn cancel(&mut self, handle: i32) { + let Some(h) = self.handles.get(&handle) else { return }; + if h.terminal { + self.handles.remove(&handle); + self.gate.forget(handle); + return; + } + self.backend.cancel(handle); + self.fail(handle, NetFailure::new(spec::ERROR_CANCELLED, "cancelled")); + } + + fn fail(&mut self, handle: i32, failure: NetFailure) { + let Some(h) = self.handles.get_mut(&handle) else { return }; + if h.terminal { + return; + } + h.terminal = true; + h.queue.clear(); + h.visible_bytes = 0; + h.dirty = false; + let mut json = format!( + "{{\"t\":\"error\",\"h\":{},\"code\":{},\"message\":{}", + handle, + json_string(&failure.code), + json_string(&failure.message) + ); + if let Some(cause) = &failure.cause { + json.push_str(",\"causeCode\":"); + json.push_str(&json_string(cause)); + } + json.push('}'); + self.pending.push_back(QueuedEvent { handle, barrier: true, weight: 0, json }); + // The handle stays until the terminal event was polled? No: errors + // carry no bytes, so nothing remains to read; drop it now. + self.handles.remove(&handle); + self.gate.forget(handle); + } + + /// Tick boundary: drain the backend, apply completions, freeze the + /// visible set under the per-tick budget. Call before every `frame()`. pub fn begin_tick(&mut self) { - let mut completions = Vec::new(); - self.transport.drain(&mut completions); - for completion in completions { - self.complete(completion); - } - } - - fn complete(&mut self, completion: TransportCompletion) { - match completion { - TransportCompletion::Done { - handle, - status, - url, - headers, - body, - } => { - let Some(request) = self.inflight.remove(&handle) else { - return; // cancelled, stale or duplicate completion - }; - let header_bytes = header_bytes(&headers); - let protocol_error = if !(100..=599).contains(&status) { - Some("invalid HTTP status") - } else if !is_http_url(&url) { - Some("invalid final URL") - } else if headers.len() > spec::MAX_HEADERS - || header_bytes > spec::MAX_HEADER_BYTES - || !valid_headers(&headers) - { - Some("response headers exceed the portable contract") - } else { - None - }; - if let Some(message) = protocol_error { - self.push_error(handle, spec::ERROR_PROTOCOL, message); - } else if body.len() > request.max_bytes || body.len() > spec::MAX_RESPONSE_BYTES { - self.push_error( + let mut events = Vec::new(); + self.backend.drain(&mut events); + for event in events { + self.apply(event); + } + // Freeze readable watermarks (inserted before the handle's barrier). + let dirty: Vec = self + .handles + .iter() + .filter(|(_, h)| h.dirty && h.head_pushed) + .map(|(k, _)| *k) + .collect(); + for handle in dirty { + let h = self.handles.get_mut(&handle).unwrap(); + h.dirty = false; + h.visible_bytes = h.queue.len(); + let json = format!("{{\"t\":\"readable\",\"h\":{},\"avail\":{}}}", handle, h.visible_bytes); + let ev = QueuedEvent { handle, barrier: false, weight: h.visible_bytes, json }; + let at = self.pending.iter().position(|e| e.handle == handle && e.barrier); + match at { + Some(i) => self.pending.insert(i, ev), + None => self.pending.push_back(ev), + } + } + // Budget: at least one event per tick, then cut on count or bytes. + let mut events = 0usize; + let mut bytes = 0usize; + while let Some(front) = self.pending.front() { + if events > 0 && (events >= self.limits.max_events_per_tick || bytes + front.weight > self.limits.max_tick_bytes) { + break; + } + let ev = self.pending.pop_front().unwrap(); + events += 1; + bytes += ev.weight; + self.visible.push_back(ev); + } + } + + fn apply(&mut self, event: BackendEvent) { + match event { + BackendEvent::Headers { handle, status, url, headers, redirected, length } => { + let Some(h) = self.handles.get(&handle) else { return }; + if h.terminal || h.head_pushed { + return; + } + if !(100..=599).contains(&status) || !is_http_url(&url) { + self.backend.cancel(handle); + self.fail(handle, NetFailure::new(spec::ERROR_PROTOCOL, "malformed response head")); + return; + } + // The response must come from the URL the gate last authorized + // for this handle (the start URL, or the latest redirect hop the + // backend asked the gate about), and `redirected` must say so. + let authorized = self.gate.authorized_urls(handle); + let expected = authorized.last().cloned().unwrap_or_default(); + if url != expected || redirected != (authorized.len() > 1) { + self.backend.cancel(handle); + self.fail( handle, - spec::ERROR_RESPONSE_TOO_LARGE, - format!("response exceeded {} bytes", request.max_bytes), + NetFailure::new(spec::ERROR_PERMISSION_DENIED, "response from a URL the policy gate did not authorize"), ); - } else { - let bytes = body.len(); - self.bodies.insert(handle, body); - self.visible.push(GuestEvent::Done { - handle, - status, - url, - headers, - bytes, - }); + return; + } + let header_bytes: usize = headers.iter().map(|(k, v)| k.len() + v.len() + 4).sum(); + if headers.len() > self.limits.max_headers + || header_bytes > self.limits.max_header_bytes + || !headers.iter().all(|(k, v)| is_token(k) && !v.contains(['\r', '\n'])) + { + self.backend.cancel(handle); + self.fail(handle, NetFailure::new(spec::ERROR_PROTOCOL, "response headers exceed the portable contract")); + return; + } + if let (Some(len), Some(max)) = (length, h.max_body_bytes) { + if len > max as u64 { + self.backend.cancel(handle); + self.fail(handle, NetFailure::new(spec::ERROR_RESPONSE_TOO_LARGE, "response exceeds maxBodyBytes")); + return; + } + } + let mut json = format!("{{\"t\":\"headers\",\"h\":{},\"status\":{},\"url\":{},\"headers\":{{", handle, status, json_string(&url)); + let mut first = true; + for (name, value) in &headers { + if !first { + json.push(','); + } + first = false; + json.push_str(&json_string(&name.to_ascii_lowercase())); + json.push(':'); + json.push_str(&json_string(value)); + } + json.push_str(&format!("}},\"redirected\":{}", redirected)); + if let Some(len) = length { + json.push_str(&format!(",\"length\":{len}")); + } + json.push('}'); + let weight = json.len(); + self.pending.push_back(QueuedEvent { handle, barrier: false, weight, json }); + self.handles.get_mut(&handle).unwrap().head_pushed = true; + } + BackendEvent::Body { handle, chunk } => { + let Some(h) = self.handles.get_mut(&handle) else { return }; + if h.terminal || !h.head_pushed { + return; + } + if let Some(max) = h.max_body_bytes { + if h.body_total + chunk.len() > max { + self.backend.cancel(handle); + self.fail(handle, NetFailure::new(spec::ERROR_RESPONSE_TOO_LARGE, "response exceeds maxBodyBytes")); + return; + } + } + h.body_total += chunk.len(); + h.queue.extend(chunk); + h.dirty = true; + if h.queue.len() >= h.queue_bytes && !h.paused { + h.paused = true; + self.backend.set_paused(handle, true); } } - TransportCompletion::Error { handle, failure } => { - if self.inflight.remove(&handle).is_none() { + BackendEvent::End { handle } => { + let Some(h) = self.handles.get_mut(&handle) else { return }; + if h.terminal { + return; + } + if !h.head_pushed { + self.fail(handle, NetFailure::new(spec::ERROR_PROTOCOL, "end before headers")); return; } - self.push_error(handle, &failure.code, failure.message); + h.terminal = true; + let json = format!("{{\"t\":\"end\",\"h\":{handle}}}"); + self.pending.push_back(QueuedEvent { handle, barrier: true, weight: 0, json }); + if h.queue.is_empty() && !h.dirty { + self.handles.remove(&handle); + self.gate.forget(handle); + } } + BackendEvent::Error { handle, failure } => self.fail(handle, failure), } } - fn push_error(&mut self, handle: i32, code: &str, message: impl Into) { - self.visible.push(GuestEvent::Error { - handle, - code: normalize_error_code(code).to_string(), - message: message.into(), - }); - } - - pub fn cancel(&mut self, handle: i32) { - self.transport.cancel(handle); - self.inflight.remove(&handle); - self.bodies.remove(&handle); - self.visible.retain(|event| match event { - GuestEvent::Done { handle: h, .. } | GuestEvent::Error { handle: h, .. } => { - *h != handle + /// `poll()` op: the visible batch as one JSON array, or None. + pub fn poll(&mut self) -> Option { + if self.visible.is_empty() { + return None; + } + let mut out = String::from("["); + let mut first = true; + while let Some(ev) = self.visible.pop_front() { + if !first { + out.push(','); } - }); - } - - pub fn take(&mut self, handle: i32) -> Option> { - self.bodies.remove(&handle) + first = false; + out.push_str(&ev.json); + } + out.push(']'); + Some(out) } - pub fn take_into(&mut self, handle: i32, into: &mut [u8]) -> i32 { - let Some(body) = self.bodies.get(&handle) else { - return -1; - }; - if body.len() != into.len() { + /// `readInto(handle, buffer)` op: copies visible bytes; -1 for an unknown + /// or fully drained terminal handle, 0 when nothing is visible yet. + pub fn read_into(&mut self, handle: i32, into: &mut [u8]) -> i32 { + let Some(h) = self.handles.get_mut(&handle) else { return -1 }; + if !h.head_pushed { return -1; } - into.copy_from_slice(body); - self.bodies.remove(&handle); - into.len() as i32 + let want = into.len().min(h.visible_bytes); + for (i, slot) in into.iter_mut().take(want).enumerate() { + *slot = h.queue[i]; + } + h.queue.drain(..want); + h.visible_bytes -= want; + if h.paused && h.queue.len() < h.queue_bytes { + h.paused = false; + self.backend.set_paused(handle, false); + } + if h.terminal && h.queue.is_empty() && !h.dirty { + self.handles.remove(&handle); + self.gate.forget(handle); + } + want as i32 } +} - /// Drain the whole tick batch in one serialization and one FFI crossing. - pub fn poll(&mut self) -> Option { - if self.visible.is_empty() { - return None; - } - let events = std::mem::take(&mut self.visible); - Some(serde_json::to_string(&events).expect("GuestEvent serialization is infallible")) +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + + +fn is_token(s: &str) -> bool { + !s.is_empty() + && s.bytes() + .all(|b| b > 0x20 && b < 0x7f && !b"()<>@,;:\\\"/[]?={}".contains(&b)) +} + +fn is_http_url(url: &str) -> bool { + parse_url(url).is_some_and(|(scheme, _, _)| scheme == "http" || scheme == "https") +} + +/// (scheme, lowercased host, effective port) for http/https/ws/wss URLs. +fn parse_url(url: &str) -> Option<(&'static str, String, u16)> { + let (scheme_raw, rest) = url.split_once("://")?; + let scheme: &'static str = match scheme_raw.to_ascii_lowercase().as_str() { + "http" => "http", + "https" => "https", + "ws" => "ws", + "wss" => "wss", + _ => return None, + }; + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + if authority.is_empty() || authority.contains('@') || authority.contains(char::is_whitespace) { + return None; + } + let default_port = if scheme == "https" || scheme == "wss" { 443 } else { 80 }; + let (host, port) = if let Some(stripped) = authority.strip_prefix('[') { + let end = stripped.find(']')?; + let host = &stripped[..end]; + let after = &stripped[end + 1..]; + let port = match after.strip_prefix(':') { + Some(p) => p.parse::().ok()?, + None if after.is_empty() => default_port, + None => return None, + }; + (host.to_string(), port) + } else if let Some((host, port)) = authority.rsplit_once(':') { + (host.to_string(), port.parse::().ok()?) + } else { + (authority.to_string(), default_port) + }; + if host.is_empty() { + return None; } + Some((scheme, host.to_ascii_lowercase(), port)) +} - pub fn last_error(&self) -> &str { - &self.last_error +fn json_string(s: &str) -> String { + serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into()) +} + +/// Clamp a code onto the shared vocabulary; unknown codes become `other`. +pub fn normalize_error_code(code: &str) -> &'static str { + match code { + spec::ERROR_INVALID_REQUEST => spec::ERROR_INVALID_REQUEST, + spec::ERROR_INVALID_STATE => spec::ERROR_INVALID_STATE, + spec::ERROR_UNSUPPORTED => spec::ERROR_UNSUPPORTED, + spec::ERROR_PERMISSION_DENIED => spec::ERROR_PERMISSION_DENIED, + spec::ERROR_BUSY => spec::ERROR_BUSY, + spec::ERROR_RESOURCE_LIMIT => spec::ERROR_RESOURCE_LIMIT, + spec::ERROR_DNS => spec::ERROR_DNS, + spec::ERROR_CONNECT => spec::ERROR_CONNECT, + spec::ERROR_ADDRESS_IN_USE => spec::ERROR_ADDRESS_IN_USE, + spec::ERROR_CLOSED => spec::ERROR_CLOSED, + spec::ERROR_TIMEOUT => spec::ERROR_TIMEOUT, + spec::ERROR_TLS_CERTIFICATE_INVALID => spec::ERROR_TLS_CERTIFICATE_INVALID, + spec::ERROR_TLS_HOSTNAME_MISMATCH => spec::ERROR_TLS_HOSTNAME_MISMATCH, + spec::ERROR_TLS_HANDSHAKE_FAILED => spec::ERROR_TLS_HANDSHAKE_FAILED, + spec::ERROR_TLS_CLOCK_UNTRUSTED => spec::ERROR_TLS_CLOCK_UNTRUSTED, + spec::ERROR_REDIRECT => spec::ERROR_REDIRECT, + spec::ERROR_RESPONSE_TOO_LARGE => spec::ERROR_RESPONSE_TOO_LARGE, + spec::ERROR_PROTOCOL => spec::ERROR_PROTOCOL, + spec::ERROR_WEBSOCKET_HANDSHAKE_FAILED => spec::ERROR_WEBSOCKET_HANDSHAKE_FAILED, + spec::ERROR_WEBSOCKET_PROTOCOL_ERROR => spec::ERROR_WEBSOCKET_PROTOCOL_ERROR, + spec::ERROR_MESSAGE_TOO_LARGE => spec::ERROR_MESSAGE_TOO_LARGE, + spec::ERROR_CANCELLED => spec::ERROR_CANCELLED, + spec::ERROR_UNAVAILABLE => spec::ERROR_UNAVAILABLE, + _ => spec::ERROR_OTHER, } } // --------------------------------------------------------------------------- -// Mount +// Mount (rquickjs) // --------------------------------------------------------------------------- -#[cfg(feature = "mount")] -use std::cell::RefCell; -#[cfg(feature = "mount")] -use std::rc::Rc; - #[cfg(feature = "mount")] use anyhow::Result; #[cfg(feature = "mount")] use pocket_mod::Guest; #[cfg(feature = "mount")] -use pocket_mod::qjs::{ArrayBuffer, Function}; +use pocket_mod::qjs::{ArrayBuffer, Function, Value}; +#[cfg(feature = "mount")] +use std::cell::RefCell; +#[cfg(feature = "mount")] +use std::rc::Rc; /// Clone-cheap mounted NET module. The host keeps a copy and calls -/// [`begin_tick`](Self::begin_tick); the namespace closures share the core. -/// Feature `mount` (default); a host with its own QuickJS wiring turns it -/// off and drives [`NetCore`] directly, spelling the five ops itself. +/// [`begin_tick`](Self::begin_tick) before every frame; the namespace closures +/// share the core. #[cfg(feature = "mount")] -pub struct NetSurface { - inner: Rc>>, +pub struct NetSurface { + inner: Rc>>, } #[cfg(feature = "mount")] -impl Clone for NetSurface { +impl Clone for NetSurface { fn clone(&self) -> Self { - Self { - inner: self.inner.clone(), - } + Self { inner: self.inner.clone() } } } #[cfg(feature = "mount")] -impl NetSurface { - pub fn new(transport: T) -> Self { - Self { - inner: Rc::new(RefCell::new(NetCore::new(transport))), - } +impl NetSurface { + pub fn new(core: NetCore) -> Self { + Self { inner: Rc::new(RefCell::new(core)) } } pub fn begin_tick(&self) { self.inner.borrow_mut().begin_tick(); } - pub fn with_core(&self, f: impl FnOnce(&mut NetCore) -> R) -> R { + pub fn with_core(&self, f: impl FnOnce(&mut NetCore) -> R) -> R { f(&mut self.inner.borrow_mut()) } - /// Mount exactly the five ops pinned in `contracts/spec/net.ts`. + /// Mount the six v2 ops of `contracts/spec/net.ts` on `globalThis.net`. pub fn mount(&self, guest: &Guest) -> Result<()> { guest.mount("net", |ctx, ns| { let core = self.inner.clone(); ns.set( "start", - Function::new(ctx.clone(), move |meta: String, body: ArrayBuffer| { - let Some(bytes) = body.as_bytes() else { - core.borrow_mut().last_error = - format!("{}: detached request body", spec::ERROR_INVALID_REQUEST); + Function::new(ctx.clone(), move |meta: String, body: Value| -> i32 { + let bytes: Vec = if body.is_null() || body.is_undefined() { + Vec::new() + } else if let Some(buffer) = body.as_object().and_then(|o| ArrayBuffer::from_object(o.clone())) { + match buffer.as_bytes() { + Some(b) => b.to_vec(), + None => { + core.borrow_mut().last_error = format!("{}: detached request body", spec::ERROR_INVALID_STATE); + return -1; + } + } + } else { + core.borrow_mut().last_error = format!("{}: body must be an ArrayBuffer or null", spec::ERROR_INVALID_REQUEST); return -1; }; - core.borrow_mut().start(&meta, bytes) + core.borrow_mut().start(&meta, &bytes) })?, )?; let core = self.inner.clone(); ns.set( - "take", - Function::new(ctx.clone(), move |handle: i32, into: ArrayBuffer| { - let Some(raw) = into.as_raw() else { - return -1; - }; - // QuickJS owns this mutable ArrayBuffer for the duration - // of the synchronous call. rquickjs exposes its raw span - // but intentionally cannot express JS mutability as &mut. - let bytes = - unsafe { std::slice::from_raw_parts_mut(raw.ptr.as_ptr(), raw.len) }; - core.borrow_mut().take_into(handle, bytes) - })?, + "cancel", + Function::new(ctx.clone(), move |handle: i32| core.borrow_mut().cancel(handle))?, )?; + let core = self.inner.clone(); + ns.set("poll", Function::new(ctx.clone(), move || core.borrow_mut().poll())?)?; + let core = self.inner.clone(); ns.set( - "cancel", - Function::new(ctx.clone(), move |handle: i32| { - core.borrow_mut().cancel(handle) - })?, + "lastError", + Function::new(ctx.clone(), move || core.borrow().last_error().to_string())?, )?; let core = self.inner.clone(); ns.set( - "poll", - Function::new(ctx.clone(), move || core.borrow_mut().poll())?, + "readInto", + Function::new(ctx.clone(), move |handle: i32, into: ArrayBuffer, offset: f64, length: f64| -> i32 { + let Some(raw) = into.as_raw() else { return -1 }; + let (offset, length) = (offset as usize, length as usize); + if offset > raw.len || length > raw.len - offset { + return -1; + } + // QuickJS owns this mutable ArrayBuffer for the duration + // of the synchronous call; rquickjs exposes the raw span + // but cannot express JS mutability as &mut. + let bytes = unsafe { std::slice::from_raw_parts_mut(raw.ptr.as_ptr().add(offset), length) }; + core.borrow_mut().read_into(handle, bytes) + })?, )?; let core = self.inner.clone(); ns.set( - "lastError", - Function::new(ctx.clone(), move || core.borrow().last_error().to_string())?, + "limits", + Function::new(ctx.clone(), move || core.borrow().limits().to_string())?, )?; Ok(()) }) } } -fn invalid(message: impl Into) -> NetFailure { - NetFailure::new(spec::ERROR_INVALID_REQUEST, message) -} - -fn is_http_url(url: &str) -> bool { - let rest = url - .strip_prefix("http://") - .or_else(|| url.strip_prefix("https://")); - matches!(rest, Some(value) if !value.is_empty() - && !value.starts_with('/') - && !value.bytes().any(|b| b.is_ascii_whitespace())) -} - -fn header_bytes(headers: &BTreeMap) -> usize { - headers - .iter() - .map(|(name, value)| name.len() + value.len() + 4) - .sum() -} - -fn valid_headers(headers: &BTreeMap) -> bool { - headers.iter().all(|(name, value)| { - !name.is_empty() - && name.bytes().all(|b| { - b.is_ascii_lowercase() - || b.is_ascii_digit() - || matches!( - b, - b'!' | b'#' - | b'$' - | b'%' - | b'&' - | b'\'' - | b'*' - | b'+' - | b'-' - | b'.' - | b'^' - | b'_' - | b'`' - | b'|' - | b'~' - ) - }) - && !value.contains(['\r', '\n']) - }) -} - -fn validate_meta(meta: &RequestMeta, body: &[u8]) -> std::result::Result<(), NetFailure> { - if !is_http_url(&meta.url) { - return Err(invalid("url must be absolute http:// or https://")); - } - if !spec::METHODS.contains(&meta.method.as_str()) { - return Err(invalid(format!("unsupported method {}", meta.method))); - } - if matches!(meta.method.as_str(), "GET" | "HEAD") && !body.is_empty() { - return Err(invalid(format!("{} cannot have a body", meta.method))); - } - if meta.timeout_ms == 0 || meta.timeout_ms > spec::MAX_TIMEOUT_MS { - return Err(invalid(format!( - "timeoutMs must be 1..{}", - spec::MAX_TIMEOUT_MS - ))); - } - if meta.max_bytes == 0 || meta.max_bytes > spec::MAX_RESPONSE_BYTES { - return Err(invalid(format!( - "maxBytes must be 1..{}", - spec::MAX_RESPONSE_BYTES - ))); - } - if meta.headers.len() > spec::MAX_HEADERS - || header_bytes(&meta.headers) > spec::MAX_HEADER_BYTES - || !valid_headers(&meta.headers) - { - return Err(invalid("request headers exceed the portable contract")); - } - Ok(()) -} - -fn normalize_error_code(code: &str) -> &'static str { - match code { - spec::ERROR_UNAVAILABLE => spec::ERROR_UNAVAILABLE, - spec::ERROR_INVALID_REQUEST => spec::ERROR_INVALID_REQUEST, - spec::ERROR_BUSY => spec::ERROR_BUSY, - spec::ERROR_DNS => spec::ERROR_DNS, - spec::ERROR_CONNECT => spec::ERROR_CONNECT, - spec::ERROR_TLS => spec::ERROR_TLS, - spec::ERROR_TIMEOUT => spec::ERROR_TIMEOUT, - spec::ERROR_REDIRECT => spec::ERROR_REDIRECT, - spec::ERROR_RESPONSE_TOO_LARGE => spec::ERROR_RESPONSE_TOO_LARGE, - spec::ERROR_PROTOCOL => spec::ERROR_PROTOCOL, - spec::ERROR_CANCELLED => spec::ERROR_CANCELLED, - _ => spec::ERROR_OTHER, - } -} +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; - use std::collections::VecDeque; #[derive(Default)] - struct FixtureTransport { + struct Fixture { started: Vec, + gates: Vec, cancelled: Vec, - completions: VecDeque, + queue: VecDeque, + paused: Vec<(i32, bool)>, + refuse: bool, } - impl HttpTransport for FixtureTransport { - fn start(&mut self, request: HttpRequest) -> std::result::Result<(), NetFailure> { + impl HttpClientBackend for Fixture { + fn start(&mut self, request: HttpRequest, gate: PolicyGate) -> Result<(), NetFailure> { + if self.refuse { + return Err(NetFailure::new(spec::ERROR_RESOURCE_LIMIT, "no sockets")); + } self.started.push(request); + self.gates.push(gate); Ok(()) } - fn cancel(&mut self, handle: i32) { self.cancelled.push(handle); } - - fn drain(&mut self, completions: &mut Vec) { - completions.extend(self.completions.drain(..)); + fn drain(&mut self, out: &mut Vec) { + out.extend(self.queue.drain(..)); + } + fn set_paused(&mut self, handle: i32, paused: bool) { + self.paused.push((handle, paused)); } } - fn meta(max_bytes: usize) -> String { - format!( - r#"{{"url":"https://example.test/a","method":"GET","headers":{{}},"timeoutMs":30000,"maxBytes":{max_bytes}}}"# - ) + fn core() -> NetCore { + NetCore::new(Fixture::default(), NetPolicy::permissive()) } - #[test] - fn accepted_request_is_owned_and_only_visible_at_tick_boundary() { - let mut core = NetCore::new(FixtureTransport::default()); - let handle = core.start(&meta(16), &[]); - assert_eq!(handle, 1); - assert!(core.poll().is_none()); - assert_eq!( - core.transport_mut().started[0].max_redirects, - spec::MAX_REDIRECTS - ); + fn headers(handle: i32, length: Option) -> BackendEvent { + let mut h = BTreeMap::new(); + h.insert("content-type".to_string(), "text/plain".to_string()); + BackendEvent::Headers { handle, status: 200, url: "http://example.test/".into(), headers: h, redirected: false, length } + } - core.transport_mut() - .completions - .push_back(TransportCompletion::Done { - handle, - status: 200, - url: "https://example.test/a".into(), - headers: BTreeMap::from([("content-type".into(), "text/plain".into())]), - body: b"hello".to_vec(), - }); - assert!(core.poll().is_none()); + const META: &str = r#"{"url":"http://example.test/","method":"GET","headers":{"x-a":"1"}}"#; + + #[test] + fn events_become_visible_only_at_the_tick_boundary_in_order() { + let mut core = core(); + let h = core.start(META, &[]); + assert!(h > 0); + assert_eq!(core.backend_mut().started[0].headers.get("x-a").map(String::as_str), Some("1")); + core.backend_mut().queue.push_back(headers(h, Some(5))); + core.backend_mut().queue.push_back(BackendEvent::Body { handle: h, chunk: b"hello".to_vec() }); + core.backend_mut().queue.push_back(BackendEvent::End { handle: h }); + assert_eq!(core.poll(), None, "nothing visible before begin_tick"); core.begin_tick(); let batch = core.poll().unwrap(); assert_eq!( batch, - r#"[{"t":"done","h":1,"status":200,"url":"https://example.test/a","headers":{"content-type":"text/plain"},"bytes":5}]"# + format!( + "[{{\"t\":\"headers\",\"h\":{h},\"status\":200,\"url\":\"http://example.test/\",\"headers\":{{\"content-type\":\"text/plain\"}},\"redirected\":false,\"length\":5}},{{\"t\":\"readable\",\"h\":{h},\"avail\":5}},{{\"t\":\"end\",\"h\":{h}}}]" + ) ); - assert_eq!(core.take(handle).as_deref(), Some(&b"hello"[..])); - assert!(core.take(handle).is_none()); + let mut buf = [0u8; 8]; + assert_eq!(core.read_into(h, &mut buf), 5); + assert_eq!(&buf[..5], b"hello"); + assert_eq!(core.read_into(h, &mut buf), -1, "drained terminal handle retires"); + assert_eq!(core.live(), 0); } #[test] - fn limit_is_checked_again_after_transport_completion() { - let mut core = NetCore::new(FixtureTransport::default()); - let handle = core.start(&meta(4), &[]); - core.transport_mut() - .completions - .push_back(TransportCompletion::Done { - handle, - status: 200, - url: "https://example.test/a".into(), - headers: BTreeMap::new(), - body: b"12345".to_vec(), - }); - core.begin_tick(); - assert!( - core.poll() - .unwrap() - .contains(spec::ERROR_RESPONSE_TOO_LARGE) + fn readable_watermark_is_frozen_per_tick_and_backpressure_pauses() { + let mut core = NetCore::with_limits( + Fixture::default(), + NetPolicy::permissive(), + NetLimits { max_queue_bytes: 4, default_queue_bytes: 4, ..NetLimits::default() }, ); - assert!(core.take(handle).is_none()); + let h = core.start(META, &[]); + core.backend_mut().queue.push_back(headers(h, None)); + core.backend_mut().queue.push_back(BackendEvent::Body { handle: h, chunk: b"abcd".to_vec() }); + core.begin_tick(); + assert!(core.poll().unwrap().contains("\"avail\":4")); + assert_eq!(core.backend_mut().paused, vec![(h, true)]); + // Bytes arriving after the boundary are not visible yet. + core.backend_mut().queue.push_back(BackendEvent::Body { handle: h, chunk: b"ef".to_vec() }); + let mut buf = [0u8; 8]; + assert_eq!(core.read_into(h, &mut buf), 4); + assert_eq!(core.backend_mut().paused.last(), Some(&(h, false))); + core.begin_tick(); + assert!(core.poll().unwrap().contains("\"avail\":2")); + assert_eq!(core.read_into(h, &mut buf), 2); + assert_eq!(&buf[..2], b"ef"); } #[test] - fn rejects_invalid_and_excess_inflight_requests_synchronously() { - let mut core = NetCore::new(FixtureTransport::default()); - assert_eq!(core.start("{}", &[]), -1); + fn synchronous_refusals_and_policy() { + let mut core = core(); + assert_eq!(core.start(r#"{"url":"https://example.test/","method":"GET"}"#, &[]), -1); + assert!(core.last_error().starts_with(spec::ERROR_UNSUPPORTED)); + assert_eq!(core.start(r#"{"url":"http://example.test/","method":"TRACE"}"#, &[]), -1); assert!(core.last_error().starts_with(spec::ERROR_INVALID_REQUEST)); - assert!(core.start(&meta(16), &[]) > 0); - assert!(core.start(&meta(16), &[]) > 0); - assert_eq!(core.start(&meta(16), &[]), -1); - assert!(core.last_error().starts_with(spec::ERROR_BUSY)); + assert_eq!(core.start(r#"{"url":"http://example.test/","method":"GET","bogus":1}"#, &[]), -1); + assert_eq!(core.start(META, b"x"), -1, "GET with a body"); + let strict = NetPolicy::parse(r#"{"connect":[{"protocol":"http","host":"*.devices.test","port":{"min":8000,"max":8100}}],"insecureTransport":true}"#).unwrap(); + let mut core = NetCore::new(Fixture::default(), strict); + assert!(core.start(r#"{"url":"http://a.devices.test:8050/","method":"GET"}"#, &[]) > 0); + assert_eq!(core.start(r#"{"url":"http://a.b.devices.test:8050/","method":"GET"}"#, &[]), -1); + assert!(core.last_error().starts_with(spec::ERROR_PERMISSION_DENIED)); + let closed = NetPolicy::parse(r#"{"connect":[{"protocol":"http","host":"h","port":80}]}"#).unwrap(); + let mut core = NetCore::new(Fixture::default(), closed); + assert_eq!(core.start(r#"{"url":"http://h/","method":"GET"}"#, &[]), -1, "insecureTransport off"); + let mut core = NetCore::new(Fixture { refuse: true, ..Default::default() }, NetPolicy::permissive()); + assert_eq!(core.start(META, &[]), -1); + assert!(core.last_error().starts_with(spec::ERROR_RESOURCE_LIMIT)); + assert_eq!(core.live(), 0, "refused starts do not hold a handle"); } #[test] - fn cancellation_discards_late_completion() { - let mut core = NetCore::new(FixtureTransport::default()); - let handle = core.start(&meta(16), &[]); - core.cancel(handle); - core.transport_mut() - .completions - .push_back(TransportCompletion::Error { - handle, - failure: NetFailure::new(spec::ERROR_TIMEOUT, "late"), - }); + fn cancel_and_late_completions() { + let mut core = core(); + let h = core.start(META, &[]); + core.cancel(h); + assert_eq!(core.backend_mut().cancelled, vec![h]); + core.backend_mut().queue.push_back(headers(h, Some(1))); + core.begin_tick(); + let batch = core.poll().unwrap(); + assert!(batch.contains("\"code\":\"cancelled\"")); + assert!(!batch.contains("\"t\":\"headers\""), "late completion discarded"); + assert_eq!(core.poll(), None); + } + + #[test] + fn budget_truncation_preserves_order_across_ticks() { + let mut core = NetCore::with_limits( + Fixture::default(), + NetPolicy::permissive(), + NetLimits { max_events_per_tick: 2, ..NetLimits::default() }, + ); + let h = core.start(META, &[]); + core.backend_mut().queue.push_back(headers(h, Some(2))); + core.backend_mut().queue.push_back(BackendEvent::Body { handle: h, chunk: b"ab".to_vec() }); + core.backend_mut().queue.push_back(BackendEvent::End { handle: h }); + core.begin_tick(); + let first = core.poll().unwrap(); + assert!(first.contains("\"t\":\"headers\"") && first.contains("\"t\":\"readable\"") && !first.contains("\"t\":\"end\"")); + core.begin_tick(); + assert!(core.poll().unwrap().contains("\"t\":\"end\"")); + } + + #[test] + fn limits_report_the_spec_major_and_features() { + let core = core(); + assert!(core.limits().contains("\"specMajor\":2")); + assert!(core.limits().contains("\"features\":[]")); + } + + #[test] + fn literal_addresses_are_classified_without_dns() { + let strict = NetPolicy::parse( + r#"{"version":1,"connect":[{"protocol":"http","host":"10.0.0.5","port":80},{"protocol":"http","host":"93.184.216.34","port":80}],"insecureTransport":true,"localNetwork":false}"#, + ) + .unwrap(); + let mut core = NetCore::new(Fixture::default(), strict); + // Private literal under localNetwork:false: admitted synchronously, + // refused with the asynchronous permission_denied the dialer raises. + let h = core.start(r#"{"url":"http://10.0.0.5/","method":"GET"}"#, &[]); + assert!(h > 0); + assert!(core.backend_mut().started.is_empty(), "the backend never sees it"); + core.begin_tick(); + let batch = core.poll().unwrap(); + assert!(batch.contains("\"code\":\"permission_denied\""), "{batch}"); + // A public literal starts. + assert!(core.start(r#"{"url":"http://93.184.216.34/","method":"GET"}"#, &[]) > 0); + assert_eq!(core.backend_mut().started.len(), 1); + } + + #[test] + fn the_gate_decides_addresses_redirects_and_tls_and_the_core_checks_the_response_url() { + let policy = NetPolicy::parse( + r#"{"version":1,"connect":[{"protocol":"http","host":"example.test","port":80},{"protocol":"http","host":"next.test","port":80},{"protocol":"https","host":"secure.test","port":443}],"insecureTransport":true,"localNetwork":false}"#, + ) + .unwrap(); + let mut core = NetCore::new(Fixture::default(), policy); + let h = core.start(r#"{"url":"http://example.test/start","method":"POST","headers":{}}"#, b"body"); + assert!(h > 0); + let gate = core.backend_mut().gates[0].clone(); + // Addresses: public yes, private no, multicast never. + assert!(gate.authorize_address("93.184.216.34".parse().unwrap()).is_ok()); + assert_eq!(gate.authorize_address("10.1.2.3".parse().unwrap()).unwrap_err().code, spec::ERROR_PERMISSION_DENIED); + assert_eq!(gate.authorize_address("224.0.0.1".parse().unwrap()).unwrap_err().code, spec::ERROR_PERMISSION_DENIED); + // Redirects: the spec table (302 POST → GET without body), the + // endpoint policy on the target, the budget, the scheme, TLS. + assert_eq!( + gate.authorize_redirect(h, "http://example.test/start", "POST", 302, Some("http://next.test/landed"), 3, RedirectMode::Follow), + Ok(RedirectPlan::Follow { url: "http://next.test/landed".into(), method: "GET".into(), drop_body: true }) + ); + assert_eq!( + gate.authorize_redirect(h, "http://next.test/landed", "GET", 307, Some("/again?x=1"), 2, RedirectMode::Follow), + Ok(RedirectPlan::Follow { url: "http://next.test/again?x=1".into(), method: "GET".into(), drop_body: false }) + ); + assert_eq!( + gate.authorize_redirect(h, "http://next.test/a", "GET", 301, Some("http://evil.test/"), 1, RedirectMode::Follow).unwrap_err().code, + spec::ERROR_PERMISSION_DENIED + ); + assert_eq!( + gate.authorize_redirect(h, "http://next.test/a", "GET", 301, Some("http://next.test/b"), 0, RedirectMode::Follow).unwrap_err().code, + spec::ERROR_REDIRECT + ); + assert_eq!( + gate.authorize_redirect(h, "http://next.test/a", "GET", 301, Some("https://secure.test/"), 1, RedirectMode::Follow).unwrap_err().code, + spec::ERROR_UNSUPPORTED, + "https without a TLS-capable backend" + ); + assert_eq!( + gate.authorize_redirect(h, "http://next.test/a", "GET", 301, Some("ftp://next.test/"), 1, RedirectMode::Follow).unwrap_err().code, + spec::ERROR_REDIRECT + ); + assert_eq!(gate.authorize_redirect(h, "http://next.test/a", "GET", 200, None, 1, RedirectMode::Follow), Ok(RedirectPlan::Deliver)); + assert_eq!(gate.authorize_redirect(h, "http://next.test/a", "GET", 302, Some("/x"), 1, RedirectMode::Manual), Ok(RedirectPlan::Deliver)); + assert_eq!( + gate.authorize_redirect(h, "http://next.test/a", "GET", 302, Some("/x"), 1, RedirectMode::Error).unwrap_err().code, + spec::ERROR_REDIRECT + ); + // TLS: verify unless policy + build + request all ask otherwise. + assert!(gate.tls_verification("secure.test", true).verify_peer); + assert_eq!(gate.tls_verification("secure.test", false).min_version, spec::TLS_MIN_VERSION); + // The core accepts the response only from the last authorized hop, + // and only with `redirected` set. + let mut h2 = BTreeMap::new(); + h2.insert("content-type".to_string(), "text/plain".to_string()); + core.backend_mut().queue.push_back(BackendEvent::Headers { + handle: h, + status: 200, + url: "http://elsewhere.test/".into(), + headers: h2.clone(), + redirected: true, + length: Some(0), + }); core.begin_tick(); - assert!(core.poll().is_none()); - assert_eq!(core.transport_mut().cancelled, vec![handle]); + let batch = core.poll().unwrap(); + assert!(batch.contains("\"code\":\"permission_denied\""), "{batch}"); + // A fresh request answered from an authorized hop passes. + let h = core.start(r#"{"url":"http://example.test/start","method":"GET","headers":{}}"#, &[]); + let gate = core.backend_mut().gates.last().unwrap().clone(); + gate.authorize_redirect(h, "http://example.test/start", "GET", 301, Some("http://next.test/landed"), 5, RedirectMode::Follow).unwrap(); + core.backend_mut().queue.push_back(BackendEvent::Headers { + handle: h, + status: 200, + url: "http://next.test/landed".into(), + headers: h2, + redirected: true, + length: Some(0), + }); + core.backend_mut().queue.push_back(BackendEvent::End { handle: h }); + core.begin_tick(); + let batch = core.poll().unwrap(); + assert!(batch.contains("\"t\":\"headers\"") && batch.contains("\"redirected\":true"), "{batch}"); + } + + #[derive(Deserialize)] + struct PolicyVectors { + policies: BTreeMap, + invalid: Vec, + connect: Vec, + address: Vec, + listen: Vec, + } + #[derive(Deserialize)] + struct InvalidVector { + name: String, + policy: serde_json::Value, + } + #[derive(Deserialize)] + struct ConnectVector { + policy: String, + protocol: String, + host: String, + port: u16, + allowed: bool, + } + #[derive(Deserialize)] + struct AddressVector { + address: String, + public: bool, + multicast: bool, + } + #[derive(Deserialize)] + struct ListenVector { + policy: String, + protocol: String, + address: String, + port: u16, + allowed: bool, + } + + #[test] + fn shared_policy_vectors() { + let vectors: PolicyVectors = + serde_json::from_str(include_str!("../../../../contracts/spec/vectors/network-policy.json")).unwrap(); + let mut policies = BTreeMap::new(); + for (name, doc) in &vectors.policies { + policies.insert(name.clone(), NetPolicy::parse(&doc.to_string()).unwrap_or_else(|e| panic!("{name}: {e}"))); + } + for v in &vectors.invalid { + assert!(NetPolicy::parse(&v.policy.to_string()).is_err(), "invalid vector accepted: {}", v.name); + } + for v in &vectors.connect { + let policy = &policies[&v.policy]; + assert_eq!(policy.allows_connect(&v.protocol, &v.host, v.port), v.allowed, "connect {:?}", (&v.policy, &v.protocol, &v.host, v.port)); + } + let open = &policies["standard"]; + let closed = &policies["secure-only"]; + for v in &vectors.address { + let addr = policy::parse_address(&v.address).unwrap_or_else(|| panic!("{}", v.address)); + assert_eq!(policy::address_is_public(addr), v.public, "{}", v.address); + assert_eq!(policy::address_is_multicast(addr), v.multicast, "{}", v.address); + assert_eq!(closed.allows_address(addr), v.public, "{}", v.address); + assert_eq!(open.allows_address(addr), !v.multicast, "{}", v.address); + } + for v in &vectors.listen { + let policy = &policies[&v.policy]; + assert_eq!(policy.allows_listen(&v.protocol, &v.address, v.port), v.allowed, "listen {:?}", (&v.policy, &v.protocol, &v.address, v.port)); + } + } + + #[derive(Deserialize)] + struct SemanticsVectors { + methods: Vec, + #[serde(rename = "requestHeaders")] + request_headers: Vec, + status: Vec, + redirect: Vec, + } + #[derive(Deserialize)] + struct MethodVector { + method: String, + accepted: bool, + } + #[derive(Deserialize)] + struct HeaderVector { + name: String, + #[serde(rename = "coreOwned")] + core_owned: bool, + } + #[derive(Deserialize)] + struct StatusVector { + status: u16, + #[serde(rename = "bodylessFraming")] + bodyless_framing: bool, + #[serde(rename = "nullBody")] + null_body: bool, + } + #[derive(Deserialize)] + struct RedirectVector { + status: u16, + method: String, + followed: bool, + #[serde(rename = "nextMethod")] + next_method: Option, + #[serde(rename = "keepBody")] + keep_body: Option, + } + + #[test] + fn shared_http_semantics_vectors() { + let vectors: SemanticsVectors = + serde_json::from_str(include_str!("../../../../contracts/spec/vectors/http-semantics.json")).unwrap(); + for v in &vectors.methods { + let mut core = core(); + let meta = serde_json::json!({"url": "http://example.test/", "method": v.method, "headers": {}}).to_string(); + let h = core.start(&meta, &[]); + assert_eq!(h > 0, v.accepted, "method {:?}", v.method); + } + for v in &vectors.request_headers { + let mut core = core(); + let meta = serde_json::json!({"url": "http://example.test/", "method": "GET", "headers": {v.name.clone(): "v"}}).to_string(); + assert!(core.start(&meta, &[]) > 0); + let sent = &core.backend_mut().started[0].headers; + assert_eq!(!sent.contains_key(&v.name.to_ascii_lowercase()), v.core_owned, "header {}", v.name); + } + for v in &vectors.status { + let framing = (100..200).contains(&v.status) || spec::HTTP_BODYLESS_STATUS.contains(&v.status); + assert_eq!(framing, v.bodyless_framing, "framing {}", v.status); + assert_eq!(spec::HTTP_NULL_BODY_STATUS.contains(&v.status), v.null_body, "null body {}", v.status); + } + let gate = PolicyGate::new(NetPolicy::permissive(), false); + for v in &vectors.redirect { + gate.begin(1, "http://example.test/a"); + let plan = gate.authorize_redirect(1, "http://example.test/a", &v.method, v.status, Some("http://example.test/b"), 5, RedirectMode::Follow); + match plan { + Ok(RedirectPlan::Follow { method, drop_body, .. }) => { + assert!(v.followed, "{} {} followed", v.status, v.method); + assert_eq!(&method, v.next_method.as_ref().unwrap(), "{} {}", v.status, v.method); + assert_eq!(!drop_body, v.keep_body.unwrap(), "{} {} body", v.status, v.method); + } + Ok(RedirectPlan::Deliver) => assert!(!v.followed, "{} {} delivered", v.status, v.method), + Err(e) => panic!("{} {}: {:?}", v.status, v.method, e.code), + } + } } #[cfg(feature = "mount")] #[test] - fn mounted_surface_copies_into_guest_owned_arraybuffer() { + fn mounts_the_v2_ops() { + use pocket_mod::qjs::Ctx; + let surface = NetSurface::new(core()); let guest = Guest::new().unwrap(); - let surface = NetSurface::new(FixtureTransport::default()); surface.mount(&guest).unwrap(); - let source = format!( - "globalThis.h = net.start({}, new ArrayBuffer(0)); globalThis.before = net.poll();", - serde_json::to_string(&meta(16)).unwrap() - ); - guest.eval("start", &source).unwrap(); - let handle: i32 = guest.with(|ctx| ctx.globals().get("h").unwrap()); - let before: Option = guest.with(|ctx| ctx.globals().get("before").unwrap()); - assert_eq!(handle, 1); - assert!(before.is_none()); - - surface.with_core(|core| { - core.transport_mut() - .completions - .push_back(TransportCompletion::Done { - handle, - status: 200, - url: "https://example.test/a".into(), - headers: BTreeMap::new(), - body: vec![7, 8, 9], - }); + guest.eval("t", "globalThis.h = net.start(JSON.stringify({url:'http://example.test/',method:'GET',headers:{}}), null);").unwrap(); + let h: i32 = guest.with(|ctx: Ctx| ctx.globals().get("h").unwrap()); + assert!(h > 0); + surface.with_core(|c| { + c.backend_mut().queue.push_back(headers(h, Some(3))); + c.backend_mut().queue.push_back(BackendEvent::Body { handle: h, chunk: b"abc".to_vec() }); + c.backend_mut().queue.push_back(BackendEvent::End { handle: h }); }); surface.begin_tick(); guest .eval( - "take", - "const e = JSON.parse(net.poll())[0];\ - const out = new ArrayBuffer(e.bytes);\ - globalThis.copied = net.take(e.h, out);\ - globalThis.first = new Uint8Array(out)[0];\ - globalThis.again = net.take(e.h, out);", + "t", + "const batch = JSON.parse(net.poll()); const buf = new ArrayBuffer(8); globalThis.n = net.readInto(globalThis.h, buf, 1, 4); globalThis.s = String.fromCharCode(...new Uint8Array(buf, 1, 3)); globalThis.k = batch.map(e => e.t).join(',');", ) .unwrap(); - let values: (i32, i32, i32) = guest.with(|ctx| { + let (n, s, k): (i32, String, String) = guest.with(|ctx: Ctx| { let g = ctx.globals(); - ( - g.get("copied").unwrap(), - g.get("first").unwrap(), - g.get("again").unwrap(), - ) + (g.get("n").unwrap(), g.get("s").unwrap(), g.get("k").unwrap()) }); - assert_eq!(values, (3, 7, -1)); + assert_eq!((n, s.as_str(), k.as_str()), (3, "abc", "headers,readable,end")); } } diff --git a/engine/crates/pocket-net/src/policy.rs b/engine/crates/pocket-net/src/policy.rs new file mode 100644 index 00000000..1cda6425 --- /dev/null +++ b/engine/crates/pocket-net/src/policy.rs @@ -0,0 +1,621 @@ +//! The network policy and its enforcement gate. +//! +//! `NetPolicy` parses exactly the canonical `ResolvedNetworkPolicy` document +//! the Build Plan resolver emits (contracts/spec/network-policy.ts, version +//! 1) — the same shapes engine/net's `pnet_policy_parse` accepts — and +//! decides connect rules, listen rules and address classification with the +//! reference semantics; contracts/spec/vectors/network-policy.json pins them +//! (see the tests at the bottom of lib.rs). +//! +//! `PolicyGate` is the authority a backend cannot route around: the core +//! hands one clone to the backend with every request, and the backend must +//! ask it for every decision that happens on the wire side of the core — +//! each resolved candidate address (`authorize_address`), each redirect hop +//! (`authorize_redirect`, which also applies the spec's method/body rewrite +//! table and the hop budget) and the TLS verification mode +//! (`tls_verification`). The gate records the URLs it authorized per handle; +//! when the backend reports response headers the core checks the response +//! URL against that record, so a backend that followed a hop on its own +//! (or answered from somewhere else) fails the exchange with +//! `permission_denied` instead of smuggling the response through. Backends +//! therefore implement transport, not policy; the rules live here once. + +use std::collections::BTreeMap; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use pocketjs_core::spec::net as spec; +use serde::Deserialize; + +use crate::{NetFailure, RedirectMode}; + +pub const NETWORK_POLICY_VERSION: u64 = 1; + +// --------------------------------------------------------------------------- +// Document +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PolicyDocument { + #[serde(default)] + version: Option, + #[serde(default)] + connect: Vec, + #[serde(default)] + listen: Vec, + #[serde(default)] + credentials: Vec, + #[serde(default, rename = "localNetwork")] + local_network: bool, + #[serde(default, rename = "insecureTransport")] + insecure_transport: bool, + #[serde(default, rename = "allowInvalidTlsForDevelopment")] + allow_invalid_tls_for_development: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RuleDocument { + protocol: String, + #[serde(default)] + host: Option, + #[serde(default)] + address: Option, + port: serde_json::Value, +} + +/// `http` / `https` / `ws` / `wss`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Protocol { + Http, + Https, + Ws, + Wss, +} + +impl Protocol { + pub fn parse(scheme: &str) -> Option { + match scheme { + "http" => Some(Protocol::Http), + "https" => Some(Protocol::Https), + "ws" => Some(Protocol::Ws), + "wss" => Some(Protocol::Wss), + _ => None, + } + } + pub fn is_plaintext(self) -> bool { + matches!(self, Protocol::Http | Protocol::Ws) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PortRule { + Single(u16), + Range { min: u16, max: u16 }, + /// Listen only: bind port 0. + Ephemeral, +} + +impl PortRule { + pub fn matches(self, port: u16) -> bool { + match self { + PortRule::Single(p) => p == port, + PortRule::Range { min, max } => (min..=max).contains(&port), + PortRule::Ephemeral => port == 0, + } + } +} + +/// What a rule's host matches. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HostRule { + /// A lowercase ASCII DNS name, compared exactly. + Name(String), + /// `*.suffix`: exactly one extra label. + Wildcard(String), + /// An IP literal, compared by address. + Address(IpAddr), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConnectRule { + pub protocol: Protocol, + pub host: HostRule, + pub port: PortRule, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ListenRule { + pub protocol: Protocol, + pub address: IpAddr, + pub port: PortRule, +} + +/// The immutable policy (one ResolvedNetworkPolicy). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NetPolicy { + pub connect: Vec, + pub listen: Vec, + pub credentials: Vec, + pub local_network: bool, + pub insecure_transport: bool, + pub allow_invalid_tls_for_development: bool, +} + +// --------------------------------------------------------------------------- +// Hostnames and addresses (mirrors contracts/spec/network-policy.ts) +// --------------------------------------------------------------------------- + +/// Lowercase ASCII DNS name: labels of [a-z0-9-], 1..63 bytes, not starting +/// or ending with '-', whole name <= 253 bytes, last label not all digits. +pub fn hostname_valid(name: &str) -> bool { + if name.is_empty() || name.len() > 253 { + return false; + } + let labels: Vec<&str> = name.split('.').collect(); + if labels.iter().any(|label| { + label.is_empty() + || label.len() > 63 + || label.starts_with('-') + || label.ends_with('-') + || !label.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + }) { + return false; + } + // A name whose last label is all digits is a malformed IPv4 literal. + !labels[labels.len() - 1].bytes().all(|b| b.is_ascii_digit()) +} + +/// Parse an IP literal (`1.2.3.4`, `::1`, `[::1]`); IPv4 octets with leading +/// zeros are refused (octal to some resolvers, decimal to others). +pub fn parse_address(text: &str) -> Option { + let body = text.strip_prefix('[').and_then(|t| t.strip_suffix(']')).unwrap_or(text); + if body.contains(':') { + return body.parse::().ok().map(IpAddr::V6); + } + let parts: Vec<&str> = body.split('.').collect(); + if parts.len() != 4 { + return None; + } + let mut octets = [0u8; 4]; + for (i, part) in parts.iter().enumerate() { + if part.is_empty() || part.len() > 3 || !part.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + if part.len() > 1 && part.starts_with('0') { + return None; + } + octets[i] = part.parse::().ok().filter(|v| *v <= 255)? as u8; + } + Some(IpAddr::V4(Ipv4Addr::from(octets))) +} + +/// Lowercase, drop one trailing root dot; None when not a valid name. +pub fn normalize_hostname(host: &str) -> Option { + if !host.bytes().all(|b| (0x21..=0x7e).contains(&b)) { + return None; + } + let mut lower = host.to_ascii_lowercase(); + if lower.len() > 1 && lower.ends_with('.') { + lower.pop(); + } + if hostname_valid(&lower) { + Some(lower) + } else { + None + } +} + +pub fn address_is_multicast(addr: IpAddr) -> bool { + match addr { + IpAddr::V4(v4) => (v4.octets()[0] & 0xf0) == 0xe0, + IpAddr::V6(v6) => v6.octets()[0] == 0xff, + } +} + +/// Globally routable unicast, the classification shared with engine/net's +/// pnet_addr_is_public and the TypeScript reference. +pub fn address_is_public(addr: IpAddr) -> bool { + match addr { + IpAddr::V4(v4) => { + let a = v4.octets(); + !(a[0] == 0 + || a[0] == 10 + || a[0] == 127 + || (a[0] == 169 && a[1] == 254) + || (a[0] == 172 && (a[1] & 0xf0) == 16) + || (a[0] == 192 && a[1] == 168) + || (a[0] == 100 && (a[1] & 0xc0) == 64) + || (a[0] & 0xf0) == 0xe0 + || a == [255, 255, 255, 255]) + } + IpAddr::V6(v6) => { + let a = v6.octets(); + if a[..15].iter().all(|b| *b == 0) && (a[15] == 0 || a[15] == 1) { + return false; + } + if a[0] == 0xfe && (a[1] & 0xc0) == 0x80 { + return false; + } + if (a[0] & 0xfe) == 0xfc { + return false; + } + if a[0] == 0xff { + return false; + } + if a[..10].iter().all(|b| *b == 0) && a[10] == 0xff && a[11] == 0xff { + return address_is_public(IpAddr::V4(Ipv4Addr::new(a[12], a[13], a[14], a[15]))); + } + true + } + } +} + +impl HostRule { + fn parse(text: &str) -> Option { + if let Some(addr) = parse_address(text) { + return Some(HostRule::Address(addr)); + } + if !text.bytes().all(|b| (0x21..=0x7e).contains(&b)) { + return None; + } + let lower = text.to_ascii_lowercase(); + let lower = if lower.len() > 1 && lower.ends_with('.') { &lower[..lower.len() - 1] } else { &lower[..] }; + if let Some(suffix) = lower.strip_prefix("*.") { + if parse_address(suffix).is_some() || !hostname_valid(suffix) { + return None; + } + return Some(HostRule::Wildcard(suffix.to_string())); + } + if lower.starts_with('*') { + return None; // a bare `*` or `*foo` is not a rule + } + if hostname_valid(lower) { + Some(HostRule::Name(lower.to_string())) + } else { + None + } + } + + /// `host` as the URL parser hands it over (brackets allowed). + pub fn matches(&self, host: &str) -> bool { + match self { + HostRule::Address(addr) => parse_address(host) == Some(*addr), + HostRule::Name(name) => normalize_hostname(host).as_deref() == Some(name.as_str()), + HostRule::Wildcard(suffix) => match normalize_hostname(host) { + Some(target) => { + target.len() > suffix.len() + 1 + && target.ends_with(suffix) + && target.as_bytes()[target.len() - suffix.len() - 1] == b'.' + && !target[..target.len() - suffix.len() - 1].contains('.') + } + None => false, + }, + } + } +} + +fn parse_port(value: &serde_json::Value, listen: bool) -> Option { + match value { + serde_json::Value::Number(n) => { + let v = n.as_u64()?; + if (1..=65535).contains(&v) { Some(PortRule::Single(v as u16)) } else { None } + } + serde_json::Value::String(s) if listen && s == "ephemeral" => Some(PortRule::Ephemeral), + serde_json::Value::Object(map) => { + let min = map.get("min")?.as_u64()?; + let max = map.get("max")?.as_u64()?; + if map.len() != 2 || min < 1 || max > 65535 || min > max { + return None; + } + Some(PortRule::Range { min: min as u16, max: max as u16 }) + } + _ => None, + } +} + +impl NetPolicy { + /// Parse the canonical policy JSON; `Err` names the first fault. + pub fn parse(json: &str) -> Result { + let doc: PolicyDocument = serde_json::from_str(json).map_err(|e| e.to_string())?; + if let Some(version) = doc.version { + if version != NETWORK_POLICY_VERSION { + return Err(format!("unsupported network policy version {version}")); + } + } + let mut connect = Vec::with_capacity(doc.connect.len()); + for (i, rule) in doc.connect.iter().enumerate() { + let protocol = Protocol::parse(&rule.protocol).ok_or_else(|| format!("connect[{i}]: unknown protocol"))?; + let host = rule.host.as_deref().ok_or_else(|| format!("connect[{i}]: host missing"))?; + let host = HostRule::parse(host).ok_or_else(|| format!("connect[{i}]: invalid host"))?; + let port = parse_port(&rule.port, false).ok_or_else(|| format!("connect[{i}]: invalid port"))?; + if rule.address.is_some() { + return Err(format!("connect[{i}]: unexpected address")); + } + connect.push(ConnectRule { protocol, host, port }); + } + let mut listen = Vec::with_capacity(doc.listen.len()); + for (i, rule) in doc.listen.iter().enumerate() { + let protocol = Protocol::parse(&rule.protocol).ok_or_else(|| format!("listen[{i}]: unknown protocol"))?; + let address = rule.address.as_deref().ok_or_else(|| format!("listen[{i}]: address missing"))?; + let address = parse_address(address).ok_or_else(|| format!("listen[{i}]: address must be an IP literal"))?; + let port = parse_port(&rule.port, true).ok_or_else(|| format!("listen[{i}]: invalid port"))?; + if rule.host.is_some() { + return Err(format!("listen[{i}]: unexpected host")); + } + listen.push(ListenRule { protocol, address, port }); + } + if doc.credentials.iter().any(|c| c.is_empty()) { + return Err("credentials: empty id".into()); + } + Ok(NetPolicy { + connect, + listen, + credentials: doc.credentials, + local_network: doc.local_network, + insecure_transport: doc.insecure_transport, + allow_invalid_tls_for_development: doc.allow_invalid_tls_for_development, + }) + } + + /// A development/test policy: plaintext and TLS to loopback names and + /// `*.test` on any port, local network allowed. Never a bare wildcard — + /// the contract has none. + pub fn permissive() -> Self { + let any = PortRule::Range { min: 1, max: 65535 }; + let hosts = [ + HostRule::Name("localhost".into()), + HostRule::Address(IpAddr::V4(Ipv4Addr::LOCALHOST)), + HostRule::Address(IpAddr::V6(Ipv6Addr::LOCALHOST)), + HostRule::Wildcard("test".into()), + ]; + let mut connect = Vec::new(); + for protocol in [Protocol::Http, Protocol::Https, Protocol::Ws, Protocol::Wss] { + for host in &hosts { + connect.push(ConnectRule { protocol, host: host.clone(), port: any }); + } + } + Self { + connect, + listen: vec![ + ListenRule { protocol: Protocol::Http, address: IpAddr::V4(Ipv4Addr::LOCALHOST), port: any }, + ListenRule { protocol: Protocol::Http, address: IpAddr::V4(Ipv4Addr::LOCALHOST), port: PortRule::Ephemeral }, + ], + credentials: Vec::new(), + local_network: true, + insecure_transport: true, + allow_invalid_tls_for_development: false, + } + } + + /// Endpoint rule + insecureTransport, before DNS. + pub fn allows_connect(&self, scheme: &str, host: &str, port: u16) -> bool { + let Some(protocol) = Protocol::parse(scheme) else { return false }; + if protocol.is_plaintext() && !self.insecure_transport { + return false; + } + self.connect + .iter() + .any(|rule| rule.protocol == protocol && rule.port.matches(port) && rule.host.matches(host)) + } + + /// A resolved candidate address: public, or local with `localNetwork`; + /// multicast never. + pub fn allows_address(&self, addr: IpAddr) -> bool { + if address_is_multicast(addr) { + return false; + } + address_is_public(addr) || self.local_network + } + + pub fn allows_listen(&self, scheme: &str, address: &str, port: u16) -> bool { + let Some(protocol) = Protocol::parse(scheme) else { return false }; + if protocol.is_plaintext() && !self.insecure_transport { + return false; + } + let Some(addr) = parse_address(address) else { return false }; + self.listen + .iter() + .any(|rule| rule.protocol == protocol && rule.address == addr && rule.port.matches(port)) + } + + pub fn has_credential(&self, id: &str) -> bool { + self.credentials.iter().any(|c| c == id) + } +} + +// --------------------------------------------------------------------------- +// Gate +// --------------------------------------------------------------------------- + +/// The plan a redirect gets from the gate. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RedirectPlan { + /// Not a redirect the client follows here (no redirect status, no + /// Location, or `redirect: "manual"`): deliver the response as it is. + Deliver, + /// Follow: the next hop's absolute URL, the method to use, and whether + /// the request body is dropped (303 for everything but HEAD, 301/302 + /// for POST). + Follow { url: String, method: String, drop_body: bool }, +} + +/// TLS verification the backend must apply to a connection. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TlsVerification { + /// Verify the chain and the hostname (DNS-ID) — always, except the + /// development-insecure case the policy + build admitted. + pub verify_peer: bool, + /// SNI / DNS-ID: the authorized hostname. + pub server_name: String, + pub min_version: &'static str, +} + +struct GateInner { + policy: NetPolicy, + development_build: AtomicBool, + tls_available: bool, + /// Per handle: every URL the gate authorized, in order (the first entry + /// is the start URL). + hops: Mutex>>, +} + +/// Clone-cheap, `Send + Sync`: a backend keeps one and consults it from +/// whatever thread runs its I/O. +#[derive(Clone)] +pub struct PolicyGate { + inner: Arc, +} + +impl PolicyGate { + pub(crate) fn new(policy: NetPolicy, tls_available: bool) -> Self { + Self { + inner: Arc::new(GateInner { + policy, + development_build: AtomicBool::new(false), + tls_available, + hops: Mutex::new(BTreeMap::new()), + }), + } + } + + pub(crate) fn set_development_build(&self, enabled: bool) { + self.inner.development_build.store(enabled, Ordering::SeqCst); + } + + pub fn policy(&self) -> &NetPolicy { + &self.inner.policy + } + + /// The core records the start URL when it admits a request. + pub(crate) fn begin(&self, handle: i32, url: &str) { + self.inner.hops.lock().unwrap().insert(handle, vec![url.to_string()]); + } + + pub(crate) fn forget(&self, handle: i32) { + self.inner.hops.lock().unwrap().remove(&handle); + } + + /// The URLs authorized for `handle` so far (start URL first). + pub fn authorized_urls(&self, handle: i32) -> Vec { + self.inner.hops.lock().unwrap().get(&handle).cloned().unwrap_or_default() + } + + /// Endpoint rule + insecureTransport for an arbitrary tuple (proxies, + /// alternate services). The core already ran it for the start URL. + pub fn authorize_endpoint(&self, scheme: &str, host: &str, port: u16) -> Result<(), NetFailure> { + if self.inner.policy.allows_connect(scheme, host, port) { + Ok(()) + } else { + Err(NetFailure::new(spec::ERROR_PERMISSION_DENIED, "endpoint is not an allowed connect rule")) + } + } + + /// Every candidate address the resolver produced, before connecting to + /// it: loopback / link-local / private / CGNAT / ULA only with + /// localNetwork, multicast never. + pub fn authorize_address(&self, addr: IpAddr) -> Result<(), NetFailure> { + if self.inner.policy.allows_address(addr) { + Ok(()) + } else { + Err(NetFailure::new(spec::ERROR_PERMISSION_DENIED, "resolved address is not permitted by the policy")) + } + } + + /// The redirect decision for a response: the spec's followed statuses + /// and rewrite table, the hop budget, the scheme and TLS availability, + /// the endpoint policy for the target — recorded for the core's check. + #[allow(clippy::too_many_arguments)] + pub fn authorize_redirect( + &self, + handle: i32, + from_url: &str, + method: &str, + status: u16, + location: Option<&str>, + redirects_left: u32, + mode: RedirectMode, + ) -> Result { + if !spec::HTTP_REDIRECT_STATUS.contains(&status) { + return Ok(RedirectPlan::Deliver); + } + let Some(location) = location else { return Ok(RedirectPlan::Deliver) }; + match mode { + RedirectMode::Manual => return Ok(RedirectPlan::Deliver), + RedirectMode::Error => return Err(NetFailure::new(spec::ERROR_REDIRECT, "redirect refused by policy")), + RedirectMode::Follow => {} + } + if redirects_left == 0 { + return Err(NetFailure::new(spec::ERROR_REDIRECT, "too many redirects")); + } + let Some(next) = resolve_url(from_url, location) else { + return Err(NetFailure::new(spec::ERROR_REDIRECT, "invalid Location")); + }; + let Some((scheme, host, port)) = crate::parse_url(&next) else { + return Err(NetFailure::new(spec::ERROR_REDIRECT, "invalid Location")); + }; + if scheme != "http" && scheme != "https" { + return Err(NetFailure::new(spec::ERROR_REDIRECT, "redirect to a non-HTTP scheme")); + } + if scheme == "https" && !self.inner.tls_available { + return Err(NetFailure::new(spec::ERROR_UNSUPPORTED, "redirect to https without network.http.client.tls")); + } + if !self.inner.policy.allows_connect(scheme, &host, port) { + return Err(NetFailure::new(spec::ERROR_PERMISSION_DENIED, "redirect target is not an allowed endpoint")); + } + let upper = method.to_ascii_uppercase(); + let to_get = (spec::HTTP_REDIRECT_ANY_TO_GET_STATUS.contains(&status) && upper != "HEAD") + || (spec::HTTP_REDIRECT_POST_TO_GET_STATUS.contains(&status) && upper == "POST"); + self.inner.hops.lock().unwrap().entry(handle).or_default().push(next.clone()); + Ok(RedirectPlan::Follow { + url: next, + method: if to_get { "GET".to_string() } else { method.to_string() }, + drop_body: to_get, + }) + } + + /// Verification for a TLS connection to `server_name`; the + /// development-insecure mode applies only when the policy, the build and + /// the request all asked for it. + pub fn tls_verification(&self, server_name: &str, development_insecure_requested: bool) -> TlsVerification { + let insecure = development_insecure_requested + && self.inner.development_build.load(Ordering::SeqCst) + && self.inner.policy.allow_invalid_tls_for_development; + TlsVerification { verify_peer: !insecure, server_name: server_name.to_string(), min_version: spec::TLS_MIN_VERSION } + } +} + +/// Resolve a Location against the current URL: absolute, scheme-relative, +/// path-absolute, or relative to the current path's directory. Query and +/// fragment of the Location are kept; the base's are dropped. +pub fn resolve_url(base: &str, location: &str) -> Option { + let location = location.trim(); + if location.is_empty() { + return None; + } + if location.contains("://") { + return Some(location.to_string()); + } + let (scheme, rest) = base.split_once("://")?; + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + if let Some(stripped) = location.strip_prefix("//") { + return Some(format!("{scheme}://{stripped}")); + } + let base_path = { + let after = &rest[authority_end..]; + let end = after.find(['?', '#']).unwrap_or(after.len()); + let path = &after[..end]; + if path.is_empty() { "/" } else { path } + }; + if location.starts_with('/') { + return Some(format!("{scheme}://{authority}{location}")); + } + let dir = match base_path.rfind('/') { + Some(i) => &base_path[..=i], + None => "/", + }; + Some(format!("{scheme}://{authority}{dir}{location}")) +} 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..f6627f44 --- /dev/null +++ b/engine/net/CMakeLists.txt @@ -0,0 +1,86 @@ +# 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) +# 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) +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..00a776c4 --- /dev/null +++ b/engine/net/drivers/posix/pnet_posix_driver.c @@ -0,0 +1,706 @@ +/* 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" +#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; +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 +#define MSG_NOSIGNAL 0 +#endif + +#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; + 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]; + /* 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) { + 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) { + if (d->resolver_running) signal_post(&d->resolver_signal); + else pnet_posix_driver_wake(d); /* inline fallback: resolved in wait() */ + } + 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; + } + } + 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); + 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; +} + +/* 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++) { + resolve_slot *r = &d->resolves[i]; + if (r->state == RS_PENDING && !r->cancelled) { + idx = i; + strcpy(host, r->host); + break; + } + 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; + } + } + 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) { + /* 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); + 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..583ec1b8 --- /dev/null +++ b/engine/net/drivers/posix/pnet_posix_driver.h @@ -0,0 +1,57 @@ +/* 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, 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); // select only, no lock held + * } + * + * `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 + +#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). + * 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..1685b943 --- /dev/null +++ b/engine/net/include/pocketjs/net/runtime.h @@ -0,0 +1,207 @@ +/* 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 (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); + +/* --- 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_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); + +/* --- 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_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); + +#ifdef __cplusplus +} +#endif + +#endif /* POCKETJS_NET_RUNTIME_H */ diff --git a/engine/net/include/pocketjs/net/spec.h b/engine/net/include/pocketjs/net/spec.h new file mode 100644 index 00000000..b7e280e1 --- /dev/null +++ b/engine/net/include/pocketjs/net/spec.h @@ -0,0 +1,174 @@ +/* GENERATED — do not edit; run `bun contracts/spec/gen-c.ts`. */ +/* C mirror of contracts/spec/{net,ws,httpd}.ts: the guest boundaries of the + * network modules (`globalThis.net` / `ws` / `httpd`). Every value here is a + * portable ceiling or a wire-visible constant; a host's limits() may only + * tighten the ceilings. tests/contract.ts byte-compares this file. */ +#ifndef POCKETJS_NET_SPEC_H +#define POCKETJS_NET_SPEC_H + +/* --- net: HTTP Client (`globalThis.net`) --- */ +#define PNET_SPEC_MAJOR 2 +#define PNET_SPEC_MINOR 0 +#define PNET_OP_START 1 +#define PNET_OP_TAKE 2 +#define PNET_OP_CANCEL 3 +#define PNET_OP_POLL 4 +#define PNET_OP_LAST_ERROR 5 +#define PNET_OP_READ_INTO 6 +#define PNET_OP_LIMITS 7 +#define PNET_OP_WRITE 8 +#define PNET_OP_END_BODY 9 +#define PNET_MAX_INFLIGHT 8 +#define PNET_MAX_REQUEST_BYTES 262144 +#define PNET_DEFAULT_QUEUE_BYTES 32768 +#define PNET_MAX_QUEUE_BYTES 262144 +#define PNET_DEFAULT_AGGREGATE_BYTES 1048576 +#define PNET_MAX_AGGREGATE_BYTES 8388608 +#define PNET_MAX_EVENTS_PER_TICK 128 +#define PNET_MAX_TICK_BYTES 262144 +#define PNET_MAX_HEADERS 64 +#define PNET_MAX_HEADER_BYTES 16384 +#define PNET_DEFAULT_TIMEOUT_MS 30000 +#define PNET_MAX_TIMEOUT_MS 120000 +#define PNET_MAX_REDIRECTS 5 +#define PNET_TLS_MIN_VERSION "1.2" +#define PNET_METHODS_FORBIDDEN_COUNT 3 +#define PNET_METHODS_FORBIDDEN { "CONNECT", "TRACE", "TRACK" } +/* HTTP semantics shared by client, server and SDK (see net.ts). */ +#define PNET_HTTP_CORE_OWNED_REQUEST_HEADERS_COUNT 10 +#define PNET_HTTP_CORE_OWNED_REQUEST_HEADERS { "host", "connection", "content-length", "transfer-encoding", "trailer", "te", "upgrade", "keep-alive", "expect", "proxy-connection" } +#define PNET_HTTP_BODYLESS_STATUS_COUNT 2 +#define PNET_HTTP_BODYLESS_STATUS { 204, 304 } +#define PNET_HTTP_NULL_BODY_STATUS_COUNT 5 +#define PNET_HTTP_NULL_BODY_STATUS { 101, 103, 204, 205, 304 } +#define PNET_HTTP_REDIRECT_STATUS_COUNT 5 +#define PNET_HTTP_REDIRECT_STATUS { 301, 302, 303, 307, 308 } +#define PNET_HTTP_REDIRECT_POST_TO_GET_STATUS_COUNT 2 +#define PNET_HTTP_REDIRECT_POST_TO_GET_STATUS { 301, 302 } +#define PNET_HTTP_REDIRECT_ANY_TO_GET_STATUS_COUNT 1 +#define PNET_HTTP_REDIRECT_ANY_TO_GET_STATUS { 303 } +#define PNET_EVENT_HEADERS "headers" +#define PNET_EVENT_READABLE "readable" +#define PNET_EVENT_END "end" +#define PNET_EVENT_ERROR "error" +#define PNET_EVENT_DRAIN "drain" +/* Error vocabulary shared by net, ws and httpd. */ +#define PNET_ERROR_INVALID_REQUEST "invalid_request" +#define PNET_ERROR_INVALID_STATE "invalid_state" +#define PNET_ERROR_UNSUPPORTED "unsupported" +#define PNET_ERROR_PERMISSION_DENIED "permission_denied" +#define PNET_ERROR_BUSY "busy" +#define PNET_ERROR_RESOURCE_LIMIT "resource_limit" +#define PNET_ERROR_DNS "dns" +#define PNET_ERROR_CONNECT "connect" +#define PNET_ERROR_ADDRESS_IN_USE "address_in_use" +#define PNET_ERROR_CLOSED "closed" +#define PNET_ERROR_TIMEOUT "timeout" +#define PNET_ERROR_TLS_CERTIFICATE_INVALID "tls_certificate_invalid" +#define PNET_ERROR_TLS_HOSTNAME_MISMATCH "tls_hostname_mismatch" +#define PNET_ERROR_TLS_HANDSHAKE_FAILED "tls_handshake_failed" +#define PNET_ERROR_TLS_CLOCK_UNTRUSTED "tls_clock_untrusted" +#define PNET_ERROR_REDIRECT "redirect" +#define PNET_ERROR_RESPONSE_TOO_LARGE "response_too_large" +#define PNET_ERROR_PROTOCOL "protocol" +#define PNET_ERROR_WEBSOCKET_HANDSHAKE_FAILED "websocket_handshake_failed" +#define PNET_ERROR_WEBSOCKET_PROTOCOL_ERROR "websocket_protocol_error" +#define PNET_ERROR_MESSAGE_TOO_LARGE "message_too_large" +#define PNET_ERROR_CANCELLED "cancelled" +#define PNET_ERROR_OTHER "other" +#define PNET_ERROR_UNAVAILABLE "unavailable" + +/* --- ws: WebSocket Client (`globalThis.ws`) --- */ +#define PWS_SPEC_MAJOR 2 +#define PWS_SPEC_MINOR 0 +#define PWS_OP_CONNECT 1 +#define PWS_OP_SEND 2 +#define PWS_OP_RECEIVE_INTO 3 +#define PWS_OP_CLOSE 4 +#define PWS_OP_TERMINATE 5 +#define PWS_OP_BUFFERED_AMOUNT 6 +#define PWS_OP_POLL 7 +#define PWS_OP_LAST_ERROR 8 +#define PWS_OP_LIMITS 9 +#define PWS_SEND_ACCEPTED 0 +#define PWS_SEND_ACCEPTED_HIGH_WATER 1 +#define PWS_SEND_CLOSED (-1) +#define PWS_SEND_BACKPRESSURE (-2) +#define PWS_SEND_INVALID (-3) +#define PWS_OPCODE_TEXT 1 +#define PWS_OPCODE_BINARY 2 +#define PWS_OPCODE_PING 9 +#define PWS_OPCODE_PONG 10 +#define PWS_EVENT_OPEN "open" +#define PWS_EVENT_MESSAGE "message" +#define PWS_EVENT_PING "ping" +#define PWS_EVENT_PONG "pong" +#define PWS_EVENT_DRAIN "drain" +#define PWS_EVENT_ERROR "error" +#define PWS_EVENT_CLOSE "close" +#define PWS_BLOB_KEY "$b" +#define PWS_FORBIDDEN_HEADERS_COUNT 9 +#define PWS_FORBIDDEN_HEADERS { "host", "connection", "upgrade", "content-length", "sec-websocket-key", "sec-websocket-version", "sec-websocket-protocol", "sec-websocket-extensions", "sec-websocket-accept" } +#define PWS_MAX_SOCKETS 8 +#define PWS_MAX_MESSAGE_BYTES 1048576 +#define PWS_MAX_RECEIVE_QUEUE_BYTES 1048576 +#define PWS_MAX_RECEIVE_QUEUE_MESSAGES 64 +#define PWS_MAX_SEND_QUEUE_BYTES 1048576 +#define PWS_SEND_HIGH_WATER_BYTES 262144 +#define PWS_SEND_LOW_WATER_BYTES 65536 +#define PWS_MAX_HANDSHAKE_HEADERS 64 +#define PWS_MAX_HANDSHAKE_HEADER_BYTES 16384 +#define PWS_MAX_EVENTS_PER_TICK 128 +#define PWS_MAX_TICK_BYTES 262144 +#define PWS_DEFAULT_CONNECT_MS 30000 +#define PWS_MAX_CONNECT_MS 120000 +#define PWS_DEFAULT_CLOSE_MS 5000 +#define PWS_CONTROL_PAYLOAD_MAX 125 + +/* --- httpd: HTTP Server (`globalThis.httpd`) --- */ +#define PHTTPD_SPEC_MAJOR 2 +#define PHTTPD_SPEC_MINOR 0 +#define PHTTPD_OP_LISTEN 1 +#define PHTTPD_OP_STOP 2 +#define PHTTPD_OP_RESPOND 3 +#define PHTTPD_OP_WRITE 4 +#define PHTTPD_OP_END_BODY 5 +#define PHTTPD_OP_READ_INTO 6 +#define PHTTPD_OP_ABORT 7 +#define PHTTPD_OP_POLL 8 +#define PHTTPD_OP_LAST_ERROR 9 +#define PHTTPD_OP_LIMITS 10 +#define PHTTPD_SEND_ACCEPTED 0 +#define PHTTPD_SEND_INVALID_REQUEST (-1) +#define PHTTPD_SEND_BACKPRESSURE (-2) +#define PHTTPD_SEND_INVALID (-3) +#define PHTTPD_EVENT_LISTENING "listening" +#define PHTTPD_EVENT_CLOSED "closed" +#define PHTTPD_EVENT_ERROR "error" +#define PHTTPD_EVENT_REQUEST "request" +#define PHTTPD_EVENT_READABLE "readable" +#define PHTTPD_EVENT_END "end" +#define PHTTPD_EVENT_DRAIN "drain" +#define PHTTPD_EVENT_ABORTED "aborted" +#define PHTTPD_MAX_SERVERS 2 +#define PHTTPD_MAX_CONNECTIONS 16 +#define PHTTPD_MAX_INFLIGHT 8 +#define PHTTPD_MAX_BACKLOG 16 +#define PHTTPD_MAX_HEADERS 64 +#define PHTTPD_MAX_HEADER_BYTES 16384 +#define PHTTPD_MAX_TARGET_BYTES 2048 +#define PHTTPD_DEFAULT_REQUEST_QUEUE_BYTES 32768 +#define PHTTPD_MAX_REQUEST_QUEUE_BYTES 262144 +#define PHTTPD_MAX_SEND_QUEUE_BYTES 262144 +#define PHTTPD_SEND_HIGH_WATER_BYTES 131072 +#define PHTTPD_SEND_LOW_WATER_BYTES 32768 +#define PHTTPD_MAX_EVENTS_PER_TICK 128 +#define PHTTPD_MAX_TICK_BYTES 262144 +#define PHTTPD_DEFAULT_HEADER_MS 10000 +#define PHTTPD_DEFAULT_BODY_IDLE_MS 30000 +#define PHTTPD_DEFAULT_HANDLER_MS 30000 +#define PHTTPD_DEFAULT_KEEP_ALIVE_MS 15000 +#define PHTTPD_DEFAULT_CLOSE_MS 5000 +#define PHTTPD_MAX_TIMEOUT_MS 120000 + +#endif /* POCKETJS_NET_SPEC_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..4d8a6267 --- /dev/null +++ b/engine/net/src/pnet_http_client.c @@ -0,0 +1,933 @@ +/* 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; + 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 */ + 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 (pnet_http_redirect_plan). */ + 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") || pnet_status_is_bodyless(head->status); + 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; } + } + 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[] = PNET_HTTP_CORE_OWNED_REQUEST_HEADERS; + bool skip = false; + 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; + 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_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); +} + +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..8a339ba9 --- /dev/null +++ b/engine/net/src/pnet_http_server.c @@ -0,0 +1,1228 @@ +/* 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; + } + /* 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"); + 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_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); +} + +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..893b155e --- /dev/null +++ b/engine/net/src/pnet_internal.h @@ -0,0 +1,611 @@ +/* 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_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); + +/* ------------------------------------------------------------------------ */ +/* 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; + 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 */ +} 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; + /** 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; + +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 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); + +/** 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..4c678b20 --- /dev/null +++ b/engine/net/src/pnet_json.c @@ -0,0 +1,362 @@ +/* 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 == '{' || 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)) { + 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..a036114e --- /dev/null +++ b/engine/net/src/pnet_policy.c @@ -0,0 +1,245 @@ +/* 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) { + 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; + 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 { + 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; + 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) { + /* `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) { + 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..70cf1624 --- /dev/null +++ b/engine/net/src/pnet_runtime.c @@ -0,0 +1,888 @@ +/* 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; + q->rendered = false; + q->rendered_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_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; + 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); + } + pnet_sb_putc(rt, &q->poll_buf, ']'); + /* Reserved exactly: rendering cannot have failed. */ + if (q->poll_buf.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; + 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..9204be5e --- /dev/null +++ b/engine/net/src/pnet_util.c @@ -0,0 +1,692 @@ +/* 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; + /* 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; + 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_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; +} + +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..114953c4 --- /dev/null +++ b/engine/net/src/pnet_ws.c @@ -0,0 +1,1120 @@ +/* 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_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); +} + +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..a40fc7c7 --- /dev/null +++ b/engine/net/test/host_test.c @@ -0,0 +1,1257 @@ +/* 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)); +} + +/* 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]; + /* 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}}," + "{\"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; + harness_start(&h, policy); + CHECK(h.rt != NULL); + if (h.rt) { + test_client(&h, &p); + test_resolver(&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..4470b6e0 --- /dev/null +++ b/engine/net/test/unit_test.c @@ -0,0 +1,754 @@ +/* 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); +} + +/* --- 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) { + 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); + + /* 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) -------------------------------------- */ + +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); +} + +/* --- 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); + if (!rt) return 1; + test_h1_head(); + test_h1_body(); + test_url(rt); + test_policy(rt); + test_policy_vectors(); + test_http_semantics_vectors(); + 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; +} diff --git a/framework/compiler/subpaths.ts b/framework/compiler/subpaths.ts index 493d81dc..02f13972 100644 --- a/framework/compiler/subpaths.ts +++ b/framework/compiler/subpaths.ts @@ -82,7 +82,10 @@ export const SUBPATHS: Record = { kinetics: { file: { solid: "framework/src/kinetics.ts" } }, launcher: { file: "framework/src/launcher.ts" }, manifest: { file: "framework/src/manifest/index.ts" }, - net: { file: "framework/src/net-api.ts", aliases: TWINS }, + headless: { file: "framework/src/headless.ts", aliases: TWINS }, + net: { file: "framework/src/net/index.ts", aliases: TWINS }, + "net/http": { file: "framework/src/net/http.ts", aliases: TWINS }, + "net/websocket": { file: "framework/src/net/websocket.ts", aliases: TWINS }, osk: { file: { solid: "framework/src/osk.tsx" } }, package: { file: "contracts/spec/pocket-package.ts" }, platform: { file: "framework/src/platform.ts" }, diff --git a/framework/src/frame-prelude.ts b/framework/src/frame-prelude.ts new file mode 100644 index 00000000..8cdeb72f --- /dev/null +++ b/framework/src/frame-prelude.ts @@ -0,0 +1,40 @@ +// The fixed prefix of every frame transaction, shared by the Solid, Vue +// Vapor, Octane and headless entries: +// +// virtual clock → input latches → service pumps → effect delivery +// +// The order is a correctness contract, not a convention: module Promise +// delivery (network batches polled by the service pumps) must enter the world +// before the frame-boundary effects and before any app code runs, and the +// input latches must be in place before anything reads analog/touch state. +// One definition here keeps a fifth runtime from re-typing the sequence. + +import { __setAnalog } from "./analog.ts"; +import { __advanceClock } from "./clock.ts"; +import { __drainEffects } from "./effects.ts"; +import { runServicePumps } from "./services.ts"; +import { __setTouches } from "./touch.ts"; + +export interface FramePreludeInput { + /** Packed analog nub sample (see analog.ts); undefined = no nub. */ + readonly analog?: number; + /** Packed touch contacts and their host-resolved hit facts (touch.ts). */ + readonly touches?: readonly number[]; + readonly hits?: readonly number[]; +} + +/** + * Run the frame prelude. UI entries pass the host's input snapshot so the + * latches are set before pumps and effects; the headless entry passes + * nothing (no input surface). Promise reactions raised inside the pumps run + * in the host's job drain after `frame()` returns. + */ +export function runFramePrelude(input?: FramePreludeInput): void { + __advanceClock(); // virtual frame++, fire due after() timers + if (input) { + __setAnalog(input.analog); // latch the nub before any app code reads it + __setTouches(input.touches, input.hits); // latch contacts + their hit facts + } + runServicePumps(); // only modules with pending async work register here + __drainEffects(); // frame-boundary deliveries enter the world first +} diff --git a/framework/src/headless.ts b/framework/src/headless.ts new file mode 100644 index 00000000..32bad3a4 --- /dev/null +++ b/framework/src/headless.ts @@ -0,0 +1,37 @@ +// Headless runtime entry: the frame transaction without a UI root. +// +// A host without a display (or a display it does not drive from PocketJS) +// still ticks the guest once per host tick through `globalThis.frame(...)`. +// `mountHeadless()` installs a frame handler that runs the same fixed +// prefix of the frame transaction the UI entries run — virtual clock → +// service pumps (network delivery) → effect delivery → app hook — and +// nothing else: no renderer, no input edge detection, no `globalThis.ui` +// requirement. Promise reactions raised inside the pumps run in the host's +// job drain after `frame()` returns, exactly as under `render()`. +// +// This is what the network smoke firmware and headless daemons use; a UI +// app keeps using `render()`/`mount()` from the framework entry. + +import { resetClock } from "./clock.ts"; +import { resetEffects } from "./effects.ts"; +import { runFramePrelude } from "./frame-prelude.ts"; +import { installFrameHandler } from "./host.ts"; + +export interface HeadlessOptions { + /** Called every frame after service pumps and effect delivery. */ + frame?: (buttons: number, analog: number) => void; +} + +/** Install the headless frame handler. Returns a disposer that uninstalls it. */ +export function mountHeadless(options: HeadlessOptions = {}): () => void { + resetClock(); // latches the host's __simHz clock policy (docs/DETERMINISM.md) + resetEffects(); + const hook = options.frame; + installFrameHandler((buttons: number, analog?: number) => { + runFramePrelude(); // clock → pumps → effects (frame-prelude.ts); no input surface to latch + if (hook) hook(buttons, analog ?? 0); + }); + return () => { + (globalThis as { frame?: unknown }).frame = undefined; + }; +} diff --git a/framework/src/index-octane.ts b/framework/src/index-octane.ts index 73b3ad8a..7b69ac8f 100644 --- a/framework/src/index-octane.ts +++ b/framework/src/index-octane.ts @@ -29,11 +29,11 @@ import { import { setOverlayRoot } from "./overlay.ts"; import { registerStyles, resolveStyle } from "./styles.ts"; import { handleFrame, setInputRoot } from "./input.ts"; -import { __setAnalog, resetFrameHooks, runFrameHooks } from "./frame-octane.tsx"; -import { __resetTouches, __setTouches } from "./touch.ts"; -import { __advanceClock, resetClock } from "./clock.ts"; -import { __drainEffects, resetEffects } from "./effects.ts"; -import { runServicePumps } from "./services.ts"; +import { resetFrameHooks, runFrameHooks } from "./frame-octane.tsx"; +import { __resetTouches } from "./touch.ts"; +import { resetClock } from "./clock.ts"; +import { resetEffects } from "./effects.ts"; +import { runFramePrelude } from "./frame-prelude.ts"; import { entries as pakEntries, get as pakGet, hasPack, loadPack } from "./pak.ts"; import { STYLE_IDS as DEFAULT_STYLE_IDS } from "./styles.generated.ts"; import { ENUMS, SCREEN_H, SCREEN_W } from "../../contracts/spec/spec.ts"; @@ -204,11 +204,7 @@ export function render(code: OctaneRenderRoot, opts: RenderOptions = {}): () => initDevtools(host.ops); // DevTools shim (docs/DEVTOOLS.md), same as the Solid path. installFrameHandler( wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[]) => { - __advanceClock(); - __setAnalog(analog); - __setTouches(touches); - runServicePumps(); - __drainEffects(); + runFramePrelude({ analog, touches }); // clock → input latches → pumps → effects (frame-prelude.ts) // Octane schedules re-renders on the microtask queue; the sync boundary // drains them before the sweep so a frame's commits land in that frame. flushUniversalSync(() => { diff --git a/framework/src/index-vue-vapor.ts b/framework/src/index-vue-vapor.ts index 7715591a..00f47a71 100644 --- a/framework/src/index-vue-vapor.ts +++ b/framework/src/index-vue-vapor.ts @@ -28,11 +28,11 @@ import { import { setOverlayRoot } from "./overlay.ts"; import { registerStyles, resolveStyle } from "./styles.ts"; import { handleFrame, setInputRoot } from "./input.ts"; -import { __setAnalog, resetFrameHooks, runFrameHooks } from "./frame-vue-vapor.ts"; -import { __resetTouches, __setTouches } from "./touch.ts"; -import { __advanceClock, resetClock } from "./clock.ts"; -import { __drainEffects, resetEffects } from "./effects.ts"; -import { runServicePumps } from "./services.ts"; +import { resetFrameHooks, runFrameHooks } from "./frame-vue-vapor.ts"; +import { __resetTouches } from "./touch.ts"; +import { resetClock } from "./clock.ts"; +import { resetEffects } from "./effects.ts"; +import { runFramePrelude } from "./frame-prelude.ts"; import { entries as pakEntries, get as pakGet, hasPack, loadPack } from "./pak.ts"; import { STYLE_IDS as DEFAULT_STYLE_IDS } from "./styles.generated.ts"; import { ENUMS, SCREEN_H, SCREEN_W } from "../../contracts/spec/spec.ts"; @@ -203,11 +203,7 @@ export function render(code: VaporRenderRoot, opts: RenderOptions = {}): () => v initDevtools(host.ops); // DevTools shim (docs/DEVTOOLS.md), same as the Solid path. installFrameHandler( wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[]) => { - __advanceClock(); - __setAnalog(analog); - __setTouches(touches); - runServicePumps(); - __drainEffects(); + runFramePrelude({ analog, touches }); // clock → input latches → pumps → effects (frame-prelude.ts) runFrameHooks(buttons); handleFrame(buttons); runSweep(); diff --git a/framework/src/index.ts b/framework/src/index.ts index 0d79aead..3775705c 100644 --- a/framework/src/index.ts +++ b/framework/src/index.ts @@ -43,11 +43,11 @@ import { registerStyles, resolveStyle } from "./styles.ts"; import { handleFrame, setHitRoot, setInputRoot } from "./input.ts"; import { __runGestures, resetGestures } from "./gesture.ts"; import { installTouchActivation } from "./touch-activation.ts"; -import { __setAnalog, resetFrameHooks, runFrameHooks } from "./frame.ts"; -import { __resetTouches, __setTouches } from "./touch.ts"; -import { __advanceClock, resetClock } from "./clock.ts"; -import { __drainEffects, resetEffects } from "./effects.ts"; -import { runServicePumps } from "./services.ts"; +import { resetFrameHooks, runFrameHooks } from "./frame.ts"; +import { __resetTouches } from "./touch.ts"; +import { resetClock } from "./clock.ts"; +import { resetEffects } from "./effects.ts"; +import { runFramePrelude } from "./frame-prelude.ts"; import { entries as pakEntries, get as pakGet, hasPack, loadPack } from "./pak.ts"; import { STYLE_IDS as DEFAULT_STYLE_IDS } from "./styles.generated.ts"; import { ENUMS, SCREEN_H, SCREEN_W } from "../../contracts/spec/spec.ts"; @@ -267,11 +267,7 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi // debug channel; one branch per frame when no transport is connected. installFrameHandler( wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[], hits?: readonly number[]) => { - __advanceClock(); // virtual frame++, fire due after() timers - __setAnalog(analog); // latch the nub before any app code reads it - __setTouches(touches, hits); // latch contacts + their host-resolved hit facts - runServicePumps(); // only modules with pending async work register here - __drainEffects(); // frame-boundary deliveries enter the world first + runFramePrelude({ analog, touches, hits }); // clock → input latches → pumps → effects (frame-prelude.ts) __runGestures(); // contact lifecycles resolve before app hooks read them runFrameHooks(buttons); // app lifecycle callbacks: onFrame/onButtonPress/etc. handleFrame(buttons); // edge-detect, focus nav, onPress (runs effects) diff --git a/framework/src/manifest/host-build-inputs.ts b/framework/src/manifest/host-build-inputs.ts index 844bd7d8..b77a4a14 100644 --- a/framework/src/manifest/host-build-inputs.ts +++ b/framework/src/manifest/host-build-inputs.ts @@ -1,3 +1,8 @@ +import { + canonicalNetworkPolicyJson, + parseNetworkPolicyJson, + type ResolvedNetworkPolicy, +} from "../../../contracts/spec/network-policy.ts"; import { PRESENTATION_MODES, type PresentationMode, @@ -10,12 +15,24 @@ export interface HostBuildInputs { readonly appOutput: string; readonly target: string; readonly hostAbi: number; + /** The plan checksum the host records next to the artifacts it embeds. */ + readonly planHash: string; readonly viewport: { readonly logical: Viewport; readonly physical: Viewport; readonly presentation: PresentationMode; readonly rasterDensity: number; }; + /** Resolved feature availability (required ids true, enhancements as the + * target provides them): the host mounts exactly the network roles the + * plan turned on. */ + readonly features: Readonly>; + /** The network policy the host hands to its core, verbatim: the resolved + * policy object and its canonical JSON (byte-identical across hosts). */ + readonly network: { + readonly policy: ResolvedNetworkPolicy; + readonly policyJson: string; + }; } export interface ExtractHostBuildInputsOptions { @@ -55,6 +72,7 @@ function hasHostInputShape(input: unknown): input is ResolvedBuildPlan { (input.viewport.rasterDensity as number) > 255 ) return false; if (typeof input.planHash !== "string" || !/^sha256:[0-9a-f]{64}$/.test(input.planHash)) return false; + if (!isRecord(input.network)) return false; return Object.values(input.features).every((available) => typeof available === "boolean"); } @@ -89,16 +107,26 @@ export function extractHostBuildInputs( `PocketJS host build: expected target ${options.expectedTarget}, got ${plan.target.id}`, ); } + // Round-trip the plan's policy through the contract parser: the host + // receives a policy the reference normalizer accepts, never a hand-edited + // object that happened to keep the checksum. + const policyJson = canonicalNetworkPolicyJson(parseNetworkPolicyJson(JSON.stringify(plan.network))); return { appOutput: plan.app.output, target: plan.target.id, hostAbi: plan.target.hostAbi, + planHash: plan.planHash, viewport: { logical: plan.viewport.logical, physical: plan.viewport.physical, presentation: plan.viewport.presentation, rasterDensity: plan.viewport.rasterDensity, }, + features: Object.freeze({ ...plan.features }), + network: Object.freeze({ + policy: parseNetworkPolicyJson(policyJson), + policyJson, + }), }; } @@ -119,5 +147,7 @@ export function hostBuildEnvironment( POCKETJS_PHYSICAL_HEIGHT: String(inputs.viewport.physical[1]), POCKETJS_PRESENTATION: inputs.viewport.presentation, POCKETJS_RASTER_DENSITY: String(inputs.viewport.rasterDensity), + POCKETJS_PLAN_HASH: inputs.planHash, + POCKETJS_NETWORK_POLICY: inputs.network.policyJson, }; } diff --git a/framework/src/manifest/plan.ts b/framework/src/manifest/plan.ts index fa821c19..1af87913 100644 --- a/framework/src/manifest/plan.ts +++ b/framework/src/manifest/plan.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import type { ResolvedNetworkPolicy } from "../../../contracts/spec/network-policy.ts"; import type { PocketManifestV2 } from "../../../contracts/spec/pocket-manifest.ts"; import type { PresentationMode, Viewport } from "../../../contracts/spec/platforms.ts"; @@ -27,6 +28,11 @@ export interface ResolvedBuildPlanContent { * svcOpen strings the app's adapters speak. Hosts build their svc * allowlist from this list (issue #295). */ readonly companions: readonly string[]; + /** The network endpoint policy resolved from `permissions.network` + * (format 3), or the deny-all policy. Hosts hand its canonical JSON to + * their network core at runtime creation and never author one + * themselves (contracts/spec/network-policy.ts). */ + readonly network: ResolvedNetworkPolicy; } export interface ResolvedBuildPlan extends ResolvedBuildPlanContent { diff --git a/framework/src/manifest/resolve.ts b/framework/src/manifest/resolve.ts index dba647da..10a6cbd2 100644 --- a/framework/src/manifest/resolve.ts +++ b/framework/src/manifest/resolve.ts @@ -1,5 +1,6 @@ import { DYNAMIC_FORMS, TARGET_FORMS } from "../../../contracts/spec/platforms.ts"; -import type { PocketManifestV2 } from "../../../contracts/spec/pocket-manifest.ts"; +import { resolveNetworkPolicy } from "../../../contracts/spec/network-policy.ts"; +import type { PocketManifest, PocketManifestV2 } from "../../../contracts/spec/pocket-manifest.ts"; import { POCKET_PLATFORM_CONTRACTS, type PlatformContractRegistry, @@ -12,10 +13,13 @@ import { type ResolvedBuildPlan, type ResolvedBuildPlanContent, } from "./plan.ts"; -import { validatePocketManifest, type ContractDiagnostic } from "./validate.ts"; +import { manifestPermissions, validatePocketManifest, type ContractDiagnostic } from "./validate.ts"; export interface ResolveBuildRequest { readonly target: string; + /** A development build: admits `permissions.network.allowInvalidTlsForDevelopment`. + * Production admission (the default) refuses it. */ + readonly development?: boolean; } export type ResolutionResult = @@ -53,7 +57,7 @@ const within = (v: Viewport, min: Viewport, max: Viewport): boolean => * pushing diagnostics. */ function resolveViewport( - manifest: PocketManifestV2, + manifest: PocketManifest, profile: TargetProfile, diagnostics: ContractDiagnostic[], ): { @@ -260,7 +264,7 @@ export function validatePlatformContractRegistry( } export function resolveBuildPlan( - manifest: PocketManifestV2, + manifest: PocketManifest, request: ResolveBuildRequest, registry: PlatformContractRegistry = POCKET_PLATFORM_CONTRACTS, ): ResolutionResult { @@ -348,7 +352,15 @@ export function resolveBuildPlan( }); } - if (diagnostics.length > 0 || !resolvedViewport) return { ok: false, diagnostics }; + // The network policy is plan truth: normalized here, covered by planHash, + // handed to the host's core verbatim. A format-2 manifest (no + // `permissions`) resolves to the deny-all policy. + const network = resolveNetworkPolicy(manifestPermissions(manifest)?.network, { + development: request.development === true, + }); + if (!network.ok) diagnostics.push(...network.diagnostics); + + if (diagnostics.length > 0 || !resolvedViewport || !network.ok) return { ok: false, diagnostics }; const logical: Viewport = [resolvedViewport.logical[0], resolvedViewport.logical[1]]; const physical: Viewport = [resolvedViewport.physical[0], resolvedViewport.physical[1]]; @@ -379,6 +391,7 @@ export function resolveBuildPlan( }, features, companions: manifest.app.companions ?? [], + network: network.policy, }; return { ok: true, plan: finalizeBuildPlan(content) }; } diff --git a/framework/src/manifest/validate.ts b/framework/src/manifest/validate.ts index 33efaaf4..91cee28f 100644 --- a/framework/src/manifest/validate.ts +++ b/framework/src/manifest/validate.ts @@ -1,8 +1,14 @@ import { + POCKET_MANIFEST_V3_VERSION, + POCKET_MANIFEST_VERSION, + POCKET_MANIFEST_VERSIONS, pocketManifestV2Schema, + pocketManifestV3Schema, type JsonSchema, type JsonSchemaObject, + type PocketManifest, type PocketManifestV2, + type PocketManifestV3, } from "../../../contracts/spec/pocket-manifest.ts"; export interface ContractDiagnostic { @@ -174,9 +180,38 @@ function validateSchema( } } -export function validatePocketManifest(input: unknown): ValidationResult { +/** + * Validate a manifest of either accepted format. The `pocket` field selects + * the schema: 2 (capabilities + viewport) or 3 (format 2 plus the top-level + * `permissions` block). A manifest with any other format value is reported + * at `/pocket` instead of failing every format-2 constant check. + */ +export function validatePocketManifest(input: unknown): ValidationResult { const diagnostics: ContractDiagnostic[] = []; + const format = input !== null && typeof input === "object" && !Array.isArray(input) + ? (input as { pocket?: unknown }).pocket + : undefined; + if (format === POCKET_MANIFEST_V3_VERSION) { + validateSchema(input, pocketManifestV3Schema, "", diagnostics); + if (diagnostics.length > 0) return { ok: false, diagnostics }; + return { ok: true, value: input as PocketManifestV3 }; + } + if (format !== undefined && format !== POCKET_MANIFEST_VERSION) { + return { + ok: false, + diagnostics: [{ + code: "schema.enum", + path: "/pocket", + message: `expected one of ${POCKET_MANIFEST_VERSIONS.join(", ")}`, + }], + }; + } validateSchema(input, pocketManifestV2Schema, "", diagnostics); if (diagnostics.length > 0) return { ok: false, diagnostics }; return { ok: true, value: input as PocketManifestV2 }; } + +/** The format-3 `permissions` block, or undefined for format 2. */ +export function manifestPermissions(manifest: PocketManifest): PocketManifestV3["permissions"] { + return manifest.pocket === POCKET_MANIFEST_V3_VERSION ? manifest.permissions : undefined; +} diff --git a/framework/src/net-api.ts b/framework/src/net-api.ts deleted file mode 100644 index 5d034327..00000000 --- a/framework/src/net-api.ts +++ /dev/null @@ -1,332 +0,0 @@ -// PocketJS net SDK — a deliberately small, bounded fetch over globalThis.net. -// The native contract lives in contracts/spec/net.ts. This file is framework -// neutral and serves ./net, ./vue-vapor/net and ./octane/net. - -import { - NET_DEFAULT_RESPONSE_BYTES, - NET_DEFAULT_TIMEOUT_MS, - NET_ERROR, - NET_MAX_HEADER_BYTES, - NET_MAX_HEADERS, - NET_MAX_REQUEST_BYTES, - NET_MAX_RESPONSE_BYTES, - NET_MAX_TIMEOUT_MS, - NET_METHODS, - type NetErrorCode, - type NetMethod, -} from "../../contracts/spec/net.ts"; -import { stringToUtf8, utf8ToString } from "./bytes.ts"; -import { registerServicePump } from "./services.ts"; - -export { - NET_DEFAULT_RESPONSE_BYTES, - NET_DEFAULT_TIMEOUT_MS, - NET_MAX_REQUEST_BYTES, - NET_MAX_RESPONSE_BYTES, - NET_MAX_TIMEOUT_MS, - NET_METHODS, -}; -export type { NetErrorCode, NetMethod }; - -export interface NetOps { - /** Request body is borrowed for this synchronous call. */ - start(metaJson: string, body: ArrayBuffer): number; - /** Copy a completed body into an exactly-sized buffer, exactly once. */ - take(handle: number, into: ArrayBuffer): number; - cancel(handle: number): void; - /** One JSON array containing the entire event batch visible this tick. */ - poll(): string | undefined; - lastError(): string; -} - -export interface FetchOptions { - method?: NetMethod; - headers?: Readonly>; - body?: string | Uint8Array | ArrayBuffer; - /** 1..120000; defaults to 30000. Enforced by the native transport. */ - timeoutMs?: number; - /** Whole response-body cap; defaults to 128 KiB, absolute max 256 KiB. */ - maxBytes?: number; -} - -export class NetError extends Error { - readonly code: NetErrorCode; - - constructor(code: NetErrorCode, message: string) { - super(message); - this.name = "NetError"; - this.code = code; - } -} - -export class PocketResponse { - readonly status: number; - readonly url: string; - readonly headers: Readonly>; - readonly ok: boolean; - private readonly data: Uint8Array; - - constructor( - status: number, - url: string, - headers: Readonly>, - body: ArrayBuffer, - ) { - this.status = status; - this.url = url; - this.headers = Object.freeze({ ...headers }); - this.ok = status >= 200 && status < 300; - this.data = new Uint8Array(body); - } - - get byteLength(): number { - return this.data.byteLength; - } - - /** A copy, so response reads cannot mutate the body retained by this value. */ - async bytes(): Promise { - return this.data.slice(); - } - - async arrayBuffer(): Promise { - return this.data.slice().buffer as ArrayBuffer; - } - - async text(): Promise { - try { - return utf8ToString(this.data); - } catch { - throw new Error("net: response is not valid UTF-8"); - } - } - - async json(): Promise { - return JSON.parse(await this.text()) as T; - } -} - -interface DoneEvent { - t: "done"; - h: number; - status: number; - url: string; - headers: Record; - bytes: number; -} - -interface ErrorEvent { - t: "error"; - h: number; - code: NetErrorCode; - message: string; -} - -type NetEvent = DoneEvent | ErrorEvent; - -interface Pending { - readonly ops: NetOps; - readonly resolve: (response: PocketResponse) => void; - readonly reject: (error: NetError) => void; -} - -const pending = new Map(); -let stopPump: (() => void) | null = null; -let activeOps: NetOps | null = null; - -export function netHost(): NetOps | null { - const ns = (globalThis as { net?: unknown }).net; - if (!ns || typeof ns !== "object") return null; - const ops = ns as Partial; - return typeof ops.start === "function" && - typeof ops.take === "function" && - typeof ops.cancel === "function" && - typeof ops.poll === "function" && - typeof ops.lastError === "function" - ? (ops as NetOps) - : null; -} - -function errorCode(value: unknown): NetErrorCode { - const code = String(value); - for (const known of Object.values(NET_ERROR)) { - if (known === code) return known; - } - return NET_ERROR.other; -} - -function settle(ev: NetEvent): void { - const p = pending.get(ev.h); - if (!p) return; - pending.delete(ev.h); - if (ev.t === "error") { - p.reject(new NetError(errorCode(ev.code), String(ev.message || ev.code))); - } else { - if ( - !Number.isInteger(ev.status) || - ev.status < 100 || - ev.status > 599 || - typeof ev.url !== "string" || - typeof ev.headers !== "object" || - ev.headers === null || - !Number.isInteger(ev.bytes) || - ev.bytes < 0 || - ev.bytes > NET_MAX_RESPONSE_BYTES - ) { - p.ops.cancel(ev.h); - p.reject(new NetError(NET_ERROR.protocol, "net: malformed done event")); - } else { - const body = new ArrayBuffer(ev.bytes); - const copied = p.ops.take(ev.h, body); - if (copied !== ev.bytes) { - p.ops.cancel(ev.h); - p.reject(new NetError(NET_ERROR.protocol, "net: response body transfer failed")); - } else { - p.resolve(new PocketResponse(ev.status, ev.url, ev.headers, body)); - } - } - } - if (pending.size === 0 && stopPump) { - stopPump(); - stopPump = null; - activeOps = null; - } -} - -/** Internal module service hook. It performs exactly one native poll call and - * only exists in the frame pump while at least one fetch is pending. */ -export function __pumpNet(): void { - if (pending.size === 0) return; - const ops = activeOps; - if (!ops) return; - const batch = ops.poll(); - if (batch !== undefined) { - let events: unknown = null; - try { - events = JSON.parse(batch); - } catch { - // handled as a protocol failure below - } - if (!Array.isArray(events)) { - for (const [handle, p] of pending) { - ops.cancel(handle); - pending.delete(handle); - p.reject(new NetError(NET_ERROR.protocol, "net: malformed event batch")); - } - } else { - for (const event of events) { - if (!event || typeof event !== "object") continue; - const ev = event as Partial; - if (!Number.isInteger(ev.h) || (ev.t !== "done" && ev.t !== "error")) continue; - settle(ev as NetEvent); - } - } - } - if (pending.size === 0 && stopPump) { - stopPump(); - stopPump = null; - activeOps = null; - } -} - -function reject(code: NetErrorCode, message: string): Promise { - return Promise.reject(new NetError(code, message)); -} - -function integerInRange(value: number, min: number, max: number, label: string): number { - if (!Number.isInteger(value) || value < min || value > max) { - throw new NetError(NET_ERROR.invalidRequest, `net: ${label} must be ${min}..${max}`); - } - return value; -} - -function normalizeHeaders(input: Readonly> | undefined): Record { - const out = Object.create(null) as Record; - let count = 0; - let bytes = 0; - for (const rawName of Object.keys(input ?? {})) { - const name = rawName.toLowerCase(); - const value = String(input![rawName]); - if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(name) || /[\r\n]/.test(value)) { - throw new NetError(NET_ERROR.invalidRequest, `net: invalid header ${rawName}`); - } - count++; - bytes += stringToUtf8(name).byteLength + stringToUtf8(value).byteLength + 4; - if (count > NET_MAX_HEADERS || bytes > NET_MAX_HEADER_BYTES) { - throw new NetError(NET_ERROR.invalidRequest, "net: request headers exceed limits"); - } - out[name] = value; - } - return out; -} - -function requestBody(body: FetchOptions["body"]): Uint8Array { - if (body === undefined) return new Uint8Array(0); - if (typeof body === "string") return stringToUtf8(body); - if (body instanceof Uint8Array) return body.slice(); - if (body instanceof ArrayBuffer) return new Uint8Array(body.slice(0)); - throw new NetError(NET_ERROR.invalidRequest, "net: body must be string or bytes"); -} - -/** The PocketJS HTTP client. It is fetch-shaped but intentionally not the - * complete browser Fetch API: no streams, cookies, cache, Request, Signal or - * implicit ambient authority. */ -export function fetch(url: string, options: FetchOptions = {}): Promise { - const ops = netHost(); - if (!ops) return reject(NET_ERROR.unavailable, "net: host did not mount the net module"); - - try { - if (typeof url !== "string" || !/^https?:\/\/[^\s/]+(?:\/|$)/.test(url)) { - throw new NetError(NET_ERROR.invalidRequest, "net: url must be absolute http:// or https://"); - } - const method = options.method ?? "GET"; - if (!(NET_METHODS as readonly string[]).includes(method)) { - throw new NetError(NET_ERROR.invalidRequest, `net: unsupported method ${String(method)}`); - } - const body = requestBody(options.body); - if ((method === "GET" || method === "HEAD") && body.byteLength > 0) { - throw new NetError(NET_ERROR.invalidRequest, `net: ${method} cannot have a body`); - } - if (body.byteLength > NET_MAX_REQUEST_BYTES) { - throw new NetError(NET_ERROR.invalidRequest, "net: request body exceeds 64 KiB"); - } - const timeoutMs = integerInRange( - options.timeoutMs ?? NET_DEFAULT_TIMEOUT_MS, - 1, - NET_MAX_TIMEOUT_MS, - "timeoutMs", - ); - const maxBytes = integerInRange( - options.maxBytes ?? NET_DEFAULT_RESPONSE_BYTES, - 1, - NET_MAX_RESPONSE_BYTES, - "maxBytes", - ); - const meta = JSON.stringify({ - url, - method, - headers: normalizeHeaders(options.headers), - timeoutMs, - maxBytes, - }); - if (activeOps && activeOps !== ops) { - throw new NetError(NET_ERROR.unavailable, "net: mounted host changed while requests are pending"); - } - const handle = ops.start(meta, body.buffer as ArrayBuffer); - if (!Number.isInteger(handle) || handle < 0) { - const detail = ops.lastError() || "unavailable: request refused"; - const split = detail.indexOf(":"); - const code = errorCode(split < 0 ? NET_ERROR.other : detail.slice(0, split)); - const message = split < 0 ? detail : detail.slice(split + 1).trim(); - return reject(code, message); - } - return new Promise((resolve, rejectPending) => { - pending.set(handle, { ops, resolve, reject: rejectPending }); - activeOps = ops; - if (!stopPump) stopPump = registerServicePump(__pumpNet); - }); - } catch (error) { - return error instanceof NetError - ? Promise.reject(error) - : reject(NET_ERROR.invalidRequest, String(error)); - } -} diff --git a/framework/src/net/abort.ts b/framework/src/net/abort.ts new file mode 100644 index 00000000..7828bf66 --- /dev/null +++ b/framework/src/net/abort.ts @@ -0,0 +1,87 @@ +// AbortController / AbortSignal for the network modules. QuickJS ships no +// DOM; the module provides its own pair with the DOM shape apps expect +// (`aborted`, `reason`, `throwIfAborted()`, `addEventListener("abort")`, +// `onabort`) so `fetch({ signal })` and `connect(...)` work on every host. +// The listeners run synchronously inside `abort()`, in registration order. + +type AbortListener = (event: { type: "abort"; target: AbortSignal }) => void; + +export class AbortSignal { + private _aborted = false; + private _reason: unknown = undefined; + private readonly listeners = new Set(); + onabort: AbortListener | null = null; + + get aborted(): boolean { + return this._aborted; + } + + get reason(): unknown { + return this._reason; + } + + throwIfAborted(): void { + if (this._aborted) throw this._reason; + } + + addEventListener(type: "abort", listener: AbortListener): void { + if (type !== "abort") return; + this.listeners.add(listener); + } + + removeEventListener(type: "abort", listener: AbortListener): void { + if (type !== "abort") return; + this.listeners.delete(listener); + } + + /** @internal */ + __abort(reason: unknown): void { + if (this._aborted) return; + this._aborted = true; + this._reason = reason === undefined ? new AbortError() : reason; + const event = { type: "abort" as const, target: this }; + if (this.onabort) this.onabort(event); + for (const listener of [...this.listeners]) listener(event); + this.listeners.clear(); + } + + static abort(reason?: unknown): AbortSignal { + const signal = new AbortSignal(); + signal.__abort(reason); + return signal; + } +} + +/** The default abort reason, DOMException-shaped. */ +export class AbortError extends Error { + readonly code = 20; + constructor(message = "The operation was aborted") { + super(message); + this.name = "AbortError"; + } +} + +export class AbortController { + readonly signal = new AbortSignal(); + + abort(reason?: unknown): void { + this.signal.__abort(reason); + } +} + +/** Accept a module signal or a host-native one (browser adapters) by shape. */ +export interface AbortSignalLike { + readonly aborted: boolean; + readonly reason?: unknown; + addEventListener(type: "abort", listener: (event?: unknown) => void): void; + removeEventListener?(type: "abort", listener: (event?: unknown) => void): void; +} + +export function isAbortSignalLike(value: unknown): value is AbortSignalLike { + return ( + !!value && + typeof value === "object" && + typeof (value as AbortSignalLike).aborted === "boolean" && + typeof (value as AbortSignalLike).addEventListener === "function" + ); +} diff --git a/framework/src/net/binding.ts b/framework/src/net/binding.ts new file mode 100644 index 00000000..44760da8 --- /dev/null +++ b/framework/src/net/binding.ts @@ -0,0 +1,204 @@ +// Network Guest Binding — the SDK-internal layer between the public modules +// and the spec-pinned namespaces (`globalThis.net` / `ws` / `httpd`). It +// finds a namespace, checks the spec major version once, drains one `poll` +// batch per tick from the framework service pump while a module has live +// handles, and hands each event to the module. Nothing here is public API. +// +// Delivery order: the host runs +// `begin_tick` before `frame()`; inside `frame()` the service pump calls a +// module's `poll` exactly once; the module updates JS state, calls handlers +// and settles Promises synchronously; Promise reactions run in the same +// tick's job drain. + +import { NET_ERROR } from "../../../contracts/spec/net.ts"; +import { registerServicePump } from "../services.ts"; +import { NetworkError, type NetworkProtocol } from "./errors.ts"; + +export interface NamespaceOps { + poll(): string | undefined; + lastError(): string; + limits(): string; +} + +export type EventRecord = Record & { t: string }; + +export interface ModuleBinding { + readonly name: string; + readonly protocol: NetworkProtocol; + /** The mounted ops, or null when the host did not mount the namespace. */ + ops(): Ops | null; + /** The mounted ops or a rejected-promise-style NetworkError. */ + require(operation: string): Ops; + /** Parsed `limits()` snapshot (cached after the first read). */ + limits(): Record; + /** Register/unregister interest in per-tick delivery. */ + retain(): void; + release(): void; + /** Number of live handles (for tests and diagnostics). */ + live(): number; + /** Runs one poll and dispatches (exposed for deterministic tests). */ + pump(): void; +} + +export interface BindingSpec { + name: string; + protocol: NetworkProtocol; + specMajor: number; + requiredOps: readonly (keyof Ops & string)[]; + dispatch(event: EventRecord, ops: Ops): void; + /** Called when a poll batch is malformed; the module must fail its handles. */ + onProtocolFailure(ops: Ops, error: NetworkError): void; +} + +export function createBinding(spec: BindingSpec): ModuleBinding { + let cachedOps: Ops | null = null; + let cachedLimits: Record | null = null; + let liveCount = 0; + let stopPump: (() => void) | null = null; + + function lookup(): Ops | null { + const ns = (globalThis as Record)[spec.name]; + if (!ns || typeof ns !== "object") return null; + for (const op of spec.requiredOps) { + if (typeof (ns as Record)[op] !== "function") return null; + } + return ns as Ops; + } + + function ops(): Ops | null { + const found = lookup(); + if (found && found !== cachedOps) { + // A different namespace object (host remounted): forget the snapshot. + cachedOps = found; + cachedLimits = null; + } else if (!found) { + cachedOps = null; + cachedLimits = null; + } + return found; + } + + function limits(): Record { + if (cachedLimits) return cachedLimits; + const found = ops(); + if (!found) { + throw new NetworkError(NET_ERROR.unavailable, `${spec.name}: host did not mount the module`, { + operation: "limits", + protocol: spec.protocol, + }); + } + let parsed: unknown = null; + try { + parsed = JSON.parse(found.limits()); + } catch { + parsed = null; + } + if (!parsed || typeof parsed !== "object") { + throw new NetworkError(NET_ERROR.protocol, `${spec.name}: malformed limits()`, { + operation: "limits", + protocol: spec.protocol, + }); + } + const record = parsed as Record; + if (record.specMajor !== spec.specMajor) { + throw new NetworkError( + NET_ERROR.unsupported, + `${spec.name}: host speaks spec ${String(record.specMajor)}, SDK requires ${spec.specMajor}`, + { operation: "limits", protocol: spec.protocol }, + ); + } + cachedLimits = Object.freeze(record); + return cachedLimits; + } + + function require(operation: string): Ops { + const found = ops(); + if (!found) { + throw new NetworkError(NET_ERROR.unavailable, `${spec.name}: host did not mount the module`, { + operation, + protocol: spec.protocol, + }); + } + limits(); // spec version check on first use + return found; + } + + function pump(): void { + if (liveCount === 0) return; + const found = cachedOps ?? ops(); + if (!found) return; + const batch = found.poll(); + if (batch === undefined) return; + let events: unknown = null; + try { + events = JSON.parse(batch); + } catch { + events = null; + } + if (!Array.isArray(events)) { + spec.onProtocolFailure( + found, + new NetworkError(NET_ERROR.protocol, `${spec.name}: malformed event batch`, { + operation: "poll", + protocol: spec.protocol, + }), + ); + return; + } + for (const event of events) { + if (!event || typeof event !== "object") continue; + const record = event as EventRecord; + if (typeof record.t !== "string") continue; + spec.dispatch(record, found); + } + } + + function retain(): void { + liveCount++; + if (!stopPump) stopPump = registerServicePump(pump); + } + + function release(): void { + if (liveCount > 0) liveCount--; + if (liveCount === 0 && stopPump) { + stopPump(); + stopPump = null; + } + } + + return { + name: spec.name, + protocol: spec.protocol, + ops, + require, + limits, + retain, + release, + live: () => liveCount, + pump, + }; +} + +/** Integer option validation shared by the modules. */ +export function integerOption( + value: unknown, + label: string, + min: number, + max: number, + operation: string, + protocol: NetworkProtocol, +): number { + if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) { + throw new NetworkError(NET_ERROR.invalidRequest, `${label} must be an integer from ${min} through ${max}`, { + operation, + protocol, + }); + } + return value; +} + +/** Read `name` from a limits snapshot as a positive integer, else fallback. */ +export function limitNumber(limits: Record, name: string, fallback: number): number { + const v = limits[name]; + return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : fallback; +} diff --git a/framework/src/net/body.ts b/framework/src/net/body.ts new file mode 100644 index 00000000..61e872cc --- /dev/null +++ b/framework/src/net/body.ts @@ -0,0 +1,628 @@ +// BodyStream — the single-consumer byte stream every HTTP body uses. +// Three flavours share one +// public shape: bytes already in JS (request bodies built from NetworkData, +// Response bodies constructed by the app), bytes that live in a native queue +// and cross only through the module's `readInto` op (client responses, +// server requests), and the bounded tee behind `clone()`. +// +// Native-backed streams consume only bytes that became visible at the last +// tick boundary; a read that cannot be satisfied parks until the next +// `readable`/`end`/`error` event delivered by the service pump. At most one +// read is pending per stream; the aggregate helpers (`text()`, `json()`, +// `arrayBuffer()`) sit on top of the same path and cancel the handle with +// `response_too_large` past their limit. + +import { NET_ERROR } from "../../../contracts/spec/net.ts"; +import { stringToUtf8, utf8ToString } from "../bytes.ts"; +import { NetworkError, type NetworkProtocol } from "./errors.ts"; + +export interface BodyReadResult { + bytes: number; + done: boolean; +} + +export interface BodyStream extends AsyncIterable { + readInto(destination: Uint8Array): Promise; + cancel(reason?: unknown): Promise; +} + +export type NetworkData = string | ArrayBuffer | ArrayBufferView; + +/** Snapshot NetworkData into an owned Uint8Array (strings as UTF-8, views by + * their current window). Detached buffers fail with `invalid_state`. */ +export function snapshotData(data: NetworkData, operation: string, protocol: NetworkProtocol): Uint8Array { + if (typeof data === "string") return stringToUtf8(data); + if (data instanceof ArrayBuffer) { + if (data.byteLength === 0 && isDetached(data)) { + throw new NetworkError(NET_ERROR.invalidState, "buffer is detached", { operation, protocol }); + } + return new Uint8Array(data.slice(0)); + } + if (ArrayBuffer.isView(data)) { + const view = data as ArrayBufferView; + if (view.byteLength === 0 && isDetached(view.buffer as ArrayBuffer)) { + throw new NetworkError(NET_ERROR.invalidState, "buffer is detached", { operation, protocol }); + } + return new Uint8Array(view.buffer as ArrayBuffer, view.byteOffset, view.byteLength).slice(); + } + throw new NetworkError(NET_ERROR.invalidRequest, "body must be a string, ArrayBuffer or ArrayBufferView", { + operation, + protocol, + }); +} + +function isDetached(buffer: ArrayBuffer): boolean { + const b = buffer as ArrayBuffer & { detached?: boolean }; + if (typeof b.detached === "boolean") return b.detached; + try { + new Uint8Array(buffer); + return false; + } catch { + return true; + } +} + +/** Common lock/consumption bookkeeping. */ +abstract class BaseBody implements BodyStream { + protected locked = false; + protected consumed = false; + protected readonly protocol: NetworkProtocol; + + constructor(protocol: NetworkProtocol) { + this.protocol = protocol; + } + + /** True once any reader, iterator or helper took the stream. */ + get bodyUsed(): boolean { + return this.locked; + } + + protected lock(operation: string): void { + if (this.locked) { + throw new NetworkError(NET_ERROR.invalidState, "body is already in use", { + operation, + protocol: this.protocol, + }); + } + this.locked = true; + } + + abstract readInto(destination: Uint8Array): Promise; + abstract cancel(reason?: unknown): Promise; + /** Bytes known to arrive in total, or -1 when unknown. */ + abstract knownLength(): number; + /** Bytes readable right now without waiting. */ + abstract available(): number; + + [Symbol.asyncIterator](): AsyncIterator { + // Async iteration takes the lock lazily on the first next() so that + // `for await` over an already-locked body rejects rather than throws. + const chunkBytes = 16 * 1024; + let started = false; + let finished = false; + return { + next: async (): Promise> => { + if (finished) return { value: undefined, done: true }; + if (!started) { + started = true; + this.lock("iterate"); + } + for (;;) { + const size = Math.max(1, Math.min(chunkBytes, this.available() || chunkBytes)); + const chunk = new Uint8Array(size); + const { bytes, done } = await this.readIntoLocked(chunk); + if (bytes > 0) return { value: chunk.subarray(0, bytes), done: false }; + if (done) { + finished = true; + return { value: undefined, done: true }; + } + } + }, + return: async (): Promise> => { + finished = true; + await this.cancel(); + return { value: undefined, done: true }; + }, + }; + } + + /** readInto for a caller that already holds the lock. */ + protected abstract readIntoLocked(destination: Uint8Array): Promise; + + /** Aggregate helper: whole body as bytes, bounded by `limitBytes`. */ + async collect(limitBytes: number, operation: string): Promise { + this.lock(operation); + const tooLarge = async (): Promise => { + await this.cancel(); + throw new NetworkError(NET_ERROR.responseTooLarge, `body exceeds ${limitBytes} bytes`, { + operation, + protocol: this.protocol, + }); + }; + const known = this.knownLength(); + if (known > limitBytes) return tooLarge(); + if (known >= 0) { + // Content-Length known: one exact allocation, filled as bytes arrive. + const buffer = new Uint8Array(known); + let filled = 0; + let done = false; + while (filled < known && !done) { + const r = await this.readIntoLocked(buffer.subarray(filled)); + filled += r.bytes; + done = r.done; + } + if (!done) { + // The last bytes and the terminal event may land in different + // ticks; observe EOF so the handle retires before we return. + const probe = new Uint8Array(1); + const r = await this.readIntoLocked(probe); + if (r.bytes > 0) { + await this.cancel(); + throw new NetworkError(NET_ERROR.protocol, "body exceeds its declared length", { + operation, + protocol: this.protocol, + }); + } + } + return filled === known ? buffer : buffer.subarray(0, filled); + } + // Unknown length (chunked / close-delimited): grow geometrically. + let buffer = new Uint8Array(Math.min(limitBytes, Math.max(this.available(), 8 * 1024))); + let filled = 0; + for (;;) { + if (filled === buffer.length) { + if (buffer.length >= limitBytes) return tooLarge(); + const grown = new Uint8Array(Math.min(limitBytes, Math.max(buffer.length * 2, filled + this.available()))); + grown.set(buffer); + buffer = grown; + } + const r = await this.readIntoLocked(buffer.subarray(filled)); + filled += r.bytes; + if (r.done) break; + } + return filled === buffer.length ? buffer : buffer.slice(0, filled); + } + + async collectText(limitBytes: number, operation: string): Promise { + const bytes = await this.collect(limitBytes, operation); + try { + return utf8ToString(bytes); + } catch { + throw new NetworkError(NET_ERROR.protocol, "body is not valid UTF-8", { + operation, + protocol: this.protocol, + }); + } + } +} + +/** Bytes already held in JS. */ +export class MemoryBody extends BaseBody { + private readonly bytes: Uint8Array; + private offset = 0; + private cancelled = false; + + constructor(bytes: Uint8Array, protocol: NetworkProtocol) { + super(protocol); + this.bytes = bytes; + } + + /** The unread bytes; used by the modules to snapshot outbound bodies. */ + peek(): Uint8Array { + return this.bytes.subarray(this.offset); + } + + knownLength(): number { + return this.bytes.length; + } + + available(): number { + return this.bytes.length - this.offset; + } + + readInto(destination: Uint8Array): Promise { + try { + this.lockOnce("readInto"); + } catch (error) { + return Promise.reject(error); + } + return this.readIntoLocked(destination); + } + + private lockOnce(operation: string): void { + if (!this.consumed) { + this.lock(operation); + this.consumed = true; + } + } + + protected async readIntoLocked(destination: Uint8Array): Promise { + if (destination.length === 0) { + throw new NetworkError(NET_ERROR.invalidRequest, "destination is empty", { + operation: "readInto", + protocol: this.protocol, + }); + } + if (this.cancelled) return { bytes: 0, done: true }; + const n = Math.min(destination.length, this.bytes.length - this.offset); + destination.set(this.bytes.subarray(this.offset, this.offset + n)); + this.offset += n; + return { bytes: n, done: this.offset >= this.bytes.length }; + } + + async cancel(): Promise { + this.cancelled = true; + this.locked = true; + this.offset = this.bytes.length; + } + + /** A second view of the same bytes (both start unread). */ + fork(): MemoryBody { + return new MemoryBody(this.bytes, this.protocol); + } +} + +/** The native side of a queue-backed stream: one `readInto` op bound to a + * handle, plus a way to cancel the handle. */ +export interface NativeSource { + /** Copy up to dest.length visible bytes into dest; -1 = handle gone. */ + pull(destination: Uint8Array): number; + /** Ask the module to cancel the handle; the terminal event follows later. */ + cancel(reason: unknown): void; +} + +/** Bytes that live in a native queue and cross through `readInto`. */ +export class NativeBody extends BaseBody { + private readonly source: NativeSource; + private avail = 0; + private ended = false; + private failure: NetworkError | null = null; + private terminal = false; + private waiter: { resolve: (r: BodyReadResult) => void; reject: (e: unknown) => void; dest: Uint8Array } | null = null; + private cancelWaiters: (() => void)[] = []; + private cancelRequested = false; + private readonly length: number; + + constructor(source: NativeSource, protocol: NetworkProtocol, knownLength: number) { + super(protocol); + this.source = source; + this.length = knownLength; + } + + knownLength(): number { + return this.length; + } + + available(): number { + return this.avail; + } + + /** True once end/error/cancel settled the native handle. */ + get isTerminal(): boolean { + return this.terminal; + } + + readInto(destination: Uint8Array): Promise { + try { + if (!this.consumed) { + this.lock("readInto"); + this.consumed = true; + } + } catch (error) { + return Promise.reject(error); + } + return this.readIntoLocked(destination); + } + + protected readIntoLocked(destination: Uint8Array): Promise { + if (destination.length === 0) { + return Promise.reject( + new NetworkError(NET_ERROR.invalidRequest, "destination is empty", { + operation: "readInto", + protocol: this.protocol, + }), + ); + } + if (this.waiter) { + return Promise.reject( + new NetworkError(NET_ERROR.busy, "a read is already pending", { + operation: "readInto", + protocol: this.protocol, + }), + ); + } + const immediate = this.tryRead(destination); + if (immediate) return Promise.resolve(immediate); + if (this.failure) return Promise.reject(this.failure); + return new Promise((resolve, reject) => { + this.waiter = { resolve, reject, dest: destination }; + }); + } + + /** Satisfy a read from visible bytes; null when nothing is readable yet. */ + private tryRead(destination: Uint8Array): BodyReadResult | null { + if (this.avail > 0) { + const want = Math.min(destination.length, this.avail); + const got = this.source.pull(destination.subarray(0, want)); + if (got < 0) { + this.avail = 0; + if (!this.ended && !this.failure) { + this.failure = new NetworkError(NET_ERROR.closed, "body handle is gone", { + operation: "readInto", + protocol: this.protocol, + }); + } + if (this.failure) return null; + return { bytes: 0, done: true }; + } + this.avail -= got; + if (got > 0 || this.avail === 0) { + return { bytes: got, done: this.ended && this.avail === 0 }; + } + } + if (this.ended) return { bytes: 0, done: true }; + return null; + } + + private settleWaiter(): void { + const w = this.waiter; + if (!w) return; + const result = this.tryRead(w.dest); + if (result) { + this.waiter = null; + w.resolve(result); + return; + } + if (this.failure) { + this.waiter = null; + w.reject(this.failure); + } + } + + /** Module callbacks (service pump delivery). */ + onReadable(avail: number): void { + if (this.terminal) return; + this.avail = Math.max(0, avail | 0); + this.settleWaiter(); + } + + onEnd(): void { + if (this.terminal) return; + this.ended = true; + this.terminal = true; + this.settleWaiter(); + this.flushCancelWaiters(); + } + + onError(error: NetworkError): void { + if (this.terminal) return; + this.terminal = true; + if (this.cancelRequested && error.code === NET_ERROR.cancelled) { + // A cancel we asked for: readers observe EOF, not an error. + this.ended = true; + this.avail = 0; + } else { + this.failure = error; + this.avail = 0; + } + this.settleWaiter(); + this.flushCancelWaiters(); + } + + private flushCancelWaiters(): void { + const waiters = this.cancelWaiters; + this.cancelWaiters = []; + for (const w of waiters) w(); + } + + cancel(reason?: unknown): Promise { + this.locked = true; + if (!this.cancelRequested) { + this.cancelRequested = true; + // Even after `end`, tell the module so unread native bytes are freed; + // on a retired handle the op is a no-op by contract. + this.source.cancel(reason); + } + this.avail = 0; + if (this.terminal) return Promise.resolve(); + return new Promise((resolve) => { + this.cancelWaiters.push(resolve); + }); + } +} + +/** Bounded tee for `clone()`: two branches over one source, each branch + * buffering what the other consumed first, up to `limitBytes`. When a branch + * falls behind by more than the limit, the leading branch waits (backpressure + * on the source) until the lagging branch reads or cancels. */ +export function teeBody( + source: BaseBody, + protocol: NetworkProtocol, + limitBytes: number, +): [TeeBranch, TeeBranch] { + const shared = new TeeShared(source, protocol, limitBytes); + return [shared.branch(0), shared.branch(1)]; +} + +class TeeShared { + readonly buffers: [Uint8Array[], Uint8Array[]] = [[], []]; + readonly buffered: [number, number] = [0, 0]; + readonly cancelled: [boolean, boolean] = [false, false]; + readonly waiters: [(() => void) | null, (() => void) | null] = [null, null]; + ended = false; + failure: unknown = null; + pulling: Promise | null = null; + readonly source: BaseBody; + readonly protocol: NetworkProtocol; + readonly limit: number; + + constructor(source: BaseBody, protocol: NetworkProtocol, limit: number) { + this.source = source; + this.protocol = protocol; + this.limit = limit; + this.source["lock"]("clone"); + } + + branch(index: 0 | 1): TeeBranch { + return new TeeBranch(this, index); + } + + wake(index: 0 | 1): void { + const w = this.waiters[index]; + if (w) { + this.waiters[index] = null; + w(); + } + } + + /** Bytes one more pull may add without pushing a live branch past the + * limit: the branch that pulls has drained its own queue, so the bound is + * the other branch's backlog. */ + room(): number { + let backlog = 0; + for (const i of [0, 1] as const) { + if (!this.cancelled[i] && this.buffered[i] > backlog) backlog = this.buffered[i]; + } + return Math.min(16 * 1024, this.limit - backlog); + } + + /** Pull one chunk from the source into both branch buffers. The chunk is + * sized to the remaining room so a branch's backlog never exceeds + * `limit` (a hard bound, not "stop after crossing it"). */ + pull(): Promise { + if (this.pulling) return this.pulling; + const room = this.room(); + if (room <= 0) return Promise.resolve(); // the caller is blocked; it waits + this.pulling = (async () => { + const chunk = new Uint8Array(room); + try { + const { bytes, done } = await this.source["readIntoLocked"](chunk); + if (bytes > 0) { + const data = chunk.slice(0, bytes); + for (const i of [0, 1] as const) { + if (this.cancelled[i]) continue; + this.buffers[i].push(data); + this.buffered[i] += bytes; + } + } + if (done) this.ended = true; + } catch (error) { + this.failure = error; + } finally { + this.pulling = null; + this.wake(0); + this.wake(1); + } + })(); + return this.pulling; + } + + /** The other branch is too far behind to pull more. */ + blocked(index: 0 | 1): boolean { + const other = index === 0 ? 1 : 0; + return !this.cancelled[other] && this.buffered[other] >= this.limit; + } + + async cancelBranch(index: 0 | 1, reason: unknown): Promise { + this.cancelled[index] = true; + this.buffers[index] = []; + this.buffered[index] = 0; + const other = index === 0 ? 1 : 0; + this.wake(other); + if (this.cancelled[other]) await this.source.cancel(reason); + } +} + +export class TeeBranch extends BaseBody { + private readonly shared: TeeShared; + private readonly index: 0 | 1; + private consumedOnce = false; + + constructor(shared: TeeShared, index: 0 | 1) { + super(shared.protocol); + this.shared = shared; + this.index = index; + } + + knownLength(): number { + return this.shared.source.knownLength(); + } + + available(): number { + return this.shared.buffered[this.index]; + } + + readInto(destination: Uint8Array): Promise { + try { + if (!this.consumedOnce) { + this.lock("readInto"); + this.consumedOnce = true; + } + } catch (error) { + return Promise.reject(error); + } + return this.readIntoLocked(destination); + } + + protected async readIntoLocked(destination: Uint8Array): Promise { + if (destination.length === 0) { + throw new NetworkError(NET_ERROR.invalidRequest, "destination is empty", { + operation: "readInto", + protocol: this.protocol, + }); + } + const s = this.shared; + for (;;) { + if (s.cancelled[this.index]) return { bytes: 0, done: true }; + const queue = s.buffers[this.index]; + if (queue.length) { + let filled = 0; + while (queue.length && filled < destination.length) { + const head = queue[0]; + const n = Math.min(head.length, destination.length - filled); + destination.set(head.subarray(0, n), filled); + filled += n; + if (n === head.length) queue.shift(); + else queue[0] = head.subarray(n); + } + s.buffered[this.index] -= filled; + s.wake(this.index === 0 ? 1 : 0); + return { bytes: filled, done: s.ended && queue.length === 0 }; + } + if (s.failure) throw s.failure; + if (s.ended) return { bytes: 0, done: true }; + if (s.blocked(this.index)) { + await new Promise((resolve) => { + s.waiters[this.index] = resolve; + }); + continue; + } + await s.pull(); + } + } + + cancel(reason?: unknown): Promise { + this.locked = true; + return this.shared.cancelBranch(this.index, reason); + } +} + +/** Convert an app-supplied body input into a stream the module can use, or + * null when there is no body. */ +export function bodyFromInput( + input: NetworkData | BodyStream | AsyncIterable | null | undefined, + operation: string, + protocol: NetworkProtocol, +): BaseBody | AsyncIterable | null { + if (input === null || input === undefined) return null; + if (input instanceof BaseBody) return input; + if (typeof input === "string" || input instanceof ArrayBuffer || ArrayBuffer.isView(input)) { + return new MemoryBody(snapshotData(input as NetworkData, operation, protocol), protocol); + } + if (typeof (input as AsyncIterable)[Symbol.asyncIterator] === "function") { + return input as AsyncIterable; + } + throw new NetworkError(NET_ERROR.invalidRequest, "unsupported body type", { operation, protocol }); +} + +export { BaseBody }; diff --git a/framework/src/net/errors.ts b/framework/src/net/errors.ts new file mode 100644 index 00000000..c264621f --- /dev/null +++ b/framework/src/net/errors.ts @@ -0,0 +1,74 @@ +// NetworkError — the one public error class of the network modules. +// Codes are the stable strings +// of contracts/spec/net.ts NET_ERROR, shared by net, ws and httpd; the +// category is derived from the code, never sent by a host. + +import { NET_ERROR, netErrorCategory, type NetErrorCode } from "../../../contracts/spec/net.ts"; + +export type NetworkErrorCategory = "runtime" | "resolver" | "transport" | "tls" | "protocol"; +export type NetworkProtocol = "http" | "websocket" | "mqtt" | "tcp" | "udp"; + +export interface NetworkErrorInit { + operation: string; + temporary?: boolean; + address?: string; + port?: number; + protocol?: NetworkProtocol; + causeCode?: string; + reasonCode?: number; +} + +/** Codes a host may report as temporary conditions. */ +const TEMPORARY = new Set([ + NET_ERROR.dns, + NET_ERROR.connect, + NET_ERROR.timeout, + NET_ERROR.busy, + NET_ERROR.resourceLimit, +]); + +export class NetworkError extends Error { + readonly category: NetworkErrorCategory; + readonly code: string; + readonly operation: string; + readonly temporary: boolean; + readonly address?: string; + readonly port?: number; + readonly protocol?: NetworkProtocol; + readonly causeCode?: string; + readonly reasonCode?: number; + + constructor(code: string, message: string, init: NetworkErrorInit) { + super(message); + this.name = "NetworkError"; + this.code = code; + this.category = netErrorCategory(code); + this.operation = init.operation; + this.temporary = init.temporary ?? TEMPORARY.has(code); + if (init.address !== undefined) this.address = init.address; + if (init.port !== undefined) this.port = init.port; + if (init.protocol !== undefined) this.protocol = init.protocol; + if (init.causeCode !== undefined) this.causeCode = init.causeCode; + if (init.reasonCode !== undefined) this.reasonCode = init.reasonCode; + } +} + +const KNOWN_CODES = new Set(Object.values(NET_ERROR)); + +/** Clamp a host-reported code onto the shared vocabulary. */ +export function normalizeErrorCode(value: unknown): NetErrorCode { + const code = String(value); + return KNOWN_CODES.has(code) ? (code as NetErrorCode) : NET_ERROR.other; +} + +/** Turn a namespace `lastError()` string (`code: message`) into an error. */ +export function errorFromLastError( + detail: string, + operation: string, + protocol: NetworkProtocol, +): NetworkError { + const split = detail.indexOf(":"); + const code = normalizeErrorCode(split < 0 ? NET_ERROR.other : detail.slice(0, split)); + const message = split < 0 ? detail || "request refused" : detail.slice(split + 1).trim(); + return new NetworkError(code, message, { operation, protocol }); +} diff --git a/framework/src/net/http.ts b/framework/src/net/http.ts new file mode 100644 index 00000000..e2d8498a --- /dev/null +++ b/framework/src/net/http.ts @@ -0,0 +1,1450 @@ +// @pocketjs/framework/net/http — HTTP Client (`fetch`) and HTTP Server +// (`serve`) over the `globalThis.net` / `globalThis.httpd` boundaries +// (contracts/spec/net.ts, contracts/spec/httpd.ts). Object shapes follow the +// WHATWG Fetch standard, with these PocketJS deviations: body locking, repeat +// consumption and detached input fail with a stable NetworkError; every +// network, permission, timeout and resource failure is a NetworkError too. +// +// Delivery: `fetch()` resolves when the response head is visible at a tick +// boundary; the body streams through `Response.body` (a BodyStream over the +// module's `readInto` op). `serve()` delivers each request from the same +// service pump and writes the handler's Response through `respond`/`write`. + +import { + HTTP_CORE_OWNED_REQUEST_HEADERS, + HTTP_NULL_BODY_STATUS, + HTTP_REDIRECT_STATUS, + NET_DEFAULT_AGGREGATE_BYTES, + NET_DEFAULT_QUEUE_BYTES, + NET_DEFAULT_TIMEOUT_MS, + NET_ERROR, + NET_MAX_AGGREGATE_BYTES, + NET_MAX_HEADER_BYTES, + NET_MAX_HEADERS, + NET_MAX_QUEUE_BYTES, + NET_MAX_REDIRECTS, + NET_MAX_REQUEST_BYTES, + NET_MAX_TIMEOUT_MS, + NET_METHODS_FORBIDDEN, + NET_SPEC_MAJOR, + type NetStartMeta, +} from "../../../contracts/spec/net.ts"; +import { + HTTPD_MAX_BACKLOG, + HTTPD_MAX_CONNECTIONS, + HTTPD_MAX_INFLIGHT, + HTTPD_MAX_REQUEST_QUEUE_BYTES, + HTTPD_MAX_SEND_QUEUE_BYTES, + HTTPD_MAX_TIMEOUT_MS, + HTTPD_SEND_ACCEPTED, + HTTPD_SEND_BACKPRESSURE, + HTTPD_SEND_INVALID, + HTTPD_SEND_INVALID_REQUEST, + HTTPD_SPEC_MAJOR, + type HttpdListenMeta, + type HttpdRespondMeta, +} from "../../../contracts/spec/httpd.ts"; +import { stringToUtf8 } from "../bytes.ts"; +import { AbortController, AbortSignal, isAbortSignalLike, type AbortSignalLike } from "./abort.ts"; +import { + BaseBody, + MemoryBody, + NativeBody, + bodyFromInput, + teeBody, + type BodyStream, + type NetworkData, +} from "./body.ts"; +import { createBinding, integerOption, limitNumber, type EventRecord } from "./binding.ts"; +import { NetworkError, errorFromLastError, normalizeErrorCode } from "./errors.ts"; +import { URL } from "./url.ts"; +import type { TlsOptions } from "./types.ts"; + +export type { BodyStream, BodyReadResult, NetworkData } from "./body.ts"; + +const PROTOCOL = "http" as const; + +// --------------------------------------------------------------------------- +// Headers +// --------------------------------------------------------------------------- + +export type HeadersInit = Headers | Record | Iterable; + +type HeadersGuard = "none" | "request" | "response" | "immutable"; + +const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +/** Request headers the core owns (framing, connection control, upgrade). An + * app cannot set them; the Fetch request guard is otherwise not applied so + * explicit `Cookie`, `Origin`, `User-Agent` etc. work on every host. */ +const CORE_OWNED_REQUEST_HEADERS = new Set(HTTP_CORE_OWNED_REQUEST_HEADERS); + +function normalizeHeaderValue(value: string): string { + // HTTP whitespace: tab, LF, CR, space. + return String(value).replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, ""); +} + +function invalidHeader(message: string): NetworkError { + return new NetworkError(NET_ERROR.invalidRequest, message, { operation: "headers", protocol: PROTOCOL }); +} + +export class Headers { + private readonly map = new Map(); + private guard: HeadersGuard = "none"; + + constructor(init?: HeadersInit) { + this.fill(init); + } + + /** @internal Append every pair of `init` under the current guard. */ + fill(init: HeadersInit | undefined | null): this { + if (init === undefined || init === null) return this; + if (init instanceof Headers) { + for (const [name, values] of init.map) for (const v of values) this.append(name, v); + return this; + } + if (typeof init === "object" && Symbol.iterator in init) { + for (const pair of init as Iterable) { + if (!pair || typeof pair !== "object" || (pair as readonly string[]).length !== 2) { + throw invalidHeader("header pairs must have exactly two items"); + } + this.append(pair[0], pair[1]); + } + return this; + } + if (typeof init === "object") { + for (const name of Object.keys(init as Record)) { + this.append(name, (init as Record)[name]); + } + return this; + } + throw invalidHeader("unsupported HeadersInit"); + } + + /** @internal */ + __setGuard(guard: HeadersGuard): this { + this.guard = guard; + return this; + } + + /** @internal */ + __guard(): HeadersGuard { + return this.guard; + } + + private checkMutable(): void { + if (this.guard === "immutable") throw new TypeError("Headers are immutable"); + } + + private accept(name: string): boolean { + return this.guard !== "request" || !CORE_OWNED_REQUEST_HEADERS.has(name); + } + + private static validate(rawName: string, rawValue: string): [string, string] { + const name = String(rawName).toLowerCase(); + if (!TOKEN.test(name)) throw invalidHeader(`invalid header name "${rawName}"`); + const value = normalizeHeaderValue(rawValue); + if (/[\0\r\n]/.test(value)) throw invalidHeader(`invalid header value for "${rawName}"`); + return [name, value]; + } + + append(rawName: string, rawValue: string): void { + this.checkMutable(); + const [name, value] = Headers.validate(rawName, rawValue); + if (!this.accept(name)) return; + const list = this.map.get(name); + if (list) list.push(value); + else this.map.set(name, [value]); + } + + set(rawName: string, rawValue: string): void { + this.checkMutable(); + const [name, value] = Headers.validate(rawName, rawValue); + if (!this.accept(name)) return; + this.map.set(name, [value]); + } + + delete(rawName: string): void { + this.checkMutable(); + const [name] = Headers.validate(rawName, ""); + if (!this.accept(name)) return; + this.map.delete(name); + } + + get(rawName: string): string | null { + const [name] = Headers.validate(rawName, ""); + const list = this.map.get(name); + if (!list) return null; + return list.join(", "); + } + + has(rawName: string): boolean { + const [name] = Headers.validate(rawName, ""); + return this.map.has(name); + } + + getSetCookie(): string[] { + return [...(this.map.get("set-cookie") ?? [])]; + } + + private sortedEntries(): [string, string][] { + const names = [...this.map.keys()].sort(); + const out: [string, string][] = []; + for (const name of names) { + const values = this.map.get(name)!; + if (name === "set-cookie") for (const v of values) out.push([name, v]); + else out.push([name, values.join(", ")]); + } + return out; + } + + *entries(): IterableIterator<[string, string]> { + yield* this.sortedEntries(); + } + *keys(): IterableIterator { + for (const [k] of this.sortedEntries()) yield k; + } + *values(): IterableIterator { + for (const [, v] of this.sortedEntries()) yield v; + } + [Symbol.iterator](): IterableIterator<[string, string]> { + return this.entries(); + } + forEach(callback: (value: string, name: string, headers: Headers) => void, thisArg?: unknown): void { + for (const [name, value] of this.sortedEntries()) callback.call(thisArg, value, name, this); + } + + /** @internal Wire form: one value per name (repeats combined), set-cookie + * combined with ", " as well because request meta is a flat object. */ + __toRecord(): Record { + const out: Record = {}; + for (const [name, values] of this.map) out[name] = values.join(", "); + return out; + } + + /** @internal Approximate encoded size for the limits check. */ + __byteSize(): { count: number; bytes: number } { + let count = 0; + let bytes = 0; + for (const [name, values] of this.map) { + count++; + bytes += stringToUtf8(name).length + stringToUtf8(values.join(", ")).length + 4; + } + return { count, bytes }; + } + + /** @internal */ + static __fromRecord(record: Record, guard: HeadersGuard): Headers { + const h = new Headers(); + for (const name of Object.keys(record)) { + const value = record[name]; + if (Array.isArray(value)) { + for (const v of value) h.appendUnchecked(name, String(v)); + } else { + h.appendUnchecked(name, String(value)); + } + } + return h.__setGuard(guard); + } + + private appendUnchecked(name: string, value: string): void { + const key = name.toLowerCase(); + if (!TOKEN.test(key)) return; + const list = this.map.get(key); + if (list) list.push(value); + else this.map.set(key, [value]); + } +} + +// --------------------------------------------------------------------------- +// Request +// --------------------------------------------------------------------------- + +export type RequestRedirect = "follow" | "manual" | "error"; +export type BodyInit = NetworkData | BodyStream | AsyncIterable | null; + +export interface RequestTimeouts { + connectMs?: number; + headersMs?: number; + idleMs?: number; + totalMs?: number; +} + +export interface RequestLimits { + /** Native receive queue (backpressure window) for the response body. */ + queueBytes?: number; + /** Total response body cap; exceeding it fails with response_too_large. */ + maxBodyBytes?: number; + /** Cap for text()/json()/arrayBuffer() on the response. */ + aggregateBytes?: number; +} + +export interface RequestInit { + method?: string; + headers?: HeadersInit; + body?: BodyInit; + signal?: AbortSignal | AbortSignalLike | null; + redirect?: RequestRedirect; + timeouts?: RequestTimeouts; + maxRedirects?: number; + tls?: TlsOptions; + limits?: RequestLimits; +} + +const STANDARD_METHODS = new Set(["DELETE", "GET", "HEAD", "OPTIONS", "POST", "PUT"]); + +function normalizeMethod(raw: unknown): string { + const method = String(raw ?? "GET"); + if (!TOKEN.test(method)) { + throw new NetworkError(NET_ERROR.invalidRequest, `invalid method "${method}"`, { + operation: "fetch", + protocol: PROTOCOL, + }); + } + const upper = method.toUpperCase(); + if ((NET_METHODS_FORBIDDEN as readonly string[]).includes(upper)) { + throw new NetworkError(NET_ERROR.invalidRequest, `method ${upper} is not allowed`, { + operation: "fetch", + protocol: PROTOCOL, + }); + } + return STANDARD_METHODS.has(upper) ? upper : method; +} + +function parseAbsoluteUrl(input: string | URL, operation: string): URL { + try { + const url = input instanceof URL ? new URL(input.href) : new URL(String(input)); + if (url.username || url.password) { + throw new NetworkError(NET_ERROR.invalidRequest, "URL must not carry credentials", { + operation, + protocol: PROTOCOL, + }); + } + return url; + } catch (error) { + if (error instanceof NetworkError) throw error; + throw new NetworkError(NET_ERROR.invalidRequest, `invalid URL: ${String(input)}`, { + operation, + protocol: PROTOCOL, + }); + } +} + +export class Request { + readonly method: string; + readonly url: string; + readonly headers: Headers; + readonly signal: AbortSignal | AbortSignalLike; + readonly redirect: RequestRedirect; + readonly timeouts: Readonly; + readonly maxRedirects: number; + readonly tls: Readonly | undefined; + readonly limits: Readonly; + private _body: BaseBody | AsyncIterable | null; + private streamUsed = false; + + constructor(input: string | URL | Request, init: RequestInit = {}) { + let url: URL; + let method = "GET"; + let headers: Headers | undefined; + let body: BaseBody | AsyncIterable | null = null; + let signal: AbortSignal | AbortSignalLike | undefined; + let redirect: RequestRedirect = "follow"; + let timeouts: RequestTimeouts = {}; + let maxRedirects = NET_MAX_REDIRECTS; + let tls: TlsOptions | undefined; + let limits: RequestLimits = {}; + + if (input instanceof Request) { + url = new URL(input.url); + method = input.method; + headers = new Headers().__setGuard("request").fill(input.headers); + signal = input.signal; + redirect = input.redirect; + timeouts = { ...input.timeouts }; + maxRedirects = input.maxRedirects; + tls = input.tls ? { ...input.tls } : undefined; + limits = { ...input.limits }; + if (init.body === undefined && input._body) { + if (input.bodyUsed) { + throw new NetworkError(NET_ERROR.invalidState, "input request body is already used", { + operation: "Request", + protocol: PROTOCOL, + }); + } + body = input._body; + input.streamUsed = true; + } + } else { + url = parseAbsoluteUrl(input, "Request"); + } + + if (init.method !== undefined) method = normalizeMethod(init.method); + if (init.headers !== undefined) { + // Wire headers delivered by the server core arrive immutable and are + // adopted as-is; anything app-supplied goes through the request guard. + headers = + init.headers instanceof Headers && init.headers.__guard() === "immutable" + ? init.headers + : new Headers().__setGuard("request").fill(init.headers); + } + if (init.signal !== undefined && init.signal !== null) { + if (!isAbortSignalLike(init.signal)) { + throw new NetworkError(NET_ERROR.invalidRequest, "signal must be an AbortSignal", { + operation: "Request", + protocol: PROTOCOL, + }); + } + signal = init.signal; + } + if (init.redirect !== undefined) { + if (init.redirect !== "follow" && init.redirect !== "manual" && init.redirect !== "error") { + throw new NetworkError(NET_ERROR.invalidRequest, "redirect must be follow, manual or error", { + operation: "Request", + protocol: PROTOCOL, + }); + } + redirect = init.redirect; + } + if (init.timeouts !== undefined) { + timeouts = {}; + for (const key of ["connectMs", "headersMs", "idleMs", "totalMs"] as const) { + const v = init.timeouts[key]; + if (v !== undefined) timeouts[key] = integerOption(v, `timeouts.${key}`, 1, NET_MAX_TIMEOUT_MS, "Request", PROTOCOL); + } + } + if (init.maxRedirects !== undefined) { + maxRedirects = integerOption(init.maxRedirects, "maxRedirects", 0, NET_MAX_REDIRECTS, "Request", PROTOCOL); + } + if (init.tls !== undefined) tls = { ...init.tls }; + if (init.limits !== undefined) { + limits = {}; + if (init.limits.queueBytes !== undefined) { + limits.queueBytes = integerOption(init.limits.queueBytes, "limits.queueBytes", 1, NET_MAX_QUEUE_BYTES, "Request", PROTOCOL); + } + if (init.limits.maxBodyBytes !== undefined) { + limits.maxBodyBytes = integerOption(init.limits.maxBodyBytes, "limits.maxBodyBytes", 0, 2 ** 31 - 1, "Request", PROTOCOL); + } + if (init.limits.aggregateBytes !== undefined) { + limits.aggregateBytes = integerOption(init.limits.aggregateBytes, "limits.aggregateBytes", 1, NET_MAX_AGGREGATE_BYTES, "Request", PROTOCOL); + } + } + if (init.body !== undefined) body = bodyFromInput(init.body, "Request", PROTOCOL); + if (body !== null && (method === "GET" || method === "HEAD")) { + throw new NetworkError(NET_ERROR.invalidRequest, `${method} cannot have a body`, { + operation: "Request", + protocol: PROTOCOL, + }); + } + + this.url = url.href; + this.method = method; + this.headers = headers ?? new Headers().__setGuard("request"); + this.signal = signal ?? new AbortSignal(); + this.redirect = redirect; + this.timeouts = Object.freeze(timeouts); + this.maxRedirects = maxRedirects; + this.tls = tls ? Object.freeze(tls) : undefined; + this.limits = Object.freeze(limits); + this._body = body; + } + + get body(): BodyStream | null { + if (this._body === null) return null; + if (this._body instanceof BaseBody) return this._body; + // Async iterables are exposed as-is (they carry no lock state). + return this._body as unknown as BodyStream; + } + + get bodyUsed(): boolean { + if (this._body instanceof BaseBody) return this._body.bodyUsed; + return this.streamUsed; + } + + /** @internal */ + get __bodySource(): BaseBody | AsyncIterable | null { + return this._body; + } + + clone(): Request { + if (this.bodyUsed) { + throw new NetworkError(NET_ERROR.invalidState, "cannot clone a used request", { + operation: "clone", + protocol: PROTOCOL, + }); + } + let bodyForCopy: BodyInit | undefined; + if (this._body instanceof MemoryBody) bodyForCopy = this._body.fork() as unknown as BodyStream; + else if (this._body instanceof BaseBody) { + const [a, b] = teeBody(this._body, PROTOCOL, aggregateLimit(this.limits)); + this._body = a; + bodyForCopy = b; + } else if (this._body !== null) { + throw new NetworkError(NET_ERROR.invalidState, "cannot clone a request with an iterator body", { + operation: "clone", + protocol: PROTOCOL, + }); + } + return new Request(this, bodyForCopy === undefined ? {} : { body: bodyForCopy }); + } + + private aggregate(): BaseBody { + if (this._body instanceof BaseBody) return this._body; + if (this._body === null) return new MemoryBody(new Uint8Array(0), PROTOCOL); + throw new NetworkError(NET_ERROR.invalidState, "iterator bodies cannot be aggregated", { + operation: "arrayBuffer", + protocol: PROTOCOL, + }); + } + + async arrayBuffer(): Promise { + const bytes = await this.aggregate().collect(aggregateLimit(this.limits), "arrayBuffer"); + return bytes.slice().buffer as ArrayBuffer; + } + + async text(): Promise { + return this.aggregate().collectText(aggregateLimit(this.limits), "text"); + } + + async json(): Promise { + return JSON.parse(await this.text()) as T; + } +} + +function aggregateLimit(limits: RequestLimits | undefined): number { + return limits?.aggregateBytes ?? Math.min(NET_DEFAULT_AGGREGATE_BYTES, hostAggregateDefault()); +} + +// --------------------------------------------------------------------------- +// Response +// --------------------------------------------------------------------------- + +export interface ResponseInit { + status?: number; + statusText?: string; + headers?: HeadersInit; +} + +const REASON_PHRASES: Record = { + 100: "Continue", + 101: "Switching Protocols", + 200: "OK", + 201: "Created", + 202: "Accepted", + 204: "No Content", + 206: "Partial Content", + 301: "Moved Permanently", + 302: "Found", + 303: "See Other", + 304: "Not Modified", + 307: "Temporary Redirect", + 308: "Permanent Redirect", + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 408: "Request Timeout", + 409: "Conflict", + 413: "Content Too Large", + 414: "URI Too Long", + 415: "Unsupported Media Type", + 429: "Too Many Requests", + 431: "Request Header Fields Too Large", + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", +}; + +const NULL_BODY_STATUS = new Set(HTTP_NULL_BODY_STATUS); + +interface ResponseInternal { + url: string; + redirected: boolean; + aggregateBytes: number; +} + +export class Response { + readonly status: number; + readonly statusText: string; + readonly headers: Headers; + readonly url: string; + readonly redirected: boolean; + private _body: BaseBody | AsyncIterable | null; + private readonly aggregateBytes: number; + private streamUsed = false; + + constructor(body: BodyInit = null, init: ResponseInit = {}, internal?: ResponseInternal) { + const status = init.status ?? 200; + if (!Number.isInteger(status) || status < 200 || status > 599) { + // The constructor is the app-facing one; network responses use the + // internal path which accepts the full 1xx-5xx range. + if (!internal || !Number.isInteger(status) || status < 100 || status > 599) { + throw new NetworkError(NET_ERROR.invalidRequest, "status must be an integer from 200 through 599", { + operation: "Response", + protocol: PROTOCOL, + }); + } + } + const statusText = init.statusText === undefined ? "" : String(init.statusText); + if (/[\r\n\0]/.test(statusText)) { + throw new NetworkError(NET_ERROR.invalidRequest, "invalid statusText", { + operation: "Response", + protocol: PROTOCOL, + }); + } + this.status = status; + this.statusText = statusText; + this.headers = init.headers instanceof Headers && internal ? init.headers : new Headers(init.headers); + this.headers.__setGuard(internal ? "immutable" : "response"); + this.url = internal?.url ?? ""; + this.redirected = internal?.redirected ?? false; + this.aggregateBytes = internal?.aggregateBytes ?? aggregateLimit(undefined); + let source = bodyFromInput(body, "Response", PROTOCOL); + if (source !== null && NULL_BODY_STATUS.has(status)) { + throw new NetworkError(NET_ERROR.invalidRequest, `status ${status} cannot have a body`, { + operation: "Response", + protocol: PROTOCOL, + }); + } + if (source instanceof MemoryBody && !internal && typeof body === "string" && !this.headers.has("content-type")) { + this.headers.set("content-type", "text/plain;charset=UTF-8"); + } + if (source === null && !internal) source = null; + this._body = source; + } + + get ok(): boolean { + return this.status >= 200 && this.status <= 299; + } + + get body(): BodyStream | null { + if (this._body === null) return null; + if (this._body instanceof BaseBody) return this._body; + return this._body as unknown as BodyStream; + } + + get bodyUsed(): boolean { + if (this._body instanceof BaseBody) return this._body.bodyUsed; + return this.streamUsed; + } + + /** @internal */ + get __bodySource(): BaseBody | AsyncIterable | null { + return this._body; + } + + /** @internal */ + __markStreamUsed(): void { + this.streamUsed = true; + } + + clone(): Response { + if (this.bodyUsed) { + throw new NetworkError(NET_ERROR.invalidState, "cannot clone a used response", { + operation: "clone", + protocol: PROTOCOL, + }); + } + let bodyForCopy: BodyInit = null; + if (this._body instanceof MemoryBody) bodyForCopy = this._body.fork() as unknown as BodyStream; + else if (this._body instanceof BaseBody) { + const [a, b] = teeBody(this._body, PROTOCOL, this.aggregateBytes); + this._body = a; + bodyForCopy = b; + } else if (this._body !== null) { + throw new NetworkError(NET_ERROR.invalidState, "cannot clone a response with an iterator body", { + operation: "clone", + protocol: PROTOCOL, + }); + } + return new Response(bodyForCopy, { status: this.status, statusText: this.statusText, headers: new Headers(this.headers) }, { + url: this.url, + redirected: this.redirected, + aggregateBytes: this.aggregateBytes, + }); + } + + private aggregate(): BaseBody { + if (this._body instanceof BaseBody) return this._body; + if (this._body === null) return new MemoryBody(new Uint8Array(0), PROTOCOL); + throw new NetworkError(NET_ERROR.invalidState, "iterator bodies cannot be aggregated", { + operation: "arrayBuffer", + protocol: PROTOCOL, + }); + } + + async arrayBuffer(): Promise { + const bytes = await this.aggregate().collect(this.aggregateBytes, "arrayBuffer"); + return bytes.slice().buffer as ArrayBuffer; + } + + async text(): Promise { + return this.aggregate().collectText(this.aggregateBytes, "text"); + } + + async json(): Promise { + return JSON.parse(await this.text()) as T; + } + + static json(data: unknown, init: ResponseInit = {}): Response { + const headers = new Headers(init.headers); + if (!headers.has("content-type")) headers.set("content-type", "application/json"); + return new Response(JSON.stringify(data), { ...init, headers }); + } + + static redirect(url: string | URL, status = 302): Response { + if (!(HTTP_REDIRECT_STATUS as readonly number[]).includes(status)) { + throw new NetworkError(NET_ERROR.invalidRequest, `redirect status must be one of ${HTTP_REDIRECT_STATUS.join(", ")}`, { + operation: "Response.redirect", + protocol: PROTOCOL, + }); + } + const target = url instanceof URL ? url.href : String(url); + return new Response(null, { status, headers: { location: target } }); + } +} + +// --------------------------------------------------------------------------- +// HTTP Client binding (`globalThis.net`) +// --------------------------------------------------------------------------- + +export interface NetOps { + start(metaJson: string, body: ArrayBuffer | null): number; + cancel(handle: number): void; + poll(): string | undefined; + lastError(): string; + readInto(handle: number, into: ArrayBuffer, offset: number, length: number): number; + limits(): string; +} + +interface PendingFetch { + request: Request; + resolve: (response: Response) => void; + reject: (error: NetworkError) => void; + body: NativeBody | null; + settled: boolean; + aggregateBytes: number; + abortListener: (() => void) | null; +} + +const pendingFetches = new Map(); + +const net = createBinding({ + name: "net", + protocol: PROTOCOL, + specMajor: NET_SPEC_MAJOR, + requiredOps: ["start", "cancel", "poll", "lastError", "readInto", "limits"], + dispatch: dispatchNetEvent, + onProtocolFailure(ops, error) { + for (const [handle, p] of [...pendingFetches]) { + ops.cancel(handle); + failFetch(handle, p, error); + } + }, +}); + +function hostAggregateDefault(): number { + const ops = net.ops(); + if (!ops) return NET_DEFAULT_AGGREGATE_BYTES; + try { + return limitNumber(net.limits(), "defaultAggregateBytes", NET_DEFAULT_AGGREGATE_BYTES); + } catch { + return NET_DEFAULT_AGGREGATE_BYTES; + } +} + +function retireFetch(handle: number, p: PendingFetch): void { + pendingFetches.delete(handle); + if (p.abortListener) { + p.request.signal.removeEventListener?.("abort", p.abortListener); + p.abortListener = null; + } + net.release(); +} + +function failFetch(handle: number, p: PendingFetch, error: NetworkError): void { + retireFetch(handle, p); + if (!p.settled) { + p.settled = true; + p.reject(error); + } + if (p.body) p.body.onError(error); +} + +function dispatchNetEvent(event: EventRecord, ops: NetOps): void { + const handle = event.h; + if (typeof handle !== "number") return; + const p = pendingFetches.get(handle); + if (!p) return; + switch (event.t) { + case "headers": { + if (p.settled) return; + const status = event.status; + const url = typeof event.url === "string" ? event.url : p.request.url; + const headers = event.headers && typeof event.headers === "object" ? (event.headers as Record) : {}; + if (typeof status !== "number" || !Number.isInteger(status) || status < 100 || status > 599) { + ops.cancel(handle); + failFetch(handle, p, new NetworkError(NET_ERROR.protocol, "malformed headers event", { operation: "fetch", protocol: PROTOCOL })); + return; + } + const length = typeof event.length === "number" && event.length >= 0 ? event.length : -1; + const nullBody = p.request.method === "HEAD" || NULL_BODY_STATUS.has(status); + const body = nullBody + ? null + : new NativeBody( + { + pull: (dest) => ops.readInto(handle, dest.buffer as ArrayBuffer, dest.byteOffset, dest.byteLength), + cancel: () => ops.cancel(handle), + }, + PROTOCOL, + length, + ); + p.body = body; + p.settled = true; + const response = new Response(body as unknown as BodyStream, { + status, + statusText: "", + headers: Headers.__fromRecord(headers, "immutable"), + }, { + url, + redirected: event.redirected === true, + aggregateBytes: p.aggregateBytes, + }); + p.resolve(response); + return; + } + case "readable": + p.body?.onReadable(typeof event.avail === "number" ? event.avail : 0); + return; + case "end": + retireFetch(handle, p); + p.body?.onEnd(); + return; + case "error": { + const error = new NetworkError( + normalizeErrorCode(event.code), + typeof event.message === "string" && event.message ? event.message : String(event.code), + { + operation: "fetch", + protocol: PROTOCOL, + causeCode: typeof event.causeCode === "string" ? event.causeCode : undefined, + }, + ); + failFetch(handle, p, error); + return; + } + default: + return; + } +} + +function fetchLimits(): Record { + return net.limits(); +} + +/** The PocketJS HTTP client. */ +export function fetch(input: string | URL | Request, init?: RequestInit): Promise { + let request: Request; + let ops: NetOps; + let handle: number; + let bodyBuffer: ArrayBuffer | null = null; + try { + request = input instanceof Request && init === undefined ? input : new Request(input, init); + ops = net.require("fetch"); + const limits = fetchLimits(); + const url = new URL(request.url); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new NetworkError(NET_ERROR.invalidRequest, "url must be http: or https:", { operation: "fetch", protocol: PROTOCOL }); + } + const features = Array.isArray(limits.features) ? (limits.features as unknown[]) : []; + if (url.protocol === "https:" && !features.includes("tls")) { + throw new NetworkError(NET_ERROR.unsupported, "this host does not provide network.http.client.tls", { + operation: "fetch", + protocol: PROTOCOL, + }); + } + if (request.signal.aborted) { + throw new NetworkError(NET_ERROR.cancelled, "request was aborted", { operation: "fetch", protocol: PROTOCOL }); + } + const source = request.__bodySource; + if (source instanceof MemoryBody) { + if (source.bodyUsed) { + throw new NetworkError(NET_ERROR.invalidState, "request body is already used", { operation: "fetch", protocol: PROTOCOL }); + } + const bytes = source.peek(); + const maxRequest = limitNumber(limits, "maxRequestBytes", NET_MAX_REQUEST_BYTES); + if (bytes.length > maxRequest) { + throw new NetworkError(NET_ERROR.resourceLimit, `request body exceeds ${maxRequest} bytes`, { + operation: "fetch", + protocol: PROTOCOL, + }); + } + bodyBuffer = bytes.slice().buffer as ArrayBuffer; + void source.cancel(); // consumed by this fetch + } else if (source !== null) { + throw new NetworkError(NET_ERROR.unsupported, "streaming request bodies are not supported by this host yet", { + operation: "fetch", + protocol: PROTOCOL, + }); + } + const size = request.headers.__byteSize(); + if (size.count > limitNumber(limits, "maxHeaders", NET_MAX_HEADERS) || size.bytes > limitNumber(limits, "maxHeaderBytes", NET_MAX_HEADER_BYTES)) { + throw new NetworkError(NET_ERROR.resourceLimit, "request headers exceed the host limits", { operation: "fetch", protocol: PROTOCOL }); + } + if (request.tls) { + const v = request.tls.verification; + if (v !== undefined && v !== "full" && v !== "development-insecure") { + throw new NetworkError(NET_ERROR.invalidRequest, "tls.verification must be full or development-insecure", { + operation: "fetch", + protocol: PROTOCOL, + }); + } + for (const key of ["ca", "credential", "alpn", "minVersion", "maxVersion", "clientCertificate", "revocation", "serverName"] as const) { + if (request.tls[key] !== undefined) { + throw new NetworkError(NET_ERROR.unsupported, `tls.${key} is not supported by this host`, { operation: "fetch", protocol: PROTOCOL }); + } + } + } + const meta: NetStartMeta = { + url: request.url, + method: request.method, + headers: request.headers.__toRecord(), + queueBytes: request.limits.queueBytes ?? limitNumber(limits, "defaultQueueBytes", NET_DEFAULT_QUEUE_BYTES), + redirect: request.redirect, + maxRedirects: Math.min(request.maxRedirects, limitNumber(limits, "maxRedirects", NET_MAX_REDIRECTS)), + timeouts: { + connectMs: request.timeouts.connectMs ?? limitNumber(limits, "defaultTimeoutMs", NET_DEFAULT_TIMEOUT_MS), + headersMs: request.timeouts.headersMs ?? limitNumber(limits, "defaultTimeoutMs", NET_DEFAULT_TIMEOUT_MS), + idleMs: request.timeouts.idleMs ?? limitNumber(limits, "defaultTimeoutMs", NET_DEFAULT_TIMEOUT_MS), + totalMs: request.timeouts.totalMs ?? limitNumber(limits, "maxTimeoutMs", NET_MAX_TIMEOUT_MS), + }, + }; + if (request.limits.maxBodyBytes !== undefined) meta.maxBodyBytes = request.limits.maxBodyBytes; + if (request.tls?.verification !== undefined) meta.tls = { verification: request.tls.verification }; + handle = ops.start(JSON.stringify(meta), bodyBuffer); + if (!Number.isInteger(handle) || handle < 0) { + throw errorFromLastError(ops.lastError(), "fetch", PROTOCOL); + } + } catch (error) { + return Promise.reject( + error instanceof NetworkError + ? error + : new NetworkError(NET_ERROR.invalidRequest, String(error), { operation: "fetch", protocol: PROTOCOL }), + ); + } + return new Promise((resolve, reject) => { + const aggregateBytes = request.limits.aggregateBytes ?? Math.min(NET_DEFAULT_AGGREGATE_BYTES, hostAggregateDefault()); + const pending: PendingFetch = { request, resolve, reject, body: null, settled: false, aggregateBytes, abortListener: null }; + pendingFetches.set(handle, pending); + net.retain(); + const onAbort = (): void => { + // The terminal error{cancelled} settles the Promise at the next tick. + ops.cancel(handle); + }; + pending.abortListener = onAbort; + request.signal.addEventListener("abort", onAbort); + }); +} + +// --------------------------------------------------------------------------- +// HTTP Server binding (`globalThis.httpd`) +// --------------------------------------------------------------------------- + +export interface HttpdOps { + listen(metaJson: string): number; + stop(handle: number, graceful: boolean, timeoutMs: number): number; + respond(req: number, metaJson: string, body: ArrayBuffer | null): number; + write(req: number, chunk: ArrayBuffer): number; + endBody(req: number): number; + readInto(req: number, into: ArrayBuffer, offset: number, length: number): number; + abort(req: number): void; + poll(): string | undefined; + lastError(): string; + limits(): string; +} + +export interface HttpServeLimits { + maxConnections?: number; + maxInflight?: number; + maxHeaderBytes?: number; + maxBodyBytes?: number; + requestQueueBytes?: number; + sendQueueBytes?: number; +} + +export interface HttpServeTimeouts { + headerMs?: number; + bodyIdleMs?: number; + handlerMs?: number; + keepAliveMs?: number; + closeMs?: number; +} + +export interface HttpServer { + readonly hostname: string; + readonly port: number; + readonly url: string; + stop(options?: { graceful?: boolean; timeout?: number }): Promise; +} + +export interface HttpServeOptions { + hostname: string; + port: number; + backlog?: number; + tls?: { credential: string }; + limits?: HttpServeLimits; + timeouts?: HttpServeTimeouts; + fetch(request: Request, server: HttpServer): Response | Promise; + error?(error: unknown): Response | Promise | void; +} + +interface ServerState { + handle: number; + options: HttpServeOptions; + server: HttpServerImpl; + resolveListen: ((server: HttpServer) => void) | null; + rejectListen: ((error: NetworkError) => void) | null; + stopWaiters: { resolve: () => void; reject: (e: NetworkError) => void }[]; + secure: boolean; +} + +interface ServerRequestState { + server: ServerState; + req: number; + body: NativeBody | null; + controller: AbortController; + responded: boolean; + terminal: boolean; + drainWaiter: (() => void) | null; +} + +const servers = new Map(); +const serverRequests = new Map(); + +const httpd = createBinding({ + name: "httpd", + protocol: PROTOCOL, + specMajor: HTTPD_SPEC_MAJOR, + requiredOps: ["listen", "stop", "respond", "write", "endBody", "readInto", "abort", "poll", "lastError", "limits"], + dispatch: dispatchHttpdEvent, + onProtocolFailure(ops, error) { + for (const [req, r] of [...serverRequests]) { + ops.abort(req); + finishServerRequest(r, error); + } + for (const [handle, s] of [...servers]) { + ops.stop(handle, false, 0); + failServer(s, error); + } + }, +}); + +class HttpServerImpl implements HttpServer { + hostname = ""; + port = 0; + private readonly state: () => ServerState; + + constructor(state: () => ServerState) { + this.state = state; + } + + get url(): string { + const host = this.hostname.includes(":") ? `[${this.hostname}]` : this.hostname; + return `${this.state().secure ? "https" : "http"}://${host}:${this.port}/`; + } + + stop(options: { graceful?: boolean; timeout?: number } = {}): Promise { + const s = this.state(); + const ops = httpd.ops(); + if (!ops || !servers.has(s.handle)) return Promise.resolve(); + const graceful = options.graceful ?? true; + const timeout = options.timeout ?? 0; + const rc = ops.stop(s.handle, graceful, timeout); + if (rc < 0) return Promise.resolve(); + return new Promise((resolve, reject) => { + s.stopWaiters.push({ resolve, reject }); + }); + } +} + +function failServer(s: ServerState, error: NetworkError): void { + if (!servers.has(s.handle)) return; + servers.delete(s.handle); + httpd.release(); + if (s.rejectListen) { + const reject = s.rejectListen; + s.rejectListen = null; + s.resolveListen = null; + reject(error); + } + for (const w of s.stopWaiters.splice(0)) w.reject(error); +} + +function closeServer(s: ServerState): void { + if (!servers.has(s.handle)) return; + servers.delete(s.handle); + httpd.release(); + for (const w of s.stopWaiters.splice(0)) w.resolve(); +} + +function finishServerRequest(r: ServerRequestState, error: NetworkError | null): void { + if (r.terminal) return; + r.terminal = true; + serverRequests.delete(r.req); + if (error) { + r.body?.onError(error); + r.controller.abort(error); + } else { + r.body?.onEnd(); + } + const w = r.drainWaiter; + r.drainWaiter = null; + if (w) w(); +} + +function dispatchHttpdEvent(event: EventRecord, ops: HttpdOps): void { + if (typeof event.req === "number" && event.t !== "request") { + const r = serverRequests.get(event.req); + if (!r) return; + switch (event.t) { + case "readable": + r.body?.onReadable(typeof event.avail === "number" ? event.avail : 0); + return; + case "end": + r.body?.onEnd(); + return; + case "drain": { + const w = r.drainWaiter; + r.drainWaiter = null; + if (w) w(); + return; + } + case "aborted": { + const code = normalizeErrorCode(event.code); + finishServerRequest( + r, + new NetworkError(code, `request ${code}`, { operation: "serve", protocol: PROTOCOL }), + ); + return; + } + default: + return; + } + } + const handle = event.h; + if (typeof handle !== "number") return; + const s = servers.get(handle); + if (!s) return; + switch (event.t) { + case "listening": { + s.server.hostname = typeof event.address === "string" ? event.address : s.options.hostname; + s.server.port = typeof event.port === "number" ? event.port : s.options.port; + const resolve = s.resolveListen; + s.resolveListen = null; + s.rejectListen = null; + if (resolve) resolve(s.server); + return; + } + case "closed": + closeServer(s); + return; + case "error": { + const error = new NetworkError( + normalizeErrorCode(event.code), + typeof event.message === "string" && event.message ? event.message : String(event.code), + { operation: "serve", protocol: PROTOCOL, causeCode: typeof event.causeCode === "string" ? event.causeCode : undefined }, + ); + if (s.rejectListen) failServer(s, error); + // After listening, `closed` follows and resolves stop waiters; the + // error itself has no app-visible surface beyond stop() rejecting. + else { + for (const w of s.stopWaiters.splice(0)) w.reject(error); + } + return; + } + case "request": + deliverRequest(s, event, ops); + return; + default: + return; + } +} + +function deliverRequest(s: ServerState, event: EventRecord, ops: HttpdOps): void { + const req = event.req; + if (typeof req !== "number") return; + const method = typeof event.method === "string" ? event.method : "GET"; + const target = typeof event.target === "string" ? event.target : "/"; + const headerRecord = event.headers && typeof event.headers === "object" ? (event.headers as Record) : {}; + const headers = Headers.__fromRecord(headerRecord, "immutable"); + const length = typeof event.length === "number" && event.length >= 0 ? event.length : -1; + const hasBody = length > 0 || (length < 0 && /chunked/i.test(headers.get("transfer-encoding") ?? "")); + const secure = event.secure === true; + const hostHeader = headers.get("host"); + const authority = hostHeader && /^[A-Za-z0-9.\-:[\]_%]+$/.test(hostHeader) ? hostHeader : `${s.server.hostname}:${s.server.port}`; + let urlText = `${secure ? "https" : "http"}://${authority}${target.startsWith("/") ? target : "/" + target}`; + if (!URL.canParse(urlText)) urlText = `${secure ? "https" : "http"}://${s.server.hostname}:${s.server.port}/`; + + const controller = new AbortController(); + const state: ServerRequestState = { + server: s, + req, + body: null, + controller, + responded: false, + terminal: false, + drainWaiter: null, + }; + const body = hasBody + ? new NativeBody( + { + pull: (dest) => ops.readInto(req, dest.buffer as ArrayBuffer, dest.byteOffset, dest.byteLength), + cancel: () => { + // Cancelling the request body does not abort the exchange; the + // core drains or closes after the response completes. + }, + }, + PROTOCOL, + length, + ) + : null; + state.body = body; + serverRequests.set(req, state); + + const request = new Request(urlText, { + method, + headers, // wire headers: immutable, adopted as-is + body: body as unknown as BodyStream, + signal: controller.signal, + }); + + let result: Response | Promise; + try { + result = s.options.fetch(request, s.server); + } catch (error) { + void handleHandlerFailure(state, ops, error); + return; + } + if (result instanceof Response) { + void sendResponse(state, ops, result); + } else if (result && typeof (result as Promise).then === "function") { + (result as Promise).then( + (response) => void sendResponse(state, ops, response), + (error) => void handleHandlerFailure(state, ops, error), + ); + } else { + void handleHandlerFailure(state, ops, new TypeError("handler must return a Response")); + } +} + +async function handleHandlerFailure(state: ServerRequestState, ops: HttpdOps, error: unknown): Promise { + if (state.terminal || state.responded) { + if (!state.terminal && state.responded) ops.abort(state.req); + return; + } + const errorHandler = state.server.options.error; + if (errorHandler) { + try { + const produced = await errorHandler(error); + if (produced instanceof Response) { + await sendResponse(state, ops, produced); + return; + } + } catch { + // fall through to the fixed 500 + } + } + await sendResponse(state, ops, new Response(null, { status: 500 })); +} + +function respondMeta(response: Response, end: boolean, contentLength?: number): HttpdRespondMeta { + const meta: HttpdRespondMeta = { + status: response.status, + statusText: response.statusText, + headers: response.headers.__toRecord(), + end, + }; + if (contentLength !== undefined) meta.contentLength = contentLength; + return meta; +} + +function waitDrain(state: ServerRequestState): Promise { + return new Promise((resolve) => { + state.drainWaiter = resolve; + }); +} + +async function sendResponse(state: ServerRequestState, ops: HttpdOps, response: Response): Promise { + if (state.terminal || state.responded) return; + state.responded = true; + const source = response.__bodySource; + try { + if (source === null || source instanceof MemoryBody) { + const bytes = source ? source.peek() : new Uint8Array(0); + const rc = ops.respond(state.req, JSON.stringify(respondMeta(response, true)), bytes.length ? (bytes.slice().buffer as ArrayBuffer) : null); + if (rc === HTTPD_SEND_ACCEPTED) { + if (source) void source.cancel(); + finishServerRequest(state, null); + return; + } + if (rc === HTTPD_SEND_BACKPRESSURE) { + // Too large for one send: stream it with a known length. + const rc2 = ops.respond(state.req, JSON.stringify(respondMeta(response, false, bytes.length)), null); + if (rc2 !== HTTPD_SEND_ACCEPTED) { + finishServerRequest(state, sendError(rc2)); + return; + } + await writeAll(state, ops, bytes); + if (source) void source.cancel(); + if (!state.terminal) { + ops.endBody(state.req); + finishServerRequest(state, null); + } + return; + } + finishServerRequest(state, sendError(rc)); + return; + } + // Streaming body (BodyStream or AsyncIterable). + const known = source instanceof BaseBody ? source.knownLength() : -1; + const rc = ops.respond(state.req, JSON.stringify(respondMeta(response, false, known >= 0 ? known : undefined)), null); + if (rc !== HTTPD_SEND_ACCEPTED) { + finishServerRequest(state, sendError(rc)); + if (source instanceof BaseBody) void source.cancel(); + return; + } + response.__markStreamUsed(); + const iterable = source as AsyncIterable; + const iterator = iterable[Symbol.asyncIterator](); + try { + for (;;) { + const { value, done } = await iterator.next(); + if (done) break; + if (state.terminal) break; + if (!(value instanceof Uint8Array)) throw new TypeError("body chunks must be Uint8Array"); + await writeAll(state, ops, value); + } + } finally { + if (state.terminal) await iterator.return?.(); + } + if (!state.terminal) { + ops.endBody(state.req); + finishServerRequest(state, null); + } + } catch (error) { + if (!state.terminal) { + ops.abort(state.req); + finishServerRequest( + state, + error instanceof NetworkError ? error : new NetworkError(NET_ERROR.other, String(error), { operation: "serve", protocol: PROTOCOL }), + ); + } + } +} + +function sendError(rc: number): NetworkError { + const code = rc === HTTPD_SEND_INVALID_REQUEST ? NET_ERROR.closed : rc === HTTPD_SEND_INVALID ? NET_ERROR.invalidRequest : NET_ERROR.other; + return new NetworkError(code, `respond failed (${rc})`, { operation: "serve", protocol: PROTOCOL }); +} + +async function writeAll(state: ServerRequestState, ops: HttpdOps, bytes: Uint8Array): Promise { + const listenQueue = state.server.options.limits?.sendQueueBytes; + let chunkMax = Math.max(1, Math.min(16 * 1024, limitNumber(httpd.limits(), "sendLowWaterBytes", 16 * 1024), listenQueue ?? Infinity)); + let offset = 0; + let refusedAt = -1; + while (offset < bytes.length) { + if (state.terminal) return; + const end = Math.min(bytes.length, offset + chunkMax); + const chunk = bytes.slice(offset, end).buffer as ArrayBuffer; + const rc = ops.write(state.req, chunk); + if (rc === HTTPD_SEND_ACCEPTED) { + offset = end; + refusedAt = -1; + continue; + } + if (rc === HTTPD_SEND_BACKPRESSURE) { + // Wait for the queue to drain; a chunk refused twice in a row is + // larger than the free window, so shrink it before retrying. + if (refusedAt === offset && chunkMax > 1) chunkMax = Math.max(1, chunkMax >> 2); + refusedAt = offset; + await waitDrain(state); + continue; + } + throw sendError(rc); + } +} + +/** Start an HTTP server. Resolves once the listener is bound; rejects on any + * bind, permission or TLS credential failure. */ +export function serve(options: HttpServeOptions): Promise { + let ops: HttpdOps; + let handle: number; + const state: ServerState = { + handle: -1, + options, + server: null as unknown as HttpServerImpl, + resolveListen: null, + rejectListen: null, + stopWaiters: [], + secure: options.tls !== undefined, + }; + state.server = new HttpServerImpl(() => state); + try { + if (typeof options.fetch !== "function") { + throw new NetworkError(NET_ERROR.invalidRequest, "serve() requires a fetch handler", { operation: "serve", protocol: PROTOCOL }); + } + ops = httpd.require("serve"); + const limits = httpd.limits(); + const meta: HttpdListenMeta = { + address: String(options.hostname), + port: integerOption(options.port, "port", 0, 65535, "serve", PROTOCOL), + }; + if (options.backlog !== undefined) meta.backlog = integerOption(options.backlog, "backlog", 1, HTTPD_MAX_BACKLOG, "serve", PROTOCOL); + if (options.tls !== undefined) { + const features = Array.isArray(limits.features) ? (limits.features as unknown[]) : []; + if (!features.includes("tls")) { + throw new NetworkError(NET_ERROR.unsupported, "this host does not provide network.http.server.tls", { operation: "serve", protocol: PROTOCOL }); + } + if (typeof options.tls.credential !== "string" || !options.tls.credential) { + throw new NetworkError(NET_ERROR.invalidRequest, "tls.credential must name a host credential", { operation: "serve", protocol: PROTOCOL }); + } + meta.tls = { credential: options.tls.credential }; + } + if (options.limits) { + meta.limits = {}; + const l = options.limits; + if (l.maxConnections !== undefined) meta.limits.maxConnections = integerOption(l.maxConnections, "limits.maxConnections", 1, HTTPD_MAX_CONNECTIONS, "serve", PROTOCOL); + if (l.maxInflight !== undefined) meta.limits.maxInflight = integerOption(l.maxInflight, "limits.maxInflight", 1, HTTPD_MAX_INFLIGHT, "serve", PROTOCOL); + if (l.maxHeaderBytes !== undefined) meta.limits.maxHeaderBytes = integerOption(l.maxHeaderBytes, "limits.maxHeaderBytes", 1, 2 ** 31 - 1, "serve", PROTOCOL); + if (l.maxBodyBytes !== undefined) meta.limits.maxBodyBytes = integerOption(l.maxBodyBytes, "limits.maxBodyBytes", 0, 2 ** 31 - 1, "serve", PROTOCOL); + if (l.requestQueueBytes !== undefined) meta.limits.requestQueueBytes = integerOption(l.requestQueueBytes, "limits.requestQueueBytes", 1, HTTPD_MAX_REQUEST_QUEUE_BYTES, "serve", PROTOCOL); + if (l.sendQueueBytes !== undefined) meta.limits.sendQueueBytes = integerOption(l.sendQueueBytes, "limits.sendQueueBytes", 1, HTTPD_MAX_SEND_QUEUE_BYTES, "serve", PROTOCOL); + } + if (options.timeouts) { + meta.timeouts = {}; + for (const key of ["headerMs", "bodyIdleMs", "handlerMs", "keepAliveMs", "closeMs"] as const) { + const v = options.timeouts[key]; + if (v !== undefined) meta.timeouts[key] = integerOption(v, `timeouts.${key}`, 1, HTTPD_MAX_TIMEOUT_MS, "serve", PROTOCOL); + } + } + handle = ops.listen(JSON.stringify(meta)); + if (!Number.isInteger(handle) || handle < 0) throw errorFromLastError(ops.lastError(), "serve", PROTOCOL); + } catch (error) { + return Promise.reject( + error instanceof NetworkError ? error : new NetworkError(NET_ERROR.invalidRequest, String(error), { operation: "serve", protocol: PROTOCOL }), + ); + } + state.handle = handle; + return new Promise((resolve, reject) => { + state.resolveListen = resolve; + state.rejectListen = reject; + servers.set(handle, state); + httpd.retain(); + }); +} + +/** @internal test hooks */ +export const __http = { net, httpd, pendingFetches, servers, serverRequests }; diff --git a/framework/src/net/index.ts b/framework/src/net/index.ts new file mode 100644 index 00000000..5f1b7f82 --- /dev/null +++ b/framework/src/net/index.ts @@ -0,0 +1,42 @@ +// @pocketjs/framework/net — the network support module. It provides the +// public types, `AbortController`/`AbortSignal`, `URL`, the `NetworkError` +// class (usable with `instanceof`) and the read-only `getNetworkLimits()` +// snapshot. Importing it assembles no I/O capability; the protocol modules +// live at `@pocketjs/framework/net/http` and `@pocketjs/framework/net/websocket` +// and share these object identities. See docs/NET.md. + +import { HTTPD_SPEC_MAJOR, type HttpdLimits } from "../../../contracts/spec/httpd.ts"; +import { NET_SPEC_MAJOR, type NetLimits } from "../../../contracts/spec/net.ts"; +import { WS_SPEC_MAJOR, type WsLimits } from "../../../contracts/spec/ws.ts"; +import type { NetworkLimits } from "./types.ts"; + +export { AbortController, AbortSignal, AbortError } from "./abort.ts"; +export { NetworkError } from "./errors.ts"; +export type { NetworkErrorCategory, NetworkProtocol } from "./errors.ts"; +export { URL } from "./url.ts"; +export type { BodyStream, BodyReadResult } from "./body.ts"; +export type { NetworkAddress, NetworkData, NetworkLimits, TlsOptions } from "./types.ts"; + +/** Read one namespace's `limits()` without touching the protocol modules. */ +function readLimits(name: string, specMajor: number): Readonly | null { + const ns = (globalThis as Record)[name]; + if (!ns || typeof ns !== "object" || typeof (ns as { limits?: unknown }).limits !== "function") return null; + try { + const parsed = JSON.parse((ns as { limits(): string }).limits()) as Record; + if (!parsed || typeof parsed !== "object" || parsed.specMajor !== specMajor) return null; + return Object.freeze({ ...parsed }) as Readonly; + } catch { + return null; + } +} + +/** A frozen snapshot of the mounted modules' effective limits and features. + * This is a capability/profile query — it never negotiates anything. Modules + * the host did not mount (or mounted at another spec major) read as null. */ +export function getNetworkLimits(): NetworkLimits { + return Object.freeze({ + httpClient: readLimits("net", NET_SPEC_MAJOR), + httpServer: readLimits("httpd", HTTPD_SPEC_MAJOR), + websocketClient: readLimits("ws", WS_SPEC_MAJOR), + }); +} diff --git a/framework/src/net/types.ts b/framework/src/net/types.ts new file mode 100644 index 00000000..76db03c4 --- /dev/null +++ b/framework/src/net/types.ts @@ -0,0 +1,36 @@ +// Public support types shared by every network module. +// Values cross the boundary as +// JSON; the types keep one object identity across `@pocketjs/framework/net` +// and its protocol subpaths. + +import type { HttpdLimits } from "../../../contracts/spec/httpd.ts"; +import type { NetLimits } from "../../../contracts/spec/net.ts"; +import type { WsLimits } from "../../../contracts/spec/ws.ts"; + +export type NetworkData = string | ArrayBuffer | ArrayBufferView; + +export type NetworkAddress = { + family: "ipv4" | "ipv6"; + address: string; + port: number; +}; + +export type TlsOptions = { + serverName?: string; + minVersion?: "1.2" | "1.3"; + maxVersion?: "1.2" | "1.3"; + alpn?: readonly string[]; + ca?: Uint8Array; + credential?: string; + clientCertificate?: "none" | "optional" | "required"; + verification?: "full" | "development-insecure"; + revocation?: "host-default" | "required"; +}; + +/** The frozen `getNetworkLimits()` snapshot: one entry per mounted module, + * null where the host did not mount the namespace. */ +export type NetworkLimits = Readonly<{ + httpClient: Readonly | null; + httpServer: Readonly | null; + websocketClient: Readonly | null; +}>; diff --git a/framework/src/net/url.ts b/framework/src/net/url.ts new file mode 100644 index 00000000..869254c1 --- /dev/null +++ b/framework/src/net/url.ts @@ -0,0 +1,312 @@ +// URL for the network modules. QuickJS has no WHATWG URL; the module ships a +// bounded parser for the schemes the modules speak (http, https, ws, wss) with +// the property surface apps use — href, protocol, username, password, host, +// hostname, port, pathname, search, hash, origin — plus relative resolution +// against a base (redirect Location). Other schemes parse as opaque +// `scheme:rest` values so `new URL("mailto:x")` does not throw here; the +// protocol modules reject them at their own scheme check. + +const SPECIAL_PORTS: Record = { + "http:": "80", + "https:": "443", + "ws:": "80", + "wss:": "443", +}; + +function isSpecial(protocol: string): boolean { + return protocol in SPECIAL_PORTS; +} + +const HEX = "0123456789ABCDEF"; + +/** Percent-encode bytes outside the "path/query safe" set (UTF-8 encoding + * non-ASCII first). Existing `%XX` escapes pass through untouched. */ +function percentEncode(s: string, extra: string): string { + let out = ""; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c === 0x25 && /^[0-9a-fA-F]{2}$/.test(s.slice(i + 1, i + 3))) { + out += s.slice(i, i + 3); + i += 2; + continue; + } + if (c > 0x20 && c < 0x7f && !extra.includes(s[i])) { + out += s[i]; + continue; + } + // UTF-8 encode the code point (surrogate pairs included). + let cp = c; + if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) { + const lo = s.charCodeAt(i + 1); + if (lo >= 0xdc00 && lo <= 0xdfff) { + cp = 0x10000 + ((c - 0xd800) << 10) + (lo - 0xdc00); + i++; + } + } + const bytes: number[] = []; + if (cp < 0x80) bytes.push(cp); + else if (cp < 0x800) bytes.push(0xc0 | (cp >> 6), 0x80 | (cp & 63)); + else if (cp < 0x10000) bytes.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63)); + else { + bytes.push( + 0xf0 | (cp >> 18), + 0x80 | ((cp >> 12) & 63), + 0x80 | ((cp >> 6) & 63), + 0x80 | (cp & 63), + ); + } + for (const b of bytes) out += "%" + HEX[b >> 4] + HEX[b & 15]; + } + return out; +} + +const PATH_EXTRA = ' "<>`{}#?'; +const QUERY_EXTRA = ' "<>#'; +const FRAGMENT_EXTRA = ' "<>`'; + +function removeDotSegments(path: string): string { + const out: string[] = []; + const segments = path.split("/"); + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + const last = i === segments.length - 1; + if (seg === "." || seg === "%2e" || seg === "%2E") { + if (last) out.push(""); + continue; + } + if (seg === ".." || /^(\.|%2e){2}$/i.test(seg)) { + if (out.length > 1) out.pop(); + if (last) out.push(""); + continue; + } + out.push(seg); + } + let joined = out.join("/"); + if (!joined.startsWith("/")) joined = "/" + joined; + return joined; +} + +interface HostParts { + hostname: string; + port: string; +} + +function parseHost(input: string, protocol: string): HostParts { + let host = input; + let port = ""; + if (host.startsWith("[")) { + const end = host.indexOf("]"); + if (end < 0) throw new TypeError("Invalid URL: unterminated IPv6 literal"); + const literal = host.slice(1, end).toLowerCase(); + if (!/^[0-9a-f:.]+$/.test(literal) || !literal.includes(":")) { + throw new TypeError("Invalid URL: invalid IPv6 literal"); + } + const rest = host.slice(end + 1); + if (rest.startsWith(":")) port = rest.slice(1); + else if (rest.length) throw new TypeError("Invalid URL: bad host"); + host = "[" + literal + "]"; + } else { + const colon = host.lastIndexOf(":"); + if (colon >= 0) { + port = host.slice(colon + 1); + host = host.slice(0, colon); + } + host = host.toLowerCase(); + if (host.length === 0 && isSpecial(protocol)) throw new TypeError("Invalid URL: empty host"); + if (/[\x00- #%/:<>?@[\\\]^|\x7f]/.test(host)) { + throw new TypeError("Invalid URL: forbidden host code point"); + } + // The v1 modules speak ASCII hostnames only (IDNA A-labels). + for (let i = 0; i < host.length; i++) { + if (host.charCodeAt(i) > 0x7e) throw new TypeError("Invalid URL: non-ASCII hostname"); + } + } + if (port.length) { + if (!/^[0-9]{1,5}$/.test(port)) throw new TypeError("Invalid URL: bad port"); + const n = Number(port); + if (n > 65535) throw new TypeError("Invalid URL: bad port"); + port = String(n); + if (SPECIAL_PORTS[protocol] === port) port = ""; + } + return { hostname: host, port }; +} + +export class URL { + private _protocol = ""; + private _username = ""; + private _password = ""; + private _hostname = ""; + private _port = ""; + private _pathname = ""; + private _search = ""; + private _hash = ""; + /** For non-special schemes: everything after `scheme:`, kept verbatim. */ + private _opaque: string | null = null; + + constructor(input: string | URL, base?: string | URL) { + const text = String(input).trim(); + const baseUrl = base === undefined ? null : base instanceof URL ? base : new URL(base); + const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):(.*)$/s.exec(text); + if (m) { + const protocol = m[1].toLowerCase() + ":"; + let rest = m[2]; + if (!isSpecial(protocol)) { + this._protocol = protocol; + this._opaque = rest; + return; + } + // Special scheme: authority is required. + rest = rest.replace(/\\/g, "/"); + if (!rest.startsWith("//")) { + if (baseUrl && baseUrl._protocol === protocol && baseUrl._opaque === null) { + this.assignRelative(baseUrl, rest); + return; + } + throw new TypeError("Invalid URL: missing authority"); + } + this._protocol = protocol; + this.parseAuthorityAndPath(rest.slice(2)); + return; + } + if (!baseUrl) throw new TypeError("Invalid URL: relative URL without a base"); + if (baseUrl._opaque !== null) throw new TypeError("Invalid URL: opaque base"); + this.assignRelative(baseUrl, text.replace(/\\/g, "/")); + } + + private parseAuthorityAndPath(rest: string): void { + let end = rest.length; + for (let i = 0; i < rest.length; i++) { + const ch = rest[i]; + if (ch === "/" || ch === "?" || ch === "#") { + end = i; + break; + } + } + let authority = rest.slice(0, end); + const remainder = rest.slice(end); + const at = authority.lastIndexOf("@"); + if (at >= 0) { + const cred = authority.slice(0, at); + authority = authority.slice(at + 1); + const colon = cred.indexOf(":"); + this._username = colon < 0 ? cred : cred.slice(0, colon); + this._password = colon < 0 ? "" : cred.slice(colon + 1); + } + const host = parseHost(authority, this._protocol); + this._hostname = host.hostname; + this._port = host.port; + this.assignPathSearchHash(remainder, "/"); + } + + private assignPathSearchHash(remainder: string, defaultPath: string): void { + let path = remainder; + let search = ""; + let hash = ""; + const hashAt = path.indexOf("#"); + if (hashAt >= 0) { + hash = path.slice(hashAt); + path = path.slice(0, hashAt); + } + const queryAt = path.indexOf("?"); + if (queryAt >= 0) { + search = path.slice(queryAt); + path = path.slice(0, queryAt); + } + if (path.length === 0) path = defaultPath; + this._pathname = removeDotSegments(percentEncode(path, PATH_EXTRA)); + this._search = search.length > 1 ? "?" + percentEncode(search.slice(1), QUERY_EXTRA) : ""; + this._hash = hash.length > 1 ? "#" + percentEncode(hash.slice(1), FRAGMENT_EXTRA) : ""; + } + + private assignRelative(base: URL, rel: string): void { + this._protocol = base._protocol; + if (rel.startsWith("//")) { + this.parseAuthorityAndPath(rel.slice(2)); + return; + } + this._username = base._username; + this._password = base._password; + this._hostname = base._hostname; + this._port = base._port; + if (rel.length === 0) { + this._pathname = base._pathname; + this._search = base._search; + this._hash = ""; + return; + } + if (rel.startsWith("/")) { + this.assignPathSearchHash(rel, "/"); + return; + } + if (rel.startsWith("?")) { + this._pathname = base._pathname; + this.assignPathSearchHash(base._pathname + rel, "/"); + return; + } + if (rel.startsWith("#")) { + this._pathname = base._pathname; + this._search = base._search; + this._hash = rel.length > 1 ? "#" + percentEncode(rel.slice(1), FRAGMENT_EXTRA) : ""; + return; + } + const dir = base._pathname.slice(0, base._pathname.lastIndexOf("/") + 1); + this.assignPathSearchHash(dir + rel, "/"); + } + + get protocol(): string { + return this._protocol; + } + get username(): string { + return this._username; + } + get password(): string { + return this._password; + } + get hostname(): string { + return this._hostname; + } + get port(): string { + return this._port; + } + get host(): string { + return this._port ? `${this._hostname}:${this._port}` : this._hostname; + } + get pathname(): string { + return this._opaque !== null ? this._opaque : this._pathname; + } + get search(): string { + return this._search; + } + get hash(): string { + return this._hash; + } + get origin(): string { + return this._opaque !== null ? "null" : `${this._protocol}//${this.host}`; + } + get href(): string { + if (this._opaque !== null) return this._protocol + this._opaque; + const cred = this._username + ? this._username + (this._password ? ":" + this._password : "") + "@" + : ""; + return `${this._protocol}//${cred}${this.host}${this._pathname}${this._search}${this._hash}`; + } + /** Numeric port, applying the scheme default. */ + get effectivePort(): number { + return Number(this._port || SPECIAL_PORTS[this._protocol] || 0); + } + toString(): string { + return this.href; + } + toJSON(): string { + return this.href; + } + + static canParse(input: string | URL, base?: string | URL): boolean { + try { + new URL(input, base); + return true; + } catch { + return false; + } + } +} diff --git a/framework/src/net/websocket.ts b/framework/src/net/websocket.ts new file mode 100644 index 00000000..7ccc662e --- /dev/null +++ b/framework/src/net/websocket.ts @@ -0,0 +1,463 @@ +// @pocketjs/framework/net/websocket — WebSocket Client over the +// `globalThis.ws` boundary (contracts/spec/ws.ts). `connect()` resolves after +// the RFC 6455 opening handshake; messages, control frames, drain and close +// arrive as handler calls inside the framework service pump, in the order the +// core delivered them. Handlers return void; a thrown handler exception is a +// guest execution error, not a NetworkError. + +import { NET_ERROR } from "../../../contracts/spec/net.ts"; +import { + WS_BLOB_KEY, + WS_CONTROL_PAYLOAD_MAX, + WS_FORBIDDEN_HEADERS, + WS_MAX_CONNECT_MS, + WS_MAX_MESSAGE_BYTES, + WS_MAX_RECEIVE_QUEUE_BYTES, + WS_MAX_RECEIVE_QUEUE_MESSAGES, + WS_MAX_SEND_QUEUE_BYTES, + WS_OPCODE, + WS_SEND_ACCEPTED, + WS_SEND_ACCEPTED_HIGH_WATER, + WS_SEND_BACKPRESSURE, + WS_SEND_CLOSED, + WS_SEND_INVALID, + WS_SPEC_MAJOR, + type WsConnectMeta, +} from "../../../contracts/spec/ws.ts"; +import { base64ToBytes, stringToUtf8 } from "../bytes.ts"; +import { snapshotData, type NetworkData } from "./body.ts"; +import { createBinding, integerOption, limitNumber, type EventRecord } from "./binding.ts"; +import { NetworkError, errorFromLastError, normalizeErrorCode } from "./errors.ts"; +import { URL } from "./url.ts"; +import type { TlsOptions } from "./types.ts"; + +const PROTOCOL = "websocket" as const; + +export type WebSocketReadyState = "connecting" | "open" | "closing" | "closed"; + +export type WebSocketSendResult = + | { status: "accepted"; needsDrain: boolean } + | { status: "backpressure" } + | { status: "closed" }; + +export interface WebSocketHandlers { + open?(socket: WebSocket): void; + message?(socket: WebSocket, data: string | Uint8Array): void; + drain?(socket: WebSocket): void; + ping?(socket: WebSocket, data: Uint8Array): void; + pong?(socket: WebSocket, data: Uint8Array): void; + close?(socket: WebSocket, code: number, reason: string): void; + error?(socket: WebSocket, error: NetworkError): void; +} + +export interface WebSocketConnectOptions { + headers?: Readonly>; + protocols?: readonly string[]; + tls?: TlsOptions; + timeouts?: { connectMs?: number; closeMs?: number }; + limits?: { + maxMessageBytes?: number; + receiveQueueBytes?: number; + receiveQueueMessages?: number; + sendQueueBytes?: number; + }; + socket: WebSocketHandlers; +} + +export interface WsOps { + connect(metaJson: string): number; + send(handle: number, opcode: number, payload: string | ArrayBuffer | null): number; + receiveInto(handle: number, into: ArrayBuffer, offset: number, length: number): number; + close(handle: number, code?: number, reason?: string): number; + terminate(handle: number): void; + bufferedAmount(handle: number): number; + poll(): string | undefined; + lastError(): string; + limits(): string; +} + +interface SocketState { + handle: number; + socket: WebSocketImpl; + handlers: WebSocketHandlers; + resolve: ((socket: WebSocket) => void) | null; + reject: ((error: NetworkError) => void) | null; +} + +const sockets = new Map(); + +const ws = createBinding({ + name: "ws", + protocol: PROTOCOL, + specMajor: WS_SPEC_MAJOR, + requiredOps: ["connect", "send", "receiveInto", "close", "terminate", "bufferedAmount", "poll", "lastError", "limits"], + dispatch: dispatchWsEvent, + onProtocolFailure(ops, error) { + for (const [handle, s] of [...sockets]) { + ops.terminate(handle); + terminate(s, error, 1006, "", false, true); + } + }, +}); + +const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +export class WebSocket { + readonly url: string; + protected _protocol = ""; + protected _readyState: WebSocketReadyState = "connecting"; + protected readonly handle: number; + protected readonly ops: WsOps; + protected readonly limitMessage: number; + + /** @internal */ + constructor(url: string, handle: number, ops: WsOps, limitMessage: number) { + this.url = url; + this.handle = handle; + this.ops = ops; + this.limitMessage = limitMessage; + } + + get protocol(): string { + return this._protocol; + } + + get readyState(): WebSocketReadyState { + return this._readyState; + } + + get bufferedAmount(): number { + if (this._readyState === "closed") return 0; + const n = this.ops.bufferedAmount(this.handle); + return n < 0 ? 0 : n; + } + + private sendFrame(opcode: number, data: NetworkData | undefined, operation: string): number { + if (this._readyState !== "open") return WS_SEND_CLOSED; + let payload: string | ArrayBuffer | null = null; + if (data !== undefined) { + if (typeof data === "string") { + if (opcode !== WS_OPCODE.text) { + const bytes = stringToUtf8(data); + payload = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + } else { + payload = data; + } + } else { + const bytes = snapshotData(data, operation, PROTOCOL); + payload = bytes.buffer as ArrayBuffer; + } + } + return this.ops.send(this.handle, opcode, payload); + } + + send(data: NetworkData): WebSocketSendResult { + const opcode = typeof data === "string" ? WS_OPCODE.text : WS_OPCODE.binary; + const rc = this.sendFrame(opcode, data, "send"); + if (rc === WS_SEND_ACCEPTED) return { status: "accepted", needsDrain: false }; + if (rc === WS_SEND_ACCEPTED_HIGH_WATER) return { status: "accepted", needsDrain: true }; + if (rc === WS_SEND_BACKPRESSURE) return { status: "backpressure" }; + if (rc === WS_SEND_INVALID) { + const size = typeof data === "string" ? stringToUtf8(data).length : (data as ArrayBufferView).byteLength ?? (data as ArrayBuffer).byteLength; + throw new NetworkError( + size > this.limitMessage ? NET_ERROR.messageTooLarge : NET_ERROR.invalidRequest, + size > this.limitMessage ? `message exceeds ${this.limitMessage} bytes` : "invalid message", + { operation: "send", protocol: PROTOCOL }, + ); + } + return { status: "closed" }; + } + + ping(data?: NetworkData): boolean { + return this.control(WS_OPCODE.ping, data, "ping"); + } + + pong(data?: NetworkData): boolean { + return this.control(WS_OPCODE.pong, data, "pong"); + } + + private control(opcode: number, data: NetworkData | undefined, operation: string): boolean { + const rc = this.sendFrame(opcode, data, operation); + if (rc === WS_SEND_INVALID) { + throw new NetworkError(NET_ERROR.invalidRequest, `${operation} payload exceeds ${WS_CONTROL_PAYLOAD_MAX} bytes`, { + operation, + protocol: PROTOCOL, + }); + } + return rc === WS_SEND_ACCEPTED || rc === WS_SEND_ACCEPTED_HIGH_WATER; + } + + close(code?: number, reason?: string): void { + if (this._readyState !== "open") return; + if (code !== undefined && (!Number.isInteger(code) || (code !== 1000 && (code < 3000 || code > 4999)))) { + throw new NetworkError(NET_ERROR.invalidRequest, "close code must be 1000 or 3000-4999", { + operation: "close", + protocol: PROTOCOL, + }); + } + if (reason !== undefined && stringToUtf8(reason).length > 123) { + throw new NetworkError(NET_ERROR.invalidRequest, "close reason exceeds 123 bytes", { + operation: "close", + protocol: PROTOCOL, + }); + } + const rc = this.ops.close(this.handle, code, reason); + if (rc === 0) this._readyState = "closing"; + } + + terminate(): void { + if (this._readyState === "closed") return; + this.ops.terminate(this.handle); + // The terminal event arrives next tick; commands stop being accepted now. + if (this._readyState === "open" || this._readyState === "connecting") this._readyState = "closing"; + } +} + +class WebSocketImpl extends WebSocket { + __setOpen(protocol: string): void { + this._protocol = protocol; + this._readyState = "open"; + } + __setClosed(): void { + this._readyState = "closed"; + } +} + +function terminate( + s: SocketState, + error: NetworkError | null, + code: number, + reason: string, + clean: boolean, + callClose: boolean, +): void { + if (!sockets.has(s.handle)) return; + sockets.delete(s.handle); + ws.release(); + if (s.reject) { + // Handshake never completed: only the connect Promise observes it. + const reject = s.reject; + s.reject = null; + s.resolve = null; + s.socket.__setClosed(); + reject(error ?? new NetworkError(NET_ERROR.closed, "socket closed before open", { operation: "connect", protocol: PROTOCOL })); + return; + } + s.socket.__setClosed(); + if (error && s.handlers.error) s.handlers.error(s.socket, error); + if (callClose && s.handlers.close) s.handlers.close(s.socket, code, reason); +} + +function dispatchWsEvent(event: EventRecord, ops: WsOps): void { + const handle = event.h; + if (typeof handle !== "number") return; + const s = sockets.get(handle); + if (!s) return; + switch (event.t) { + case "open": { + const protocol = typeof event.protocol === "string" ? event.protocol : ""; + s.socket.__setOpen(protocol); + const resolve = s.resolve; + s.resolve = null; + s.reject = null; + if (s.handlers.open) s.handlers.open(s.socket); + if (resolve) resolve(s.socket); + return; + } + case "message": { + if (!s.handlers.message) { + // Still dequeue binary payloads so the native queue drains. + if (event.kind === "binary" && typeof event.bytes === "number") { + const scratch = new ArrayBuffer(Math.max(0, event.bytes)); + ops.receiveInto(handle, scratch, 0, scratch.byteLength); + } + return; + } + if (event.kind === "text") { + s.handlers.message(s.socket, typeof event.text === "string" ? event.text : ""); + return; + } + if (event.kind === "binary" && typeof event.bytes === "number" && event.bytes >= 0) { + const bytes = new Uint8Array(event.bytes); + const got = ops.receiveInto(handle, bytes.buffer as ArrayBuffer, 0, bytes.length); + if (got !== bytes.length) { + ops.terminate(handle); + terminate( + s, + new NetworkError(NET_ERROR.protocol, "binary message transfer failed", { operation: "message", protocol: PROTOCOL }), + 1006, + "", + false, + true, + ); + return; + } + s.handlers.message(s.socket, bytes); + } + return; + } + case "ping": + case "pong": { + const handler = event.t === "ping" ? s.handlers.ping : s.handlers.pong; + if (!handler) return; + const payload = event.payload as Record | undefined; + const b64 = payload && typeof payload === "object" ? payload[WS_BLOB_KEY] : undefined; + handler(s.socket, typeof b64 === "string" ? base64ToBytes(b64) : new Uint8Array(0)); + return; + } + case "drain": + if (s.handlers.drain) s.handlers.drain(s.socket); + return; + case "error": { + const error = new NetworkError( + normalizeErrorCode(event.code), + typeof event.message === "string" && event.message ? event.message : String(event.code), + { + operation: s.reject ? "connect" : "socket", + protocol: PROTOCOL, + causeCode: typeof event.causeCode === "string" ? event.causeCode : undefined, + reasonCode: typeof event.status === "number" ? event.status : undefined, + }, + ); + if (s.reject) { + terminate(s, error, 1006, "", false, false); + return; + } + // After open, `close` follows; report the error now, close on arrival. + if (s.handlers.error) s.handlers.error(s.socket, error); + s.handlers = { ...s.handlers, error: undefined }; + return; + } + case "close": { + const code = typeof event.code === "number" ? event.code : 1005; + const reason = typeof event.reason === "string" ? event.reason : ""; + terminate(s, null, code, reason, event.clean === true, true); + return; + } + default: + return; + } +} + +/** Open a WebSocket. Resolves with the socket after the handshake; failures + * before `open` only reject the Promise. */ +export function connect(url: string | URL, options: WebSocketConnectOptions): Promise { + let ops: WsOps; + let handle: number; + let href: string; + let maxMessage = WS_MAX_MESSAGE_BYTES; + try { + if (!options || typeof options !== "object" || !options.socket || typeof options.socket !== "object") { + throw new NetworkError(NET_ERROR.invalidRequest, "connect() requires socket handlers", { operation: "connect", protocol: PROTOCOL }); + } + ops = ws.require("connect"); + const limits = ws.limits(); + let parsed: URL; + try { + parsed = url instanceof URL ? new URL(url.href) : new URL(String(url)); + } catch { + throw new NetworkError(NET_ERROR.invalidRequest, `invalid URL: ${String(url)}`, { operation: "connect", protocol: PROTOCOL }); + } + if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { + throw new NetworkError(NET_ERROR.invalidRequest, "url must be ws: or wss:", { operation: "connect", protocol: PROTOCOL }); + } + if (parsed.hash) { + throw new NetworkError(NET_ERROR.invalidRequest, "WebSocket URLs cannot carry a fragment", { operation: "connect", protocol: PROTOCOL }); + } + if (parsed.username || parsed.password) { + throw new NetworkError(NET_ERROR.invalidRequest, "URL must not carry credentials", { operation: "connect", protocol: PROTOCOL }); + } + const features = Array.isArray(limits.features) ? (limits.features as unknown[]) : []; + if (parsed.protocol === "wss:" && !features.includes("tls")) { + throw new NetworkError(NET_ERROR.unsupported, "this host does not provide network.websocket.client.tls", { + operation: "connect", + protocol: PROTOCOL, + }); + } + href = parsed.href; + const meta: WsConnectMeta = { url: href }; + if (options.protocols !== undefined) { + const seen = new Set(); + const list: string[] = []; + for (const p of options.protocols) { + if (typeof p !== "string" || !TOKEN.test(p) || seen.has(p)) { + throw new NetworkError(NET_ERROR.invalidRequest, `invalid subprotocol "${String(p)}"`, { operation: "connect", protocol: PROTOCOL }); + } + seen.add(p); + list.push(p); + } + if (list.length) meta.protocols = list; + } + if (options.headers !== undefined) { + const headers: Record = {}; + for (const rawName of Object.keys(options.headers)) { + const name = rawName.toLowerCase(); + const value = String(options.headers[rawName]).replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, ""); + if (!TOKEN.test(name) || /[\0\r\n]/.test(value)) { + throw new NetworkError(NET_ERROR.invalidRequest, `invalid header ${rawName}`, { operation: "connect", protocol: PROTOCOL }); + } + if ((WS_FORBIDDEN_HEADERS as readonly string[]).includes(name)) { + throw new NetworkError(NET_ERROR.invalidRequest, `header ${rawName} is owned by the WebSocket core`, { + operation: "connect", + protocol: PROTOCOL, + }); + } + headers[name] = value; + } + meta.headers = headers; + } + if (options.timeouts !== undefined) { + meta.timeouts = {}; + if (options.timeouts.connectMs !== undefined) { + meta.timeouts.connectMs = integerOption(options.timeouts.connectMs, "timeouts.connectMs", 1, WS_MAX_CONNECT_MS, "connect", PROTOCOL); + } + if (options.timeouts.closeMs !== undefined) { + meta.timeouts.closeMs = integerOption(options.timeouts.closeMs, "timeouts.closeMs", 1, WS_MAX_CONNECT_MS, "connect", PROTOCOL); + } + } + maxMessage = limitNumber(limits, "maxMessageBytes", WS_MAX_MESSAGE_BYTES); + if (options.limits !== undefined) { + meta.limits = {}; + const l = options.limits; + if (l.maxMessageBytes !== undefined) { + meta.limits.maxMessageBytes = integerOption(l.maxMessageBytes, "limits.maxMessageBytes", 1, maxMessage, "connect", PROTOCOL); + maxMessage = meta.limits.maxMessageBytes; + } + if (l.receiveQueueBytes !== undefined) { + meta.limits.receiveQueueBytes = integerOption(l.receiveQueueBytes, "limits.receiveQueueBytes", 1, limitNumber(limits, "maxReceiveQueueBytes", WS_MAX_RECEIVE_QUEUE_BYTES), "connect", PROTOCOL); + } + if (l.receiveQueueMessages !== undefined) { + meta.limits.receiveQueueMessages = integerOption(l.receiveQueueMessages, "limits.receiveQueueMessages", 1, limitNumber(limits, "maxReceiveQueueMessages", WS_MAX_RECEIVE_QUEUE_MESSAGES), "connect", PROTOCOL); + } + if (l.sendQueueBytes !== undefined) { + meta.limits.sendQueueBytes = integerOption(l.sendQueueBytes, "limits.sendQueueBytes", 1, limitNumber(limits, "maxSendQueueBytes", WS_MAX_SEND_QUEUE_BYTES), "connect", PROTOCOL); + } + } + if (options.tls !== undefined) { + const v = options.tls.verification; + if (v !== undefined && v !== "full" && v !== "development-insecure") { + throw new NetworkError(NET_ERROR.invalidRequest, "tls.verification must be full or development-insecure", { operation: "connect", protocol: PROTOCOL }); + } + for (const key of ["ca", "credential", "alpn", "minVersion", "maxVersion", "clientCertificate", "revocation", "serverName"] as const) { + if (options.tls[key] !== undefined) { + throw new NetworkError(NET_ERROR.unsupported, `tls.${key} is not supported by this host`, { operation: "connect", protocol: PROTOCOL }); + } + } + if (v !== undefined) meta.tls = { verification: v }; + } + handle = ops.connect(JSON.stringify(meta)); + if (!Number.isInteger(handle) || handle < 0) throw errorFromLastError(ops.lastError(), "connect", PROTOCOL); + } catch (error) { + return Promise.reject( + error instanceof NetworkError ? error : new NetworkError(NET_ERROR.invalidRequest, String(error), { operation: "connect", protocol: PROTOCOL }), + ); + } + return new Promise((resolve, reject) => { + const socket = new WebSocketImpl(href, handle, ops, maxMessage); + sockets.set(handle, { handle, socket, handlers: options.socket, resolve, reject }); + ws.retain(); + }); +} + +/** @internal test hooks */ +export const __websocket = { ws, sockets }; diff --git a/hosts/esp-idf/README.md b/hosts/esp-idf/README.md new file mode 100644 index 00000000..895a781e --- /dev/null +++ b/hosts/esp-idf/README.md @@ -0,0 +1,105 @@ +# PocketJS on ESP-IDF + +`hosts/esp-idf` is the ESP-IDF product-host half of the network stack: the +QuickJS-ng guest owner, the network modules over the portable core +(`engine/net`) and lwIP, and the board bring-up for the first two profiles. +The renderer side for ESP32-P4 lives in `hosts/esp32p4` (PPA backend). + +| Component | Role | +|---|---| +| `components/pocketjs_net_core` | `engine/net` (HTTP client, HTTP server, WebSocket client cores) plus the BSD-socket driver compiled against lwIP | +| `components/pocketjs_esp_host` | QuickJS-ng guest on one owner task, fixed-rate `frame()` ticks with `begin_tick` before each, `globalThis.net` / `ws` / `httpd` bindings, a network task that services sockets under the runtime lock | +| `components/pocketjs_net_esptls` | ESP-TLS TlsProvider (ESP-TLS + the IDF certificate bundle) for `https:`/`wss:` | +| `components/pocketjs_board` | Wi-Fi station + DHCP + SNTP for the AtomS3R (native Wi-Fi) and the Tab5 (ESP32-P4 rev 1.3 + ESP32-C6 over SDIO via esp_hosted 2.12.12 / esp_wifi_remote 1.6.4, WLAN rail on the PI4IOE5V6408 @0x44 bit 0) | +| `examples/net-smoke` | Headless smoke app (`app.ts`) and the firmware template used by the hardware gate | + +Toolchain: ESP-IDF v6.0.2 (`7101770dc6db`), QuickJS-ng 0.14.0 from the +component registry, Bun for the guest bundle. + +## Execution model + +The guest runs only inside `frame()` on the owner task. Before every +frame the owner +task calls `pnet_runtime_begin_tick()`, which freezes the visible event set; +inside `frame()` the framework service pump calls each module's `poll` once +and copies bodies out with `readInto`; Promise reactions run in the job +drain right after `frame()`. The network task never touches QuickJS: it +runs `pnet_runtime_service()` under the same mutex the bindings take, waits +in `select()` with the core's next deadline, and is woken through a +loopback UDP socket whenever the guest issued an op. DNS lookups run on the +driver's own `pnet-dns` task, never on the network task. + +Tick k is scheduled at `t0 + k / tick_hz` on the microsecond timer, so a +60 Hz guest runs at **60.00 Hz** (an integer 16 ms FreeRTOS period would be +62.5 Hz and drift the virtual clock from the wall clock by 4 %). A frame that +overruns makes the next ticks late, and each late tick still gets its one +turn (Law 3); only a host more than 0.5 s behind drops ticks, counted in +`stats.frames_skipped`. Shutdown is a single unwind: `stop()` asks both +tasks to exit, bounds a guest turn in progress through the QuickJS interrupt +handler, waits for both exit flags and only then frees; a failed start +releases everything it created. + +## Build Plan inputs + +The firmware authors no network policy. `examples/net-smoke/pocket.json` is +a **format 3** manifest (`permissions.network`); `tools/esp-idf.ts`, run by +`main/CMakeLists.txt`, merges the rig's endpoints (Kconfig: workstation +peer, peer board, serve port, TLS host), resolves the plan against the +board's private profile (`tools/esp-idf-profile.ts`: `atoms3r-dev` / +`tab5-dev`, advertising the HTTP client (+TLS), HTTP server and WebSocket +client (+TLS) roles) and writes into the build directory: + +| File | Use | +|---|---| +| `network-policy.json` | the canonical `ResolvedNetworkPolicy` (plan truth, covered by `planHash`), embedded and passed to `pnet_runtime_create` verbatim | +| `host-inputs.h` | `POCKETJS_PLAN_HASH`, target, resolved features (`POCKETJS_FEATURE_*`) — the roles `main.c` mounts | +| `app.js` | the guest bundle built against the same plan | +| `plan.json`, `pocket.resolved.json` | the plan and the merged manifest, for inspection | + +`wall_clock_trusted` is a board state, not a date check: the board layer +latches it when an SNTP sync completes (`pocketjs_board_sync_time`, and every +re-sync through the SNTP notification) or when the product asserts it; until +then every verifying TLS connection fails closed with `tls_clock_untrusted`. + +## Hardware smoke (plaintext) + +`examples/net-smoke` against `bun tools/net-peer.ts` on the workstation and +board-to-board, both boards serving on :8080: + +| Board | Result | +|---|---| +| AtomS3R (ESP32-S3-PICO-1-N8R8) | 20/20 plaintext + 6 TLS = 26/26: GET/POST/JSON/chunked/404, redirect follow+manual, 200 KB body through an 8 KiB queue at ~350 KiB/s, aggregate limit, headers timeout, permission_denied, connect refused, WebSocket echo (text/binary/ping/pong/close), peer board GET/POST/JSON/stream/404, continuous pings | +| Tab5 (ESP32-P4 rev 1.3 + C6) | 26/26, same suite, ~370 KiB/s | + +The TLS block (enable `CONFIG_SMOKE_ENABLE_TLS=y`) needs internet and +an SNTP sync: HTTPS/1.1 to a public host with a valid chain from the IDF +certificate bundle, plus badssl.com's expired / wrong-host / self-signed / +untrusted-root endpoints, all failing closed. Hostname mismatch reports +`tls_hostname_mismatch`; the other certificate faults report +`tls_certificate_invalid` or `tls_handshake_failed` (ESP-TLS exposes the +Mbed TLS verify flags inconsistently on the async path) — the precise +per-fault codes are proven in the desktop OpenSSL conformance suite. + +Steady state after 60 s: guest heap ≈363 KB (high water ≈686 KB during +bundle evaluation), core heap ≈4 KB, one socket per live connection, no +growth. An earlier 12-minute board-to-board soak (43,200 frames, 330 HTTP +round trips each way, both boards serving the other) ended with zero +failures and the same heap figures — it ran on the 16 ms (62.5 Hz) host, so +its "12 minutes" was the frame count ÷ 60 and about 11.5 min of wall clock; +the exact-cadence host reports 1800 frames per 30.0 s of uptime in its +periodic stats. Bundle evaluation of the 116 KB smoke IIFE: ≈780 ms on the +S3, ≈350 ms on the P4. ESP-TLS handshake steps run under the runtime lock, so +a handshake stalls the guest's `begin_tick` for up to a couple of seconds; +the overload guard shows this as `frames_skipped` during the TLS block. + +## Tab5 pitfalls + +- Rev 1.3 silicon needs `CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y` and + `CONFIG_ESP32P4_REV_MIN_100=y`; the default v3-only image does not boot. +- The C6 sits behind the SDIO1 preset (`CONFIG_ESP32P4_TAB5_C6_BOARD=y`: + CLK 12, CMD 13, D0–D3 11/10/9/8, reset GPIO 15) and needs + `CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH=y` — GPIO15 drives EN through + 1 kΩ; the active-low default leaves the C6 held in reset (SDIO CMD5 + timeout). +- Power the WLAN rail before `esp_wifi_init()` (`pocketjs_board_prepare_wifi`). +- `CONFIG_FREERTOS_HZ=1000` keeps the hosted transport free of bus jitter warnings. diff --git a/hosts/esp-idf/components/pocketjs_board/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_board/CMakeLists.txt new file mode 100644 index 00000000..351c025a --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_board/CMakeLists.txt @@ -0,0 +1,10 @@ +set(reqs esp_wifi esp_netif esp_event nvs_flash esp_driver_i2c) +if(CONFIG_IDF_TARGET_ESP32P4) + list(APPEND reqs esp_wifi_remote esp_hosted) +endif() +idf_component_register( + SRCS "src/board_wifi.c" "src/board_prepare.c" + INCLUDE_DIRS "include" + REQUIRES ${reqs} + PRIV_REQUIRES log freertos) +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/hosts/esp-idf/components/pocketjs_board/idf_component.yml b/hosts/esp-idf/components/pocketjs_board/idf_component.yml new file mode 100644 index 00000000..72128fae --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_board/idf_component.yml @@ -0,0 +1,13 @@ +description: Wi-Fi station bring-up for the AtomS3R (ESP32-S3) and Tab5 (ESP32-P4 + C6) PocketJS profiles. +version: "0.1.0" +dependencies: + idf: + version: ">=5.4" + espressif/esp_wifi_remote: + version: "1.6.4" + rules: + - if: "target in [esp32p4]" + espressif/esp_hosted: + version: "2.12.12" + rules: + - if: "target in [esp32p4]" diff --git a/hosts/esp-idf/components/pocketjs_board/include/pocketjs/board.h b/hosts/esp-idf/components/pocketjs_board/include/pocketjs/board.h new file mode 100644 index 00000000..142f5bba --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_board/include/pocketjs/board.h @@ -0,0 +1,65 @@ +/* Board bring-up for the first two ESP-IDF PocketJS profiles: + * + * AtomS3R ESP32-S3-PICO-1-N8R8, native Wi-Fi. + * Tab5 ESP32-P4 rev 1.3 + on-board ESP32-C6 over SDIO (esp_hosted + + * esp_wifi_remote); the C6 power rail sits behind the PI4IOE5V6408 + * IO expander at 0x44 (bit 0, WLAN_PWR_EN) on the internal I2C bus + * (SDA GPIO31, SCL GPIO32) and must be on before esp_wifi_init(). + * + * The public network modules never see any of this: link driver, BSP and + * credentials are product/host concerns. This component gives the smoke + * firmware one call that brings the + * station interface up with DHCP and returns the address. + */ +#ifndef POCKETJS_BOARD_H +#define POCKETJS_BOARD_H + +#include +#include +#include + +#include "esp_err.h" +#include "esp_netif_ip_addr.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct pocketjs_board_wifi_config { + const char *ssid; + const char *password; + /** Wait for DHCP this long (0 = 30 s). */ + uint32_t timeout_ms; +} pocketjs_board_wifi_config; + +/** Board-specific power/transport preparation (Tab5: enable the C6 rail and + * start the hosted transport). No-op on AtomS3R. Idempotent. */ +esp_err_t pocketjs_board_prepare_wifi(void); + +/** NVS + netif + event loop + STA + DHCP; returns once an IPv4 address is + * bound (written to *ip) or fails after the timeout. Reconnects on drops. */ +esp_err_t pocketjs_board_wifi_connect(const pocketjs_board_wifi_config *cfg, esp_ip4_addr_t *ip); + +/** Current station IPv4 address as text ("0.0.0.0" when down). */ +void pocketjs_board_ip_text(char *out, size_t cap); + +/** Sync the wall clock over SNTP. Returns ESP_OK once the time is set (the + * clock is then trusted, see below), ESP_ERR_TIMEOUT otherwise. */ +esp_err_t pocketjs_board_sync_time(uint32_t timeout_ms); + +/** Wall-clock trust state for TLS certificate validation — a state the board + * layer maintains, not a guess from the date: true after an SNTP sync + * completed (pocketjs_board_sync_time, or any later SNTP re-sync reported + * through the sync notification), or after the product asserted it with + * pocketjs_board_set_clock_trusted (a validated battery-backed RTC, + * provisioning). Wire it into pocketjs_esp_host_config.wall_clock_trusted. */ +bool pocketjs_board_clock_trusted(void); +void pocketjs_board_set_clock_trusted(bool trusted); +/** Adapter with the host's callback signature (ignores `user`). */ +bool pocketjs_board_clock_trusted_cb(void *user); + +#ifdef __cplusplus +} +#endif + +#endif /* POCKETJS_BOARD_H */ diff --git a/hosts/esp-idf/components/pocketjs_board/src/board_prepare.c b/hosts/esp-idf/components/pocketjs_board/src/board_prepare.c new file mode 100644 index 00000000..1e09d7ef --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_board/src/board_prepare.c @@ -0,0 +1,112 @@ +/* Board-specific preparation before esp_wifi_init(). */ +#include "pocketjs/board.h" + +#include "esp_log.h" +#include "sdkconfig.h" + +static const char *TAG = "board"; + +#if CONFIG_IDF_TARGET_ESP32P4 +/* Tab5: the ESP32-C6 module is powered through the second PI4IOE5V6408 IO + * expander (0x44, bit 0 = WLAN_PWR_EN) on the internal I2C bus, and reached + * over SDIO through esp_hosted. The expander register values are the ones + * M5Stack's Tab5 demo programs; only bit 0 matters here. GPIO15 (P4) drives + * the C6 EN pin through 1 kΩ and is left to esp_hosted's reset sequence, + * which the sdkconfig must configure active-high. */ +#include "driver/i2c_master.h" +#include "esp_hosted.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#define TAB5_I2C_PORT 0 +#define TAB5_I2C_SDA 31 +#define TAB5_I2C_SCL 32 +#define TAB5_PI4IOE2_ADDR 0x44 +#define PI4IO_REG_CHIP_RESET 0x01 +#define PI4IO_REG_IO_DIR 0x03 +#define PI4IO_REG_OUT_SET 0x05 +#define PI4IO_REG_OUT_H_IM 0x07 +#define PI4IO_REG_PULL_EN 0x0B +#define PI4IO_REG_PULL_SEL 0x0D + +static bool s_prepared; + +static esp_err_t pi4io_write(i2c_master_dev_handle_t dev, uint8_t reg, uint8_t value) { + uint8_t buf[2] = {reg, value}; + return i2c_master_transmit(dev, buf, sizeof buf, 100); +} + +static esp_err_t tab5_power_wlan(void) { + i2c_master_bus_config_t bus_cfg = { + .clk_source = I2C_CLK_SRC_DEFAULT, + .i2c_port = TAB5_I2C_PORT, + .sda_io_num = TAB5_I2C_SDA, + .scl_io_num = TAB5_I2C_SCL, + .glitch_ignore_cnt = 7, + .flags.enable_internal_pullup = true, + }; + i2c_master_bus_handle_t bus; + esp_err_t err = i2c_new_master_bus(&bus_cfg, &bus); + if (err != ESP_OK) { + /* The bus may already exist (a display BSP created it). */ + err = i2c_master_get_bus_handle(TAB5_I2C_PORT, &bus); + if (err != ESP_OK) return err; + } + i2c_device_config_t dev_cfg = { + .dev_addr_length = I2C_ADDR_BIT_LEN_7, + .device_address = TAB5_PI4IOE2_ADDR, + .scl_speed_hz = 400000, + }; + i2c_master_dev_handle_t dev; + err = i2c_master_bus_add_device(bus, &dev_cfg, &dev); + if (err != ESP_OK) return err; + /* Same programming as the M5Stack Tab5 demo for PI4IOE2. */ + err = pi4io_write(dev, PI4IO_REG_IO_DIR, 0xB9); + if (err == ESP_OK) err = pi4io_write(dev, PI4IO_REG_OUT_SET, 0x09); + if (err == ESP_OK) err = pi4io_write(dev, PI4IO_REG_OUT_H_IM, 0x06); + if (err == ESP_OK) err = pi4io_write(dev, PI4IO_REG_PULL_EN, 0xF9); + if (err == ESP_OK) err = pi4io_write(dev, PI4IO_REG_PULL_SEL, 0xB9); + if (err == ESP_OK) { + /* WLAN_PWR_EN = bit 0 high (read-modify-write like bsp_set_wifi_power_enable). */ + uint8_t reg = PI4IO_REG_OUT_SET; + uint8_t cur = 0; + if (i2c_master_transmit_receive(dev, ®, 1, &cur, 1, 100) == ESP_OK) { + err = pi4io_write(dev, PI4IO_REG_OUT_SET, (uint8_t)(cur | 0x01)); + } else { + err = pi4io_write(dev, PI4IO_REG_OUT_SET, 0x09); + } + } + i2c_master_bus_rm_device(dev); + if (err != ESP_OK) return err; + vTaskDelay(pdMS_TO_TICKS(200)); /* rail settle before the C6 reset sequence */ + return ESP_OK; +} + +esp_err_t pocketjs_board_prepare_wifi(void) { + if (s_prepared) return ESP_OK; + ESP_LOGI(TAG, "Tab5: enabling the WLAN power rail"); + esp_err_t err = tab5_power_wlan(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Tab5: WLAN_PWR_EN failed: %s", esp_err_to_name(err)); + return err; + } + ESP_LOGI(TAG, "Tab5: starting the esp_hosted SDIO transport to the C6"); + int rc = esp_hosted_init(); + if (rc != 0) { + ESP_LOGE(TAG, "esp_hosted_init: %d", rc); + return ESP_FAIL; + } + rc = esp_hosted_connect_to_slave(); + if (rc != 0) { + ESP_LOGE(TAG, "esp_hosted_connect_to_slave: %d", rc); + return ESP_FAIL; + } + s_prepared = true; + return ESP_OK; +} +#else +esp_err_t pocketjs_board_prepare_wifi(void) { + ESP_LOGI(TAG, "native Wi-Fi: no board preparation needed"); + return ESP_OK; +} +#endif diff --git a/hosts/esp-idf/components/pocketjs_board/src/board_wifi.c b/hosts/esp-idf/components/pocketjs_board/src/board_wifi.c new file mode 100644 index 00000000..1eb03547 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_board/src/board_wifi.c @@ -0,0 +1,133 @@ +/* Wi-Fi station bring-up shared by the AtomS3R and Tab5 profiles. */ +#include "pocketjs/board.h" + +#include + +#include "esp_event.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "esp_wifi.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include "freertos/task.h" +#include "esp_netif_sntp.h" +#include "esp_sntp.h" +#include "nvs_flash.h" +#include "sdkconfig.h" +#include + +static const char *TAG = "board"; + +static EventGroupHandle_t s_events; +static esp_ip4_addr_t s_ip; +static int s_retries; +static bool s_started; +#define GOT_IP_BIT BIT0 +#define FAILED_BIT BIT1 + +static void on_wifi(void *arg, esp_event_base_t base, int32_t id, void *data) { + (void)arg; + (void)data; + if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) { + esp_wifi_connect(); + } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) { + memset(&s_ip, 0, sizeof s_ip); + s_retries++; + ESP_LOGW(TAG, "station disconnected (attempt %d), reconnecting", s_retries); + vTaskDelay(pdMS_TO_TICKS(500)); + esp_wifi_connect(); + } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t *ev = data; + s_ip = ev->ip_info.ip; + ESP_LOGI(TAG, "station got ip " IPSTR, IP2STR(&s_ip)); + xEventGroupSetBits(s_events, GOT_IP_BIT); + } +} + +esp_err_t pocketjs_board_wifi_connect(const pocketjs_board_wifi_config *cfg, esp_ip4_addr_t *ip) { + if (!cfg || !cfg->ssid) return ESP_ERR_INVALID_ARG; + if (!s_started) { + esp_err_t err = nvs_flash_init(); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + err = nvs_flash_init(); + } + ESP_ERROR_CHECK(err); + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + ESP_ERROR_CHECK(pocketjs_board_prepare_wifi()); + esp_netif_create_default_wifi_sta(); + wifi_init_config_t init = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&init)); + s_events = xEventGroupCreate(); + ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, on_wifi, NULL)); + ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, on_wifi, NULL)); + wifi_config_t wc; + memset(&wc, 0, sizeof wc); + strncpy((char *)wc.sta.ssid, cfg->ssid, sizeof wc.sta.ssid - 1); + if (cfg->password) strncpy((char *)wc.sta.password, cfg->password, sizeof wc.sta.password - 1); + wc.sta.threshold.authmode = cfg->password && cfg->password[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN; + wc.sta.pmf_cfg.capable = true; + wc.sta.pmf_cfg.required = false; + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wc)); + ESP_ERROR_CHECK(esp_wifi_start()); + s_started = true; + ESP_LOGI(TAG, "connecting to \"%s\"", cfg->ssid); + } + uint32_t timeout = cfg->timeout_ms ? cfg->timeout_ms : 30000; + EventBits_t bits = xEventGroupWaitBits(s_events, GOT_IP_BIT, pdFALSE, pdFALSE, pdMS_TO_TICKS(timeout)); + if (!(bits & GOT_IP_BIT)) { + ESP_LOGE(TAG, "no address after %u ms", (unsigned)timeout); + return ESP_ERR_TIMEOUT; + } + if (ip) *ip = s_ip; + return ESP_OK; +} + +/* Wall-clock trust: latched by a completed SNTP sync (first sync and every + * later re-sync, through the notification callback) or by the product. */ +static volatile bool s_clock_trusted; + +static void on_time_synced(struct timeval *tv) { + (void)tv; + s_clock_trusted = true; +} + +bool pocketjs_board_clock_trusted(void) { + return s_clock_trusted; +} + +void pocketjs_board_set_clock_trusted(bool trusted) { + s_clock_trusted = trusted; +} + +bool pocketjs_board_clock_trusted_cb(void *user) { + (void)user; + return s_clock_trusted; +} + +esp_err_t pocketjs_board_sync_time(uint32_t timeout_ms) { + static bool started; + if (!started) { + esp_sntp_config_t cfg = ESP_NETIF_SNTP_DEFAULT_CONFIG("pool.ntp.org"); + cfg.sync_cb = on_time_synced; + ESP_ERROR_CHECK(esp_netif_sntp_init(&cfg)); + started = true; + } + if (esp_netif_sntp_sync_wait(pdMS_TO_TICKS(timeout_ms ? timeout_ms : 15000)) != ESP_OK) { + ESP_LOGW(TAG, "SNTP did not sync in time; the wall clock stays untrusted (TLS fails closed)"); + return ESP_ERR_TIMEOUT; + } + s_clock_trusted = true; + time_t now = time(NULL); + struct tm tm; + localtime_r(&now, &tm); + ESP_LOGI(TAG, "time synced: %04d-%02d-%02d %02d:%02d:%02d UTC (wall clock trusted)", tm.tm_year + 1900, tm.tm_mon + 1, + tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec); + return ESP_OK; +} + +void pocketjs_board_ip_text(char *out, size_t cap) { + snprintf(out, cap, IPSTR, IP2STR(&s_ip)); +} diff --git a/hosts/esp-idf/components/pocketjs_esp_host/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_esp_host/CMakeLists.txt new file mode 100644 index 00000000..8d38fbae --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_host/CMakeLists.txt @@ -0,0 +1,7 @@ +idf_component_register( + SRCS "src/host.c" "src/net_binding.c" + INCLUDE_DIRS "include" + REQUIRES pocketjs_net_core pocketjs_net_esptls quickjs-ng + PRIV_REQUIRES esp_timer heap freertos log) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/hosts/esp-idf/components/pocketjs_esp_host/idf_component.yml b/hosts/esp-idf/components/pocketjs_esp_host/idf_component.yml new file mode 100644 index 00000000..0ccf4356 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_host/idf_component.yml @@ -0,0 +1,7 @@ +description: PocketJS QuickJS-ng guest owner with the network modules for ESP-IDF. +version: "0.1.0" +dependencies: + idf: + version: ">=5.4" + espressif/quickjs-ng: + version: "0.14.0" diff --git a/hosts/esp-idf/components/pocketjs_esp_host/include/pocketjs/esp_host.h b/hosts/esp-idf/components/pocketjs_esp_host/include/pocketjs/esp_host.h new file mode 100644 index 00000000..f16eb2db --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_host/include/pocketjs/esp_host.h @@ -0,0 +1,141 @@ +/* PocketJS ESP-IDF host: a QuickJS-ng guest owned by one FreeRTOS task, + * ticked at a fixed rate through `globalThis.frame(...)`, with the network + * modules (`globalThis.net` / `ws` / `httpd`) mounted over the portable core + * (engine/net) and a network task driving lwIP sockets. + * + * Execution model: every guest turn is one `frame()` call followed by the + * job drain, on the owner task only. Before each frame the owner task runs + * `pnet_runtime_begin_tick()` under the runtime lock; the network task + * services sockets under the same lock and never touches QuickJS. + * + * Cadence: tick k is scheduled at t0 + k / tick_hz (absolute microsecond + * deadlines), so the host's real tick rate equals the realm's `__simHz` + * exactly (60 Hz is 60 Hz, not the 62.5 Hz a 16 ms integer period gives), + * and every tick gets its one guest turn (Law 3): after a frame overruns, + * the late ticks run back to back until the schedule is caught up. Only a + * host that falls more than half a second behind drops the excess ticks and + * resyncs (stats.frames_skipped) — an overload guard, not the normal path. + * + * Ownership: the host owns the guest task, the network task, the runtime, + * the driver and the TLS provider. Startup unwinds everything it created on + * any failure; stop() releases a resource only after the task that uses it + * has definitely exited. A guest turn in progress while stopping is bounded + * by the QuickJS interrupt handler (stop_turn_budget_ms), so stop() never + * frees under a running turn. + * + * Build Plan truth: the network policy is the application's + * ResolvedNetworkPolicy (contracts/spec/network-policy.ts), handed over as + * the canonical JSON the plan resolver emits (HostBuildInputs.network. + * policyJson); a product host embeds that projection, it never writes a + * policy of its own. Which modules to mount follows the plan's features. + */ +#ifndef POCKETJS_ESP_HOST_H +#define POCKETJS_ESP_HOST_H + +#include +#include +#include + +#include "esp_err.h" +#include "pocketjs/net/runtime.h" +#include "quickjs.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct pocketjs_esp_host pocketjs_esp_host_t; + +typedef struct pocketjs_esp_host_config { + /** Guest ticks per second (the realm's `__simHz`). Default 60. */ + uint32_t tick_hz; + /** QuickJS memory limit in bytes (0 = 4 MiB). */ + size_t guest_memory_limit; + /** QuickJS stack limit in bytes (0 = 3/4 of the guest task stack). */ + size_t guest_stack_limit; + /** Allocate the QuickJS heap from PSRAM (recommended when present). */ + bool guest_in_psram; + /** Owner task stack bytes (default 32 KiB) and priority/core. */ + uint32_t guest_task_stack; + int guest_task_priority; + int guest_task_core; + /** Network task stack bytes (default 12 KiB) and priority/core. */ + uint32_t net_task_stack; + int net_task_priority; + int net_task_core; + /** While stopping, a guest turn (frame + job drain) longer than this is + * interrupted (QuickJS interrupt handler) so shutdown is bounded. + * Default 50 ms; 0 = default. */ + uint32_t stop_turn_budget_ms; + /** The application's network policy: the canonical ResolvedNetworkPolicy + * JSON from its Build Plan (version 1). NULL mounts no network module. + * Never a host-authored string — see the header comment. */ + const char *network_policy_json; + /** The plan's checksum (ResolvedBuildPlan.planHash), logged at boot and + * reported in stats so a running device names the plan it runs. Optional. */ + const char *plan_hash; + /** Enable TLS (https:/wss:) through the ESP-TLS provider with the IDF + * certificate bundle. */ + bool network_tls; + /** Whether the wall clock is trusted for certificate validity: true only + * after the platform established it (SNTP sync completed, a validated + * persisted RTC, explicit provisioning) — "the clock has a plausible + * value" is not trust. Required for TLS: while it returns false (or when + * NULL) every verifying connection fails closed with tls_clock_untrusted + * before any I/O. The board layer provides it (pocketjs_board_clock_trusted). */ + bool (*wall_clock_trusted)(void *user); + /** Which roles this host admits: `globalThis.net` is always mounted with + * a policy; `ws` and `httpd` only when set (default true for both). A + * product host mounts exactly the roles its plan's features turned on. */ + bool mount_websocket_client; + bool mount_http_server; + /** Core limits; NULL = spec ceilings tightened by the host defaults. */ + const pnet_runtime_config *network_config; + /** Sockets the driver may track (default 12). */ + int network_max_sockets; + /** Called on the owner task after the namespaces are mounted and before + * the bundle is evaluated (install host globals). */ + void (*before_eval)(JSContext *ctx, void *user); + /** Called on the owner task after every frame + job drain (diagnostics). */ + void (*after_frame)(uint32_t frame, void *user); + void *user; +} pocketjs_esp_host_config; + +/** Fill in the defaults described above. */ +void pocketjs_esp_host_config_defaults(pocketjs_esp_host_config *cfg); + +/** Create the runtime and both tasks, evaluate `bundle` (an IIFE that + * installs `globalThis.frame`), and start ticking. `bundle` must stay valid + * for the host's lifetime (embedded flash text is fine). On failure nothing + * is left allocated and *out_host is untouched. */ +esp_err_t pocketjs_esp_host_start(const pocketjs_esp_host_config *cfg, const char *bundle, size_t bundle_len, + pocketjs_esp_host_t **out_host); + +/** Quiesce the network, run a bounded number of wind-down frames, wait for + * both tasks to exit, then release the guest, the runtime, the driver and + * the TLS provider. Blocks the caller until the tasks are gone; if a task + * does not exit within the (generous) deadline the host is leaked with an + * error log rather than freed under a running task. */ +void pocketjs_esp_host_stop(pocketjs_esp_host_t *host); + +typedef struct pocketjs_esp_host_stats { + uint32_t frames; /* guest turns run */ + uint32_t frames_skipped; /* ticks dropped by the overload guard (> 0.5 s behind) */ + uint32_t jobs; + uint32_t frame_errors; + size_t guest_heap_bytes; /* QuickJS reported */ + size_t guest_heap_high_water; + size_t net_heap_bytes; /* core accounting */ + int net_sockets; + uint32_t frame_max_us; + bool guest_boot_failed; /* the bundle did not evaluate; the host idles */ + const char *plan_hash; /* cfg.plan_hash or "" */ +} pocketjs_esp_host_stats_t; + +void pocketjs_esp_host_stats(pocketjs_esp_host_t *host, pocketjs_esp_host_stats_t *out); + +#ifdef __cplusplus +} +#endif + +#endif /* POCKETJS_ESP_HOST_H */ diff --git a/hosts/esp-idf/components/pocketjs_esp_host/src/host.c b/hosts/esp-idf/components/pocketjs_esp_host/src/host.c new file mode 100644 index 00000000..277a1f43 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_host/src/host.c @@ -0,0 +1,588 @@ +/* PocketJS ESP-IDF host: guest owner task, network task, lifecycle. */ +#include "host_internal.h" + +#include +#include +#include + +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "esp_random.h" +#include "esp_timer.h" + +static const char *TAG = "pocketjs"; + +/* ------------------------------------------------------------------------ */ +/* Config */ +/* ------------------------------------------------------------------------ */ + +void pocketjs_esp_host_config_defaults(pocketjs_esp_host_config *cfg) { + memset(cfg, 0, sizeof *cfg); + cfg->tick_hz = 60; + cfg->guest_memory_limit = 4 * 1024 * 1024; + cfg->guest_stack_limit = 0; + cfg->guest_in_psram = true; + cfg->guest_task_stack = 32 * 1024; + cfg->guest_task_priority = 5; + cfg->guest_task_core = tskNO_AFFINITY; + cfg->net_task_stack = 12 * 1024; + cfg->net_task_priority = 8; + cfg->net_task_core = tskNO_AFFINITY; + cfg->stop_turn_budget_ms = 50; + cfg->network_policy_json = NULL; + cfg->plan_hash = NULL; + cfg->network_tls = false; + cfg->wall_clock_trusted = NULL; + cfg->mount_websocket_client = true; + cfg->mount_http_server = true; + cfg->network_config = NULL; + cfg->network_max_sockets = 12; +} + +/* ------------------------------------------------------------------------ */ +/* QuickJS allocator: PSRAM when requested, byte accounting */ +/* ------------------------------------------------------------------------ */ + +static uint32_t heap_caps_for(pocketjs_esp_host_t *host) { + return host->cfg.guest_in_psram ? (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT) : MALLOC_CAP_8BIT; +} + +static void account(pocketjs_esp_host_t *host, void *ptr, bool add) { + if (!ptr) return; + size_t size = heap_caps_get_allocated_size(ptr); + if (add) { + host->guest_heap += size; + if (host->guest_heap > host->guest_heap_high_water) host->guest_heap_high_water = host->guest_heap; + } else { + host->guest_heap = host->guest_heap >= size ? host->guest_heap - size : 0; + } +} + +static void *guest_calloc(void *opaque, size_t count, size_t size) { + pocketjs_esp_host_t *host = opaque; + void *p = heap_caps_calloc(count, size, heap_caps_for(host)); + account(host, p, true); + return p; +} + +static void *guest_malloc(void *opaque, size_t size) { + pocketjs_esp_host_t *host = opaque; + void *p = heap_caps_malloc(size, heap_caps_for(host)); + account(host, p, true); + return p; +} + +static void guest_free(void *opaque, void *ptr) { + pocketjs_esp_host_t *host = opaque; + account(host, ptr, false); + heap_caps_free(ptr); +} + +static void *guest_realloc(void *opaque, void *ptr, size_t size) { + pocketjs_esp_host_t *host = opaque; + if (size == 0) { + guest_free(opaque, ptr); + return NULL; + } + account(host, ptr, false); + void *p = heap_caps_realloc(ptr, size, heap_caps_for(host)); + if (!p) { + account(host, ptr, true); + return NULL; + } + account(host, p, true); + return p; +} + +static size_t guest_usable_size(const void *ptr) { + return ptr ? heap_caps_get_allocated_size((void *)ptr) : 0; +} + +static const JSMallocFunctions GUEST_ALLOC = { + .js_calloc = guest_calloc, + .js_malloc = guest_malloc, + .js_free = guest_free, + .js_realloc = guest_realloc, + .js_malloc_usable_size = guest_usable_size, +}; + +/* ------------------------------------------------------------------------ */ +/* Network core platform */ +/* ------------------------------------------------------------------------ */ + +static uint64_t plat_now_ms(void *ctx) { + (void)ctx; + return (uint64_t)(esp_timer_get_time() / 1000); +} + +static void *plat_alloc(void *ctx, size_t size) { + (void)ctx; + /* Prefer PSRAM for payload buffers; fall back to internal RAM. */ + void *p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!p) p = heap_caps_malloc(size, MALLOC_CAP_8BIT); + return p; +} + +static void plat_free(void *ctx, void *ptr, size_t size) { + (void)ctx; + (void)size; + heap_caps_free(ptr); +} + +static void plat_random(void *ctx, uint8_t *out, size_t len) { + (void)ctx; + esp_fill_random(out, len); +} + +static bool plat_clock_trusted(void *ctx) { + /* Trust is a platform state the board/product layer maintains (SNTP sync + * completed, validated RTC, provisioning) — never "the date looks + * plausible". No callback = never trusted = TLS fails closed. */ + pocketjs_esp_host_t *host = ctx; + return host->cfg.wall_clock_trusted ? host->cfg.wall_clock_trusted(host->cfg.user) : false; +} + +static void plat_log(void *ctx, pnet_log_level level, const char *msg) { + (void)ctx; + switch (level) { + case PNET_LOG_ERROR: ESP_LOGE("pnet", "%s", msg); break; + case PNET_LOG_WARN: ESP_LOGW("pnet", "%s", msg); break; + case PNET_LOG_INFO: ESP_LOGI("pnet", "%s", msg); break; + default: ESP_LOGD("pnet", "%s", msg); break; + } +} + +/* ------------------------------------------------------------------------ */ +/* Guest console */ +/* ------------------------------------------------------------------------ */ + +static JSValue console_write(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv, int magic) { + (void)this_val; + char line[512]; + size_t used = 0; + for (int i = 0; i < argc && used + 2 < sizeof line; i++) { + const char *s = JS_ToCString(ctx, argv[i]); + if (!s) continue; + int n = snprintf(line + used, sizeof line - used, "%s%s", i ? " " : "", s); + JS_FreeCString(ctx, s); + if (n > 0) used += (size_t)n < sizeof line - used ? (size_t)n : sizeof line - used - 1; + } + line[used] = 0; + switch (magic) { + case 0: ESP_LOGE("guest", "%s", line); break; + case 1: ESP_LOGW("guest", "%s", line); break; + default: ESP_LOGI("guest", "%s", line); break; + } + return JS_UNDEFINED; +} + +static void install_console(JSContext *ctx) { + JSValue global = JS_GetGlobalObject(ctx); + JSValue console = JS_NewObject(ctx); + JS_SetPropertyStr(ctx, console, "error", JS_NewCFunctionMagic(ctx, console_write, "error", 1, JS_CFUNC_generic_magic, 0)); + JS_SetPropertyStr(ctx, console, "warn", JS_NewCFunctionMagic(ctx, console_write, "warn", 1, JS_CFUNC_generic_magic, 1)); + JS_SetPropertyStr(ctx, console, "log", JS_NewCFunctionMagic(ctx, console_write, "log", 1, JS_CFUNC_generic_magic, 2)); + JS_SetPropertyStr(ctx, console, "info", JS_NewCFunctionMagic(ctx, console_write, "info", 1, JS_CFUNC_generic_magic, 2)); + JS_SetPropertyStr(ctx, console, "debug", JS_NewCFunctionMagic(ctx, console_write, "debug", 1, JS_CFUNC_generic_magic, 3)); + JS_SetPropertyStr(ctx, global, "console", console); + JS_FreeValue(ctx, global); +} + +static void log_exception(JSContext *ctx, const char *phase) { + JSValue exc = JS_GetException(ctx); + const char *msg = JS_ToCString(ctx, exc); + ESP_LOGE("guest", "%s: %s", phase, msg ? msg : "(exception)"); + if (msg) JS_FreeCString(ctx, msg); + if (JS_IsObject(exc)) { + JSValue stack = JS_GetPropertyStr(ctx, exc, "stack"); + const char *st = JS_ToCString(ctx, stack); + if (st && *st) ESP_LOGE("guest", "%s", st); + if (st) JS_FreeCString(ctx, st); + JS_FreeValue(ctx, stack); + } + JS_FreeValue(ctx, exc); +} + +/* ------------------------------------------------------------------------ */ +/* Task exit protocol */ +/* ------------------------------------------------------------------------ */ + +/* The last thing a task does with `host`: publish its done flag, then wake + * whoever waits in stop()/unwind. The waiter handle is read BEFORE the flag + * is published (after the flag the owner may free `host`). */ +static void task_exit(pocketjs_esp_host_t *host, volatile bool *done_flag) { + TaskHandle_t waiter = __atomic_load_n(&host->stop_waiter, __ATOMIC_SEQ_CST); + __atomic_store_n(done_flag, true, __ATOMIC_SEQ_CST); + if (waiter) xTaskNotifyGive(waiter); + vTaskDelete(NULL); +} + +/* Block until `flag` is set: notification-driven with a short poll fallback + * (a task that finished before the waiter registered itself never notifies). + * Returns false if the deadline passed. */ +static bool wait_flag(volatile bool *flag, uint32_t deadline_ms) { + int64_t end = esp_timer_get_time() + (int64_t)deadline_ms * 1000; + while (!__atomic_load_n(flag, __ATOMIC_SEQ_CST)) { + if (esp_timer_get_time() >= end) return false; + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(10)); + } + return true; +} + +/* ------------------------------------------------------------------------ */ +/* Network task */ +/* ------------------------------------------------------------------------ */ + +static void net_task(void *arg) { + pocketjs_esp_host_t *host = arg; + while (!host->stopping) { + pocketjs_host_net_lock(host); + pnet_posix_driver_dispatch(host->driver, host->net); + pnet_runtime_service(host->net); + uint64_t deadline = pnet_runtime_next_deadline_ms(host->net); + bool more = pnet_runtime_has_pending_output(host->net); + pocketjs_host_net_unlock(host); + int timeout = 250; + if (deadline) { + uint64_t now = plat_now_ms(NULL); + timeout = deadline > now ? (int)(deadline - now) : 0; + if (timeout > 250) timeout = 250; + } + if (more) timeout = 0; + if (host->stopping) break; + pnet_posix_driver_wait(host->driver, timeout); + } + task_exit(host, &host->net_done); +} + +/* ------------------------------------------------------------------------ */ +/* Guest task */ +/* ------------------------------------------------------------------------ */ + +/* QuickJS interrupt handler: while stopping, a turn that outlives the budget + * is aborted (QuickJS raises an uncatchable InternalError at the next + * check), so shutdown is bounded whatever the bundle does. */ +static int guest_interrupt(JSRuntime *rt, void *opaque) { + (void)rt; + pocketjs_esp_host_t *host = opaque; + if (!host->stopping) return 0; + uint32_t budget = host->cfg.stop_turn_budget_ms ? host->cfg.stop_turn_budget_ms : 50; + return (esp_timer_get_time() - host->turn_started_us) > (int64_t)budget * 1000; +} + +static void drain_jobs(pocketjs_esp_host_t *host) { + JSContext *ctx; + for (int i = 0; i < 4096; i++) { + int rc = JS_ExecutePendingJob(host->rt, &ctx); + if (rc == 0) break; + host->stats.jobs++; + if (rc < 0) log_exception(ctx ? ctx : host->ctx, "job"); + } +} + +static bool guest_boot(pocketjs_esp_host_t *host) { + host->rt = JS_NewRuntime2(&GUEST_ALLOC, host); + if (!host->rt) { + ESP_LOGE(TAG, "JS_NewRuntime2 failed"); + return false; + } + JS_SetMemoryLimit(host->rt, host->cfg.guest_memory_limit ? host->cfg.guest_memory_limit : 4 * 1024 * 1024); + size_t stack_limit = host->cfg.guest_stack_limit ? host->cfg.guest_stack_limit : (host->cfg.guest_task_stack / 4) * 3; + JS_SetMaxStackSize(host->rt, stack_limit); + JS_UpdateStackTop(host->rt); + JS_SetInterruptHandler(host->rt, guest_interrupt, host); + host->ctx = JS_NewContext(host->rt); + if (!host->ctx) { + ESP_LOGE(TAG, "JS_NewContext failed"); + return false; + } + install_console(host->ctx); + JSValue global = JS_GetGlobalObject(host->ctx); + JS_SetPropertyStr(host->ctx, global, "__simHz", JS_NewUint32(host->ctx, host->cfg.tick_hz)); + JS_SetPropertyStr(host->ctx, global, "frame", JS_UNDEFINED); + JS_FreeValue(host->ctx, global); + if (host->net) pocketjs_host_mount_network(host); + if (host->cfg.before_eval) host->cfg.before_eval(host->ctx, host->cfg.user); + int64_t t0 = esp_timer_get_time(); + host->turn_started_us = t0; + JSValue result = JS_Eval(host->ctx, host->bundle, host->bundle_len, "app.js", JS_EVAL_TYPE_GLOBAL); + if (JS_IsException(result)) { + log_exception(host->ctx, "eval"); + JS_FreeValue(host->ctx, result); + return false; + } + JS_FreeValue(host->ctx, result); + drain_jobs(host); + ESP_LOGI(TAG, "bundle evaluated in %lld us, guest heap %u bytes%s%s", (long long)(esp_timer_get_time() - t0), + (unsigned)host->guest_heap, host->cfg.plan_hash ? ", plan " : "", host->cfg.plan_hash ? host->cfg.plan_hash : ""); + global = JS_GetGlobalObject(host->ctx); + host->frame_fn = JS_GetPropertyStr(host->ctx, global, "frame"); + JS_FreeValue(host->ctx, global); + if (!JS_IsFunction(host->ctx, host->frame_fn)) { + ESP_LOGW(TAG, "bundle installed no globalThis.frame; the host will tick without a guest turn"); + } + return true; +} + +static void guest_frame(pocketjs_esp_host_t *host, uint32_t frame) { + if (host->net) { + pocketjs_host_net_lock(host); + pnet_runtime_begin_tick(host->net); + pocketjs_host_net_unlock(host); + } + int64_t t0 = esp_timer_get_time(); + host->turn_started_us = t0; + if (JS_IsFunction(host->ctx, host->frame_fn)) { + JSValue args[2] = {JS_NewInt32(host->ctx, 0), JS_NewInt32(host->ctx, 0x8080)}; + JSValue global = JS_GetGlobalObject(host->ctx); + JSValue r = JS_Call(host->ctx, host->frame_fn, global, 2, args); + JS_FreeValue(host->ctx, global); + if (JS_IsException(r)) { + host->stats.frame_errors++; + log_exception(host->ctx, "frame"); + } + JS_FreeValue(host->ctx, r); + } + drain_jobs(host); + uint32_t us = (uint32_t)(esp_timer_get_time() - t0); + if (us > host->stats.frame_max_us) host->stats.frame_max_us = us; + if (host->net && host->net_dirty) { + host->net_dirty = false; + pnet_posix_driver_wake(host->driver); + } + host->stats.frames = frame; + if (host->cfg.after_frame) host->cfg.after_frame(frame, host->cfg.user); +} + +/* Sleep until the absolute deadline (µs on the esp_timer clock). The tick + * granularity rounds UP, so a frame starts within one RTOS tick after its + * deadline and never before it; because deadlines are absolute (t0 + k/hz) + * the error does not accumulate and the cadence is exactly tick_hz. */ +static void sleep_until(int64_t deadline_us) { + int64_t wait = deadline_us - esp_timer_get_time(); + if (wait <= 0) return; + const int64_t tick_us = (int64_t)portTICK_PERIOD_MS * 1000; + TickType_t ticks = (TickType_t)((wait + tick_us - 1) / tick_us); + if (ticks == 0) ticks = 1; + vTaskDelay(ticks); +} + +static void guest_task(void *arg) { + pocketjs_esp_host_t *host = arg; + if (!guest_boot(host)) { + host->boot_failed = true; + host->stats.guest_boot_failed = true; + host->stopping = true; + } + const uint32_t hz = host->cfg.tick_hz ? host->cfg.tick_hz : 60; + /* Law 3: one guest turn per tick. A frame that overruns its period makes + * the following ticks late; they still get their turn (run back to back, + * no sleep) until the schedule is caught up. Only a host that falls more + * than max_backlog ticks (0.5 s) behind drops the excess and resyncs — + * the overload guard against a spiral, counted in stats.frames_skipped. */ + const uint64_t max_backlog = hz / 2 ? hz / 2 : 1; + const int64_t t0 = esp_timer_get_time(); + uint64_t k = 0; /* tick index on the absolute schedule */ + uint32_t frame = 0; /* guest turns run */ + while (!host->stopping) { + int64_t deadline = t0 + (int64_t)((k * 1000000ULL) / hz); + int64_t now = esp_timer_get_time(); + if (now > deadline) { + uint64_t behind = (uint64_t)(now - deadline) * hz / 1000000ULL; /* whole ticks late */ + if (behind > max_backlog) { + k += behind; + host->stats.frames_skipped += (uint32_t)behind; + deadline = t0 + (int64_t)((k * 1000000ULL) / hz); + } + } + sleep_until(deadline); /* returns at once when late: the catch-up turn */ + if (host->stopping) break; + guest_frame(host, ++frame); + k++; + } + /* Wind-down: bounded frames so cancellations reach the guest. Each turn is + * bounded by the interrupt handler (stopping is set). */ + if (host->rt && host->ctx) { + if (host->net) { + pocketjs_host_net_lock(host); + pnet_runtime_quiesce(host->net); + pocketjs_host_net_unlock(host); + } + for (int i = 0; i < 4; i++) { + guest_frame(host, ++frame); + vTaskDelay(pdMS_TO_TICKS(1000 / hz ? 1000 / hz : 1)); + } + } + if (host->ctx) { + JS_FreeValue(host->ctx, host->frame_fn); + host->frame_fn = JS_UNDEFINED; + JS_FreeContext(host->ctx); + host->ctx = NULL; + } + if (host->rt) { + JS_FreeRuntime(host->rt); + host->rt = NULL; + } + task_exit(host, &host->guest_done); +} + +/* ------------------------------------------------------------------------ */ +/* Lifecycle */ +/* ------------------------------------------------------------------------ */ + +/* Release everything the host owns. Tasks that were started must already + * have exited (their done flags set): this is the single teardown path for + * a failed start and for stop(). */ +static void host_release(pocketjs_esp_host_t *host) { + if (host->net) pnet_runtime_destroy(host->net); + if (host->tls_provider) pnet_esp_tls_destroy(host->tls_provider); + if (host->driver) pnet_posix_driver_destroy(host->driver); + if (host->net_lock) vSemaphoreDelete(host->net_lock); + free(host); +} + +/* Ask running tasks to stop and wait for them; true when every started task + * has exited and the host may be released. */ +static bool host_join(pocketjs_esp_host_t *host, uint32_t deadline_ms) { + __atomic_store_n(&host->stop_waiter, xTaskGetCurrentTaskHandle(), __ATOMIC_SEQ_CST); + __atomic_store_n(&host->stopping, true, __ATOMIC_SEQ_CST); + if (host->driver) pnet_posix_driver_wake(host->driver); + bool ok = true; + if (host->guest_task && !wait_flag(&host->guest_done, deadline_ms)) ok = false; + if (host->net_task) { + /* Keep waking the network task: its select may have started before + * stopping was visible to it. */ + int64_t end = esp_timer_get_time() + (int64_t)deadline_ms * 1000; + while (!__atomic_load_n(&host->net_done, __ATOMIC_SEQ_CST)) { + if (esp_timer_get_time() >= end) { + ok = false; + break; + } + pnet_posix_driver_wake(host->driver); + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(10)); + } + } + /* Let the idle task reclaim the deleted tasks' stacks before we free + * memory they were allocated next to. */ + if (ok) vTaskDelay(pdMS_TO_TICKS(2)); + return ok; +} + +esp_err_t pocketjs_esp_host_start(const pocketjs_esp_host_config *cfg, const char *bundle, size_t bundle_len, + pocketjs_esp_host_t **out_host) { + if (!cfg || !bundle || !out_host) return ESP_ERR_INVALID_ARG; + pocketjs_esp_host_t *host = calloc(1, sizeof *host); + if (!host) return ESP_ERR_NO_MEM; + host->cfg = *cfg; + host->bundle = bundle; + host->bundle_len = bundle_len; + host->frame_fn = JS_UNDEFINED; + host->stats.plan_hash = cfg->plan_hash ? cfg->plan_hash : ""; + esp_err_t err = ESP_OK; + if (cfg->network_policy_json) { + if (cfg->network_tls && !cfg->wall_clock_trusted) { + ESP_LOGW(TAG, "TLS enabled without a wall_clock_trusted callback: verifying connections fail closed " + "(tls_clock_untrusted) until the board layer provides one"); + } + host->net_lock = xSemaphoreCreateMutex(); + if (!host->net_lock) { err = ESP_ERR_NO_MEM; goto fail; } + host->driver = pnet_posix_driver_create(cfg->network_max_sockets > 0 ? cfg->network_max_sockets : 12); + if (!host->driver) { err = ESP_ERR_NO_MEM; goto fail; } + pnet_platform plat = {host, plat_now_ms, plat_alloc, plat_free, plat_random, plat_log, plat_clock_trusted}; + pnet_runtime_config ncfg; + if (cfg->network_config) ncfg = *cfg->network_config; + else { + pnet_runtime_config_defaults(&ncfg); + /* Host tightening for an MCU profile: queues stay in PSRAM but the + * event/aggregate budgets are modest. */ + ncfg.http_max_inflight = 4; + ncfg.http_default_queue_bytes = 16 * 1024; + ncfg.http_max_queue_bytes = 64 * 1024; + ncfg.http_default_aggregate_bytes = 256 * 1024; + ncfg.http_max_aggregate_bytes = 1024 * 1024; + ncfg.http_max_tick_bytes = 64 * 1024; + ncfg.ws_max_sockets = 4; + ncfg.ws_max_message_bytes = 64 * 1024; + ncfg.ws_max_receive_queue_bytes = 128 * 1024; + ncfg.ws_max_send_queue_bytes = 128 * 1024; + ncfg.ws_send_high_water_bytes = 32 * 1024; + ncfg.ws_send_low_water_bytes = 8 * 1024; + ncfg.ws_max_tick_bytes = 64 * 1024; + ncfg.httpd_max_connections = 8; + ncfg.httpd_max_inflight = 4; + ncfg.httpd_default_request_queue_bytes = 16 * 1024; + ncfg.httpd_max_request_queue_bytes = 64 * 1024; + ncfg.httpd_max_send_queue_bytes = 64 * 1024; + ncfg.httpd_send_high_water_bytes = 32 * 1024; + ncfg.httpd_send_low_water_bytes = 8 * 1024; + ncfg.httpd_max_tick_bytes = 64 * 1024; + ncfg.max_heap_bytes = 1024 * 1024; + ncfg.io_chunk_bytes = 1460; + } + if (cfg->network_tls) { + host->tls_provider = pnet_esp_tls_create(pnet_posix_driver_ops(), host->driver); + if (!host->tls_provider) { + ESP_LOGE(TAG, "ESP-TLS provider creation failed"); + err = ESP_ERR_NO_MEM; + goto fail; + } + host->net = pnet_runtime_create_tls(&plat, pnet_posix_driver_ops(), host->driver, pnet_esp_tls_ops(), + pnet_esp_tls_ctx(host->tls_provider), &ncfg, cfg->network_policy_json); + } else { + host->net = pnet_runtime_create(&plat, pnet_posix_driver_ops(), host->driver, &ncfg, cfg->network_policy_json); + } + if (!host->net) { + ESP_LOGE(TAG, "network runtime creation failed (is network_policy_json the plan's canonical policy?)"); + err = ESP_ERR_INVALID_ARG; + goto fail; + } + if (xTaskCreatePinnedToCore(net_task, "pocketjs-net", cfg->net_task_stack, host, cfg->net_task_priority, + &host->net_task, cfg->net_task_core) != pdPASS) { + ESP_LOGE(TAG, "network task creation failed"); + host->net_task = NULL; + err = ESP_ERR_NO_MEM; + goto fail; + } + } + if (xTaskCreatePinnedToCore(guest_task, "pocketjs-guest", cfg->guest_task_stack, host, cfg->guest_task_priority, + &host->guest_task, cfg->guest_task_core) != pdPASS) { + ESP_LOGE(TAG, "guest task creation failed"); + host->guest_task = NULL; + err = ESP_ERR_NO_MEM; + goto fail; + } + *out_host = host; + return ESP_OK; + +fail: + /* One unwind path: stop whatever task already runs, then release. */ + if (host_join(host, 5000)) host_release(host); + else ESP_LOGE(TAG, "start unwind: a task did not exit; leaking the host rather than freeing under it"); + return err; +} + +void pocketjs_esp_host_stop(pocketjs_esp_host_t *host) { + if (!host) return; + /* Bound: wind-down is 4 frames + the turn in progress, each capped by the + * stop budget; the network task exits within one select timeout. 10 s is + * far beyond that and exists only so a wedged task is detected. */ + if (host_join(host, 10000)) { + host_release(host); + } else { + ESP_LOGE(TAG, "stop: a task did not exit in time; leaking the host rather than freeing under a running task"); + } +} + +void pocketjs_esp_host_stats(pocketjs_esp_host_t *host, pocketjs_esp_host_stats_t *out) { + *out = host->stats; + out->guest_heap_bytes = host->guest_heap; + out->guest_heap_high_water = host->guest_heap_high_water; + out->guest_boot_failed = host->boot_failed; + out->plan_hash = host->cfg.plan_hash ? host->cfg.plan_hash : ""; + if (host->net) { + pocketjs_host_net_lock(host); + out->net_heap_bytes = pnet_runtime_heap_bytes(host->net); + out->net_sockets = pnet_posix_driver_socket_count(host->driver); + pocketjs_host_net_unlock(host); + } +} diff --git a/hosts/esp-idf/components/pocketjs_esp_host/src/host_internal.h b/hosts/esp-idf/components/pocketjs_esp_host/src/host_internal.h new file mode 100644 index 00000000..9614fe53 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_host/src/host_internal.h @@ -0,0 +1,61 @@ +/* Internal shape of the ESP-IDF host. */ +#ifndef POCKETJS_ESP_HOST_INTERNAL_H +#define POCKETJS_ESP_HOST_INTERNAL_H + +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "pnet_posix_driver.h" +#include "pocketjs/esp_host.h" +#include "pocketjs/net/esp_tls_provider.h" + +struct pocketjs_esp_host { + pocketjs_esp_host_config cfg; + const char *bundle; + size_t bundle_len; + /* guest */ + JSRuntime *rt; + JSContext *ctx; + JSValue frame_fn; + size_t guest_heap; + size_t guest_heap_high_water; + TaskHandle_t guest_task; + /* the current guest turn: set before JS_Call/JS_Eval/job drain, read by the + * interrupt handler to bound turns while stopping */ + volatile int64_t turn_started_us; + /* network */ + pnet_runtime *net; + pnet_posix_driver *driver; + pnet_esp_tls *tls_provider; + SemaphoreHandle_t net_lock; + TaskHandle_t net_task; + volatile bool net_dirty; /* an op ran during this frame: wake the network task */ + /* lifecycle — one state machine: + * stopping asked to stop (by stop() or by a failed boot); both task + * loops exit at their next check + * guest_done / net_done + * set by the task as the LAST thing it does with `host`, + * right before it notifies the waiter and deletes itself; + * the owner frees nothing a task uses until its flag is set + * stop_waiter the task blocked in stop()/unwind, notified by exiting tasks + */ + volatile bool stopping; + volatile bool guest_done; + volatile bool net_done; + volatile bool boot_failed; + TaskHandle_t stop_waiter; + pocketjs_esp_host_stats_t stats; +}; + +/* net_binding.c */ +void pocketjs_host_mount_network(pocketjs_esp_host_t *host); + +/* host.c helpers used by the binding */ +static inline void pocketjs_host_net_lock(pocketjs_esp_host_t *host) { + xSemaphoreTake(host->net_lock, portMAX_DELAY); +} +static inline void pocketjs_host_net_unlock(pocketjs_esp_host_t *host) { + xSemaphoreGive(host->net_lock); +} + +#endif diff --git a/hosts/esp-idf/components/pocketjs_esp_host/src/net_binding.c b/hosts/esp-idf/components/pocketjs_esp_host/src/net_binding.c new file mode 100644 index 00000000..945f3ffd --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_host/src/net_binding.c @@ -0,0 +1,417 @@ +/* `globalThis.net` / `ws` / `httpd` on QuickJS-ng: each op takes the runtime + * lock, forwards to the portable core and marshals the result. Buffers are + * borrowed for the duration of the synchronous call only; the core copies + * everything it keeps. */ +#include "host_internal.h" + +#include + +/* The host pointer travels in the function's magic-less opaque: QuickJS-ng + * C functions get no closure, so keep the active host in the runtime opaque. */ +static pocketjs_esp_host_t *host_of(JSContext *ctx) { + return JS_GetRuntimeOpaque(JS_GetRuntime(ctx)); +} + +/* --- argument helpers ------------------------------------------------------- */ + +static bool arg_i32(JSContext *ctx, JSValueConst v, int32_t *out) { + return JS_ToInt32(ctx, out, v) == 0; +} + +/** Borrow an ArrayBuffer (or null/undefined → NULL with len 0). Returns false + * and throws on any other type. */ +static bool arg_buffer(JSContext *ctx, JSValueConst v, uint8_t **ptr, size_t *len) { + if (JS_IsNull(v) || JS_IsUndefined(v)) { + *ptr = NULL; + *len = 0; + return true; + } + size_t size = 0; + uint8_t *p = JS_GetArrayBuffer(ctx, &size, v); + /* NULL means "not an ArrayBuffer" or "detached": QuickJS left the TypeError + * pending. Zero-length buffers still yield a non-NULL data pointer. */ + if (!p) return false; + *ptr = p; + *len = size; + return true; +} + +/** Slice into a borrowed ArrayBuffer at [offset, offset+length). */ +static bool arg_window(JSContext *ctx, JSValueConst buf, JSValueConst off, JSValueConst len, uint8_t **ptr, size_t *out_len) { + size_t size = 0; + uint8_t *p = JS_GetArrayBuffer(ctx, &size, buf); + if (!p) { + JS_FreeValue(ctx, JS_GetException(ctx)); /* replaced by the caller's RangeError */ + return false; + } + int32_t o, l; + if (!arg_i32(ctx, off, &o) || !arg_i32(ctx, len, &l) || o < 0 || l < 0) return false; + if ((size_t)o > size || (size_t)l > size - (size_t)o) return false; + *ptr = p + o; + *out_len = (size_t)l; + return true; +} + +#define WITH_HOST(name) \ + pocketjs_esp_host_t *host = host_of(ctx); \ + (void)this_val; \ + if (!host || !host->net) return JS_ThrowTypeError(ctx, name ": network runtime unavailable") + +#define LOCKED(expr) \ + do { \ + pocketjs_host_net_lock(host); \ + expr; \ + pocketjs_host_net_unlock(host); \ + host->net_dirty = true; \ + } while (0) + +/* --- net ---------------------------------------------------------------------- */ + +static JSValue net_start(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("net.start"); + if (argc < 1) return JS_ThrowTypeError(ctx, "net.start(meta, body)"); + const char *meta = JS_ToCString(ctx, argv[0]); + if (!meta) return JS_EXCEPTION; + uint8_t *body = NULL; + size_t body_len = 0; + if (argc >= 2 && !arg_buffer(ctx, argv[1], &body, &body_len)) { + JS_FreeCString(ctx, meta); + return JS_EXCEPTION; + } + int handle; + LOCKED(handle = pnet_http_start(host->net, meta, body, body_len)); + JS_FreeCString(ctx, meta); + return JS_NewInt32(ctx, handle); +} + +static JSValue net_cancel(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("net.cancel"); + int32_t handle; + if (argc < 1 || !arg_i32(ctx, argv[0], &handle)) return JS_UNDEFINED; + LOCKED(pnet_http_cancel(host->net, handle)); + return JS_UNDEFINED; +} + +static JSValue net_poll(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("net.poll"); + (void)argc; + (void)argv; + size_t len = 0; + pocketjs_host_net_lock(host); + /* Two-phase poll: the batch leaves the core only once the guest holds its + * copy. If QuickJS cannot allocate the string the events stay visible and + * are rendered again next tick instead of vanishing. */ + const char *batch = pnet_http_poll_render(host->net, &len); + JSValue out = batch ? JS_NewStringLen(ctx, batch, len) : JS_UNDEFINED; + if (batch && !JS_IsException(out)) pnet_http_poll_consume(host->net); + pocketjs_host_net_unlock(host); + return out; +} + +static JSValue net_last_error(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("net.lastError"); + (void)argc; + (void)argv; + pocketjs_host_net_lock(host); + JSValue out = JS_NewString(ctx, pnet_http_last_error(host->net)); + pocketjs_host_net_unlock(host); + return out; +} + +static JSValue net_read_into(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("net.readInto"); + int32_t handle; + uint8_t *ptr; + size_t len; + if (argc < 4 || !arg_i32(ctx, argv[0], &handle) || !arg_window(ctx, argv[1], argv[2], argv[3], &ptr, &len)) { + return JS_ThrowRangeError(ctx, "net.readInto(handle, buffer, offset, length)"); + } + int n; + LOCKED(n = pnet_http_read_into(host->net, handle, ptr, len)); + return JS_NewInt32(ctx, n); +} + +static JSValue net_limits(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("net.limits"); + (void)argc; + (void)argv; + return JS_NewString(ctx, pnet_http_limits(host->net)); +} + +/* --- ws ----------------------------------------------------------------------- */ + +static JSValue ws_connect(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("ws.connect"); + if (argc < 1) return JS_ThrowTypeError(ctx, "ws.connect(meta)"); + const char *meta = JS_ToCString(ctx, argv[0]); + if (!meta) return JS_EXCEPTION; + int handle; + LOCKED(handle = pnet_ws_connect(host->net, meta)); + JS_FreeCString(ctx, meta); + return JS_NewInt32(ctx, handle); +} + +static JSValue ws_send(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("ws.send"); + int32_t handle, opcode; + if (argc < 3 || !arg_i32(ctx, argv[0], &handle) || !arg_i32(ctx, argv[1], &opcode)) { + return JS_ThrowTypeError(ctx, "ws.send(handle, opcode, payload)"); + } + const uint8_t *payload = NULL; + size_t len = 0; + const char *text = NULL; + if (JS_IsString(argv[2])) { + text = JS_ToCStringLen(ctx, &len, argv[2]); + if (!text) return JS_EXCEPTION; + payload = (const uint8_t *)text; + } else { + uint8_t *p; + if (!arg_buffer(ctx, argv[2], &p, &len)) return JS_EXCEPTION; + payload = p; + } + int rc; + LOCKED(rc = pnet_ws_send(host->net, handle, opcode, payload, len)); + if (text) JS_FreeCString(ctx, text); + return JS_NewInt32(ctx, rc); +} + +static JSValue ws_receive_into(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("ws.receiveInto"); + int32_t handle; + uint8_t *ptr; + size_t len; + if (argc < 4 || !arg_i32(ctx, argv[0], &handle) || !arg_window(ctx, argv[1], argv[2], argv[3], &ptr, &len)) { + return JS_ThrowRangeError(ctx, "ws.receiveInto(handle, buffer, offset, length)"); + } + int n; + LOCKED(n = pnet_ws_receive_into(host->net, handle, ptr, len)); + return JS_NewInt32(ctx, n); +} + +static JSValue ws_close(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("ws.close"); + int32_t handle, code = 0; + if (argc < 1 || !arg_i32(ctx, argv[0], &handle)) return JS_NewInt32(ctx, -1); + if (argc >= 2 && !JS_IsUndefined(argv[1]) && !arg_i32(ctx, argv[1], &code)) return JS_NewInt32(ctx, -3); + const char *reason = NULL; + size_t reason_len = 0; + if (argc >= 3 && !JS_IsUndefined(argv[2])) { + reason = JS_ToCStringLen(ctx, &reason_len, argv[2]); + if (!reason) return JS_EXCEPTION; + } + int rc; + LOCKED(rc = pnet_ws_close(host->net, handle, code, reason, reason_len)); + if (reason) JS_FreeCString(ctx, reason); + return JS_NewInt32(ctx, rc); +} + +static JSValue ws_terminate(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("ws.terminate"); + int32_t handle; + if (argc < 1 || !arg_i32(ctx, argv[0], &handle)) return JS_UNDEFINED; + LOCKED(pnet_ws_terminate(host->net, handle)); + return JS_UNDEFINED; +} + +static JSValue ws_buffered_amount(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("ws.bufferedAmount"); + int32_t handle; + if (argc < 1 || !arg_i32(ctx, argv[0], &handle)) return JS_NewInt32(ctx, -1); + int n; + pocketjs_host_net_lock(host); + n = pnet_ws_buffered_amount(host->net, handle); + pocketjs_host_net_unlock(host); + return JS_NewInt32(ctx, n); +} + +static JSValue ws_poll(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("ws.poll"); + (void)argc; + (void)argv; + size_t len = 0; + pocketjs_host_net_lock(host); + /* Two-phase poll: the batch leaves the core only once the guest holds its + * copy. If QuickJS cannot allocate the string the events stay visible and + * are rendered again next tick instead of vanishing. */ + const char *batch = pnet_ws_poll_render(host->net, &len); + JSValue out = batch ? JS_NewStringLen(ctx, batch, len) : JS_UNDEFINED; + if (batch && !JS_IsException(out)) pnet_ws_poll_consume(host->net); + pocketjs_host_net_unlock(host); + return out; +} + +static JSValue ws_last_error(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("ws.lastError"); + (void)argc; + (void)argv; + pocketjs_host_net_lock(host); + JSValue out = JS_NewString(ctx, pnet_ws_last_error(host->net)); + pocketjs_host_net_unlock(host); + return out; +} + +static JSValue ws_limits(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("ws.limits"); + (void)argc; + (void)argv; + return JS_NewString(ctx, pnet_ws_limits(host->net)); +} + +/* --- httpd -------------------------------------------------------------------- */ + +static JSValue httpd_listen(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.listen"); + if (argc < 1) return JS_ThrowTypeError(ctx, "httpd.listen(meta)"); + const char *meta = JS_ToCString(ctx, argv[0]); + if (!meta) return JS_EXCEPTION; + int handle; + LOCKED(handle = pnet_httpd_listen(host->net, meta)); + JS_FreeCString(ctx, meta); + return JS_NewInt32(ctx, handle); +} + +static JSValue httpd_stop(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.stop"); + int32_t handle, timeout = 0; + if (argc < 1 || !arg_i32(ctx, argv[0], &handle)) return JS_NewInt32(ctx, -1); + bool graceful = argc >= 2 ? JS_ToBool(ctx, argv[1]) : true; + if (argc >= 3) arg_i32(ctx, argv[2], &timeout); + int rc; + LOCKED(rc = pnet_httpd_stop(host->net, handle, graceful, timeout > 0 ? (uint32_t)timeout : 0)); + return JS_NewInt32(ctx, rc); +} + +static JSValue httpd_respond(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.respond"); + int32_t req; + if (argc < 2 || !arg_i32(ctx, argv[0], &req)) return JS_ThrowTypeError(ctx, "httpd.respond(req, meta, body)"); + const char *meta = JS_ToCString(ctx, argv[1]); + if (!meta) return JS_EXCEPTION; + uint8_t *body = NULL; + size_t body_len = 0; + if (argc >= 3 && !arg_buffer(ctx, argv[2], &body, &body_len)) { + JS_FreeCString(ctx, meta); + return JS_EXCEPTION; + } + int rc; + LOCKED(rc = pnet_httpd_respond(host->net, req, meta, body, body_len)); + JS_FreeCString(ctx, meta); + return JS_NewInt32(ctx, rc); +} + +static JSValue httpd_write(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.write"); + int32_t req; + uint8_t *chunk; + size_t len; + if (argc < 2 || !arg_i32(ctx, argv[0], &req) || !arg_buffer(ctx, argv[1], &chunk, &len)) { + return JS_ThrowTypeError(ctx, "httpd.write(req, chunk)"); + } + int rc; + LOCKED(rc = pnet_httpd_write(host->net, req, chunk, len)); + return JS_NewInt32(ctx, rc); +} + +static JSValue httpd_end_body(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.endBody"); + int32_t req; + if (argc < 1 || !arg_i32(ctx, argv[0], &req)) return JS_NewInt32(ctx, -1); + int rc; + LOCKED(rc = pnet_httpd_end_body(host->net, req)); + return JS_NewInt32(ctx, rc); +} + +static JSValue httpd_read_into(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.readInto"); + int32_t req; + uint8_t *ptr; + size_t len; + if (argc < 4 || !arg_i32(ctx, argv[0], &req) || !arg_window(ctx, argv[1], argv[2], argv[3], &ptr, &len)) { + return JS_ThrowRangeError(ctx, "httpd.readInto(req, buffer, offset, length)"); + } + int n; + LOCKED(n = pnet_httpd_read_into(host->net, req, ptr, len)); + return JS_NewInt32(ctx, n); +} + +static JSValue httpd_abort(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.abort"); + int32_t req; + if (argc < 1 || !arg_i32(ctx, argv[0], &req)) return JS_UNDEFINED; + LOCKED(pnet_httpd_abort(host->net, req)); + return JS_UNDEFINED; +} + +static JSValue httpd_poll(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.poll"); + (void)argc; + (void)argv; + size_t len = 0; + pocketjs_host_net_lock(host); + /* Two-phase poll: the batch leaves the core only once the guest holds its + * copy. If QuickJS cannot allocate the string the events stay visible and + * are rendered again next tick instead of vanishing. */ + const char *batch = pnet_httpd_poll_render(host->net, &len); + JSValue out = batch ? JS_NewStringLen(ctx, batch, len) : JS_UNDEFINED; + if (batch && !JS_IsException(out)) pnet_httpd_poll_consume(host->net); + pocketjs_host_net_unlock(host); + return out; +} + +static JSValue httpd_last_error(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.lastError"); + (void)argc; + (void)argv; + pocketjs_host_net_lock(host); + JSValue out = JS_NewString(ctx, pnet_httpd_last_error(host->net)); + pocketjs_host_net_unlock(host); + return out; +} + +static JSValue httpd_limits(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + WITH_HOST("httpd.limits"); + (void)argc; + (void)argv; + return JS_NewString(ctx, pnet_httpd_limits(host->net)); +} + +/* --- mount -------------------------------------------------------------------- */ + +typedef struct op_entry { + const char *name; + JSCFunction *fn; + int length; +} op_entry; + +static void mount_namespace(JSContext *ctx, JSValueConst global, const char *name, const op_entry *ops, size_t count) { + JSValue ns = JS_NewObject(ctx); + for (size_t i = 0; i < count; i++) { + JS_SetPropertyStr(ctx, ns, ops[i].name, JS_NewCFunction(ctx, ops[i].fn, ops[i].name, ops[i].length)); + } + JS_SetPropertyStr(ctx, global, name, ns); +} + +void pocketjs_host_mount_network(pocketjs_esp_host_t *host) { + JSContext *ctx = host->ctx; + JS_SetRuntimeOpaque(host->rt, host); + static const op_entry NET_OPS[] = { + {"start", net_start, 2}, {"cancel", net_cancel, 1}, {"poll", net_poll, 0}, + {"lastError", net_last_error, 0}, {"readInto", net_read_into, 4}, {"limits", net_limits, 0}, + }; + static const op_entry WS_OPS[] = { + {"connect", ws_connect, 1}, {"send", ws_send, 3}, {"receiveInto", ws_receive_into, 4}, + {"close", ws_close, 3}, {"terminate", ws_terminate, 1}, {"bufferedAmount", ws_buffered_amount, 1}, + {"poll", ws_poll, 0}, {"lastError", ws_last_error, 0}, {"limits", ws_limits, 0}, + }; + static const op_entry HTTPD_OPS[] = { + {"listen", httpd_listen, 1}, {"stop", httpd_stop, 3}, {"respond", httpd_respond, 3}, + {"write", httpd_write, 2}, {"endBody", httpd_end_body, 1}, {"readInto", httpd_read_into, 4}, + {"abort", httpd_abort, 1}, {"poll", httpd_poll, 0}, {"lastError", httpd_last_error, 0}, + {"limits", httpd_limits, 0}, + }; + JSValue global = JS_GetGlobalObject(ctx); + mount_namespace(ctx, global, "net", NET_OPS, sizeof NET_OPS / sizeof NET_OPS[0]); + if (host->cfg.mount_websocket_client) mount_namespace(ctx, global, "ws", WS_OPS, sizeof WS_OPS / sizeof WS_OPS[0]); + if (host->cfg.mount_http_server) mount_namespace(ctx, global, "httpd", HTTPD_OPS, sizeof HTTPD_OPS / sizeof HTTPD_OPS[0]); + JS_FreeValue(ctx, global); +} diff --git a/hosts/esp-idf/components/pocketjs_net_core/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_core/CMakeLists.txt new file mode 100644 index 00000000..3a48e473 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_core/CMakeLists.txt @@ -0,0 +1,28 @@ +# PocketJS network core (engine/net) as an ESP-IDF component: the portable C +# protocol cores plus the BSD-socket driver compiled against lwIP. Nothing in +# engine/net includes ESP-IDF headers; only the driver's FreeRTOS mutex and +# sdkconfig.h are pulled in through ESP_PLATFORM. +set(PNET_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../../../engine/net") +get_filename_component(PNET_ROOT "${PNET_ROOT}" ABSOLUTE) + +idf_component_register( + SRCS + "${PNET_ROOT}/src/pnet_util.c" + "${PNET_ROOT}/src/pnet_json.c" + "${PNET_ROOT}/src/pnet_url.c" + "${PNET_ROOT}/src/pnet_policy.c" + "${PNET_ROOT}/src/pnet_http1.c" + "${PNET_ROOT}/src/pnet_runtime.c" + "${PNET_ROOT}/src/pnet_http_client.c" + "${PNET_ROOT}/src/pnet_http_server.c" + "${PNET_ROOT}/src/pnet_ws.c" + "${PNET_ROOT}/drivers/posix/pnet_posix_driver.c" + INCLUDE_DIRS + "${PNET_ROOT}/include" + "${PNET_ROOT}/drivers/posix" + PRIV_INCLUDE_DIRS + "${PNET_ROOT}/src" + REQUIRES lwip + PRIV_REQUIRES freertos) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror -Wno-error=format) diff --git a/hosts/esp-idf/components/pocketjs_net_core/idf_component.yml b/hosts/esp-idf/components/pocketjs_net_core/idf_component.yml new file mode 100644 index 00000000..5b4686a3 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_core/idf_component.yml @@ -0,0 +1,5 @@ +description: PocketJS network core (HTTP client, HTTP server, WebSocket client) over lwIP sockets. +version: "0.1.0" +dependencies: + idf: + version: ">=5.4" diff --git a/hosts/esp-idf/components/pocketjs_net_esptls/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_esptls/CMakeLists.txt new file mode 100644 index 00000000..e5d58fb9 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esptls/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "src/pnet_esp_tls.c" + INCLUDE_DIRS "include" + REQUIRES pocketjs_net_core esp-tls mbedtls + PRIV_REQUIRES esp_common) +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror -Wno-error=unused-parameter) diff --git a/hosts/esp-idf/components/pocketjs_net_esptls/idf_component.yml b/hosts/esp-idf/components/pocketjs_net_esptls/idf_component.yml new file mode 100644 index 00000000..fd865928 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esptls/idf_component.yml @@ -0,0 +1,5 @@ +description: ESP-TLS TlsProvider for the PocketJS network core (HTTPS/WSS on ESP-IDF). +version: "0.1.0" +dependencies: + idf: + version: ">=5.4" diff --git a/hosts/esp-idf/components/pocketjs_net_esptls/include/pocketjs/net/esp_tls_provider.h b/hosts/esp-idf/components/pocketjs_net_esptls/include/pocketjs/net/esp_tls_provider.h new file mode 100644 index 00000000..9e3ab838 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esptls/include/pocketjs/net/esp_tls_provider.h @@ -0,0 +1,33 @@ +/* PocketJS network core — ESP-TLS TlsProvider (ESP-IDF). + * + * A `pnet_tls_ops` over ESP-TLS + its default Mbed TLS backend, layered on the + * lwIP sockets the driver already connected. Uses the ESP-IDF certificate + * bundle for host trust, SNI = the authorized hostname, DNS-ID/IP-ID + * hostname verification, TLS 1.2 minimum, non-blocking handshake. It maps + * ESP-TLS/Mbed TLS failures onto the four stable tls_* codes. esp_tls drives + * the handshake over the fd the driver already connected; on close esp_tls + * closes the fd and the driver's own close is a harmless no-op. + */ +#ifndef POCKETJS_NET_ESP_TLS_PROVIDER_H +#define POCKETJS_NET_ESP_TLS_PROVIDER_H + +#include "pocketjs/net/driver.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct pnet_esp_tls pnet_esp_tls; + +/** Create the provider. `driver`/`driver_ctx` are the runtime's; the provider + * calls `native_handle` to reach the lwIP fd. NULL on failure. */ +pnet_esp_tls *pnet_esp_tls_create(const pnet_driver_ops *driver, void *driver_ctx); +void pnet_esp_tls_destroy(pnet_esp_tls *tls); +const pnet_tls_ops *pnet_esp_tls_ops(void); +void *pnet_esp_tls_ctx(pnet_esp_tls *tls); + +#ifdef __cplusplus +} +#endif + +#endif /* POCKETJS_NET_ESP_TLS_PROVIDER_H */ diff --git a/hosts/esp-idf/components/pocketjs_net_esptls/src/pnet_esp_tls.c b/hosts/esp-idf/components/pocketjs_net_esptls/src/pnet_esp_tls.c new file mode 100644 index 00000000..5d4b2bdc --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esptls/src/pnet_esp_tls.c @@ -0,0 +1,198 @@ +/* ESP-TLS TlsProvider (see esp_tls_provider.h). */ +#include "pocketjs/net/esp_tls_provider.h" + +#include + +#include "esp_crt_bundle.h" +#include "esp_tls.h" +#include "mbedtls/ssl.h" +#include "pocketjs/net/spec.h" + +#define MAX_SESSIONS 12 + +typedef struct session { + pnet_sock s; + esp_tls_t *tls; + char host[256]; + uint16_t port; + bool in_use; + bool started; +} session; + +struct pnet_esp_tls { + const pnet_driver_ops *driver; + void *driver_ctx; + session sessions[MAX_SESSIONS]; +}; + +static session *session_for(pnet_esp_tls *p, pnet_sock s) { + for (int i = 0; i < MAX_SESSIONS; i++) + if (p->sessions[i].in_use && p->sessions[i].s == s) return &p->sessions[i]; + return NULL; +} + +pnet_esp_tls *pnet_esp_tls_create(const pnet_driver_ops *driver, void *driver_ctx) { + if (!driver || !driver->native_handle) return NULL; + pnet_esp_tls *p = calloc(1, sizeof *p); + if (!p) return NULL; + p->driver = driver; + p->driver_ctx = driver_ctx; + return p; +} + +void pnet_esp_tls_destroy(pnet_esp_tls *p) { + if (!p) return; + for (int i = 0; i < MAX_SESSIONS; i++) { + if (p->sessions[i].in_use && p->sessions[i].tls) esp_tls_conn_destroy(p->sessions[i].tls); + } + free(p); +} + +void *pnet_esp_tls_ctx(pnet_esp_tls *p) { + return p; +} + +static void free_session(session *sess) { + if (sess->tls) { + esp_tls_conn_destroy(sess->tls); /* closes the socket fd */ + sess->tls = NULL; + } + sess->in_use = false; + sess->started = false; +} + +static int op_start(void *ctx, pnet_sock s, const pnet_tls_policy *policy) { + pnet_esp_tls *p = ctx; + int fd = p->driver->native_handle(p->driver_ctx, s); + if (fd < 0) return PNET_IO_ERROR; + session *sess = NULL; + for (int i = 0; i < MAX_SESSIONS; i++) + if (!p->sessions[i].in_use) { sess = &p->sessions[i]; break; } + if (!sess) return PNET_IO_NOMEM; + memset(sess, 0, sizeof *sess); + sess->s = s; + sess->in_use = true; + sess->tls = esp_tls_init(); + if (!sess->tls) { + sess->in_use = false; + return PNET_IO_NOMEM; + } + size_t hlen = policy->server_name ? strlen(policy->server_name) : 0; + if (hlen >= sizeof sess->host) hlen = sizeof sess->host - 1; + if (hlen) memcpy(sess->host, policy->server_name, hlen); + sess->host[hlen] = 0; + /* esp_tls takes the connected fd and drives handshake/read/write over it. + * On close it calls close(fd); the driver's own close() then runs on the + * same network task under the same lock with no fd allocated in between, so + * the second close is a harmless no-op on an already-closed descriptor. */ + esp_tls_set_conn_sockfd(sess->tls, fd); + esp_tls_set_conn_state(sess->tls, ESP_TLS_CONNECTING); + return 0; +} + +/* Map an ESP-TLS/Mbed TLS handshake failure onto a stable code. A hostname + * mismatch is reported precisely; other certificate faults (expired, future, + * untrusted root, self-signed, revoked) are reported as tls_certificate_invalid + * when Mbed TLS exposes the verify flags, and otherwise collapse to + * tls_handshake_failed. In every case the connection fails closed with no + * plaintext fallback; the precise per-fault classification is exercised by the + * desktop OpenSSL conformance suite (engine/net/test/tls_test.c). */ +static const char *classify(esp_tls_t *tls, int *cause) { + esp_tls_error_handle_t eh = NULL; + int esp_code = 0, err_flags = 0; + if (esp_tls_get_error_handle(tls, &eh) == ESP_OK && eh) { + esp_tls_get_and_clear_last_error(eh, &esp_code, &err_flags); + } + /* The certificate verification flags are most reliably read straight from + * the mbedTLS session (the error-handle copy is not always populated on the + * async path). */ + uint32_t flags = (uint32_t)err_flags; + mbedtls_ssl_context *ssl = (mbedtls_ssl_context *)esp_tls_get_ssl_context(tls); + if (ssl) { + uint32_t vr = mbedtls_ssl_get_verify_result(ssl); + if (vr != 0 && vr != 0xFFFFFFFFu) flags |= vr; + } + if (cause) *cause = esp_code ? esp_code : (int)flags; + if (flags != 0) { + if (flags & MBEDTLS_X509_BADCERT_CN_MISMATCH) return PNET_ERROR_TLS_HOSTNAME_MISMATCH; + return PNET_ERROR_TLS_CERTIFICATE_INVALID; /* expired, future, untrusted, revoked, bad key usage */ + } + return PNET_ERROR_TLS_HANDSHAKE_FAILED; +} + +static int op_step(void *ctx, pnet_sock s, pnet_tls_failure *failure) { + pnet_esp_tls *p = ctx; + session *sess = session_for(p, s); + if (!sess || !sess->tls) return -1; + /* non_block = false makes ESP-TLS skip its internal select() on the + * connection (which assumes ESP-TLS did the connect and populated its own + * fd sets). Our socket is already connected and set non-blocking by the + * driver, so mbedtls_ssl_handshake returns WANT_READ/WRITE and ESP-TLS + * reports 0 (pending) — the reactor drives the handshake to completion + * across service passes without ever blocking the network task. */ + esp_tls_cfg_t cfg = { + .crt_bundle_attach = esp_crt_bundle_attach, + .common_name = sess->host[0] ? sess->host : NULL, + .non_block = false, + .timeout_ms = 0, + .is_plain_tcp = false, + .skip_common_name = false, + }; + int rc = esp_tls_conn_new_async(sess->host, (int)strlen(sess->host), sess->port, &cfg, sess->tls); + if (rc == 1) return 1; + if (rc == 0) return 0; /* pending */ + int cause = 0; + failure->code = classify(sess->tls, &cause); + failure->cause = cause; + return -1; +} + +static int map_io(int rc) { + if (rc == ESP_TLS_ERR_SSL_WANT_READ || rc == ESP_TLS_ERR_SSL_WANT_WRITE) return PNET_IO_AGAIN; + if (rc == 0) return PNET_IO_EOF; + return PNET_IO_CLOSED; +} + +static int op_read(void *ctx, pnet_sock s, uint8_t *buf, size_t len) { + pnet_esp_tls *p = ctx; + session *sess = session_for(p, s); + if (!sess || !sess->tls) return PNET_IO_ERROR; + ssize_t rc = esp_tls_conn_read(sess->tls, buf, len); + if (rc > 0) return (int)rc; + return map_io((int)rc); +} + +static int op_write(void *ctx, pnet_sock s, const uint8_t *buf, size_t len) { + pnet_esp_tls *p = ctx; + session *sess = session_for(p, s); + if (!sess || !sess->tls) return PNET_IO_ERROR; + ssize_t rc = esp_tls_conn_write(sess->tls, buf, len); + if (rc > 0) return (int)rc; + return map_io((int)rc); +} + +static unsigned op_interest(void *ctx, pnet_sock s) { + (void)ctx; + (void)s; + /* Mbed TLS non-blocking handshake alternates read/write; keep both armed. */ + return PNET_INTEREST_READ | PNET_INTEREST_WRITE; +} + +static void op_close(void *ctx, pnet_sock s) { + pnet_esp_tls *p = ctx; + session *sess = session_for(p, s); + if (sess) free_session(sess); +} + +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_esp_tls_ops(void) { + return &OPS; +} diff --git a/hosts/esp-idf/examples/net-smoke/app.ts b/hosts/esp-idf/examples/net-smoke/app.ts new file mode 100644 index 00000000..4e899b4c --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/app.ts @@ -0,0 +1,373 @@ +// Network smoke app for the ESP-IDF hosts (AtomS3R / Tab5). Headless: no UI, +// only the frame transaction that delivers network completions. +// +// What it does, in order: +// 1. serve HTTP on :8080 (/hello, /echo, /json, /stream, /status) +// 2. against the Mac peer (tools/net-peer.ts): GET/POST/chunked/redirect/ +// big-body/404/timeout/permission cases + a WebSocket echo session +// 3. against the peer board (when configured): GET /hello + POST /echo, then +// a periodic ping every ~2 s that keeps both boards talking +// +// The host injects `globalThis.__pocketSmoke` before evaluating the bundle. + +import { after } from "@pocketjs/framework/clock"; +import { mountHeadless } from "@pocketjs/framework/headless"; +import { NetworkError, URL, getNetworkLimits } from "@pocketjs/framework/net"; +import { fetch, Response, serve, type Request } from "@pocketjs/framework/net/http"; +import { connect } from "@pocketjs/framework/net/websocket"; + +interface SmokeConfig { + board: string; + selfIp: string; + peerHost: string; + peerPort: number; + macHost: string; + macPort: number; + macWsPort: number; + ping: boolean; + tls: boolean; + tlsHost: string; +} + +const cfg: SmokeConfig = (globalThis as { __pocketSmoke?: SmokeConfig }).__pocketSmoke ?? { + board: "unknown", + selfIp: "0.0.0.0", + peerHost: "", + peerPort: 8080, + macHost: "", + macPort: 8790, + macWsPort: 8791, + ping: true, + tls: false, + tlsHost: "example.com", +}; + +let passed = 0; +let failed = 0; +const failures: string[] = []; + +function ok(name: string, condition: boolean, detail = ""): void { + if (condition) { + passed++; + console.log(`PASS ${name}${detail ? " " + detail : ""}`); + } else { + failed++; + failures.push(name); + console.error(`FAIL ${name}${detail ? " " + detail : ""}`); + } +} + +function describeError(error: unknown): string { + if (error instanceof NetworkError) return `${error.code}(${error.category}): ${error.message}`; + return String(error); +} + +// QuickJS ships no TextEncoder/TextDecoder; the smoke only moves ASCII. +function asciiDecode(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i++) out += String.fromCharCode(bytes[i]); + return out; +} +function asciiEncode(text: string): Uint8Array { + const out = new Uint8Array(text.length); + for (let i = 0; i < text.length; i++) out[i] = text.charCodeAt(i) & 0x7f; + return out; +} + +// --- 1. HTTP server ----------------------------------------------------------- + +let served = 0; +async function startServer(): Promise { + try { + const server = await serve({ + hostname: "0.0.0.0", + port: 8080, + timeouts: { handlerMs: 10_000, keepAliveMs: 5_000 }, + async fetch(request: Request) { + served++; + const url = new URL(request.url); + switch (url.pathname) { + case "/hello": + return new Response(`hello from ${cfg.board} (${cfg.selfIp}) #${served}`); + case "/echo": { + const body = await request.arrayBuffer(); + return new Response(body, { headers: { "content-type": request.headers.get("content-type") ?? "application/octet-stream", "x-echo-bytes": String(body.byteLength) } }); + } + case "/json": + return Response.json({ board: cfg.board, ip: cfg.selfIp, served, limits: getNetworkLimits().httpServer?.maxInflight }); + case "/stream": { + async function* chunks(): AsyncGenerator { + for (let i = 0; i < 5; i++) yield asciiEncode(`chunk-${i};`); + } + return new Response(chunks() as unknown as AsyncIterable, { headers: { "content-type": "text/plain" } }); + } + case "/status": + return Response.json({ passed, failed, failures, served }); + default: + return new Response("not found", { status: 404 }); + } + }, + error(error) { + console.error("server handler error", describeError(error)); + return new Response("boom", { status: 500 }); + }, + }); + ok("serve listening", server.port === 8080, `at ${server.url}`); + } catch (error) { + ok("serve listening", false, describeError(error)); + } +} + +// --- 2. HTTP client against the Mac peer ------------------------------------- + +async function clientSuite(base: string, tag: string): Promise { + // plain GET + try { + const r = await fetch(`${base}/hello`); + const text = await r.text(); + ok(`${tag} GET /hello`, r.status === 200 && text.length > 0, `${r.status} "${text.slice(0, 40)}"`); + } catch (error) { + ok(`${tag} GET /hello`, false, describeError(error)); + } + // POST echo + try { + const payload = new Uint8Array(1024); + for (let i = 0; i < payload.length; i++) payload[i] = i & 0xff; + const r = await fetch(`${base}/echo`, { method: "POST", body: payload, headers: { "content-type": "application/octet-stream" } }); + const echoed = new Uint8Array(await r.arrayBuffer()); + let same = echoed.length === payload.length; + for (let i = 0; same && i < echoed.length; i++) same = echoed[i] === payload[i]; + ok(`${tag} POST /echo 1 KiB`, r.status === 200 && same, `${r.status} ${echoed.length} bytes`); + } catch (error) { + ok(`${tag} POST /echo 1 KiB`, false, describeError(error)); + } + // JSON + try { + const r = await fetch(`${base}/json`); + const data = await r.json<{ board?: string }>(); + ok(`${tag} GET /json`, r.status === 200 && typeof data === "object", JSON.stringify(data).slice(0, 60)); + } catch (error) { + ok(`${tag} GET /json`, false, describeError(error)); + } + // streaming (chunked) via async iteration + try { + const r = await fetch(`${base}/stream`); + let total = ""; + let chunks = 0; + for await (const chunk of r.body!) { + total += asciiDecode(chunk); + chunks++; + } + ok(`${tag} GET /stream`, r.status === 200 && total.includes("chunk-4;"), `${chunks} reads, ${total.length} bytes`); + } catch (error) { + ok(`${tag} GET /stream`, false, describeError(error)); + } + // 404 is a successful exchange + try { + const r = await fetch(`${base}/missing`); + await r.text(); + ok(`${tag} GET /missing → 404`, r.status === 404, String(r.status)); + } catch (error) { + ok(`${tag} GET /missing → 404`, false, describeError(error)); + } +} + +async function macSuite(): Promise { + const base = `http://${cfg.macHost}:${cfg.macPort}`; + await clientSuite(base, "mac"); + // redirect follow + try { + const r = await fetch(`${base}/redirect`); + const text = await r.text(); + ok("mac redirect follow", r.status === 200 && r.redirected && text.length > 0, `${r.status} redirected=${r.redirected} url=${r.url}`); + } catch (error) { + ok("mac redirect follow", false, describeError(error)); + } + // redirect manual + try { + const r = await fetch(`${base}/redirect`, { redirect: "manual" }); + await r.text(); + ok("mac redirect manual", r.status === 302, String(r.status)); + } catch (error) { + ok("mac redirect manual", false, describeError(error)); + } + // big body with backpressure through a small queue + try { + const t0 = Date.now(); + const r = await fetch(`${base}/big?bytes=200000`, { limits: { queueBytes: 8192 } }); + let total = 0; + let checksum = 0; + for await (const chunk of r.body!) { + total += chunk.length; + for (let i = 0; i < chunk.length; i += 97) checksum = (checksum + chunk[i]) & 0xffff; + } + const ms = Date.now() - t0; + ok("mac GET /big 200 KB", r.status === 200 && total === 200000, `${total} bytes in ${ms} ms (${Math.round(total / 1024 / (ms / 1000))} KiB/s) checksum=${checksum}`); + } catch (error) { + ok("mac GET /big 200 KB", false, describeError(error)); + } + // aggregate limit + try { + const r = await fetch(`${base}/big?bytes=100000`, { limits: { aggregateBytes: 4096 } }); + await r.text(); + ok("mac aggregate limit", false, "text() resolved"); + } catch (error) { + ok("mac aggregate limit", error instanceof NetworkError && error.code === "response_too_large", describeError(error)); + } + // timeout + try { + await fetch(`${base}/slow?ms=3000`, { timeouts: { headersMs: 500 } }); + ok("mac headers timeout", false, "resolved"); + } catch (error) { + ok("mac headers timeout", error instanceof NetworkError && error.code === "timeout", describeError(error)); + } + // permission: an endpoint outside the policy + try { + await fetch(`http://${cfg.macHost}:1/x`); + ok("mac permission_denied", false, "resolved"); + } catch (error) { + ok("mac permission_denied", error instanceof NetworkError && error.code === "permission_denied", describeError(error)); + } + // connection refused on an allowed but closed port (macPort + 1 is the + // WebSocket listener, so use macPort + 2) + try { + await fetch(`http://${cfg.macHost}:${cfg.macPort + 2}/x`); + ok("mac connect refused", false, "resolved"); + } catch (error) { + ok("mac connect refused", error instanceof NetworkError && error.code === "connect", describeError(error)); + } + await wsSuite(); +} + +// --- WebSocket against the Mac peer ----------------------------------------- + +function wsSuite(): Promise { + return new Promise((resolve) => { + const received: string[] = []; + let binaryOk = false; + let pongSeen = false; + let done = false; + const finish = (name: string, condition: boolean, detail: string): void => { + if (done) return; + done = true; + ok(name, condition, detail); + resolve(); + }; + connect(`ws://${cfg.macHost}:${cfg.macWsPort}/echo`, { + protocols: ["smoke.v1"], + timeouts: { connectMs: 5000 }, + socket: { + open(socket) { + console.log(`ws open protocol=${socket.protocol}`); + socket.send("hello ws"); + socket.send(new Uint8Array([1, 2, 3, 4, 5])); + socket.ping(new Uint8Array([9])); + }, + message(socket, data) { + if (typeof data === "string") { + received.push(data); + } else { + binaryOk = data.length === 5 && data[0] === 1 && data[4] === 5; + } + if (received.length >= 1 && binaryOk && pongSeen) socket.close(1000, "done"); + }, + pong(socket, data) { + pongSeen = data.length === 1 && data[0] === 9; + if (received.length >= 1 && binaryOk && pongSeen) socket.close(1000, "done"); + }, + close(_socket, code, reason) { + finish("mac websocket echo", received[0] === "hello ws" && binaryOk && pongSeen && code === 1000, `code=${code} reason=${reason}`); + }, + error(_socket, error) { + console.error("ws error", describeError(error)); + }, + }, + }).catch((error: unknown) => finish("mac websocket echo", false, describeError(error))); + after(15, () => finish("mac websocket echo", false, "timed out")); + }); +} + +// --- 3. Board-to-board ---------------------------------------------------------- + +let pings = 0; +let pingFailures = 0; +async function peerSuite(): Promise { + const base = `http://${cfg.peerHost}:${cfg.peerPort}`; + await clientSuite(base, "peer"); +} + +function schedulePing(): void { + after(2, async () => { + try { + const r = await fetch(`http://${cfg.peerHost}:${cfg.peerPort}/json`); + const data = await r.json<{ board: string; served: number }>(); + pings++; + if (pings % 5 === 1) console.log(`ping #${pings} → ${data.board} served=${data.served} (failures=${pingFailures}, our served=${served})`); + } catch (error) { + pingFailures++; + console.error(`ping failed: ${describeError(error)}`); + } + schedulePing(); + }); +} + +// --- TLS (base .tls: host trust, SNI, hostname verification) -------------------- + +async function tlsSuite(): Promise { + const limits = getNetworkLimits(); + ok("tls advertised", limits.httpClient?.features.includes("tls") === true, JSON.stringify(limits.httpClient?.features)); + // positive control: a real public HTTPS host with a valid chain + try { + const r = await fetch(`https://${cfg.tlsHost}/`, { timeouts: { connectMs: 15000, headersMs: 15000 } }); + const text = await r.text(); + ok(`https ${cfg.tlsHost}`, r.status >= 200 && r.status < 500 && text.length >= 0, `${r.status} ${text.length} bytes`); + } catch (error) { + ok(`https ${cfg.tlsHost}`, false, describeError(error)); + } + const expectTlsError = async (name: string, url: string, code: string): Promise => { + try { + const r = await fetch(url, { timeouts: { connectMs: 15000, headersMs: 15000 } }); + await r.text(); + ok(name, false, `resolved ${r.status}`); + } catch (error) { + ok(name, error instanceof NetworkError && (error.code === code || error.category === "tls"), describeError(error)); + } + }; + await expectTlsError("https expired cert", "https://expired.badssl.com/", "tls_certificate_invalid"); + await expectTlsError("https wrong host", "https://wrong.host.badssl.com/", "tls_hostname_mismatch"); + await expectTlsError("https self-signed", "https://self-signed.badssl.com/", "tls_certificate_invalid"); + await expectTlsError("https untrusted root", "https://untrusted-root.badssl.com/", "tls_certificate_invalid"); +} + +// --- main ----------------------------------------------------------------------- + +mountHeadless(); + +async function main(): Promise { + console.log(`net-smoke on ${cfg.board} ip=${cfg.selfIp} mac=${cfg.macHost || "-"} peer=${cfg.peerHost || "-"}`); + const limits = getNetworkLimits(); + ok("limits mounted", !!limits.httpClient && !!limits.httpServer && !!limits.websocketClient, `httpClient.maxInflight=${limits.httpClient?.maxInflight}`); + await startServer(); + if (cfg.tls) await tlsSuite(); + if (cfg.macHost) await macSuite(); + if (cfg.peerHost) { + // Give the peer time to boot when both boards start together. + for (let attempt = 0; attempt < 30; attempt++) { + try { + const r = await fetch(`http://${cfg.peerHost}:${cfg.peerPort}/hello`, { timeouts: { connectMs: 2000 } }); + await r.text(); + break; + } catch { + await new Promise((resolve) => after(2, resolve)); + } + } + await peerSuite(); + if (cfg.ping) schedulePing(); + } + console.log(`SMOKE ${failed === 0 ? "PASS" : "FAIL"} ${passed}/${passed + failed}${failed ? " failed: " + failures.join(", ") : ""}`); +} + +main().catch((error: unknown) => { + console.error("smoke crashed", describeError(error)); + console.log(`SMOKE FAIL ${passed}/${passed + failed + 1}`); +}); diff --git a/hosts/esp-idf/examples/net-smoke/firmware/CMakeLists.txt b/hosts/esp-idf/examples/net-smoke/firmware/CMakeLists.txt new file mode 100644 index 00000000..de607b7f --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/firmware/CMakeLists.txt @@ -0,0 +1,7 @@ +# net-smoke firmware. Build from a copy or a symlinked project directory: +# idf.py -B build set-target esp32s3 && idf.py build flash monitor +cmake_minimum_required(VERSION 3.16) +get_filename_component(POCKETJS_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../../../.." ABSOLUTE) +list(APPEND EXTRA_COMPONENT_DIRS "${POCKETJS_ROOT}/hosts/esp-idf/components") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(pocketjs_net_smoke) diff --git a/hosts/esp-idf/examples/net-smoke/firmware/main/CMakeLists.txt b/hosts/esp-idf/examples/net-smoke/firmware/main/CMakeLists.txt new file mode 100644 index 00000000..ba67e4d8 --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/firmware/main/CMakeLists.txt @@ -0,0 +1,53 @@ +# The smoke firmware's guest bundle AND its network policy come from the +# smoke manifest's Build Plan: tools/esp-idf.ts resolves +# hosts/esp-idf/examples/net-smoke/pocket.json (format 3) plus this rig's +# endpoints (Kconfig) against the board's private profile and writes +# app.js, network-policy.json (the canonical ResolvedNetworkPolicy the host +# hands to the core verbatim), plan.json and host-inputs.h (plan hash, +# features) into the build directory. main.c embeds the first two and +# compiles against the header; it authors no policy of its own. +# Set POCKETJS_ROOT / BUN when the defaults do not match the checkout. +if(NOT DEFINED POCKETJS_ROOT) + get_filename_component(POCKETJS_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../../../../.." ABSOLUTE) +endif() +if(NOT DEFINED BUN) + find_program(BUN bun HINTS "$ENV{HOME}/.bun/bin" "/opt/homebrew/bin" "/usr/local/bin") +endif() +set(SMOKE_DIR "${POCKETJS_ROOT}/hosts/esp-idf/examples/net-smoke") +set(SMOKE_APP "${SMOKE_DIR}/app.ts") +set(SMOKE_MANIFEST "${SMOKE_DIR}/pocket.json") +set(SMOKE_OUT "${CMAKE_BINARY_DIR}/pocketjs-app") +set(SMOKE_JS "${SMOKE_OUT}/app.js") +set(SMOKE_POLICY "${SMOKE_OUT}/network-policy.json") +set(SMOKE_HEADER "${SMOKE_OUT}/host-inputs.h") + +# Which private profile: the board name selects atoms3r-dev / tab5-dev. +if(CONFIG_SMOKE_BOARD_NAME STREQUAL "tab5") + set(SMOKE_BOARD "tab5") +else() + set(SMOKE_BOARD "atoms3r") +endif() + +idf_component_register( + SRCS "main.c" + INCLUDE_DIRS "${SMOKE_OUT}" + REQUIRES pocketjs_esp_host pocketjs_board pocketjs_net_core + PRIV_REQUIRES esp_timer heap + EMBED_TXTFILES "${SMOKE_JS}" "${SMOKE_POLICY}") + +add_custom_command( + OUTPUT "${SMOKE_JS}" "${SMOKE_POLICY}" "${SMOKE_HEADER}" + COMMAND "${BUN}" "${POCKETJS_ROOT}/tools/esp-idf.ts" smoke-inputs + "--board=${SMOKE_BOARD}" "--outdir=${SMOKE_OUT}" + "--mac-host=${CONFIG_SMOKE_MAC_HOST}" "--mac-http-port=${CONFIG_SMOKE_MAC_HTTP_PORT}" + "--mac-ws-port=${CONFIG_SMOKE_MAC_WS_PORT}" + "--peer-host=${CONFIG_SMOKE_PEER_HOST}" "--peer-port=${CONFIG_SMOKE_PEER_PORT}" + "--serve-port=${CONFIG_SMOKE_SERVE_PORT}" "--tls-host=${CONFIG_SMOKE_TLS_HOST}" + "--tick-hz=${CONFIG_SMOKE_TICK_HZ}" + WORKING_DIRECTORY "${POCKETJS_ROOT}" + DEPENDS "${SMOKE_APP}" "${SMOKE_MANIFEST}" "${POCKETJS_ROOT}/tools/esp-idf.ts" "${POCKETJS_ROOT}/tools/esp-idf-profile.ts" + "${CMAKE_BINARY_DIR}/config/sdkconfig.h" + COMMENT "PocketJS: resolving the smoke plan (${SMOKE_BOARD}) and bundling ${SMOKE_APP}" + VERBATIM) +add_custom_target(pocketjs_smoke_inputs DEPENDS "${SMOKE_JS}" "${SMOKE_POLICY}" "${SMOKE_HEADER}") +add_dependencies(${COMPONENT_LIB} pocketjs_smoke_inputs) diff --git a/hosts/esp-idf/examples/net-smoke/firmware/main/Kconfig.projbuild b/hosts/esp-idf/examples/net-smoke/firmware/main/Kconfig.projbuild new file mode 100644 index 00000000..104e1a02 --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/firmware/main/Kconfig.projbuild @@ -0,0 +1,63 @@ +menu "PocketJS network smoke" + + config SMOKE_BOARD_NAME + string "Board name reported by the guest" + default "esp32" + + config SMOKE_WIFI_SSID + string "Wi-Fi SSID" + default "" + + config SMOKE_WIFI_PASSWORD + string "Wi-Fi password" + default "" + + config SMOKE_MAC_HOST + string "Workstation peer address (tools/net-peer.ts); empty disables" + default "" + + config SMOKE_MAC_HTTP_PORT + int "Workstation peer HTTP port" + default 8790 + + config SMOKE_MAC_WS_PORT + int "Workstation peer WebSocket port" + default 8791 + + config SMOKE_PEER_HOST + string "Peer board address; empty disables the board-to-board suite" + default "" + + config SMOKE_PEER_PORT + int "Peer board HTTP port" + default 8080 + + config SMOKE_PEER_PING + bool "Ping the peer board every ~2 s after the suite" + default y + + config SMOKE_SERVE_PORT + int "Port the guest serves on" + default 8080 + + config SMOKE_TICK_HZ + int "Guest tick rate" + default 60 + + config SMOKE_GUEST_MEMORY_KB + int "QuickJS memory limit (KiB)" + default 4096 + + config SMOKE_GUEST_STACK_KB + int "Guest owner task stack (KiB)" + default 32 + + config SMOKE_ENABLE_TLS + bool "Run the HTTPS/TLS suite against public hosts (needs internet + SNTP)" + default n + + config SMOKE_TLS_HOST + string "Public HTTPS host for the positive TLS check" + default "example.com" + +endmenu diff --git a/hosts/esp-idf/examples/net-smoke/firmware/main/main.c b/hosts/esp-idf/examples/net-smoke/firmware/main/main.c new file mode 100644 index 00000000..e302e8fe --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/firmware/main/main.c @@ -0,0 +1,124 @@ +/* net-smoke firmware: bring Wi-Fi up, start the PocketJS host with the + * network modules, evaluate the embedded smoke bundle, report stats. + * + * Everything the host mounts and allows comes from the smoke manifest's + * Build Plan (tools/esp-idf.ts, run by main/CMakeLists.txt): the embedded + * network-policy.json is the canonical ResolvedNetworkPolicy of that plan, + * host-inputs.h carries the plan hash and the resolved features, app.js is + * the bundle built against the same plan. This file authors no policy; the + * rig's addresses (Kconfig) reach the guest only as test configuration. */ +#include +#include + +#include "esp_chip_info.h" +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "host-inputs.h" +#include "pocketjs/board.h" +#include "pocketjs/esp_host.h" +#include "sdkconfig.h" + +static const char *TAG = "smoke"; + +extern const char app_js_start[] asm("_binary_app_js_start"); +extern const char app_js_end[] asm("_binary_app_js_end"); +extern const char network_policy_json_start[] asm("_binary_network_policy_json_start"); +extern const char network_policy_json_end[] asm("_binary_network_policy_json_end"); + +static char s_self_ip[16] = "0.0.0.0"; +/* The embedded policy text, NUL-terminated for the core (EMBED_TXTFILES adds + * the NUL; the trailing newline is JSON whitespace). */ +static char s_policy[1024]; + +static void install_smoke_config(JSContext *ctx, void *user) { + (void)user; + char json[512]; + snprintf(json, sizeof json, + "({\"board\":\"%s\",\"selfIp\":\"%s\",\"peerHost\":\"%s\",\"peerPort\":%d,\"macHost\":\"%s\",\"macPort\":%d," + "\"macWsPort\":%d,\"ping\":%s,\"tls\":%s,\"tlsHost\":\"%s\"})", + CONFIG_SMOKE_BOARD_NAME, s_self_ip, CONFIG_SMOKE_PEER_HOST, CONFIG_SMOKE_PEER_PORT, CONFIG_SMOKE_MAC_HOST, + CONFIG_SMOKE_MAC_HTTP_PORT, CONFIG_SMOKE_MAC_WS_PORT, CONFIG_SMOKE_PEER_PING ? "true" : "false", +#if CONFIG_SMOKE_ENABLE_TLS + "true", CONFIG_SMOKE_TLS_HOST); +#else + "false", ""); +#endif + JSValue value = JS_Eval(ctx, json, strlen(json), "smoke-config", JS_EVAL_TYPE_GLOBAL); + JSValue global = JS_GetGlobalObject(ctx); + JS_SetPropertyStr(ctx, global, "__pocketSmoke", value); + JS_FreeValue(ctx, global); +} + +static void report(uint32_t frame, void *user) { + pocketjs_esp_host_t **host = user; + if (frame % (POCKETJS_TICK_HZ * 30) != 0 || !*host) return; /* every 30 s of guest turns */ + pocketjs_esp_host_stats_t st; + pocketjs_esp_host_stats(*host, &st); + ESP_LOGI(TAG, + "frames=%u skipped=%u jobs=%u frameErrors=%u frameMax=%uus guestHeap=%u/%u netHeap=%u sockets=%d " + "freeInternal=%u freePsram=%u uptime=%llus", + (unsigned)st.frames, (unsigned)st.frames_skipped, (unsigned)st.jobs, (unsigned)st.frame_errors, + (unsigned)st.frame_max_us, (unsigned)st.guest_heap_bytes, (unsigned)st.guest_heap_high_water, + (unsigned)st.net_heap_bytes, st.net_sockets, (unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL), + (unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM), (unsigned long long)(esp_timer_get_time() / 1000000)); +} + +static pocketjs_esp_host_t *s_host; + +void app_main(void) { + esp_chip_info_t chip; + esp_chip_info(&chip); + ESP_LOGI(TAG, "%s (%s, rev v%d.%d) free internal %u, psram %u", CONFIG_SMOKE_BOARD_NAME, CONFIG_IDF_TARGET, + chip.revision / 100, chip.revision % 100, (unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL), + (unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM)); + ESP_LOGI(TAG, "plan %s (target %s, host ABI %d)", POCKETJS_PLAN_HASH, POCKETJS_TARGET, POCKETJS_HOST_ABI); + + pocketjs_board_wifi_config wifi = {.ssid = CONFIG_SMOKE_WIFI_SSID, .password = CONFIG_SMOKE_WIFI_PASSWORD, .timeout_ms = 60000}; + esp_ip4_addr_t ip; + while (pocketjs_board_wifi_connect(&wifi, &ip) != ESP_OK) { + ESP_LOGW(TAG, "retrying Wi-Fi"); + vTaskDelay(pdMS_TO_TICKS(2000)); + } + pocketjs_board_ip_text(s_self_ip, sizeof s_self_ip); + ESP_LOGI(TAG, "station ip %s, serving http://%s:%d/", s_self_ip, s_self_ip, CONFIG_SMOKE_SERVE_PORT); +#if CONFIG_SMOKE_ENABLE_TLS + if (pocketjs_board_sync_time(20000) != ESP_OK) + ESP_LOGW(TAG, "wall clock untrusted: every verifying TLS connection will fail closed with tls_clock_untrusted"); +#endif + + size_t policy_len = (size_t)(network_policy_json_end - network_policy_json_start); + if (policy_len >= sizeof s_policy) { + ESP_LOGE(TAG, "embedded policy is %u bytes, larger than the %u byte buffer", (unsigned)policy_len, (unsigned)sizeof s_policy); + return; + } + memcpy(s_policy, network_policy_json_start, policy_len); + s_policy[policy_len] = 0; + ESP_LOGI(TAG, "policy %s", s_policy); + + pocketjs_esp_host_config cfg; + pocketjs_esp_host_config_defaults(&cfg); + cfg.tick_hz = POCKETJS_TICK_HZ; + cfg.network_policy_json = s_policy; + cfg.plan_hash = POCKETJS_PLAN_HASH; + /* Roles follow the plan's features, not a host opinion. */ + cfg.mount_websocket_client = POCKETJS_FEATURE_NETWORK_WEBSOCKET_CLIENT; + cfg.mount_http_server = POCKETJS_FEATURE_NETWORK_HTTP_SERVER; +#if CONFIG_SMOKE_ENABLE_TLS + cfg.network_tls = POCKETJS_FEATURE_NETWORK_HTTP_CLIENT_TLS; +#endif + cfg.wall_clock_trusted = pocketjs_board_clock_trusted_cb; + cfg.guest_in_psram = true; + cfg.guest_memory_limit = CONFIG_SMOKE_GUEST_MEMORY_KB * 1024; + cfg.guest_task_stack = CONFIG_SMOKE_GUEST_STACK_KB * 1024; + cfg.before_eval = install_smoke_config; + cfg.after_frame = report; + cfg.user = &s_host; + size_t bundle_len = (size_t)(app_js_end - app_js_start); + if (bundle_len > 0 && app_js_start[bundle_len - 1] == 0) bundle_len--; /* EMBED_TXTFILES adds a NUL */ + ESP_LOGI(TAG, "starting the guest with a %u byte bundle", (unsigned)bundle_len); + ESP_ERROR_CHECK(pocketjs_esp_host_start(&cfg, app_js_start, bundle_len, &s_host)); +} diff --git a/hosts/esp-idf/examples/net-smoke/firmware/partitions.csv b/hosts/esp-idf/examples/net-smoke/firmware/partitions.csv new file mode 100644 index 00000000..508c9a43 --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/firmware/partitions.csv @@ -0,0 +1,4 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 0x3f0000, diff --git a/hosts/esp-idf/examples/net-smoke/firmware/sdkconfig.defaults b/hosts/esp-idf/examples/net-smoke/firmware/sdkconfig.defaults new file mode 100644 index 00000000..27164c06 --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/firmware/sdkconfig.defaults @@ -0,0 +1,18 @@ +# Common to both profiles. +CONFIG_COMPILER_OPTIMIZATION_SIZE=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y +CONFIG_FREERTOS_HZ=1000 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +CONFIG_ESP_TASK_WDT_TIMEOUT_S=30 +# lwIP: loopback for the driver's wake socket, a few more sockets, IPv4-only paths first +CONFIG_LWIP_NETIF_LOOPBACK=y +CONFIG_LWIP_MAX_SOCKETS=16 +CONFIG_LWIP_SO_REUSE=y +CONFIG_LWIP_TCP_MSL=5000 +CONFIG_LWIP_DNS_MAX_HOST_IP=4 +# TLS off by default: keep mbedTLS out of the network core's path (Wi-Fi WPA2 still uses it) +CONFIG_ESP_TLS_INSECURE=n +# Log the guest at info level +CONFIG_LOG_DEFAULT_LEVEL_INFO=y diff --git a/hosts/esp-idf/examples/net-smoke/firmware/sdkconfig.defaults.esp32p4 b/hosts/esp-idf/examples/net-smoke/firmware/sdkconfig.defaults.esp32p4 new file mode 100644 index 00000000..e83a38c3 --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/firmware/sdkconfig.defaults.esp32p4 @@ -0,0 +1,18 @@ +# Tab5: ESP32-P4 rev 1.3 + ESP32-C6 over SDIO (esp_hosted + esp_wifi_remote). +# The revision-range and reset-polarity options below are what that silicon needs. +CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y +CONFIG_ESP32P4_REV_MIN_100=y +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_HEX=y +CONFIG_SPIRAM_SPEED_200M=y +CONFIG_SPIRAM_USE_MALLOC=y +CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096 +CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=65536 +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_360=y +CONFIG_ESP_WIFI_REMOTE_ENABLED=y +CONFIG_ESP_HOSTED_SDIO_HOST_INTERFACE=y +CONFIG_ESP32P4_TAB5_C6_BOARD=y +CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH=y +# CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_LOW is not set +CONFIG_SMOKE_BOARD_NAME="tab5" diff --git a/hosts/esp-idf/examples/net-smoke/firmware/sdkconfig.defaults.esp32s3 b/hosts/esp-idf/examples/net-smoke/firmware/sdkconfig.defaults.esp32s3 new file mode 100644 index 00000000..ad7045d4 --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/firmware/sdkconfig.defaults.esp32s3 @@ -0,0 +1,14 @@ +# AtomS3R: ESP32-S3-PICO-1-N8R8 (8 MB flash, 8 MB octal PSRAM). +CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y +CONFIG_ESPTOOLPY_FLASHMODE_QIO=y +CONFIG_ESPTOOLPY_FLASHFREQ_80M=y +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_OCT=y +CONFIG_SPIRAM_SPEED_80M=y +CONFIG_SPIRAM_USE_MALLOC=y +CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096 +CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=65536 +CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240=y +CONFIG_SMOKE_BOARD_NAME="atoms3r" diff --git a/hosts/esp-idf/examples/net-smoke/pocket.json b/hosts/esp-idf/examples/net-smoke/pocket.json new file mode 100644 index 00000000..48800bd0 --- /dev/null +++ b/hosts/esp-idf/examples/net-smoke/pocket.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://pocketjs.dev/schema/pocket-3.json", + "pocket": 3, + "id": "dev.pocket-stack.net-smoke", + "name": "net-smoke", + "title": "PocketJS network smoke", + "version": "0.1.0", + "engine": { + "capabilities": { + "requires": [ + "network.http.client", + "network.http.server", + "network.websocket.client" + ], + "enhances": [ + "network.http.client.tls" + ] + } + }, + "app": { + "entry": "app.ts", + "output": "app", + "framework": "solid", + "viewport": { + "logical": [128, 128], + "presentation": "native" + } + }, + "permissions": { + "network": { + "connect": [ + { "protocol": "https", "host": "example.com", "port": 443 }, + { "protocol": "https", "host": "expired.badssl.com", "port": 443 }, + { "protocol": "https", "host": "wrong.host.badssl.com", "port": 443 }, + { "protocol": "https", "host": "self-signed.badssl.com", "port": 443 }, + { "protocol": "https", "host": "untrusted-root.badssl.com", "port": 443 } + ], + "listen": [ + { "protocol": "http", "address": "0.0.0.0", "port": 8080 } + ], + "credentials": [], + "localNetwork": true, + "insecureTransport": true, + "allowInvalidTlsForDevelopment": false + } + } +} diff --git a/hosts/sim/httpd.ts b/hosts/sim/httpd.ts new file mode 100644 index 00000000..44c69522 --- /dev/null +++ b/hosts/sim/httpd.ts @@ -0,0 +1,422 @@ +// Deterministic virtual-clock HTTP Server module (`globalThis.httpd`, spec v2) +// for conformance tests. No socket is opened: a test injects requests with +// `host.inject(...)`, the listener/request events become visible at the next +// tick(), and everything the app answers through respond/write/endBody lands +// on the injected request record. Inject via bootWorld's extraGlobals: +// `{ httpd: host.ns }`. + +import { + HTTPD_DEFAULT_BODY_IDLE_MS, + HTTPD_DEFAULT_CLOSE_MS, + HTTPD_DEFAULT_HANDLER_MS, + HTTPD_DEFAULT_HEADER_MS, + HTTPD_DEFAULT_KEEP_ALIVE_MS, + HTTPD_DEFAULT_REQUEST_QUEUE_BYTES, + HTTPD_MAX_CONNECTIONS, + HTTPD_MAX_EVENTS_PER_TICK, + HTTPD_MAX_HEADERS, + HTTPD_MAX_HEADER_BYTES, + HTTPD_MAX_INFLIGHT, + HTTPD_MAX_REQUEST_QUEUE_BYTES, + HTTPD_MAX_SEND_QUEUE_BYTES, + HTTPD_MAX_SERVERS, + HTTPD_MAX_TARGET_BYTES, + HTTPD_MAX_TICK_BYTES, + HTTPD_MAX_TIMEOUT_MS, + HTTPD_SEND_ACCEPTED, + HTTPD_SEND_BACKPRESSURE, + HTTPD_SEND_HIGH_WATER_BYTES, + HTTPD_SEND_INVALID, + HTTPD_SEND_INVALID_REQUEST, + HTTPD_SEND_LOW_WATER_BYTES, + HTTPD_SPEC_MAJOR, + HTTPD_SPEC_MINOR, + type HttpdLimits, + type HttpdListenMeta, + type HttpdRespondMeta, +} from "../../contracts/spec/httpd.ts"; +import { NET_ERROR, NET_TLS_MIN_VERSION } from "../../contracts/spec/net.ts"; +import { networkPolicyAllowsListen } from "../../contracts/spec/network-policy.ts"; +import { stringToUtf8 } from "../../framework/src/bytes.ts"; +import type { HttpdOps } from "../../framework/src/net/http.ts"; +import { simPolicy, type SimHostOptions } from "./net.ts"; + +export interface SimInjectOptions { + method?: string; + target?: string; + headers?: Readonly>; + body?: string | Uint8Array | readonly (string | Uint8Array)[]; + /** Ticks between body chunks (default 0: all with the request). */ + chunkTicks?: number; + remote?: { address: string; port: number }; + /** Announce a Content-Length (default: total body bytes; null = chunked). */ + length?: number | null; +} + +export interface SimInjectedRequest { + readonly req: number; + status: number; + statusText: string; + headers: Record; + contentLength: number | undefined; + readonly chunks: Uint8Array[]; + responded: boolean; + /** true once respond(end=true) or endBody landed. */ + complete: boolean; + aborted: string | null; + /** Concatenated response body. */ + body(): Uint8Array; + text(): string; + /** Simulate the peer disconnecting; the app sees aborted{closed}. */ + disconnect(): void; +} + +interface Server { + handle: number; + meta: HttpdListenMeta; + listeningTick: number; + listening: boolean; + stopping: boolean; + closeTick: number; + terminal: boolean; +} + +interface Pending { + server: Server; + record: SimInjectedRequest & { visible: Uint8Array[]; visibleBytes: number; chunks_in: Uint8Array[]; nextChunkTick: number; delivered: boolean; deliverTick: number; ended: boolean; drainArmed: boolean; disconnectRequested: boolean; terminal: boolean; options: SimInjectOptions; queued: number }; +} + +export interface SimHttpdHost { + readonly ns: HttpdOps; + tick(): void; + /** Queue a request for the server bound to `port` (or the only server). */ + inject(options?: SimInjectOptions, port?: number): SimInjectedRequest; + readonly log: string[]; + readonly live: () => number; + /** Bytes the sim send queue accepts per respond/write before -2. */ + sendQueueBytes: number; +} + +export const SIM_HTTPD_LIMITS: HttpdLimits = Object.freeze({ + specMajor: HTTPD_SPEC_MAJOR, + specMinor: HTTPD_SPEC_MINOR, + maxServers: HTTPD_MAX_SERVERS, + maxConnections: HTTPD_MAX_CONNECTIONS, + maxInflight: HTTPD_MAX_INFLIGHT, + maxTlsInflight: 0, + maxHeaders: HTTPD_MAX_HEADERS, + maxHeaderBytes: HTTPD_MAX_HEADER_BYTES, + maxTargetBytes: HTTPD_MAX_TARGET_BYTES, + defaultRequestQueueBytes: HTTPD_DEFAULT_REQUEST_QUEUE_BYTES, + maxRequestQueueBytes: HTTPD_MAX_REQUEST_QUEUE_BYTES, + maxSendQueueBytes: HTTPD_MAX_SEND_QUEUE_BYTES, + sendHighWaterBytes: HTTPD_SEND_HIGH_WATER_BYTES, + sendLowWaterBytes: HTTPD_SEND_LOW_WATER_BYTES, + maxEventsPerTick: HTTPD_MAX_EVENTS_PER_TICK, + maxTickBytes: HTTPD_MAX_TICK_BYTES, + defaultHeaderMs: HTTPD_DEFAULT_HEADER_MS, + defaultBodyIdleMs: HTTPD_DEFAULT_BODY_IDLE_MS, + defaultHandlerMs: HTTPD_DEFAULT_HANDLER_MS, + defaultKeepAliveMs: HTTPD_DEFAULT_KEEP_ALIVE_MS, + defaultCloseMs: HTTPD_DEFAULT_CLOSE_MS, + maxTimeoutMs: HTTPD_MAX_TIMEOUT_MS, + tlsMinVersion: NET_TLS_MIN_VERSION, + features: [], +}); + +function toBytes(value: string | Uint8Array): Uint8Array { + return value instanceof Uint8Array ? value.slice() : stringToUtf8(value); +} + +export function createSimHttpdHost(options: SimHostOptions = {}): SimHttpdHost { + const policy = simPolicy(options); + const servers = new Map(); + const requests = new Map(); + const events: object[] = []; + const log: string[] = []; + let nextHandle = 1; + let nextReq = 1; + let nextEphemeral = 40000; + let now = 0; + let lastError = ""; + + const refuse = (code: string, message: string): number => { + lastError = `${code}: ${message}`; + return -1; + }; + + const host: SimHttpdHost = { + sendQueueBytes: HTTPD_MAX_SEND_QUEUE_BYTES, + ns: { + listen(metaJson) { + let meta: HttpdListenMeta; + try { + meta = JSON.parse(metaJson) as HttpdListenMeta; + } catch { + return refuse(NET_ERROR.invalidRequest, "malformed listen metadata"); + } + if (typeof meta.address !== "string" || !Number.isInteger(meta.port)) { + return refuse(NET_ERROR.invalidRequest, "address/port required"); + } + if (meta.tls) return refuse(NET_ERROR.unsupported, "tls not provided"); + if (policy && !networkPolicyAllowsListen(policy, meta.tls ? "https" : "http", meta.address, meta.port)) { + return refuse(NET_ERROR.permissionDenied, "address/port is not an allowed listen rule"); + } + if (servers.size >= HTTPD_MAX_SERVERS) return refuse(NET_ERROR.resourceLimit, "too many servers"); + for (const s of servers.values()) { + if (s.meta.port === meta.port && meta.port !== 0 && !s.terminal) { + // Bind conflicts surface asynchronously like a native bind(). + } + } + const handle = nextHandle++; + servers.set(handle, { handle, meta, listeningTick: now + 1, listening: false, stopping: false, closeTick: 0, terminal: false }); + log.push(`listen ${handle} ${meta.address}:${meta.port}`); + return handle; + }, + stop(handle, graceful, timeoutMs) { + const s = servers.get(handle); + if (!s || s.terminal || s.stopping) return -1; + s.stopping = true; + s.closeTick = now + 1; + log.push(`stop ${handle} ${graceful} ${timeoutMs}`); + return 0; + }, + respond(req, metaJson, body) { + const p = requests.get(req); + if (!p || p.record.responded || p.record.terminal) return HTTPD_SEND_INVALID_REQUEST; + let meta: HttpdRespondMeta; + try { + meta = JSON.parse(metaJson) as HttpdRespondMeta; + } catch { + return HTTPD_SEND_INVALID; + } + if (!Number.isInteger(meta.status) || meta.status < 200 || meta.status > 599) return HTTPD_SEND_INVALID; + const bytes = body ? new Uint8Array(body.slice(0)) : new Uint8Array(0); + const end = meta.end !== false; + if (end && p.record.queued + bytes.length > host.sendQueueBytes) { + p.record.drainArmed = true; + return HTTPD_SEND_BACKPRESSURE; + } + p.record.queued += bytes.length; + if (meta.contentLength !== undefined && end && meta.contentLength !== bytes.length) return HTTPD_SEND_INVALID; + p.record.responded = true; + p.record.status = meta.status; + p.record.statusText = meta.statusText ?? ""; + p.record.headers = { ...(meta.headers ?? {}) }; + p.record.contentLength = meta.contentLength; + if (bytes.length) p.record.chunks.push(bytes); + if (end) { + p.record.complete = true; + finish(p); + } + log.push(`respond ${req} ${meta.status} end=${end} ${bytes.length}`); + return HTTPD_SEND_ACCEPTED; + }, + write(req, chunk) { + const p = requests.get(req); + if (!p || !p.record.responded || p.record.complete || p.record.terminal) return HTTPD_SEND_INVALID_REQUEST; + const bytes = new Uint8Array(chunk.slice(0)); + if (bytes.length > HTTPD_MAX_SEND_QUEUE_BYTES) return HTTPD_SEND_INVALID; + if (p.record.queued + bytes.length > host.sendQueueBytes) { + p.record.drainArmed = true; + return HTTPD_SEND_BACKPRESSURE; + } + p.record.queued += bytes.length; + p.record.chunks.push(bytes); + log.push(`write ${req} ${bytes.length}`); + return HTTPD_SEND_ACCEPTED; + }, + endBody(req) { + const p = requests.get(req); + if (!p || !p.record.responded || p.record.complete || p.record.terminal) return -1; + p.record.complete = true; + finish(p); + log.push(`endBody ${req}`); + return 0; + }, + readInto(req, into, offset, length) { + const p = requests.get(req); + if (!p || !p.record.delivered) return -1; + const dest = new Uint8Array(into, offset, length); + let copied = 0; + while (p.record.visible.length && copied < dest.length) { + const head = p.record.visible[0]; + const n = Math.min(head.length, dest.length - copied); + dest.set(head.subarray(0, n), copied); + copied += n; + if (n === head.length) p.record.visible.shift(); + else p.record.visible[0] = head.subarray(n); + } + p.record.visibleBytes -= copied; + return copied; + }, + abort(req) { + const p = requests.get(req); + if (!p || p.record.terminal) return; + p.record.aborted = NET_ERROR.cancelled; + log.push(`abort ${req}`); + }, + poll() { + return events.length ? JSON.stringify(events.splice(0)) : undefined; + }, + lastError() { + return lastError; + }, + limits() { + return JSON.stringify(SIM_HTTPD_LIMITS); + }, + }, + tick, + inject, + log, + live: () => requests.size, + }; + + function finish(p: Pending): void { + p.record.terminal = true; + requests.delete(p.record.req); + } + + function inject(options: SimInjectOptions = {}, port?: number): SimInjectedRequest { + let server: Server | undefined; + for (const s of servers.values()) { + if (s.terminal) continue; + if (port === undefined || s.meta.port === port) { + server = s; + break; + } + } + if (!server) throw new Error("sim httpd: no server to inject into"); + const req = nextReq++; + const rawBody = options.body ?? ""; + const chunksIn = Array.isArray(rawBody) + ? (rawBody as readonly (string | Uint8Array)[]).map(toBytes) + : [toBytes(rawBody as string | Uint8Array)].filter((c) => c.length > 0); + const record = { + req, + status: 0, + statusText: "", + headers: {} as Record, + contentLength: undefined as number | undefined, + chunks: [] as Uint8Array[], + responded: false, + complete: false, + aborted: null as string | null, + visible: [] as Uint8Array[], + visibleBytes: 0, + chunks_in: chunksIn, + nextChunkTick: now + 1, + delivered: false, + deliverTick: now + 1, + ended: false, + drainArmed: false, + disconnectRequested: false, + terminal: false, + options, + queued: 0, + body(): Uint8Array { + const total = record.chunks.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let o = 0; + for (const c of record.chunks) { + out.set(c, o); + o += c.length; + } + return out; + }, + text(): string { + return new TextDecoder().decode(record.body()); + }, + disconnect(): void { + record.disconnectRequested = true; + }, + }; + requests.set(req, { server, record }); + return record; + } + + function tick(): void { + now++; + for (const s of [...servers.values()]) { + if (s.terminal) continue; + if (!s.listening && now >= s.listeningTick) { + s.listening = true; + const port = s.meta.port === 0 ? nextEphemeral++ : s.meta.port; + s.meta.port = port; + events.push({ t: "listening", h: s.handle, address: s.meta.address, port }); + } + if (s.stopping && now >= s.closeTick) { + s.terminal = true; + servers.delete(s.handle); + for (const p of [...requests.values()]) { + if (p.server === s && !p.record.terminal) { + p.record.aborted = NET_ERROR.closed; + finish(p); + events.push({ t: "aborted", req: p.record.req, code: NET_ERROR.closed }); + } + } + events.push({ t: "closed", h: s.handle }); + } + } + for (const p of [...requests.values()]) { + const r = p.record; + if (r.terminal) continue; + if (r.aborted) { + const code = r.aborted; + finish(p); + events.push({ t: "aborted", req: r.req, code }); + continue; + } + if (r.disconnectRequested) { + r.aborted = NET_ERROR.closed; + finish(p); + events.push({ t: "aborted", req: r.req, code: NET_ERROR.closed }); + continue; + } + if (!p.server.listening) continue; + if (!r.delivered) { + if (now < r.deliverTick) continue; + r.delivered = true; + const total = r.chunks_in.reduce((n, c) => n + c.length, 0); + const headers: Record = { host: `${p.server.meta.address}:${p.server.meta.port}`, ...(r.options.headers ?? {}) }; + const ev: Record = { + t: "request", + h: p.server.handle, + req: r.req, + method: r.options.method ?? "GET", + target: r.options.target ?? "/", + headers, + remote: r.options.remote ?? { address: "127.0.0.1", port: 50000 + r.req }, + secure: false, + }; + if (r.options.length !== null) { + ev.length = r.options.length ?? total; + if (headers["content-length"] === undefined && (ev.length as number) > 0) headers["content-length"] = String(ev.length); + } else headers["transfer-encoding"] = "chunked"; + events.push(ev); + } + let announced = false; + while (r.chunks_in.length && now >= r.nextChunkTick) { + const next = r.chunks_in.shift()!; + r.visible.push(next); + r.visibleBytes += next.length; + announced = true; + r.nextChunkTick = now + (r.options.chunkTicks ?? 0); + if ((r.options.chunkTicks ?? 0) > 0) break; + } + if (announced) events.push({ t: "readable", req: r.req, avail: r.visibleBytes }); + if (r.chunks_in.length === 0 && !r.ended) { + r.ended = true; + events.push({ t: "end", req: r.req }); + } + // The network task wrote the queued bytes out during this tick. + r.queued = 0; + if (r.drainArmed) { + r.drainArmed = false; + events.push({ t: "drain", req: r.req }); + } + } + } + + return host; +} diff --git a/hosts/sim/net.ts b/hosts/sim/net.ts index f2214b3b..4160ce3b 100644 --- a/hosts/sim/net.ts +++ b/hosts/sim/net.ts @@ -1,159 +1,356 @@ -// Deterministic virtual-clock NET module for conformance tests. It never uses -// ambient host networking: routes are fixtures, and completions become visible -// only after tick(), exactly like a native transport crossing a tick boundary. +// Deterministic virtual-clock HTTP Client module (`globalThis.net`, spec v2) +// for conformance tests. It never uses ambient host networking: routes are +// fixtures, response heads and body chunks become visible only after +// tick(), and bytes cross through `readInto` exactly like a native transport +// crossing a tick boundary. Inject via bootWorld's extraGlobals: `{ net: +// host.ns }`, the way a device host mounts the namespace beside `ui`. import { + NET_DEFAULT_AGGREGATE_BYTES, + NET_DEFAULT_QUEUE_BYTES, + NET_DEFAULT_TIMEOUT_MS, NET_ERROR, + NET_MAX_AGGREGATE_BYTES, + NET_MAX_EVENTS_PER_TICK, + NET_MAX_HEADER_BYTES, + NET_MAX_HEADERS, NET_MAX_INFLIGHT, - NET_MAX_RESPONSE_BYTES, + NET_MAX_QUEUE_BYTES, + NET_MAX_REDIRECTS, + NET_MAX_REQUEST_BYTES, + NET_MAX_TICK_BYTES, + NET_MAX_TIMEOUT_MS, + NET_METHODS_FORBIDDEN, + NET_SPEC_MAJOR, + NET_SPEC_MINOR, + NET_TLS_MIN_VERSION, + type NetLimits, + type NetStartMeta, } from "../../contracts/spec/net.ts"; +import { + networkPolicyAllowsConnect, + parseNetworkPolicyJson, + type ResolvedNetworkPolicy, +} from "../../contracts/spec/network-policy.ts"; import { stringToUtf8 } from "../../framework/src/bytes.ts"; -import type { NetOps } from "../../framework/src/net-api.ts"; +import type { NetOps } from "../../framework/src/net/http.ts"; +import { URL } from "../../framework/src/net/url.ts"; + +/** Host options shared by the sim network modules. */ +export interface SimHostOptions { + /** The Build Plan's ResolvedNetworkPolicy (object or canonical JSON). When + * set, the sim enforces it exactly like a native core — connect rule and + * insecureTransport before any route lookup, listen rule before bind, + * the redirect target again — so the policy conformance vectors run on + * this host too. Without it the fixture routes act as the allowlist. */ + readonly policy?: ResolvedNetworkPolicy | string; +} + +export function simPolicy(options: SimHostOptions | undefined): ResolvedNetworkPolicy | null { + const policy = options?.policy; + if (policy === undefined) return null; + return typeof policy === "string" ? parseNetworkPolicyJson(policy) : policy; +} + +/** Endpoint tuple of an absolute http(s)/ws(s) URL for the policy matcher. */ +export function simEndpoint(url: string): { protocol: string; host: string; port: number } | null { + try { + const parsed = new URL(url); + const protocol = parsed.protocol.slice(0, -1); + const host = parsed.hostname.replace(/^\[|\]$/g, ""); + const port = parsed.port ? Number(parsed.port) : protocol === "http" || protocol === "ws" ? 80 : 443; + return { protocol, host, port }; + } catch { + return null; + } +} export interface SimNetRequest { readonly url: string; readonly method: string; readonly headers: Readonly>; readonly body: Uint8Array; - readonly timeoutMs: number; - readonly maxBytes: number; + readonly meta: NetStartMeta; } export interface SimNetResponse { readonly status?: number; + /** Final URL (defaults to the request URL). */ readonly url?: string; + readonly redirected?: boolean; readonly headers?: Readonly>; - readonly body?: string | Uint8Array; - /** Virtual ticks after start before the completion is visible. Default 1. */ + /** Body as one value or as chunks that become visible one per `chunkTicks`. */ + readonly body?: string | Uint8Array | readonly (string | Uint8Array)[]; + /** Announce a Content-Length (default: total body bytes; null = unknown). */ + readonly length?: number | null; + /** Virtual ticks after start before the head is visible. Default 1. */ readonly delayTicks?: number; - readonly error?: { readonly code: string; readonly message: string }; + /** Virtual ticks between body chunks. Default 0 (all with the head). */ + readonly chunkTicks?: number; + /** Fail instead of answering; `afterHeaders` fails the body stream. */ + readonly error?: { readonly code: string; readonly message: string; readonly afterHeaders?: boolean }; } export type SimNetRoute = SimNetResponse | ((request: SimNetRequest) => SimNetResponse); -interface PendingRequest { +interface Pending { readonly handle: number; - readonly readyTick: number; readonly request: SimNetRequest; readonly response: SimNetResponse; + readonly queueBytes: number; + readonly maxBodyBytes: number; + headTick: number; + headSent: boolean; + chunks: Uint8Array[]; + nextChunkTick: number; + /** Bytes visible to readInto (already announced). */ + visible: Uint8Array[]; + visibleBytes: number; + totalDelivered: number; + ended: boolean; + endSent: boolean; + cancelled: boolean; + terminal: boolean; } export interface SimNetHost { readonly ns: NetOps; + /** Advance one virtual tick: the sim's `begin_tick`. */ tick(): void; readonly log: string[]; readonly pollCalls: () => number; + /** Live handles (for leak assertions). */ + readonly live: () => number; } -function bytes(value: string | Uint8Array | undefined): Uint8Array { - if (value instanceof Uint8Array) return value.slice(); - return stringToUtf8(value ?? ""); +function toBytes(value: string | Uint8Array): Uint8Array { + return value instanceof Uint8Array ? value.slice() : stringToUtf8(value); } -export function createSimNetHost(routes: Readonly>): SimNetHost { - const pending = new Map(); - const bodies = new Map(); - const visible: object[] = []; +export const SIM_NET_LIMITS: NetLimits = Object.freeze({ + specMajor: NET_SPEC_MAJOR, + specMinor: NET_SPEC_MINOR, + maxInflight: NET_MAX_INFLIGHT, + maxTlsInflight: 0, + maxRequestBytes: NET_MAX_REQUEST_BYTES, + defaultQueueBytes: NET_DEFAULT_QUEUE_BYTES, + maxQueueBytes: NET_MAX_QUEUE_BYTES, + defaultAggregateBytes: NET_DEFAULT_AGGREGATE_BYTES, + maxAggregateBytes: NET_MAX_AGGREGATE_BYTES, + maxEventsPerTick: NET_MAX_EVENTS_PER_TICK, + maxTickBytes: NET_MAX_TICK_BYTES, + maxHeaders: NET_MAX_HEADERS, + maxHeaderBytes: NET_MAX_HEADER_BYTES, + defaultTimeoutMs: NET_DEFAULT_TIMEOUT_MS, + maxTimeoutMs: NET_MAX_TIMEOUT_MS, + maxRedirects: NET_MAX_REDIRECTS, + tlsMinVersion: NET_TLS_MIN_VERSION, + features: [], +}); + +export function createSimNetHost(routes: Readonly>, options: SimHostOptions = {}): SimNetHost { + const policy = simPolicy(options); + const pending = new Map(); + /** Handles that sent `end` but still hold visible unread bytes. */ + const drained = new Map(); + const events: object[] = []; const log: string[] = []; let nextHandle = 1; let now = 0; let lastError = ""; let polls = 0; + const refuse = (code: string, message: string): number => { + lastError = `${code}: ${message}`; + return -1; + }; + const ns: NetOps = { - start(metaJson: string, bodyBuffer: ArrayBuffer): number { - let meta: Omit; + start(metaJson, bodyBuffer) { + let meta: NetStartMeta; try { - meta = JSON.parse(metaJson) as typeof meta; + meta = JSON.parse(metaJson) as NetStartMeta; } catch { - lastError = `${NET_ERROR.invalidRequest}: malformed metadata`; - return -1; + return refuse(NET_ERROR.invalidRequest, "malformed request metadata"); } - if (pending.size >= NET_MAX_INFLIGHT) { - lastError = `${NET_ERROR.busy}: at most ${NET_MAX_INFLIGHT} requests may be in flight`; - return -1; + if (typeof meta.url !== "string" || !/^https?:\/\//.test(meta.url)) { + return refuse(NET_ERROR.invalidRequest, "url must be absolute http:// or https://"); } - const route = routes[meta.url]; - if (!route) { - lastError = `${NET_ERROR.invalidRequest}: no deterministic route for ${meta.url}`; - return -1; + if (meta.url.startsWith("https://")) return refuse(NET_ERROR.unsupported, "tls not provided"); + if ( + typeof meta.method !== "string" || + !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(meta.method) || + (NET_METHODS_FORBIDDEN as readonly string[]).includes(meta.method.toUpperCase()) + ) { + return refuse(NET_ERROR.invalidRequest, "method not allowed"); } - const request: SimNetRequest = { ...meta, body: new Uint8Array(bodyBuffer).slice() }; + if (pending.size >= NET_MAX_INFLIGHT) return refuse(NET_ERROR.resourceLimit, "too many requests in flight"); + const body = bodyBuffer ? new Uint8Array(bodyBuffer.slice(0)) : new Uint8Array(0); + if (body.length > NET_MAX_REQUEST_BYTES) return refuse(NET_ERROR.resourceLimit, "request body too large"); + if (policy) { + const endpoint = simEndpoint(meta.url); + if (!endpoint || !networkPolicyAllowsConnect(policy, endpoint.protocol, endpoint.host, endpoint.port)) { + return refuse(NET_ERROR.permissionDenied, "endpoint is not an allowed connect rule"); + } + } + const route = routes[meta.url]; + if (!route) return refuse(NET_ERROR.permissionDenied, `no route for ${meta.url}`); + const request: SimNetRequest = { url: meta.url, method: meta.method, headers: meta.headers ?? {}, body, meta }; const response = typeof route === "function" ? route(request) : route; const handle = nextHandle++; - const delay = Math.max(1, Math.floor(response.delayTicks ?? 1)); - pending.set(handle, { handle, request, response, readyTick: now + delay }); - log.push(`start ${handle} ${request.method} ${request.url} ${request.body.byteLength}`); + const rawBody = response.body ?? ""; + const chunks = Array.isArray(rawBody) + ? (rawBody as readonly (string | Uint8Array)[]).map(toBytes) + : [toBytes(rawBody as string | Uint8Array)].filter((c) => c.length > 0); + const headTick = now + Math.max(1, response.delayTicks ?? 1); + pending.set(handle, { + handle, + request, + response, + queueBytes: meta.queueBytes ?? NET_DEFAULT_QUEUE_BYTES, + maxBodyBytes: meta.maxBodyBytes ?? Number.POSITIVE_INFINITY, + headTick, + headSent: false, + chunks, + nextChunkTick: headTick, + visible: [], + visibleBytes: 0, + totalDelivered: 0, + ended: false, + endSent: false, + cancelled: false, + terminal: false, + }); + log.push(`start ${handle} ${meta.method} ${meta.url} ${body.length}`); return handle; }, - take(handle: number, into: ArrayBuffer): number { - const body = bodies.get(handle); - if (!body || into.byteLength !== body.byteLength) return -1; - bodies.delete(handle); - log.push(`take ${handle} ${body.byteLength}`); - new Uint8Array(into).set(body); - return body.byteLength; - }, - cancel(handle: number): void { - pending.delete(handle); - bodies.delete(handle); - for (let i = visible.length - 1; i >= 0; i--) { - if ((visible[i] as { h?: number }).h === handle) visible.splice(i, 1); + cancel(handle) { + const p = pending.get(handle); + if (!p || p.terminal) { + // A handle that already ended keeps unread bytes until the guest + // releases them; cancel frees them without another event. + if (drained.delete(handle)) log.push(`cancel ${handle}`); + return; } + p.cancelled = true; log.push(`cancel ${handle}`); }, - poll(): string | undefined { + poll() { polls++; - if (visible.length === 0) return undefined; - const batch = JSON.stringify(visible.splice(0)); - log.push(`poll ${batch}`); - return batch; + return events.length ? JSON.stringify(events.splice(0)) : undefined; }, - lastError(): string { + lastError() { return lastError; }, + readInto(handle, into, offset, length) { + const p = pending.get(handle) ?? drained.get(handle); + if (!p || !p.headSent) return -1; + if (p.terminal && !drained.has(handle)) return -1; + const dest = new Uint8Array(into, offset, length); + let copied = 0; + while (p.visible.length && copied < dest.length) { + const head = p.visible[0]; + const n = Math.min(head.length, dest.length - copied); + dest.set(head.subarray(0, n), copied); + copied += n; + if (n === head.length) p.visible.shift(); + else p.visible[0] = head.subarray(n); + } + p.visibleBytes -= copied; + if (p.visible.length === 0) drained.delete(handle); + return copied; + }, + limits() { + return JSON.stringify(SIM_NET_LIMITS); + }, }; - return { - ns, - tick(): void { - now++; - for (const [handle, item] of [...pending]) { - if (item.readyTick > now) continue; - pending.delete(handle); - const response = item.response; - if (response.error) { - visible.push({ - t: "error", - h: handle, - code: response.error.code, - message: response.error.message, - }); + function tick(): void { + now++; + for (const p of [...pending.values()]) { + if (p.terminal) continue; + if (p.cancelled) { + p.terminal = true; + pending.delete(p.handle); + events.push({ t: "error", h: p.handle, code: NET_ERROR.cancelled, message: "cancelled" }); + continue; + } + if (!p.headSent) { + if (now < p.headTick) continue; + if (p.response.error && !p.response.error.afterHeaders) { + p.terminal = true; + pending.delete(p.handle); + events.push({ t: "error", h: p.handle, code: p.response.error.code, message: p.response.error.message }); continue; } - const body = bytes(response.body); - const limit = Math.min(item.request.maxBytes, NET_MAX_RESPONSE_BYTES); - if (body.byteLength > limit) { - visible.push({ - t: "error", - h: handle, - code: NET_ERROR.responseTooLarge, - message: `response exceeded ${limit} bytes`, - }); + // A fixture that answers from another URL stands in for a redirect: + // the target is re-authorized like a native core re-checks each hop. + if (policy && p.response.url !== undefined && p.response.url !== p.request.url) { + const endpoint = simEndpoint(p.response.url); + if (!endpoint || !networkPolicyAllowsConnect(policy, endpoint.protocol, endpoint.host, endpoint.port)) { + p.terminal = true; + pending.delete(p.handle); + events.push({ t: "error", h: p.handle, code: NET_ERROR.permissionDenied, message: "redirect target is not an allowed endpoint" }); + continue; + } + } + p.headSent = true; + const total = p.chunks.reduce((n, c) => n + c.length, 0); + const head: Record = { + t: "headers", + h: p.handle, + status: p.response.status ?? 200, + url: p.response.url ?? p.request.url, + headers: p.response.headers ?? {}, + redirected: p.response.redirected ?? false, + }; + if (p.response.length !== null) head.length = p.response.length ?? total; + events.push(head); + } + // Body chunks: each becomes visible when its tick arrives and the + // queue has room (queueBytes is the backpressure window). + let announced = false; + while (p.chunks.length && now >= p.nextChunkTick) { + const next = p.chunks[0]; + if (p.visibleBytes + next.length > p.queueBytes && p.visibleBytes > 0) break; + if (p.totalDelivered + next.length > p.maxBodyBytes) { + p.terminal = true; + pending.delete(p.handle); + events.push({ t: "error", h: p.handle, code: NET_ERROR.responseTooLarge, message: "body exceeds maxBodyBytes" }); + break; + } + p.chunks.shift(); + p.visible.push(next); + p.visibleBytes += next.length; + p.totalDelivered += next.length; + announced = true; + p.nextChunkTick = now + (p.response.chunkTicks ?? 0); + if ((p.response.chunkTicks ?? 0) > 0) break; + } + if (p.terminal) continue; + if (announced) events.push({ t: "readable", h: p.handle, avail: p.visibleBytes }); + if (p.chunks.length === 0 && !p.endSent) { + if (p.response.error?.afterHeaders) { + p.terminal = true; + pending.delete(p.handle); + events.push({ t: "error", h: p.handle, code: p.response.error.code, message: p.response.error.message }); continue; } - bodies.set(handle, body); - visible.push({ - t: "done", - h: handle, - status: response.status ?? 200, - url: response.url ?? item.request.url, - headers: response.headers ?? {}, - bytes: body.byteLength, - }); + p.endSent = true; + p.terminal = true; + pending.delete(p.handle); + events.push({ t: "end", h: p.handle }); + // Visible bytes stay readable after `end`; the SDK drains them. + if (p.visibleBytes > 0) drained.set(p.handle, p); } - }, + } + } + + return { + ns, + tick, log, pollCalls: () => polls, + live: () => pending.size, }; } diff --git a/hosts/sim/sim.ts b/hosts/sim/sim.ts index f05e6e45..2133f5e3 100644 --- a/hosts/sim/sim.ts +++ b/hosts/sim/sim.ts @@ -243,6 +243,9 @@ export async function bootWorld( g.audio = undefined; // audio module namespace: absent unless extraGlobals mounts one g.db = undefined; // db module namespace: absent unless extraGlobals mounts one g.fs = undefined; // fs module namespace: absent unless extraGlobals mounts one + g.net = undefined; // HTTP Client module namespace (hosts/sim/net.ts): absent unless mounted + g.ws = undefined; // WebSocket Client module namespace (hosts/sim/ws.ts): absent unless mounted + g.httpd = undefined; // HTTP Server module namespace (hosts/sim/httpd.ts): absent unless mounted g.__pocketApp = app; g.__simHz = hz; g.__pocketEffectTrace = (e: EffectEvent) => effects.push(e); diff --git a/hosts/sim/ws.ts b/hosts/sim/ws.ts new file mode 100644 index 00000000..6d015dd4 --- /dev/null +++ b/hosts/sim/ws.ts @@ -0,0 +1,317 @@ +// Deterministic virtual-clock WebSocket Client module (`globalThis.ws`, +// spec v2) for conformance tests. Peers are fixtures keyed by URL: they +// answer the handshake, echo or script messages, and every event becomes +// visible at the next tick(). Inject via bootWorld's extraGlobals: +// `{ ws: host.ns }`. + +import { NET_ERROR, NET_TLS_MIN_VERSION } from "../../contracts/spec/net.ts"; +import { + WS_BLOB_KEY, + WS_CONTROL_PAYLOAD_MAX, + WS_DEFAULT_CLOSE_MS, + WS_DEFAULT_CONNECT_MS, + WS_MAX_CONNECT_MS, + WS_MAX_EVENTS_PER_TICK, + WS_MAX_HANDSHAKE_HEADERS, + WS_MAX_HANDSHAKE_HEADER_BYTES, + WS_MAX_MESSAGE_BYTES, + WS_MAX_RECEIVE_QUEUE_BYTES, + WS_MAX_RECEIVE_QUEUE_MESSAGES, + WS_MAX_SEND_QUEUE_BYTES, + WS_MAX_SOCKETS, + WS_MAX_TICK_BYTES, + WS_OPCODE, + WS_SEND_ACCEPTED, + WS_SEND_ACCEPTED_HIGH_WATER, + WS_SEND_BACKPRESSURE, + WS_SEND_CLOSED, + WS_SEND_HIGH_WATER_BYTES, + WS_SEND_INVALID, + WS_SEND_LOW_WATER_BYTES, + WS_SPEC_MAJOR, + WS_SPEC_MINOR, + type WsConnectMeta, + type WsLimits, +} from "../../contracts/spec/ws.ts"; +import { networkPolicyAllowsConnect } from "../../contracts/spec/network-policy.ts"; +import { bytesToBase64, stringToUtf8, utf8ToString } from "../../framework/src/bytes.ts"; +import type { WsOps } from "../../framework/src/net/websocket.ts"; +import { simEndpoint, simPolicy, type SimHostOptions } from "./net.ts"; + +export interface SimWsPeer { + /** Subprotocol the peer selects (default: first requested or ""). */ + protocol?: string; + /** Ticks after connect before `open` (default 1). */ + delayTicks?: number; + /** Fail the handshake instead of opening. */ + error?: { code: string; message: string; status?: number }; + /** Called for each text/binary message the app sends; the returned value + * (if any) is sent back next tick. Default: echo. */ + onMessage?: (data: string | Uint8Array, peer: SimWsPeerControl) => string | Uint8Array | void; + /** Bytes the peer's send window accepts before `send` reports backpressure + * (default: the spec send queue). */ + sendWindowBytes?: number; +} + +export interface SimWsPeerControl { + /** Send a message to the app (visible next tick). */ + send(data: string | Uint8Array): void; + ping(payload?: Uint8Array): void; + /** Peer-initiated close handshake. */ + close(code?: number, reason?: string): void; + /** Transport loss without a Close frame. */ + drop(): void; +} + +interface Socket { + handle: number; + meta: WsConnectMeta; + peer: SimWsPeer; + openTick: number; + open: boolean; + closing: boolean; + /** Queued events for this socket, released in order at tick(). */ + inbox: object[]; + outbound: Uint8Array[]; // messages awaiting receiveInto + buffered: number; + drainArmed: boolean; + terminate: boolean; + closeRequested: { code: number; reason: string } | null; + closeTick: number; + terminal: boolean; + control: SimWsPeerControl; +} + +export interface SimWsHost { + readonly ns: WsOps; + tick(): void; + readonly log: string[]; + readonly live: () => number; + /** Peer control for an open socket URL (first match). */ + peer(url: string): SimWsPeerControl; +} + +export const SIM_WS_LIMITS: WsLimits = Object.freeze({ + specMajor: WS_SPEC_MAJOR, + specMinor: WS_SPEC_MINOR, + maxSockets: WS_MAX_SOCKETS, + maxTlsInflight: 0, + maxMessageBytes: WS_MAX_MESSAGE_BYTES, + maxReceiveQueueBytes: WS_MAX_RECEIVE_QUEUE_BYTES, + maxReceiveQueueMessages: WS_MAX_RECEIVE_QUEUE_MESSAGES, + maxSendQueueBytes: WS_MAX_SEND_QUEUE_BYTES, + sendHighWaterBytes: WS_SEND_HIGH_WATER_BYTES, + sendLowWaterBytes: WS_SEND_LOW_WATER_BYTES, + maxHandshakeHeaders: WS_MAX_HANDSHAKE_HEADERS, + maxHandshakeHeaderBytes: WS_MAX_HANDSHAKE_HEADER_BYTES, + maxEventsPerTick: WS_MAX_EVENTS_PER_TICK, + maxTickBytes: WS_MAX_TICK_BYTES, + defaultConnectMs: WS_DEFAULT_CONNECT_MS, + maxConnectMs: WS_MAX_CONNECT_MS, + defaultCloseMs: WS_DEFAULT_CLOSE_MS, + tlsMinVersion: NET_TLS_MIN_VERSION, + features: [], +}); + +export function createSimWsHost(peers: Readonly>, options: SimHostOptions = {}): SimWsHost { + const policy = simPolicy(options); + const sockets = new Map(); + const events: object[] = []; + const log: string[] = []; + let nextHandle = 1; + let now = 0; + let lastError = ""; + + const refuse = (code: string, message: string): number => { + lastError = `${code}: ${message}`; + return -1; + }; + + function queueMessage(s: Socket, data: string | Uint8Array): void { + if (typeof data === "string") s.inbox.push({ t: "message", h: s.handle, kind: "text", text: data }); + else { + s.outbound.push(data.slice()); + s.inbox.push({ t: "message", h: s.handle, kind: "binary", bytes: data.length }); + } + } + + const ns: WsOps = { + connect(metaJson) { + let meta: WsConnectMeta; + try { + meta = JSON.parse(metaJson) as WsConnectMeta; + } catch { + return refuse(NET_ERROR.invalidRequest, "malformed connect metadata"); + } + if (typeof meta.url !== "string" || !/^wss?:\/\//.test(meta.url)) { + return refuse(NET_ERROR.invalidRequest, "url must be ws:// or wss://"); + } + if (meta.url.startsWith("wss://")) return refuse(NET_ERROR.unsupported, "tls not provided"); + if (sockets.size >= WS_MAX_SOCKETS) return refuse(NET_ERROR.resourceLimit, "too many sockets"); + if (policy) { + const endpoint = simEndpoint(meta.url); + if (!endpoint || !networkPolicyAllowsConnect(policy, endpoint.protocol, endpoint.host, endpoint.port)) { + return refuse(NET_ERROR.permissionDenied, "endpoint is not an allowed connect rule"); + } + } + const peer = peers[meta.url]; + if (!peer) return refuse(NET_ERROR.permissionDenied, `no peer for ${meta.url}`); + const handle = nextHandle++; + const socket: Socket = { + handle, + meta, + peer, + openTick: now + Math.max(1, peer.delayTicks ?? 1), + open: false, + closing: false, + inbox: [], + outbound: [], + buffered: 0, + drainArmed: false, + terminate: false, + closeRequested: null, + closeTick: 0, + terminal: false, + control: null as unknown as SimWsPeerControl, + }; + socket.control = { + send: (data) => queueMessage(socket, data), + ping: (payload) => { + socket.inbox.push({ t: "ping", h: handle, payload: { [WS_BLOB_KEY]: bytesToBase64(payload ?? new Uint8Array(0)) } }); + }, + close: (code = 1000, reason = "") => { + if (socket.terminal) return; + socket.inbox.push({ t: "close", h: handle, code, reason, clean: true, local: false }); + socket.terminal = true; + }, + drop: () => { + if (socket.terminal) return; + socket.inbox.push({ t: "error", h: handle, code: NET_ERROR.closed, message: "connection lost" }); + socket.inbox.push({ t: "close", h: handle, code: 1006, reason: "", clean: false, local: false }); + socket.terminal = true; + }, + }; + sockets.set(handle, socket); + log.push(`connect ${handle} ${meta.url}`); + return handle; + }, + send(handle, opcode, payload) { + const s = sockets.get(handle); + if (!s || !s.open || s.closing || s.terminal) return WS_SEND_CLOSED; + const bytes = payload === null ? new Uint8Array(0) : typeof payload === "string" ? stringToUtf8(payload) : new Uint8Array(payload.slice(0)); + if (opcode === WS_OPCODE.ping || opcode === WS_OPCODE.pong) { + if (bytes.length > WS_CONTROL_PAYLOAD_MAX) return WS_SEND_INVALID; + if (opcode === WS_OPCODE.ping) s.inbox.push({ t: "pong", h: handle, payload: { [WS_BLOB_KEY]: bytesToBase64(bytes) } }); + log.push(`${opcode === WS_OPCODE.ping ? "ping" : "pong"} ${handle} ${bytes.length}`); + return WS_SEND_ACCEPTED; + } + if (opcode !== WS_OPCODE.text && opcode !== WS_OPCODE.binary) return WS_SEND_INVALID; + const maxMessage = s.meta.limits?.maxMessageBytes ?? WS_MAX_MESSAGE_BYTES; + if (bytes.length > maxMessage) return WS_SEND_INVALID; + const window = s.peer.sendWindowBytes ?? (s.meta.limits?.sendQueueBytes ?? WS_MAX_SEND_QUEUE_BYTES); + if (s.buffered + bytes.length > window) { + s.drainArmed = true; + return WS_SEND_BACKPRESSURE; + } + s.buffered += bytes.length; + const data = opcode === WS_OPCODE.text ? utf8ToString(bytes) : bytes; + log.push(`send ${handle} ${opcode === WS_OPCODE.text ? "text" : "binary"} ${bytes.length}`); + const reply = s.peer.onMessage ? s.peer.onMessage(data, s.control) : data; + if (reply !== undefined) queueMessage(s, reply); + const high = WS_SEND_HIGH_WATER_BYTES; + const rc = s.buffered > high ? WS_SEND_ACCEPTED_HIGH_WATER : WS_SEND_ACCEPTED; + if (rc === WS_SEND_ACCEPTED_HIGH_WATER) s.drainArmed = true; + return rc; + }, + receiveInto(handle, into, offset, length) { + const s = sockets.get(handle); + if (!s || !s.outbound.length) return -1; + const head = s.outbound[0]; + if (length < head.length) return -1; + new Uint8Array(into, offset, length).set(head); + s.outbound.shift(); + return head.length; + }, + close(handle, code, reason) { + const s = sockets.get(handle); + if (!s || !s.open || s.closing || s.terminal) return -1; + if (code !== undefined && code !== 1000 && (code < 3000 || code > 4999)) return WS_SEND_INVALID; + if (reason !== undefined && stringToUtf8(reason).length > 123) return WS_SEND_INVALID; + s.closing = true; + s.closeRequested = { code: code ?? 1005, reason: reason ?? "" }; + s.closeTick = now + 1; + log.push(`close ${handle} ${code ?? ""}`); + return 0; + }, + terminate(handle) { + const s = sockets.get(handle); + if (!s || s.terminal) return; + s.terminate = true; + log.push(`terminate ${handle}`); + }, + bufferedAmount(handle) { + const s = sockets.get(handle); + return s ? s.buffered : -1; + }, + poll() { + return events.length ? JSON.stringify(events.splice(0)) : undefined; + }, + lastError() { + return lastError; + }, + limits() { + return JSON.stringify(SIM_WS_LIMITS); + }, + }; + + function tick(): void { + now++; + for (const s of [...sockets.values()]) { + if (s.terminate) { + sockets.delete(s.handle); + if (!s.open) events.push({ t: "error", h: s.handle, code: NET_ERROR.cancelled, message: "terminated" }); + else events.push({ t: "close", h: s.handle, code: 1006, reason: "", clean: false, local: true }); + continue; + } + if (!s.open) { + if (now < s.openTick) continue; + if (s.peer.error) { + sockets.delete(s.handle); + events.push({ t: "error", h: s.handle, code: s.peer.error.code, message: s.peer.error.message, status: s.peer.error.status }); + continue; + } + s.open = true; + const protocol = s.peer.protocol ?? (s.meta.protocols?.[0] ?? ""); + events.push({ t: "open", h: s.handle, protocol }); + } + // The peer "consumed" what the app sent: release the send window. + if (s.buffered > 0) { + s.buffered = 0; + if (s.drainArmed) { + s.drainArmed = false; + s.inbox.push({ t: "drain", h: s.handle }); + } + } + if (s.inbox.length) events.push(...s.inbox.splice(0)); + if (s.terminal) { + sockets.delete(s.handle); + continue; + } + if (s.closing && s.closeRequested && now >= s.closeTick) { + sockets.delete(s.handle); + events.push({ t: "close", h: s.handle, code: s.closeRequested.code === 1005 ? 1005 : s.closeRequested.code, reason: s.closeRequested.reason, clean: true, local: true }); + } + } + } + + return { + ns, + tick, + log, + live: () => sockets.size, + peer(url) { + for (const s of sockets.values()) if (s.meta.url === url) return s.control; + throw new Error(`sim ws: no live socket for ${url}`); + }, + }; +} diff --git a/hosts/web/net-spec.js b/hosts/web/net-spec.js new file mode 100644 index 00000000..5d0cc829 --- /dev/null +++ b/hosts/web/net-spec.js @@ -0,0 +1,25 @@ +// GENERATED — do not edit; run `bun contracts/spec/gen-web.ts`. +// Plain-ESM mirror of contracts/spec/net.ts for the browser dev host +// (hosts/web/net.js). tests/contract.ts byte-compares this file. +export const NET_SPEC_MAJOR = 2; +export const NET_SPEC_MINOR = 0; +export const NET_MAX_INFLIGHT = 8; +export const NET_MAX_REQUEST_BYTES = 262144; +export const NET_DEFAULT_QUEUE_BYTES = 32768; +export const NET_MAX_QUEUE_BYTES = 262144; +export const NET_DEFAULT_AGGREGATE_BYTES = 1048576; +export const NET_MAX_AGGREGATE_BYTES = 8388608; +export const NET_MAX_EVENTS_PER_TICK = 128; +export const NET_MAX_TICK_BYTES = 262144; +export const NET_MAX_HEADERS = 64; +export const NET_MAX_HEADER_BYTES = 16384; +export const NET_DEFAULT_TIMEOUT_MS = 30000; +export const NET_MAX_TIMEOUT_MS = 120000; +export const NET_MAX_REDIRECTS = 5; +export const NET_TLS_MIN_VERSION = "1.2"; +export const NET_METHODS_FORBIDDEN = ["CONNECT","TRACE","TRACK"]; +export const HTTP_CORE_OWNED_REQUEST_HEADERS = ["host","connection","content-length","transfer-encoding","trailer","te","upgrade","keep-alive","expect","proxy-connection"]; +export const HTTP_NULL_BODY_STATUS = [101,103,204,205,304]; +export const HTTP_REDIRECT_STATUS = [301,302,303,307,308]; +export const NET_EVENT = {"headers":"headers","readable":"readable","end":"end","error":"error","drain":"drain"}; +export const NET_ERROR = {"invalidRequest":"invalid_request","invalidState":"invalid_state","unsupported":"unsupported","permissionDenied":"permission_denied","busy":"busy","resourceLimit":"resource_limit","dns":"dns","connect":"connect","addressInUse":"address_in_use","closed":"closed","timeout":"timeout","tlsCertificateInvalid":"tls_certificate_invalid","tlsHostnameMismatch":"tls_hostname_mismatch","tlsHandshakeFailed":"tls_handshake_failed","tlsClockUntrusted":"tls_clock_untrusted","redirect":"redirect","responseTooLarge":"response_too_large","protocol":"protocol","websocketHandshakeFailed":"websocket_handshake_failed","websocketProtocolError":"websocket_protocol_error","messageTooLarge":"message_too_large","cancelled":"cancelled","other":"other","unavailable":"unavailable"}; diff --git a/hosts/web/net.js b/hosts/web/net.js index 3eefe75f..b53bc290 100644 --- a/hosts/web/net.js +++ b/hosts/web/net.js @@ -1,15 +1,80 @@ -// Browser dev host for the PocketJS NET module. Browser fetch is the physical -// transport; this adapter supplies the bounded contract and tick batching from -// contracts/spec/net.ts without exposing browser globals as the guest API. +// Browser dev host for the PocketJS HTTP Client module (`globalThis.net`, +// contracts/spec/net.ts v2). Browser fetch is the physical transport; this +// adapter supplies the spec-shaped ops, the bounded receive queue and the +// tick batching without exposing browser globals as the guest API. +// +// Delivery contract: fetch +// callbacks only ever append to `completed`; `beginFrame()` (the host's tick +// boundary) freezes each handle's readable watermark and moves the facts into +// `visible`; `poll()` reads `visible` alone. Bytes read from the response +// stream stay in a per-handle queue until the guest copies them out with +// `readInto`; the reader stops pulling while the queue is at capacity. +// +// Browser profile deviations: credentials "omit", cache "no-store", +// redirect "manual" — a redirect the browser hides ends the request with +// `unsupported`; TLS is the browser's, so "tls" is advertised. -const MAX_INFLIGHT = 2; -const MAX_REQUEST_BYTES = 64 * 1024; -const MAX_RESPONSE_BYTES = 256 * 1024; -const MAX_HEADERS = 32; -const MAX_HEADER_BYTES = 8 * 1024; -const MAX_TIMEOUT_MS = 120_000; -const MAX_REDIRECTS = 3; -const METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]); +import { + HTTP_NULL_BODY_STATUS, + NET_DEFAULT_AGGREGATE_BYTES, + NET_DEFAULT_QUEUE_BYTES, + NET_DEFAULT_TIMEOUT_MS, + NET_MAX_AGGREGATE_BYTES, + NET_MAX_EVENTS_PER_TICK, + NET_MAX_HEADER_BYTES, + NET_MAX_HEADERS, + NET_MAX_INFLIGHT, + NET_MAX_QUEUE_BYTES, + NET_MAX_REDIRECTS, + NET_MAX_REQUEST_BYTES, + NET_MAX_TICK_BYTES, + NET_MAX_TIMEOUT_MS, + NET_METHODS_FORBIDDEN, + NET_SPEC_MAJOR, + NET_SPEC_MINOR, + NET_TLS_MIN_VERSION, +} from "./net-spec.js"; + +// The spec ceilings, as this host's effective limits (it tightens none of +// them; the generated net-spec.js is the single source, never literals here). +const SPEC_MAJOR = NET_SPEC_MAJOR; +const SPEC_MINOR = NET_SPEC_MINOR; +const MAX_INFLIGHT = NET_MAX_INFLIGHT; +const MAX_REQUEST_BYTES = NET_MAX_REQUEST_BYTES; +const DEFAULT_QUEUE_BYTES = NET_DEFAULT_QUEUE_BYTES; +const MAX_QUEUE_BYTES = NET_MAX_QUEUE_BYTES; +const DEFAULT_AGGREGATE_BYTES = NET_DEFAULT_AGGREGATE_BYTES; +const MAX_AGGREGATE_BYTES = NET_MAX_AGGREGATE_BYTES; +const MAX_EVENTS_PER_TICK = NET_MAX_EVENTS_PER_TICK; +const MAX_TICK_BYTES = NET_MAX_TICK_BYTES; +const MAX_HEADERS = NET_MAX_HEADERS; +const MAX_HEADER_BYTES = NET_MAX_HEADER_BYTES; +const DEFAULT_TIMEOUT_MS = NET_DEFAULT_TIMEOUT_MS; +const MAX_TIMEOUT_MS = NET_MAX_TIMEOUT_MS; +const MAX_REDIRECTS = NET_MAX_REDIRECTS; +const FORBIDDEN_METHODS = new Set(NET_METHODS_FORBIDDEN); +const NULL_BODY_STATUS = new Set(HTTP_NULL_BODY_STATUS); + +const LIMITS = Object.freeze({ + specMajor: SPEC_MAJOR, + specMinor: SPEC_MINOR, + maxInflight: MAX_INFLIGHT, + maxTlsInflight: MAX_INFLIGHT, + maxRequestBytes: MAX_REQUEST_BYTES, + defaultQueueBytes: DEFAULT_QUEUE_BYTES, + maxQueueBytes: MAX_QUEUE_BYTES, + defaultAggregateBytes: DEFAULT_AGGREGATE_BYTES, + maxAggregateBytes: MAX_AGGREGATE_BYTES, + maxEventsPerTick: MAX_EVENTS_PER_TICK, + maxTickBytes: MAX_TICK_BYTES, + maxHeaders: MAX_HEADERS, + maxHeaderBytes: MAX_HEADER_BYTES, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + maxTimeoutMs: MAX_TIMEOUT_MS, + maxRedirects: MAX_REDIRECTS, + tlsMinVersion: NET_TLS_MIN_VERSION, + features: ["tls"], +}); function headerBytes(headers) { let bytes = 0; @@ -31,77 +96,16 @@ function validHeaders(headers) { ); } -function failure(error, timedOut) { - if (timedOut) return { code: "timeout", message: "request timed out" }; - const message = error instanceof Error ? error.message : String(error); - return { code: "connect", message }; -} - -async function readBounded(response, maxBytes) { - if (!response.body) { - const body = new Uint8Array(await response.arrayBuffer()); - if (body.byteLength > maxBytes) throw new Error("response_too_large"); - return body; - } - const reader = response.body.getReader(); - const chunks = []; - let size = 0; - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - size += value.byteLength; - if (size > maxBytes) { - await reader.cancel(); - throw new Error("response_too_large"); - } - chunks.push(value); - } - } finally { - reader.releaseLock(); - } - const body = new Uint8Array(size); - let offset = 0; - for (const chunk of chunks) { - body.set(chunk, offset); - offset += chunk.byteLength; - } - return body; -} - -async function followBounded(nativeFetch, request, signal) { - let url = request.url; - let method = request.method; - let body = request.body.byteLength ? request.body : undefined; - for (let redirects = 0; ; redirects++) { - const response = await nativeFetch(url, { - method, - headers: request.headers, - body: method === "GET" || method === "HEAD" ? undefined : body, - credentials: "omit", - cache: "no-store", - redirect: "manual", - signal, - }); - if (response.type === "opaqueredirect") throw new Error("redirect_opaque"); - if (![301, 302, 303, 307, 308].includes(response.status)) return response; - await response.body?.cancel(); - if (redirects >= MAX_REDIRECTS) throw new Error("redirect_limit"); - const location = response.headers.get("location"); - if (!location) throw new Error("redirect_location"); - url = new URL(location, url).href; - if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === "POST")) { - method = "GET"; - body = undefined; - } - } +function timeoutValue(value, fallback) { + if (value === undefined) return fallback; + if (!Number.isInteger(value) || value < 1 || value > MAX_TIMEOUT_MS) return null; + return value; } export function createNetHost(nativeFetch = globalThis.fetch.bind(globalThis)) { let nextHandle = 1; let lastError = ""; - const pending = new Map(); // handle -> AbortController - const bodies = new Map(); // handle -> Uint8Array + const states = new Map(); // handle -> state (until retired) const completed = []; // async transport facts, not guest-visible yet const visible = []; // facts frozen at beginFrame() @@ -110,6 +114,150 @@ export function createNetHost(nativeFetch = globalThis.fetch.bind(globalThis)) { return -1; } + function inflight() { + let n = 0; + for (const s of states.values()) if (!s.terminal) n++; + return n; + } + + function stopTimers(state) { + for (const t of state.timers) clearTimeout(t); + state.timers.length = 0; + } + + /** Terminal failure: one error event, native resources released. */ + function fail(state, code, message) { + if (state.terminal) return; + state.terminal = true; + stopTimers(state); + state.controller.abort(); + state.reader?.cancel().catch(() => {}); + state.chunks.length = 0; + state.queued = 0; + states.delete(state.handle); + completed.push({ t: "error", h: state.handle, code, message }); + } + + /** Terminal EOF: `end` event; unread bytes stay readable until drained. */ + function end(state) { + if (state.terminal) return; + state.terminal = true; + state.ended = true; + stopTimers(state); + completed.push({ t: "end", h: state.handle }); + if (state.queued === 0) states.delete(state.handle); + } + + async function run(state, meta, body) { + let response; + try { + response = await nativeFetch(meta.url, { + method: meta.method, + headers: meta.headers, + body: meta.method === "GET" || meta.method === "HEAD" || body.byteLength === 0 ? undefined : body, + credentials: "omit", + cache: "no-store", + redirect: "manual", + signal: state.controller.signal, + }); + } catch (error) { + if (!state.terminal) fail(state, state.timedOut ? "timeout" : "connect", error instanceof Error ? error.message : String(error)); + return; + } + if (state.terminal) { + await response.body?.cancel().catch(() => {}); + return; + } + clearTimeout(state.headersTimer); + if (response.type === "opaqueredirect") { + fail(state, "unsupported", "the browser hides redirect targets"); + return; + } + if ([301, 302, 303, 307, 308].includes(response.status) && meta.redirect === "error") { + fail(state, "redirect", `redirect ${response.status} refused`); + await response.body?.cancel().catch(() => {}); + return; + } + const headers = Object.create(null); + response.headers.forEach((value, name) => { + headers[name.toLowerCase()] = value; + }); + if (!validHeaders(headers)) { + fail(state, "protocol", "response headers exceed limits"); + await response.body?.cancel().catch(() => {}); + return; + } + const head = { + t: "headers", + h: state.handle, + status: response.status, + url: response.url || meta.url, + headers, + redirected: response.redirected === true, + }; + const lengthHeader = response.headers.get("content-length"); + if (lengthHeader !== null && /^\d+$/.test(lengthHeader)) head.length = Number(lengthHeader); + completed.push(head); + state.headSent = true; + if (!response.body || meta.method === "HEAD" || NULL_BODY_STATUS.has(response.status)) { + await response.body?.cancel().catch(() => {}); + end(state); + return; + } + // A BYOB reader bounds every read to the queue's free space, so the + // receive queue is a hard cap (queueBytes) the way a native core's is. + // Bodies that are not byte streams (some runtimes' synthetic responses) + // fall back to the default reader, whose chunks are sized by the + // browser: the host then stops pulling at the cap but the chunk that + // crossed it is held whole (at most one chunk past queueBytes). + let reader; + let byob = false; + try { + reader = response.body.getReader({ mode: "byob" }); + byob = true; + } catch { + reader = response.body.getReader(); + } + state.reader = reader; + try { + for (;;) { + // Backpressure: never pull past the queue capacity. + while (state.queued >= state.queueBytes && !state.terminal) { + await new Promise((resolve) => { + state.wake = resolve; + }); + } + if (state.terminal) break; + state.armIdle(); + const { done, value } = byob + ? await reader.read(new Uint8Array(Math.min(state.queueBytes - state.queued, 64 * 1024))) + : await reader.read(); + if (state.terminal) break; + if (done) { + end(state); + break; + } + state.total += value.byteLength; + if (state.total > state.maxBodyBytes) { + fail(state, "response_too_large", `body exceeds ${state.maxBodyBytes} bytes`); + break; + } + if (value.byteLength === 0) continue; // a BYOB read may fill nothing yet + state.chunks.push(value); + state.queued += value.byteLength; + state.dirty = true; // new bytes: announce `readable` at the next tick + } + } catch (error) { + if (!state.terminal) fail(state, state.timedOut ? "timeout" : "closed", error instanceof Error ? error.message : String(error)); + } finally { + try { + reader.releaseLock(); + } catch { + // already released + } + } + } + const ns = { start(metaJson, bodyBuffer) { let meta; @@ -118,88 +266,92 @@ export function createNetHost(nativeFetch = globalThis.fetch.bind(globalThis)) { } catch { return refuse("invalid_request", "malformed request metadata"); } - if (!meta || typeof meta !== "object" || !(bodyBuffer instanceof ArrayBuffer)) { + if (!meta || typeof meta !== "object" || (bodyBuffer !== null && !(bodyBuffer instanceof ArrayBuffer))) { return refuse("invalid_request", "malformed request metadata or body"); } - const body = new Uint8Array(bodyBuffer).slice(); - if (pending.size >= MAX_INFLIGHT) return refuse("busy", "at most 2 requests may be in flight"); + const body = bodyBuffer ? new Uint8Array(bodyBuffer).slice() : new Uint8Array(0); + if (inflight() >= MAX_INFLIGHT) return refuse("resource_limit", `at most ${MAX_INFLIGHT} requests may be in flight`); if (typeof meta.url !== "string" || !/^https?:\/\/[^\s/]+(?:\/|$)/.test(meta.url)) { return refuse("invalid_request", "url must be absolute HTTP(S)"); } - if (!METHODS.has(meta.method)) return refuse("invalid_request", "unsupported method"); + if (typeof meta.method !== "string" || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(meta.method) || FORBIDDEN_METHODS.has(meta.method.toUpperCase())) { + return refuse("invalid_request", "unsupported method"); + } if ((meta.method === "GET" || meta.method === "HEAD") && body.byteLength) { return refuse("invalid_request", `${meta.method} cannot have a body`); } - if (body.byteLength > MAX_REQUEST_BYTES) return refuse("invalid_request", "request body too large"); - if (!Number.isInteger(meta.timeoutMs) || meta.timeoutMs < 1 || meta.timeoutMs > MAX_TIMEOUT_MS) { - return refuse("invalid_request", "invalid timeoutMs"); - } - if (!Number.isInteger(meta.maxBytes) || meta.maxBytes < 1 || meta.maxBytes > MAX_RESPONSE_BYTES) { - return refuse("invalid_request", "invalid maxBytes"); - } + if (body.byteLength > MAX_REQUEST_BYTES) return refuse("resource_limit", "request body too large"); if (!meta.headers || typeof meta.headers !== "object" || !validHeaders(meta.headers)) { return refuse("invalid_request", "invalid headers"); } - + const timeouts = meta.timeouts && typeof meta.timeouts === "object" ? meta.timeouts : {}; + const connectMs = timeoutValue(timeouts.connectMs, DEFAULT_TIMEOUT_MS); + const headersMs = timeoutValue(timeouts.headersMs, DEFAULT_TIMEOUT_MS); + const idleMs = timeoutValue(timeouts.idleMs, DEFAULT_TIMEOUT_MS); + const totalMs = timeoutValue(timeouts.totalMs, MAX_TIMEOUT_MS); + if (connectMs === null || headersMs === null || idleMs === null || totalMs === null) { + return refuse("invalid_request", "invalid timeouts"); + } + const queueBytes = meta.queueBytes === undefined ? DEFAULT_QUEUE_BYTES : meta.queueBytes; + if (!Number.isInteger(queueBytes) || queueBytes < 1 || queueBytes > MAX_QUEUE_BYTES) { + return refuse("invalid_request", "invalid queueBytes"); + } + if (meta.tls && meta.tls.verification === "development-insecure") { + return refuse("unsupported", "the browser owns TLS verification"); + } const handle = nextHandle++; - const controller = new AbortController(); - pending.set(handle, controller); - let timedOut = false; - const timer = setTimeout(() => { - timedOut = true; - controller.abort(); - }, meta.timeoutMs); - const request = { ...meta, body }; - void followBounded(nativeFetch, request, controller.signal) - .then(async (response) => { - const headers = Object.create(null); - response.headers.forEach((value, name) => { - headers[name.toLowerCase()] = value; - }); - if (!validHeaders(headers)) throw new Error("response_headers"); - const responseBody = await readBounded(response, meta.maxBytes); - if (!pending.has(handle)) return; - bodies.set(handle, responseBody); - completed.push({ - t: "done", - h: handle, - status: response.status, - url: response.url || meta.url, - headers, - bytes: responseBody.byteLength, - }); - }) - .catch((error) => { - if (!pending.has(handle)) return; - const message = error instanceof Error ? error.message : String(error); - const mapped = message === "response_too_large" - ? { code: "response_too_large", message: `response exceeded ${meta.maxBytes} bytes` } - : message.startsWith("redirect_") - ? { code: "redirect", message } - : message === "response_headers" - ? { code: "protocol", message: "response headers exceed limits" } - : failure(error, timedOut); - completed.push({ t: "error", h: handle, ...mapped }); - }) - .finally(() => { - clearTimeout(timer); - pending.delete(handle); - }); + const state = { + handle, + controller: new AbortController(), + timedOut: false, + terminal: false, + ended: false, + headSent: false, + queueBytes, + maxBodyBytes: Number.isInteger(meta.maxBodyBytes) ? meta.maxBodyBytes : Number.POSITIVE_INFINITY, + chunks: [], + queued: 0, + visibleBytes: 0, + total: 0, + dirty: false, + wake: null, + reader: null, + timers: [], + headersTimer: null, + idleTimer: null, + armIdle() { + clearTimeout(this.idleTimer); + this.idleTimer = setTimeout(() => { + this.timedOut = true; + fail(this, "timeout", "body idle timeout"); + }, idleMs); + this.timers.push(this.idleTimer); + }, + }; + state.headersTimer = setTimeout(() => { + state.timedOut = true; + fail(state, "timeout", "response headers timeout"); + }, Math.min(connectMs + headersMs, totalMs)); + state.timers.push(state.headersTimer); + state.timers.push( + setTimeout(() => { + state.timedOut = true; + fail(state, "timeout", "total timeout"); + }, totalMs), + ); + states.set(handle, state); + void run(state, meta, body); return handle; }, - take(handle, into) { - const body = bodies.get(handle); - if (!body || into.byteLength !== body.byteLength) return -1; - new Uint8Array(into).set(body); - bodies.delete(handle); - return body.byteLength; - }, cancel(handle) { - pending.get(handle)?.abort(); - pending.delete(handle); - bodies.delete(handle); - for (let i = completed.length - 1; i >= 0; i--) if (completed[i].h === handle) completed.splice(i, 1); - for (let i = visible.length - 1; i >= 0; i--) if (visible[i].h === handle) visible.splice(i, 1); + const state = states.get(handle); + if (!state) return; + if (state.ended) { + // Ended handle with unread bytes: release them, no further event. + states.delete(handle); + return; + } + fail(state, "cancelled", "cancelled"); }, poll() { return visible.length ? JSON.stringify(visible.splice(0)) : undefined; @@ -207,16 +359,60 @@ export function createNetHost(nativeFetch = globalThis.fetch.bind(globalThis)) { lastError() { return lastError; }, + readInto(handle, into, offset, length) { + const state = states.get(handle); + if (!state || !state.headSent) return -1; + const dest = new Uint8Array(into, offset, length); + let copied = 0; + const budget = Math.min(dest.byteLength, state.visibleBytes); + while (state.chunks.length && copied < budget) { + const head = state.chunks[0]; + const n = Math.min(head.byteLength, budget - copied); + dest.set(head.subarray(0, n), copied); + copied += n; + if (n === head.byteLength) state.chunks.shift(); + else state.chunks[0] = head.subarray(n); + } + state.visibleBytes -= copied; + state.queued -= copied; + if (state.wake && state.queued < state.queueBytes) { + const wake = state.wake; + state.wake = null; + wake(); + } + if (state.ended && state.queued === 0) states.delete(handle); + return copied; + }, + limits() { + return JSON.stringify(LIMITS); + }, }; return { ns, beginFrame() { - visible.push(...completed.splice(0)); + // Freeze the readable watermark of every handle with new bytes. + for (const state of states.values()) { + if (state.dirty && state.headSent) { + state.dirty = false; + state.visibleBytes = state.queued; + completed.push({ t: "readable", h: state.handle, avail: state.visibleBytes }); + } + } + // `end` must follow the readable that announced the final bytes: the + // reader loop pushed `end` before beginFrame() ran, so hoist readable + // events ahead of their handle's `end`. + const ends = completed.filter((e) => e.t === "end"); + const rest = completed.filter((e) => e.t !== "end"); + visible.push(...rest, ...ends); + completed.length = 0; }, reset() { - for (const handle of [...pending.keys()]) ns.cancel(handle); - bodies.clear(); + for (const handle of [...states.keys()]) { + const state = states.get(handle); + if (state && !state.terminal) fail(state, "cancelled", "reset"); + states.delete(handle); + } completed.length = 0; visible.length = 0; }, diff --git a/package.json b/package.json index 6841920b..2296487f 100644 --- a/package.json +++ b/package.json @@ -148,7 +148,10 @@ "./kinetics": "./framework/src/kinetics.ts", "./launcher": "./framework/src/launcher.ts", "./manifest": "./framework/src/manifest/index.ts", - "./net": "./framework/src/net-api.ts", + "./headless": "./framework/src/headless.ts", + "./net": "./framework/src/net/index.ts", + "./net/http": "./framework/src/net/http.ts", + "./net/websocket": "./framework/src/net/websocket.ts", "./osk": "./framework/src/osk.tsx", "./package": "./contracts/spec/pocket-package.ts", "./platform": "./framework/src/platform.ts", @@ -170,7 +173,10 @@ "./vue-vapor/fs": "./framework/src/fs-api.ts", "./vue-vapor/lifecycle": "./framework/src/lifecycle-vue-vapor.ts", "./vue-vapor/input": "./framework/src/input-api.ts", - "./vue-vapor/net": "./framework/src/net-api.ts", + "./vue-vapor/headless": "./framework/src/headless.ts", + "./vue-vapor/net": "./framework/src/net/index.ts", + "./vue-vapor/net/http": "./framework/src/net/http.ts", + "./vue-vapor/net/websocket": "./framework/src/net/websocket.ts", "./vue-vapor/renderer": "./framework/src/renderer-vue-vapor.ts", "./octane": "./framework/src/index-octane.ts", "./octane/animation": "./framework/src/animation.ts", @@ -182,7 +188,10 @@ "./octane/fs": "./framework/src/fs-api.ts", "./octane/lifecycle": "./framework/src/lifecycle-octane.ts", "./octane/input": "./framework/src/input-api.ts", - "./octane/net": "./framework/src/net-api.ts", + "./octane/headless": "./framework/src/headless.ts", + "./octane/net": "./framework/src/net/index.ts", + "./octane/net/http": "./framework/src/net/http.ts", + "./octane/net/websocket": "./framework/src/net/websocket.ts", "./octane/renderer": "./framework/src/renderer-octane.ts" }, "description": "High-performance JSX UI outside the browser, with native rendering, standard Vue Vapor and Solid support, a Tailwind design system, and 60 FPS animation under an 8 MB memory budget.", @@ -234,7 +243,7 @@ "devtools:psp": "bun tools/devtools-psp.ts", "test:tailwind": "bun test tests/tailwind.test.ts", "contract": "bun tests/contract.ts", - "gen": "bun contracts/spec/gen-rust.ts && bun tools/gen-exports.ts", + "gen": "bun contracts/spec/gen-rust.ts && bun contracts/spec/gen-c.ts && bun contracts/spec/gen-web.ts && bun tools/gen-exports.ts", "vapor:gb": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target gb", "vapor:nes": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target nes", "vapor:esp32": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target esp32", diff --git a/site/build.ts b/site/build.ts index 45ae7a4b..e52bb248 100644 --- a/site/build.ts +++ b/site/build.ts @@ -507,6 +507,7 @@ async function main() { // validator. The deployed path is POCKET_MANIFEST_SCHEMA_ID — // /schema/pocket-2.json, independent of where the repo keeps the file. copy(ROOT + "contracts/schema/pocket-2.json", "schema/pocket-2.json"); + copy(ROOT + "contracts/schema/pocket-3.json", "schema/pocket-3.json"); copy(ROOT + "hosts/web/pocketjs.wasm", "pg/pocketjs.wasm"); copy(ROOT + "assets/fonts/Inter-Regular.ttf", "pg/fonts/Inter-Regular.ttf"); copy(ROOT + "assets/fonts/Inter-Bold.ttf", "pg/fonts/Inter-Bold.ttf"); diff --git a/site/content/changelog.md b/site/content/changelog.md index dbdd716d..9d012619 100644 --- a/site/content/changelog.md +++ b/site/content/changelog.md @@ -3,6 +3,46 @@ Engine and site milestones, newest first. Versions track the `@pocketjs/framework` npm package. +## Unreleased + +**The network modules: streaming HTTP client, HTTP server and WebSocket client over one policy the Build Plan owns.** +This is a **breaking migration** of the 0.10.0 `net` module, not an additive +feature, and the first network capability with a hardware-proven native core. + +- **Breaking: `@pocketjs/framework/net` is a support module now.** `fetch`, + `Headers`, `Request`, `Response`, `BodyStream` and `serve` moved to + `@pocketjs/framework/net/http`; the WebSocket client is + `@pocketjs/framework/net/websocket` (`connect`). The root `net` subpath + exports `AbortController`, `AbortSignal`, `URL`, `NetworkError`, + `getNetworkLimits` and the shared types. `fetch` returns a streaming + `Response` (`body.readInto`, `for await`, `text()`/`json()`/`arrayBuffer()` + under an aggregate cap) instead of the whole-response `PocketResponse`; + `NetError` is `NetworkError` with a stable `code`/`category`. + Migration: `import { fetch } from "@pocketjs/framework/net"` → + `import { fetch } from "@pocketjs/framework/net/http"`. +- **Breaking: the capability id `net.http` is gone.** Manifests declare the + role-split ids `network.http.client`, `network.http.client.tls`, + `network.http.server`, `network.http.server.tls`, + `network.websocket.client`, `network.websocket.client.tls`. No stock target + advertises them yet; a target adds an id only when its native host ships + and tests the module. +- **Manifest format 3: `permissions.network` is the single source of the + network policy.** A format-3 `pocket.json` (`"pocket": 3`, + `https://pocketjs.dev/schema/pocket-3.json`) declares connect rules + (protocol, host, port or range), listen rules, host credential ids and the + `localNetwork` / `insecureTransport` / `allowInvalidTlsForDevelopment` + switches. The resolver normalizes them into `ResolvedBuildPlan.network` + (covered by `planHash`); `extractHostBuildInputs()` hands custom hosts the + canonical policy JSON (`POCKETJS_NETWORK_POLICY`) that every network core + enforces on each command. Format 2 stays valid and resolves to the deny-all + policy. The contract, its reference matcher and shared vectors live in + `contracts/spec/network-policy.ts` and `contracts/spec/vectors/`. +- **Portable C core, ESP-IDF host, TLS.** `engine/net` (HTTP/1.1 client and + server, RFC 6455 client, bounded queues with backpressure, tick-boundary + delivery) runs on AtomS3R and Tab5 under ESP-IDF v6.0.2 with ESP-TLS; + `engine/crates/pocket-net` is the Rust HTTP client core for Rust hosts. + `@pocketjs/framework/headless` runs the frame transaction without a UI. + ## 0.10.1 — August 16, 2026 **Three more physical phones run PocketJS, Pocket Vapor compiles a smaller reactive graph, and Pocket3D gains a deterministic systemic world.** diff --git a/site/content/docs/concepts.md b/site/content/docs/concepts.md index f5525799..7b4736d0 100644 --- a/site/content/docs/concepts.md +++ b/site/content/docs/concepts.md @@ -16,7 +16,7 @@ Runtime = Host + mounted Modules + Guest ┌────────────────────────── Runtime ──────────────────────────┐ │ Guest product code (QuickJS bundle / wasm host eval) │ │ ───────── one namespace per mounted module ───────────── │ -│ Modules ui · audio · db · fs · net · strike │ +│ Modules ui · audio · db · fs · net/ws/httpd · strike │ │ core+spec, one per module │ │ Substrate pocket3d · platform drivers (no guest API) │ │ Host PSP EBOOT · Vita · browser · headless sim │ @@ -46,8 +46,9 @@ The **core** owns the domain's state and its clock; per-entity, per-frame work happens only there, and the core never calls into the guest. The **SDK** is ordinary guest code shaped for its domain — JSX components for `ui`, `decodeWav` and a `WavPlayer` for `audio`, a `Database` with prepared -statements for `db`, `file()` and the node:fs sync subset for `fs`, `fetch` -and buffered responses for `net`, a mod API for OpenStrike's `strike`. The +statements for `db`, `file()` and the node:fs sync subset for `fs`, `fetch`, +`serve` and `connect` over streaming bodies for the network modules, a mod +API for OpenStrike's `strike`. The two sides can be replaced independently because the **spec** between them does not move: swap Solid for Vue Vapor, or rewrite the layout engine, and the other side cannot tell. @@ -55,8 +56,9 @@ the other side cannot tell. `ui` (pocketjs-core + the `ui.*` ops + the JSX SDK) was the first module. `strike` was the second. `audio` — credit-based PCM streaming — is the third, and the first written spec-first: the protocol existed before any -host implemented it. `net` applies the same shape to bounded HTTP while -leaving sockets, TLS, and the concrete client library in each host. +host implemented it. The network modules (`net`, `httpd`, `ws`) apply the +same shape to HTTP and WebSocket: one spec per role, a core that owns the +wire and the limits, and hosts that mount only the roles they admit. ## Spec @@ -109,7 +111,7 @@ assembly: | PSP UI runtime | PSP EBOOT | `ui` + `audio` | any PocketJS app | | Music demo in the browser | browser dev host | `ui` + `audio` | `apps/music` | | OpenStrike | its own Rust bin | `strike` + `ui` (HUD) | round rules, weapons, bots — all JS | -| Headless CI | Bun sim | `ui` + virtual `audio` + fixture `net` | the same bundles, byte-for-byte | +| Headless CI | Bun sim | `ui` + virtual `audio` + fixture `net`/`httpd`/`ws` | the same bundles, byte-for-byte | ## The three laws @@ -160,5 +162,5 @@ framework, and the app did not change. A new domain — networking, haptics, a camera — lands the same way: write the spec, build the core against it, mount it in a host, ship the SDK with a headless test. -The NET module is the networking instance of this rule. Its API and host -adapter boundary are documented in [NET module](/docs/net/). +The network modules are the networking instance of this rule. Their APIs +and host boundaries are documented in [Networking](/docs/net/). diff --git a/site/content/docs/net.md b/site/content/docs/net.md index 17097780..23b607e2 100644 --- a/site/content/docs/net.md +++ b/site/content/docs/net.md @@ -1,90 +1,120 @@ # Networking -PocketJS provides a small, bounded HTTP client through the NET module. It is -fetch-shaped without importing the browser's complete networking stack. +PocketJS networking is a set of explicitly imported modules: an HTTP client +and server in `@pocketjs/framework/net/http`, a WebSocket client in +`@pocketjs/framework/net/websocket`, and the shared support types in +`@pocketjs/framework/net`. Each module sits on its own spec-pinned guest +boundary (`globalThis.net`, `globalThis.httpd`, `globalThis.ws`) that a host +mounts only when it ships the capability. ```ts -import { fetch } from "@pocketjs/framework/net"; +import { fetch, serve, Response } from "@pocketjs/framework/net/http"; +import { connect } from "@pocketjs/framework/net/websocket"; +import { AbortController, NetworkError, URL } from "@pocketjs/framework/net"; -const response = await fetch("https://api.example.com/items", { +const controller = new AbortController(); +const response = await fetch("http://api.example.test/items", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "Pocket" }), - timeoutMs: 5_000, - maxBytes: 64 * 1024, + timeouts: { headersMs: 5_000 }, + signal: controller.signal, }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); -const data = await response.json(); -``` - -The public surface includes common application methods, string and byte -request bodies, headers, a timeout, a response-size budget, and buffered -`text()`, `json()`, `bytes()` and `arrayBuffer()` reads. It deliberately omits -streams, cookies, cache, `Request`, `Headers`, `AbortSignal`, WebSocket, -servers, and raw sockets. - -## Why responses are buffered - -The first version resolves `fetch` only after the body is complete. The -native transport still reads chunks and stops as soon as `maxBytes` is -exceeded; the transport-neutral core checks the final size again. This keeps -the JS API and every embedded adapter small without allowing an unbounded -response into memory. - -The default body budget is 128 KiB and the absolute maximum is 256 KiB. A -request body is limited to 64 KiB. Media downloads and other payloads that -fundamentally require streaming are not NET v1 use cases. - -## Tick delivery and polling +const items = await response.json(); -Network work may happen on native threads, but those threads never call the -guest. The host drains completions at a tick boundary, then the framework -settles fetch Promises in the guest's normal turn. - -There is no idle native poll. The first pending fetch registers a small -framework-neutral service pump and the final completion removes it. While -requests are pending the SDK calls `net.poll()` once per guest tick; that one -call returns the entire visible completion batch. - -## What belongs where - -| Layer | Artifact | Responsibility | -| --- | --- | --- | -| SDK | `framework/src/net-api.ts` | fetch-shaped guest API and Promise delivery | -| Spec | `contracts/spec/net.ts` | ops, events, limits, errors, ownership and tick contract | -| Core | `engine/crates/pocket-net` | handles, validation, bodies and a transport interface | -| Sim | `hosts/sim/net.ts` | deterministic fixture routes | -| Browser host | `hosts/web/net.js` | bounded adapter over browser fetch | -| Host adapter | the owning runtime | DNS, TLS, HTTP client library, workers and credentials | +const server = await serve({ + hostname: "0.0.0.0", + port: 8080, + fetch: (request) => Response.json({ path: new URL(request.url).pathname }), +}); -PocketJS does not force one HTTP library on every platform. A desktop host can -adapt `ureq`, an ESP host can adapt `esp_http_client`, and an Apple host can -adapt `URLSession`. A product-specific runtime keeps that adapter in its own -repository. Only adapters for hosts owned and tested by PocketJS belong under -this repository's `hosts/` directory. +const socket = await connect("ws://broker.example.test/telemetry", { + protocols: ["telemetry.v1"], + socket: { + message(socket, data) { socket.send(data); }, + close(_socket, code) { console.log("closed", code); }, + }, +}); +``` -The Rust boundary is deliberately only `start`, `cancel`, and non-blocking -`drain`. The reference core supplies every portable rule around it, so -changing an HTTP library cannot change what guest code observes. +The objects follow the WHATWG Fetch shapes (`Headers`, `Request`, +`Response`, `RequestInit` with `method/headers/body/signal/redirect` plus the +PocketJS `timeouts/maxRedirects/tls/limits`) with two deliberate deviations: +body locking, repeat consumption and detached input fail with a +`NetworkError`, and every network, permission, timeout and resource failure +is a `NetworkError` too. HTTP status codes do not reject: a 404 resolves with +`ok === false`. + +## Streaming bodies + +`fetch()` resolves when the response head is visible; the body streams +through `response.body`, a `BodyStream` that supports `for await`, +`readInto(destination)` and `cancel()`. `text()`, `json()` and +`arrayBuffer()` aggregate the same stream and reject with +`response_too_large` past their cap. Bytes wait in a bounded native queue +until the application reads them; **when the queue is full the host stops +reading the socket and TCP flow control holds the peer**, so a slow reader +never grows memory past `queueBytes`. `clone()` creates a bounded tee whose +backlog never exceeds the aggregate limit — cancel the branch you do not +read. + +## When results arrive + +Network completions reach the guest only at frame boundaries. The host +freezes the visible set before each `frame()`, the framework's service pump +polls each module once inside `frame()`, and Promise reactions run in the +same tick's job drain. **A network round trip therefore reaches application +code within one frame period** (16.7 ms at 60 Hz), and the order of events +is the same on every host and in a replay. + +## Capabilities and permissions + +Importing a module grants nothing. Capabilities are split by protocol, role +and TLS — `network.http.client`, `network.http.client.tls`, +`network.http.server`, `network.websocket.client`, … — and the application +declares its endpoints in the manifest (format 3, `permissions.network`: +`connect` rules with protocol, host and port or range; `listen` rules with +address and port; `insecureTransport`; `localNetwork`). The Build Plan +resolver normalizes them into the plan's network policy, the host hands that +policy to its core verbatim, and **every command is checked against it**: +the connect rule before DNS, each resolved address after DNS, the listen rule +before bind, the endpoint rule again on every redirect. A format-2 manifest +resolves to a deny-all policy. No stock target advertises a network +capability yet; a target advertises one only when its native host ships and +tests the module. + +## Errors + +`NetworkError` carries a stable `code` and a derived `category`: + +| Category | Codes | +| --- | --- | +| runtime | `cancelled` `timeout` `closed` `invalid_request` `invalid_state` `busy` `resource_limit` `unsupported` `permission_denied` `unavailable` | +| resolver | `dns` | +| transport | `connect` `address_in_use` | +| tls | `tls_certificate_invalid` `tls_hostname_mismatch` `tls_handshake_failed` `tls_clock_untrusted` | +| protocol | `redirect` `response_too_large` `protocol` `websocket_handshake_failed` `websocket_protocol_error` `message_too_large` | ## Limits -| Resource | V1 limit | -| --- | ---: | -| Concurrent requests | 2 | -| Request body | 64 KiB | -| Response body | 128 KiB default, 256 KiB maximum | -| Headers | 32 fields / 8 KiB | -| Timeout | 30 s default, 120 s maximum | -| Redirects | 3 | - -Supported methods are `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, and -`OPTIONS`. `CONNECT` and `TRACE` have tunnel, proxy, and security semantics -that do not belong in an application fetch primitive. A closed method set also -means every host can make the same guarantee. - -Transport failures reject with `NetError` and a portable `code` such as -`dns`, `connect`, `tls`, `timeout`, or `response_too_large`. HTTP status codes -do not reject: a 404 response resolves with `ok === false`, like browser -fetch. +`getNetworkLimits()` returns a frozen snapshot of the mounted modules' +effective limits (spec ceilings tightened by the host): concurrent handles, +request-body cap, receive-queue defaults and maxima, aggregate caps, +per-tick event/byte budgets, header limits, timeouts, redirects and TLS +features. Applications choose chunk and queue sizes from it; they cannot +raise a limit. + +## Where the pieces live + +| Layer | Artifact | +| --- | --- | +| SDK | `framework/src/net/*` | +| Specs | `contracts/spec/net.ts`, `contracts/spec/ws.ts`, `contracts/spec/httpd.ts` | +| Reference cores | `engine/net` (portable C: HTTP client/server, WebSocket client, BSD/lwIP driver), `engine/crates/pocket-net` (Rust HTTP client core over `HttpClientBackend`) | +| Deterministic hosts | `hosts/sim/net.ts`, `hosts/sim/httpd.ts`, `hosts/sim/ws.ts` | +| Browser host | `hosts/web/net.js` | +| ESP-IDF host | `hosts/esp-idf` (AtomS3R, Tab5) | + +The pinned boundaries are `contracts/spec/net.ts`, `contracts/spec/ws.ts` +and `contracts/spec/httpd.ts`; the engineering summary is `docs/NET.md`. diff --git a/site/content/docs/platform-contracts.md b/site/content/docs/platform-contracts.md index 72f25878..2c7ab80c 100644 --- a/site/content/docs/platform-contracts.md +++ b/site/content/docs/platform-contracts.md @@ -66,6 +66,16 @@ Format 2 is strict JSON data. A PSP-shaped portable app can say: The manifest contains no physical resolution, scale factor, Vita flag, native crate path, or host ABI. Those are framework-owned facts. +**Format 3** (`"pocket": 3`, `https://pocketjs.dev/schema/pocket-3.json`) is +format 2 plus a top-level `permissions` block. Its only member today is +`permissions.network` — the endpoints the app may connect to and listen on, +the host credential ids it may name, and the `localNetwork` / +`insecureTransport` / `allowInvalidTlsForDevelopment` switches +(`contracts/spec/network-policy.ts`). The resolver normalizes it into +`ResolvedBuildPlan.network`, covered by `planHash`, and hosts enforce that +policy on every network command; a format-2 manifest resolves to the +deny-all policy. See the [network](/docs/net/) page. + `requires` is the compatibility floor. Resolution fails before compilation if the selected host does not provide one of those APIs. `enhances` declares an optional API for which the app has a fallback. Its availability becomes a diff --git a/tests/contract.ts b/tests/contract.ts index d133b93d..d9fb06d9 100644 --- a/tests/contract.ts +++ b/tests/contract.ts @@ -9,7 +9,9 @@ // (framework/compiler/subpaths.ts) and byte-compares: the npm surface // can never drift from the one declaration. Fix = `bun tools/gen-exports.ts`. +import { generateC } from "../contracts/spec/gen-c.ts"; import { generateRust } from "../contracts/spec/gen-rust.ts"; +import { generateWeb } from "../contracts/spec/gen-web.ts"; import { withGeneratedExports } from "../tools/gen-exports.ts"; import { abgr, @@ -47,6 +49,26 @@ check( "run `bun contracts/spec/gen-rust.ts` and commit the result", ); +// ---- (a2) generated network spec.h is in sync ------------------------------ + +const specHPath = new URL("../engine/net/include/pocketjs/net/spec.h", import.meta.url).pathname; +const committedH = await Bun.file(specHPath).text().catch(() => null); +check( + committedH !== null && committedH === generateC(), + "engine/net/include/pocketjs/net/spec.h matches contracts/spec/{net,ws,httpd}.ts", + "run `bun contracts/spec/gen-c.ts` and commit the result", +); + +// ---- (a3) generated browser-host mirror is in sync -------------------------- + +const webSpecPath = new URL("../hosts/web/net-spec.js", import.meta.url).pathname; +const committedWeb = await Bun.file(webSpecPath).text().catch(() => null); +check( + committedWeb !== null && committedWeb === generateWeb(), + "hosts/web/net-spec.js matches contracts/spec/net.ts", + "run `bun contracts/spec/gen-web.ts` and commit the result", +); + // ---- (c) package.json exports match the subpath registry --------------------- const pkgPath = new URL("../package.json", import.meta.url).pathname; diff --git a/tests/esp-idf-profile.test.ts b/tests/esp-idf-profile.test.ts new file mode 100644 index 00000000..08708177 --- /dev/null +++ b/tests/esp-idf-profile.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { canonicalNetworkPolicyJson, parseNetworkPolicyJson } from "../contracts/spec/network-policy.ts"; +import { POCKET_TARGETS } from "../contracts/spec/platforms.ts"; +import { extractHostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts"; +import { verifyPlanHash } from "../framework/src/manifest/plan.ts"; +import { validatePocketManifest } from "../framework/src/manifest/validate.ts"; +import { hostInputsHeader, smokeManifest, type SmokeRig } from "../tools/esp-idf.ts"; +import { + ATOMS3R_DEV_TARGET_ID, + ESP_IDF_DEV_CONTRACTS, + ESP_IDF_DEV_HOST_ABI, + ESP_IDF_NETWORK_CAPABILITIES, + TAB5_DEV_TARGET_ID, + resolveEspIdfBuildPlan, +} from "../tools/esp-idf-profile.ts"; + +const REPOSITORY = fileURLToPath(new URL("../", import.meta.url)); +const MANIFEST_PATH = join(REPOSITORY, "hosts/esp-idf/examples/net-smoke/pocket.json"); + +function smokeBase(): Record { + return JSON.parse(readFileSync(MANIFEST_PATH, "utf8")); +} + +const RIG: SmokeRig = { + board: "atoms3r", + macHost: "172.16.10.225", + macHttpPort: 8790, + macWsPort: 8791, + peerHost: "172.16.10.145", + peerPort: 8080, + servePort: 8080, + tlsHost: "example.com", + tickHz: 60, +}; + +describe("private ESP-IDF network-host profiles", () => { + test("stay private and advertise exactly the hardware-proven network roles", () => { + expect(POCKET_TARGETS).not.toHaveProperty(ATOMS3R_DEV_TARGET_ID); + expect(POCKET_TARGETS).not.toHaveProperty(TAB5_DEV_TARGET_ID); + for (const id of [ATOMS3R_DEV_TARGET_ID, TAB5_DEV_TARGET_ID] as const) { + const profile = ESP_IDF_DEV_CONTRACTS.targets[id]; + expect(profile.hostAbi).toBe(ESP_IDF_DEV_HOST_ABI); + expect(profile.platform).toBe("esp-idf"); + expect(profile.capabilities).toEqual(ESP_IDF_NETWORK_CAPABILITIES); + // No server TLS, no input, no text: the host does not implement them. + expect(profile.capabilities).not.toContain("network.http.server.tls"); + expect(profile.capabilities.some((c: string) => c.startsWith("input.") || c.startsWith("text."))).toBe(false); + } + }); + + test("the smoke manifest is format 3 and resolves on both boards with the rig's endpoints merged", () => { + const base = smokeBase(); + expect(base.pocket).toBe(3); + expect(validatePocketManifest(base).ok).toBe(true); + for (const board of ["atoms3r", "tab5"] as const) { + const manifest = smokeManifest(base, { ...RIG, board }); + const plan = resolveEspIdfBuildPlan(manifest, board); + expect(verifyPlanHash(plan)).toBe(true); + expect(plan.target.id).toBe(board === "tab5" ? TAB5_DEV_TARGET_ID : ATOMS3R_DEV_TARGET_ID); + expect(plan.viewport.logical).toEqual(board === "tab5" ? [1280, 720] : [128, 128]); + expect(plan.features).toEqual({ + "network.http.client": true, + "network.http.client.tls": true, + "network.http.server": true, + "network.websocket.client": true, + }); + // The policy is the plan's: rig endpoints + the manifest's TLS hosts, + // canonical and sorted, the serve port as the only listen rule. + expect(plan.network.connect).toEqual([ + { protocol: "http", host: "172.16.10.145", port: 8080 }, + { protocol: "http", host: "172.16.10.225", port: { min: 8790, max: 8792 } }, + { protocol: "https", host: "example.com", port: 443 }, + { protocol: "https", host: "expired.badssl.com", port: 443 }, + { protocol: "https", host: "self-signed.badssl.com", port: 443 }, + { protocol: "https", host: "untrusted-root.badssl.com", port: 443 }, + { protocol: "https", host: "wrong.host.badssl.com", port: 443 }, + { protocol: "ws", host: "172.16.10.225", port: 8791 }, + ]); + expect(plan.network.listen).toEqual([{ protocol: "http", address: "0.0.0.0", port: 8080 }]); + expect(plan.network.insecureTransport).toBe(true); + expect(plan.network.localNetwork).toBe(true); + expect(plan.network.allowInvalidTlsForDevelopment).toBe(false); + } + }); + + test("the firmware inputs are the plan's projection: canonical policy JSON and a header of plan facts", () => { + const plan = resolveEspIdfBuildPlan(smokeManifest(smokeBase(), RIG), "atoms3r"); + const inputs = extractHostBuildInputs(plan); + // What main.c embeds and hands to pnet_runtime_create verbatim. + expect(inputs.network.policyJson).toBe(canonicalNetworkPolicyJson(plan.network)); + expect(parseNetworkPolicyJson(inputs.network.policyJson)).toEqual(plan.network); + const header = hostInputsHeader(inputs, RIG); + expect(header).toContain(`#define POCKETJS_PLAN_HASH "${plan.planHash}"`); + expect(header).toContain('#define POCKETJS_TARGET "atoms3r-dev"'); + expect(header).toContain("#define POCKETJS_TICK_HZ 60"); + expect(header).toContain("#define POCKETJS_FEATURE_NETWORK_HTTP_SERVER 1"); + expect(header).toContain("#define POCKETJS_FEATURE_NETWORK_WEBSOCKET_CLIENT 1"); + expect(header).toContain("#define POCKETJS_FEATURE_NETWORK_HTTP_CLIENT_TLS 1"); + expect(header).not.toContain("SERVER_TLS"); + }); + + test("a rig without peers still resolves (the suite skips what is not configured)", () => { + const manifest = smokeManifest(smokeBase(), { ...RIG, macHost: undefined, peerHost: undefined, tlsHost: undefined }); + const plan = resolveEspIdfBuildPlan(manifest, "atoms3r"); + expect(plan.network.connect.every((rule) => rule.protocol === "https")).toBe(true); + expect(plan.network.listen).toEqual([{ protocol: "http", address: "0.0.0.0", port: 8080 }]); + }); +}); diff --git a/tests/fixtures/plans/portable-psp.plan.json b/tests/fixtures/plans/portable-psp.plan.json index 1d5c7142..1352efa3 100644 --- a/tests/fixtures/plans/portable-psp.plan.json +++ b/tests/fixtures/plans/portable-psp.plan.json @@ -29,5 +29,14 @@ "text.glyphs.baked": true }, "companions": [], - "planHash": "sha256:e3a257d89114161e6faecb37249f3cba37f444034a951c80a8ffcaed5a593ddf" + "network": { + "version": 1, + "connect": [], + "listen": [], + "credentials": [], + "localNetwork": false, + "insecureTransport": false, + "allowInvalidTlsForDevelopment": false + }, + "planHash": "sha256:4c09b588891f5ea2364d46ab57cbedd787017f93141d31242d5ebab061b24c5a" } diff --git a/tests/fixtures/plans/portable-vita.plan.json b/tests/fixtures/plans/portable-vita.plan.json index 1528e858..f6de2f91 100644 --- a/tests/fixtures/plans/portable-vita.plan.json +++ b/tests/fixtures/plans/portable-vita.plan.json @@ -29,5 +29,14 @@ "text.glyphs.baked": true }, "companions": [], - "planHash": "sha256:5b2ab23a3d3b54e0ef54bdd706092cd8350561d978f6cb1280a0c72afa647e96" + "network": { + "version": 1, + "connect": [], + "listen": [], + "credentials": [], + "localNetwork": false, + "insecureTransport": false, + "allowInvalidTlsForDevelopment": false + }, + "planHash": "sha256:85ff09c126edf5758b21166b4e9cad6d1b02bffd49461d1af8605f90a5cec909" } diff --git a/tests/host-build-inputs.test.ts b/tests/host-build-inputs.test.ts index 3a0d030b..98f87a63 100644 --- a/tests/host-build-inputs.test.ts +++ b/tests/host-build-inputs.test.ts @@ -1,4 +1,8 @@ import { describe, expect, test } from "bun:test"; +import { + DENY_ALL_NETWORK_POLICY, + canonicalNetworkPolicyJson, +} from "../contracts/spec/network-policy.ts"; import { extractHostBuildInputs, hostBuildEnvironment, @@ -22,13 +26,67 @@ describe("custom host build boundary", () => { appOutput: "main", target: "psp", hostAbi: 1, + planHash: plan.planHash, viewport: { logical: [480, 272], physical: [480, 272], presentation: "integer-fit", rasterDensity: 1, }, + features: { + "input.analog.left": true, + "input.buttons": true, + "text.glyphs.baked": true, + }, + // A format-2 manifest carries no permissions: the host receives the + // deny-all policy, spelled in the canonical form every core parses. + network: { + policy: DENY_ALL_NETWORK_POLICY, + policyJson: canonicalNetworkPolicyJson(DENY_ALL_NETWORK_POLICY), + }, + }); + expect(extractHostBuildInputs(plan).network.policyJson).toBe( + '{"allowInvalidTlsForDevelopment":false,"connect":[],"credentials":[],"insecureTransport":false,"listen":[],"localNetwork":false,"version":1}', + ); + }); + + test("projects a format-3 network policy verbatim and refuses a tampered one", () => { + const manifest = structuredClone(portableInput) as Record; + manifest.$schema = "https://pocketjs.dev/schema/pocket-3.json"; + manifest.pocket = 3; + manifest.permissions = { + network: { + connect: [ + { protocol: "https", host: "API.Example.com.", port: 443 }, + { protocol: "http", host: "192.168.1.20", port: { min: 8080, max: 8080 } }, + ], + listen: [{ protocol: "http", address: "0:0:0:0:0:0:0:0", port: "ephemeral" }], + credentials: ["device-cert"], + insecureTransport: true, + localNetwork: true, + }, + }; + const result = validateAndResolveBuildPlan(manifest, { target: "psp" }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const inputs = extractHostBuildInputs(result.plan); + expect(inputs.network.policy).toEqual({ + version: 1, + connect: [ + { protocol: "http", host: "192.168.1.20", port: 8080 }, + { protocol: "https", host: "api.example.com", port: 443 }, + ], + listen: [{ protocol: "http", address: "::", port: "ephemeral" }], + credentials: ["device-cert"], + localNetwork: true, + insecureTransport: true, + allowInvalidTlsForDevelopment: false, }); + expect(inputs.network.policyJson).toBe(canonicalNetworkPolicyJson(result.plan.network)); + // Widening the policy after resolution breaks the checksum. + const widened = structuredClone(result.plan) as any; + widened.network.connect.push({ protocol: "https", host: "evil.example", port: 443 }); + expect(() => extractHostBuildInputs(widened)).toThrow("invalid ResolvedBuildPlan checksum"); }); test("rejects a modified plan and an unexpected target", () => { @@ -56,6 +114,8 @@ describe("custom host build boundary", () => { POCKETJS_PHYSICAL_HEIGHT: "272", POCKETJS_PRESENTATION: "integer-fit", POCKETJS_RASTER_DENSITY: "1", + POCKETJS_PLAN_HASH: inputs.planHash, + POCKETJS_NETWORK_POLICY: canonicalNetworkPolicyJson(DENY_ALL_NETWORK_POLICY), }); }); }); diff --git a/tests/http-semantics.test.ts b/tests/http-semantics.test.ts new file mode 100644 index 00000000..82bcaef3 --- /dev/null +++ b/tests/http-semantics.test.ts @@ -0,0 +1,123 @@ +// The SDK, the sim host and the browser dev host against the shared HTTP +// semantics vectors (contracts/spec/vectors/http-semantics.json): method +// acceptance, core-owned request headers, null-body statuses and the +// redirect status table. engine/net (pnet_unit_test) and the Rust core run +// the same file. +import { afterEach, describe, expect, test } from "bun:test"; + +import { + HTTP_BODYLESS_STATUS, + HTTP_NULL_BODY_STATUS, + HTTP_REDIRECT_ANY_TO_GET_STATUS, + HTTP_REDIRECT_POST_TO_GET_STATUS, + HTTP_REDIRECT_STATUS, + NET_ERROR, +} from "../contracts/spec/net.ts"; +import { fetch as pocketFetch, Request, Response, type NetOps } from "../framework/src/net/http.ts"; +import { runServicePumps } from "../framework/src/services.ts"; +import { createSimNetHost } from "../hosts/sim/net.ts"; +// @ts-expect-error — the browser dev host is plain ESM without declarations. +import { createNetHost as createWebNetHost } from "../hosts/web/net.js"; + +interface Vectors { + readonly methods: readonly { method: string; accepted: boolean }[]; + readonly requestHeaders: readonly { name: string; coreOwned: boolean }[]; + readonly status: readonly { status: number; bodylessFraming: boolean; nullBody: boolean }[]; + readonly redirect: readonly { + status: number; + method: string; + followed: boolean; + nextMethod?: string; + keepBody?: boolean; + }[]; +} + +const vectors = (await Bun.file(new URL("../contracts/spec/vectors/http-semantics.json", import.meta.url)).json()) as Vectors; + +afterEach(() => { + delete (globalThis as { net?: NetOps }).net; +}); + +async function ticks(host: { tick(): void }, n = 1): Promise { + for (let i = 0; i < n; i++) { + host.tick(); + runServicePumps(); + for (let j = 0; j < 8; j++) await Promise.resolve(); + } +} + +describe("http semantics vectors", () => { + test("methods: the SDK accepts or refuses before the host; the sim and web hosts decide the same at start()", async () => { + const seen: string[] = []; + const host = createSimNetHost({ + "http://example.test/m": (request) => { + seen.push(request.method); + return { body: "ok" }; + }, + }); + (globalThis as { net?: NetOps }).net = host.ns; + for (const v of vectors.methods) { + if (v.accepted) { + const pending = pocketFetch("http://example.test/m", { method: v.method }); + await ticks(host); + expect((await pending).status, v.method).toBe(200); + } else { + await expect(pocketFetch("http://example.test/m", { method: v.method }), v.method).rejects.toMatchObject({ + code: NET_ERROR.invalidRequest, + }); + } + // The hosts' own check (the SDK normalizes standard tokens, so feed + // them the raw token): valid-but-forbidden tokens refuse with + // invalid_request, accepted tokens start. + const meta = JSON.stringify({ url: "http://example.test/m", method: v.method, headers: {} }); + const simHandle = host.ns.start(meta, null); + expect(simHandle > 0, `sim ${v.method}`).toBe(v.accepted); + if (simHandle > 0) host.ns.cancel(simHandle); + const web = createWebNetHost(async () => new globalThis.Response("x")) as { ns: NetOps }; + const webHandle = web.ns.start(meta, null); + expect(webHandle > 0, `web ${v.method}`).toBe(v.accepted); + if (webHandle > 0) web.ns.cancel(webHandle); + } + // One fetch through the SDK plus one raw start() per accepted token. + expect(seen.length).toBe(2 * vectors.methods.filter((v) => v.accepted).length); + await ticks(host, 2); + }); + + test("request headers: core-owned names are silently dropped on a Request, others kept", () => { + for (const v of vectors.requestHeaders) { + const request = new Request("http://example.test/", { headers: { [v.name]: "value" } }); + expect(request.headers.has(v.name.toLowerCase()), v.name).toBe(!v.coreOwned); + } + }); + + test("statuses: a Response refuses a body exactly for the null-body set; framing constants agree", () => { + for (const v of vectors.status) { + // App-constructed responses take 200..599 (1xx exist only on the wire). + if (v.status >= 200 && v.nullBody) { + expect(() => new Response("x", { status: v.status }), String(v.status)).toThrow(); + expect(new Response(null, { status: v.status }).status).toBe(v.status); + } else if (v.status >= 200) { + expect(new Response("x", { status: v.status }).status).toBe(v.status); + } + const framingBodyless = (v.status >= 100 && v.status < 200) || (HTTP_BODYLESS_STATUS as readonly number[]).includes(v.status); + expect(framingBodyless, `framing ${v.status}`).toBe(v.bodylessFraming); + expect((HTTP_NULL_BODY_STATUS as readonly number[]).includes(v.status), `null ${v.status}`).toBe(v.nullBody); + } + }); + + test("redirects: the followed set and the method rewrite table", () => { + for (const v of vectors.redirect) { + const followed = (HTTP_REDIRECT_STATUS as readonly number[]).includes(v.status); + expect(followed, String(v.status)).toBe(v.followed); + if (followed) { + expect(Response.redirect("http://example.test/next", v.status).status).toBe(v.status); + const toGet = ((HTTP_REDIRECT_ANY_TO_GET_STATUS as readonly number[]).includes(v.status) && v.method !== "HEAD") || + ((HTTP_REDIRECT_POST_TO_GET_STATUS as readonly number[]).includes(v.status) && v.method === "POST"); + expect(toGet ? "GET" : v.method, `${v.status} ${v.method}`).toBe(v.nextMethod!); + expect(!toGet, `${v.status} ${v.method} body`).toBe(v.keepBody!); + } else { + expect(() => Response.redirect("http://example.test/next", v.status)).toThrow(); + } + } + }); +}); diff --git a/tests/net-httpd.test.ts b/tests/net-httpd.test.ts new file mode 100644 index 00000000..93dfc2ed --- /dev/null +++ b/tests/net-httpd.test.ts @@ -0,0 +1,208 @@ +// HTTP Server SDK (`serve()` in `@pocketjs/framework/net/http`) + deterministic +// sim host (hosts/sim/httpd.ts): listen/stop lifecycle, request delivery in +// the service pump, streaming request bodies, one-shot and streamed responses +// through respond/write/endBody with drain, error handler fallbacks and +// aborted requests. + +import { afterEach, describe, expect, test } from "bun:test"; + +import { NET_ERROR } from "../contracts/spec/net.ts"; +import { Response, serve, type HttpdOps } from "../framework/src/net/http.ts"; +import { NetworkError } from "../framework/src/net/index.ts"; +import { runServicePumps } from "../framework/src/services.ts"; +import { createSimHttpdHost } from "../hosts/sim/httpd.ts"; + +function mount(ns: HttpdOps): void { + (globalThis as { httpd?: HttpdOps }).httpd = ns; +} + +afterEach(() => { + delete (globalThis as { httpd?: HttpdOps }).httpd; +}); + +async function ticks(host: { tick(): void }, n = 1): Promise { + for (let i = 0; i < n; i++) { + host.tick(); + runServicePumps(); + for (let j = 0; j < 12; j++) await Promise.resolve(); + } +} + +describe("httpd SDK + deterministic sim host", () => { + test("serve resolves on listening; handlers answer in the same tick", async () => { + const host = createSimHttpdHost(); + mount(host.ns); + const seen: string[] = []; + const listening = serve({ + hostname: "0.0.0.0", + port: 8080, + fetch(request) { + seen.push(`${request.method} ${new URL(request.url).pathname}`); + return new Response("hello", { headers: { "x-served": "1" } }); + }, + }); + let settled = false; + listening.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + await ticks(host); + const server = await listening; + expect(server.port).toBe(8080); + expect(server.url).toBe("http://0.0.0.0:8080/"); + + const injected = host.inject({ method: "GET", target: "/hello?x=1" }); + await ticks(host); + expect(seen).toEqual(["GET /hello"]); + expect(injected.responded).toBe(true); + expect(injected.complete).toBe(true); + expect(injected.status).toBe(200); + expect(injected.headers["x-served"]).toBe("1"); + expect(injected.headers["content-type"]).toBe("text/plain;charset=UTF-8"); + expect(injected.text()).toBe("hello"); + expect(host.live()).toBe(0); + + const stopped = server.stop({ graceful: true, timeout: 100 }); + await ticks(host); + await stopped; + expect(host.log.at(-1)).toBe("stop 1 true 100"); + }); + + test("request bodies stream through readInto; async handlers respond later", async () => { + const host = createSimHttpdHost(); + mount(host.ns); + const listening = serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const text = await request.text(); + return Response.json({ echo: text, len: request.headers.get("content-length") }); + }, + }); + await ticks(host); + const server = await listening; + expect(server.port).toBeGreaterThanOrEqual(40000); + const injected = host.inject({ method: "POST", target: "/echo", body: ["ab", "cd", "ef"], chunkTicks: 1 }); + await ticks(host, 5); + expect(injected.complete).toBe(true); + expect(JSON.parse(injected.text())).toEqual({ echo: "abcdef", len: "6" }); + }); + + test("streamed responses use respond(end=false) + write + endBody and honour drain", async () => { + const host = createSimHttpdHost(); + host.sendQueueBytes = 4; + mount(host.ns); + async function* chunks(): AsyncGenerator { + yield new Uint8Array([1, 2, 3]); + yield new Uint8Array([4, 5, 6, 7]); + } + const listening = serve({ + hostname: "127.0.0.1", + port: 9000, + fetch() { + return new Response(chunks() as unknown as AsyncIterable, { status: 200 }); + }, + }); + await ticks(host); + await listening; + const injected = host.inject({ target: "/stream" }); + await ticks(host, 6); + expect(injected.complete).toBe(true); + expect([...injected.body()]).toEqual([1, 2, 3, 4, 5, 6, 7]); + expect(host.log.filter((l) => l.startsWith("write")).length).toBe(2); + expect(host.log.some((l) => l.startsWith("endBody"))).toBe(true); + }); + + test("a one-shot body that does not fit the send queue falls back to streaming", async () => { + const host = createSimHttpdHost(); + host.sendQueueBytes = 5; + mount(host.ns); + const listening = serve({ + hostname: "127.0.0.1", + port: 9001, + fetch() { + return new Response("0123456789"); + }, + }); + await ticks(host); + await listening; + const injected = host.inject({ target: "/big" }); + await ticks(host, 16); + expect(injected.complete).toBe(true); + expect(injected.contentLength).toBe(10); + expect(injected.text()).toBe("0123456789"); + }); + + test("handler failures go through error(); its failure yields a fixed 500", async () => { + const host = createSimHttpdHost(); + mount(host.ns); + let mode: "handled" | "unhandled" = "handled"; + const listening = serve({ + hostname: "127.0.0.1", + port: 9002, + fetch() { + throw new Error("boom"); + }, + error(error) { + if (mode === "unhandled") throw error; + return new Response("recovered", { status: 502 }); + }, + }); + await ticks(host); + await listening; + const first = host.inject({ target: "/a" }); + await ticks(host, 2); + expect(first.status).toBe(502); + expect(first.text()).toBe("recovered"); + mode = "unhandled"; + const second = host.inject({ target: "/b" }); + await ticks(host, 2); + expect(second.status).toBe(500); + expect(second.text()).toBe(""); + }); + + test("peer disconnect aborts the request signal; late responses are dropped", async () => { + const host = createSimHttpdHost(); + mount(host.ns); + let resolveLater: ((r: Response) => void) | null = null; + let aborted = false; + const listening = serve({ + hostname: "127.0.0.1", + port: 9003, + fetch(request) { + request.signal.addEventListener("abort", () => { + aborted = true; + }); + return new Promise((resolve) => { + resolveLater = resolve; + }); + }, + }); + await ticks(host); + await listening; + const injected = host.inject({ target: "/slow" }); + await ticks(host); + expect(resolveLater).not.toBeNull(); + injected.disconnect(); + await ticks(host); + expect(aborted).toBe(true); + expect(injected.aborted).toBe(NET_ERROR.closed); + resolveLater!(new Response("too late")); + await ticks(host); + expect(injected.responded).toBe(false); + expect(host.live()).toBe(0); + }); + + test("synchronous refusals and a missing namespace reject", async () => { + await expect(serve({ hostname: "127.0.0.1", port: 1, fetch: () => new Response("x") })).rejects.toMatchObject({ + code: NET_ERROR.unavailable, + }); + const host = createSimHttpdHost(); + mount(host.ns); + await expect( + serve({ hostname: "127.0.0.1", port: 443, tls: { credential: "c" }, fetch: () => new Response("x") }), + ).rejects.toMatchObject({ code: NET_ERROR.unsupported }); + await expect(serve({ hostname: "127.0.0.1", port: 70000, fetch: () => new Response("x") })).rejects.toBeInstanceOf(NetworkError); + }); +}); diff --git a/tests/net-policy-hosts.test.ts b/tests/net-policy-hosts.test.ts new file mode 100644 index 00000000..940e1a2f --- /dev/null +++ b/tests/net-policy-hosts.test.ts @@ -0,0 +1,135 @@ +// The sim hosts enforce a Build Plan network policy the way the native cores +// do: the connect rule and insecureTransport before any route lookup (and +// before the pump sees a handle), the listen rule before bind, the redirect +// target again. The policies are the shared vectors' documents, so the same +// decisions the C and Rust cores pin here arrive at the SDK as +// `permission_denied`. + +import { afterEach, describe, expect, test } from "bun:test"; + +import { NET_ERROR } from "../contracts/spec/net.ts"; +import { canonicalNetworkPolicyJson, parseNetworkPolicyJson } from "../contracts/spec/network-policy.ts"; +import { fetch as pocketFetch, Response, serve, type HttpdOps, type NetOps } from "../framework/src/net/http.ts"; +import { connect, type WsOps } from "../framework/src/net/websocket.ts"; +import { runServicePumps } from "../framework/src/services.ts"; +import { createSimHttpdHost } from "../hosts/sim/httpd.ts"; +import { createSimNetHost } from "../hosts/sim/net.ts"; +import { createSimWsHost } from "../hosts/sim/ws.ts"; + +const vectors = (await Bun.file(new URL("../contracts/spec/vectors/network-policy.json", import.meta.url)).json()) as { + policies: Record; +}; +const standard = parseNetworkPolicyJson(JSON.stringify(vectors.policies.standard)); +const secureOnly = parseNetworkPolicyJson(JSON.stringify(vectors.policies["secure-only"])); + +type Globals = { net?: NetOps; ws?: WsOps; httpd?: HttpdOps }; +afterEach(() => { + const g = globalThis as Globals; + delete g.net; + delete g.ws; + delete g.httpd; +}); + +async function ticks(host: { tick(): void }, n = 1): Promise { + for (let i = 0; i < n; i++) { + host.tick(); + runServicePumps(); + for (let j = 0; j < 12; j++) await Promise.resolve(); + } +} + +describe("sim hosts enforce the plan's network policy", () => { + test("net: connect rule + insecureTransport decide before routes; the pump never sees a refused handle", async () => { + const routes = { + "http://localhost:8050/ok": { body: "ok" }, + "http://localhost:9000/no": { body: "never" }, + "http://192.168.1.20:8080/ip": { body: "ip" }, + "http://192.168.1.21:8080/other": { body: "never" }, + }; + const host = createSimNetHost(routes, { policy: canonicalNetworkPolicyJson(standard) }); + (globalThis as Globals).net = host.ns; + + const ok = pocketFetch("http://localhost:8050/ok"); + await ticks(host); + expect((await ok).status).toBe(200); + const ip = pocketFetch("http://192.168.1.20:8080/ip"); + await ticks(host); + expect((await ip).status).toBe(200); + + const polls = host.pollCalls(); + // Routed, but outside the policy: refused synchronously. + await expect(pocketFetch("http://localhost:9000/no")).rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + await expect(pocketFetch("http://192.168.1.21:8080/other")).rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + await expect(pocketFetch("http://LOCALHOST:8101/x")).rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + runServicePumps(); + expect(host.pollCalls()).toBe(polls); + expect(host.log.filter((line) => line.startsWith("start"))).toHaveLength(2); + }); + + test("net: insecureTransport=false refuses a matched plaintext rule", async () => { + const host = createSimNetHost( + { "http://api.example.com/x": { body: "x" } }, + { policy: secureOnly }, + ); + (globalThis as Globals).net = host.ns; + await expect(pocketFetch("http://api.example.com/x")).rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + // Without a policy the same host answers (routes are the allowlist). + const open = createSimNetHost({ "http://api.example.com/x": { body: "x" } }); + (globalThis as Globals).net = open.ns; + const response = pocketFetch("http://api.example.com/x"); + await ticks(open); + expect((await response).status).toBe(200); + }); + + test("net: a redirect target outside the policy fails the exchange with permission_denied", async () => { + const host = createSimNetHost( + { + "http://localhost:8050/go": { url: "http://localhost:9000/landed", redirected: true, body: "landed" }, + "http://localhost:8051/go": { url: "http://localhost:8052/landed", redirected: true, body: "landed" }, + }, + { policy: standard }, + ); + (globalThis as Globals).net = host.ns; + const refused = pocketFetch("http://localhost:8050/go"); + const followed = pocketFetch("http://localhost:8051/go"); + await ticks(host, 2); + await expect(refused).rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + const response = await followed; + expect(response.redirected).toBe(true); + expect(response.url).toBe("http://localhost:8052/landed"); + }); + + test("ws: the connect rule is checked before the peer table", async () => { + const host = createSimWsHost( + { "ws://echo.example.com/s": {}, "ws://other.example.com/s": {} }, + { policy: standard }, + ); + (globalThis as Globals).ws = host.ns; + await expect(connect("ws://other.example.com/s", { socket: {} })).rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + const opening = connect("ws://echo.example.com/s", { socket: {} }); + await ticks(host); + const socket = await opening; + expect(socket.readyState).toBe("open"); + socket.terminate(); + await ticks(host); + }); + + test("httpd: listen tuples decide bind; ephemeral only matches port 0", async () => { + const host = createSimHttpdHost({ policy: standard }); + (globalThis as Globals).httpd = host.ns; + await expect(serve({ hostname: "0.0.0.0", port: 8081, fetch: () => new Response("x") })) + .rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + await expect(serve({ hostname: "127.0.0.1", port: 8080, fetch: () => new Response("x") })) + .rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + const listening = serve({ hostname: "0.0.0.0", port: 8080, fetch: () => new Response("x") }); + const ephemeral = serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("x") }); + await ticks(host); + const server = await listening; + expect(server.port).toBe(8080); + const eph = await ephemeral; + expect(eph.port).toBeGreaterThan(0); + server.stop(); + eph.stop(); + await ticks(host); + }); +}); diff --git a/tests/net-web.test.js b/tests/net-web.test.js index d8c259b7..b16f37be 100644 --- a/tests/net-web.test.js +++ b/tests/net-web.test.js @@ -1,9 +1,13 @@ import { expect, test } from "bun:test"; -import { fetch as pocketFetch } from "../framework/src/net-api.ts"; +import { fetch as pocketFetch } from "../framework/src/net/http.ts"; import { runServicePumps } from "../framework/src/services.ts"; import { createNetHost } from "../hosts/web/net.js"; +async function settle() { + for (let i = 0; i < 8; i++) await Promise.resolve(); +} + test("browser net adapter uses native fetch but delivers only at beginFrame", async () => { const calls = []; const host = createNetHost(async (url, options) => { @@ -18,12 +22,11 @@ test("browser net adapter uses native fetch but delivers only at beginFrame", as let settled = false; const promise = pocketFetch("https://example.test/web", { headers: { "x-test": "1" }, - maxBytes: 64, }).then((response) => { settled = true; return response; }); - await Bun.sleep(0); + await Bun.sleep(5); runServicePumps(); await Promise.resolve(); expect(settled).toBe(false); @@ -31,25 +34,60 @@ test("browser net adapter uses native fetch but delivers only at beginFrame", as host.beginFrame(); runServicePumps(); const response = await promise; - expect(await response.text()).toBe("web transport"); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/plain"); + const text = response.text(); + await Bun.sleep(5); + host.beginFrame(); + runServicePumps(); + await settle(); + host.beginFrame(); + runServicePumps(); + expect(await text).toBe("web transport"); expect(calls).toHaveLength(1); expect(calls[0].options.credentials).toBe("omit"); expect(calls[0].options.redirect).toBe("manual"); + expect(calls[0].options.headers["x-test"]).toBe("1"); } finally { host.reset(); delete globalThis.net; } }); -test("browser net adapter enforces response maxBytes while reading", async () => { +test("browser net adapter enforces maxBodyBytes while reading", async () => { const host = createNetHost(async () => new Response("12345")); globalThis.net = host.ns; try { - const promise = pocketFetch("https://example.test/large", { maxBytes: 4 }); - await Bun.sleep(0); + const promise = pocketFetch("https://example.test/large", { limits: { maxBodyBytes: 4 } }); + await Bun.sleep(5); + host.beginFrame(); + runServicePumps(); + const response = await promise; + const outcome = response.text().catch((error) => error); + await Bun.sleep(5); + host.beginFrame(); + runServicePumps(); + await settle(); + expect(await outcome).toMatchObject({ code: "response_too_large" }); + } finally { + host.reset(); + delete globalThis.net; + } +}); + +test("browser net adapter maps hidden redirects to unsupported", async () => { + const host = createNetHost(async () => { + const response = new Response(null, { status: 302, headers: { location: "https://elsewhere.test/" } }); + Object.defineProperty(response, "type", { value: "opaqueredirect" }); + return response; + }); + globalThis.net = host.ns; + try { + const promise = pocketFetch("https://example.test/redirect"); + await Bun.sleep(5); host.beginFrame(); runServicePumps(); - await expect(promise).rejects.toMatchObject({ code: "response_too_large" }); + await expect(promise).rejects.toMatchObject({ code: "unsupported" }); } finally { host.reset(); delete globalThis.net; diff --git a/tests/net-websocket.test.ts b/tests/net-websocket.test.ts new file mode 100644 index 00000000..f1097041 --- /dev/null +++ b/tests/net-websocket.test.ts @@ -0,0 +1,232 @@ +// WebSocket Client SDK (`@pocketjs/framework/net/websocket`) + deterministic +// sim host (hosts/sim/ws.ts): handshake delivery order, text/binary messages, +// control frames, backpressure/drain, close handshake, terminate, handshake +// failures and synchronous refusals. + +import { afterEach, describe, expect, test } from "bun:test"; + +import { NET_ERROR } from "../contracts/spec/net.ts"; +import { NetworkError } from "../framework/src/net/index.ts"; +import { connect, type WebSocket, type WsOps } from "../framework/src/net/websocket.ts"; +import { runServicePumps } from "../framework/src/services.ts"; +import { createSimWsHost } from "../hosts/sim/ws.ts"; + +function mount(ns: WsOps): void { + (globalThis as { ws?: WsOps }).ws = ns; +} + +afterEach(() => { + delete (globalThis as { ws?: WsOps }).ws; +}); + +async function ticks(host: { tick(): void }, n = 1): Promise { + for (let i = 0; i < n; i++) { + host.tick(); + runServicePumps(); + for (let j = 0; j < 8; j++) await Promise.resolve(); + } +} + +describe("websocket SDK + deterministic sim host", () => { + test("open runs readyState → open handler → resolve, in that order", async () => { + const host = createSimWsHost({ "ws://echo.test/socket": { protocol: "telemetry.v1" } }); + mount(host.ns); + const order: string[] = []; + let opened: WebSocket | null = null; + const promise = connect("ws://echo.test/socket", { + protocols: ["telemetry.v1", "other"], + socket: { + open(socket) { + order.push(`open:${socket.readyState}:${socket.protocol}`); + opened = socket; + }, + }, + }).then((socket) => { + order.push("resolved"); + return socket; + }); + await Promise.resolve(); + expect(order).toEqual([]); + await ticks(host); + const socket = await promise; + expect(socket).toBe(opened!); + expect(order).toEqual(["open:open:telemetry.v1", "resolved"]); + expect(socket.url).toBe("ws://echo.test/socket"); + expect(socket.readyState).toBe("open"); + expect(socket.bufferedAmount).toBe(0); + }); + + test("text and binary messages round-trip; binary arrives as an owned Uint8Array", async () => { + const host = createSimWsHost({ "ws://echo.test/socket": {} }); + mount(host.ns); + const received: (string | Uint8Array)[] = []; + const promise = connect("ws://echo.test/socket", { + socket: { + message(_socket, data) { + received.push(data); + }, + }, + }); + await ticks(host); + const socket = await promise; + expect(socket.send("héllo")).toEqual({ status: "accepted", needsDrain: false }); + const payload = new Uint8Array([1, 2, 3]); + expect(socket.send(payload)).toEqual({ status: "accepted", needsDrain: false }); + payload[0] = 9; // snapshot at send() + await ticks(host); + expect(received.length).toBe(2); + expect(received[0]).toBe("héllo"); + expect([...(received[1] as Uint8Array)]).toEqual([1, 2, 3]); + expect(host.log).toContain("send 1 text 6"); + }); + + test("ping/pong control frames and the 125-byte cap", async () => { + const host = createSimWsHost({ "ws://echo.test/socket": {} }); + mount(host.ns); + const pongs: number[] = []; + const pings: number[] = []; + const promise = connect("ws://echo.test/socket", { + socket: { + pong(_s, data) { + pongs.push(data.length); + }, + ping(_s, data) { + pings.push(data.length); + }, + }, + }); + await ticks(host); + const socket = await promise; + expect(socket.ping(new Uint8Array(3))).toBe(true); + expect(() => socket.ping(new Uint8Array(126))).toThrow(NetworkError); + host.peer("ws://echo.test/socket").ping(new Uint8Array(2)); + await ticks(host); + expect(pongs).toEqual([3]); + expect(pings).toEqual([2]); + }); + + test("backpressure returns without accepting; drain fires once", async () => { + const host = createSimWsHost({ "ws://echo.test/socket": { sendWindowBytes: 4, onMessage: () => undefined } }); + mount(host.ns); + let drains = 0; + const promise = connect("ws://echo.test/socket", { + socket: { + drain() { + drains++; + }, + }, + }); + await ticks(host); + const socket = await promise; + expect(socket.send("abc")).toEqual({ status: "accepted", needsDrain: false }); + expect(socket.bufferedAmount).toBe(3); + expect(socket.send("de")).toEqual({ status: "backpressure" }); + await ticks(host); + expect(drains).toBe(1); + expect(socket.bufferedAmount).toBe(0); + expect(socket.send("de")).toEqual({ status: "accepted", needsDrain: false }); + await ticks(host); + expect(drains).toBe(1); // not re-armed + }); + + test("close handshake: closing → close handler with the peer's code", async () => { + const host = createSimWsHost({ "ws://echo.test/socket": {} }); + mount(host.ns); + const closes: [number, string][] = []; + const promise = connect("ws://echo.test/socket", { + socket: { + close(_s, code, reason) { + closes.push([code, reason]); + }, + }, + }); + await ticks(host); + const socket = await promise; + expect(() => socket.close(1001)).toThrow(NetworkError); + socket.close(4000, "bye"); + expect(socket.readyState).toBe("closing"); + expect(socket.send("x")).toEqual({ status: "closed" }); + await ticks(host); + expect(socket.readyState).toBe("closed"); + expect(closes).toEqual([[4000, "bye"]]); + expect(host.live()).toBe(0); + }); + + test("peer close and transport loss report error then close", async () => { + const host = createSimWsHost({ "ws://a.test/": {}, "ws://b.test/": {} }); + mount(host.ns); + const events: string[] = []; + const handlers = (tag: string) => ({ + error(_s: WebSocket, error: NetworkError) { + events.push(`${tag}:error:${error.code}`); + }, + close(_s: WebSocket, code: number) { + events.push(`${tag}:close:${code}`); + }, + }); + const a = connect("ws://a.test/", { socket: handlers("a") }); + const b = connect("ws://b.test/", { socket: handlers("b") }); + await ticks(host); + await a; + await b; + host.peer("ws://a.test/").close(1000, "done"); + host.peer("ws://b.test/").drop(); + await ticks(host); + expect(events).toEqual(["a:close:1000", "b:error:closed", "b:close:1006"]); + expect(host.live()).toBe(0); + }); + + test("terminate aborts without a Close frame", async () => { + const host = createSimWsHost({ "ws://echo.test/socket": {} }); + mount(host.ns); + const closes: [number, string][] = []; + const promise = connect("ws://echo.test/socket", { + socket: { + close(_s, code, reason) { + closes.push([code, reason]); + }, + }, + }); + await ticks(host); + const socket = await promise; + socket.terminate(); + await ticks(host); + expect(closes).toEqual([[1006, ""]]); + expect(socket.readyState).toBe("closed"); + }); + + test("handshake failure rejects connect and calls no handler", async () => { + const host = createSimWsHost({ + "ws://deny.test/": { error: { code: "websocket_handshake_failed", message: "403", status: 403 } }, + }); + mount(host.ns); + let handlerCalls = 0; + const promise = connect("ws://deny.test/", { + socket: { + error() { + handlerCalls++; + }, + close() { + handlerCalls++; + }, + }, + }); + await ticks(host); + const error = await promise.catch((e: unknown) => e); + expect(error).toMatchObject({ code: "websocket_handshake_failed", category: "protocol", reasonCode: 403 }); + expect(handlerCalls).toBe(0); + expect(host.live()).toBe(0); + }); + + test("synchronous refusals", async () => { + const host = createSimWsHost({ "ws://echo.test/socket": {} }); + mount(host.ns); + await expect(connect("wss://echo.test/socket", { socket: {} })).rejects.toMatchObject({ code: NET_ERROR.unsupported }); + await expect(connect("http://echo.test/socket", { socket: {} })).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); + await expect(connect("ws://echo.test/socket#frag", { socket: {} })).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); + await expect(connect("ws://echo.test/socket", { protocols: ["a", "a"], socket: {} })).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); + await expect(connect("ws://echo.test/socket", { headers: { Host: "x" }, socket: {} })).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); + await expect(connect("ws://other.test/", { socket: {} })).rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + expect(host.live()).toBe(0); + }); +}); diff --git a/tests/net.test.ts b/tests/net.test.ts index 882ab2b6..e61a13f2 100644 --- a/tests/net.test.ts +++ b/tests/net.test.ts @@ -1,11 +1,13 @@ +// HTTP Client SDK (`@pocketjs/framework/net/http` fetch) + deterministic sim +// host (hosts/sim/net.ts): tick-boundary delivery, streaming bodies through +// readInto, aggregate helpers, cancellation, error mapping and the support +// module (URL, Headers, AbortController, NetworkError). + import { afterEach, describe, expect, test } from "bun:test"; import { NET_ERROR } from "../contracts/spec/net.ts"; -import { - fetch as pocketFetch, - NetError, - type NetOps, -} from "../framework/src/net-api.ts"; +import { fetch as pocketFetch, Headers, Request, Response, type NetOps } from "../framework/src/net/http.ts"; +import { AbortController, NetworkError, URL, getNetworkLimits } from "../framework/src/net/index.ts"; import { runServicePumps } from "../framework/src/services.ts"; import { createSimNetHost } from "../hosts/sim/net.ts"; @@ -17,10 +19,21 @@ afterEach(() => { delete (globalThis as { net?: NetOps }).net; }); +/** Run `n` host ticks, each followed by the framework service pump and a + * microtask drain (the job drain of that tick). */ +async function ticks(host: { tick(): void }, n = 1): Promise { + for (let i = 0; i < n; i++) { + host.tick(); + runServicePumps(); + // Promise reactions of this tick's deliveries. + for (let j = 0; j < 8; j++) await Promise.resolve(); + } +} + describe("net SDK + deterministic sim host", () => { test("fetch resolves only after a tick boundary and keeps polling lazy", async () => { const host = createSimNetHost({ - "https://example.test/message": { + "http://example.test/message": { status: 200, headers: { "content-type": "application/json" }, body: '{"message":"你好"}', @@ -29,10 +42,10 @@ describe("net SDK + deterministic sim host", () => { mount(host.ns); runServicePumps(); - expect(host.pollCalls()).toBe(0); // no pending Promise: no native poll + expect(host.pollCalls()).toBe(0); // no pending handle: no native poll let settled = false; - const promise = pocketFetch("https://example.test/message").then((response) => { + const promise = pocketFetch("http://example.test/message").then((response) => { settled = true; return response; }); @@ -41,13 +54,17 @@ describe("net SDK + deterministic sim host", () => { expect(settled).toBe(false); // transport has not crossed a tick boundary expect(host.pollCalls()).toBe(1); - host.tick(); - runServicePumps(); + await ticks(host); const response = await promise; expect(response.status).toBe(200); expect(response.ok).toBe(true); - expect(response.headers["content-type"]).toBe("application/json"); - expect(await response.json<{ message: string }>()).toEqual({ message: "你好" }); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(response.url).toBe("http://example.test/message"); + const json = response.json<{ message: string }>(); + await ticks(host); + expect(await json).toEqual({ message: "你好" }); + expect(response.bodyUsed).toBe(true); + expect(host.live()).toBe(0); const pollsAfterSettle = host.pollCalls(); runServicePumps(); @@ -55,96 +72,311 @@ describe("net SDK + deterministic sim host", () => { expect(host.pollCalls()).toBe(pollsAfterSettle); // pump unregistered itself }); - test("one poll drains every completion visible in the tick", async () => { + test("bodies stream through readInto across ticks with backpressure", async () => { const host = createSimNetHost({ - "https://example.test/a": { body: "a" }, - "https://example.test/b": { body: "b" }, + "http://example.test/stream": { + body: ["abc", "def", "ghi", "jkl"], + chunkTicks: 1, + length: null, + }, }); mount(host.ns); - const a = pocketFetch("https://example.test/a"); - const b = pocketFetch("https://example.test/b"); - host.tick(); - runServicePumps(); + const promise = pocketFetch("http://example.test/stream"); + await ticks(host); // head + first chunk + const response = await promise; + expect(response.headers.has("content-length")).toBe(false); + const seen: string[] = []; + const reader = (async () => { + for await (const chunk of response.body!) seen.push(new TextDecoder().decode(chunk)); + })(); + for (let i = 0; i < 6; i++) await ticks(host); + await reader; + expect(seen).toEqual(["abc", "def", "ghi", "jkl"]); + expect(host.live()).toBe(0); + }); - expect(host.pollCalls()).toBe(1); - expect(await (await a).text()).toBe("a"); - expect(await (await b).text()).toBe("b"); - expect(host.log.filter((line) => line.startsWith("poll "))).toHaveLength(1); + test("readInto: one pending read, empty destination rejected, EOF only as {0,true}", async () => { + const host = createSimNetHost({ "http://example.test/two": { body: ["12", "34"], chunkTicks: 1 } }); + mount(host.ns); + const promise = pocketFetch("http://example.test/two"); + await ticks(host); + const response = await promise; + const body = response.body!; + await expect(body.readInto(new Uint8Array(0))).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); + const buf = new Uint8Array(8); + const first = await body.readInto(buf); + expect(first).toEqual({ bytes: 2, done: false }); + const second = body.readInto(buf.subarray(2)); + await expect(body.readInto(new Uint8Array(1))).rejects.toMatchObject({ code: NET_ERROR.busy }); + await ticks(host); + // The last bytes and `end` land in the same batch: the read that took + // the bytes reports done only once EOF was observed, so the next read + // is the {0,true} EOF marker. + expect((await second).bytes).toBe(2); + expect(new TextDecoder().decode(buf.subarray(0, 4))).toBe("1234"); + expect(await body.readInto(buf)).toEqual({ bytes: 0, done: true }); + // The stream is locked: text() must fail with invalid_state. + await expect(response.text()).rejects.toMatchObject({ code: NET_ERROR.invalidState }); }); - test("request metadata and body cross as owned bounded data", async () => { + test("aggregate helpers cancel past their limit with response_too_large", async () => { const host = createSimNetHost({ - "https://example.test/items": (request) => { - expect(request.method).toBe("POST"); - expect(request.headers).toEqual({ "content-type": "application/json", "x-id": "42" }); - expect(new TextDecoder().decode(request.body)).toBe('{"name":"pocket"}'); - expect(request.timeoutMs).toBe(2500); - expect(request.maxBytes).toBe(1024); - return { status: 201, body: "created" }; - }, + "http://example.test/big": { body: "x".repeat(2048), length: null, chunkTicks: 0 }, }); mount(host.ns); - const promise = pocketFetch("https://example.test/items", { - method: "POST", - headers: { "Content-Type": "application/json", "X-ID": "42" }, - body: '{"name":"pocket"}', - timeoutMs: 2500, - maxBytes: 1024, - }); - host.tick(); - runServicePumps(); + const promise = pocketFetch("http://example.test/big", { limits: { aggregateBytes: 1024 } }); + await ticks(host); + const response = await promise; + const text = response.text(); + await ticks(host, 2); + await expect(text).rejects.toMatchObject({ code: NET_ERROR.responseTooLarge }); + expect(host.log.some((l) => l.startsWith("cancel"))).toBe(true); + await ticks(host); + expect(host.live()).toBe(0); + }); + + test("known Content-Length above the limit fails before reading", async () => { + const host = createSimNetHost({ "http://example.test/len": { body: "x".repeat(4096) } }); + mount(host.ns); + const promise = pocketFetch("http://example.test/len", { limits: { aggregateBytes: 100 } }); + await ticks(host); const response = await promise; - expect(response.status).toBe(201); - expect(await response.text()).toBe("created"); - expect(await response.text()).toBe("created"); // buffered response can be reread + const bytes = response.arrayBuffer(); + await ticks(host, 2); + await expect(bytes).rejects.toBeInstanceOf(NetworkError); }); - test("whole-response cap rejects before oversized data reaches guest", async () => { + test("clone tees the body; both branches read the same bytes", async () => { + const host = createSimNetHost({ "http://example.test/clone": { body: ["hello ", "world"], chunkTicks: 1 } }); + mount(host.ns); + const promise = pocketFetch("http://example.test/clone"); + await ticks(host); + const original = await promise; + const copy = original.clone(); + const a = original.text(); + const b = copy.text(); + await ticks(host, 4); + expect(await a).toBe("hello world"); + expect(await b).toBe("hello world"); + expect(() => original.clone()).toThrow(NetworkError); + }); + + test("clone's tee is a hard bound: the lagging branch never holds more than the aggregate limit", async () => { + // 3 KiB body in 1 KiB chunks, a 2 KiB aggregate limit: the branch that + // is not read may buffer at most 2 KiB; the reading branch then waits + // (backpressure on the source) until the other branch drains. + const chunk = "x".repeat(1024); + const host = createSimNetHost({ "http://example.test/tee": { body: [chunk, chunk, chunk], chunkTicks: 1 } }); + mount(host.ns); + const promise = pocketFetch("http://example.test/tee", { limits: { aggregateBytes: 2048 } }); + await ticks(host); + const original = await promise; + const copy = original.clone(); + const reader = original.body!; + // TeeBranch (the runtime class behind the clone's BodyStream) exposes + // its backlog; the test reads it through the class, not the public type. + const lagging = copy.body as unknown as import("../framework/src/net/body.ts").TeeBranch; + let read = 0; + const sink = new Uint8Array(256); + // Drive the leading branch as fast as the sim delivers. + const pump = (async () => { + for (;;) { + const { bytes, done } = await reader.readInto(sink); + read += bytes; + if (done) break; + } + })(); + await ticks(host, 6); + // The leading branch is throttled by the bound: it cannot run ahead of + // the lagging branch by more than 2 KiB, whatever the source offers. + expect(read).toBeLessThanOrEqual(2048); + expect(lagging.available()).toBeLessThanOrEqual(2048); + expect(lagging.available()).toBe(read); + // Draining the lagging branch releases the leading one. + const drained = lagging.readInto(new Uint8Array(4096)); + await ticks(host, 6); + expect((await drained).bytes).toBeGreaterThan(0); + await pump; + expect(read).toBe(3072); + await lagging.cancel(); + await ticks(host, 2); + }); + + test("HEAD and 204 responses have a null body and retire on end", async () => { const host = createSimNetHost({ - "https://example.test/large": { body: new Uint8Array(5) }, + "http://example.test/head": { status: 200, headers: { "content-length": "42" }, length: 42, body: "" }, + "http://example.test/nocontent": { status: 204, body: "" }, }); mount(host.ns); - const promise = pocketFetch("https://example.test/large", { maxBytes: 4 }); - host.tick(); - runServicePumps(); - await expect(promise).rejects.toMatchObject({ code: NET_ERROR.responseTooLarge }); - expect(host.log.some((line) => line.startsWith("take "))).toBe(false); + const head = pocketFetch("http://example.test/head", { method: "HEAD" }); + const none = pocketFetch("http://example.test/nocontent"); + await ticks(host); + expect((await head).body).toBeNull(); + expect((await none).body).toBeNull(); + expect((await none).status).toBe(204); + expect(await (await head).text()).toBe(""); + expect(host.live()).toBe(0); }); - test("the third concurrent request is refused with busy", async () => { + test("errors map onto NetworkError with the stable code and category", async () => { const host = createSimNetHost({ - "https://example.test/a": { body: "a", delayTicks: 2 }, - "https://example.test/b": { body: "b", delayTicks: 2 }, - "https://example.test/c": { body: "c", delayTicks: 2 }, + "http://example.test/dns": { error: { code: "dns", message: "no such host" } }, + "http://example.test/late": { body: ["ab", "cd"], chunkTicks: 1, error: { code: "closed", message: "peer reset", afterHeaders: true } }, }); mount(host.ns); - const a = pocketFetch("https://example.test/a"); - const b = pocketFetch("https://example.test/b"); - await expect(pocketFetch("https://example.test/c")).rejects.toMatchObject({ - code: NET_ERROR.busy, - }); - host.tick(); - host.tick(); + const failing = pocketFetch("http://example.test/dns"); + await ticks(host); + const error = await failing.catch((e: unknown) => e); + expect(error).toBeInstanceOf(NetworkError); + expect(error).toMatchObject({ code: "dns", category: "resolver", operation: "fetch", protocol: "http", temporary: true }); + + const late = pocketFetch("http://example.test/late"); + await ticks(host); + const response = await late; + const text = response.text(); + await ticks(host, 3); + await expect(text).rejects.toMatchObject({ code: "closed", category: "runtime" }); + expect(host.live()).toBe(0); + }); + + test("synchronous refusals reject without touching the pump", async () => { + const host = createSimNetHost({}); + mount(host.ns); + await expect(pocketFetch("http://example.test/none")).rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + await expect(pocketFetch("https://example.test/tls")).rejects.toMatchObject({ code: NET_ERROR.unsupported }); + await expect(pocketFetch("ftp://example.test/x")).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); + await expect(pocketFetch("http://example.test/x", { method: "TRACE" })).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); + await expect(pocketFetch("http://example.test/x", { method: "GET", body: "nope" })).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); + await expect(pocketFetch("http://user:pw@example.test/x")).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); + expect(host.pollCalls()).toBe(0); runServicePumps(); - await Promise.all([a, b]); + expect(host.pollCalls()).toBe(0); + }); + + test("the namespace missing yields unavailable, not a crash", async () => { + await expect(pocketFetch("http://example.test/x")).rejects.toMatchObject({ code: NET_ERROR.unavailable }); + expect(getNetworkLimits().httpClient).toBeNull(); + }); + + test("AbortSignal cancels; the terminal event settles at the next tick", async () => { + const host = createSimNetHost({ "http://example.test/slow": { body: "later", delayTicks: 5 } }); + mount(host.ns); + const controller = new AbortController(); + const promise = pocketFetch("http://example.test/slow", { signal: controller.signal }); + await ticks(host); + controller.abort(); + let settled = false; + promise.catch(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); // nothing settles inside abort() + await ticks(host); + await expect(promise).rejects.toMatchObject({ code: NET_ERROR.cancelled }); + expect(host.live()).toBe(0); + // Already-aborted signals refuse synchronously. + const done = new AbortController(); + done.abort(); + await expect(pocketFetch("http://example.test/slow", { signal: done.signal })).rejects.toMatchObject({ code: NET_ERROR.cancelled }); }); - test("invalid portable requests fail without entering the host", async () => { + test("request bodies cross as one borrowed snapshot; headers reach the host lowercased", async () => { + let seen: { method: string; body: Uint8Array; headers: Record } | null = null; const host = createSimNetHost({ - "https://example.test/a": { body: "unused" }, + "http://example.test/echo": (request) => { + seen = { method: request.method, body: request.body, headers: { ...request.headers } }; + return { status: 201, body: request.body }; + }, }); mount(host.ns); - await expect( - pocketFetch("https://example.test/a", { method: "GET", body: "no" }), - ).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); - await expect(pocketFetch("file:///secret")).rejects.toBeInstanceOf(NetError); - expect(host.log).toEqual([]); + const bytes = new Uint8Array([1, 2, 3, 4]); + const promise = pocketFetch("http://example.test/echo", { + method: "post", + body: bytes, + headers: { "X-Trace": " abc ", Host: "evil.test", Cookie: "a=1" }, + }); + bytes[0] = 99; // after start(): the snapshot is unaffected + await ticks(host); + const response = await promise; + expect(seen!.method).toBe("POST"); + expect([...seen!.body]).toEqual([1, 2, 3, 4]); + expect(seen!.headers["x-trace"]).toBe("abc"); + expect(seen!.headers.host).toBeUndefined(); // core-owned header dropped by the request guard + expect(seen!.headers.cookie).toBe("a=1"); // explicit cookies are allowed + const echoed = response.arrayBuffer(); + await ticks(host); + expect([...new Uint8Array(await echoed)]).toEqual([1, 2, 3, 4]); }); - test("an unmounted module rejects explicitly", async () => { - delete (globalThis as { net?: NetOps }).net; - await expect(pocketFetch("https://example.test/a")).rejects.toMatchObject({ - code: NET_ERROR.unavailable, - }); + test("getNetworkLimits reflects the mounted module", () => { + const host = createSimNetHost({}); + mount(host.ns); + const limits = getNetworkLimits(); + expect(limits.httpClient?.specMajor).toBe(2); + expect(limits.httpClient?.features).toEqual([]); + expect(limits.websocketClient).toBeNull(); + expect(Object.isFrozen(limits)).toBe(true); + }); +}); + +describe("net support module", () => { + test("URL parses, resolves and normalizes the special schemes", () => { + const u = new URL("HTTP://Example.TEST:80/a/./b/../c?q=1#frag"); + expect(u.href).toBe("http://example.test/a/c?q=1#frag"); + expect(u.protocol).toBe("http:"); + expect(u.hostname).toBe("example.test"); + expect(u.port).toBe(""); + expect(u.effectivePort).toBe(80); + expect(u.origin).toBe("http://example.test"); + expect(new URL("https://h:8443/x").port).toBe("8443"); + expect(new URL("/other?y", "http://a.test/p/q").href).toBe("http://a.test/other?y"); + expect(new URL("rel", "http://a.test/p/q").href).toBe("http://a.test/p/rel"); + expect(new URL("//b.test/z", "http://a.test/p").href).toBe("http://b.test/z"); + expect(new URL("http://[::1]:8080/").host).toBe("[::1]:8080"); + expect(new URL("ws://h/a b").pathname).toBe("/a%20b"); + expect(URL.canParse("http://")).toBe(false); + expect(URL.canParse("nope")).toBe(false); + expect(() => new URL("http://exa mple.test/")).toThrow(TypeError); + expect(new URL("mailto:someone@x").protocol).toBe("mailto:"); + }); + + test("Headers normalizes, combines, sorts and splits Set-Cookie", () => { + const h = new Headers([ + ["Content-Type", " text/plain "], + ["set-cookie", "a=1"], + ["Set-Cookie", "b=2"], + ["accept", "x"], + ]); + h.append("Accept", "y"); + expect(h.get("accept")).toBe("x, y"); + expect(h.get("content-type")).toBe("text/plain"); + expect(h.getSetCookie()).toEqual(["a=1", "b=2"]); + expect([...h.keys()]).toEqual(["accept", "content-type", "set-cookie", "set-cookie"]); + expect(() => h.set("bad name", "v")).toThrow(NetworkError); + expect(() => h.set("x", "a\r\nb")).toThrow(NetworkError); + h.delete("accept"); + expect(h.has("accept")).toBe(false); + }); + + test("Request/Response constructors validate and lock bodies", async () => { + const request = new Request("http://a.test/x", { method: "post", body: "hi", headers: { "x-a": "1" } }); + expect(request.method).toBe("POST"); + expect(request.bodyUsed).toBe(false); + const copy = request.clone(); + expect(await request.text()).toBe("hi"); + expect(request.bodyUsed).toBe(true); + expect(await copy.text()).toBe("hi"); + await expect(request.text()).rejects.toMatchObject({ code: NET_ERROR.invalidState }); + expect(() => new Request("http://a.test/x", { redirect: "sometimes" as "follow" })).toThrow(NetworkError); + + const response = Response.json({ ok: true }, { status: 201 }); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(await response.json<{ ok: boolean }>()).toEqual({ ok: true }); + expect(response.bodyUsed).toBe(true); + const redirect = Response.redirect("http://b.test/", 307); + expect(redirect.status).toBe(307); + expect(redirect.headers.get("location")).toBe("http://b.test/"); + expect(() => new Response("x", { status: 204 })).toThrow(NetworkError); + expect(() => new Response(null, { status: 199 })).toThrow(NetworkError); }); }); diff --git a/tests/network-policy.test.ts b/tests/network-policy.test.ts new file mode 100644 index 00000000..080739f5 --- /dev/null +++ b/tests/network-policy.test.ts @@ -0,0 +1,129 @@ +// The TypeScript reference of the network policy contract against the shared +// vectors. engine/net (pnet_unit_test) and engine/crates/pocket-net run the +// same file; a decision that differs between the three is a conformance +// failure, not a host quirk. +import { describe, expect, test } from "bun:test"; +import { + DENY_ALL_NETWORK_POLICY, + canonicalNetworkPolicyJson, + formatNetworkAddress, + networkAddressIsMulticast, + networkAddressIsPublic, + networkPolicyAllowsAddress, + networkPolicyAllowsConnect, + networkPolicyAllowsListen, + parseNetworkAddress, + parseNetworkPolicyJson, + resolveNetworkPolicy, + type ResolvedNetworkPolicy, +} from "../contracts/spec/network-policy.ts"; + +interface Vectors { + readonly policies: Readonly>; + readonly invalid: readonly { name: string; policy: unknown }[]; + readonly connect: readonly { policy: string; protocol: string; host: string; port: number; allowed: boolean }[]; + readonly address: readonly { address: string; public: boolean; multicast: boolean }[]; + readonly listen: readonly { policy: string; protocol: string; address: string; port: number; allowed: boolean }[]; +} + +const vectors = (await Bun.file(new URL("../contracts/spec/vectors/network-policy.json", import.meta.url)).json()) as Vectors; + +const policies = new Map(); +for (const [name, document] of Object.entries(vectors.policies)) { + policies.set(name, parseNetworkPolicyJson(JSON.stringify(document))); +} + +describe("network policy vectors", () => { + test("every vector policy is canonical: parse → canonical JSON reproduces the document", () => { + for (const [name, document] of Object.entries(vectors.policies)) { + const policy = policies.get(name)!; + expect(JSON.parse(canonicalNetworkPolicyJson(policy))).toEqual(document); + // Canonical JSON is a fixed point. + expect(canonicalNetworkPolicyJson(parseNetworkPolicyJson(canonicalNetworkPolicyJson(policy)))).toBe(canonicalNetworkPolicyJson(policy)); + } + expect(policies.get("deny-all")).toEqual(DENY_ALL_NETWORK_POLICY); + }); + + test("invalid documents are refused", () => { + for (const { name, policy } of vectors.invalid) { + expect(() => parseNetworkPolicyJson(JSON.stringify(policy)), name).toThrow(); + } + }); + + test("connect decisions", () => { + for (const v of vectors.connect) { + const policy = policies.get(v.policy)!; + expect(networkPolicyAllowsConnect(policy, v.protocol, v.host, v.port), JSON.stringify(v)).toBe(v.allowed); + } + }); + + test("address classification and the localNetwork gate", () => { + const open = policies.get("standard")!; // localNetwork: true + const closed = policies.get("secure-only")!; // localNetwork: false + for (const v of vectors.address) { + const addr = parseNetworkAddress(v.address); + expect(addr, v.address).not.toBeNull(); + expect(networkAddressIsPublic(addr!), v.address).toBe(v.public); + expect(networkAddressIsMulticast(addr!), v.address).toBe(v.multicast); + expect(networkPolicyAllowsAddress(closed, addr!), v.address).toBe(v.public); + expect(networkPolicyAllowsAddress(open, addr!), v.address).toBe(!v.multicast); + // Canonical text round-trips. + expect(formatNetworkAddress(parseNetworkAddress(formatNetworkAddress(addr!))!)).toBe(formatNetworkAddress(addr!)); + } + }); + + test("listen decisions", () => { + for (const v of vectors.listen) { + const policy = policies.get(v.policy)!; + expect(networkPolicyAllowsListen(policy, v.protocol, v.address, v.port), JSON.stringify(v)).toBe(v.allowed); + } + }); +}); + +describe("network policy resolution", () => { + test("normalizes, sorts and collapses manifest intent", () => { + const result = resolveNetworkPolicy({ + connect: [ + { protocol: "https", host: "B.Example.com.", port: { min: 443, max: 443 } }, + { protocol: "https", host: "a.example.com", port: 443 }, + { protocol: "http", host: "[::1]", port: { min: 1, max: 65535 } }, + ], + listen: [{ protocol: "http", address: "0000:0000:0000:0000:0000:0000:0000:0001", port: "ephemeral" }], + credentials: ["b", "a"], + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.policy).toEqual({ + version: 1, + connect: [ + { protocol: "http", host: "::1", port: { min: 1, max: 65535 } }, + { protocol: "https", host: "a.example.com", port: 443 }, + { protocol: "https", host: "b.example.com", port: 443 }, + ], + listen: [{ protocol: "http", address: "::1", port: "ephemeral" }], + credentials: ["a", "b"], + localNetwork: false, + insecureTransport: false, + allowInvalidTlsForDevelopment: false, + }); + }); + + test("reports every fault with its pointer under the caller's prefix", () => { + const result = resolveNetworkPolicy( + { + connect: [{ protocol: "https", host: "*", port: 443 }, { protocol: "https", host: "ok.example", port: 443 }, { protocol: "https", host: "OK.example", port: 443 }], + listen: [{ protocol: "http", address: "0.0.0.0", port: { min: 2, max: 1 } }], + allowInvalidTlsForDevelopment: true, + }, + { path: "/x" }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.map((d) => [d.code, d.path])).toEqual([ + ["network.invalidHost", "/x/connect/0/host"], + ["network.duplicateRule", "/x/connect/2"], + ["network.reversedPortRange", "/x/listen/0/port"], + ["network.developmentOnly", "/x/allowInvalidTlsForDevelopment"], + ]); + }); +}); diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index 583775e7..5e633fb6 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from "bun:test"; import { generatePocketManifestV2Schema, + generatePocketManifestV3Schema, POCKET_MANIFEST_SCHEMA_ID, - type PocketManifestV2, + POCKET_MANIFEST_V3_SCHEMA_ID, + type PocketManifest, } from "../contracts/spec/pocket-manifest.ts"; +import { DENY_ALL_NETWORK_POLICY, canonicalNetworkPolicyJson } from "../contracts/spec/network-policy.ts"; import { POCKET_CAPABILITIES, POCKET_PLATFORM_CONTRACTS, @@ -28,7 +31,7 @@ const portableInput: unknown = await Bun.file(fixtureUrl("portable-psp")).json() const invalidExtraInput: unknown = await Bun.file(fixtureUrl("invalid-extra-field")).json(); const touchInput: unknown = await Bun.file(fixtureUrl("requires-touch")).json(); -function manifest(input: unknown): PocketManifestV2 { +function manifest(input: unknown): PocketManifest { const result = validatePocketManifest(input); if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); return result.value; @@ -139,6 +142,172 @@ describe("pocket.json v2 schema", () => { }); }); +describe("pocket.json v3 schema (format 2 + permissions)", () => { + function formatThree(): Record { + const input = structuredClone(portableInput) as Record; + input.$schema = POCKET_MANIFEST_V3_SCHEMA_ID; + input.pocket = 3; + return input; + } + + test("uses its own schema path and the committed JSON Schema is byte-exact", async () => { + expect(POCKET_MANIFEST_V3_SCHEMA_ID).toBe("https://pocketjs.dev/schema/pocket-3.json"); + const committed = await Bun.file(new URL("../contracts/schema/pocket-3.json", import.meta.url)).text(); + expect(committed).toBe(generatePocketManifestV3Schema()); + }); + + test("accepts a format-3 manifest with and without permissions; refuses other formats", () => { + expect(validatePocketManifest(formatThree()).ok).toBe(true); + const withNetwork = formatThree(); + withNetwork.permissions = { + network: { + connect: [{ protocol: "https", host: "api.example.com", port: 443 }], + listen: [{ protocol: "http", address: "127.0.0.1", port: "ephemeral" }], + credentials: ["device-cert"], + localNetwork: false, + insecureTransport: true, + }, + }; + expect(validatePocketManifest(withNetwork).ok).toBe(true); + + // Format 2 stays strict: `permissions` is an unknown field there. + const twoWithPermissions = structuredClone(portableInput) as Record; + twoWithPermissions.permissions = { network: {} }; + const two = validatePocketManifest(twoWithPermissions); + expect(two.ok).toBe(false); + if (!two.ok) expect(two.diagnostics).toContainEqual({ code: "schema.additionalProperty", path: "/permissions", message: "unknown property" }); + + const four = structuredClone(portableInput) as Record; + four.pocket = 4; + const result = validatePocketManifest(four); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.diagnostics).toEqual([{ code: "schema.enum", path: "/pocket", message: "expected one of 2, 3" }]); + }); + + test("rejects malformed network permissions at their JSON Pointer", () => { + const bad = formatThree(); + bad.permissions = { + network: { + connect: [ + { protocol: "ftp", host: "x", port: 21 }, + { protocol: "https", host: "", port: 443 }, + { protocol: "https", host: "api.example.com", port: 70000 }, + { protocol: "https", host: "api.example.com", port: "ephemeral" }, + ], + listen: [{ protocol: "http", address: "0.0.0.0", port: 0 }], + broadcast: true, + }, + }; + const result = validatePocketManifest(bad); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.map((item) => [item.code, item.path])).toEqual(expect.arrayContaining([ + ["schema.enum", "/permissions/network/connect/0/protocol"], + ["schema.minLength", "/permissions/network/connect/1/host"], + ["schema.anyOf", "/permissions/network/connect/2/port"], + ["schema.anyOf", "/permissions/network/connect/3/port"], + ["schema.anyOf", "/permissions/network/listen/0/port"], + ["schema.additionalProperty", "/permissions/network/broadcast"], + ])); + }); + + test("resolves permissions into the plan's canonical network policy", () => { + const input = formatThree(); + input.permissions = { + network: { + connect: [ + { protocol: "https", host: "Api.Example.COM.", port: 443 }, + { protocol: "https", host: "*.Devices.example.com", port: { min: 8443, max: 8443 } }, + { protocol: "http", host: "[2001:DB8:0:0:0:0:0:1]", port: { min: 8000, max: 8100 } }, + ], + listen: [ + { protocol: "http", address: "0.0.0.0", port: 8080 }, + { protocol: "http", address: "127.0.0.1", port: "ephemeral" }, + ], + credentials: ["device-cert", "backup-cert"], + insecureTransport: true, + }, + }; + const result = validateAndResolveBuildPlan(input, { target: "psp" }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.plan.network).toEqual({ + version: 1, + connect: [ + { protocol: "http", host: "2001:db8::1", port: { min: 8000, max: 8100 } }, + { protocol: "https", host: "*.devices.example.com", port: 8443 }, + { protocol: "https", host: "api.example.com", port: 443 }, + ], + listen: [ + { protocol: "http", address: "0.0.0.0", port: 8080 }, + { protocol: "http", address: "127.0.0.1", port: "ephemeral" }, + ], + credentials: ["backup-cert", "device-cert"], + localNetwork: false, + insecureTransport: true, + allowInvalidTlsForDevelopment: false, + }); + expect(verifyPlanHash(result.plan)).toBe(true); + // The same intent in another order yields the same plan hash: the + // policy is canonical, not positional. + const shuffled = structuredClone(input); + shuffled.permissions.network.connect.reverse(); + shuffled.permissions.network.listen.reverse(); + shuffled.permissions.network.credentials.reverse(); + const again = validateAndResolveBuildPlan(shuffled, { target: "psp" }); + expect(again.ok && again.plan.planHash).toBe(result.plan.planHash); + // And a different policy is a different plan. + const wider = structuredClone(input); + wider.permissions.network.localNetwork = true; + const widerPlan = validateAndResolveBuildPlan(wider, { target: "psp" }); + expect(widerPlan.ok && widerPlan.plan.planHash).not.toBe(result.plan.planHash); + }); + + test("format 2 and a permission-less format 3 resolve to the deny-all policy", () => { + const two = validateAndResolveBuildPlan(portableInput, { target: "psp" }); + const three = validateAndResolveBuildPlan(formatThree(), { target: "psp" }); + expect(two.ok && three.ok).toBe(true); + if (!two.ok || !three.ok) return; + expect(two.plan.network).toEqual(DENY_ALL_NETWORK_POLICY); + expect(three.plan.network).toEqual(DENY_ALL_NETWORK_POLICY); + expect(canonicalNetworkPolicyJson(two.plan.network)).toBe(canonicalNetworkPolicyJson(three.plan.network)); + }); + + test("refuses semantic policy faults: bare wildcard, reversed range, duplicates, dev-only TLS", () => { + const input = formatThree(); + input.permissions = { + network: { + connect: [ + { protocol: "https", host: "*", port: 443 }, + { protocol: "https", host: "api.example.com", port: { min: 9000, max: 8000 } }, + { protocol: "https", host: "api.example.com", port: 443 }, + { protocol: "https", host: "API.example.com", port: { min: 443, max: 443 } }, + { protocol: "https", host: "*.1.2.3.4", port: 443 }, + ], + listen: [{ protocol: "http", address: "localhost", port: 8080 }], + allowInvalidTlsForDevelopment: true, + }, + }; + const result = validateAndResolveBuildPlan(input, { target: "psp" }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.map((item) => [item.code, item.path])).toEqual([ + ["network.invalidHost", "/permissions/network/connect/0/host"], + ["network.reversedPortRange", "/permissions/network/connect/1/port"], + ["network.duplicateRule", "/permissions/network/connect/3"], + ["network.invalidHost", "/permissions/network/connect/4/host"], + ["network.invalidAddress", "/permissions/network/listen/0/address"], + ["network.developmentOnly", "/permissions/network/allowInvalidTlsForDevelopment"], + ]); + // A development build admits the dev-only switch. + const devOnly = formatThree(); + devOnly.permissions = { network: { allowInvalidTlsForDevelopment: true } }; + expect(validateAndResolveBuildPlan(devOnly, { target: "psp" }).ok).toBe(false); + const dev = validateAndResolveBuildPlan(devOnly, { target: "psp", development: true }); + expect(dev.ok && dev.plan.network.allowInvalidTlsForDevelopment).toBe(true); + }); +}); + describe("platform registry", () => { test("production advertises only the truthful stock-host profiles", () => { expect(Object.keys(POCKET_TARGETS)).toEqual([ diff --git a/tests/symbian-package.test.ts b/tests/symbian-package.test.ts index dc674e09..f4f23c14 100644 --- a/tests/symbian-package.test.ts +++ b/tests/symbian-package.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { DENY_ALL_NETWORK_POLICY } from "../contracts/spec/network-policy.ts"; import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; import { symbianDataBaseForEmbeddedBytes, @@ -31,6 +32,7 @@ function plan( }, features: {}, companions: [], + network: DENY_ALL_NETWORK_POLICY, planHash: `sha256:${"0".repeat(64)}`, }; } diff --git a/tests/symbian-runtime.test.ts b/tests/symbian-runtime.test.ts index 9a84d25c..bc2cf6f2 100644 --- a/tests/symbian-runtime.test.ts +++ b/tests/symbian-runtime.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { DENY_ALL_NETWORK_POLICY } from "../contracts/spec/network-policy.ts"; import { existsSync, mkdtempSync, @@ -150,6 +151,7 @@ describe("experimental Nokia E7 runtime profile", () => { }, features: {}, companions: [], + network: DENY_ALL_NETWORK_POLICY, planHash: `sha256:${"0".repeat(64)}`, }, packageBytes: new Uint8Array(bytes), diff --git a/tools/esp-idf-profile.ts b/tools/esp-idf-profile.ts new file mode 100644 index 00000000..2596c65e --- /dev/null +++ b/tools/esp-idf-profile.ts @@ -0,0 +1,97 @@ +import { + POCKET_CAPABILITIES, + definePlatformContractRegistry, + defineTargetRegistry, +} from "../contracts/spec/platforms.ts"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; +import { validateAndResolveBuildPlan, type ResolveBuildRequest } from "../framework/src/manifest/resolve.ts"; + +/** + * Private ESP-IDF network-host profiles, used only by the hardware gate + * (hosts/esp-idf/examples/net-smoke). They deliberately stay out of the + * production `POCKET_TARGETS` registry: the ESP-IDF host ships the network + * modules, not a renderer, so these profiles advertise exactly the roles the + * AtomS3R and Tab5 hosts implemented and passed on hardware — the HTTP + * client (with ESP-TLS), the HTTP server (plaintext) and the WebSocket + * client (with ESP-TLS) — and nothing about input or text. The display + * facts are the boards' panels; the smoke firmware is headless and never + * presents, but a plan names the panel the build was made for. + * + * The point of the profile is the plan: the smoke manifest (format 3) + * resolves against it, the resolver normalizes `permissions.network` into + * the plan, and the firmware embeds that canonical policy — the host never + * authors one. + */ +export const ESP_IDF_DEV_HOST_ABI = 9; +export const ATOMS3R_DEV_TARGET_ID = "atoms3r-dev"; +export const TAB5_DEV_TARGET_ID = "tab5-dev"; +export const ATOMS3R_VIEWPORT = [128, 128] as const; +export const TAB5_VIEWPORT = [1280, 720] as const; + +export const ESP_IDF_NETWORK_CAPABILITIES = [ + "network.http.client", + "network.http.client.tls", + "network.http.server", + "network.websocket.client", + "network.websocket.client.tls", +] as const; + +export const ESP_IDF_DEV_CONTRACTS = definePlatformContractRegistry( + POCKET_CAPABILITIES, + defineTargetRegistry({ + [ATOMS3R_DEV_TARGET_ID]: { + hostAbi: ESP_IDF_DEV_HOST_ABI, + platform: "esp-idf", + form: "takeover", + display: { + physicalViewport: ATOMS3R_VIEWPORT, + logicalViewports: [ATOMS3R_VIEWPORT], + presentations: ["native"], + rasterDensity: 1, + }, + capabilities: ESP_IDF_NETWORK_CAPABILITIES, + }, + [TAB5_DEV_TARGET_ID]: { + hostAbi: ESP_IDF_DEV_HOST_ABI, + platform: "esp-idf", + form: "takeover", + display: { + physicalViewport: TAB5_VIEWPORT, + logicalViewports: [TAB5_VIEWPORT], + presentations: ["native"], + rasterDensity: 1, + }, + capabilities: ESP_IDF_NETWORK_CAPABILITIES, + }, + }), +); + +export type EspIdfBoard = "atoms3r" | "tab5"; + +export function espIdfTargetId(board: EspIdfBoard): string { + return board === "tab5" ? TAB5_DEV_TARGET_ID : ATOMS3R_DEV_TARGET_ID; +} + +export function espIdfPanel(board: EspIdfBoard): readonly [number, number] { + return board === "tab5" ? TAB5_VIEWPORT : ATOMS3R_VIEWPORT; +} + +export function resolveEspIdfBuildPlan( + input: unknown, + board: EspIdfBoard, + options: Omit = {}, +): ResolvedBuildPlan { + const resolution = validateAndResolveBuildPlan( + input, + { target: espIdfTargetId(board), ...options }, + ESP_IDF_DEV_CONTRACTS, + ); + if (!resolution.ok) { + throw new Error( + `pocket esp-idf: manifest did not resolve for ${board}: ${resolution.diagnostics + .map((diagnostic) => `${diagnostic.path || "/"}: ${diagnostic.message}`) + .join("; ")}`, + ); + } + return resolution.plan; +} diff --git a/tools/esp-idf.ts b/tools/esp-idf.ts new file mode 100644 index 00000000..5db1618d --- /dev/null +++ b/tools/esp-idf.ts @@ -0,0 +1,154 @@ +// tools/esp-idf.ts — build inputs for the ESP-IDF network hosts. +// +// bun tools/esp-idf.ts smoke-inputs --board=atoms3r|tab5 --outdir= +// [--mac-host=H --mac-http-port=P --mac-ws-port=P] +// [--peer-host=H --peer-port=P] [--serve-port=P] [--tls-host=H] +// [--tick-hz=N] [--no-bundle] +// +// The smoke firmware's CMake runs this before compiling. It turns the smoke +// manifest (hosts/esp-idf/examples/net-smoke/pocket.json, format 3) plus the +// rig's endpoints (Kconfig: workstation peer, peer board, serve port, TLS +// host) into one resolved manifest, resolves the Build Plan against the +// board's private profile (tools/esp-idf-profile.ts), and writes into +// : +// +// pocket.resolved.json the manifest the plan was resolved from +// plan.json the ResolvedBuildPlan (planHash covers the policy) +// network-policy.json HostBuildInputs.network.policyJson — the canonical +// ResolvedNetworkPolicy the firmware embeds and hands +// to pnet_runtime_create verbatim +// host-inputs.h C defines: plan hash, target, features, tick rate +// app.js the guest bundle (tools/build.ts --plan) +// +// The firmware never authors a policy: everything it mounts and allows comes +// from these files, and planHash names the build on the device. + +import { mkdirSync } from "node:fs"; +import { dirname, join, resolve as resolvePath } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { NetworkConnectRule, NetworkListenRule } from "../contracts/spec/network-policy.ts"; +import { extractHostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts"; +import { espIdfPanel, resolveEspIdfBuildPlan, type EspIdfBoard } from "./esp-idf-profile.ts"; + +const ROOT = resolvePath(fileURLToPath(new URL("..", import.meta.url))); +const SMOKE_DIR = join(ROOT, "hosts/esp-idf/examples/net-smoke"); + +export interface SmokeRig { + readonly board: EspIdfBoard; + readonly macHost?: string; + readonly macHttpPort: number; + readonly macWsPort: number; + readonly peerHost?: string; + readonly peerPort: number; + readonly servePort: number; + readonly tlsHost?: string; + readonly tickHz: number; +} + +/** The smoke manifest with the rig's endpoints merged into its intent: + * workstation peer HTTP (its port and the two after it — the WebSocket + * listener and a closed port for the connection-refused case), workstation + * WebSocket, peer board HTTP, the serve port, the positive TLS host, and the + * board's panel as the (nominal, headless) viewport. */ +export function smokeManifest(base: Record, rig: SmokeRig): Record { + const manifest = structuredClone(base); + const network = (manifest.permissions ??= {}).network ??= {}; + const connect: NetworkConnectRule[] = [...(network.connect ?? [])]; + if (rig.macHost) { + connect.push({ protocol: "http", host: rig.macHost, port: { min: rig.macHttpPort, max: rig.macHttpPort + 2 } }); + connect.push({ protocol: "ws", host: rig.macHost, port: rig.macWsPort }); + } + if (rig.peerHost) connect.push({ protocol: "http", host: rig.peerHost, port: rig.peerPort }); + if (rig.tlsHost && !connect.some((rule) => rule.protocol === "https" && rule.host === rig.tlsHost && rule.port === 443)) { + connect.push({ protocol: "https", host: rig.tlsHost, port: 443 }); + } + network.connect = connect; + const listen: NetworkListenRule[] = [{ protocol: "http", address: "0.0.0.0", port: rig.servePort }]; + network.listen = listen; + const panel = espIdfPanel(rig.board); + manifest.app.viewport = { logical: [panel[0], panel[1]], presentation: "native" }; + return manifest; +} + +/** C header with the plan facts the firmware compiles against. */ +export function hostInputsHeader(inputs: ReturnType, rig: SmokeRig): string { + const define = (name: string, value: string | number) => `#define ${name} ${typeof value === "number" ? value : JSON.stringify(value)}`; + const feature = (id: string) => define(`POCKETJS_FEATURE_${id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`, inputs.features[id] ? 1 : 0); + return [ + "/* GENERATED by tools/esp-idf.ts from the smoke manifest's Build Plan — do not edit. */", + "#ifndef POCKETJS_HOST_INPUTS_H", + "#define POCKETJS_HOST_INPUTS_H", + define("POCKETJS_PLAN_HASH", inputs.planHash), + define("POCKETJS_TARGET", inputs.target), + define("POCKETJS_HOST_ABI", inputs.hostAbi), + define("POCKETJS_APP_OUTPUT", inputs.appOutput), + define("POCKETJS_TICK_HZ", rig.tickHz), + ...Object.keys(inputs.features).sort().map(feature), + "#endif", + "", + ].join("\n"); +} + +function flag(args: string[], name: string): string | undefined { + const prefix = `--${name}=`; + const hit = args.find((a) => a.startsWith(prefix)); + return hit?.slice(prefix.length); +} + +function intFlag(args: string[], name: string, fallback: number): number { + const raw = flag(args, name); + if (raw === undefined || raw === "") return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) throw new Error(`pocket esp-idf: --${name} must be a non-negative integer`); + return value; +} + +async function smokeInputs(args: string[]): Promise { + const board = flag(args, "board"); + if (board !== "atoms3r" && board !== "tab5") throw new Error("pocket esp-idf: --board=atoms3r|tab5 is required"); + const outdir = flag(args, "outdir"); + if (!outdir) throw new Error("pocket esp-idf: --outdir= is required"); + const rig: SmokeRig = { + board, + macHost: flag(args, "mac-host") || undefined, + macHttpPort: intFlag(args, "mac-http-port", 8790), + macWsPort: intFlag(args, "mac-ws-port", 8791), + peerHost: flag(args, "peer-host") || undefined, + peerPort: intFlag(args, "peer-port", 8080), + servePort: intFlag(args, "serve-port", 8080), + tlsHost: flag(args, "tls-host") || undefined, + tickHz: intFlag(args, "tick-hz", 60), + }; + const base = await Bun.file(join(SMOKE_DIR, "pocket.json")).json(); + const manifest = smokeManifest(base, rig); + const plan = resolveEspIdfBuildPlan(manifest, board); + const inputs = extractHostBuildInputs(plan); + const out = resolvePath(outdir); + mkdirSync(out, { recursive: true }); + await Bun.write(join(out, "pocket.resolved.json"), JSON.stringify(manifest, null, 2) + "\n"); + await Bun.write(join(out, "plan.json"), JSON.stringify(plan, null, 2) + "\n"); + await Bun.write(join(out, "network-policy.json"), inputs.network.policyJson + "\n"); + await Bun.write(join(out, "host-inputs.h"), hostInputsHeader(inputs, rig)); + if (!args.includes("--no-bundle")) { + const build = Bun.spawnSync( + ["bun", join(ROOT, "tools/build.ts"), `--plan=${join(out, "plan.json")}`, `--project-root=${SMOKE_DIR}`, `--outdir=${out}`, `--hz=${rig.tickHz}`], + { cwd: ROOT, stdout: "inherit", stderr: "inherit" }, + ); + if (build.exitCode !== 0) throw new Error(`pocket esp-idf: bundle build failed (${build.exitCode})`); + } + console.log(`pocket esp-idf: ${board} plan ${plan.planHash.slice(0, 23)}… → ${out}`); +} + +if (import.meta.main) { + const [command, ...rest] = process.argv.slice(2); + try { + if (command === "smoke-inputs") await smokeInputs(rest); + else { + console.error("usage: bun tools/esp-idf.ts smoke-inputs --board=atoms3r|tab5 --outdir= [--mac-host=H ...]"); + process.exit(2); + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/tools/net-peer.ts b/tools/net-peer.ts new file mode 100644 index 00000000..9406871d --- /dev/null +++ b/tools/net-peer.ts @@ -0,0 +1,111 @@ +// tools/net-peer.ts — the independent HTTP + WebSocket peer for the network +// hardware smoke (hosts/esp-idf/examples/net-smoke). Runs on the workstation +// with Bun; the boards reach it over the LAN. It is deliberately a different +// implementation from the PocketJS core, so the smoke tests the wire against +// an independent peer instead of the same code on both ends. +// +// bun tools/net-peer.ts [--http=8790] [--ws=8791] [--host=0.0.0.0] +// +// Routes: /hello /echo (POST) /json /stream (chunked) /redirect (302 → /hello) +// /big?bytes=N /slow?ms=N /status ; anything else → 404. +// WebSocket: /echo echoes text and binary, negotiates "smoke.v1". + +const args = new Map(process.argv.slice(2).map((a) => { + const [k, v] = a.replace(/^--/, "").split("="); + return [k, v ?? "true"] as const; +})); +const httpPort = Number(args.get("http") ?? 8790); +const wsPort = Number(args.get("ws") ?? 8791); +const host = args.get("host") ?? "0.0.0.0"; + +let requests = 0; +const log = (line: string): void => console.log(`[${new Date().toISOString().slice(11, 19)}] ${line}`); + +const http = Bun.serve({ + hostname: host, + port: httpPort, + async fetch(request, server) { + requests++; + const url = new URL(request.url); + const remote = server.requestIP(request); + log(`${remote?.address ?? "?"} ${request.method} ${url.pathname}${url.search}`); + switch (url.pathname) { + case "/hello": + return new Response(`hello from net-peer #${requests}\n`, { headers: { "content-type": "text/plain" } }); + case "/echo": { + const body = new Uint8Array(await request.arrayBuffer()); + return new Response(body, { + headers: { + "content-type": request.headers.get("content-type") ?? "application/octet-stream", + "x-echo-bytes": String(body.byteLength), + }, + }); + } + case "/json": + return Response.json({ peer: "net-peer", requests, now: Date.now() }); + case "/stream": { + const stream = new ReadableStream({ + async start(controller) { + for (let i = 0; i < 5; i++) { + controller.enqueue(new TextEncoder().encode(`chunk-${i};`)); + await Bun.sleep(30); + } + controller.close(); + }, + }); + return new Response(stream, { headers: { "content-type": "text/plain" } }); + } + case "/redirect": + return new Response(null, { status: 302, headers: { location: "/hello" } }); + case "/big": { + const bytes = Math.min(4 * 1024 * 1024, Math.max(1, Number(url.searchParams.get("bytes") ?? 100000))); + const body = new Uint8Array(bytes); + for (let i = 0; i < bytes; i++) body[i] = 97 + ((i / 1000) | 0) % 26; + return new Response(body, { headers: { "content-type": "application/octet-stream" } }); + } + case "/slow": { + const ms = Math.min(60000, Number(url.searchParams.get("ms") ?? 2000)); + await Bun.sleep(ms); + return new Response("slow\n"); + } + case "/status": + return Response.json({ requests, uptimeMs: Math.round(performance.now()) }); + default: + return new Response("not found\n", { status: 404 }); + } + }, +}); + +const ws = Bun.serve({ + hostname: host, + port: wsPort, + fetch(request, server) { + const protocols = (request.headers.get("sec-websocket-protocol") ?? "").split(",").map((s) => s.trim()).filter(Boolean); + const selected = protocols.includes("smoke.v1") ? "smoke.v1" : undefined; + const upgraded = server.upgrade(request, { + headers: selected ? { "sec-websocket-protocol": selected } : {}, + data: { remote: server.requestIP(request)?.address ?? "?" }, + }); + if (upgraded) return undefined as unknown as Response; + return new Response("websocket only\n", { status: 426 }); + }, + websocket: { + open(socket) { + log(`ws open from ${(socket.data as { remote: string }).remote}`); + }, + message(socket, message) { + if (typeof message === "string") { + log(`ws text ${JSON.stringify(message).slice(0, 60)}`); + socket.send(message); + } else { + log(`ws binary ${message.byteLength} bytes`); + socket.send(message); + } + }, + close(_socket, code, reason) { + log(`ws close ${code} ${reason}`); + }, + }, +}); + +log(`net-peer http://${host}:${http.port} ws://${host}:${ws.port}`); diff --git a/tools/test.ts b/tools/test.ts index bbdb6ee5..d9f8d1bd 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -66,7 +66,13 @@ const SUITE: readonly Stage[] = [ "tests/db.test.ts", "tests/fs.test.ts", "tests/net.test.ts", + "tests/net-httpd.test.ts", + "tests/net-websocket.test.ts", "tests/net-web.test.js", + "tests/network-policy.test.ts", + "tests/net-policy-hosts.test.ts", + "tests/http-semantics.test.ts", + "tests/esp-idf-profile.test.ts", "tests/vita-package.test.ts", "tests/psp-toolchain.test.ts", "tests/symbian-data.test.ts",