diff --git a/contracts/schema/pocket-3.json b/contracts/schema/pocket-3.json new file mode 100644 index 00000000..736c02f5 --- /dev/null +++ b/contracts/schema/pocket-3.json @@ -0,0 +1,599 @@ +{ + "$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 + }, + "requiresOneOf": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "options" + ], + "properties": { + "options": { + "type": "array", + "minItems": 1, + "items": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$" + } + } + } + } + } + }, + "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" + ] + }, + "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, + "required": [ + "connect", + "listen", + "localNetwork", + "insecureTransport", + "broadcast", + "multicast", + "allowInvalidTlsForDevelopment", + "browserAmbientCredentials", + "browserOpaqueWebSocketRedirects", + "credentials" + ], + "properties": { + "connect": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", + "host", + "port" + ], + "properties": { + "protocol": { + "enum": [ + "http", + "https", + "ws", + "wss", + "mqtt", + "mqtts", + "tcp", + "tcp-tls", + "udp" + ] + }, + "host": { + "type": "string", + "minLength": 1, + "maxLength": 253 + }, + "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", + "tcp", + "tcp-tls", + "udp" + ] + }, + "address": { + "type": "string", + "minLength": 1, + "maxLength": 45 + }, + "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 + } + } + }, + { + "enum": [ + "ephemeral" + ] + } + ] + } + } + } + }, + "localNetwork": { + "type": "boolean" + }, + "insecureTransport": { + "type": "boolean" + }, + "broadcast": { + "type": "boolean" + }, + "multicast": { + "type": "boolean" + }, + "allowInvalidTlsForDevelopment": { + "type": "boolean" + }, + "browserAmbientCredentials": { + "type": "boolean" + }, + "browserOpaqueWebSocketRedirects": { + "type": "boolean" + }, + "credentials": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + } + } + } + } + } + }, + "resources": { + "type": "object", + "additionalProperties": false, + "properties": { + "network": { + "type": "object", + "additionalProperties": false, + "required": [ + "minimum" + ], + "properties": { + "minimum": { + "type": "object", + "additionalProperties": false, + "properties": { + "runtime": { + "type": "object", + "additionalProperties": false, + "properties": { + "connections": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "pendingOperations": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "completionDescriptors": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "nativeBufferBytes": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + } + }, + "stream": { + "type": "object", + "additionalProperties": false, + "properties": { + "receiveQueueBytes": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "sendQueueBytes": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + } + }, + "http": { + "type": "object", + "additionalProperties": false, + "properties": { + "connections": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "inflightRequests": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "headerBytes": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "headerCount": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "bufferedBodyBytes": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + } + }, + "websocket": { + "type": "object", + "additionalProperties": false, + "properties": { + "connections": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "messageBytes": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "queuedMessages": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + } + }, + "mqtt": { + "type": "object", + "additionalProperties": false, + "properties": { + "connections": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "packetBytes": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "qos1Inflight": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "receiveQueueBytes": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + } + }, + "udp": { + "type": "object", + "additionalProperties": false, + "properties": { + "sockets": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "datagramBytes": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "receiveDatagrams": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + } + } + } + } + } + } + } + } + } +} diff --git a/contracts/spec/network/README.md b/contracts/spec/network/README.md new file mode 100644 index 00000000..7f32de34 --- /dev/null +++ b/contracts/spec/network/README.md @@ -0,0 +1,95 @@ +# PocketJS private network ABI v1 + +本目录固定 `pocketjs:internal/network-v1` 的 Guest ↔ native 边界。它不是公共 package export,也不授予网络权限。**native Protocol Core 对每条 command 继续执行已验证的 Build Plan、ResolvedNetworkPolicy 和资源上限。** + +## 生成边界 + +[`definition.ts`](./definition.ts) 是 numeric ABI 的单一数据源。它生成: + +- [`generated/network-v1.ts`](./generated/network-v1.ts):Guest Binding 使用的 TypeScript 常量; +- [`generated/pocketjs_network_v1_abi.h`](./generated/pocketjs_network_v1_abi.h):native adapter 使用的 C 常量和固定宽度 identity/turn 结构。 + +command opcode、event code、feature id 和错误码在同一个 ABI major 内只允许追加,不能重排、删除或复用。增加 identifier 时同时增加 ABI minor。ABI major 必须精确相等;native minor 可以高于 Guest 要求的 minor,但不能向旧 Guest 投递它不认识的 code。 + +```sh +bun contracts/spec/network/generate.ts +bun contracts/spec/network/generate.ts --check +bun test contracts/spec/network/network-v1.test.ts +``` + +测试会从 `definition.ts` 在内存中重新生成两个产物并逐字节比较,同时用 C11 `-Wall -Wextra -Werror` 编译 native header。 + +## Mount handshake + +bundle factory 捕获冻结的 `NetworkV1BindingTable` 后,在任何应用 initializer、callback 或应用可触发的 microtask 之前执行 handshake。handshake 固定包含: + +- ABI major/minor; +- 非零 `runtimeGeneration`; +- 已验证 ResolvedBuildPlan `planHash` 的 32-byte SHA-256 digest; +- Build Plan 中值为 `true` 的 `network.*` feature 的严格递增 numeric id 列表。 + +**plan hash、runtime generation 和 feature id 列表必须精确一致。** feature 列表不能包含 native descriptor 能实现但 Build Plan 没有授予的能力。未知、重复或乱序 feature id 都会在应用入口执行前拒绝 mount。 + +v1.0 的 handshake 和六个 binding method 必须保留为 table 自身的冻结 data property;v1.1 追加同样为 own data property 的 `getLimits()`。新 Guest 要求 native minor 至少为 1,更高 ABI minor 可以继续追加 property,但不能替换或删除已有 property。table、handshake、limits snapshot 与它们的嵌套数组不能使用 accessor 在验证前后返回不同值。 + +当前 stock target registry 仍不声明网络 capability;本 ABI 的存在不会打开 `network.http.client` 或 TLS。 + +## ABI 1.1 limits snapshot + +`getLimits({ runtimeGeneration, protocol, role })` 是 owner thread 上的同步只读查询。protocol/role 的 `0` 表示 build-wide 维度;其他值使用生成文件中的固定 numeric id。它不执行运行期协商,也不能增加 Build Plan 权限。 + +Host 返回冻结且 accessor-free 的 snapshot: + +- runtime generation 与 protocol/role 必须精确回显 query; +- `values` 最多 64 项,按 bounded dotted ASCII name 严格排序且不重复; +- 每项满足 `0 <= minimum <= default <= hard <= Number.MAX_SAFE_INTEGER`,其中零 minimum 表示 manifest 没有抬高该项的 admitted floor; +- `featureIds` 是 mount handshake 对当前 protocol/role 的精确有序子集,不能加入 descriptor 支持但 Build Plan 未授予的 feature; +- Guest Binding 在返回公共 `NetworkLimits` 前复制并深度冻结 snapshot,应用不能修改 Host table 或用查询扩大单次 operation limit。 + +## Identity 与顺序 + +每条 command 和 completion 都携带: + +- `runtimeGeneration`; +- resource、operation 和 body 的 `(id, generation)` handle; +- 当前 runtime generation 内单调递增、范围为 `1..Number.MAX_SAFE_INTEGER` 的 sequence。 + +`(0, 0)` 是 absent handle;一个字段为零而另一个非零是 ABI 错误。generation 或 runtime 不匹配的 completion 只能执行 native cleanup,不能更新 Guest 对象或完成 Promise。completion sequence 必须严格递增。 + +generation 达到 `UINT32_MAX`、sequence 达到 `Number.MAX_SAFE_INTEGER` 后不允许回绕;需要复用槽或继续分配时以 `resource_limit`/runtime teardown 失败关闭。 + +## Command、completion 与 body credit + +command 通过 owner thread 上的同步 `dispatch()` 进入 native adapter。`HttpRequestStart` 的 URL、method、header、timeout、TLS 和 limit metadata 已由 Guest Binding 规范化;这些 metadata string/list 与显式 borrowed buffer 都只在该同步调用期间有效,adapter 必须在返回前完成校验和必要的 native snapshot。native Core 仍重新执行权限、feature 和 hard-limit 检查。 + +v1 固定以下双向 body 信号: + +| 信号 | Guest → Core | Core → Guest | +|---|---|---| +| `BODY_PULL(maxBytes)` | 为 response body 授予一个 chunk credit | 请求 Guest request-body producer 产出一个 chunk | +| `BODY_CHUNK` | 同步借用 Guest input,adapter 返回前复制到 native lease | 携带可领取的 native BufferLease descriptor | +| `BODY_END` | request body 正常结束 | response body 正常结束 | +| `BODY_ERROR` | Guest producer 失败 | native producer/协议失败 | +| `BODY_CANCEL` | Guest 不再读取 response body | Core 不再读取 request body | + +**每个 body 同时最多存在一个 credit。** `BODY_CHUNK.byteLength` 必须为正且不超过对应 credit;`END`、`ERROR` 和 `CANCEL` 都进入同一个 terminal 状态,此后不能再接收 body 信号。 + +## BufferLease + +native completion 只携带 BufferLease `(id, generation)` 与 byte length,不把 payload 放进 completion descriptor。owner thread 使用以下同步顺序: + +1. `BUFFER_LEASE_TAKE` 把 lease 从 `Queued` 转为 `Taken`; +2. 一个或多个 `BUFFER_LEASE_READ_INTO` 把 lease 区间复制到调用方提供的精确 `Uint8Array` window; +3. `BUFFER_LEASE_RELEASE` 把 lease 从 `Taken` 转为 `Released`。 + +stale generation、取消或 teardown 可以在 Guest take 前执行 `Queued → Released` cleanup。其他 transition,包括重复 take、重复 release 和 take 后 native cleanup,都是 ABI 错误。**borrowed input/output 只在同步 adapter 调用期间有效,不能被 worker、Core 或 completion 保存。** + +## NetworkServiceTurn + +mount 通过 `registerServiceDispatcher()` 注册一次私有 dispatcher。Host 只在同一个 QuickJS owner thread 上调用它,并为每次调用提供非零 `turnId`、runtime generation、turn kind、event budget 和 payload-byte budget。 + +dispatcher 只能在该调用内使用 `nextCompletion({ runtimeGeneration, maxPayloadBytes })` 投递已经由 Core 按 sequence 排序的 completion。每次调用传入本 turn 剩余 byte credit;native 返回 `Item`、`Drained` 或 `BudgetExhausted`。下一个 completion 的完整 payload 放不进剩余 credit 时,native 不 dequeue 它并返回 `BudgetExhausted`;零 credit 可以作为不消费 queue 的 ready probe。 + +dispatcher 结果报告已投递 event 数、已选择 event 所引用的 payload 总 byte length、最后 sequence,以及 `Drained` 或 `MoreReady`。payload byte 在选择 `BODY_CHUNK` completion 时按整个 lease length 计入预算,不依赖应用随后是否或何时 `readInto`。计数不能超过请求预算;没有投递 event 时 bytes 和 last sequence 都必须为零。`MoreReady` 要求 Host 保持 network-ready 并安排后续 NetworkServiceTurn,不等待 UI frame。 + +completion delivery 之后的 Promise/job/reactive quiescence 与 non-presenting commit 仍由 Host/framework logical-turn 实现负责;dispatcher 不推进 UI virtual time,也不 present。 diff --git a/contracts/spec/network/definition.ts b/contracts/spec/network/definition.ts new file mode 100644 index 00000000..4fba391a --- /dev/null +++ b/contracts/spec/network/definition.ts @@ -0,0 +1,265 @@ +/** + * Single source of truth for the PocketJS private network ABI v1. + * + * Numeric identifiers are append-only within an ABI major. Never renumber, + * remove, or reuse an entry. New entries require an ABI minor increment. + * Run `bun contracts/spec/network/generate.ts` after changing this file. + */ + +export interface NetworkV1NumericEntry { + readonly name: string; + readonly cName: string; + readonly value: number; + readonly description: string; +} + +export interface NetworkV1FeatureEntry extends NetworkV1NumericEntry { + readonly capability: string; +} + +export interface NetworkV1NamedEntry extends NetworkV1NumericEntry { + readonly wireName: string; +} + +export const NETWORK_V1_DEFINITION = { + abi: { + major: 1, + minor: 1, + planHashBytes: 32, + sequenceMax: Number.MAX_SAFE_INTEGER, + limitEntryMax: 64, + limitNameMaxBytes: 64, + }, + + /** + * The exact native/Browser network projection of ResolvedBuildPlan.features. + * Feature ids are sparse so protocol and role additions do not renumber an + * already shipped feature. + */ + features: [ + { name: "HttpClient", cName: "HTTP_CLIENT", value: 0x0100, capability: "network.http.client", description: "HTTP client role" }, + { name: "HttpClientTls", cName: "HTTP_CLIENT_TLS", value: 0x0101, capability: "network.http.client.tls", description: "TLS for the HTTP client role" }, + { name: "HttpClientH2", cName: "HTTP_CLIENT_H2", value: 0x0110, capability: "network.http.client.h2", description: "HTTP/2 client extension" }, + { name: "HttpClientH3", cName: "HTTP_CLIENT_H3", value: 0x0111, capability: "network.http.client.h3", description: "HTTP/3 client extension" }, + { name: "HttpClientCompression", cName: "HTTP_CLIENT_COMPRESSION", value: 0x0112, capability: "network.http.client.compression", description: "HTTP client content decoding" }, + { name: "HttpClientTlsCustomCa", cName: "HTTP_CLIENT_TLS_CUSTOM_CA", value: 0x0118, capability: "network.http.client.tls.custom-ca", description: "HTTP client custom CA extension" }, + { name: "HttpClientTlsClientAuth", cName: "HTTP_CLIENT_TLS_CLIENT_AUTH", value: 0x0119, capability: "network.http.client.tls.client-auth", description: "HTTP client certificate authentication" }, + { name: "HttpClientTlsAlpn", cName: "HTTP_CLIENT_TLS_ALPN", value: 0x011a, capability: "network.http.client.tls.alpn", description: "HTTP client custom ALPN" }, + { name: "HttpClientTlsV13", cName: "HTTP_CLIENT_TLS_V1_3", value: 0x011b, capability: "network.http.client.tls.v1-3", description: "Required HTTP client TLS 1.3" }, + { name: "HttpClientTlsRevocation", cName: "HTTP_CLIENT_TLS_REVOCATION", value: 0x011c, capability: "network.http.client.tls.revocation", description: "Required HTTP client certificate revocation" }, + + { name: "HttpServer", cName: "HTTP_SERVER", value: 0x0120, capability: "network.http.server", description: "HTTP server role" }, + { name: "HttpServerTls", cName: "HTTP_SERVER_TLS", value: 0x0121, capability: "network.http.server.tls", description: "TLS for the HTTP server role" }, + { name: "HttpServerH2", cName: "HTTP_SERVER_H2", value: 0x0130, capability: "network.http.server.h2", description: "HTTP/2 server extension" }, + { name: "HttpServerH3", cName: "HTTP_SERVER_H3", value: 0x0131, capability: "network.http.server.h3", description: "HTTP/3 server extension" }, + { name: "HttpServerCompression", cName: "HTTP_SERVER_COMPRESSION", value: 0x0132, capability: "network.http.server.compression", description: "HTTP server content encoding" }, + { name: "HttpServerTlsCustomCa", cName: "HTTP_SERVER_TLS_CUSTOM_CA", value: 0x0138, capability: "network.http.server.tls.custom-ca", description: "HTTP server custom CA extension" }, + { name: "HttpServerTlsClientAuth", cName: "HTTP_SERVER_TLS_CLIENT_AUTH", value: 0x0139, capability: "network.http.server.tls.client-auth", description: "HTTP server client-certificate authentication" }, + { name: "HttpServerTlsAlpn", cName: "HTTP_SERVER_TLS_ALPN", value: 0x013a, capability: "network.http.server.tls.alpn", description: "HTTP server custom ALPN" }, + { name: "HttpServerTlsV13", cName: "HTTP_SERVER_TLS_V1_3", value: 0x013b, capability: "network.http.server.tls.v1-3", description: "Required HTTP server TLS 1.3" }, + { name: "HttpServerTlsRevocation", cName: "HTTP_SERVER_TLS_REVOCATION", value: 0x013c, capability: "network.http.server.tls.revocation", description: "Required HTTP server certificate revocation" }, + + { name: "WebSocketClient", cName: "WEBSOCKET_CLIENT", value: 0x0200, capability: "network.websocket.client", description: "WebSocket client role" }, + { name: "WebSocketClientTls", cName: "WEBSOCKET_CLIENT_TLS", value: 0x0201, capability: "network.websocket.client.tls", description: "TLS for the WebSocket client role" }, + { name: "WebSocketClientCompression", cName: "WEBSOCKET_CLIENT_COMPRESSION", value: 0x0210, capability: "network.websocket.client.compression", description: "WebSocket client compression" }, + { name: "WebSocketClientTlsCustomCa", cName: "WEBSOCKET_CLIENT_TLS_CUSTOM_CA", value: 0x0218, capability: "network.websocket.client.tls.custom-ca", description: "WebSocket client custom CA extension" }, + { name: "WebSocketClientTlsClientAuth", cName: "WEBSOCKET_CLIENT_TLS_CLIENT_AUTH", value: 0x0219, capability: "network.websocket.client.tls.client-auth", description: "WebSocket client certificate authentication" }, + { name: "WebSocketClientTlsAlpn", cName: "WEBSOCKET_CLIENT_TLS_ALPN", value: 0x021a, capability: "network.websocket.client.tls.alpn", description: "WebSocket client custom ALPN" }, + { name: "WebSocketClientTlsV13", cName: "WEBSOCKET_CLIENT_TLS_V1_3", value: 0x021b, capability: "network.websocket.client.tls.v1-3", description: "Required WebSocket client TLS 1.3" }, + { name: "WebSocketClientTlsRevocation", cName: "WEBSOCKET_CLIENT_TLS_REVOCATION", value: 0x021c, capability: "network.websocket.client.tls.revocation", description: "Required WebSocket client certificate revocation" }, + + { name: "WebSocketServer", cName: "WEBSOCKET_SERVER", value: 0x0220, capability: "network.websocket.server", description: "WebSocket server role" }, + { name: "WebSocketServerTls", cName: "WEBSOCKET_SERVER_TLS", value: 0x0221, capability: "network.websocket.server.tls", description: "TLS for the WebSocket server role" }, + { name: "WebSocketServerUpgrade", cName: "WEBSOCKET_SERVER_UPGRADE", value: 0x0222, capability: "network.websocket.server.upgrade", description: "HTTP-to-WebSocket server handoff" }, + { name: "WebSocketServerCompression", cName: "WEBSOCKET_SERVER_COMPRESSION", value: 0x0230, capability: "network.websocket.server.compression", description: "WebSocket server compression" }, + { name: "WebSocketServerTlsCustomCa", cName: "WEBSOCKET_SERVER_TLS_CUSTOM_CA", value: 0x0238, capability: "network.websocket.server.tls.custom-ca", description: "WebSocket server custom CA extension" }, + { name: "WebSocketServerTlsClientAuth", cName: "WEBSOCKET_SERVER_TLS_CLIENT_AUTH", value: 0x0239, capability: "network.websocket.server.tls.client-auth", description: "WebSocket server client-certificate authentication" }, + { name: "WebSocketServerTlsAlpn", cName: "WEBSOCKET_SERVER_TLS_ALPN", value: 0x023a, capability: "network.websocket.server.tls.alpn", description: "WebSocket server custom ALPN" }, + { name: "WebSocketServerTlsV13", cName: "WEBSOCKET_SERVER_TLS_V1_3", value: 0x023b, capability: "network.websocket.server.tls.v1-3", description: "Required WebSocket server TLS 1.3" }, + { name: "WebSocketServerTlsRevocation", cName: "WEBSOCKET_SERVER_TLS_REVOCATION", value: 0x023c, capability: "network.websocket.server.tls.revocation", description: "Required WebSocket server certificate revocation" }, + + { name: "MqttClient", cName: "MQTT_CLIENT", value: 0x0300, capability: "network.mqtt.client", description: "MQTT client role" }, + { name: "MqttClientTls", cName: "MQTT_CLIENT_TLS", value: 0x0301, capability: "network.mqtt.client.tls", description: "TLS for the MQTT client role" }, + { name: "MqttClientV5", cName: "MQTT_CLIENT_V5", value: 0x0310, capability: "network.mqtt.client.v5", description: "MQTT 5 extension" }, + { name: "MqttClientQos2", cName: "MQTT_CLIENT_QOS2", value: 0x0311, capability: "network.mqtt.client.qos2", description: "MQTT QoS 2 extension" }, + { name: "MqttClientTlsCustomCa", cName: "MQTT_CLIENT_TLS_CUSTOM_CA", value: 0x0318, capability: "network.mqtt.client.tls.custom-ca", description: "MQTT client custom CA extension" }, + { name: "MqttClientTlsClientAuth", cName: "MQTT_CLIENT_TLS_CLIENT_AUTH", value: 0x0319, capability: "network.mqtt.client.tls.client-auth", description: "MQTT client certificate authentication" }, + { name: "MqttClientTlsAlpn", cName: "MQTT_CLIENT_TLS_ALPN", value: 0x031a, capability: "network.mqtt.client.tls.alpn", description: "MQTT client custom ALPN" }, + { name: "MqttClientTlsV13", cName: "MQTT_CLIENT_TLS_V1_3", value: 0x031b, capability: "network.mqtt.client.tls.v1-3", description: "Required MQTT client TLS 1.3" }, + { name: "MqttClientTlsRevocation", cName: "MQTT_CLIENT_TLS_REVOCATION", value: 0x031c, capability: "network.mqtt.client.tls.revocation", description: "Required MQTT client certificate revocation" }, + + { name: "TcpClient", cName: "TCP_CLIENT", value: 0x0400, capability: "network.tcp.client", description: "TCP client role" }, + { name: "TcpClientTls", cName: "TCP_CLIENT_TLS", value: 0x0401, capability: "network.tcp.client.tls", description: "TLS for the TCP client role" }, + { name: "TcpClientIpv6", cName: "TCP_CLIENT_IPV6", value: 0x0410, capability: "network.tcp.client.ipv6", description: "TCP client IPv6 extension" }, + { name: "TcpClientSocketOptions", cName: "TCP_CLIENT_SOCKET_OPTIONS", value: 0x0411, capability: "network.tcp.client.socket-options", description: "TCP client socket options" }, + { name: "TcpClientTlsCustomCa", cName: "TCP_CLIENT_TLS_CUSTOM_CA", value: 0x0418, capability: "network.tcp.client.tls.custom-ca", description: "TCP client custom CA extension" }, + { name: "TcpClientTlsClientAuth", cName: "TCP_CLIENT_TLS_CLIENT_AUTH", value: 0x0419, capability: "network.tcp.client.tls.client-auth", description: "TCP client certificate authentication" }, + { name: "TcpClientTlsAlpn", cName: "TCP_CLIENT_TLS_ALPN", value: 0x041a, capability: "network.tcp.client.tls.alpn", description: "TCP client custom ALPN" }, + { name: "TcpClientTlsV13", cName: "TCP_CLIENT_TLS_V1_3", value: 0x041b, capability: "network.tcp.client.tls.v1-3", description: "Required TCP client TLS 1.3" }, + { name: "TcpClientTlsRevocation", cName: "TCP_CLIENT_TLS_REVOCATION", value: 0x041c, capability: "network.tcp.client.tls.revocation", description: "Required TCP client certificate revocation" }, + + { name: "TcpServer", cName: "TCP_SERVER", value: 0x0420, capability: "network.tcp.server", description: "TCP server role" }, + { name: "TcpServerTls", cName: "TCP_SERVER_TLS", value: 0x0421, capability: "network.tcp.server.tls", description: "TLS for the TCP server role" }, + { name: "TcpServerIpv6", cName: "TCP_SERVER_IPV6", value: 0x0430, capability: "network.tcp.server.ipv6", description: "TCP server IPv6 extension" }, + { name: "TcpServerSocketOptions", cName: "TCP_SERVER_SOCKET_OPTIONS", value: 0x0431, capability: "network.tcp.server.socket-options", description: "TCP server socket options" }, + { name: "TcpServerTlsCustomCa", cName: "TCP_SERVER_TLS_CUSTOM_CA", value: 0x0438, capability: "network.tcp.server.tls.custom-ca", description: "TCP server custom CA extension" }, + { name: "TcpServerTlsClientAuth", cName: "TCP_SERVER_TLS_CLIENT_AUTH", value: 0x0439, capability: "network.tcp.server.tls.client-auth", description: "TCP server client-certificate authentication" }, + { name: "TcpServerTlsAlpn", cName: "TCP_SERVER_TLS_ALPN", value: 0x043a, capability: "network.tcp.server.tls.alpn", description: "TCP server custom ALPN" }, + { name: "TcpServerTlsV13", cName: "TCP_SERVER_TLS_V1_3", value: 0x043b, capability: "network.tcp.server.tls.v1-3", description: "Required TCP server TLS 1.3" }, + { name: "TcpServerTlsRevocation", cName: "TCP_SERVER_TLS_REVOCATION", value: 0x043c, capability: "network.tcp.server.tls.revocation", description: "Required TCP server certificate revocation" }, + + { name: "Udp", cName: "UDP", value: 0x0500, capability: "network.udp", description: "UDP socket role" }, + { name: "UdpIpv6", cName: "UDP_IPV6", value: 0x0510, capability: "network.udp.ipv6", description: "UDP IPv6 extension" }, + { name: "UdpBroadcast", cName: "UDP_BROADCAST", value: 0x0511, capability: "network.udp.broadcast", description: "UDP broadcast extension" }, + { name: "UdpMulticast", cName: "UDP_MULTICAST", value: 0x0512, capability: "network.udp.multicast", description: "UDP multicast extension" }, + + { name: "BrowserHttpClient", cName: "BROWSER_HTTP_CLIENT", value: 0x7000, capability: "network.browser.http.client", description: "Browser HTTP adapter role" }, + { name: "BrowserWebSocketClient", cName: "BROWSER_WEBSOCKET_CLIENT", value: 0x7010, capability: "network.browser.websocket.client", description: "Browser WebSocket adapter role" }, + ] satisfies readonly NetworkV1FeatureEntry[], + + commands: [ + { name: "OperationCancel", cName: "OPERATION_CANCEL", value: 0x0001, description: "Compete for an operation's single terminal claim" }, + { name: "BufferLeaseTake", cName: "BUFFER_LEASE_TAKE", value: 0x0002, description: "Synchronously claim a delivered BufferLease" }, + { name: "BufferLeaseReadInto", cName: "BUFFER_LEASE_READ_INTO", value: 0x0003, description: "Synchronously copy from a taken lease into borrowed output" }, + { name: "BufferLeaseRelease", cName: "BUFFER_LEASE_RELEASE", value: 0x0004, description: "Release a taken BufferLease exactly once" }, + { name: "BodyPull", cName: "BODY_PULL", value: 0x0010, description: "Grant one bounded body-chunk credit" }, + { name: "BodyChunk", cName: "BODY_CHUNK", value: 0x0011, description: "Satisfy body credit with one non-empty chunk" }, + { name: "BodyEnd", cName: "BODY_END", value: 0x0012, description: "End a body normally" }, + { name: "BodyError", cName: "BODY_ERROR", value: 0x0013, description: "End a body with a normalized error" }, + { name: "BodyCancel", cName: "BODY_CANCEL", value: 0x0014, description: "Cancel the opposite body producer or consumer" }, + { name: "HttpRequestStart", cName: "HTTP_REQUEST_START", value: 0x0100, description: "Start an admitted HTTP client exchange" }, + ] satisfies readonly NetworkV1NumericEntry[], + + events: [ + { name: "BodyPull", cName: "BODY_PULL", value: 0x0010, description: "Request one bounded chunk from a Guest body producer" }, + { name: "BodyChunk", cName: "BODY_CHUNK", value: 0x0011, description: "Publish one native BufferLease-backed body chunk" }, + { name: "BodyEnd", cName: "BODY_END", value: 0x0012, description: "Report normal body end" }, + { name: "BodyError", cName: "BODY_ERROR", value: 0x0013, description: "Report normalized body failure" }, + { name: "BodyCancel", cName: "BODY_CANCEL", value: 0x0014, description: "Report cancellation of the opposite body endpoint" }, + { name: "HttpResponseHeaders", cName: "HTTP_RESPONSE_HEADERS", value: 0x0100, description: "Publish validated HTTP response headers" }, + { name: "HttpRequestError", cName: "HTTP_REQUEST_ERROR", value: 0x0101, description: "Publish the HTTP operation's terminal error" }, + ] satisfies readonly NetworkV1NumericEntry[], + + errorCategories: [ + { name: "Runtime", cName: "RUNTIME", value: 1, wireName: "runtime", description: "Runtime, lifecycle, admission, or capacity failure" }, + { name: "Resolver", cName: "RESOLVER", value: 2, wireName: "resolver", description: "Name resolution failure" }, + { name: "Transport", cName: "TRANSPORT", value: 3, wireName: "transport", description: "Plain transport failure" }, + { name: "Tls", cName: "TLS", value: 4, wireName: "tls", description: "TLS policy or handshake failure" }, + { name: "Protocol", cName: "PROTOCOL", value: 5, wireName: "protocol", description: "Protocol framing or semantic failure" }, + ] satisfies readonly NetworkV1NamedEntry[], + + errors: [ + { name: "Aborted", cName: "ABORTED", value: 0x0100, wireName: "aborted", description: "The application aborted the operation" }, + { name: "TimedOut", cName: "TIMED_OUT", value: 0x0101, wireName: "timed_out", description: "A Core-owned monotonic deadline won" }, + { name: "Closed", cName: "CLOSED", value: 0x0102, wireName: "closed", description: "The owning resource closed" }, + { name: "InvalidState", cName: "INVALID_STATE", value: 0x0103, wireName: "invalid_state", description: "The operation is invalid in the current state" }, + { name: "Busy", cName: "BUSY", value: 0x0104, wireName: "busy", description: "A bounded in-flight slot is already occupied" }, + { name: "ResourceLimit", cName: "RESOURCE_LIMIT", value: 0x0105, wireName: "resource_limit", description: "An admitted hard resource limit was reached" }, + { name: "Unsupported", cName: "UNSUPPORTED", value: 0x0106, wireName: "unsupported", description: "The Build Plan does not contain the requested feature" }, + { name: "PermissionDenied", cName: "PERMISSION_DENIED", value: 0x0107, wireName: "permission_denied", description: "ResolvedNetworkPolicy rejected the operation" }, + { name: "DnsNotFound", cName: "DNS_NOT_FOUND", value: 0x0200, wireName: "dns_not_found", description: "The hostname does not exist" }, + { name: "DnsTemporaryFailure", cName: "DNS_TEMPORARY_FAILURE", value: 0x0201, wireName: "dns_temporary_failure", description: "Name resolution failed temporarily" }, + { name: "DnsRefused", cName: "DNS_REFUSED", value: 0x0202, wireName: "dns_refused", description: "The resolver refused the query" }, + { name: "ConnectionRefused", cName: "CONNECTION_REFUSED", value: 0x0300, wireName: "connection_refused", description: "The peer refused the connection" }, + { name: "ConnectionReset", cName: "CONNECTION_RESET", value: 0x0301, wireName: "connection_reset", description: "The peer reset the connection" }, + { name: "NetworkUnreachable", cName: "NETWORK_UNREACHABLE", value: 0x0302, wireName: "network_unreachable", description: "No route is available" }, + { name: "AddressInUse", cName: "ADDRESS_IN_USE", value: 0x0303, wireName: "address_in_use", description: "The local address is already in use" }, + { name: "BrokenPipe", cName: "BROKEN_PIPE", value: 0x0304, wireName: "broken_pipe", description: "The write side is no longer usable" }, + { name: "TlsCertificateInvalid", cName: "TLS_CERTIFICATE_INVALID", value: 0x0400, wireName: "tls_certificate_invalid", description: "Certificate chain, usage, or time validation failed" }, + { name: "TlsHostnameMismatch", cName: "TLS_HOSTNAME_MISMATCH", value: 0x0401, wireName: "tls_hostname_mismatch", description: "The certificate does not match the authorized hostname" }, + { name: "TlsHandshakeFailed", cName: "TLS_HANDSHAKE_FAILED", value: 0x0402, wireName: "tls_handshake_failed", description: "The TLS handshake failed" }, + { name: "TlsVersionUnsupported", cName: "TLS_VERSION_UNSUPPORTED", value: 0x0403, wireName: "tls_version_unsupported", description: "The requested TLS version cannot be negotiated" }, + { name: "TlsAlert", cName: "TLS_ALERT", value: 0x0404, wireName: "tls_alert", description: "The peer sent a TLS alert" }, + { name: "HttpProtocolError", cName: "HTTP_PROTOCOL_ERROR", value: 0x0500, wireName: "http_protocol_error", description: "HTTP framing or semantic validation failed" }, + { name: "WebSocketProtocolError", cName: "WEBSOCKET_PROTOCOL_ERROR", value: 0x0501, wireName: "websocket_protocol_error", description: "WebSocket framing or semantic validation failed" }, + { name: "MqttUnacceptableProtocolVersion", cName: "MQTT_UNACCEPTABLE_PROTOCOL_VERSION", value: 0x0510, wireName: "mqtt_unacceptable_protocol_version", description: "The broker rejected the MQTT protocol version" }, + { name: "MqttIdentifierRejected", cName: "MQTT_IDENTIFIER_REJECTED", value: 0x0511, wireName: "mqtt_identifier_rejected", description: "The broker rejected the client identifier" }, + { name: "MqttServerUnavailable", cName: "MQTT_SERVER_UNAVAILABLE", value: 0x0512, wireName: "mqtt_server_unavailable", description: "The MQTT server is unavailable" }, + { name: "MqttBadCredentials", cName: "MQTT_BAD_CREDENTIALS", value: 0x0513, wireName: "mqtt_bad_credentials", description: "The broker rejected supplied credentials" }, + { name: "MqttNotAuthorized", cName: "MQTT_NOT_AUTHORIZED", value: 0x0514, wireName: "mqtt_not_authorized", description: "The MQTT action is not authorized" }, + { name: "MqttProtocolError", cName: "MQTT_PROTOCOL_ERROR", value: 0x0515, wireName: "mqtt_protocol_error", description: "MQTT framing or semantic validation failed" }, + { name: "MessageTooLarge", cName: "MESSAGE_TOO_LARGE", value: 0x0520, wireName: "message_too_large", description: "A protocol message exceeded its admitted bound" }, + { name: "SystemError", cName: "SYSTEM_ERROR", value: 0x7fff, wireName: "system_error", description: "A redacted platform failure has no more specific mapping" }, + ] satisfies readonly NetworkV1NamedEntry[], + + dispatchStatuses: [ + { name: "Accepted", cName: "ACCEPTED", value: 1, description: "The command was accepted and any borrowed input was copied" }, + { name: "Completed", cName: "COMPLETED", value: 2, description: "The synchronous control command completed" }, + { name: "Refused", cName: "REFUSED", value: 3, description: "The command was refused before asynchronous work began" }, + ] satisfies readonly NetworkV1NumericEntry[], + + completionPollStatuses: [ + { name: "Item", cName: "ITEM", value: 1, description: "One completion was removed within the remaining byte budget" }, + { name: "Drained", cName: "DRAINED", value: 2, description: "No immediately deliverable completion remains" }, + { name: "BudgetExhausted", cName: "BUDGET_EXHAUSTED", value: 3, description: "The next completion remains queued because its payload exceeds the remaining budget" }, + ] satisfies readonly NetworkV1NumericEntry[], + + borrowedInputKinds: [ + { name: "CustomCa", cName: "CUSTOM_CA", value: 1, description: "Custom CA bytes attached to a start command" }, + { name: "BodyChunk", cName: "BODY_CHUNK", value: 2, description: "Body bytes attached to BODY_CHUNK" }, + ] satisfies readonly NetworkV1NumericEntry[], + + limitProtocols: [ + { name: "Http", cName: "HTTP", value: 1, description: "HTTP limits and features" }, + { name: "WebSocket", cName: "WEBSOCKET", value: 2, description: "WebSocket limits and features" }, + { name: "Mqtt", cName: "MQTT", value: 3, description: "MQTT limits and features" }, + { name: "Tcp", cName: "TCP", value: 4, description: "TCP limits and features" }, + { name: "Udp", cName: "UDP", value: 5, description: "UDP limits and features" }, + ] satisfies readonly NetworkV1NumericEntry[], + + limitRoles: [ + { name: "Client", cName: "CLIENT", value: 1, description: "Client-role limits and features" }, + { name: "Server", cName: "SERVER", value: 2, description: "Server-role limits and features" }, + ] satisfies readonly NetworkV1NumericEntry[], + + httpRedirectModes: [ + { name: "Follow", cName: "FOLLOW", value: 1, description: "Follow redirects inside the HTTP Core" }, + { name: "Manual", cName: "MANUAL", value: 2, description: "Publish the redirect response without following" }, + { name: "Error", cName: "ERROR", value: 3, description: "Fail when a redirect response is received" }, + ] satisfies readonly NetworkV1NumericEntry[], + + tlsVersions: [ + { name: "V12", cName: "V1_2", value: 0x0102, description: "TLS 1.2" }, + { name: "V13", cName: "V1_3", value: 0x0103, description: "TLS 1.3" }, + ] satisfies readonly NetworkV1NumericEntry[], + + tlsVerifications: [ + { name: "Full", cName: "FULL", value: 1, description: "Verify trust, time, usage, and hostname" }, + { name: "DevelopmentInsecure", cName: "DEVELOPMENT_INSECURE", value: 2, description: "Development-only invalid-certificate mode" }, + ] satisfies readonly NetworkV1NumericEntry[], + + tlsRevocations: [ + { name: "HostDefault", cName: "HOST_DEFAULT", value: 1, description: "Use the selected TLS provider's revocation policy" }, + { name: "Required", cName: "REQUIRED", value: 2, description: "Require the admitted revocation feature" }, + ] satisfies readonly NetworkV1NumericEntry[], + + clientCertificateModes: [ + { name: "None", cName: "NONE", value: 1, description: "Do not request a client certificate" }, + { name: "Optional", cName: "OPTIONAL", value: 2, description: "Accept a client certificate when supplied" }, + { name: "Required", cName: "REQUIRED", value: 3, description: "Require an admitted client certificate" }, + ] satisfies readonly NetworkV1NumericEntry[], + + serviceTurnKinds: [ + { name: "Network", cName: "NETWORK", value: 1, description: "Deliver an ordinary NetworkServiceTurn" }, + { name: "Shutdown", cName: "SHUTDOWN", value: 2, description: "Deliver bounded network cancellation in ShutdownTurn" }, + ] satisfies readonly NetworkV1NumericEntry[], + + serviceTurnStatuses: [ + { name: "Drained", cName: "DRAINED", value: 1, description: "No immediately deliverable network work remains" }, + { name: "MoreReady", cName: "MORE_READY", value: 2, description: "Work remains and the Host must keep service ready" }, + ] satisfies readonly NetworkV1NumericEntry[], + + leaseStates: [ + { name: "Queued", cName: "QUEUED", value: 1, description: "The Core owns a lease referenced by a completion" }, + { name: "Taken", cName: "TAKEN", value: 2, description: "The owner-thread Guest Binding claimed the lease" }, + { name: "Released", cName: "RELEASED", value: 3, description: "The lease returned to the native pool" }, + ] satisfies readonly NetworkV1NumericEntry[], + + leaseActions: [ + { name: "Take", cName: "TAKE", value: 1, description: "Claim a queued lease" }, + { name: "Release", cName: "RELEASE", value: 2, description: "Release a taken lease" }, + { name: "Cleanup", cName: "CLEANUP", value: 3, description: "Native stale/cancel teardown releases an untaken lease" }, + ] satisfies readonly NetworkV1NumericEntry[], +} as const; + +export type NetworkV1Definition = typeof NETWORK_V1_DEFINITION; diff --git a/contracts/spec/network/generate.ts b/contracts/spec/network/generate.ts new file mode 100644 index 00000000..a61acbd3 --- /dev/null +++ b/contracts/spec/network/generate.ts @@ -0,0 +1,583 @@ +// Deterministic code generation for the private PocketJS network ABI. +// +// Source: contracts/spec/network/definition.ts +// Outputs: +// generated/network-v1.ts +// generated/pocketjs_network_v1_abi.h +// +// Run `bun contracts/spec/network/generate.ts`. CI/tests regenerate both +// outputs in memory and compare every byte. + +import { mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + NETWORK_V1_DEFINITION, + type NetworkV1FeatureEntry, + type NetworkV1NamedEntry, + type NetworkV1NumericEntry, +} from "./definition.ts"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const GENERATED_DIRECTORY = join(HERE, "generated"); +export const NETWORK_V1_TYPESCRIPT_PATH = join(GENERATED_DIRECTORY, "network-v1.ts"); +export const NETWORK_V1_HEADER_PATH = join( + GENERATED_DIRECTORY, + "pocketjs_network_v1_abi.h", +); + +function assertUint16(value: number, label: string): void { + if (!Number.isInteger(value) || value <= 0 || value > 0xffff) { + throw new TypeError(`${label} must be a non-zero uint16`); + } +} + +function validateEntries( + label: string, + entries: readonly NetworkV1NumericEntry[], +): void { + const names = new Set(); + const cNames = new Set(); + const values = new Set(); + let previous = 0; + for (const entry of entries) { + assertUint16(entry.value, `${label}.${entry.name}`); + if (!/^[A-Z][A-Z0-9_]*$/.test(entry.cName)) { + throw new TypeError(`${label}.${entry.name} has an invalid C identifier`); + } + if (!/^[A-Z][A-Za-z0-9]*$/.test(entry.name)) { + throw new TypeError(`${label}.${entry.name} has an invalid TypeScript identifier`); + } + if (entry.description.length === 0) { + throw new TypeError(`${label}.${entry.name} needs a description`); + } + if (names.has(entry.name) || cNames.has(entry.cName) || values.has(entry.value)) { + throw new TypeError(`${label}.${entry.name} duplicates an ABI identifier`); + } + if (entry.value <= previous) { + throw new TypeError(`${label}.${entry.name} is not append-only numeric order`); + } + names.add(entry.name); + cNames.add(entry.cName); + values.add(entry.value); + previous = entry.value; + } +} + +function validateNamedEntries( + label: string, + entries: readonly NetworkV1NamedEntry[], +): void { + validateEntries(label, entries); + const names = new Set(); + for (const entry of entries) { + if (!/^[a-z][a-z0-9_]*(?:-[a-z0-9]+)*$/.test(entry.wireName)) { + throw new TypeError(`${label}.${entry.name} has an invalid wire name`); + } + if (names.has(entry.wireName)) { + throw new TypeError(`${label}.${entry.name} duplicates a wire name`); + } + names.add(entry.wireName); + } +} + +export function validateNetworkV1Definition(): void { + const definition = NETWORK_V1_DEFINITION; + assertUint16(definition.abi.major, "abi.major"); + if (!Number.isInteger(definition.abi.minor) || definition.abi.minor < 0 || + definition.abi.minor > 0xffff) { + throw new TypeError("abi.minor must be a uint16"); + } + if (definition.abi.planHashBytes !== 32) { + throw new TypeError("ABI v1 plan hash must remain SHA-256 (32 bytes)"); + } + if (definition.abi.sequenceMax !== Number.MAX_SAFE_INTEGER) { + throw new TypeError("ABI v1 sequence bound must remain Number.MAX_SAFE_INTEGER"); + } + assertUint16(definition.abi.limitEntryMax, "abi.limitEntryMax"); + assertUint16(definition.abi.limitNameMaxBytes, "abi.limitNameMaxBytes"); + + validateEntries("features", definition.features); + validateEntries("commands", definition.commands); + validateEntries("events", definition.events); + validateNamedEntries("errorCategories", definition.errorCategories); + validateNamedEntries("errors", definition.errors); + validateEntries("dispatchStatuses", definition.dispatchStatuses); + validateEntries("completionPollStatuses", definition.completionPollStatuses); + validateEntries("borrowedInputKinds", definition.borrowedInputKinds); + validateEntries("limitProtocols", definition.limitProtocols); + validateEntries("limitRoles", definition.limitRoles); + validateEntries("httpRedirectModes", definition.httpRedirectModes); + validateEntries("tlsVersions", definition.tlsVersions); + validateEntries("tlsVerifications", definition.tlsVerifications); + validateEntries("tlsRevocations", definition.tlsRevocations); + validateEntries("clientCertificateModes", definition.clientCertificateModes); + validateEntries("serviceTurnKinds", definition.serviceTurnKinds); + validateEntries("serviceTurnStatuses", definition.serviceTurnStatuses); + validateEntries("leaseStates", definition.leaseStates); + validateEntries("leaseActions", definition.leaseActions); + + const capabilities = new Set(); + for (const feature of definition.features) { + if (!/^network\.[a-z0-9][a-z0-9.-]*$/.test(feature.capability)) { + throw new TypeError(`features.${feature.name} has an invalid capability`); + } + if (capabilities.has(feature.capability)) { + throw new TypeError(`features.${feature.name} duplicates a capability`); + } + capabilities.add(feature.capability); + } + + const commandByName = new Map(definition.commands.map((entry) => [entry.name, entry.value])); + const eventByName = new Map(definition.events.map((entry) => [entry.name, entry.value])); + for (const name of ["BodyPull", "BodyChunk", "BodyEnd", "BodyError", "BodyCancel"]) { + if (commandByName.get(name) !== eventByName.get(name)) { + throw new TypeError(`${name} must use the same numeric code in both directions`); + } + } +} + +function hex(value: number): string { + return `0x${value.toString(16).padStart(4, "0")}`; +} + +function generatedObject( + exportName: string, + entries: readonly NetworkV1NumericEntry[], +): string[] { + const lines = [`export const ${exportName} = Object.freeze({`]; + for (const entry of entries) { + lines.push(` /** ${entry.description}. */`); + lines.push(` ${entry.name}: ${hex(entry.value)},`); + } + lines.push("} as const);"); + lines.push(`export type ${exportName} =`); + lines.push(` (typeof ${exportName})[keyof typeof ${exportName}];`); + return lines; +} + +function generatedValues( + exportName: string, + objectName: string, + entries: readonly NetworkV1NumericEntry[], +): string[] { + return [ + `export const ${exportName} = Object.freeze([`, + ...entries.map((entry) => ` ${objectName}.${entry.name},`), + `] as const);`, + ]; +} + +function generatedNameMaps( + prefix: string, + objectName: string, + entries: readonly NetworkV1NamedEntry[], +): string[] { + const lines = [ + `export const ${prefix}_NAME_BY_ID = Object.freeze({`, + ...entries.map((entry) => ` [${objectName}.${entry.name}]: ${JSON.stringify(entry.wireName)},`), + `} as const);`, + `export const ${prefix}_ID_BY_NAME = Object.freeze({`, + ...entries.map((entry) => ` ${JSON.stringify(entry.wireName)}: ${objectName}.${entry.name},`), + `} as const);`, + ]; + return lines; +} + +export function generateNetworkV1TypeScript(): string { + validateNetworkV1Definition(); + const definition = NETWORK_V1_DEFINITION; + const lines: string[] = [ + "// GENERATED — do not edit; run `bun contracts/spec/network/generate.ts`.", + "// Source of truth: contracts/spec/network/definition.ts.", + "", + `export const NETWORK_V1_ABI_MAJOR = ${definition.abi.major} as const;`, + `export const NETWORK_V1_ABI_MINOR = ${definition.abi.minor} as const;`, + `export const NETWORK_V1_PLAN_HASH_BYTES = ${definition.abi.planHashBytes} as const;`, + `export const NETWORK_V1_SEQUENCE_MAX = ${definition.abi.sequenceMax} as const;`, + `export const NETWORK_V1_LIMIT_ENTRY_MAX = ${definition.abi.limitEntryMax} as const;`, + `export const NETWORK_V1_LIMIT_NAME_MAX_BYTES = ${definition.abi.limitNameMaxBytes} as const;`, + "export const NETWORK_V1_UINT32_MAX = 0xffff_ffff as const;", + "export const NETWORK_V1_ABSENT_ID = 0 as const;", + "export const NETWORK_V1_LIMIT_PROTOCOL_ANY = 0 as const;", + "export const NETWORK_V1_LIMIT_ROLE_ANY = 0 as const;", + "", + ]; + + const collections: ReadonlyArray = [ + ["NetworkV1FeatureId", definition.features], + ["NetworkV1CommandOpcode", definition.commands], + ["NetworkV1EventCode", definition.events], + ["NetworkV1ErrorCategory", definition.errorCategories], + ["NetworkV1ErrorCode", definition.errors], + ["NetworkV1DispatchStatus", definition.dispatchStatuses], + ["NetworkV1CompletionPollStatus", definition.completionPollStatuses], + ["NetworkV1BorrowedInputKind", definition.borrowedInputKinds], + ["NetworkV1LimitProtocol", definition.limitProtocols], + ["NetworkV1LimitRole", definition.limitRoles], + ["NetworkV1HttpRedirectMode", definition.httpRedirectModes], + ["NetworkV1TlsVersion", definition.tlsVersions], + ["NetworkV1TlsVerification", definition.tlsVerifications], + ["NetworkV1TlsRevocation", definition.tlsRevocations], + ["NetworkV1ClientCertificateMode", definition.clientCertificateModes], + ["NetworkV1ServiceTurnKind", definition.serviceTurnKinds], + ["NetworkV1ServiceTurnStatus", definition.serviceTurnStatuses], + ["NetworkV1LeaseState", definition.leaseStates], + ["NetworkV1LeaseAction", definition.leaseActions], + ]; + for (const [name, entries] of collections) { + lines.push(...generatedObject(name, entries), ""); + } + + lines.push( + ...generatedValues("NETWORK_V1_FEATURE_IDS", "NetworkV1FeatureId", definition.features), + "", + ...generatedValues("NETWORK_V1_COMMAND_OPCODES", "NetworkV1CommandOpcode", definition.commands), + "", + ...generatedValues("NETWORK_V1_EVENT_CODES", "NetworkV1EventCode", definition.events), + "", + ...generatedValues("NETWORK_V1_ERROR_CODES", "NetworkV1ErrorCode", definition.errors), + "", + "export const NETWORK_V1_FEATURE_CAPABILITY_BY_ID = Object.freeze({", + ...definition.features.map((entry) => + ` [NetworkV1FeatureId.${entry.name}]: ${JSON.stringify(entry.capability)},` + ), + "} as const);", + "export const NETWORK_V1_FEATURE_ID_BY_CAPABILITY = Object.freeze({", + ...definition.features.map((entry) => + ` ${JSON.stringify(entry.capability)}: NetworkV1FeatureId.${entry.name},` + ), + "} as const);", + "", + ...generatedNameMaps( + "NETWORK_V1_ERROR_CATEGORY", + "NetworkV1ErrorCategory", + definition.errorCategories, + ), + "", + ...generatedNameMaps("NETWORK_V1_ERROR", "NetworkV1ErrorCode", definition.errors), + ); + + return `${lines.join("\n")}\n`; +} + +function cDefine( + group: string, + cType: string, + entries: readonly NetworkV1NumericEntry[], +): string[] { + const lines: string[] = []; + for (const entry of entries) { + lines.push(`/** ${entry.description}. */`); + lines.push( + `#define POCKETJS_NETWORK_V1_${group}_${entry.cName} ((pocketjs_network_v1_${cType}_t)UINT16_C(${entry.value}))`, + ); + } + return lines; +} + +export function generateNetworkV1Header(): string { + validateNetworkV1Definition(); + const definition = NETWORK_V1_DEFINITION; + const lines: string[] = [ + "/* GENERATED — do not edit; run `bun contracts/spec/network/generate.ts`.", + " * Source of truth: contracts/spec/network/definition.ts.", + " */", + "#ifndef POCKETJS_NETWORK_V1_ABI_H", + "#define POCKETJS_NETWORK_V1_ABI_H", + "", + "#include ", + "#include ", + "", + "#ifdef __cplusplus", + 'extern "C" {', + "#endif", + "", + `#define POCKETJS_NETWORK_V1_ABI_MAJOR UINT16_C(${definition.abi.major})`, + `#define POCKETJS_NETWORK_V1_ABI_MINOR UINT16_C(${definition.abi.minor})`, + `#define POCKETJS_NETWORK_V1_PLAN_HASH_BYTES UINT16_C(${definition.abi.planHashBytes})`, + `#define POCKETJS_NETWORK_V1_SEQUENCE_MAX UINT64_C(${definition.abi.sequenceMax})`, + `#define POCKETJS_NETWORK_V1_LIMIT_ENTRY_MAX UINT16_C(${definition.abi.limitEntryMax})`, + `#define POCKETJS_NETWORK_V1_LIMIT_NAME_MAX_BYTES UINT16_C(${definition.abi.limitNameMaxBytes})`, + "#define POCKETJS_NETWORK_V1_ABSENT_ID UINT32_C(0)", + "#define POCKETJS_NETWORK_V1_LIMIT_PROTOCOL_ANY UINT16_C(0)", + "#define POCKETJS_NETWORK_V1_LIMIT_ROLE_ANY UINT16_C(0)", + `#define POCKETJS_NETWORK_V1_FEATURE_COUNT UINT16_C(${definition.features.length})`, + "", + "typedef uint16_t pocketjs_network_v1_feature_id_t;", + "typedef uint16_t pocketjs_network_v1_command_opcode_t;", + "typedef uint16_t pocketjs_network_v1_event_code_t;", + "typedef uint16_t pocketjs_network_v1_error_category_t;", + "typedef uint16_t pocketjs_network_v1_error_code_t;", + "typedef uint16_t pocketjs_network_v1_dispatch_status_t;", + "typedef uint16_t pocketjs_network_v1_completion_poll_status_t;", + "typedef uint16_t pocketjs_network_v1_borrowed_input_kind_t;", + "typedef uint16_t pocketjs_network_v1_limit_protocol_t;", + "typedef uint16_t pocketjs_network_v1_limit_role_t;", + "typedef uint16_t pocketjs_network_v1_http_redirect_mode_t;", + "typedef uint16_t pocketjs_network_v1_tls_version_t;", + "typedef uint16_t pocketjs_network_v1_tls_verification_t;", + "typedef uint16_t pocketjs_network_v1_tls_revocation_t;", + "typedef uint16_t pocketjs_network_v1_client_certificate_mode_t;", + "typedef uint16_t pocketjs_network_v1_service_turn_kind_t;", + "typedef uint16_t pocketjs_network_v1_service_turn_status_t;", + "typedef uint16_t pocketjs_network_v1_lease_state_t;", + "typedef uint16_t pocketjs_network_v1_lease_action_t;", + "", + ...cDefine("FEATURE", "feature_id", definition.features), + "", + ...definition.features.map((entry) => + `#define POCKETJS_NETWORK_V1_FEATURE_${entry.cName}_CAPABILITY ${JSON.stringify(entry.capability)}` + ), + "", + ...cDefine("COMMAND", "command_opcode", definition.commands), + "", + ...cDefine("EVENT", "event_code", definition.events), + "", + ...cDefine("ERROR_CATEGORY", "error_category", definition.errorCategories), + "", + ...definition.errorCategories.map((entry) => + `#define POCKETJS_NETWORK_V1_ERROR_CATEGORY_${entry.cName}_NAME ${JSON.stringify(entry.wireName)}` + ), + "", + ...cDefine("ERROR", "error_code", definition.errors), + "", + ...definition.errors.map((entry) => + `#define POCKETJS_NETWORK_V1_ERROR_${entry.cName}_NAME ${JSON.stringify(entry.wireName)}` + ), + "", + ...cDefine("DISPATCH", "dispatch_status", definition.dispatchStatuses), + "", + ...cDefine( + "COMPLETION_POLL", + "completion_poll_status", + definition.completionPollStatuses, + ), + "", + ...cDefine("BORROWED_INPUT", "borrowed_input_kind", definition.borrowedInputKinds), + "", + ...cDefine("LIMIT_PROTOCOL", "limit_protocol", definition.limitProtocols), + "", + ...cDefine("LIMIT_ROLE", "limit_role", definition.limitRoles), + "", + ...cDefine("HTTP_REDIRECT", "http_redirect_mode", definition.httpRedirectModes), + "", + ...cDefine("TLS_VERSION", "tls_version", definition.tlsVersions), + "", + ...cDefine("TLS_VERIFICATION", "tls_verification", definition.tlsVerifications), + "", + ...cDefine("TLS_REVOCATION", "tls_revocation", definition.tlsRevocations), + "", + ...cDefine( + "CLIENT_CERTIFICATE", + "client_certificate_mode", + definition.clientCertificateModes, + ), + "", + ...cDefine("SERVICE_TURN_KIND", "service_turn_kind", definition.serviceTurnKinds), + "", + ...cDefine("SERVICE_TURN_STATUS", "service_turn_status", definition.serviceTurnStatuses), + "", + ...cDefine("LEASE_STATE", "lease_state", definition.leaseStates), + "", + ...cDefine("LEASE_ACTION", "lease_action", definition.leaseActions), + "", + "/** A zero/zero handle is absent. A live handle has non-zero id and generation. */", + "typedef struct pocketjs_network_v1_handle {", + " uint32_t id;", + " uint32_t generation;", + "} pocketjs_network_v1_handle_t;", + "", + "/** Identity carried by every command accepted by the native adapter. */", + "typedef struct pocketjs_network_v1_command_identity {", + " uint32_t runtime_generation;", + " pocketjs_network_v1_handle_t resource;", + " pocketjs_network_v1_handle_t operation;", + " pocketjs_network_v1_handle_t body;", + " uint64_t command_sequence;", + "} pocketjs_network_v1_command_identity_t;", + "", + "/** Identity carried by every Core-to-Guest completion. */", + "typedef struct pocketjs_network_v1_completion_identity {", + " uint32_t runtime_generation;", + " pocketjs_network_v1_handle_t resource;", + " pocketjs_network_v1_handle_t operation;", + " pocketjs_network_v1_handle_t body;", + " uint64_t sequence;", + "} pocketjs_network_v1_completion_identity_t;", + "", + "/** A completion advertises this descriptor; payload bytes remain native. */", + "typedef struct pocketjs_network_v1_lease_descriptor {", + " uint32_t runtime_generation;", + " pocketjs_network_v1_handle_t lease;", + " uint32_t byte_length;", + "} pocketjs_network_v1_lease_descriptor_t;", + "", + "/**", + " * Borrowed input is valid only during the synchronous adapter call. The", + " * adapter copies exactly this window to an owned BufferLease before it", + " * returns POCKETJS_NETWORK_V1_DISPATCH_ACCEPTED.", + " */", + "typedef struct pocketjs_network_v1_borrowed_input_view {", + " pocketjs_network_v1_borrowed_input_kind_t kind;", + " uint16_t reserved_zero;", + " const uint8_t *data;", + " uint32_t byte_length;", + "} pocketjs_network_v1_borrowed_input_view_t;", + "", + "/** Writable Guest memory borrowed only for BUFFER_LEASE_READ_INTO. */", + "typedef struct pocketjs_network_v1_borrowed_output_view {", + " uint8_t *data;", + " uint32_t byte_length;", + "} pocketjs_network_v1_borrowed_output_view_t;", + "", + "/** Refused carries non-zero category/code; Accepted/Completed carry zeros. */", + "typedef struct pocketjs_network_v1_dispatch_result {", + " pocketjs_network_v1_dispatch_status_t status;", + " pocketjs_network_v1_error_category_t error_category;", + " pocketjs_network_v1_error_code_t error_code;", + " uint16_t reserved_zero;", + "} pocketjs_network_v1_dispatch_result_t;", + "", + "/** Remaining byte credit passed to one completion dequeue attempt. */", + "typedef struct pocketjs_network_v1_completion_poll_request {", + " uint32_t runtime_generation;", + " uint32_t max_payload_bytes;", + "} pocketjs_network_v1_completion_poll_request_t;", + "", + "/** ITEM reports the selected completion's entire advertised payload size. */", + "typedef struct pocketjs_network_v1_completion_poll_result {", + " pocketjs_network_v1_completion_poll_status_t status;", + " uint16_t reserved_zero;", + " uint32_t payload_bytes_delivered;", + "} pocketjs_network_v1_completion_poll_result_t;", + "", + "/**", + " * Mount handshake view. feature_ids are strictly increasing and describe", + " * exactly the true network feature projection of the verified Build Plan.", + " * plan_hash is the 32-byte digest portion of the sha256: planHash.", + " */", + "typedef struct pocketjs_network_v1_handshake_view {", + " uint16_t abi_major;", + " uint16_t abi_minor;", + " uint32_t runtime_generation;", + " const pocketjs_network_v1_feature_id_t *feature_ids;", + " uint16_t feature_count;", + " uint16_t reserved_zero;", + " uint8_t plan_hash[POCKETJS_NETWORK_V1_PLAN_HASH_BYTES];", + "} pocketjs_network_v1_handshake_view_t;", + "", + "/** Zero protocol/role selects the build-wide dimension. */", + "typedef struct pocketjs_network_v1_limits_query {", + " uint32_t runtime_generation;", + " pocketjs_network_v1_limit_protocol_t protocol;", + " pocketjs_network_v1_limit_role_t role;", + "} pocketjs_network_v1_limits_query_t;", + "", + "/** One immutable effective limit entry returned by the Host. */", + "typedef struct pocketjs_network_v1_limit_entry_view {", + " const char *name;", + " uint16_t name_length;", + " uint16_t reserved_zero;", + " uint64_t default_value;", + " uint64_t hard_value;", + " uint64_t minimum_value;", + "} pocketjs_network_v1_limit_entry_view_t;", + "", + "/** Borrowed synchronous view for the ABI 1.1 getLimits method. */", + "typedef struct pocketjs_network_v1_limits_snapshot_view {", + " uint32_t runtime_generation;", + " pocketjs_network_v1_limit_protocol_t protocol;", + " pocketjs_network_v1_limit_role_t role;", + " const pocketjs_network_v1_limit_entry_view_t *values;", + " uint16_t value_count;", + " const pocketjs_network_v1_feature_id_t *feature_ids;", + " uint16_t feature_count;", + "} pocketjs_network_v1_limits_snapshot_view_t;", + "", + "/** Host-to-Guest budget for one registered service-dispatcher invocation. */", + "typedef struct pocketjs_network_v1_service_turn_request {", + " uint32_t runtime_generation;", + " uint64_t turn_id;", + " pocketjs_network_v1_service_turn_kind_t kind;", + " uint16_t reserved_zero;", + " uint32_t max_events;", + " uint32_t max_payload_bytes;", + "} pocketjs_network_v1_service_turn_request_t;", + "", + "/** Guest result; counts cannot exceed the request and sequence is monotonic. */", + "typedef struct pocketjs_network_v1_service_turn_result {", + " pocketjs_network_v1_service_turn_status_t status;", + " uint16_t reserved_zero;", + " uint32_t events_delivered;", + " uint32_t payload_bytes_delivered;", + " uint64_t last_sequence;", + "} pocketjs_network_v1_service_turn_result_t;", + "", + "static inline int pocketjs_network_v1_handle_is_absent(", + " pocketjs_network_v1_handle_t handle) {", + " return handle.id == POCKETJS_NETWORK_V1_ABSENT_ID &&", + " handle.generation == POCKETJS_NETWORK_V1_ABSENT_ID;", + "}", + "", + "static inline int pocketjs_network_v1_handle_is_live(", + " pocketjs_network_v1_handle_t handle) {", + " return handle.id != POCKETJS_NETWORK_V1_ABSENT_ID &&", + " handle.generation != POCKETJS_NETWORK_V1_ABSENT_ID;", + "}", + "", + "#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L", + '_Static_assert(sizeof(pocketjs_network_v1_handle_t) == 8, "network v1 handle layout");', + '_Static_assert(sizeof(uint64_t) == 8, "network v1 requires uint64_t");', + "#endif", + "", + "#ifdef __cplusplus", + "} /* extern \"C\" */", + "#endif", + "", + "#endif /* POCKETJS_NETWORK_V1_ABI_H */", + "", + ]; + return lines.join("\n"); +} + +export interface NetworkV1GeneratedFiles { + readonly typescript: string; + readonly header: string; +} + +export function generateNetworkV1Files(): NetworkV1GeneratedFiles { + return { + typescript: generateNetworkV1TypeScript(), + header: generateNetworkV1Header(), + }; +} + +async function writeOrCheck(check: boolean): Promise { + const generated = generateNetworkV1Files(); + if (check) { + const [typescript, header] = await Promise.all([ + Bun.file(NETWORK_V1_TYPESCRIPT_PATH).text().catch(() => null), + Bun.file(NETWORK_V1_HEADER_PATH).text().catch(() => null), + ]); + const failures: string[] = []; + if (typescript !== generated.typescript) failures.push(NETWORK_V1_TYPESCRIPT_PATH); + if (header !== generated.header) failures.push(NETWORK_V1_HEADER_PATH); + if (failures.length > 0) { + throw new Error(`generated network ABI drift: ${failures.join(", ")}`); + } + return; + } + + await mkdir(GENERATED_DIRECTORY, { recursive: true }); + await Promise.all([ + Bun.write(NETWORK_V1_TYPESCRIPT_PATH, generated.typescript), + Bun.write(NETWORK_V1_HEADER_PATH, generated.header), + ]); +} + +if (import.meta.main) { + const check = process.argv.slice(2).includes("--check"); + await writeOrCheck(check); + console.log(check ? "network ABI generated files are current" : "generated network ABI v1"); +} diff --git a/contracts/spec/network/generated/network-v1.ts b/contracts/spec/network/generated/network-v1.ts new file mode 100644 index 00000000..638f5122 --- /dev/null +++ b/contracts/spec/network/generated/network-v1.ts @@ -0,0 +1,785 @@ +// GENERATED — do not edit; run `bun contracts/spec/network/generate.ts`. +// Source of truth: contracts/spec/network/definition.ts. + +export const NETWORK_V1_ABI_MAJOR = 1 as const; +export const NETWORK_V1_ABI_MINOR = 1 as const; +export const NETWORK_V1_PLAN_HASH_BYTES = 32 as const; +export const NETWORK_V1_SEQUENCE_MAX = 9007199254740991 as const; +export const NETWORK_V1_LIMIT_ENTRY_MAX = 64 as const; +export const NETWORK_V1_LIMIT_NAME_MAX_BYTES = 64 as const; +export const NETWORK_V1_UINT32_MAX = 0xffff_ffff as const; +export const NETWORK_V1_ABSENT_ID = 0 as const; +export const NETWORK_V1_LIMIT_PROTOCOL_ANY = 0 as const; +export const NETWORK_V1_LIMIT_ROLE_ANY = 0 as const; + +export const NetworkV1FeatureId = Object.freeze({ + /** HTTP client role. */ + HttpClient: 0x0100, + /** TLS for the HTTP client role. */ + HttpClientTls: 0x0101, + /** HTTP/2 client extension. */ + HttpClientH2: 0x0110, + /** HTTP/3 client extension. */ + HttpClientH3: 0x0111, + /** HTTP client content decoding. */ + HttpClientCompression: 0x0112, + /** HTTP client custom CA extension. */ + HttpClientTlsCustomCa: 0x0118, + /** HTTP client certificate authentication. */ + HttpClientTlsClientAuth: 0x0119, + /** HTTP client custom ALPN. */ + HttpClientTlsAlpn: 0x011a, + /** Required HTTP client TLS 1.3. */ + HttpClientTlsV13: 0x011b, + /** Required HTTP client certificate revocation. */ + HttpClientTlsRevocation: 0x011c, + /** HTTP server role. */ + HttpServer: 0x0120, + /** TLS for the HTTP server role. */ + HttpServerTls: 0x0121, + /** HTTP/2 server extension. */ + HttpServerH2: 0x0130, + /** HTTP/3 server extension. */ + HttpServerH3: 0x0131, + /** HTTP server content encoding. */ + HttpServerCompression: 0x0132, + /** HTTP server custom CA extension. */ + HttpServerTlsCustomCa: 0x0138, + /** HTTP server client-certificate authentication. */ + HttpServerTlsClientAuth: 0x0139, + /** HTTP server custom ALPN. */ + HttpServerTlsAlpn: 0x013a, + /** Required HTTP server TLS 1.3. */ + HttpServerTlsV13: 0x013b, + /** Required HTTP server certificate revocation. */ + HttpServerTlsRevocation: 0x013c, + /** WebSocket client role. */ + WebSocketClient: 0x0200, + /** TLS for the WebSocket client role. */ + WebSocketClientTls: 0x0201, + /** WebSocket client compression. */ + WebSocketClientCompression: 0x0210, + /** WebSocket client custom CA extension. */ + WebSocketClientTlsCustomCa: 0x0218, + /** WebSocket client certificate authentication. */ + WebSocketClientTlsClientAuth: 0x0219, + /** WebSocket client custom ALPN. */ + WebSocketClientTlsAlpn: 0x021a, + /** Required WebSocket client TLS 1.3. */ + WebSocketClientTlsV13: 0x021b, + /** Required WebSocket client certificate revocation. */ + WebSocketClientTlsRevocation: 0x021c, + /** WebSocket server role. */ + WebSocketServer: 0x0220, + /** TLS for the WebSocket server role. */ + WebSocketServerTls: 0x0221, + /** HTTP-to-WebSocket server handoff. */ + WebSocketServerUpgrade: 0x0222, + /** WebSocket server compression. */ + WebSocketServerCompression: 0x0230, + /** WebSocket server custom CA extension. */ + WebSocketServerTlsCustomCa: 0x0238, + /** WebSocket server client-certificate authentication. */ + WebSocketServerTlsClientAuth: 0x0239, + /** WebSocket server custom ALPN. */ + WebSocketServerTlsAlpn: 0x023a, + /** Required WebSocket server TLS 1.3. */ + WebSocketServerTlsV13: 0x023b, + /** Required WebSocket server certificate revocation. */ + WebSocketServerTlsRevocation: 0x023c, + /** MQTT client role. */ + MqttClient: 0x0300, + /** TLS for the MQTT client role. */ + MqttClientTls: 0x0301, + /** MQTT 5 extension. */ + MqttClientV5: 0x0310, + /** MQTT QoS 2 extension. */ + MqttClientQos2: 0x0311, + /** MQTT client custom CA extension. */ + MqttClientTlsCustomCa: 0x0318, + /** MQTT client certificate authentication. */ + MqttClientTlsClientAuth: 0x0319, + /** MQTT client custom ALPN. */ + MqttClientTlsAlpn: 0x031a, + /** Required MQTT client TLS 1.3. */ + MqttClientTlsV13: 0x031b, + /** Required MQTT client certificate revocation. */ + MqttClientTlsRevocation: 0x031c, + /** TCP client role. */ + TcpClient: 0x0400, + /** TLS for the TCP client role. */ + TcpClientTls: 0x0401, + /** TCP client IPv6 extension. */ + TcpClientIpv6: 0x0410, + /** TCP client socket options. */ + TcpClientSocketOptions: 0x0411, + /** TCP client custom CA extension. */ + TcpClientTlsCustomCa: 0x0418, + /** TCP client certificate authentication. */ + TcpClientTlsClientAuth: 0x0419, + /** TCP client custom ALPN. */ + TcpClientTlsAlpn: 0x041a, + /** Required TCP client TLS 1.3. */ + TcpClientTlsV13: 0x041b, + /** Required TCP client certificate revocation. */ + TcpClientTlsRevocation: 0x041c, + /** TCP server role. */ + TcpServer: 0x0420, + /** TLS for the TCP server role. */ + TcpServerTls: 0x0421, + /** TCP server IPv6 extension. */ + TcpServerIpv6: 0x0430, + /** TCP server socket options. */ + TcpServerSocketOptions: 0x0431, + /** TCP server custom CA extension. */ + TcpServerTlsCustomCa: 0x0438, + /** TCP server client-certificate authentication. */ + TcpServerTlsClientAuth: 0x0439, + /** TCP server custom ALPN. */ + TcpServerTlsAlpn: 0x043a, + /** Required TCP server TLS 1.3. */ + TcpServerTlsV13: 0x043b, + /** Required TCP server certificate revocation. */ + TcpServerTlsRevocation: 0x043c, + /** UDP socket role. */ + Udp: 0x0500, + /** UDP IPv6 extension. */ + UdpIpv6: 0x0510, + /** UDP broadcast extension. */ + UdpBroadcast: 0x0511, + /** UDP multicast extension. */ + UdpMulticast: 0x0512, + /** Browser HTTP adapter role. */ + BrowserHttpClient: 0x7000, + /** Browser WebSocket adapter role. */ + BrowserWebSocketClient: 0x7010, +} as const); +export type NetworkV1FeatureId = + (typeof NetworkV1FeatureId)[keyof typeof NetworkV1FeatureId]; + +export const NetworkV1CommandOpcode = Object.freeze({ + /** Compete for an operation's single terminal claim. */ + OperationCancel: 0x0001, + /** Synchronously claim a delivered BufferLease. */ + BufferLeaseTake: 0x0002, + /** Synchronously copy from a taken lease into borrowed output. */ + BufferLeaseReadInto: 0x0003, + /** Release a taken BufferLease exactly once. */ + BufferLeaseRelease: 0x0004, + /** Grant one bounded body-chunk credit. */ + BodyPull: 0x0010, + /** Satisfy body credit with one non-empty chunk. */ + BodyChunk: 0x0011, + /** End a body normally. */ + BodyEnd: 0x0012, + /** End a body with a normalized error. */ + BodyError: 0x0013, + /** Cancel the opposite body producer or consumer. */ + BodyCancel: 0x0014, + /** Start an admitted HTTP client exchange. */ + HttpRequestStart: 0x0100, +} as const); +export type NetworkV1CommandOpcode = + (typeof NetworkV1CommandOpcode)[keyof typeof NetworkV1CommandOpcode]; + +export const NetworkV1EventCode = Object.freeze({ + /** Request one bounded chunk from a Guest body producer. */ + BodyPull: 0x0010, + /** Publish one native BufferLease-backed body chunk. */ + BodyChunk: 0x0011, + /** Report normal body end. */ + BodyEnd: 0x0012, + /** Report normalized body failure. */ + BodyError: 0x0013, + /** Report cancellation of the opposite body endpoint. */ + BodyCancel: 0x0014, + /** Publish validated HTTP response headers. */ + HttpResponseHeaders: 0x0100, + /** Publish the HTTP operation's terminal error. */ + HttpRequestError: 0x0101, +} as const); +export type NetworkV1EventCode = + (typeof NetworkV1EventCode)[keyof typeof NetworkV1EventCode]; + +export const NetworkV1ErrorCategory = Object.freeze({ + /** Runtime, lifecycle, admission, or capacity failure. */ + Runtime: 0x0001, + /** Name resolution failure. */ + Resolver: 0x0002, + /** Plain transport failure. */ + Transport: 0x0003, + /** TLS policy or handshake failure. */ + Tls: 0x0004, + /** Protocol framing or semantic failure. */ + Protocol: 0x0005, +} as const); +export type NetworkV1ErrorCategory = + (typeof NetworkV1ErrorCategory)[keyof typeof NetworkV1ErrorCategory]; + +export const NetworkV1ErrorCode = Object.freeze({ + /** The application aborted the operation. */ + Aborted: 0x0100, + /** A Core-owned monotonic deadline won. */ + TimedOut: 0x0101, + /** The owning resource closed. */ + Closed: 0x0102, + /** The operation is invalid in the current state. */ + InvalidState: 0x0103, + /** A bounded in-flight slot is already occupied. */ + Busy: 0x0104, + /** An admitted hard resource limit was reached. */ + ResourceLimit: 0x0105, + /** The Build Plan does not contain the requested feature. */ + Unsupported: 0x0106, + /** ResolvedNetworkPolicy rejected the operation. */ + PermissionDenied: 0x0107, + /** The hostname does not exist. */ + DnsNotFound: 0x0200, + /** Name resolution failed temporarily. */ + DnsTemporaryFailure: 0x0201, + /** The resolver refused the query. */ + DnsRefused: 0x0202, + /** The peer refused the connection. */ + ConnectionRefused: 0x0300, + /** The peer reset the connection. */ + ConnectionReset: 0x0301, + /** No route is available. */ + NetworkUnreachable: 0x0302, + /** The local address is already in use. */ + AddressInUse: 0x0303, + /** The write side is no longer usable. */ + BrokenPipe: 0x0304, + /** Certificate chain, usage, or time validation failed. */ + TlsCertificateInvalid: 0x0400, + /** The certificate does not match the authorized hostname. */ + TlsHostnameMismatch: 0x0401, + /** The TLS handshake failed. */ + TlsHandshakeFailed: 0x0402, + /** The requested TLS version cannot be negotiated. */ + TlsVersionUnsupported: 0x0403, + /** The peer sent a TLS alert. */ + TlsAlert: 0x0404, + /** HTTP framing or semantic validation failed. */ + HttpProtocolError: 0x0500, + /** WebSocket framing or semantic validation failed. */ + WebSocketProtocolError: 0x0501, + /** The broker rejected the MQTT protocol version. */ + MqttUnacceptableProtocolVersion: 0x0510, + /** The broker rejected the client identifier. */ + MqttIdentifierRejected: 0x0511, + /** The MQTT server is unavailable. */ + MqttServerUnavailable: 0x0512, + /** The broker rejected supplied credentials. */ + MqttBadCredentials: 0x0513, + /** The MQTT action is not authorized. */ + MqttNotAuthorized: 0x0514, + /** MQTT framing or semantic validation failed. */ + MqttProtocolError: 0x0515, + /** A protocol message exceeded its admitted bound. */ + MessageTooLarge: 0x0520, + /** A redacted platform failure has no more specific mapping. */ + SystemError: 0x7fff, +} as const); +export type NetworkV1ErrorCode = + (typeof NetworkV1ErrorCode)[keyof typeof NetworkV1ErrorCode]; + +export const NetworkV1DispatchStatus = Object.freeze({ + /** The command was accepted and any borrowed input was copied. */ + Accepted: 0x0001, + /** The synchronous control command completed. */ + Completed: 0x0002, + /** The command was refused before asynchronous work began. */ + Refused: 0x0003, +} as const); +export type NetworkV1DispatchStatus = + (typeof NetworkV1DispatchStatus)[keyof typeof NetworkV1DispatchStatus]; + +export const NetworkV1CompletionPollStatus = Object.freeze({ + /** One completion was removed within the remaining byte budget. */ + Item: 0x0001, + /** No immediately deliverable completion remains. */ + Drained: 0x0002, + /** The next completion remains queued because its payload exceeds the remaining budget. */ + BudgetExhausted: 0x0003, +} as const); +export type NetworkV1CompletionPollStatus = + (typeof NetworkV1CompletionPollStatus)[keyof typeof NetworkV1CompletionPollStatus]; + +export const NetworkV1BorrowedInputKind = Object.freeze({ + /** Custom CA bytes attached to a start command. */ + CustomCa: 0x0001, + /** Body bytes attached to BODY_CHUNK. */ + BodyChunk: 0x0002, +} as const); +export type NetworkV1BorrowedInputKind = + (typeof NetworkV1BorrowedInputKind)[keyof typeof NetworkV1BorrowedInputKind]; + +export const NetworkV1LimitProtocol = Object.freeze({ + /** HTTP limits and features. */ + Http: 0x0001, + /** WebSocket limits and features. */ + WebSocket: 0x0002, + /** MQTT limits and features. */ + Mqtt: 0x0003, + /** TCP limits and features. */ + Tcp: 0x0004, + /** UDP limits and features. */ + Udp: 0x0005, +} as const); +export type NetworkV1LimitProtocol = + (typeof NetworkV1LimitProtocol)[keyof typeof NetworkV1LimitProtocol]; + +export const NetworkV1LimitRole = Object.freeze({ + /** Client-role limits and features. */ + Client: 0x0001, + /** Server-role limits and features. */ + Server: 0x0002, +} as const); +export type NetworkV1LimitRole = + (typeof NetworkV1LimitRole)[keyof typeof NetworkV1LimitRole]; + +export const NetworkV1HttpRedirectMode = Object.freeze({ + /** Follow redirects inside the HTTP Core. */ + Follow: 0x0001, + /** Publish the redirect response without following. */ + Manual: 0x0002, + /** Fail when a redirect response is received. */ + Error: 0x0003, +} as const); +export type NetworkV1HttpRedirectMode = + (typeof NetworkV1HttpRedirectMode)[keyof typeof NetworkV1HttpRedirectMode]; + +export const NetworkV1TlsVersion = Object.freeze({ + /** TLS 1.2. */ + V12: 0x0102, + /** TLS 1.3. */ + V13: 0x0103, +} as const); +export type NetworkV1TlsVersion = + (typeof NetworkV1TlsVersion)[keyof typeof NetworkV1TlsVersion]; + +export const NetworkV1TlsVerification = Object.freeze({ + /** Verify trust, time, usage, and hostname. */ + Full: 0x0001, + /** Development-only invalid-certificate mode. */ + DevelopmentInsecure: 0x0002, +} as const); +export type NetworkV1TlsVerification = + (typeof NetworkV1TlsVerification)[keyof typeof NetworkV1TlsVerification]; + +export const NetworkV1TlsRevocation = Object.freeze({ + /** Use the selected TLS provider's revocation policy. */ + HostDefault: 0x0001, + /** Require the admitted revocation feature. */ + Required: 0x0002, +} as const); +export type NetworkV1TlsRevocation = + (typeof NetworkV1TlsRevocation)[keyof typeof NetworkV1TlsRevocation]; + +export const NetworkV1ClientCertificateMode = Object.freeze({ + /** Do not request a client certificate. */ + None: 0x0001, + /** Accept a client certificate when supplied. */ + Optional: 0x0002, + /** Require an admitted client certificate. */ + Required: 0x0003, +} as const); +export type NetworkV1ClientCertificateMode = + (typeof NetworkV1ClientCertificateMode)[keyof typeof NetworkV1ClientCertificateMode]; + +export const NetworkV1ServiceTurnKind = Object.freeze({ + /** Deliver an ordinary NetworkServiceTurn. */ + Network: 0x0001, + /** Deliver bounded network cancellation in ShutdownTurn. */ + Shutdown: 0x0002, +} as const); +export type NetworkV1ServiceTurnKind = + (typeof NetworkV1ServiceTurnKind)[keyof typeof NetworkV1ServiceTurnKind]; + +export const NetworkV1ServiceTurnStatus = Object.freeze({ + /** No immediately deliverable network work remains. */ + Drained: 0x0001, + /** Work remains and the Host must keep service ready. */ + MoreReady: 0x0002, +} as const); +export type NetworkV1ServiceTurnStatus = + (typeof NetworkV1ServiceTurnStatus)[keyof typeof NetworkV1ServiceTurnStatus]; + +export const NetworkV1LeaseState = Object.freeze({ + /** The Core owns a lease referenced by a completion. */ + Queued: 0x0001, + /** The owner-thread Guest Binding claimed the lease. */ + Taken: 0x0002, + /** The lease returned to the native pool. */ + Released: 0x0003, +} as const); +export type NetworkV1LeaseState = + (typeof NetworkV1LeaseState)[keyof typeof NetworkV1LeaseState]; + +export const NetworkV1LeaseAction = Object.freeze({ + /** Claim a queued lease. */ + Take: 0x0001, + /** Release a taken lease. */ + Release: 0x0002, + /** Native stale/cancel teardown releases an untaken lease. */ + Cleanup: 0x0003, +} as const); +export type NetworkV1LeaseAction = + (typeof NetworkV1LeaseAction)[keyof typeof NetworkV1LeaseAction]; + +export const NETWORK_V1_FEATURE_IDS = Object.freeze([ + NetworkV1FeatureId.HttpClient, + NetworkV1FeatureId.HttpClientTls, + NetworkV1FeatureId.HttpClientH2, + NetworkV1FeatureId.HttpClientH3, + NetworkV1FeatureId.HttpClientCompression, + NetworkV1FeatureId.HttpClientTlsCustomCa, + NetworkV1FeatureId.HttpClientTlsClientAuth, + NetworkV1FeatureId.HttpClientTlsAlpn, + NetworkV1FeatureId.HttpClientTlsV13, + NetworkV1FeatureId.HttpClientTlsRevocation, + NetworkV1FeatureId.HttpServer, + NetworkV1FeatureId.HttpServerTls, + NetworkV1FeatureId.HttpServerH2, + NetworkV1FeatureId.HttpServerH3, + NetworkV1FeatureId.HttpServerCompression, + NetworkV1FeatureId.HttpServerTlsCustomCa, + NetworkV1FeatureId.HttpServerTlsClientAuth, + NetworkV1FeatureId.HttpServerTlsAlpn, + NetworkV1FeatureId.HttpServerTlsV13, + NetworkV1FeatureId.HttpServerTlsRevocation, + NetworkV1FeatureId.WebSocketClient, + NetworkV1FeatureId.WebSocketClientTls, + NetworkV1FeatureId.WebSocketClientCompression, + NetworkV1FeatureId.WebSocketClientTlsCustomCa, + NetworkV1FeatureId.WebSocketClientTlsClientAuth, + NetworkV1FeatureId.WebSocketClientTlsAlpn, + NetworkV1FeatureId.WebSocketClientTlsV13, + NetworkV1FeatureId.WebSocketClientTlsRevocation, + NetworkV1FeatureId.WebSocketServer, + NetworkV1FeatureId.WebSocketServerTls, + NetworkV1FeatureId.WebSocketServerUpgrade, + NetworkV1FeatureId.WebSocketServerCompression, + NetworkV1FeatureId.WebSocketServerTlsCustomCa, + NetworkV1FeatureId.WebSocketServerTlsClientAuth, + NetworkV1FeatureId.WebSocketServerTlsAlpn, + NetworkV1FeatureId.WebSocketServerTlsV13, + NetworkV1FeatureId.WebSocketServerTlsRevocation, + NetworkV1FeatureId.MqttClient, + NetworkV1FeatureId.MqttClientTls, + NetworkV1FeatureId.MqttClientV5, + NetworkV1FeatureId.MqttClientQos2, + NetworkV1FeatureId.MqttClientTlsCustomCa, + NetworkV1FeatureId.MqttClientTlsClientAuth, + NetworkV1FeatureId.MqttClientTlsAlpn, + NetworkV1FeatureId.MqttClientTlsV13, + NetworkV1FeatureId.MqttClientTlsRevocation, + NetworkV1FeatureId.TcpClient, + NetworkV1FeatureId.TcpClientTls, + NetworkV1FeatureId.TcpClientIpv6, + NetworkV1FeatureId.TcpClientSocketOptions, + NetworkV1FeatureId.TcpClientTlsCustomCa, + NetworkV1FeatureId.TcpClientTlsClientAuth, + NetworkV1FeatureId.TcpClientTlsAlpn, + NetworkV1FeatureId.TcpClientTlsV13, + NetworkV1FeatureId.TcpClientTlsRevocation, + NetworkV1FeatureId.TcpServer, + NetworkV1FeatureId.TcpServerTls, + NetworkV1FeatureId.TcpServerIpv6, + NetworkV1FeatureId.TcpServerSocketOptions, + NetworkV1FeatureId.TcpServerTlsCustomCa, + NetworkV1FeatureId.TcpServerTlsClientAuth, + NetworkV1FeatureId.TcpServerTlsAlpn, + NetworkV1FeatureId.TcpServerTlsV13, + NetworkV1FeatureId.TcpServerTlsRevocation, + NetworkV1FeatureId.Udp, + NetworkV1FeatureId.UdpIpv6, + NetworkV1FeatureId.UdpBroadcast, + NetworkV1FeatureId.UdpMulticast, + NetworkV1FeatureId.BrowserHttpClient, + NetworkV1FeatureId.BrowserWebSocketClient, +] as const); + +export const NETWORK_V1_COMMAND_OPCODES = Object.freeze([ + NetworkV1CommandOpcode.OperationCancel, + NetworkV1CommandOpcode.BufferLeaseTake, + NetworkV1CommandOpcode.BufferLeaseReadInto, + NetworkV1CommandOpcode.BufferLeaseRelease, + NetworkV1CommandOpcode.BodyPull, + NetworkV1CommandOpcode.BodyChunk, + NetworkV1CommandOpcode.BodyEnd, + NetworkV1CommandOpcode.BodyError, + NetworkV1CommandOpcode.BodyCancel, + NetworkV1CommandOpcode.HttpRequestStart, +] as const); + +export const NETWORK_V1_EVENT_CODES = Object.freeze([ + NetworkV1EventCode.BodyPull, + NetworkV1EventCode.BodyChunk, + NetworkV1EventCode.BodyEnd, + NetworkV1EventCode.BodyError, + NetworkV1EventCode.BodyCancel, + NetworkV1EventCode.HttpResponseHeaders, + NetworkV1EventCode.HttpRequestError, +] as const); + +export const NETWORK_V1_ERROR_CODES = Object.freeze([ + NetworkV1ErrorCode.Aborted, + NetworkV1ErrorCode.TimedOut, + NetworkV1ErrorCode.Closed, + NetworkV1ErrorCode.InvalidState, + NetworkV1ErrorCode.Busy, + NetworkV1ErrorCode.ResourceLimit, + NetworkV1ErrorCode.Unsupported, + NetworkV1ErrorCode.PermissionDenied, + NetworkV1ErrorCode.DnsNotFound, + NetworkV1ErrorCode.DnsTemporaryFailure, + NetworkV1ErrorCode.DnsRefused, + NetworkV1ErrorCode.ConnectionRefused, + NetworkV1ErrorCode.ConnectionReset, + NetworkV1ErrorCode.NetworkUnreachable, + NetworkV1ErrorCode.AddressInUse, + NetworkV1ErrorCode.BrokenPipe, + NetworkV1ErrorCode.TlsCertificateInvalid, + NetworkV1ErrorCode.TlsHostnameMismatch, + NetworkV1ErrorCode.TlsHandshakeFailed, + NetworkV1ErrorCode.TlsVersionUnsupported, + NetworkV1ErrorCode.TlsAlert, + NetworkV1ErrorCode.HttpProtocolError, + NetworkV1ErrorCode.WebSocketProtocolError, + NetworkV1ErrorCode.MqttUnacceptableProtocolVersion, + NetworkV1ErrorCode.MqttIdentifierRejected, + NetworkV1ErrorCode.MqttServerUnavailable, + NetworkV1ErrorCode.MqttBadCredentials, + NetworkV1ErrorCode.MqttNotAuthorized, + NetworkV1ErrorCode.MqttProtocolError, + NetworkV1ErrorCode.MessageTooLarge, + NetworkV1ErrorCode.SystemError, +] as const); + +export const NETWORK_V1_FEATURE_CAPABILITY_BY_ID = Object.freeze({ + [NetworkV1FeatureId.HttpClient]: "network.http.client", + [NetworkV1FeatureId.HttpClientTls]: "network.http.client.tls", + [NetworkV1FeatureId.HttpClientH2]: "network.http.client.h2", + [NetworkV1FeatureId.HttpClientH3]: "network.http.client.h3", + [NetworkV1FeatureId.HttpClientCompression]: "network.http.client.compression", + [NetworkV1FeatureId.HttpClientTlsCustomCa]: "network.http.client.tls.custom-ca", + [NetworkV1FeatureId.HttpClientTlsClientAuth]: "network.http.client.tls.client-auth", + [NetworkV1FeatureId.HttpClientTlsAlpn]: "network.http.client.tls.alpn", + [NetworkV1FeatureId.HttpClientTlsV13]: "network.http.client.tls.v1-3", + [NetworkV1FeatureId.HttpClientTlsRevocation]: "network.http.client.tls.revocation", + [NetworkV1FeatureId.HttpServer]: "network.http.server", + [NetworkV1FeatureId.HttpServerTls]: "network.http.server.tls", + [NetworkV1FeatureId.HttpServerH2]: "network.http.server.h2", + [NetworkV1FeatureId.HttpServerH3]: "network.http.server.h3", + [NetworkV1FeatureId.HttpServerCompression]: "network.http.server.compression", + [NetworkV1FeatureId.HttpServerTlsCustomCa]: "network.http.server.tls.custom-ca", + [NetworkV1FeatureId.HttpServerTlsClientAuth]: "network.http.server.tls.client-auth", + [NetworkV1FeatureId.HttpServerTlsAlpn]: "network.http.server.tls.alpn", + [NetworkV1FeatureId.HttpServerTlsV13]: "network.http.server.tls.v1-3", + [NetworkV1FeatureId.HttpServerTlsRevocation]: "network.http.server.tls.revocation", + [NetworkV1FeatureId.WebSocketClient]: "network.websocket.client", + [NetworkV1FeatureId.WebSocketClientTls]: "network.websocket.client.tls", + [NetworkV1FeatureId.WebSocketClientCompression]: "network.websocket.client.compression", + [NetworkV1FeatureId.WebSocketClientTlsCustomCa]: "network.websocket.client.tls.custom-ca", + [NetworkV1FeatureId.WebSocketClientTlsClientAuth]: "network.websocket.client.tls.client-auth", + [NetworkV1FeatureId.WebSocketClientTlsAlpn]: "network.websocket.client.tls.alpn", + [NetworkV1FeatureId.WebSocketClientTlsV13]: "network.websocket.client.tls.v1-3", + [NetworkV1FeatureId.WebSocketClientTlsRevocation]: "network.websocket.client.tls.revocation", + [NetworkV1FeatureId.WebSocketServer]: "network.websocket.server", + [NetworkV1FeatureId.WebSocketServerTls]: "network.websocket.server.tls", + [NetworkV1FeatureId.WebSocketServerUpgrade]: "network.websocket.server.upgrade", + [NetworkV1FeatureId.WebSocketServerCompression]: "network.websocket.server.compression", + [NetworkV1FeatureId.WebSocketServerTlsCustomCa]: "network.websocket.server.tls.custom-ca", + [NetworkV1FeatureId.WebSocketServerTlsClientAuth]: "network.websocket.server.tls.client-auth", + [NetworkV1FeatureId.WebSocketServerTlsAlpn]: "network.websocket.server.tls.alpn", + [NetworkV1FeatureId.WebSocketServerTlsV13]: "network.websocket.server.tls.v1-3", + [NetworkV1FeatureId.WebSocketServerTlsRevocation]: "network.websocket.server.tls.revocation", + [NetworkV1FeatureId.MqttClient]: "network.mqtt.client", + [NetworkV1FeatureId.MqttClientTls]: "network.mqtt.client.tls", + [NetworkV1FeatureId.MqttClientV5]: "network.mqtt.client.v5", + [NetworkV1FeatureId.MqttClientQos2]: "network.mqtt.client.qos2", + [NetworkV1FeatureId.MqttClientTlsCustomCa]: "network.mqtt.client.tls.custom-ca", + [NetworkV1FeatureId.MqttClientTlsClientAuth]: "network.mqtt.client.tls.client-auth", + [NetworkV1FeatureId.MqttClientTlsAlpn]: "network.mqtt.client.tls.alpn", + [NetworkV1FeatureId.MqttClientTlsV13]: "network.mqtt.client.tls.v1-3", + [NetworkV1FeatureId.MqttClientTlsRevocation]: "network.mqtt.client.tls.revocation", + [NetworkV1FeatureId.TcpClient]: "network.tcp.client", + [NetworkV1FeatureId.TcpClientTls]: "network.tcp.client.tls", + [NetworkV1FeatureId.TcpClientIpv6]: "network.tcp.client.ipv6", + [NetworkV1FeatureId.TcpClientSocketOptions]: "network.tcp.client.socket-options", + [NetworkV1FeatureId.TcpClientTlsCustomCa]: "network.tcp.client.tls.custom-ca", + [NetworkV1FeatureId.TcpClientTlsClientAuth]: "network.tcp.client.tls.client-auth", + [NetworkV1FeatureId.TcpClientTlsAlpn]: "network.tcp.client.tls.alpn", + [NetworkV1FeatureId.TcpClientTlsV13]: "network.tcp.client.tls.v1-3", + [NetworkV1FeatureId.TcpClientTlsRevocation]: "network.tcp.client.tls.revocation", + [NetworkV1FeatureId.TcpServer]: "network.tcp.server", + [NetworkV1FeatureId.TcpServerTls]: "network.tcp.server.tls", + [NetworkV1FeatureId.TcpServerIpv6]: "network.tcp.server.ipv6", + [NetworkV1FeatureId.TcpServerSocketOptions]: "network.tcp.server.socket-options", + [NetworkV1FeatureId.TcpServerTlsCustomCa]: "network.tcp.server.tls.custom-ca", + [NetworkV1FeatureId.TcpServerTlsClientAuth]: "network.tcp.server.tls.client-auth", + [NetworkV1FeatureId.TcpServerTlsAlpn]: "network.tcp.server.tls.alpn", + [NetworkV1FeatureId.TcpServerTlsV13]: "network.tcp.server.tls.v1-3", + [NetworkV1FeatureId.TcpServerTlsRevocation]: "network.tcp.server.tls.revocation", + [NetworkV1FeatureId.Udp]: "network.udp", + [NetworkV1FeatureId.UdpIpv6]: "network.udp.ipv6", + [NetworkV1FeatureId.UdpBroadcast]: "network.udp.broadcast", + [NetworkV1FeatureId.UdpMulticast]: "network.udp.multicast", + [NetworkV1FeatureId.BrowserHttpClient]: "network.browser.http.client", + [NetworkV1FeatureId.BrowserWebSocketClient]: "network.browser.websocket.client", +} as const); +export const NETWORK_V1_FEATURE_ID_BY_CAPABILITY = Object.freeze({ + "network.http.client": NetworkV1FeatureId.HttpClient, + "network.http.client.tls": NetworkV1FeatureId.HttpClientTls, + "network.http.client.h2": NetworkV1FeatureId.HttpClientH2, + "network.http.client.h3": NetworkV1FeatureId.HttpClientH3, + "network.http.client.compression": NetworkV1FeatureId.HttpClientCompression, + "network.http.client.tls.custom-ca": NetworkV1FeatureId.HttpClientTlsCustomCa, + "network.http.client.tls.client-auth": NetworkV1FeatureId.HttpClientTlsClientAuth, + "network.http.client.tls.alpn": NetworkV1FeatureId.HttpClientTlsAlpn, + "network.http.client.tls.v1-3": NetworkV1FeatureId.HttpClientTlsV13, + "network.http.client.tls.revocation": NetworkV1FeatureId.HttpClientTlsRevocation, + "network.http.server": NetworkV1FeatureId.HttpServer, + "network.http.server.tls": NetworkV1FeatureId.HttpServerTls, + "network.http.server.h2": NetworkV1FeatureId.HttpServerH2, + "network.http.server.h3": NetworkV1FeatureId.HttpServerH3, + "network.http.server.compression": NetworkV1FeatureId.HttpServerCompression, + "network.http.server.tls.custom-ca": NetworkV1FeatureId.HttpServerTlsCustomCa, + "network.http.server.tls.client-auth": NetworkV1FeatureId.HttpServerTlsClientAuth, + "network.http.server.tls.alpn": NetworkV1FeatureId.HttpServerTlsAlpn, + "network.http.server.tls.v1-3": NetworkV1FeatureId.HttpServerTlsV13, + "network.http.server.tls.revocation": NetworkV1FeatureId.HttpServerTlsRevocation, + "network.websocket.client": NetworkV1FeatureId.WebSocketClient, + "network.websocket.client.tls": NetworkV1FeatureId.WebSocketClientTls, + "network.websocket.client.compression": NetworkV1FeatureId.WebSocketClientCompression, + "network.websocket.client.tls.custom-ca": NetworkV1FeatureId.WebSocketClientTlsCustomCa, + "network.websocket.client.tls.client-auth": NetworkV1FeatureId.WebSocketClientTlsClientAuth, + "network.websocket.client.tls.alpn": NetworkV1FeatureId.WebSocketClientTlsAlpn, + "network.websocket.client.tls.v1-3": NetworkV1FeatureId.WebSocketClientTlsV13, + "network.websocket.client.tls.revocation": NetworkV1FeatureId.WebSocketClientTlsRevocation, + "network.websocket.server": NetworkV1FeatureId.WebSocketServer, + "network.websocket.server.tls": NetworkV1FeatureId.WebSocketServerTls, + "network.websocket.server.upgrade": NetworkV1FeatureId.WebSocketServerUpgrade, + "network.websocket.server.compression": NetworkV1FeatureId.WebSocketServerCompression, + "network.websocket.server.tls.custom-ca": NetworkV1FeatureId.WebSocketServerTlsCustomCa, + "network.websocket.server.tls.client-auth": NetworkV1FeatureId.WebSocketServerTlsClientAuth, + "network.websocket.server.tls.alpn": NetworkV1FeatureId.WebSocketServerTlsAlpn, + "network.websocket.server.tls.v1-3": NetworkV1FeatureId.WebSocketServerTlsV13, + "network.websocket.server.tls.revocation": NetworkV1FeatureId.WebSocketServerTlsRevocation, + "network.mqtt.client": NetworkV1FeatureId.MqttClient, + "network.mqtt.client.tls": NetworkV1FeatureId.MqttClientTls, + "network.mqtt.client.v5": NetworkV1FeatureId.MqttClientV5, + "network.mqtt.client.qos2": NetworkV1FeatureId.MqttClientQos2, + "network.mqtt.client.tls.custom-ca": NetworkV1FeatureId.MqttClientTlsCustomCa, + "network.mqtt.client.tls.client-auth": NetworkV1FeatureId.MqttClientTlsClientAuth, + "network.mqtt.client.tls.alpn": NetworkV1FeatureId.MqttClientTlsAlpn, + "network.mqtt.client.tls.v1-3": NetworkV1FeatureId.MqttClientTlsV13, + "network.mqtt.client.tls.revocation": NetworkV1FeatureId.MqttClientTlsRevocation, + "network.tcp.client": NetworkV1FeatureId.TcpClient, + "network.tcp.client.tls": NetworkV1FeatureId.TcpClientTls, + "network.tcp.client.ipv6": NetworkV1FeatureId.TcpClientIpv6, + "network.tcp.client.socket-options": NetworkV1FeatureId.TcpClientSocketOptions, + "network.tcp.client.tls.custom-ca": NetworkV1FeatureId.TcpClientTlsCustomCa, + "network.tcp.client.tls.client-auth": NetworkV1FeatureId.TcpClientTlsClientAuth, + "network.tcp.client.tls.alpn": NetworkV1FeatureId.TcpClientTlsAlpn, + "network.tcp.client.tls.v1-3": NetworkV1FeatureId.TcpClientTlsV13, + "network.tcp.client.tls.revocation": NetworkV1FeatureId.TcpClientTlsRevocation, + "network.tcp.server": NetworkV1FeatureId.TcpServer, + "network.tcp.server.tls": NetworkV1FeatureId.TcpServerTls, + "network.tcp.server.ipv6": NetworkV1FeatureId.TcpServerIpv6, + "network.tcp.server.socket-options": NetworkV1FeatureId.TcpServerSocketOptions, + "network.tcp.server.tls.custom-ca": NetworkV1FeatureId.TcpServerTlsCustomCa, + "network.tcp.server.tls.client-auth": NetworkV1FeatureId.TcpServerTlsClientAuth, + "network.tcp.server.tls.alpn": NetworkV1FeatureId.TcpServerTlsAlpn, + "network.tcp.server.tls.v1-3": NetworkV1FeatureId.TcpServerTlsV13, + "network.tcp.server.tls.revocation": NetworkV1FeatureId.TcpServerTlsRevocation, + "network.udp": NetworkV1FeatureId.Udp, + "network.udp.ipv6": NetworkV1FeatureId.UdpIpv6, + "network.udp.broadcast": NetworkV1FeatureId.UdpBroadcast, + "network.udp.multicast": NetworkV1FeatureId.UdpMulticast, + "network.browser.http.client": NetworkV1FeatureId.BrowserHttpClient, + "network.browser.websocket.client": NetworkV1FeatureId.BrowserWebSocketClient, +} as const); + +export const NETWORK_V1_ERROR_CATEGORY_NAME_BY_ID = Object.freeze({ + [NetworkV1ErrorCategory.Runtime]: "runtime", + [NetworkV1ErrorCategory.Resolver]: "resolver", + [NetworkV1ErrorCategory.Transport]: "transport", + [NetworkV1ErrorCategory.Tls]: "tls", + [NetworkV1ErrorCategory.Protocol]: "protocol", +} as const); +export const NETWORK_V1_ERROR_CATEGORY_ID_BY_NAME = Object.freeze({ + "runtime": NetworkV1ErrorCategory.Runtime, + "resolver": NetworkV1ErrorCategory.Resolver, + "transport": NetworkV1ErrorCategory.Transport, + "tls": NetworkV1ErrorCategory.Tls, + "protocol": NetworkV1ErrorCategory.Protocol, +} as const); + +export const NETWORK_V1_ERROR_NAME_BY_ID = Object.freeze({ + [NetworkV1ErrorCode.Aborted]: "aborted", + [NetworkV1ErrorCode.TimedOut]: "timed_out", + [NetworkV1ErrorCode.Closed]: "closed", + [NetworkV1ErrorCode.InvalidState]: "invalid_state", + [NetworkV1ErrorCode.Busy]: "busy", + [NetworkV1ErrorCode.ResourceLimit]: "resource_limit", + [NetworkV1ErrorCode.Unsupported]: "unsupported", + [NetworkV1ErrorCode.PermissionDenied]: "permission_denied", + [NetworkV1ErrorCode.DnsNotFound]: "dns_not_found", + [NetworkV1ErrorCode.DnsTemporaryFailure]: "dns_temporary_failure", + [NetworkV1ErrorCode.DnsRefused]: "dns_refused", + [NetworkV1ErrorCode.ConnectionRefused]: "connection_refused", + [NetworkV1ErrorCode.ConnectionReset]: "connection_reset", + [NetworkV1ErrorCode.NetworkUnreachable]: "network_unreachable", + [NetworkV1ErrorCode.AddressInUse]: "address_in_use", + [NetworkV1ErrorCode.BrokenPipe]: "broken_pipe", + [NetworkV1ErrorCode.TlsCertificateInvalid]: "tls_certificate_invalid", + [NetworkV1ErrorCode.TlsHostnameMismatch]: "tls_hostname_mismatch", + [NetworkV1ErrorCode.TlsHandshakeFailed]: "tls_handshake_failed", + [NetworkV1ErrorCode.TlsVersionUnsupported]: "tls_version_unsupported", + [NetworkV1ErrorCode.TlsAlert]: "tls_alert", + [NetworkV1ErrorCode.HttpProtocolError]: "http_protocol_error", + [NetworkV1ErrorCode.WebSocketProtocolError]: "websocket_protocol_error", + [NetworkV1ErrorCode.MqttUnacceptableProtocolVersion]: "mqtt_unacceptable_protocol_version", + [NetworkV1ErrorCode.MqttIdentifierRejected]: "mqtt_identifier_rejected", + [NetworkV1ErrorCode.MqttServerUnavailable]: "mqtt_server_unavailable", + [NetworkV1ErrorCode.MqttBadCredentials]: "mqtt_bad_credentials", + [NetworkV1ErrorCode.MqttNotAuthorized]: "mqtt_not_authorized", + [NetworkV1ErrorCode.MqttProtocolError]: "mqtt_protocol_error", + [NetworkV1ErrorCode.MessageTooLarge]: "message_too_large", + [NetworkV1ErrorCode.SystemError]: "system_error", +} as const); +export const NETWORK_V1_ERROR_ID_BY_NAME = Object.freeze({ + "aborted": NetworkV1ErrorCode.Aborted, + "timed_out": NetworkV1ErrorCode.TimedOut, + "closed": NetworkV1ErrorCode.Closed, + "invalid_state": NetworkV1ErrorCode.InvalidState, + "busy": NetworkV1ErrorCode.Busy, + "resource_limit": NetworkV1ErrorCode.ResourceLimit, + "unsupported": NetworkV1ErrorCode.Unsupported, + "permission_denied": NetworkV1ErrorCode.PermissionDenied, + "dns_not_found": NetworkV1ErrorCode.DnsNotFound, + "dns_temporary_failure": NetworkV1ErrorCode.DnsTemporaryFailure, + "dns_refused": NetworkV1ErrorCode.DnsRefused, + "connection_refused": NetworkV1ErrorCode.ConnectionRefused, + "connection_reset": NetworkV1ErrorCode.ConnectionReset, + "network_unreachable": NetworkV1ErrorCode.NetworkUnreachable, + "address_in_use": NetworkV1ErrorCode.AddressInUse, + "broken_pipe": NetworkV1ErrorCode.BrokenPipe, + "tls_certificate_invalid": NetworkV1ErrorCode.TlsCertificateInvalid, + "tls_hostname_mismatch": NetworkV1ErrorCode.TlsHostnameMismatch, + "tls_handshake_failed": NetworkV1ErrorCode.TlsHandshakeFailed, + "tls_version_unsupported": NetworkV1ErrorCode.TlsVersionUnsupported, + "tls_alert": NetworkV1ErrorCode.TlsAlert, + "http_protocol_error": NetworkV1ErrorCode.HttpProtocolError, + "websocket_protocol_error": NetworkV1ErrorCode.WebSocketProtocolError, + "mqtt_unacceptable_protocol_version": NetworkV1ErrorCode.MqttUnacceptableProtocolVersion, + "mqtt_identifier_rejected": NetworkV1ErrorCode.MqttIdentifierRejected, + "mqtt_server_unavailable": NetworkV1ErrorCode.MqttServerUnavailable, + "mqtt_bad_credentials": NetworkV1ErrorCode.MqttBadCredentials, + "mqtt_not_authorized": NetworkV1ErrorCode.MqttNotAuthorized, + "mqtt_protocol_error": NetworkV1ErrorCode.MqttProtocolError, + "message_too_large": NetworkV1ErrorCode.MessageTooLarge, + "system_error": NetworkV1ErrorCode.SystemError, +} as const); diff --git a/contracts/spec/network/generated/pocketjs_network_v1_abi.h b/contracts/spec/network/generated/pocketjs_network_v1_abi.h new file mode 100644 index 00000000..0b803fd5 --- /dev/null +++ b/contracts/spec/network/generated/pocketjs_network_v1_abi.h @@ -0,0 +1,646 @@ +/* GENERATED — do not edit; run `bun contracts/spec/network/generate.ts`. + * Source of truth: contracts/spec/network/definition.ts. + */ +#ifndef POCKETJS_NETWORK_V1_ABI_H +#define POCKETJS_NETWORK_V1_ABI_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define POCKETJS_NETWORK_V1_ABI_MAJOR UINT16_C(1) +#define POCKETJS_NETWORK_V1_ABI_MINOR UINT16_C(1) +#define POCKETJS_NETWORK_V1_PLAN_HASH_BYTES UINT16_C(32) +#define POCKETJS_NETWORK_V1_SEQUENCE_MAX UINT64_C(9007199254740991) +#define POCKETJS_NETWORK_V1_LIMIT_ENTRY_MAX UINT16_C(64) +#define POCKETJS_NETWORK_V1_LIMIT_NAME_MAX_BYTES UINT16_C(64) +#define POCKETJS_NETWORK_V1_ABSENT_ID UINT32_C(0) +#define POCKETJS_NETWORK_V1_LIMIT_PROTOCOL_ANY UINT16_C(0) +#define POCKETJS_NETWORK_V1_LIMIT_ROLE_ANY UINT16_C(0) +#define POCKETJS_NETWORK_V1_FEATURE_COUNT UINT16_C(70) + +typedef uint16_t pocketjs_network_v1_feature_id_t; +typedef uint16_t pocketjs_network_v1_command_opcode_t; +typedef uint16_t pocketjs_network_v1_event_code_t; +typedef uint16_t pocketjs_network_v1_error_category_t; +typedef uint16_t pocketjs_network_v1_error_code_t; +typedef uint16_t pocketjs_network_v1_dispatch_status_t; +typedef uint16_t pocketjs_network_v1_completion_poll_status_t; +typedef uint16_t pocketjs_network_v1_borrowed_input_kind_t; +typedef uint16_t pocketjs_network_v1_limit_protocol_t; +typedef uint16_t pocketjs_network_v1_limit_role_t; +typedef uint16_t pocketjs_network_v1_http_redirect_mode_t; +typedef uint16_t pocketjs_network_v1_tls_version_t; +typedef uint16_t pocketjs_network_v1_tls_verification_t; +typedef uint16_t pocketjs_network_v1_tls_revocation_t; +typedef uint16_t pocketjs_network_v1_client_certificate_mode_t; +typedef uint16_t pocketjs_network_v1_service_turn_kind_t; +typedef uint16_t pocketjs_network_v1_service_turn_status_t; +typedef uint16_t pocketjs_network_v1_lease_state_t; +typedef uint16_t pocketjs_network_v1_lease_action_t; + +/** HTTP client role. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT ((pocketjs_network_v1_feature_id_t)UINT16_C(256)) +/** TLS for the HTTP client role. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_TLS ((pocketjs_network_v1_feature_id_t)UINT16_C(257)) +/** HTTP/2 client extension. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_H2 ((pocketjs_network_v1_feature_id_t)UINT16_C(272)) +/** HTTP/3 client extension. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_H3 ((pocketjs_network_v1_feature_id_t)UINT16_C(273)) +/** HTTP client content decoding. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_COMPRESSION ((pocketjs_network_v1_feature_id_t)UINT16_C(274)) +/** HTTP client custom CA extension. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_TLS_CUSTOM_CA ((pocketjs_network_v1_feature_id_t)UINT16_C(280)) +/** HTTP client certificate authentication. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_TLS_CLIENT_AUTH ((pocketjs_network_v1_feature_id_t)UINT16_C(281)) +/** HTTP client custom ALPN. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_TLS_ALPN ((pocketjs_network_v1_feature_id_t)UINT16_C(282)) +/** Required HTTP client TLS 1.3. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_TLS_V1_3 ((pocketjs_network_v1_feature_id_t)UINT16_C(283)) +/** Required HTTP client certificate revocation. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_TLS_REVOCATION ((pocketjs_network_v1_feature_id_t)UINT16_C(284)) +/** HTTP server role. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER ((pocketjs_network_v1_feature_id_t)UINT16_C(288)) +/** TLS for the HTTP server role. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_TLS ((pocketjs_network_v1_feature_id_t)UINT16_C(289)) +/** HTTP/2 server extension. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_H2 ((pocketjs_network_v1_feature_id_t)UINT16_C(304)) +/** HTTP/3 server extension. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_H3 ((pocketjs_network_v1_feature_id_t)UINT16_C(305)) +/** HTTP server content encoding. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_COMPRESSION ((pocketjs_network_v1_feature_id_t)UINT16_C(306)) +/** HTTP server custom CA extension. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_TLS_CUSTOM_CA ((pocketjs_network_v1_feature_id_t)UINT16_C(312)) +/** HTTP server client-certificate authentication. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_TLS_CLIENT_AUTH ((pocketjs_network_v1_feature_id_t)UINT16_C(313)) +/** HTTP server custom ALPN. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_TLS_ALPN ((pocketjs_network_v1_feature_id_t)UINT16_C(314)) +/** Required HTTP server TLS 1.3. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_TLS_V1_3 ((pocketjs_network_v1_feature_id_t)UINT16_C(315)) +/** Required HTTP server certificate revocation. */ +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_TLS_REVOCATION ((pocketjs_network_v1_feature_id_t)UINT16_C(316)) +/** WebSocket client role. */ +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_CLIENT ((pocketjs_network_v1_feature_id_t)UINT16_C(512)) +/** TLS for the WebSocket client role. */ +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_CLIENT_TLS ((pocketjs_network_v1_feature_id_t)UINT16_C(513)) +/** WebSocket client compression. */ +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_CLIENT_COMPRESSION ((pocketjs_network_v1_feature_id_t)UINT16_C(528)) +/** WebSocket client custom CA extension. */ +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_CLIENT_TLS_CUSTOM_CA ((pocketjs_network_v1_feature_id_t)UINT16_C(536)) +/** WebSocket client certificate authentication. */ +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_CLIENT_TLS_CLIENT_AUTH ((pocketjs_network_v1_feature_id_t)UINT16_C(537)) +/** WebSocket client custom ALPN. */ +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_CLIENT_TLS_ALPN ((pocketjs_network_v1_feature_id_t)UINT16_C(538)) +/** Required WebSocket client TLS 1.3. */ +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_CLIENT_TLS_V1_3 ((pocketjs_network_v1_feature_id_t)UINT16_C(539)) +/** Required WebSocket client certificate revocation. */ +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_CLIENT_TLS_REVOCATION ((pocketjs_network_v1_feature_id_t)UINT16_C(540)) +/** WebSocket server role. */ +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_SERVER ((pocketjs_network_v1_feature_id_t)UINT16_C(544)) +/** TLS for the WebSocket server role. */ +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_SERVER_TLS ((pocketjs_network_v1_feature_id_t)UINT16_C(545)) +/** HTTP-to-WebSocket server handoff. */ +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_SERVER_UPGRADE ((pocketjs_network_v1_feature_id_t)UINT16_C(546)) +/** WebSocket server compression. */ +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_SERVER_COMPRESSION ((pocketjs_network_v1_feature_id_t)UINT16_C(560)) +/** WebSocket server custom CA extension. */ +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_SERVER_TLS_CUSTOM_CA ((pocketjs_network_v1_feature_id_t)UINT16_C(568)) +/** WebSocket server client-certificate authentication. */ +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_SERVER_TLS_CLIENT_AUTH ((pocketjs_network_v1_feature_id_t)UINT16_C(569)) +/** WebSocket server custom ALPN. */ +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_SERVER_TLS_ALPN ((pocketjs_network_v1_feature_id_t)UINT16_C(570)) +/** Required WebSocket server TLS 1.3. */ +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_SERVER_TLS_V1_3 ((pocketjs_network_v1_feature_id_t)UINT16_C(571)) +/** Required WebSocket server certificate revocation. */ +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_SERVER_TLS_REVOCATION ((pocketjs_network_v1_feature_id_t)UINT16_C(572)) +/** MQTT client role. */ +#define POCKETJS_NETWORK_V1_FEATURE_MQTT_CLIENT ((pocketjs_network_v1_feature_id_t)UINT16_C(768)) +/** TLS for the MQTT client role. */ +#define POCKETJS_NETWORK_V1_FEATURE_MQTT_CLIENT_TLS ((pocketjs_network_v1_feature_id_t)UINT16_C(769)) +/** MQTT 5 extension. */ +#define POCKETJS_NETWORK_V1_FEATURE_MQTT_CLIENT_V5 ((pocketjs_network_v1_feature_id_t)UINT16_C(784)) +/** MQTT QoS 2 extension. */ +#define POCKETJS_NETWORK_V1_FEATURE_MQTT_CLIENT_QOS2 ((pocketjs_network_v1_feature_id_t)UINT16_C(785)) +/** MQTT client custom CA extension. */ +#define POCKETJS_NETWORK_V1_FEATURE_MQTT_CLIENT_TLS_CUSTOM_CA ((pocketjs_network_v1_feature_id_t)UINT16_C(792)) +/** MQTT client certificate authentication. */ +#define POCKETJS_NETWORK_V1_FEATURE_MQTT_CLIENT_TLS_CLIENT_AUTH ((pocketjs_network_v1_feature_id_t)UINT16_C(793)) +/** MQTT client custom ALPN. */ +#define POCKETJS_NETWORK_V1_FEATURE_MQTT_CLIENT_TLS_ALPN ((pocketjs_network_v1_feature_id_t)UINT16_C(794)) +/** Required MQTT client TLS 1.3. */ +#define POCKETJS_NETWORK_V1_FEATURE_MQTT_CLIENT_TLS_V1_3 ((pocketjs_network_v1_feature_id_t)UINT16_C(795)) +/** Required MQTT client certificate revocation. */ +#define POCKETJS_NETWORK_V1_FEATURE_MQTT_CLIENT_TLS_REVOCATION ((pocketjs_network_v1_feature_id_t)UINT16_C(796)) +/** TCP client role. */ +#define POCKETJS_NETWORK_V1_FEATURE_TCP_CLIENT ((pocketjs_network_v1_feature_id_t)UINT16_C(1024)) +/** TLS for the TCP client role. */ +#define POCKETJS_NETWORK_V1_FEATURE_TCP_CLIENT_TLS ((pocketjs_network_v1_feature_id_t)UINT16_C(1025)) +/** TCP client IPv6 extension. */ +#define POCKETJS_NETWORK_V1_FEATURE_TCP_CLIENT_IPV6 ((pocketjs_network_v1_feature_id_t)UINT16_C(1040)) +/** TCP client socket options. */ +#define POCKETJS_NETWORK_V1_FEATURE_TCP_CLIENT_SOCKET_OPTIONS ((pocketjs_network_v1_feature_id_t)UINT16_C(1041)) +/** TCP client custom CA extension. */ +#define POCKETJS_NETWORK_V1_FEATURE_TCP_CLIENT_TLS_CUSTOM_CA ((pocketjs_network_v1_feature_id_t)UINT16_C(1048)) +/** TCP client certificate authentication. */ +#define POCKETJS_NETWORK_V1_FEATURE_TCP_CLIENT_TLS_CLIENT_AUTH ((pocketjs_network_v1_feature_id_t)UINT16_C(1049)) +/** TCP client custom ALPN. */ +#define POCKETJS_NETWORK_V1_FEATURE_TCP_CLIENT_TLS_ALPN ((pocketjs_network_v1_feature_id_t)UINT16_C(1050)) +/** Required TCP client TLS 1.3. */ +#define POCKETJS_NETWORK_V1_FEATURE_TCP_CLIENT_TLS_V1_3 ((pocketjs_network_v1_feature_id_t)UINT16_C(1051)) +/** Required TCP client certificate revocation. */ +#define POCKETJS_NETWORK_V1_FEATURE_TCP_CLIENT_TLS_REVOCATION ((pocketjs_network_v1_feature_id_t)UINT16_C(1052)) +/** TCP server role. */ +#define POCKETJS_NETWORK_V1_FEATURE_TCP_SERVER ((pocketjs_network_v1_feature_id_t)UINT16_C(1056)) +/** TLS for the TCP server role. */ +#define POCKETJS_NETWORK_V1_FEATURE_TCP_SERVER_TLS ((pocketjs_network_v1_feature_id_t)UINT16_C(1057)) +/** TCP server IPv6 extension. */ +#define POCKETJS_NETWORK_V1_FEATURE_TCP_SERVER_IPV6 ((pocketjs_network_v1_feature_id_t)UINT16_C(1072)) +/** TCP server socket options. */ +#define POCKETJS_NETWORK_V1_FEATURE_TCP_SERVER_SOCKET_OPTIONS ((pocketjs_network_v1_feature_id_t)UINT16_C(1073)) +/** TCP server custom CA extension. */ +#define POCKETJS_NETWORK_V1_FEATURE_TCP_SERVER_TLS_CUSTOM_CA ((pocketjs_network_v1_feature_id_t)UINT16_C(1080)) +/** TCP server client-certificate authentication. */ +#define POCKETJS_NETWORK_V1_FEATURE_TCP_SERVER_TLS_CLIENT_AUTH ((pocketjs_network_v1_feature_id_t)UINT16_C(1081)) +/** TCP server custom ALPN. */ +#define POCKETJS_NETWORK_V1_FEATURE_TCP_SERVER_TLS_ALPN ((pocketjs_network_v1_feature_id_t)UINT16_C(1082)) +/** Required TCP server TLS 1.3. */ +#define POCKETJS_NETWORK_V1_FEATURE_TCP_SERVER_TLS_V1_3 ((pocketjs_network_v1_feature_id_t)UINT16_C(1083)) +/** Required TCP server certificate revocation. */ +#define POCKETJS_NETWORK_V1_FEATURE_TCP_SERVER_TLS_REVOCATION ((pocketjs_network_v1_feature_id_t)UINT16_C(1084)) +/** UDP socket role. */ +#define POCKETJS_NETWORK_V1_FEATURE_UDP ((pocketjs_network_v1_feature_id_t)UINT16_C(1280)) +/** UDP IPv6 extension. */ +#define POCKETJS_NETWORK_V1_FEATURE_UDP_IPV6 ((pocketjs_network_v1_feature_id_t)UINT16_C(1296)) +/** UDP broadcast extension. */ +#define POCKETJS_NETWORK_V1_FEATURE_UDP_BROADCAST ((pocketjs_network_v1_feature_id_t)UINT16_C(1297)) +/** UDP multicast extension. */ +#define POCKETJS_NETWORK_V1_FEATURE_UDP_MULTICAST ((pocketjs_network_v1_feature_id_t)UINT16_C(1298)) +/** Browser HTTP adapter role. */ +#define POCKETJS_NETWORK_V1_FEATURE_BROWSER_HTTP_CLIENT ((pocketjs_network_v1_feature_id_t)UINT16_C(28672)) +/** Browser WebSocket adapter role. */ +#define POCKETJS_NETWORK_V1_FEATURE_BROWSER_WEBSOCKET_CLIENT ((pocketjs_network_v1_feature_id_t)UINT16_C(28688)) + +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_CAPABILITY "network.http.client" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_TLS_CAPABILITY "network.http.client.tls" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_H2_CAPABILITY "network.http.client.h2" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_H3_CAPABILITY "network.http.client.h3" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_COMPRESSION_CAPABILITY "network.http.client.compression" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_TLS_CUSTOM_CA_CAPABILITY "network.http.client.tls.custom-ca" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_TLS_CLIENT_AUTH_CAPABILITY "network.http.client.tls.client-auth" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_TLS_ALPN_CAPABILITY "network.http.client.tls.alpn" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_TLS_V1_3_CAPABILITY "network.http.client.tls.v1-3" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_TLS_REVOCATION_CAPABILITY "network.http.client.tls.revocation" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_CAPABILITY "network.http.server" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_TLS_CAPABILITY "network.http.server.tls" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_H2_CAPABILITY "network.http.server.h2" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_H3_CAPABILITY "network.http.server.h3" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_COMPRESSION_CAPABILITY "network.http.server.compression" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_TLS_CUSTOM_CA_CAPABILITY "network.http.server.tls.custom-ca" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_TLS_CLIENT_AUTH_CAPABILITY "network.http.server.tls.client-auth" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_TLS_ALPN_CAPABILITY "network.http.server.tls.alpn" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_TLS_V1_3_CAPABILITY "network.http.server.tls.v1-3" +#define POCKETJS_NETWORK_V1_FEATURE_HTTP_SERVER_TLS_REVOCATION_CAPABILITY "network.http.server.tls.revocation" +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_CLIENT_CAPABILITY "network.websocket.client" +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_CLIENT_TLS_CAPABILITY "network.websocket.client.tls" +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_CLIENT_COMPRESSION_CAPABILITY "network.websocket.client.compression" +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_CLIENT_TLS_CUSTOM_CA_CAPABILITY "network.websocket.client.tls.custom-ca" +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_CLIENT_TLS_CLIENT_AUTH_CAPABILITY "network.websocket.client.tls.client-auth" +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_CLIENT_TLS_ALPN_CAPABILITY "network.websocket.client.tls.alpn" +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_CLIENT_TLS_V1_3_CAPABILITY "network.websocket.client.tls.v1-3" +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_CLIENT_TLS_REVOCATION_CAPABILITY "network.websocket.client.tls.revocation" +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_SERVER_CAPABILITY "network.websocket.server" +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_SERVER_TLS_CAPABILITY "network.websocket.server.tls" +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_SERVER_UPGRADE_CAPABILITY "network.websocket.server.upgrade" +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_SERVER_COMPRESSION_CAPABILITY "network.websocket.server.compression" +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_SERVER_TLS_CUSTOM_CA_CAPABILITY "network.websocket.server.tls.custom-ca" +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_SERVER_TLS_CLIENT_AUTH_CAPABILITY "network.websocket.server.tls.client-auth" +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_SERVER_TLS_ALPN_CAPABILITY "network.websocket.server.tls.alpn" +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_SERVER_TLS_V1_3_CAPABILITY "network.websocket.server.tls.v1-3" +#define POCKETJS_NETWORK_V1_FEATURE_WEBSOCKET_SERVER_TLS_REVOCATION_CAPABILITY "network.websocket.server.tls.revocation" +#define POCKETJS_NETWORK_V1_FEATURE_MQTT_CLIENT_CAPABILITY "network.mqtt.client" +#define POCKETJS_NETWORK_V1_FEATURE_MQTT_CLIENT_TLS_CAPABILITY "network.mqtt.client.tls" +#define POCKETJS_NETWORK_V1_FEATURE_MQTT_CLIENT_V5_CAPABILITY "network.mqtt.client.v5" +#define POCKETJS_NETWORK_V1_FEATURE_MQTT_CLIENT_QOS2_CAPABILITY "network.mqtt.client.qos2" +#define POCKETJS_NETWORK_V1_FEATURE_MQTT_CLIENT_TLS_CUSTOM_CA_CAPABILITY "network.mqtt.client.tls.custom-ca" +#define POCKETJS_NETWORK_V1_FEATURE_MQTT_CLIENT_TLS_CLIENT_AUTH_CAPABILITY "network.mqtt.client.tls.client-auth" +#define POCKETJS_NETWORK_V1_FEATURE_MQTT_CLIENT_TLS_ALPN_CAPABILITY "network.mqtt.client.tls.alpn" +#define POCKETJS_NETWORK_V1_FEATURE_MQTT_CLIENT_TLS_V1_3_CAPABILITY "network.mqtt.client.tls.v1-3" +#define POCKETJS_NETWORK_V1_FEATURE_MQTT_CLIENT_TLS_REVOCATION_CAPABILITY "network.mqtt.client.tls.revocation" +#define POCKETJS_NETWORK_V1_FEATURE_TCP_CLIENT_CAPABILITY "network.tcp.client" +#define POCKETJS_NETWORK_V1_FEATURE_TCP_CLIENT_TLS_CAPABILITY "network.tcp.client.tls" +#define POCKETJS_NETWORK_V1_FEATURE_TCP_CLIENT_IPV6_CAPABILITY "network.tcp.client.ipv6" +#define POCKETJS_NETWORK_V1_FEATURE_TCP_CLIENT_SOCKET_OPTIONS_CAPABILITY "network.tcp.client.socket-options" +#define POCKETJS_NETWORK_V1_FEATURE_TCP_CLIENT_TLS_CUSTOM_CA_CAPABILITY "network.tcp.client.tls.custom-ca" +#define POCKETJS_NETWORK_V1_FEATURE_TCP_CLIENT_TLS_CLIENT_AUTH_CAPABILITY "network.tcp.client.tls.client-auth" +#define POCKETJS_NETWORK_V1_FEATURE_TCP_CLIENT_TLS_ALPN_CAPABILITY "network.tcp.client.tls.alpn" +#define POCKETJS_NETWORK_V1_FEATURE_TCP_CLIENT_TLS_V1_3_CAPABILITY "network.tcp.client.tls.v1-3" +#define POCKETJS_NETWORK_V1_FEATURE_TCP_CLIENT_TLS_REVOCATION_CAPABILITY "network.tcp.client.tls.revocation" +#define POCKETJS_NETWORK_V1_FEATURE_TCP_SERVER_CAPABILITY "network.tcp.server" +#define POCKETJS_NETWORK_V1_FEATURE_TCP_SERVER_TLS_CAPABILITY "network.tcp.server.tls" +#define POCKETJS_NETWORK_V1_FEATURE_TCP_SERVER_IPV6_CAPABILITY "network.tcp.server.ipv6" +#define POCKETJS_NETWORK_V1_FEATURE_TCP_SERVER_SOCKET_OPTIONS_CAPABILITY "network.tcp.server.socket-options" +#define POCKETJS_NETWORK_V1_FEATURE_TCP_SERVER_TLS_CUSTOM_CA_CAPABILITY "network.tcp.server.tls.custom-ca" +#define POCKETJS_NETWORK_V1_FEATURE_TCP_SERVER_TLS_CLIENT_AUTH_CAPABILITY "network.tcp.server.tls.client-auth" +#define POCKETJS_NETWORK_V1_FEATURE_TCP_SERVER_TLS_ALPN_CAPABILITY "network.tcp.server.tls.alpn" +#define POCKETJS_NETWORK_V1_FEATURE_TCP_SERVER_TLS_V1_3_CAPABILITY "network.tcp.server.tls.v1-3" +#define POCKETJS_NETWORK_V1_FEATURE_TCP_SERVER_TLS_REVOCATION_CAPABILITY "network.tcp.server.tls.revocation" +#define POCKETJS_NETWORK_V1_FEATURE_UDP_CAPABILITY "network.udp" +#define POCKETJS_NETWORK_V1_FEATURE_UDP_IPV6_CAPABILITY "network.udp.ipv6" +#define POCKETJS_NETWORK_V1_FEATURE_UDP_BROADCAST_CAPABILITY "network.udp.broadcast" +#define POCKETJS_NETWORK_V1_FEATURE_UDP_MULTICAST_CAPABILITY "network.udp.multicast" +#define POCKETJS_NETWORK_V1_FEATURE_BROWSER_HTTP_CLIENT_CAPABILITY "network.browser.http.client" +#define POCKETJS_NETWORK_V1_FEATURE_BROWSER_WEBSOCKET_CLIENT_CAPABILITY "network.browser.websocket.client" + +/** Compete for an operation's single terminal claim. */ +#define POCKETJS_NETWORK_V1_COMMAND_OPERATION_CANCEL ((pocketjs_network_v1_command_opcode_t)UINT16_C(1)) +/** Synchronously claim a delivered BufferLease. */ +#define POCKETJS_NETWORK_V1_COMMAND_BUFFER_LEASE_TAKE ((pocketjs_network_v1_command_opcode_t)UINT16_C(2)) +/** Synchronously copy from a taken lease into borrowed output. */ +#define POCKETJS_NETWORK_V1_COMMAND_BUFFER_LEASE_READ_INTO ((pocketjs_network_v1_command_opcode_t)UINT16_C(3)) +/** Release a taken BufferLease exactly once. */ +#define POCKETJS_NETWORK_V1_COMMAND_BUFFER_LEASE_RELEASE ((pocketjs_network_v1_command_opcode_t)UINT16_C(4)) +/** Grant one bounded body-chunk credit. */ +#define POCKETJS_NETWORK_V1_COMMAND_BODY_PULL ((pocketjs_network_v1_command_opcode_t)UINT16_C(16)) +/** Satisfy body credit with one non-empty chunk. */ +#define POCKETJS_NETWORK_V1_COMMAND_BODY_CHUNK ((pocketjs_network_v1_command_opcode_t)UINT16_C(17)) +/** End a body normally. */ +#define POCKETJS_NETWORK_V1_COMMAND_BODY_END ((pocketjs_network_v1_command_opcode_t)UINT16_C(18)) +/** End a body with a normalized error. */ +#define POCKETJS_NETWORK_V1_COMMAND_BODY_ERROR ((pocketjs_network_v1_command_opcode_t)UINT16_C(19)) +/** Cancel the opposite body producer or consumer. */ +#define POCKETJS_NETWORK_V1_COMMAND_BODY_CANCEL ((pocketjs_network_v1_command_opcode_t)UINT16_C(20)) +/** Start an admitted HTTP client exchange. */ +#define POCKETJS_NETWORK_V1_COMMAND_HTTP_REQUEST_START ((pocketjs_network_v1_command_opcode_t)UINT16_C(256)) + +/** Request one bounded chunk from a Guest body producer. */ +#define POCKETJS_NETWORK_V1_EVENT_BODY_PULL ((pocketjs_network_v1_event_code_t)UINT16_C(16)) +/** Publish one native BufferLease-backed body chunk. */ +#define POCKETJS_NETWORK_V1_EVENT_BODY_CHUNK ((pocketjs_network_v1_event_code_t)UINT16_C(17)) +/** Report normal body end. */ +#define POCKETJS_NETWORK_V1_EVENT_BODY_END ((pocketjs_network_v1_event_code_t)UINT16_C(18)) +/** Report normalized body failure. */ +#define POCKETJS_NETWORK_V1_EVENT_BODY_ERROR ((pocketjs_network_v1_event_code_t)UINT16_C(19)) +/** Report cancellation of the opposite body endpoint. */ +#define POCKETJS_NETWORK_V1_EVENT_BODY_CANCEL ((pocketjs_network_v1_event_code_t)UINT16_C(20)) +/** Publish validated HTTP response headers. */ +#define POCKETJS_NETWORK_V1_EVENT_HTTP_RESPONSE_HEADERS ((pocketjs_network_v1_event_code_t)UINT16_C(256)) +/** Publish the HTTP operation's terminal error. */ +#define POCKETJS_NETWORK_V1_EVENT_HTTP_REQUEST_ERROR ((pocketjs_network_v1_event_code_t)UINT16_C(257)) + +/** Runtime, lifecycle, admission, or capacity failure. */ +#define POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME ((pocketjs_network_v1_error_category_t)UINT16_C(1)) +/** Name resolution failure. */ +#define POCKETJS_NETWORK_V1_ERROR_CATEGORY_RESOLVER ((pocketjs_network_v1_error_category_t)UINT16_C(2)) +/** Plain transport failure. */ +#define POCKETJS_NETWORK_V1_ERROR_CATEGORY_TRANSPORT ((pocketjs_network_v1_error_category_t)UINT16_C(3)) +/** TLS policy or handshake failure. */ +#define POCKETJS_NETWORK_V1_ERROR_CATEGORY_TLS ((pocketjs_network_v1_error_category_t)UINT16_C(4)) +/** Protocol framing or semantic failure. */ +#define POCKETJS_NETWORK_V1_ERROR_CATEGORY_PROTOCOL ((pocketjs_network_v1_error_category_t)UINT16_C(5)) + +#define POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME_NAME "runtime" +#define POCKETJS_NETWORK_V1_ERROR_CATEGORY_RESOLVER_NAME "resolver" +#define POCKETJS_NETWORK_V1_ERROR_CATEGORY_TRANSPORT_NAME "transport" +#define POCKETJS_NETWORK_V1_ERROR_CATEGORY_TLS_NAME "tls" +#define POCKETJS_NETWORK_V1_ERROR_CATEGORY_PROTOCOL_NAME "protocol" + +/** The application aborted the operation. */ +#define POCKETJS_NETWORK_V1_ERROR_ABORTED ((pocketjs_network_v1_error_code_t)UINT16_C(256)) +/** A Core-owned monotonic deadline won. */ +#define POCKETJS_NETWORK_V1_ERROR_TIMED_OUT ((pocketjs_network_v1_error_code_t)UINT16_C(257)) +/** The owning resource closed. */ +#define POCKETJS_NETWORK_V1_ERROR_CLOSED ((pocketjs_network_v1_error_code_t)UINT16_C(258)) +/** The operation is invalid in the current state. */ +#define POCKETJS_NETWORK_V1_ERROR_INVALID_STATE ((pocketjs_network_v1_error_code_t)UINT16_C(259)) +/** A bounded in-flight slot is already occupied. */ +#define POCKETJS_NETWORK_V1_ERROR_BUSY ((pocketjs_network_v1_error_code_t)UINT16_C(260)) +/** An admitted hard resource limit was reached. */ +#define POCKETJS_NETWORK_V1_ERROR_RESOURCE_LIMIT ((pocketjs_network_v1_error_code_t)UINT16_C(261)) +/** The Build Plan does not contain the requested feature. */ +#define POCKETJS_NETWORK_V1_ERROR_UNSUPPORTED ((pocketjs_network_v1_error_code_t)UINT16_C(262)) +/** ResolvedNetworkPolicy rejected the operation. */ +#define POCKETJS_NETWORK_V1_ERROR_PERMISSION_DENIED ((pocketjs_network_v1_error_code_t)UINT16_C(263)) +/** The hostname does not exist. */ +#define POCKETJS_NETWORK_V1_ERROR_DNS_NOT_FOUND ((pocketjs_network_v1_error_code_t)UINT16_C(512)) +/** Name resolution failed temporarily. */ +#define POCKETJS_NETWORK_V1_ERROR_DNS_TEMPORARY_FAILURE ((pocketjs_network_v1_error_code_t)UINT16_C(513)) +/** The resolver refused the query. */ +#define POCKETJS_NETWORK_V1_ERROR_DNS_REFUSED ((pocketjs_network_v1_error_code_t)UINT16_C(514)) +/** The peer refused the connection. */ +#define POCKETJS_NETWORK_V1_ERROR_CONNECTION_REFUSED ((pocketjs_network_v1_error_code_t)UINT16_C(768)) +/** The peer reset the connection. */ +#define POCKETJS_NETWORK_V1_ERROR_CONNECTION_RESET ((pocketjs_network_v1_error_code_t)UINT16_C(769)) +/** No route is available. */ +#define POCKETJS_NETWORK_V1_ERROR_NETWORK_UNREACHABLE ((pocketjs_network_v1_error_code_t)UINT16_C(770)) +/** The local address is already in use. */ +#define POCKETJS_NETWORK_V1_ERROR_ADDRESS_IN_USE ((pocketjs_network_v1_error_code_t)UINT16_C(771)) +/** The write side is no longer usable. */ +#define POCKETJS_NETWORK_V1_ERROR_BROKEN_PIPE ((pocketjs_network_v1_error_code_t)UINT16_C(772)) +/** Certificate chain, usage, or time validation failed. */ +#define POCKETJS_NETWORK_V1_ERROR_TLS_CERTIFICATE_INVALID ((pocketjs_network_v1_error_code_t)UINT16_C(1024)) +/** The certificate does not match the authorized hostname. */ +#define POCKETJS_NETWORK_V1_ERROR_TLS_HOSTNAME_MISMATCH ((pocketjs_network_v1_error_code_t)UINT16_C(1025)) +/** The TLS handshake failed. */ +#define POCKETJS_NETWORK_V1_ERROR_TLS_HANDSHAKE_FAILED ((pocketjs_network_v1_error_code_t)UINT16_C(1026)) +/** The requested TLS version cannot be negotiated. */ +#define POCKETJS_NETWORK_V1_ERROR_TLS_VERSION_UNSUPPORTED ((pocketjs_network_v1_error_code_t)UINT16_C(1027)) +/** The peer sent a TLS alert. */ +#define POCKETJS_NETWORK_V1_ERROR_TLS_ALERT ((pocketjs_network_v1_error_code_t)UINT16_C(1028)) +/** HTTP framing or semantic validation failed. */ +#define POCKETJS_NETWORK_V1_ERROR_HTTP_PROTOCOL_ERROR ((pocketjs_network_v1_error_code_t)UINT16_C(1280)) +/** WebSocket framing or semantic validation failed. */ +#define POCKETJS_NETWORK_V1_ERROR_WEBSOCKET_PROTOCOL_ERROR ((pocketjs_network_v1_error_code_t)UINT16_C(1281)) +/** The broker rejected the MQTT protocol version. */ +#define POCKETJS_NETWORK_V1_ERROR_MQTT_UNACCEPTABLE_PROTOCOL_VERSION ((pocketjs_network_v1_error_code_t)UINT16_C(1296)) +/** The broker rejected the client identifier. */ +#define POCKETJS_NETWORK_V1_ERROR_MQTT_IDENTIFIER_REJECTED ((pocketjs_network_v1_error_code_t)UINT16_C(1297)) +/** The MQTT server is unavailable. */ +#define POCKETJS_NETWORK_V1_ERROR_MQTT_SERVER_UNAVAILABLE ((pocketjs_network_v1_error_code_t)UINT16_C(1298)) +/** The broker rejected supplied credentials. */ +#define POCKETJS_NETWORK_V1_ERROR_MQTT_BAD_CREDENTIALS ((pocketjs_network_v1_error_code_t)UINT16_C(1299)) +/** The MQTT action is not authorized. */ +#define POCKETJS_NETWORK_V1_ERROR_MQTT_NOT_AUTHORIZED ((pocketjs_network_v1_error_code_t)UINT16_C(1300)) +/** MQTT framing or semantic validation failed. */ +#define POCKETJS_NETWORK_V1_ERROR_MQTT_PROTOCOL_ERROR ((pocketjs_network_v1_error_code_t)UINT16_C(1301)) +/** A protocol message exceeded its admitted bound. */ +#define POCKETJS_NETWORK_V1_ERROR_MESSAGE_TOO_LARGE ((pocketjs_network_v1_error_code_t)UINT16_C(1312)) +/** A redacted platform failure has no more specific mapping. */ +#define POCKETJS_NETWORK_V1_ERROR_SYSTEM_ERROR ((pocketjs_network_v1_error_code_t)UINT16_C(32767)) + +#define POCKETJS_NETWORK_V1_ERROR_ABORTED_NAME "aborted" +#define POCKETJS_NETWORK_V1_ERROR_TIMED_OUT_NAME "timed_out" +#define POCKETJS_NETWORK_V1_ERROR_CLOSED_NAME "closed" +#define POCKETJS_NETWORK_V1_ERROR_INVALID_STATE_NAME "invalid_state" +#define POCKETJS_NETWORK_V1_ERROR_BUSY_NAME "busy" +#define POCKETJS_NETWORK_V1_ERROR_RESOURCE_LIMIT_NAME "resource_limit" +#define POCKETJS_NETWORK_V1_ERROR_UNSUPPORTED_NAME "unsupported" +#define POCKETJS_NETWORK_V1_ERROR_PERMISSION_DENIED_NAME "permission_denied" +#define POCKETJS_NETWORK_V1_ERROR_DNS_NOT_FOUND_NAME "dns_not_found" +#define POCKETJS_NETWORK_V1_ERROR_DNS_TEMPORARY_FAILURE_NAME "dns_temporary_failure" +#define POCKETJS_NETWORK_V1_ERROR_DNS_REFUSED_NAME "dns_refused" +#define POCKETJS_NETWORK_V1_ERROR_CONNECTION_REFUSED_NAME "connection_refused" +#define POCKETJS_NETWORK_V1_ERROR_CONNECTION_RESET_NAME "connection_reset" +#define POCKETJS_NETWORK_V1_ERROR_NETWORK_UNREACHABLE_NAME "network_unreachable" +#define POCKETJS_NETWORK_V1_ERROR_ADDRESS_IN_USE_NAME "address_in_use" +#define POCKETJS_NETWORK_V1_ERROR_BROKEN_PIPE_NAME "broken_pipe" +#define POCKETJS_NETWORK_V1_ERROR_TLS_CERTIFICATE_INVALID_NAME "tls_certificate_invalid" +#define POCKETJS_NETWORK_V1_ERROR_TLS_HOSTNAME_MISMATCH_NAME "tls_hostname_mismatch" +#define POCKETJS_NETWORK_V1_ERROR_TLS_HANDSHAKE_FAILED_NAME "tls_handshake_failed" +#define POCKETJS_NETWORK_V1_ERROR_TLS_VERSION_UNSUPPORTED_NAME "tls_version_unsupported" +#define POCKETJS_NETWORK_V1_ERROR_TLS_ALERT_NAME "tls_alert" +#define POCKETJS_NETWORK_V1_ERROR_HTTP_PROTOCOL_ERROR_NAME "http_protocol_error" +#define POCKETJS_NETWORK_V1_ERROR_WEBSOCKET_PROTOCOL_ERROR_NAME "websocket_protocol_error" +#define POCKETJS_NETWORK_V1_ERROR_MQTT_UNACCEPTABLE_PROTOCOL_VERSION_NAME "mqtt_unacceptable_protocol_version" +#define POCKETJS_NETWORK_V1_ERROR_MQTT_IDENTIFIER_REJECTED_NAME "mqtt_identifier_rejected" +#define POCKETJS_NETWORK_V1_ERROR_MQTT_SERVER_UNAVAILABLE_NAME "mqtt_server_unavailable" +#define POCKETJS_NETWORK_V1_ERROR_MQTT_BAD_CREDENTIALS_NAME "mqtt_bad_credentials" +#define POCKETJS_NETWORK_V1_ERROR_MQTT_NOT_AUTHORIZED_NAME "mqtt_not_authorized" +#define POCKETJS_NETWORK_V1_ERROR_MQTT_PROTOCOL_ERROR_NAME "mqtt_protocol_error" +#define POCKETJS_NETWORK_V1_ERROR_MESSAGE_TOO_LARGE_NAME "message_too_large" +#define POCKETJS_NETWORK_V1_ERROR_SYSTEM_ERROR_NAME "system_error" + +/** The command was accepted and any borrowed input was copied. */ +#define POCKETJS_NETWORK_V1_DISPATCH_ACCEPTED ((pocketjs_network_v1_dispatch_status_t)UINT16_C(1)) +/** The synchronous control command completed. */ +#define POCKETJS_NETWORK_V1_DISPATCH_COMPLETED ((pocketjs_network_v1_dispatch_status_t)UINT16_C(2)) +/** The command was refused before asynchronous work began. */ +#define POCKETJS_NETWORK_V1_DISPATCH_REFUSED ((pocketjs_network_v1_dispatch_status_t)UINT16_C(3)) + +/** One completion was removed within the remaining byte budget. */ +#define POCKETJS_NETWORK_V1_COMPLETION_POLL_ITEM ((pocketjs_network_v1_completion_poll_status_t)UINT16_C(1)) +/** No immediately deliverable completion remains. */ +#define POCKETJS_NETWORK_V1_COMPLETION_POLL_DRAINED ((pocketjs_network_v1_completion_poll_status_t)UINT16_C(2)) +/** The next completion remains queued because its payload exceeds the remaining budget. */ +#define POCKETJS_NETWORK_V1_COMPLETION_POLL_BUDGET_EXHAUSTED ((pocketjs_network_v1_completion_poll_status_t)UINT16_C(3)) + +/** Custom CA bytes attached to a start command. */ +#define POCKETJS_NETWORK_V1_BORROWED_INPUT_CUSTOM_CA ((pocketjs_network_v1_borrowed_input_kind_t)UINT16_C(1)) +/** Body bytes attached to BODY_CHUNK. */ +#define POCKETJS_NETWORK_V1_BORROWED_INPUT_BODY_CHUNK ((pocketjs_network_v1_borrowed_input_kind_t)UINT16_C(2)) + +/** HTTP limits and features. */ +#define POCKETJS_NETWORK_V1_LIMIT_PROTOCOL_HTTP ((pocketjs_network_v1_limit_protocol_t)UINT16_C(1)) +/** WebSocket limits and features. */ +#define POCKETJS_NETWORK_V1_LIMIT_PROTOCOL_WEBSOCKET ((pocketjs_network_v1_limit_protocol_t)UINT16_C(2)) +/** MQTT limits and features. */ +#define POCKETJS_NETWORK_V1_LIMIT_PROTOCOL_MQTT ((pocketjs_network_v1_limit_protocol_t)UINT16_C(3)) +/** TCP limits and features. */ +#define POCKETJS_NETWORK_V1_LIMIT_PROTOCOL_TCP ((pocketjs_network_v1_limit_protocol_t)UINT16_C(4)) +/** UDP limits and features. */ +#define POCKETJS_NETWORK_V1_LIMIT_PROTOCOL_UDP ((pocketjs_network_v1_limit_protocol_t)UINT16_C(5)) + +/** Client-role limits and features. */ +#define POCKETJS_NETWORK_V1_LIMIT_ROLE_CLIENT ((pocketjs_network_v1_limit_role_t)UINT16_C(1)) +/** Server-role limits and features. */ +#define POCKETJS_NETWORK_V1_LIMIT_ROLE_SERVER ((pocketjs_network_v1_limit_role_t)UINT16_C(2)) + +/** Follow redirects inside the HTTP Core. */ +#define POCKETJS_NETWORK_V1_HTTP_REDIRECT_FOLLOW ((pocketjs_network_v1_http_redirect_mode_t)UINT16_C(1)) +/** Publish the redirect response without following. */ +#define POCKETJS_NETWORK_V1_HTTP_REDIRECT_MANUAL ((pocketjs_network_v1_http_redirect_mode_t)UINT16_C(2)) +/** Fail when a redirect response is received. */ +#define POCKETJS_NETWORK_V1_HTTP_REDIRECT_ERROR ((pocketjs_network_v1_http_redirect_mode_t)UINT16_C(3)) + +/** TLS 1.2. */ +#define POCKETJS_NETWORK_V1_TLS_VERSION_V1_2 ((pocketjs_network_v1_tls_version_t)UINT16_C(258)) +/** TLS 1.3. */ +#define POCKETJS_NETWORK_V1_TLS_VERSION_V1_3 ((pocketjs_network_v1_tls_version_t)UINT16_C(259)) + +/** Verify trust, time, usage, and hostname. */ +#define POCKETJS_NETWORK_V1_TLS_VERIFICATION_FULL ((pocketjs_network_v1_tls_verification_t)UINT16_C(1)) +/** Development-only invalid-certificate mode. */ +#define POCKETJS_NETWORK_V1_TLS_VERIFICATION_DEVELOPMENT_INSECURE ((pocketjs_network_v1_tls_verification_t)UINT16_C(2)) + +/** Use the selected TLS provider's revocation policy. */ +#define POCKETJS_NETWORK_V1_TLS_REVOCATION_HOST_DEFAULT ((pocketjs_network_v1_tls_revocation_t)UINT16_C(1)) +/** Require the admitted revocation feature. */ +#define POCKETJS_NETWORK_V1_TLS_REVOCATION_REQUIRED ((pocketjs_network_v1_tls_revocation_t)UINT16_C(2)) + +/** Do not request a client certificate. */ +#define POCKETJS_NETWORK_V1_CLIENT_CERTIFICATE_NONE ((pocketjs_network_v1_client_certificate_mode_t)UINT16_C(1)) +/** Accept a client certificate when supplied. */ +#define POCKETJS_NETWORK_V1_CLIENT_CERTIFICATE_OPTIONAL ((pocketjs_network_v1_client_certificate_mode_t)UINT16_C(2)) +/** Require an admitted client certificate. */ +#define POCKETJS_NETWORK_V1_CLIENT_CERTIFICATE_REQUIRED ((pocketjs_network_v1_client_certificate_mode_t)UINT16_C(3)) + +/** Deliver an ordinary NetworkServiceTurn. */ +#define POCKETJS_NETWORK_V1_SERVICE_TURN_KIND_NETWORK ((pocketjs_network_v1_service_turn_kind_t)UINT16_C(1)) +/** Deliver bounded network cancellation in ShutdownTurn. */ +#define POCKETJS_NETWORK_V1_SERVICE_TURN_KIND_SHUTDOWN ((pocketjs_network_v1_service_turn_kind_t)UINT16_C(2)) + +/** No immediately deliverable network work remains. */ +#define POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_DRAINED ((pocketjs_network_v1_service_turn_status_t)UINT16_C(1)) +/** Work remains and the Host must keep service ready. */ +#define POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_MORE_READY ((pocketjs_network_v1_service_turn_status_t)UINT16_C(2)) + +/** The Core owns a lease referenced by a completion. */ +#define POCKETJS_NETWORK_V1_LEASE_STATE_QUEUED ((pocketjs_network_v1_lease_state_t)UINT16_C(1)) +/** The owner-thread Guest Binding claimed the lease. */ +#define POCKETJS_NETWORK_V1_LEASE_STATE_TAKEN ((pocketjs_network_v1_lease_state_t)UINT16_C(2)) +/** The lease returned to the native pool. */ +#define POCKETJS_NETWORK_V1_LEASE_STATE_RELEASED ((pocketjs_network_v1_lease_state_t)UINT16_C(3)) + +/** Claim a queued lease. */ +#define POCKETJS_NETWORK_V1_LEASE_ACTION_TAKE ((pocketjs_network_v1_lease_action_t)UINT16_C(1)) +/** Release a taken lease. */ +#define POCKETJS_NETWORK_V1_LEASE_ACTION_RELEASE ((pocketjs_network_v1_lease_action_t)UINT16_C(2)) +/** Native stale/cancel teardown releases an untaken lease. */ +#define POCKETJS_NETWORK_V1_LEASE_ACTION_CLEANUP ((pocketjs_network_v1_lease_action_t)UINT16_C(3)) + +/** A zero/zero handle is absent. A live handle has non-zero id and generation. */ +typedef struct pocketjs_network_v1_handle { + uint32_t id; + uint32_t generation; +} pocketjs_network_v1_handle_t; + +/** Identity carried by every command accepted by the native adapter. */ +typedef struct pocketjs_network_v1_command_identity { + uint32_t runtime_generation; + pocketjs_network_v1_handle_t resource; + pocketjs_network_v1_handle_t operation; + pocketjs_network_v1_handle_t body; + uint64_t command_sequence; +} pocketjs_network_v1_command_identity_t; + +/** Identity carried by every Core-to-Guest completion. */ +typedef struct pocketjs_network_v1_completion_identity { + uint32_t runtime_generation; + pocketjs_network_v1_handle_t resource; + pocketjs_network_v1_handle_t operation; + pocketjs_network_v1_handle_t body; + uint64_t sequence; +} pocketjs_network_v1_completion_identity_t; + +/** A completion advertises this descriptor; payload bytes remain native. */ +typedef struct pocketjs_network_v1_lease_descriptor { + uint32_t runtime_generation; + pocketjs_network_v1_handle_t lease; + uint32_t byte_length; +} pocketjs_network_v1_lease_descriptor_t; + +/** + * Borrowed input is valid only during the synchronous adapter call. The + * adapter copies exactly this window to an owned BufferLease before it + * returns POCKETJS_NETWORK_V1_DISPATCH_ACCEPTED. + */ +typedef struct pocketjs_network_v1_borrowed_input_view { + pocketjs_network_v1_borrowed_input_kind_t kind; + uint16_t reserved_zero; + const uint8_t *data; + uint32_t byte_length; +} pocketjs_network_v1_borrowed_input_view_t; + +/** Writable Guest memory borrowed only for BUFFER_LEASE_READ_INTO. */ +typedef struct pocketjs_network_v1_borrowed_output_view { + uint8_t *data; + uint32_t byte_length; +} pocketjs_network_v1_borrowed_output_view_t; + +/** Refused carries non-zero category/code; Accepted/Completed carry zeros. */ +typedef struct pocketjs_network_v1_dispatch_result { + pocketjs_network_v1_dispatch_status_t status; + pocketjs_network_v1_error_category_t error_category; + pocketjs_network_v1_error_code_t error_code; + uint16_t reserved_zero; +} pocketjs_network_v1_dispatch_result_t; + +/** Remaining byte credit passed to one completion dequeue attempt. */ +typedef struct pocketjs_network_v1_completion_poll_request { + uint32_t runtime_generation; + uint32_t max_payload_bytes; +} pocketjs_network_v1_completion_poll_request_t; + +/** ITEM reports the selected completion's entire advertised payload size. */ +typedef struct pocketjs_network_v1_completion_poll_result { + pocketjs_network_v1_completion_poll_status_t status; + uint16_t reserved_zero; + uint32_t payload_bytes_delivered; +} pocketjs_network_v1_completion_poll_result_t; + +/** + * Mount handshake view. feature_ids are strictly increasing and describe + * exactly the true network feature projection of the verified Build Plan. + * plan_hash is the 32-byte digest portion of the sha256: planHash. + */ +typedef struct pocketjs_network_v1_handshake_view { + uint16_t abi_major; + uint16_t abi_minor; + uint32_t runtime_generation; + const pocketjs_network_v1_feature_id_t *feature_ids; + uint16_t feature_count; + uint16_t reserved_zero; + uint8_t plan_hash[POCKETJS_NETWORK_V1_PLAN_HASH_BYTES]; +} pocketjs_network_v1_handshake_view_t; + +/** Zero protocol/role selects the build-wide dimension. */ +typedef struct pocketjs_network_v1_limits_query { + uint32_t runtime_generation; + pocketjs_network_v1_limit_protocol_t protocol; + pocketjs_network_v1_limit_role_t role; +} pocketjs_network_v1_limits_query_t; + +/** One immutable effective limit entry returned by the Host. */ +typedef struct pocketjs_network_v1_limit_entry_view { + const char *name; + uint16_t name_length; + uint16_t reserved_zero; + uint64_t default_value; + uint64_t hard_value; + uint64_t minimum_value; +} pocketjs_network_v1_limit_entry_view_t; + +/** Borrowed synchronous view for the ABI 1.1 getLimits method. */ +typedef struct pocketjs_network_v1_limits_snapshot_view { + uint32_t runtime_generation; + pocketjs_network_v1_limit_protocol_t protocol; + pocketjs_network_v1_limit_role_t role; + const pocketjs_network_v1_limit_entry_view_t *values; + uint16_t value_count; + const pocketjs_network_v1_feature_id_t *feature_ids; + uint16_t feature_count; +} pocketjs_network_v1_limits_snapshot_view_t; + +/** Host-to-Guest budget for one registered service-dispatcher invocation. */ +typedef struct pocketjs_network_v1_service_turn_request { + uint32_t runtime_generation; + uint64_t turn_id; + pocketjs_network_v1_service_turn_kind_t kind; + uint16_t reserved_zero; + uint32_t max_events; + uint32_t max_payload_bytes; +} pocketjs_network_v1_service_turn_request_t; + +/** Guest result; counts cannot exceed the request and sequence is monotonic. */ +typedef struct pocketjs_network_v1_service_turn_result { + pocketjs_network_v1_service_turn_status_t status; + uint16_t reserved_zero; + uint32_t events_delivered; + uint32_t payload_bytes_delivered; + uint64_t last_sequence; +} pocketjs_network_v1_service_turn_result_t; + +static inline int pocketjs_network_v1_handle_is_absent( + pocketjs_network_v1_handle_t handle) { + return handle.id == POCKETJS_NETWORK_V1_ABSENT_ID && + handle.generation == POCKETJS_NETWORK_V1_ABSENT_ID; +} + +static inline int pocketjs_network_v1_handle_is_live( + pocketjs_network_v1_handle_t handle) { + return handle.id != POCKETJS_NETWORK_V1_ABSENT_ID && + handle.generation != POCKETJS_NETWORK_V1_ABSENT_ID; +} + +#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L +_Static_assert(sizeof(pocketjs_network_v1_handle_t) == 8, "network v1 handle layout"); +_Static_assert(sizeof(uint64_t) == 8, "network v1 requires uint64_t"); +#endif + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* POCKETJS_NETWORK_V1_ABI_H */ diff --git a/contracts/spec/network/network-v1.test.ts b/contracts/spec/network/network-v1.test.ts new file mode 100644 index 00000000..6c887f17 --- /dev/null +++ b/contracts/spec/network/network-v1.test.ts @@ -0,0 +1,787 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + generateNetworkV1Header, + generateNetworkV1TypeScript, + NETWORK_V1_HEADER_PATH, + NETWORK_V1_TYPESCRIPT_PATH, + validateNetworkV1Definition, +} from "./generate.ts"; +import { + assertNetworkV1BorrowedInput, + assertNetworkV1BindingTable, + assertNetworkV1CommandIdentity, + assertNetworkV1CompletionIdentity, + assertNetworkV1CompletionPollResult, + assertNetworkV1Handle, + assertNetworkV1Handshake, + assertNetworkV1LeaseDescriptor, + assertNetworkV1LeaseReadInto, + assertNetworkV1NextSequence, + assertNetworkV1RuntimeGeneration, + assertNetworkV1Sequence, + assertNetworkV1ServiceTurnRequest, + assertNetworkV1ServiceTurnResult, + NETWORK_V1_ABI_MAJOR, + NETWORK_V1_ABI_MINOR, + NETWORK_V1_ABSENT_HANDLE, + NETWORK_V1_COMMAND_OPCODES, + NETWORK_V1_ERROR_CODES, + NETWORK_V1_EVENT_CODES, + NETWORK_V1_FEATURE_IDS, + NETWORK_V1_INITIAL_BODY_FLOW, + NETWORK_V1_LIMIT_ENTRY_MAX, + NETWORK_V1_LIMIT_PROTOCOL_ANY, + NETWORK_V1_LIMIT_ROLE_ANY, + NETWORK_V1_SEQUENCE_MAX, + NETWORK_V1_UINT32_MAX, + networkV1ApplyBodySignal, + networkV1FeatureIdsFromBuildPlan, + networkV1HandleIsAbsent, + networkV1IdentityMatchesGeneration, + networkV1LeaseTransition, + networkV1NextGeneration, + networkV1PlanHashBytes, + snapshotNetworkV1Limits, + NetworkV1BorrowedInputKind, + NetworkV1CommandOpcode, + NetworkV1CompletionPollStatus, + NetworkV1EventCode, + NetworkV1FeatureId, + NetworkV1LeaseAction, + NetworkV1LeaseState, + NetworkV1LimitProtocol, + NetworkV1LimitRole, + NetworkV1ServiceTurnKind, + NetworkV1ServiceTurnStatus, + type NetworkV1CommandIdentity, + type NetworkV1CompletionIdentity, + type NetworkV1Handshake, + type NetworkV1LimitEntry, + type NetworkV1LimitsQuery, +} from "./network-v1.ts"; + +const ZERO_HASH = `sha256:${"00".repeat(32)}`; +const ONE_HASH = `sha256:${"01".repeat(32)}`; + +function live(id = 1, generation = 1) { + return { id, generation } as const; +} + +function commandIdentity( + overrides: Partial = {}, +): NetworkV1CommandIdentity { + return { + runtimeGeneration: 1, + resource: live(1, 2), + operation: live(2, 3), + body: live(3, 4), + commandSequence: 1, + ...overrides, + }; +} + +function completionIdentity( + overrides: Partial = {}, +): NetworkV1CompletionIdentity { + return { + runtimeGeneration: 1, + resource: live(1, 2), + operation: live(2, 3), + body: live(3, 4), + sequence: 1, + ...overrides, + }; +} + +function handshake(overrides: Partial = {}): NetworkV1Handshake { + return { + abiMajor: NETWORK_V1_ABI_MAJOR, + abiMinor: NETWORK_V1_ABI_MINOR, + runtimeGeneration: 1, + planHash: networkV1PlanHashBytes(ZERO_HASH), + featureIds: [NetworkV1FeatureId.HttpClient, NetworkV1FeatureId.HttpClientTls], + ...overrides, + }; +} + +function expectStrictlyIncreasing(values: readonly number[]): void { + expect(values.length).toBeGreaterThan(0); + expect(new Set(values).size).toBe(values.length); + expect(values[0]).toBeGreaterThan(0); + for (let index = 1; index < values.length; index += 1) { + expect(values[index]!).toBeGreaterThan(values[index - 1]!); + } +} + +function frozenLimits( + query: NetworkV1LimitsQuery, + values: readonly NetworkV1LimitEntry[], + featureIds: readonly NetworkV1FeatureId[], +) { + return Object.freeze({ + runtimeGeneration: query.runtimeGeneration, + protocol: query.protocol, + role: query.role, + values: Object.freeze(values.map((entry) => Object.freeze({ ...entry }))), + featureIds: Object.freeze([...featureIds]), + }); +} + +describe("network private ABI code generation", () => { + test("regenerates the committed TypeScript and C header byte-for-byte", async () => { + validateNetworkV1Definition(); + const [typescript, header] = await Promise.all([ + Bun.file(NETWORK_V1_TYPESCRIPT_PATH).text(), + Bun.file(NETWORK_V1_HEADER_PATH).text(), + ]); + expect(typescript).toBe(generateNetworkV1TypeScript()); + expect(header).toBe(generateNetworkV1Header()); + }); + + test("keeps every numeric namespace unique, non-zero, and append-only ordered", () => { + expectStrictlyIncreasing(NETWORK_V1_FEATURE_IDS); + expectStrictlyIncreasing(NETWORK_V1_COMMAND_OPCODES); + expectStrictlyIncreasing(NETWORK_V1_EVENT_CODES); + expectStrictlyIncreasing(NETWORK_V1_ERROR_CODES); + + expect(NetworkV1CommandOpcode.OperationCancel).toBe(0x0001); + expect(NetworkV1CommandOpcode.BufferLeaseTake).toBe(0x0002); + expect(NetworkV1CommandOpcode.BufferLeaseReadInto).toBe(0x0003); + expect(NetworkV1CommandOpcode.BufferLeaseRelease).toBe(0x0004); + expect(NetworkV1CommandOpcode.HttpRequestStart).toBe(0x0100); + expect(NetworkV1EventCode.HttpResponseHeaders).toBe(0x0100); + expect(NetworkV1EventCode.HttpRequestError).toBe(0x0101); + + for (const name of ["BodyPull", "BodyChunk", "BodyEnd", "BodyError", "BodyCancel"] as const) { + expect(NetworkV1CommandOpcode[name]).toBe(NetworkV1EventCode[name]); + } + }); + + test("compiles the generated native header as strict C11", async () => { + const directory = await mkdtemp(join(tmpdir(), "pocketjs-network-abi-")); + try { + const source = join(directory, "abi_smoke.c"); + const output = join(directory, "abi_smoke"); + await Bun.write(source, ` +#include "pocketjs_network_v1_abi.h" + +int main(void) { + pocketjs_network_v1_handle_t absent = {0}; + pocketjs_network_v1_handle_t live = {1, 1}; + pocketjs_network_v1_service_turn_request_t turn = {0}; + turn.runtime_generation = 1; + turn.turn_id = 1; + turn.kind = POCKETJS_NETWORK_V1_SERVICE_TURN_KIND_NETWORK; + turn.max_events = 1; + turn.max_payload_bytes = 1; + if (!pocketjs_network_v1_handle_is_absent(absent)) return 1; + if (!pocketjs_network_v1_handle_is_live(live)) return 2; + pocketjs_network_v1_limits_query_t query = {0}; + pocketjs_network_v1_limit_entry_view_t entry = {0}; + pocketjs_network_v1_limits_snapshot_view_t limits = {0}; + query.runtime_generation = 1; + query.protocol = POCKETJS_NETWORK_V1_LIMIT_PROTOCOL_HTTP; + query.role = POCKETJS_NETWORK_V1_LIMIT_ROLE_CLIENT; + entry.name = "http.headerBytes"; + entry.name_length = 16; + limits.runtime_generation = query.runtime_generation; + limits.protocol = query.protocol; + limits.role = query.role; + limits.values = &entry; + limits.value_count = 1; + if (POCKETJS_NETWORK_V1_ABI_MAJOR != 1 || POCKETJS_NETWORK_V1_ABI_MINOR != 1) return 3; + if (POCKETJS_NETWORK_V1_COMMAND_BODY_PULL != POCKETJS_NETWORK_V1_EVENT_BODY_PULL) return 4; + return turn.kind == 0 || limits.value_count != 1; +} +`); + const includeDirectory = dirname(NETWORK_V1_HEADER_PATH); + const compiled = Bun.spawnSync([ + process.env.CC ?? "cc", + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-I", + includeDirectory, + source, + "-o", + output, + ]); + expect(compiled.exitCode).toBe(0); + expect(compiled.stderr.toString()).toBe(""); + expect(Bun.spawnSync([output]).exitCode).toBe(0); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +describe("network v1 mount handshake", () => { + test("accepts only the exact plan hash and sorted feature projection", () => { + const expected = handshake(); + expect(() => assertNetworkV1Handshake(expected, handshake())).not.toThrow(); + expect(() => assertNetworkV1Handshake(expected, handshake({ abiMinor: 1 }))).not.toThrow(); + + expect(() => assertNetworkV1Handshake(expected, handshake({ abiMajor: 2 }))).toThrow( + "incompatible ABI version", + ); + expect(() => assertNetworkV1Handshake(expected, handshake({ runtimeGeneration: 2 }))).toThrow( + "different runtime generation", + ); + expect(() => assertNetworkV1Handshake( + expected, + handshake({ planHash: networkV1PlanHashBytes(ONE_HASH) }), + )).toThrow("Build Plan hash"); + expect(() => assertNetworkV1Handshake( + expected, + handshake({ featureIds: [NetworkV1FeatureId.HttpClient] }), + )).toThrow("feature set"); + expect(() => assertNetworkV1Handshake( + expected, + handshake({ + featureIds: [NetworkV1FeatureId.HttpClientTls, NetworkV1FeatureId.HttpClient], + }), + )).toThrow("strictly increasing"); + expect(() => assertNetworkV1Handshake( + expected, + handshake({ + featureIds: [NetworkV1FeatureId.HttpClient, NetworkV1FeatureId.HttpClient], + }), + )).toThrow("strictly increasing"); + expect(() => assertNetworkV1Handshake( + expected, + handshake({ featureIds: [0xffff as NetworkV1FeatureId] }), + )).toThrow("not defined"); + }); + + test("snapshots each Host handshake field once before validation", () => { + const reads = new Map(); + const values = handshake(); + const hostile = Object.defineProperties({}, { + abiMajor: { get: () => { reads.set("abiMajor", (reads.get("abiMajor") ?? 0) + 1); return values.abiMajor; } }, + abiMinor: { get: () => { reads.set("abiMinor", (reads.get("abiMinor") ?? 0) + 1); return values.abiMinor; } }, + runtimeGeneration: { get: () => { reads.set("runtimeGeneration", (reads.get("runtimeGeneration") ?? 0) + 1); return values.runtimeGeneration; } }, + planHash: { get: () => { reads.set("planHash", (reads.get("planHash") ?? 0) + 1); return values.planHash; } }, + featureIds: { get: () => { reads.set("featureIds", (reads.get("featureIds") ?? 0) + 1); return values.featureIds; } }, + }) as NetworkV1Handshake; + expect(() => assertNetworkV1Handshake(handshake(), hostile)).not.toThrow(); + expect(Object.fromEntries(reads)).toEqual({ + abiMajor: 1, + abiMinor: 1, + runtimeGeneration: 1, + planHash: 1, + featureIds: 1, + }); + }); + + test("uses typed-array intrinsic slots without invoking a hostile iterator", () => { + let byteLengthReads = 0; + let iteratorCalls = 0; + const planHash = new Uint8Array(32); + Object.defineProperties(planHash, { + byteLength: { get: () => { byteLengthReads += 1; return 1; } }, + [Symbol.iterator]: { + value: () => { iteratorCalls += 1; throw new Error("iterator must not run"); }, + }, + }); + const actual = handshake({ planHash }); + expect(() => assertNetworkV1Handshake(handshake(), actual)).not.toThrow(); + expect(byteLengthReads).toBe(0); + expect(iteratorCalls).toBe(0); + }); + + test("bounds handshake feature arrays before copying them", () => { + const tooMany = Array.from( + { length: NETWORK_V1_FEATURE_IDS.length + 1 }, + () => NetworkV1FeatureId.HttpClient, + ); + expect(() => assertNetworkV1Handshake( + handshake(), + handshake({ featureIds: tooMany }), + )).toThrow("known feature count"); + }); + + test("requires a frozen accessor-free binding table before mount", () => { + const expected = handshake(); + const actualHandshake = Object.freeze({ + ...handshake(), + featureIds: Object.freeze([...handshake().featureIds]), + }); + const method = () => undefined; + const table = Object.freeze({ + handshake: actualHandshake, + getLimits: method, + dispatch: method, + nextCompletion: method, + leaseTake: method, + leaseReadInto: method, + leaseRelease: method, + registerServiceDispatcher: method, + }); + expect(() => assertNetworkV1BindingTable(table, expected)).not.toThrow(); + expect(() => assertNetworkV1BindingTable({ ...table }, expected)).toThrow("frozen"); + + const accessorTable = Object.freeze(Object.defineProperties({}, { + ...Object.fromEntries(Object.entries(table).map(([key, field]) => [key, { + value: field, + enumerable: true, + }])), + dispatch: { get: () => method, enumerable: true }, + })); + expect(() => assertNetworkV1BindingTable(accessorTable, expected)).toThrow( + "own frozen data property", + ); + }); + + test("projects only known enabled network Build Plan features in numeric order", () => { + expect(networkV1FeatureIdsFromBuildPlan({ + "network.http.client.tls": true, + "ui.core": true, + "network.http.client": true, + "network.udp": false, + })).toEqual([ + NetworkV1FeatureId.HttpClient, + NetworkV1FeatureId.HttpClientTls, + ]); + expect(() => networkV1FeatureIdsFromBuildPlan({ + "network.future.not-admitted": true, + })).toThrow("unknown capability"); + }); + + test("decodes only canonical SHA-256 Build Plan hashes", () => { + expect(networkV1PlanHashBytes(ZERO_HASH)).toEqual(new Uint8Array(32)); + expect(networkV1PlanHashBytes(ONE_HASH)).toEqual(new Uint8Array(32).fill(1)); + expect(() => networkV1PlanHashBytes("sha256:00")).toThrow("lowercase sha256"); + expect(() => networkV1PlanHashBytes(`sha256:${"AA".repeat(32)}`)).toThrow( + "lowercase sha256", + ); + }); +}); + +describe("network v1.1 admitted limits snapshot", () => { + const query = Object.freeze({ + runtimeGeneration: 1, + protocol: NetworkV1LimitProtocol.Http, + role: NetworkV1LimitRole.Client, + }); + const admittedHandshake = handshake({ + featureIds: [ + NetworkV1FeatureId.HttpClient, + NetworkV1FeatureId.HttpClientTls, + NetworkV1FeatureId.WebSocketServer, + NetworkV1FeatureId.Udp, + ], + }); + const values = [ + { name: "http.headerBytes", default: 8192, hard: 16384, minimum: 4096 }, + { name: "runtime.connections", default: 4, hard: 8, minimum: 2 }, + ] as const; + + test("returns a detached frozen snapshot with the exact scoped features", () => { + const source = frozenLimits(query, values, [ + NetworkV1FeatureId.HttpClient, + NetworkV1FeatureId.HttpClientTls, + ]); + const snapshot = snapshotNetworkV1Limits(query, source, admittedHandshake); + expect(snapshot).toEqual(source); + expect(snapshot).not.toBe(source); + expect(snapshot.values[0]).not.toBe(source.values[0]); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.values)).toBe(true); + expect(Object.isFrozen(snapshot.values[0])).toBe(true); + expect(Object.isFrozen(snapshot.featureIds)).toBe(true); + }); + + test("requires a build-wide query to return the complete handshake feature set", () => { + const globalQuery = Object.freeze({ + runtimeGeneration: 1, + protocol: NETWORK_V1_LIMIT_PROTOCOL_ANY, + role: NETWORK_V1_LIMIT_ROLE_ANY, + }); + const source = frozenLimits(globalQuery, values, admittedHandshake.featureIds); + expect(snapshotNetworkV1Limits(globalQuery, source, admittedHandshake).featureIds) + .toEqual(admittedHandshake.featureIds); + expect(() => snapshotNetworkV1Limits( + globalQuery, + frozenLimits(globalQuery, values, [NetworkV1FeatureId.HttpClient]), + admittedHandshake, + )).toThrow("exact scoped feature set"); + }); + + test("rejects wrong scope, stale runtime, and feature escalation", () => { + const valid = frozenLimits(query, values, [ + NetworkV1FeatureId.HttpClient, + NetworkV1FeatureId.HttpClientTls, + ]); + expect(() => snapshotNetworkV1Limits( + { ...query, runtimeGeneration: 2 }, + valid, + admittedHandshake, + )).toThrow("mounted runtime"); + expect(() => snapshotNetworkV1Limits( + { ...query, protocol: 0xffff as NetworkV1LimitProtocol }, + valid, + admittedHandshake, + )).toThrow("protocol is unknown"); + expect(() => snapshotNetworkV1Limits( + query, + Object.freeze({ ...valid, role: NetworkV1LimitRole.Server }), + admittedHandshake, + )).toThrow("does not echo"); + expect(() => snapshotNetworkV1Limits( + query, + frozenLimits(query, values, [ + NetworkV1FeatureId.HttpClient, + NetworkV1FeatureId.HttpClientTls, + NetworkV1FeatureId.WebSocketServer, + ]), + admittedHandshake, + )).toThrow("exact scoped feature set"); + }); + + test("requires bounded sorted limits with minimum <= default <= hard", () => { + const features = [ + NetworkV1FeatureId.HttpClient, + NetworkV1FeatureId.HttpClientTls, + ] as const; + expect(() => snapshotNetworkV1Limits( + query, + frozenLimits(query, [...values].reverse(), features), + admittedHandshake, + )).toThrow("lexicographically sorted"); + expect(() => snapshotNetworkV1Limits( + query, + frozenLimits(query, [{ + name: "http.headerBytes", + default: 2, + hard: 1, + minimum: 1, + }], features), + admittedHandshake, + )).toThrow("minimum <= default <= hard"); + expect(() => snapshotNetworkV1Limits( + query, + frozenLimits(query, Array.from({ length: NETWORK_V1_LIMIT_ENTRY_MAX + 1 }, (_, index) => ({ + name: `runtime.limit${String(index).padStart(2, "0")}`, + default: 1, + hard: 1, + minimum: 1, + })), features), + admittedHandshake, + )).toThrow(`cannot exceed ${NETWORK_V1_LIMIT_ENTRY_MAX}`); + }); + + test("does not invoke Host accessors while validating limits", () => { + let calls = 0; + const hostile = Object.freeze(Object.defineProperties({}, { + runtimeGeneration: { value: 1, enumerable: true }, + protocol: { value: query.protocol, enumerable: true }, + role: { value: query.role, enumerable: true }, + values: { get: () => { calls += 1; return []; }, enumerable: true }, + featureIds: { + value: Object.freeze([ + NetworkV1FeatureId.HttpClient, + NetworkV1FeatureId.HttpClientTls, + ]), + enumerable: true, + }, + })); + expect(() => snapshotNetworkV1Limits(query, hostile, admittedHandshake)).toThrow( + "own frozen data property", + ); + expect(calls).toBe(0); + }); +}); + +describe("network v1 generation identities", () => { + test("distinguishes absent handles from every live generation", () => { + expect(networkV1HandleIsAbsent(NETWORK_V1_ABSENT_HANDLE)).toBe(true); + expect(() => assertNetworkV1Handle(NETWORK_V1_ABSENT_HANDLE)).not.toThrow(); + expect(() => assertNetworkV1Handle(NETWORK_V1_ABSENT_HANDLE, "handle", false)).toThrow( + "must be live", + ); + expect(() => assertNetworkV1Handle(live(NETWORK_V1_UINT32_MAX, NETWORK_V1_UINT32_MAX), "handle", false)).not.toThrow(); + expect(() => assertNetworkV1Handle({ id: 1, generation: 0 })).toThrow("zero/zero"); + expect(() => assertNetworkV1Handle({ id: 0, generation: 1 })).toThrow("zero/zero"); + expect(() => assertNetworkV1Handle({ id: NETWORK_V1_UINT32_MAX + 1, generation: 1 })).toThrow( + "integer", + ); + }); + + test("pins runtime, resource, operation, and body generations on every envelope", () => { + expect(() => assertNetworkV1CommandIdentity(commandIdentity())).not.toThrow(); + expect(() => assertNetworkV1CompletionIdentity(completionIdentity())).not.toThrow(); + expect(networkV1IdentityMatchesGeneration(completionIdentity(), { + runtimeGeneration: 1, + resource: live(1, 2), + operation: live(2, 3), + body: live(3, 4), + })).toBe(true); + expect(networkV1IdentityMatchesGeneration(completionIdentity(), { + runtimeGeneration: 2, + })).toBe(false); + expect(networkV1IdentityMatchesGeneration(completionIdentity(), { + runtimeGeneration: 1, + resource: live(1, 3), + })).toBe(false); + expect(networkV1IdentityMatchesGeneration(completionIdentity(), { + runtimeGeneration: 1, + operation: live(2, 4), + })).toBe(false); + expect(networkV1IdentityMatchesGeneration(completionIdentity(), { + runtimeGeneration: 1, + body: live(3, 5), + })).toBe(false); + }); + + test("fails closed at generation and safe-sequence boundaries", () => { + expect(() => assertNetworkV1RuntimeGeneration(1)).not.toThrow(); + expect(() => assertNetworkV1RuntimeGeneration(NETWORK_V1_UINT32_MAX)).not.toThrow(); + expect(() => assertNetworkV1RuntimeGeneration(0)).toThrow(); + expect(() => assertNetworkV1RuntimeGeneration(NETWORK_V1_UINT32_MAX + 1)).toThrow(); + expect(networkV1NextGeneration(1)).toBe(2); + expect(networkV1NextGeneration(NETWORK_V1_UINT32_MAX - 1)).toBe( + NETWORK_V1_UINT32_MAX, + ); + expect(() => networkV1NextGeneration(NETWORK_V1_UINT32_MAX)).toThrow(); + expect(() => assertNetworkV1Sequence(NETWORK_V1_SEQUENCE_MAX)).not.toThrow(); + expect(() => assertNetworkV1Sequence(NETWORK_V1_SEQUENCE_MAX + 1)).toThrow(); + expect(() => assertNetworkV1NextSequence( + NETWORK_V1_SEQUENCE_MAX - 1, + NETWORK_V1_SEQUENCE_MAX, + )).not.toThrow(); + expect(() => assertNetworkV1NextSequence(7, 7)).toThrow("strictly monotonic"); + }); +}); + +describe("network v1 BufferLease and body flow", () => { + test("allows only queued-take-release or queued-cleanup ownership paths", () => { + const taken = networkV1LeaseTransition( + NetworkV1LeaseState.Queued, + NetworkV1LeaseAction.Take, + ); + expect(taken).toBe(NetworkV1LeaseState.Taken); + expect(networkV1LeaseTransition(taken, NetworkV1LeaseAction.Release)).toBe( + NetworkV1LeaseState.Released, + ); + expect(networkV1LeaseTransition( + NetworkV1LeaseState.Queued, + NetworkV1LeaseAction.Cleanup, + )).toBe(NetworkV1LeaseState.Released); + expect(() => networkV1LeaseTransition(taken, NetworkV1LeaseAction.Take)).toThrow(); + expect(() => networkV1LeaseTransition( + NetworkV1LeaseState.Released, + NetworkV1LeaseAction.Release, + )).toThrow(); + }); + + test("bounds lease descriptors and owner-thread readInto windows", () => { + expect(() => assertNetworkV1LeaseDescriptor({ + runtimeGeneration: 1, + lease: live(NETWORK_V1_UINT32_MAX, NETWORK_V1_UINT32_MAX), + byteLength: NETWORK_V1_UINT32_MAX, + })).not.toThrow(); + expect(() => assertNetworkV1LeaseDescriptor({ + runtimeGeneration: 1, + lease: live(), + byteLength: 0, + })).toThrow(); + + const readCommand = { + opcode: NetworkV1CommandOpcode.BufferLeaseReadInto, + identity: commandIdentity(), + lease: live(9, 2), + offset: 3, + maxBytes: 4, + } as const; + expect(() => assertNetworkV1LeaseReadInto( + readCommand, + new Uint8Array(4), + 7, + )).not.toThrow(); + expect(() => assertNetworkV1LeaseReadInto( + readCommand, + new Uint8Array(3), + 7, + )).toThrow("exact borrowed Uint8Array window"); + expect(() => assertNetworkV1LeaseReadInto( + { ...readCommand, maxBytes: 5 }, + new Uint8Array(5), + 7, + )).toThrow("beyond the BufferLease"); + }); + + test("requires synchronous borrowed inputs to be non-empty exact windows", () => { + const backing = new Uint8Array([0, 1, 2, 3]); + expect(() => assertNetworkV1BorrowedInput( + { kind: NetworkV1BorrowedInputKind.BodyChunk, bytes: backing.subarray(1, 3) }, + NetworkV1BorrowedInputKind.BodyChunk, + 2, + 2, + )).not.toThrow(); + expect(() => assertNetworkV1BorrowedInput( + { kind: NetworkV1BorrowedInputKind.CustomCa, bytes: new Uint8Array() }, + NetworkV1BorrowedInputKind.CustomCa, + 8, + )).toThrow("integer"); + expect(() => assertNetworkV1BorrowedInput( + { kind: NetworkV1BorrowedInputKind.CustomCa, bytes: new Uint8Array(2) }, + NetworkV1BorrowedInputKind.BodyChunk, + 8, + )).toThrow("does not match"); + expect(() => assertNetworkV1BorrowedInput( + { kind: NetworkV1BorrowedInputKind.CustomCa, bytes: new Uint8Array(2) }, + NetworkV1BorrowedInputKind.CustomCa, + 8, + 3, + )).toThrow("does not match normalized metadata"); + const shadowed = new Uint8Array(4); + Object.defineProperty(shadowed, "byteLength", { get: () => 1 }); + expect(() => assertNetworkV1BorrowedInput( + { kind: NetworkV1BorrowedInputKind.BodyChunk, bytes: shadowed }, + NetworkV1BorrowedInputKind.BodyChunk, + 4, + 1, + )).toThrow("does not match normalized metadata"); + }); + + test("enforces one BODY_PULL credit before each bounded BODY_CHUNK", () => { + const credited = networkV1ApplyBodySignal( + NETWORK_V1_INITIAL_BODY_FLOW, + { kind: "pull", maxBytes: 64 }, + 64, + ); + expect(credited).toEqual({ pendingCredit: 64, terminal: false }); + expect(() => networkV1ApplyBodySignal( + credited, + { kind: "pull", maxBytes: 1 }, + 64, + )).toThrow("overlap"); + expect(() => networkV1ApplyBodySignal( + credited, + { kind: "chunk", byteLength: 65 }, + 64, + )).toThrow(); + const consumed = networkV1ApplyBodySignal( + credited, + { kind: "chunk", byteLength: 64 }, + 64, + ); + expect(consumed).toEqual(NETWORK_V1_INITIAL_BODY_FLOW); + expect(() => networkV1ApplyBodySignal( + consumed, + { kind: "chunk", byteLength: 1 }, + 64, + )).toThrow("requires prior BODY_PULL"); + const terminal = networkV1ApplyBodySignal( + credited, + { kind: "cancel" }, + 64, + ); + expect(terminal).toEqual({ pendingCredit: 0, terminal: true }); + expect(() => networkV1ApplyBodySignal( + terminal, + { kind: "end" }, + 64, + )).toThrow("after terminal"); + }); +}); + +describe("network v1 service turns", () => { + test("dequeues a whole payload only when it fits the remaining byte budget", () => { + const request = { runtimeGeneration: 1, maxPayloadBytes: 4 } as const; + const item = { + status: NetworkV1CompletionPollStatus.Item, + completion: { + eventCode: NetworkV1EventCode.BodyChunk, + identity: completionIdentity(), + payload: { + runtimeGeneration: 1, + lease: live(7, 2), + byteLength: 4, + }, + }, + payloadBytesDelivered: 4, + } as const; + expect(() => assertNetworkV1CompletionPollResult(request, item)).not.toThrow(); + expect(() => assertNetworkV1CompletionPollResult( + { ...request, maxPayloadBytes: 3 }, + item, + )).toThrow("payloadBytesDelivered"); + expect(() => assertNetworkV1CompletionPollResult(request, { + ...item, + completion: { + ...item.completion, + identity: completionIdentity({ runtimeGeneration: 2 }), + }, + })).toThrow("stale runtime generation"); + expect(() => assertNetworkV1CompletionPollResult( + { runtimeGeneration: 1, maxPayloadBytes: 0 }, + { + status: NetworkV1CompletionPollStatus.BudgetExhausted, + payloadBytesDelivered: 0, + }, + )).not.toThrow(); + }); + + test("accepts exact event/byte budgets and reports stable sequence", () => { + const request = { + runtimeGeneration: NETWORK_V1_UINT32_MAX, + turnId: NETWORK_V1_SEQUENCE_MAX, + kind: NetworkV1ServiceTurnKind.Network, + maxEvents: 8, + maxPayloadBytes: 1024, + } as const; + expect(() => assertNetworkV1ServiceTurnRequest(request)).not.toThrow(); + expect(() => assertNetworkV1ServiceTurnResult(request, { + status: NetworkV1ServiceTurnStatus.MoreReady, + eventsDelivered: 8, + payloadBytesDelivered: 1024, + lastSequence: NETWORK_V1_SEQUENCE_MAX, + })).not.toThrow(); + expect(() => assertNetworkV1ServiceTurnResult(request, { + status: NetworkV1ServiceTurnStatus.Drained, + eventsDelivered: 0, + payloadBytesDelivered: 0, + lastSequence: 0, + })).not.toThrow(); + }); + + test("rejects over-budget and internally inconsistent service results", () => { + const request = { + runtimeGeneration: 1, + turnId: 1, + kind: NetworkV1ServiceTurnKind.Shutdown, + maxEvents: 2, + maxPayloadBytes: 4, + } as const; + expect(() => assertNetworkV1ServiceTurnResult(request, { + status: NetworkV1ServiceTurnStatus.Drained, + eventsDelivered: 3, + payloadBytesDelivered: 0, + lastSequence: 3, + })).toThrow("eventsDelivered"); + expect(() => assertNetworkV1ServiceTurnResult(request, { + status: NetworkV1ServiceTurnStatus.Drained, + eventsDelivered: 1, + payloadBytesDelivered: 5, + lastSequence: 1, + })).toThrow("payloadBytesDelivered"); + expect(() => assertNetworkV1ServiceTurnResult(request, { + status: NetworkV1ServiceTurnStatus.Drained, + eventsDelivered: 0, + payloadBytesDelivered: 0, + lastSequence: 1, + })).toThrow("zero sequence"); + expect(() => assertNetworkV1ServiceTurnResult(request, { + status: NetworkV1ServiceTurnStatus.Drained, + eventsDelivered: 1, + payloadBytesDelivered: 0, + lastSequence: 0, + })).toThrow("lastSequence"); + }); +}); diff --git a/contracts/spec/network/network-v1.ts b/contracts/spec/network/network-v1.ts new file mode 100644 index 00000000..f8c0be3e --- /dev/null +++ b/contracts/spec/network/network-v1.ts @@ -0,0 +1,1177 @@ +/** + * Semantic contract for `pocketjs:internal/network-v1`. + * + * This module is private to the version-matched framework and Host. It does + * not grant authority and is not a public package export. The native Core + * still enforces the verified Build Plan and ResolvedNetworkPolicy. + */ + +import { + NETWORK_V1_ABI_MAJOR, + NETWORK_V1_ABI_MINOR, + NETWORK_V1_FEATURE_CAPABILITY_BY_ID, + NETWORK_V1_FEATURE_ID_BY_CAPABILITY, + NETWORK_V1_FEATURE_IDS, + NETWORK_V1_LIMIT_ENTRY_MAX, + NETWORK_V1_LIMIT_NAME_MAX_BYTES, + NETWORK_V1_LIMIT_PROTOCOL_ANY, + NETWORK_V1_LIMIT_ROLE_ANY, + NETWORK_V1_PLAN_HASH_BYTES, + NETWORK_V1_SEQUENCE_MAX, + NETWORK_V1_UINT32_MAX, + NetworkV1BorrowedInputKind, + NetworkV1ClientCertificateMode, + NetworkV1CommandOpcode, + NetworkV1CompletionPollStatus, + NetworkV1DispatchStatus, + NetworkV1ErrorCategory, + NetworkV1ErrorCode, + NetworkV1EventCode, + NetworkV1FeatureId, + NetworkV1HttpRedirectMode, + NetworkV1LeaseAction, + NetworkV1LeaseState, + NetworkV1LimitProtocol, + NetworkV1LimitRole, + NetworkV1ServiceTurnKind, + NetworkV1ServiceTurnStatus, + NetworkV1TlsRevocation, + NetworkV1TlsVerification, + NetworkV1TlsVersion, +} from "./generated/network-v1.ts"; + +export * from "./generated/network-v1.ts"; + +export interface NetworkV1Handle { + readonly id: number; + readonly generation: number; +} + +export const NETWORK_V1_ABSENT_HANDLE: Readonly = + Object.freeze({ id: 0, generation: 0 }); + +export interface NetworkV1CommandIdentity { + readonly runtimeGeneration: number; + readonly resource: NetworkV1Handle; + readonly operation: NetworkV1Handle; + readonly body: NetworkV1Handle; + /** Monotonic within one runtime generation; zero is never emitted. */ + readonly commandSequence: number; +} + +export interface NetworkV1CompletionIdentity { + readonly runtimeGeneration: number; + readonly resource: NetworkV1Handle; + readonly operation: NetworkV1Handle; + readonly body: NetworkV1Handle; + /** Core-assigned global merge order; zero is never emitted. */ + readonly sequence: number; +} + +export interface NetworkV1GenerationSnapshot { + readonly runtimeGeneration: number; + readonly resource?: NetworkV1Handle; + readonly operation?: NetworkV1Handle; + readonly body?: NetworkV1Handle; +} + +export interface NetworkV1Handshake { + readonly abiMajor: number; + readonly abiMinor: number; + readonly runtimeGeneration: number; + /** Raw 32-byte digest from the verified `sha256:` Build Plan hash. */ + readonly planHash: Uint8Array; + /** Strictly increasing exact projection of true `network.*` plan features. */ + readonly featureIds: readonly NetworkV1FeatureId[]; +} + +export type NetworkV1LimitProtocolQuery = + | typeof NETWORK_V1_LIMIT_PROTOCOL_ANY + | NetworkV1LimitProtocol; + +export type NetworkV1LimitRoleQuery = + | typeof NETWORK_V1_LIMIT_ROLE_ANY + | NetworkV1LimitRole; + +export interface NetworkV1LimitsQuery { + readonly runtimeGeneration: number; + /** Zero selects every protocol in the admitted build. */ + readonly protocol: NetworkV1LimitProtocolQuery; + /** Zero selects every role in the admitted build. */ + readonly role: NetworkV1LimitRoleQuery; +} + +export interface NetworkV1LimitEntry { + /** Stable dotted identifier, unique and lexicographically sorted. */ + readonly name: string; + readonly default: number; + readonly hard: number; + readonly minimum: number; +} + +export interface NetworkV1LimitsSnapshot { + readonly runtimeGeneration: number; + readonly protocol: NetworkV1LimitProtocolQuery; + readonly role: NetworkV1LimitRoleQuery; + readonly values: readonly NetworkV1LimitEntry[]; + /** Exact scoped subset of the mount handshake feature ids. */ + readonly featureIds: readonly NetworkV1FeatureId[]; +} + +export interface NetworkV1Header { + /** Lowercase HTTP field name after the selected Fetch guard ran. */ + readonly name: string; + /** ByteString value with OWS normalized and CR/LF rejected. */ + readonly value: string; +} + +export interface NetworkV1HttpTimeouts { + /** Zero means the Build Plan/provider default; non-zero is milliseconds. */ + readonly connectMs: number; + readonly headersMs: number; + readonly idleMs: number; + readonly totalMs: number; +} + +export interface NetworkV1LimitOverride { + /** Provider-defined stable limit name, sorted lexicographically by name. */ + readonly name: string; + /** Already clamped to the admitted minimum/default interval. */ + readonly value: number; +} + +export interface NetworkV1TlsMetadata { + /** Empty only when the authorized endpoint is an IP literal. */ + readonly serverName: string; + readonly minVersion: NetworkV1TlsVersion; + readonly maxVersion: NetworkV1TlsVersion; + readonly alpn: readonly string[]; + /** Empty string means no Host credential id. */ + readonly credential: string; + readonly clientCertificate: NetworkV1ClientCertificateMode; + readonly verification: NetworkV1TlsVerification; + readonly revocation: NetworkV1TlsRevocation; + /** Must equal the CustomCa borrowed input length, or zero when absent. */ + readonly customCaBytes: number; +} + +export interface NetworkV1HttpRequestMetadata { + /** Absolute, canonical HTTP(S) URL; native policy checks it again. */ + readonly url: string; + /** Normalized HTTP token; CONNECT and TRACE are forbidden. */ + readonly method: string; + readonly headers: readonly NetworkV1Header[]; + readonly hasBody: boolean; + readonly redirect: NetworkV1HttpRedirectMode; + readonly timeouts: NetworkV1HttpTimeouts; + readonly maxRedirects: number; + readonly tls: NetworkV1TlsMetadata | null; + readonly limits: readonly NetworkV1LimitOverride[]; + readonly ref: boolean; +} + +export interface NetworkV1HttpResponseMetadata { + readonly status: number; + readonly statusText: string; + readonly headers: readonly NetworkV1Header[]; + readonly url: string; + readonly redirected: boolean; + /** Native aggregation ceiling after all build/provider limits are applied. */ + readonly bufferedBodyBytes: number; +} + +export interface NetworkV1ErrorMetadata { + readonly category: NetworkV1ErrorCategory; + readonly code: NetworkV1ErrorCode; + /** Stable operation label selected by the protocol Core. */ + readonly operation: string; + readonly temporary: boolean; + readonly address?: string; + readonly port?: number; + readonly causeCode?: string; + readonly reasonCode?: number; +} + +/** + * Input remains borrowed only for the synchronous native adapter call. The + * adapter must copy it into an admitted BufferLease before returning Accepted. + */ +export interface NetworkV1BorrowedInput { + readonly kind: NetworkV1BorrowedInputKind; + readonly bytes: Uint8Array; +} + +interface NetworkV1CommandBase { + readonly opcode: NetworkV1CommandOpcode; + readonly identity: NetworkV1CommandIdentity; +} + +export interface NetworkV1OperationCancelCommand extends NetworkV1CommandBase { + readonly opcode: typeof NetworkV1CommandOpcode.OperationCancel; +} + +export interface NetworkV1BufferLeaseTakeCommand extends NetworkV1CommandBase { + readonly opcode: typeof NetworkV1CommandOpcode.BufferLeaseTake; + readonly lease: NetworkV1Handle; + readonly byteLength: number; +} + +export interface NetworkV1BufferLeaseReadIntoCommand extends NetworkV1CommandBase { + readonly opcode: typeof NetworkV1CommandOpcode.BufferLeaseReadInto; + readonly lease: NetworkV1Handle; + readonly offset: number; + /** Destination capacity for the synchronous borrowed-output argument. */ + readonly maxBytes: number; +} + +export interface NetworkV1BufferLeaseReleaseCommand extends NetworkV1CommandBase { + readonly opcode: typeof NetworkV1CommandOpcode.BufferLeaseRelease; + readonly lease: NetworkV1Handle; +} + +export interface NetworkV1BodyPullCommand extends NetworkV1CommandBase { + readonly opcode: typeof NetworkV1CommandOpcode.BodyPull; + readonly maxBytes: number; +} + +export interface NetworkV1BodyChunkCommand extends NetworkV1CommandBase { + readonly opcode: typeof NetworkV1CommandOpcode.BodyChunk; + readonly input: NetworkV1BorrowedInput & Readonly<{ + kind: typeof NetworkV1BorrowedInputKind.BodyChunk; + }>; +} + +export interface NetworkV1BodyEndCommand extends NetworkV1CommandBase { + readonly opcode: typeof NetworkV1CommandOpcode.BodyEnd; +} + +export interface NetworkV1BodyErrorCommand extends NetworkV1CommandBase { + readonly opcode: typeof NetworkV1CommandOpcode.BodyError; + readonly error: NetworkV1ErrorMetadata; +} + +export interface NetworkV1BodyCancelCommand extends NetworkV1CommandBase { + readonly opcode: typeof NetworkV1CommandOpcode.BodyCancel; +} + +export interface NetworkV1HttpRequestStartCommand extends NetworkV1CommandBase { + readonly opcode: typeof NetworkV1CommandOpcode.HttpRequestStart; + readonly metadata: NetworkV1HttpRequestMetadata; + /** Present only when metadata.customCaBytes is non-zero. */ + readonly input?: NetworkV1BorrowedInput & Readonly<{ + kind: typeof NetworkV1BorrowedInputKind.CustomCa; + }>; +} + +export type NetworkV1LeaseCommand = + | NetworkV1BufferLeaseTakeCommand + | NetworkV1BufferLeaseReadIntoCommand + | NetworkV1BufferLeaseReleaseCommand; + +export type NetworkV1AsyncCommand = + | NetworkV1OperationCancelCommand + | NetworkV1BodyPullCommand + | NetworkV1BodyChunkCommand + | NetworkV1BodyEndCommand + | NetworkV1BodyErrorCommand + | NetworkV1BodyCancelCommand + | NetworkV1HttpRequestStartCommand; + +export type NetworkV1Command = NetworkV1LeaseCommand | NetworkV1AsyncCommand; + +interface NetworkV1CompletionBase { + readonly eventCode: NetworkV1EventCode; + readonly identity: NetworkV1CompletionIdentity; +} + +export interface NetworkV1BodyPullEvent extends NetworkV1CompletionBase { + readonly eventCode: typeof NetworkV1EventCode.BodyPull; + readonly maxBytes: number; +} + +export interface NetworkV1LeaseDescriptor { + readonly runtimeGeneration: number; + readonly lease: NetworkV1Handle; + readonly byteLength: number; +} + +export interface NetworkV1BodyChunkEvent extends NetworkV1CompletionBase { + readonly eventCode: typeof NetworkV1EventCode.BodyChunk; + readonly payload: NetworkV1LeaseDescriptor; +} + +export interface NetworkV1BodyEndEvent extends NetworkV1CompletionBase { + readonly eventCode: typeof NetworkV1EventCode.BodyEnd; +} + +export interface NetworkV1BodyErrorEvent extends NetworkV1CompletionBase { + readonly eventCode: typeof NetworkV1EventCode.BodyError; + readonly error: NetworkV1ErrorMetadata; +} + +export interface NetworkV1BodyCancelEvent extends NetworkV1CompletionBase { + readonly eventCode: typeof NetworkV1EventCode.BodyCancel; +} + +export interface NetworkV1HttpResponseHeadersEvent extends NetworkV1CompletionBase { + readonly eventCode: typeof NetworkV1EventCode.HttpResponseHeaders; + readonly metadata: NetworkV1HttpResponseMetadata; +} + +export interface NetworkV1HttpRequestErrorEvent extends NetworkV1CompletionBase { + readonly eventCode: typeof NetworkV1EventCode.HttpRequestError; + readonly error: NetworkV1ErrorMetadata; +} + +export type NetworkV1Completion = + | NetworkV1BodyPullEvent + | NetworkV1BodyChunkEvent + | NetworkV1BodyEndEvent + | NetworkV1BodyErrorEvent + | NetworkV1BodyCancelEvent + | NetworkV1HttpResponseHeadersEvent + | NetworkV1HttpRequestErrorEvent; + +export interface NetworkV1CompletionPollRequest { + readonly runtimeGeneration: number; + /** Remaining payload-byte budget; zero is a non-consuming readiness probe. */ + readonly maxPayloadBytes: number; +} + +export type NetworkV1CompletionPollResult = + | Readonly<{ + status: typeof NetworkV1CompletionPollStatus.Item; + completion: NetworkV1Completion; + /** Entire payload size charged when this descriptor was selected. */ + payloadBytesDelivered: number; + }> + | Readonly<{ + status: + | typeof NetworkV1CompletionPollStatus.Drained + | typeof NetworkV1CompletionPollStatus.BudgetExhausted; + payloadBytesDelivered: 0; + }>; + +export type NetworkV1AcceptedResult = Readonly<{ + status: typeof NetworkV1DispatchStatus.Accepted; +}>; + +export type NetworkV1RefusedResult = Readonly<{ + status: typeof NetworkV1DispatchStatus.Refused; + /** No completion follows a synchronously refused command. */ + error: NetworkV1ErrorMetadata; +}>; + +export type NetworkV1CompletedResult = Readonly<{ + status: typeof NetworkV1DispatchStatus.Completed; +}>; + +export type NetworkV1AsyncDispatchResult = + | NetworkV1AcceptedResult + | NetworkV1RefusedResult; + +export type NetworkV1SynchronousResult = + | NetworkV1CompletedResult + | NetworkV1RefusedResult; + +export type NetworkV1DispatchResult = + | NetworkV1AcceptedResult + | NetworkV1CompletedResult + | NetworkV1RefusedResult; + +export type NetworkV1LeaseTakeResult = + | Readonly<{ + status: typeof NetworkV1DispatchStatus.Completed; + /** Exact length already carried by the delivered lease descriptor. */ + byteLength: number; + }> + | NetworkV1RefusedResult; + +export type NetworkV1LeaseReadResult = + | Readonly<{ + status: typeof NetworkV1DispatchStatus.Completed; + /** Bytes synchronously copied into the borrowed destination. */ + bytesCopied: number; + }> + | NetworkV1RefusedResult; + +export interface NetworkV1ServiceTurnRequest { + readonly runtimeGeneration: number; + readonly turnId: number; + readonly kind: NetworkV1ServiceTurnKind; + readonly maxEvents: number; + readonly maxPayloadBytes: number; +} + +export interface NetworkV1ServiceTurnResult { + readonly status: NetworkV1ServiceTurnStatus; + readonly eventsDelivered: number; + /** Sum of advertised lease byte lengths selected for this turn. */ + readonly payloadBytesDelivered: number; + /** Zero exactly when eventsDelivered is zero. */ + readonly lastSequence: number; +} + +export type NetworkV1ServiceDispatcher = ( + request: NetworkV1ServiceTurnRequest, +) => NetworkV1ServiceTurnResult; + +/** + * Frozen table captured lexically by the framework bundle factory. Mount calls + * `registerServiceDispatcher` exactly once before application initialization. + * Every method is owner-thread-only and non-reentrant. + */ +export interface NetworkV1BindingTable { + readonly handshake: NetworkV1Handshake; + /** ABI 1.1: immutable Build Plan/descriptor snapshot; never negotiates. */ + getLimits(query: NetworkV1LimitsQuery): NetworkV1LimitsSnapshot; + dispatch(command: NetworkV1AsyncCommand): NetworkV1AsyncDispatchResult; + nextCompletion(request: NetworkV1CompletionPollRequest): NetworkV1CompletionPollResult; + leaseTake(command: NetworkV1BufferLeaseTakeCommand): NetworkV1LeaseTakeResult; + leaseReadInto( + command: NetworkV1BufferLeaseReadIntoCommand, + destination: Uint8Array, + ): NetworkV1LeaseReadResult; + leaseRelease(command: NetworkV1BufferLeaseReleaseCommand): NetworkV1SynchronousResult; + registerServiceDispatcher(dispatcher: NetworkV1ServiceDispatcher): void; +} + +function fail(label: string, detail: string): never { + throw new TypeError(`PocketJS network ABI: ${label} ${detail}`); +} + +const NETWORK_V1_TYPED_ARRAY_PROTOTYPE = Object.getPrototypeOf(Uint8Array.prototype); +const NETWORK_V1_TYPED_ARRAY_BYTE_LENGTH = Object.getOwnPropertyDescriptor( + NETWORK_V1_TYPED_ARRAY_PROTOTYPE, + "byteLength", +)!.get!; +const NETWORK_V1_UINT8_ARRAY_SET = Uint8Array.prototype.set; + +function networkV1Uint8ArrayByteLength(value: unknown, label: string): number { + if (!(value instanceof Uint8Array)) fail(label, "must be a Uint8Array window"); + try { + return Reflect.apply(NETWORK_V1_TYPED_ARRAY_BYTE_LENGTH, value, []); + } catch { + fail(label, "must expose valid Uint8Array intrinsic slots"); + } +} + +function networkV1CopyUint8Array(value: unknown, label: string): Uint8Array { + const byteLength = networkV1Uint8ArrayByteLength(value, label); + const copy = new Uint8Array(byteLength); + try { + Reflect.apply(NETWORK_V1_UINT8_ARRAY_SET, copy, [value]); + } catch { + fail(label, "could not be copied from Uint8Array intrinsic slots"); + } + return copy; +} + +function assertIntegerInRange( + value: unknown, + minimum: number, + maximum: number, + label: string, +): asserts value is number { + if (!Number.isSafeInteger(value) || (value as number) < minimum || + (value as number) > maximum) { + fail(label, `must be an integer in [${minimum}, ${maximum}]`); + } +} + +export function assertNetworkV1RuntimeGeneration( + value: unknown, + label = "runtimeGeneration", +): asserts value is number { + assertIntegerInRange(value, 1, NETWORK_V1_UINT32_MAX, label); +} + +export function assertNetworkV1Sequence( + value: unknown, + label = "sequence", +): asserts value is number { + assertIntegerInRange(value, 1, NETWORK_V1_SEQUENCE_MAX, label); +} + +/** Generation reuse is fail-closed; zero and wraparound are never emitted. */ +export function networkV1NextGeneration( + current: number, + label = "generation", +): number { + assertIntegerInRange(current, 1, NETWORK_V1_UINT32_MAX - 1, label); + return current + 1; +} + +export function assertNetworkV1Handle( + value: unknown, + label = "handle", + allowAbsent = true, +): asserts value is NetworkV1Handle { + if (typeof value !== "object" || value === null) fail(label, "must be an object"); + const record = value as Partial; + const id = record.id; + const generation = record.generation; + assertIntegerInRange(id, 0, NETWORK_V1_UINT32_MAX, `${label}.id`); + assertIntegerInRange( + generation, + 0, + NETWORK_V1_UINT32_MAX, + `${label}.generation`, + ); + if ((id === 0) !== (generation === 0)) { + fail(label, "must be either zero/zero or non-zero/non-zero"); + } + if (!allowAbsent && id === 0) fail(label, "must be live"); +} + +export function networkV1HandleIsAbsent(handle: NetworkV1Handle): boolean { + return handle.id === 0 && handle.generation === 0; +} + +export function networkV1SameHandle( + left: NetworkV1Handle, + right: NetworkV1Handle, +): boolean { + return left.id === right.id && left.generation === right.generation; +} + +export function assertNetworkV1CommandIdentity( + identity: NetworkV1CommandIdentity, +): void { + assertNetworkV1RuntimeGeneration(identity.runtimeGeneration); + assertNetworkV1Handle(identity.resource, "resource"); + assertNetworkV1Handle(identity.operation, "operation"); + assertNetworkV1Handle(identity.body, "body"); + assertNetworkV1Sequence(identity.commandSequence, "commandSequence"); +} + +export function assertNetworkV1CompletionIdentity( + identity: NetworkV1CompletionIdentity, +): void { + assertNetworkV1RuntimeGeneration(identity.runtimeGeneration); + assertNetworkV1Handle(identity.resource, "resource"); + assertNetworkV1Handle(identity.operation, "operation"); + assertNetworkV1Handle(identity.body, "body"); + assertNetworkV1Sequence(identity.sequence); +} + +export function assertNetworkV1CompletionPollResult( + request: NetworkV1CompletionPollRequest, + result: NetworkV1CompletionPollResult, +): void { + assertNetworkV1RuntimeGeneration(request.runtimeGeneration); + assertIntegerInRange( + request.maxPayloadBytes, + 0, + NETWORK_V1_UINT32_MAX, + "completionPoll.maxPayloadBytes", + ); + if (result.status === NetworkV1CompletionPollStatus.Item) { + assertIntegerInRange( + result.payloadBytesDelivered, + 0, + request.maxPayloadBytes, + "completionPoll.payloadBytesDelivered", + ); + assertNetworkV1CompletionIdentity(result.completion.identity); + if (result.completion.identity.runtimeGeneration !== request.runtimeGeneration) { + fail("completionPoll", "returned a stale runtime generation"); + } + if (result.completion.eventCode === NetworkV1EventCode.BodyChunk) { + assertNetworkV1LeaseDescriptor(result.completion.payload); + if (result.completion.payload.runtimeGeneration !== request.runtimeGeneration || + result.completion.payload.byteLength !== result.payloadBytesDelivered) { + fail("completionPoll", "BODY_CHUNK payload does not match its lease descriptor"); + } + } else if (result.payloadBytesDelivered !== 0) { + fail("completionPoll", "a descriptor without payload consumed byte budget"); + } + return; + } + if (result.status !== NetworkV1CompletionPollStatus.Drained && + result.status !== NetworkV1CompletionPollStatus.BudgetExhausted) { + fail("completionPoll.status", "is unknown"); + } + if (result.payloadBytesDelivered !== 0) { + fail("completionPoll", "a non-item result cannot consume byte budget"); + } +} + +export function networkV1IdentityMatchesGeneration( + identity: NetworkV1CompletionIdentity, + current: NetworkV1GenerationSnapshot, +): boolean { + if (identity.runtimeGeneration !== current.runtimeGeneration) return false; + if (current.resource && !networkV1SameHandle(identity.resource, current.resource)) return false; + if (current.operation && !networkV1SameHandle(identity.operation, current.operation)) return false; + if (current.body && !networkV1SameHandle(identity.body, current.body)) return false; + return true; +} + +export function assertNetworkV1NextSequence(last: number, next: number): void { + if (last !== 0) assertNetworkV1Sequence(last, "lastSequence"); + assertNetworkV1Sequence(next, "nextSequence"); + if (next <= last) fail("nextSequence", "must be strictly monotonic"); +} + +export function networkV1PlanHashBytes(planHash: string): Uint8Array { + const match = /^sha256:([0-9a-f]{64})$/.exec(planHash); + if (!match) fail("planHash", "must be a lowercase sha256 digest"); + const hex = match[1]!; + const bytes = new Uint8Array(NETWORK_V1_PLAN_HASH_BYTES); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} + +const NETWORK_V1_FEATURE_ID_SET: ReadonlySet = + new Set(NETWORK_V1_FEATURE_IDS); + +function assertFeatureIds( + featureIds: readonly number[], + label: string, +): asserts featureIds is readonly NetworkV1FeatureId[] { + let previous = 0; + for (let index = 0; index < featureIds.length; index += 1) { + const feature = featureIds[index]; + assertIntegerInRange(feature, 1, 0xffff, `${label}[${index}]`); + if (!NETWORK_V1_FEATURE_ID_SET.has(feature)) { + fail(`${label}[${index}]`, "is not defined by ABI v1.0"); + } + if (feature <= previous) fail(label, "must be unique and strictly increasing"); + previous = feature; + } +} + +export function networkV1FeatureIdsFromBuildPlan( + features: Readonly>, +): readonly NetworkV1FeatureId[] { + const ids: NetworkV1FeatureId[] = []; + for (const [capability, enabled] of Object.entries(features)) { + if (!enabled || !capability.startsWith("network.")) continue; + const id = (NETWORK_V1_FEATURE_ID_BY_CAPABILITY as Readonly>)[ + capability + ]; + if (id === undefined) fail("featureSet", `contains unknown capability ${capability}`); + ids.push(id as NetworkV1FeatureId); + } + ids.sort((left, right) => left - right); + assertFeatureIds(ids, "featureIds"); + return Object.freeze(ids.slice()); +} + +function assertPlanHash(value: Uint8Array, label: string): void { + if (networkV1Uint8ArrayByteLength(value, label) !== NETWORK_V1_PLAN_HASH_BYTES) { + fail(label, `must contain exactly ${NETWORK_V1_PLAN_HASH_BYTES} bytes`); + } +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + let difference = 0; + for (let index = 0; index < left.byteLength; index += 1) { + difference |= left[index]! ^ right[index]!; + } + return difference === 0; +} + +function snapshotHandshake(value: NetworkV1Handshake, label: string): NetworkV1Handshake { + if (typeof value !== "object" || value === null) fail(label, "must be an object"); + const record = value as Partial; + // Snapshot every recognized Host-supplied field exactly once before any + // validation. A frozen object can still expose stateful accessors. + const abiMajor = record.abiMajor; + const abiMinor = record.abiMinor; + const runtimeGeneration = record.runtimeGeneration; + const rawPlanHash = record.planHash; + const rawFeatureIds = record.featureIds; + + assertIntegerInRange(abiMajor, 1, 0xffff, `${label}.abiMajor`); + assertIntegerInRange(abiMinor, 0, 0xffff, `${label}.abiMinor`); + assertNetworkV1RuntimeGeneration(runtimeGeneration, `${label}.runtimeGeneration`); + assertPlanHash(rawPlanHash as Uint8Array, `${label}.planHash`); + if (!Array.isArray(rawFeatureIds)) fail(`${label}.featureIds`, "must be an array"); + if (rawFeatureIds.length > NETWORK_V1_FEATURE_IDS.length) { + fail(`${label}.featureIds`, "cannot exceed the known feature count"); + } + const featureIds: number[] = []; + for (let index = 0; index < rawFeatureIds.length; index += 1) { + featureIds.push((rawFeatureIds as readonly number[])[index]!); + } + assertFeatureIds(featureIds, `${label}.featureIds`); + return { + abiMajor, + abiMinor, + runtimeGeneration, + planHash: networkV1CopyUint8Array(rawPlanHash, `${label}.planHash`), + featureIds, + }; +} + +/** Validate before any application initializer, callback, or microtask runs. */ +export function assertNetworkV1Handshake( + expected: NetworkV1Handshake, + actual: NetworkV1Handshake, +): void { + const expectedSnapshot = snapshotHandshake(expected, "expectedHandshake"); + const actualSnapshot = snapshotHandshake(actual, "actualHandshake"); + if (expectedSnapshot.abiMajor !== NETWORK_V1_ABI_MAJOR || + expectedSnapshot.abiMinor !== NETWORK_V1_ABI_MINOR) { + fail("expectedHandshake", "does not describe the compiled Guest ABI"); + } + if (actualSnapshot.abiMajor !== expectedSnapshot.abiMajor || + actualSnapshot.abiMinor < expectedSnapshot.abiMinor) { + fail("actualHandshake", "has an incompatible ABI version"); + } + if (actualSnapshot.runtimeGeneration !== expectedSnapshot.runtimeGeneration) { + fail("actualHandshake", "has a different runtime generation"); + } + if (!equalBytes(actualSnapshot.planHash, expectedSnapshot.planHash)) { + fail("actualHandshake", "does not match the verified Build Plan hash"); + } + if (actualSnapshot.featureIds.length !== expectedSnapshot.featureIds.length || + actualSnapshot.featureIds.some( + (feature, index) => feature !== expectedSnapshot.featureIds[index], + )) { + fail("actualHandshake", "does not match the exact Build Plan feature set"); + } +} + +const NETWORK_V1_LIMIT_PROTOCOL_SET: ReadonlySet = new Set([ + NETWORK_V1_LIMIT_PROTOCOL_ANY, + ...Object.values(NetworkV1LimitProtocol), +]); + +const NETWORK_V1_LIMIT_ROLE_SET: ReadonlySet = new Set([ + NETWORK_V1_LIMIT_ROLE_ANY, + ...Object.values(NetworkV1LimitRole), +]); + +function assertNetworkV1LimitsQuery( + query: NetworkV1LimitsQuery, + runtimeGeneration: number, +): void { + if (typeof query !== "object" || query === null) fail("limitsQuery", "must be an object"); + const queryRuntimeGeneration = query.runtimeGeneration; + const protocol = query.protocol; + const role = query.role; + assertNetworkV1RuntimeGeneration( + queryRuntimeGeneration, + "limitsQuery.runtimeGeneration", + ); + if (queryRuntimeGeneration !== runtimeGeneration) { + fail("limitsQuery.runtimeGeneration", "does not match the mounted runtime"); + } + assertIntegerInRange(protocol, 0, 0xffff, "limitsQuery.protocol"); + if (!NETWORK_V1_LIMIT_PROTOCOL_SET.has(protocol)) { + fail("limitsQuery.protocol", "is unknown"); + } + assertIntegerInRange(role, 0, 0xffff, "limitsQuery.role"); + if (!NETWORK_V1_LIMIT_ROLE_SET.has(role)) fail("limitsQuery.role", "is unknown"); +} + +function frozenDataField( + value: object, + key: string, + label: string, +): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + fail(`${label}.${key}`, "must be an own frozen data property"); + } + return descriptor.value; +} + +function assertFrozenRecord(value: unknown, label: string): asserts value is object { + if (typeof value !== "object" || value === null || !Object.isFrozen(value)) { + fail(label, "must be a frozen object"); + } +} + +function capabilityMatchesLimitsQuery( + capability: string, + protocol: NetworkV1LimitProtocolQuery, + role: NetworkV1LimitRoleQuery, +): boolean { + let protocolMatches = true; + if (protocol === NetworkV1LimitProtocol.Http) { + protocolMatches = capability.startsWith("network.http.") || + capability.startsWith("network.browser.http."); + } else if (protocol === NetworkV1LimitProtocol.WebSocket) { + protocolMatches = capability.startsWith("network.websocket.") || + capability.startsWith("network.browser.websocket."); + } else if (protocol === NetworkV1LimitProtocol.Mqtt) { + protocolMatches = capability.startsWith("network.mqtt."); + } else if (protocol === NetworkV1LimitProtocol.Tcp) { + protocolMatches = capability.startsWith("network.tcp."); + } else if (protocol === NetworkV1LimitProtocol.Udp) { + protocolMatches = capability === "network.udp" || + capability.startsWith("network.udp."); + } + if (!protocolMatches || role === NETWORK_V1_LIMIT_ROLE_ANY) return protocolMatches; + if (role === NetworkV1LimitRole.Client) { + return capability.includes(".client") || capability.includes(".browser."); + } + return capability.includes(".server"); +} + +function expectedLimitFeatureIds( + handshakeSnapshot: NetworkV1Handshake, + protocol: NetworkV1LimitProtocolQuery, + role: NetworkV1LimitRoleQuery, +): readonly NetworkV1FeatureId[] { + return handshakeSnapshot.featureIds.filter((featureId) => { + const capability = ( + NETWORK_V1_FEATURE_CAPABILITY_BY_ID as Readonly> + )[featureId]; + return capability !== undefined && + capabilityMatchesLimitsQuery(capability, protocol, role); + }); +} + +/** + * Validate and detach the ABI 1.1 limits result before exposing it publicly. + * Every Host-provided container must already be frozen and accessor-free. + */ +export function snapshotNetworkV1Limits( + query: NetworkV1LimitsQuery, + value: unknown, + handshake: NetworkV1Handshake, +): Readonly { + const handshakeSnapshot = snapshotHandshake(handshake, "handshake"); + assertNetworkV1LimitsQuery(query, handshakeSnapshot.runtimeGeneration); + assertFrozenRecord(value, "limitsSnapshot"); + + const runtimeGeneration = frozenDataField( + value, + "runtimeGeneration", + "limitsSnapshot", + ); + const protocol = frozenDataField(value, "protocol", "limitsSnapshot"); + const role = frozenDataField(value, "role", "limitsSnapshot"); + const rawValues = frozenDataField(value, "values", "limitsSnapshot"); + const rawFeatureIds = frozenDataField(value, "featureIds", "limitsSnapshot"); + + assertNetworkV1RuntimeGeneration(runtimeGeneration, "limitsSnapshot.runtimeGeneration"); + if (runtimeGeneration !== query.runtimeGeneration) { + fail("limitsSnapshot.runtimeGeneration", "does not echo the query"); + } + if (protocol !== query.protocol) fail("limitsSnapshot.protocol", "does not echo the query"); + if (role !== query.role) fail("limitsSnapshot.role", "does not echo the query"); + + if (!Array.isArray(rawValues) || !Object.isFrozen(rawValues)) { + fail("limitsSnapshot.values", "must be a frozen data array"); + } + if (rawValues.length > NETWORK_V1_LIMIT_ENTRY_MAX) { + fail("limitsSnapshot.values", `cannot exceed ${NETWORK_V1_LIMIT_ENTRY_MAX} entries`); + } + const values: NetworkV1LimitEntry[] = []; + let previousName = ""; + for (let index = 0; index < rawValues.length; index += 1) { + const entry = frozenDataField(rawValues, String(index), "limitsSnapshot.values"); + assertFrozenRecord(entry, `limitsSnapshot.values[${index}]`); + const name = frozenDataField(entry, "name", `limitsSnapshot.values[${index}]`); + const defaultValue = frozenDataField( + entry, + "default", + `limitsSnapshot.values[${index}]`, + ); + const hard = frozenDataField(entry, "hard", `limitsSnapshot.values[${index}]`); + const minimum = frozenDataField( + entry, + "minimum", + `limitsSnapshot.values[${index}]`, + ); + if (typeof name !== "string" || name.length === 0 || + name.length > NETWORK_V1_LIMIT_NAME_MAX_BYTES || + !/^[a-z][A-Za-z0-9]*(?:\.[a-z][A-Za-z0-9]*)*$/.test(name) || + name.split(".").some((segment) => + segment === "constructor" || segment === "prototype" + )) { + fail( + `limitsSnapshot.values[${index}].name`, + "must be a bounded dotted ASCII identifier", + ); + } + if (name <= previousName) { + fail("limitsSnapshot.values", "must be unique and lexicographically sorted"); + } + assertIntegerInRange( + defaultValue, + 1, + Number.MAX_SAFE_INTEGER, + `limitsSnapshot.values[${index}].default`, + ); + assertIntegerInRange( + hard, + 1, + Number.MAX_SAFE_INTEGER, + `limitsSnapshot.values[${index}].hard`, + ); + assertIntegerInRange( + minimum, + 0, + Number.MAX_SAFE_INTEGER, + `limitsSnapshot.values[${index}].minimum`, + ); + if (minimum > defaultValue || defaultValue > hard) { + fail( + `limitsSnapshot.values[${index}]`, + "must satisfy minimum <= default <= hard", + ); + } + values.push(Object.freeze({ name, default: defaultValue, hard, minimum })); + previousName = name; + } + + if (!Array.isArray(rawFeatureIds) || !Object.isFrozen(rawFeatureIds)) { + fail("limitsSnapshot.featureIds", "must be a frozen data array"); + } + if (rawFeatureIds.length > NETWORK_V1_FEATURE_IDS.length) { + fail("limitsSnapshot.featureIds", "cannot exceed the known feature count"); + } + const featureIds: number[] = []; + for (let index = 0; index < rawFeatureIds.length; index += 1) { + featureIds.push( + frozenDataField(rawFeatureIds, String(index), "limitsSnapshot.featureIds") as number, + ); + } + assertFeatureIds(featureIds, "limitsSnapshot.featureIds"); + const expectedFeatureIds = expectedLimitFeatureIds( + handshakeSnapshot, + query.protocol, + query.role, + ); + if (featureIds.length !== expectedFeatureIds.length || + featureIds.some((featureId, index) => featureId !== expectedFeatureIds[index])) { + fail("limitsSnapshot.featureIds", "does not match the exact scoped feature set"); + } + + return Object.freeze({ + runtimeGeneration, + protocol: query.protocol, + role: query.role, + values: Object.freeze(values), + featureIds: Object.freeze(featureIds as NetworkV1FeatureId[]), + }); +} + +const NETWORK_V1_BINDING_KEYS = [ + "handshake", + "getLimits", + "dispatch", + "nextCompletion", + "leaseTake", + "leaseReadInto", + "leaseRelease", + "registerServiceDispatcher", +] as const; + +/** + * Validate the lexical table without invoking accessors. Newer ABI minors may + * append frozen data properties; every v1.1 property remains mandatory. + */ +export function assertNetworkV1BindingTable( + value: unknown, + expectedHandshake: NetworkV1Handshake, +): asserts value is NetworkV1BindingTable { + if (typeof value !== "object" || value === null || !Object.isFrozen(value)) { + fail("bindingTable", "must be a frozen object"); + } + const fields = new Map(); + for (const key of NETWORK_V1_BINDING_KEYS) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + fail(`bindingTable.${key}`, "must be an own frozen data property"); + } + fields.set(key, descriptor.value); + } + const actualHandshake = fields.get("handshake"); + if (typeof actualHandshake !== "object" || actualHandshake === null || + !Object.isFrozen(actualHandshake)) { + fail("bindingTable.handshake", "must be frozen"); + } + const featureIds = Object.getOwnPropertyDescriptor(actualHandshake, "featureIds"); + if (!featureIds || !("value" in featureIds) || !Object.isFrozen(featureIds.value)) { + fail("bindingTable.handshake.featureIds", "must be a frozen data array"); + } + assertNetworkV1Handshake( + expectedHandshake, + actualHandshake as NetworkV1Handshake, + ); + for (const key of NETWORK_V1_BINDING_KEYS.slice(1)) { + if (typeof fields.get(key) !== "function") { + fail(`bindingTable.${key}`, "must be a function"); + } + } +} + +export interface NetworkV1BodyFlowState { + readonly pendingCredit: number; + readonly terminal: boolean; +} + +export type NetworkV1BodySignal = + | Readonly<{ kind: "pull"; maxBytes: number }> + | Readonly<{ kind: "chunk"; byteLength: number }> + | Readonly<{ kind: "end" | "error" | "cancel" }>; + +export const NETWORK_V1_INITIAL_BODY_FLOW: NetworkV1BodyFlowState = + Object.freeze({ pendingCredit: 0, terminal: false }); + +/** Shared state machine for command-pull/event-chunk and event-pull/command-chunk. */ +export function networkV1ApplyBodySignal( + state: NetworkV1BodyFlowState, + signal: NetworkV1BodySignal, + maxChunkBytes: number, +): NetworkV1BodyFlowState { + assertIntegerInRange(maxChunkBytes, 1, NETWORK_V1_UINT32_MAX, "maxChunkBytes"); + assertIntegerInRange( + state.pendingCredit, + 0, + maxChunkBytes, + "body.pendingCredit", + ); + if (state.terminal) fail("body", "cannot receive a signal after terminal state"); + + if (signal.kind === "pull") { + assertIntegerInRange(signal.maxBytes, 1, maxChunkBytes, "BODY_PULL.maxBytes"); + if (state.pendingCredit !== 0) fail("BODY_PULL", "cannot overlap existing credit"); + return Object.freeze({ pendingCredit: signal.maxBytes, terminal: false }); + } + if (signal.kind === "chunk") { + if (state.pendingCredit === 0) fail("BODY_CHUNK", "requires prior BODY_PULL credit"); + assertIntegerInRange( + signal.byteLength, + 1, + state.pendingCredit, + "BODY_CHUNK.byteLength", + ); + return Object.freeze({ pendingCredit: 0, terminal: false }); + } + return Object.freeze({ pendingCredit: 0, terminal: true }); +} + +/** Exact BufferLease ownership transitions; every other edge is an ABI fault. */ +export function networkV1LeaseTransition( + state: NetworkV1LeaseState, + action: NetworkV1LeaseAction, +): NetworkV1LeaseState { + if (state === NetworkV1LeaseState.Queued && action === NetworkV1LeaseAction.Take) { + return NetworkV1LeaseState.Taken; + } + if (state === NetworkV1LeaseState.Queued && action === NetworkV1LeaseAction.Cleanup) { + return NetworkV1LeaseState.Released; + } + if (state === NetworkV1LeaseState.Taken && action === NetworkV1LeaseAction.Release) { + return NetworkV1LeaseState.Released; + } + fail("BufferLease", "has an invalid take/release/cleanup transition"); +} + +export function assertNetworkV1LeaseDescriptor( + descriptor: NetworkV1LeaseDescriptor, +): void { + assertNetworkV1RuntimeGeneration(descriptor.runtimeGeneration); + assertNetworkV1Handle(descriptor.lease, "lease", false); + assertIntegerInRange( + descriptor.byteLength, + 1, + NETWORK_V1_UINT32_MAX, + "lease.byteLength", + ); +} + +export function assertNetworkV1BorrowedInput( + input: NetworkV1BorrowedInput, + expectedKind: NetworkV1BorrowedInputKind, + maxBytes: number, + exactBytes?: number, +): void { + assertIntegerInRange(maxBytes, 1, NETWORK_V1_UINT32_MAX, "borrowedInput.maxBytes"); + if (input.kind !== expectedKind) fail("borrowedInput.kind", "does not match the command"); + const byteLength = networkV1Uint8ArrayByteLength( + input.bytes, + "borrowedInput.bytes", + ); + assertIntegerInRange( + byteLength, + 1, + maxBytes, + "borrowedInput.byteLength", + ); + if (exactBytes !== undefined) { + assertIntegerInRange(exactBytes, 1, maxBytes, "borrowedInput.exactBytes"); + if (byteLength !== exactBytes) { + fail("borrowedInput.byteLength", "does not match normalized metadata"); + } + } +} + +export function assertNetworkV1LeaseReadInto( + command: NetworkV1BufferLeaseReadIntoCommand, + destination: Uint8Array, + leaseByteLength: number, +): void { + assertNetworkV1CommandIdentity(command.identity); + assertNetworkV1Handle(command.lease, "lease", false); + assertIntegerInRange(leaseByteLength, 1, NETWORK_V1_UINT32_MAX, "leaseByteLength"); + assertIntegerInRange(command.offset, 0, leaseByteLength, "leaseRead.offset"); + assertIntegerInRange(command.maxBytes, 1, NETWORK_V1_UINT32_MAX, "leaseRead.maxBytes"); + if (networkV1Uint8ArrayByteLength(destination, "leaseRead.destination") !== + command.maxBytes) { + fail("leaseRead.destination", "must be the exact borrowed Uint8Array window"); + } + if (command.maxBytes > leaseByteLength - command.offset) { + fail("leaseRead", "would read beyond the BufferLease"); + } +} + +export function assertNetworkV1ServiceTurnRequest( + request: NetworkV1ServiceTurnRequest, +): void { + assertNetworkV1RuntimeGeneration(request.runtimeGeneration); + assertNetworkV1Sequence(request.turnId, "turnId"); + if (request.kind !== NetworkV1ServiceTurnKind.Network && + request.kind !== NetworkV1ServiceTurnKind.Shutdown) { + fail("serviceTurn.kind", "is unknown"); + } + assertIntegerInRange(request.maxEvents, 1, NETWORK_V1_UINT32_MAX, "maxEvents"); + assertIntegerInRange( + request.maxPayloadBytes, + 1, + NETWORK_V1_UINT32_MAX, + "maxPayloadBytes", + ); +} + +export function assertNetworkV1ServiceTurnResult( + request: NetworkV1ServiceTurnRequest, + result: NetworkV1ServiceTurnResult, +): void { + assertNetworkV1ServiceTurnRequest(request); + if (result.status !== NetworkV1ServiceTurnStatus.Drained && + result.status !== NetworkV1ServiceTurnStatus.MoreReady) { + fail("serviceTurn.status", "is unknown"); + } + assertIntegerInRange( + result.eventsDelivered, + 0, + request.maxEvents, + "eventsDelivered", + ); + assertIntegerInRange( + result.payloadBytesDelivered, + 0, + request.maxPayloadBytes, + "payloadBytesDelivered", + ); + if (result.eventsDelivered === 0) { + if (result.lastSequence !== 0 || result.payloadBytesDelivered !== 0) { + fail("serviceTurn.result", "must use zero sequence/bytes when no event was delivered"); + } + } else { + assertNetworkV1Sequence(result.lastSequence, "lastSequence"); + } +} diff --git a/contracts/spec/platforms.ts b/contracts/spec/platforms.ts index d3b6d079..3284b6bc 100644 --- a/contracts/spec/platforms.ts +++ b/contracts/spec/platforms.ts @@ -144,11 +144,6 @@ 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", // 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 14b14579..eb121124 100644 --- a/contracts/spec/pocket-manifest.ts +++ b/contracts/spec/pocket-manifest.ts @@ -8,6 +8,8 @@ import { export const POCKET_MANIFEST_VERSION = 2 as const; export const POCKET_MANIFEST_SCHEMA_ID = "https://pocketjs.dev/schema/pocket-2.json"; +export const POCKET_MANIFEST_V3_VERSION = 3 as const; +export const POCKET_MANIFEST_V3_SCHEMA_ID = "https://pocketjs.dev/schema/pocket-3.json"; export type JsonPrimitive = boolean | number | string; export type JsonValue = JsonPrimitive | null | JsonValue[] | { [key: string]: JsonValue }; @@ -69,6 +71,124 @@ export interface PocketManifestV2 { }; } +export const NETWORK_CONNECT_PROTOCOLS = [ + "http", + "https", + "ws", + "wss", + "mqtt", + "mqtts", + "tcp", + "tcp-tls", + "udp", +] as const; + +export const NETWORK_LISTEN_PROTOCOLS = [ + "http", + "https", + "ws", + "wss", + "tcp", + "tcp-tls", + "udp", +] as const; + +export type NetworkConnectProtocol = (typeof NETWORK_CONNECT_PROTOCOLS)[number]; +export type NetworkListenProtocol = (typeof NETWORK_LISTEN_PROTOCOLS)[number]; + +export type NetworkPort = number | Readonly<{ min: number; max: number }>; +export type NetworkListenPort = NetworkPort | "ephemeral"; + +export interface NetworkConnectPermission { + readonly protocol: NetworkConnectProtocol; + readonly host: string; + readonly port: NetworkPort; +} + +export interface NetworkListenPermission { + readonly protocol: NetworkListenProtocol; + readonly address: string; + readonly port: NetworkListenPort; +} + +export interface NetworkPermissions { + /** Empty endpoint lists are explicit deny-all policies for that direction. */ + readonly connect: readonly NetworkConnectPermission[]; + readonly listen: readonly NetworkListenPermission[]; + readonly localNetwork: boolean; + readonly insecureTransport: boolean; + readonly broadcast: boolean; + readonly multicast: boolean; + readonly allowInvalidTlsForDevelopment: boolean; + readonly browserAmbientCredentials: boolean; + readonly browserOpaqueWebSocketRedirects: boolean; + readonly credentials: readonly string[]; +} + +export interface NetworkResourceMinimum { + /** Every field is optional: an empty minimum accepts the Host defaults. */ + readonly runtime?: Readonly<{ + connections?: number; + pendingOperations?: number; + completionDescriptors?: number; + nativeBufferBytes?: number; + }>; + readonly stream?: Readonly<{ + receiveQueueBytes?: number; + sendQueueBytes?: number; + }>; + readonly http?: Readonly<{ + connections?: number; + inflightRequests?: number; + headerBytes?: number; + headerCount?: number; + bufferedBodyBytes?: number; + }>; + readonly websocket?: Readonly<{ + connections?: number; + messageBytes?: number; + queuedMessages?: number; + }>; + readonly mqtt?: Readonly<{ + connections?: number; + packetBytes?: number; + qos1Inflight?: number; + receiveQueueBytes?: number; + }>; + readonly udp?: Readonly<{ + sockets?: number; + datagramBytes?: number; + receiveDatagrams?: number; + }>; +} + +export interface CapabilityProviderAlternative { + readonly options: readonly (readonly string[])[]; +} + +/** Format 3 extends application intent with provider alternatives and the + * network permission/resource inputs consumed by the build resolver. */ +export type PocketManifestV3 = Omit< + PocketManifestV2, + "$schema" | "pocket" | "engine" +> & { + readonly $schema: typeof POCKET_MANIFEST_V3_SCHEMA_ID; + readonly pocket: typeof POCKET_MANIFEST_V3_VERSION; + readonly engine: { + readonly capabilities: { + readonly requires: readonly string[]; + readonly requiresOneOf?: readonly CapabilityProviderAlternative[]; + readonly enhances?: readonly string[]; + }; + }; + readonly permissions?: Readonly<{ network?: NetworkPermissions }>; + readonly resources?: Readonly<{ + network?: Readonly<{ minimum: NetworkResourceMinimum }>; + }>; +}; + +export type PocketManifest = PocketManifestV2 | PocketManifestV3; + /** A fixed-screen viewport declaration (takeover/kiosk/embedded targets). */ export interface FixedViewportSpec { readonly logical: Viewport; @@ -254,6 +374,231 @@ export const pocketManifestV2Schema = { }, } as const satisfies JsonSchema; +const networkPortRangeSchema = { + 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; + +const networkPortSchema = { + anyOf: [ + { type: "integer", minimum: 1, maximum: 65535 }, + networkPortRangeSchema, + ], +} as const satisfies JsonSchema; + +const networkListenPortSchema = { + anyOf: [ + { type: "integer", minimum: 1, maximum: 65535 }, + networkPortRangeSchema, + { enum: ["ephemeral"] }, + ], +} as const satisfies JsonSchema; + +const networkPositiveIntegerSchema = { + type: "integer", + minimum: 1, + maximum: 2147483647, +} as const satisfies JsonSchema; + +function networkResourceGroupSchema( + properties: Readonly>, +): JsonSchema { + return { + type: "object", + additionalProperties: false, + properties, + }; +} + +const networkResourceMinimumSchema = { + type: "object", + additionalProperties: false, + properties: { + runtime: networkResourceGroupSchema({ + connections: networkPositiveIntegerSchema, + pendingOperations: networkPositiveIntegerSchema, + completionDescriptors: networkPositiveIntegerSchema, + nativeBufferBytes: networkPositiveIntegerSchema, + }), + stream: networkResourceGroupSchema({ + receiveQueueBytes: networkPositiveIntegerSchema, + sendQueueBytes: networkPositiveIntegerSchema, + }), + http: networkResourceGroupSchema({ + connections: networkPositiveIntegerSchema, + inflightRequests: networkPositiveIntegerSchema, + headerBytes: networkPositiveIntegerSchema, + headerCount: networkPositiveIntegerSchema, + bufferedBodyBytes: networkPositiveIntegerSchema, + }), + websocket: networkResourceGroupSchema({ + connections: networkPositiveIntegerSchema, + messageBytes: networkPositiveIntegerSchema, + queuedMessages: networkPositiveIntegerSchema, + }), + mqtt: networkResourceGroupSchema({ + connections: networkPositiveIntegerSchema, + packetBytes: networkPositiveIntegerSchema, + qos1Inflight: networkPositiveIntegerSchema, + receiveQueueBytes: networkPositiveIntegerSchema, + }), + udp: networkResourceGroupSchema({ + sockets: networkPositiveIntegerSchema, + datagramBytes: networkPositiveIntegerSchema, + receiveDatagrams: networkPositiveIntegerSchema, + }), + }, +} as const satisfies JsonSchema; + +/** Strict format-3 application intent. Network authorization remains absent + * unless the manifest supplies the complete permissions.network object. */ +export const pocketManifestV3Schema = { + ...pocketManifestV2Schema, + $id: POCKET_MANIFEST_V3_SCHEMA_ID, + title: "Pocket application manifest, format 3", + properties: { + ...pocketManifestV2Schema.properties, + $schema: { const: POCKET_MANIFEST_V3_SCHEMA_ID }, + pocket: { const: POCKET_MANIFEST_V3_VERSION }, + 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, + }, + requiresOneOf: { + type: "array", + minItems: 1, + items: { + type: "object", + additionalProperties: false, + required: ["options"], + properties: { + options: { + type: "array", + minItems: 1, + items: { + type: "array", + minItems: 1, + uniqueItems: true, + items: capabilityIdSchema, + }, + }, + }, + }, + }, + enhances: { + type: "array", + items: capabilityIdSchema, + uniqueItems: true, + }, + }, + }, + }, + }, + permissions: { + type: "object", + additionalProperties: false, + properties: { + network: { + type: "object", + additionalProperties: false, + required: [ + "connect", + "listen", + "localNetwork", + "insecureTransport", + "broadcast", + "multicast", + "allowInvalidTlsForDevelopment", + "browserAmbientCredentials", + "browserOpaqueWebSocketRedirects", + "credentials", + ], + properties: { + connect: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["protocol", "host", "port"], + properties: { + protocol: { enum: NETWORK_CONNECT_PROTOCOLS }, + host: { type: "string", minLength: 1, maxLength: 253 }, + port: networkPortSchema, + }, + }, + }, + listen: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["protocol", "address", "port"], + properties: { + protocol: { enum: NETWORK_LISTEN_PROTOCOLS }, + address: { type: "string", minLength: 1, maxLength: 45 }, + port: networkListenPortSchema, + }, + }, + }, + localNetwork: { type: "boolean" }, + insecureTransport: { type: "boolean" }, + broadcast: { type: "boolean" }, + multicast: { type: "boolean" }, + allowInvalidTlsForDevelopment: { type: "boolean" }, + browserAmbientCredentials: { type: "boolean" }, + browserOpaqueWebSocketRedirects: { type: "boolean" }, + credentials: { + type: "array", + uniqueItems: true, + items: { + type: "string", + minLength: 1, + maxLength: 128, + pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + }, + }, + }, + }, + }, + }, + resources: { + type: "object", + additionalProperties: false, + properties: { + network: { + type: "object", + additionalProperties: false, + required: ["minimum"], + properties: { + minimum: networkResourceMinimumSchema, + }, + }, + }, + }, + }, +} as const satisfies JsonSchema; + 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/docs/NET.md b/docs/NET.md index dd16c767..d60f39b2 100644 --- a/docs/NET.md +++ b/docs/NET.md @@ -1,139 +1,59 @@ -# NET module - -The NET module gives a guest one bounded HTTP client API: - -```ts -import { fetch } from "@pocketjs/framework/net"; - -const response = await fetch("https://api.example.com/items", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Pocket" }), - timeoutMs: 5_000, - maxBytes: 64 * 1024, -}); - -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. - -## Module ownership - -| 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); -} -``` - -`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 -``` - -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. +# Networking status + +**NET v1 is not a public PocketJS API.** Stock web and sim runtimes do not +install `globalThis.net`, the capability registry does not contain `net.http`, +and `@pocketjs/framework/net` no longer exports the v1 `fetch`, `NetError`, or +`PocketResponse` values. + +The replacement API uses one `net` package namespace: + +| Module | Current contents | +| --- | --- | +| `@pocketjs/framework/net` | Shared `NetworkError`, `URL`, abort values, limits, and types | +| `@pocketjs/framework/net/http` | HTTP client and server declarations | +| `@pocketjs/framework/net/websocket` | WebSocket client, server, and upgrade declarations | +| `@pocketjs/framework/net/mqtt` | MQTT client declarations | +| `@pocketjs/framework/net/tcp` | TCP client and listener declarations | +| `@pocketjs/framework/net/udp` | UDP socket declarations | + +**Protocol I/O is staged and cannot be linked into an application yet.** Type +imports and the shared support values are available. Calls such as +`net/http.fetch()` fail with `NetworkError(code="unsupported")` outside a +PocketJS build, and the compiler rejects staged protocol values even when a +synthetic build feature is supplied. No stock target advertises a replacement +network capability yet. + +## Retained NET v1 implementation + +The following files remain as internal migration inputs: + +| Artifact | Retained material | +| --- | --- | +| `framework/src/net-api.ts` | Bounded request validation and Promise delivery | +| `contracts/spec/net.ts` | Old operation numbers, event records, limits, and error mapping | +| `engine/crates/pocket-net` | Handle ownership, body limits, and transport completion batching | +| `hosts/web/net.js` | Browser transport fixture with bounded response reads | +| `hosts/sim/net.ts` | Deterministic fixture routes and virtual-tick completions | + +These files are not package exports and stock hosts do not install them. A +migration test may import a fixture and assign its namespace explicitly; that +assignment is test setup, not runtime behavior. The retained files can be +removed after the replacement binding has equivalent tests for validation, +bounded allocation, cancellation, error normalization, and ordered delivery. + +## Replacement contract + +[pocketjs-network-architecture.md](./pocketjs-network-architecture.md) defines +the endpoint API, role-specific capabilities, policy resolution, private +native binding, delivery order, and hardware admission gates. + +**Network threads never call QuickJS.** Native transports enqueue bounded +events. The runtime drains those events during a scheduled network service +turn on the QuickJS owner thread. The native binding remains private to the +framework; applications receive only imports from `@pocketjs/framework/net` +and its protocol subpaths. + +Capability ids are added to the public registry only after a stock host +implements and tests the complete role. A client implementation does not +admit a server capability, and a board connectivity test does not by itself +admit either role. diff --git a/docs/RUNTIMES.md b/docs/RUNTIMES.md index 81211004..1a9a1648 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` | Internal NET v1 migration core: bounded HTTP validation, handle/body ownership, completion batches, and the old `HttpTransport` adapter. Stock hosts do not mount it. 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). | @@ -157,6 +157,12 @@ The grammar is implemented once, as infrastructure every runtime reuses: | `pocket3d-vita` | The 3D substrate, Vita edition: CPU projection and six-plane clipping into vita2d/GXM at 960x544, painter-sorted so a PocketJS HUD can share the same scene. | | `pocketjs-vita` (lib) | Guest hosting + `ui` surface, Vita edition: QuickJS, density-2 pak/font resources, controller/dual-analog input, logical-coordinate front-panel contacts and a native-density 960x544 vita2d backend over the portable 480x272 logical layout. | +**Stock web and sim hosts do not install `globalThis.net`, and `net.http` is +not a registered capability.** The replacement network package uses +`@pocketjs/framework/net` and protocol subpaths. Native network access will be +registered through a private framework binding only after a stock host passes +the protocol and hardware gates. + A specialized runtime is then a thin composition. OpenStrike is: ``` diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index e2924e80..2c3000b4 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -32,7 +32,7 @@ pocketjs/ ├─ vapor/ Pocket Vapor: the AOT compiler family (Vue Vapor subset → GBA/GB/NES) ├─ contracts/ single sources of truth binding the layers │ ├─ spec/ op contract, platform contracts, manifest + package spec, gen-rust -│ └─ schema/ published JSON schemas (pocket-2.json) +│ └─ schema/ published JSON schemas (pocket-2.json, pocket-3.json) ├─ apps/ demo apps (pocket.json manifests; built by tools/build.ts) ├─ tools/ every command: build/dev/device/release bun scripts (flat), │ plus cli/ (@pocketjs/cli), psplink/, imagegen/, and @@ -64,8 +64,14 @@ New things go where the axis says — never invent a top-level directory: ## Invariants the layout preserves -- **npm surface is frozen**: `@pocketjs/framework/*` export *keys* never - change; the `exports`/`files` maps in package.json absorb internal moves. +- **npm export keys are generated from `framework/compiler/subpaths.ts`**: + additions and breaking removals update that registry and the generated + `package.json` map together. Internal moves keep an existing key stable. +- **Network modules use one package namespace**: shared support values are in + `@pocketjs/framework/net`; protocol modules are `net/http`, `net/websocket`, + `net/mqtt`, `net/tcp`, and `net/udp`. These modules do not publish + `vue-vapor` or `octane` aliases. The former `net` fetch value API and its + framework-prefixed aliases are removed. - **Cargo stays non-workspace where toolchains demand it**: `engine/core`, `engine/wasm`, `engine/symbian`, `engine/backends/esp32p4-ppa`, `hosts/psp`, `hosts/vita`, `hosts/pocketbook`, and the gu/vita 3D crates each stand alone diff --git a/docs/pocketjs-network-architecture.md b/docs/pocketjs-network-architecture.md new file mode 100644 index 00000000..8a46a3b6 --- /dev/null +++ b/docs/pocketjs-network-architecture.md @@ -0,0 +1,1571 @@ +# PocketJS 网络架构 + +本文定义 PocketJS 端侧运行时(QuickJS Guest)的网络目标架构。Bun 只用于核对协议能力清单;公共范围、交付顺序和资源边界由 PocketJS 的端侧应用、现有 Host 与硬件约束决定。所有能力都使用 PocketJS 的显式模块导入、能力裁剪、权限检查、资源预算和 Host 装配模型。 + +**本设计替换当前 NET v1,不保留它在 `@pocketjs/framework/net` 下的旧导出、`globalThis.net` 或 `net.http` 公共兼容层。** `@pocketjs/framework/net` 路径由新的公共支持模块接管,协议模块统一位于它的子路径;当前实现中已经验证的有界分配、Host 注入和错误规范化机制可以迁移,但旧接口不是新架构的约束。 + +未来的浏览器 Host profile 直接使用浏览器提供的 `fetch`、WebSocket 等能力,并继续受浏览器 CORS、CSP 和安全上下文规则约束。本文重点是端侧 PocketJS 在 QuickJS 中提供一致能力;浏览器不模拟原始 TCP、UDP 或监听端口,且该 profile 按第 1.1 节保持 staged。 + +## 1. 已确定的范围 + +- 目标公共模块包括 HTTP Client / Server、WebSocket Client / Server、MQTT Client、TCP Client / Server 和 UDP;**目标目录不等于首批交付范围**。 +- 公共入口来自显式的 `@pocketjs/framework/*` 导入;Build Plan 决定可装配能力,manifest 生成不可变访问策略,Host 在每次连接和监听时执行该策略。import 本身不授予权限。 +- HTTP、WebSocket、MQTT、TCP 和 UDP 分别拥有 JS-free Protocol Core。各 Backend 的 wire 状态机独立;各模块共享异步运行时、operation、completion queue、buffer、timer 和 wake 机制。 +- HTTP、WebSocket 和 MQTT 的 reference implementation 与 native implementation 使用同一个按角色拆分的 Backend 契约。 +- Network Guest Binding 位于公共 SDK 与 Protocol Core 之间,持有 JS 对象、Promise 和 handler,并通过版本固定的私有 ABI 交换 command/completion。Protocol Core 和 Backend 永远不调用 Guest。 +- TLS 是按协议角色附加的可选能力。明文能力不会自动获得 TLS,TLS 失败也不会回退到明文。 +- 网络事件通过独立的 NetworkServiceTurn 进入 QuickJS。即使没有 UI frame,Promise continuation 和 JavaScript 网络 handler 也会运行。 +- `v1` 表示本文固定的角色级 API/wire contract 版本,不表示所有角色在同一个版本或同一台 Host 上同时发布。首批只交付 HTTP Client;其余角色按本节阶段表逐项进入。 +- AOT 网络执行不在本阶段范围内。AOT 构建可以拒绝网络模块,后续设计不能复用 QuickJS callback 细节作为 AOT ABI。 + +### 1.1 规范状态 + +本文按下列状态读取,后文更细的 API 描述不会覆盖该状态: + +| 状态 | 范围 | 含义 | +|---|---|---| +| 共同基线 | 第 8–20、24–25 节中的 capability、permission、Backend、turn、operation、queue、buffer、错误、资源、teardown 与 conformance 机制 | 从第一项网络能力开始生效;后续协议不能绕开 | +| 第一项公共能力 | 明文 HTTP Client,以及它实际使用的 `@pocketjs/framework/net` 支持类型 | 完成 ESP-IDF Phase 1A 后只声明 `network.http.client`;HTTPS 在 Phase 1B 单独准入 | +| 同一 Host 的后续能力 | HTTP Client TLS、WebSocket Client | HTTP TLS 通过独立 Phase 1B 后才声明 `network.http.client.tls`;WebSocket 只有 HTTP/substrate gate 通过后才进入 ESP-IDF Phase 1C。WSS 的 TLS role 另行 admission | +| staged target | HTTP Server、WebSocket Server/upgrade、MQTT、公共 TCP/UDP、Browser profile 与第 23 节完整 record/replay tooling | 保留目标契约和能力命名,**当前不构成交付承诺**;必须有具体用户故事、Host 预算和独立互操作测试后单独晋级 | +| future | TLS-PSK、mDNS/设备发现、HTTP/2/3、MQTT 5、DTLS 与真正无 UI daemon | 只保留边界或命名空间,不进入本轮公共 API | + +NetDriver 的 plain stream、listener 和 datagram 形状继续作为完整 substrate 设计保留;这不等于 Phase 1 对应用开放 TCP、UDP 或 Server capability。staged role 也不能因为文档已有字段就被 Host descriptor 宣告为已实现。 + +### 1.2 交付顺序与首批 Host + +| 阶段 | Host 与公开角色 | 必须通过的出口条件 | +|---|---|---| +| Phase 0 | 旧 NET v1 | 冻结旧 surface;在首个新 capability 合入前移除 `@pocketjs/framework/net` 的旧导出以及应用可达的 `globalThis.net` 和 `net.http`,内部代码只可作为迁移素材暂留;随后才由新的支持模块接管该 package path | +| Phase 1A | **ESP-IDF:明文 HTTP Client** | Shared Async Runtime、HTTP/1.1、权限、资源、取消/timeout、无 UI frame delivery、独立 HTTP peer 与目标硬件资源测试全部通过;只广告 `network.http.client` | +| Phase 1B | **ESP-IDF:HTTP Client TLS** | 在已经通过的 HTTP Core 上选择 ESP-TLS source;TLS 1.2、Host trust、可信时钟、hostname verification、SNI、无明文 fallback、独立 TLS peer 与握手资源测试全部通过后才广告 `network.http.client.tls` | +| Phase 1C | **ESP-IDF:WebSocket Client** | 复用已经通过的 substrate;通过独立 RFC 6455 suite、长连接、断链与背压测试。WSS 还必须独立准入 `websocket.client` TLS role | +| Phase 2A | **PSP:明文 HTTP Client parity** | 不扩大 JS surface;先证明 PSP 网络初始化、link/address 变化、IPv4 resolver/plain stream、owner-thread wake、内存预算和真机 teardown,再运行 HTTP 公共断言 | +| Phase 2B | **PSP:HTTPS/TLS gate** | 单独选择并验证 PSP TLS source/HTTPS Backend;通过独立 TLS peer、错误时钟和握手内存测试前不声明 `network.http.client.tls` | +| Phase 2C | **PSP:WebSocket Client parity** | Phase 2A/2B 通过后执行 WebSocket 长连接、WLAN 断开/恢复、休眠/恢复和真机资源测试;WSS 仍需要独立 TLS source/descriptor/peer gate,不能继承 Phase 2B 的 HTTP TLS | +| 后续阶段 | staged target 中的单个 role | 每个 role 独立提出用户故事、Host 列表、预算和 conformance gate;不能把整组协议一次晋级 | + +第一 ESP-IDF 落点使用 **ESP-IDF v6.0 最低基线**;首轮硬件工作锁定 v6.0.2 tag 的 commit `7101770dc6db2667b3c477cc31365dd1acd6db4e`。ESP32-P4 产品 Host 是首个产品集成目标,ESP32-S3 同时作为独立 Host profile 验证。当前 `hosts/esp32p4` 只提供可复用的 PPA renderer 侧和 smoke build,**不是已经具备 QuickJS 网络能力的完整产品 Host**;仓库也没有 ESP32-S3 产品 Host。Phase 1 必须分别补齐产品 BSP、Guest owner thread、network interface、wake、limits、descriptor、资源报告与生命周期集成后,目标 profile 才可声明完成。一个 profile 的结果不能外推到另一个 profile。 + +首轮板级 gate 使用以下固定组合: + +| Host profile | 开发板与数据路径 | 首轮验证边界 | +|---|---|---| +| ESP32-S3 | AtomS3R,ESP32-S3-PICO-1-N8R8,原生 Wi-Fi | 独立 firmware build/link、STA/DHCP、HTTP Client 与资源测试;板间测试中可以临时运行 HTTP Server,但这不开放公共 Server capability | +| ESP32-P4+C6 | Tab5 rev 1.3,P4 经 SDIO 连接板载 C6 | 独立 firmware build/link、C6 power/reset/transport、STA/DHCP、HTTP Client 与资源测试 | + +ESP32-P4 不原生提供 Wi-Fi。目标产品必须在 Host descriptor 中明确选择并验证具体 `esp_netif` 数据路径,例如外接 PHY 的 Ethernet,或通过 companion chip 提供的 [Wi-Fi expansion](https://docs.espressif.com/projects/esp-idf/en/v6.0/esp32p4/api-guides/wifi-expansion.html);不能把“ESP-IDF Host”解释为必然存在 Wi-Fi。link driver、BSP 和凭据配置属于产品 Host,公共网络模块不直接暴露板卡 SDK 概念。 + +Tab5 rev 1.3 的 image 必须选择 pre-v3 silicon,并锁定 1.x revision range:`CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y`、`CONFIG_ESP32P4_REV_MIN_100=y`。首轮 C6 transport 使用 `CONFIG_ESP32P4_TAB5_C6_BOARD=y` 对应的 SDIO1/四线/40 MHz board preset,并锁定 `esp_wifi_remote` 1.6.4 与 `esp_hosted` 2.12.12。BSP 必须在 `esp_wifi_init()` 前打开 Tab5 的 `WLAN_PWR_EN`;revision 支持不能通过烧 eFuse 或忽略 revision check 绕过。 + +Tab5 的 P4 GPIO15 经 1 kΩ 电阻直接连接 C6 `EN`,运行态必须保持高电平。`esp_hosted` 2.12.12 的 Tab5 preset 默认选择 active-low reset,并在 reset sequence 结束后把 GPIO15 保持为低,导致 SDIO CMD5 超时;host defaults 必须显式设置 `CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH=y` 并取消 `CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_LOW`。同时设置 `CONFIG_FREERTOS_HZ=1000`,避免 Hosted 在 100 Hz tick 下报告 bus-level jitter 风险。每次 clean configure 后必须从生成的 `sdkconfig` 复核 revision range、reset polarity、SDIO pins 和 tick frequency。 + +### 1.3 PSP 第二阶段边界 + +当前 PSP Host 是固定 frame loop 上的单 QuickJS worker,仓库尚未接入 PSP network、HTTP 或 TLS SDK。Phase 2A 不是“把 ESP-IDF adapter 换一层 FFI”,而是先补齐下列 Host 基础设施: + +- PSP utility/network module 与 AP profile 生命周期、WLAN/link/address 状态、IPv4 resolver/plain stream,以及不会隐式弹出系统配置 UI 的启动策略; +- owner wait 同时等待 network wake 和下一 frame deadline,native callback/worker 只写入有界队列;NetworkServiceTurn 不能等到下一次 vblank 才运行; +- `quickjs-counted` 的 job/instruction budget,以及 app switch 前有截止时间的 `Quiesce → ShutdownTurn → Release`; +- 精确锁定的 PSP toolchain 上的 build/link 证明,并在 PSP-1000 真机验证网络模块、内存、断链、WLAN-off、teardown 与 soak。PPSSPP 只用于 build、UI 和部分错误路径,不能替代真机网络/TLS conformance。 + +PSP 的内存 profile 不是固定的 2 MiB arena。当前 Host 使用 **1 MiB worker stack**;QuickJS、Rust allocation 与 newlib `malloc` 共享的 arena 默认取启动时最大可用分区减去 **2 MiB 外部安全余量**,实际容量随设备、包和启动状态变化。网络预算必须同时记录共享 arena、SDK native pool、socket buffer 与额外 thread stack 的峰值,继续保留外部安全余量;不能把模拟器一次测得的 arena 数字写成固定硬件预算。 + +Phase 2B 在两条路径中择一并记录到 descriptor:平台 `sceHttp`/HTTPS 可以作为带 `internalTls` 的 native `HttpClientBackend` 候选,但只有兑现 streaming、redirect、权限、错误和 TLS 合同后才能采用,且不能因此自称通用 `TlsProvider`;portable TLS provider 则与 Phase 2A 的 reference HTTP Backend 组合,并必须先通过 PSP 的 MIPS/no-std/toolchain、allocator、熵、trust store、SNI、证书时间与握手峰值验证。如果 Phase 2B 的 `backendByRole["http.client"]` 与 Phase 2A 不同,必须对最终 HTTPS Backend 重跑 Phase 2A 的完整 HTTP/framing/permission/resource suite,再运行 TLS suite。两条路径都未通过前,PSP 只允许保持 `.tls` capability 缺失。 + +## 2. 公共模块 + +下列代码列出目标 catalog。应用只从所需模块导入能力;构建目标尚未晋级的 staged module 必须在 Build Plan 阶段失败,不能生成运行期空壳: + +```ts +import { fetch, serve, Headers, Request, Response } from "@pocketjs/framework/net/http"; +import type { BodyStream } from "@pocketjs/framework/net/http"; +import { connect, serve as serveWebSocket, upgrade } from "@pocketjs/framework/net/websocket"; +import type { WebSocketUpgrade } from "@pocketjs/framework/net/websocket"; +import { connect as connectMqtt } from "@pocketjs/framework/net/mqtt"; +import { connect as connectTcp, listen } from "@pocketjs/framework/net/tcp"; +import { udpSocket } from "@pocketjs/framework/net/udp"; +import { + AbortController, + NetworkError, + URL, + getNetworkLimits, +} from "@pocketjs/framework/net"; +import type { + AbortSignal, + NetworkAddress, + NetworkData, + NetworkLimits, + TlsOptions, +} from "@pocketjs/framework/net"; +``` + +同名函数通过模块归属区分,应用可以在导入时重命名。所有网络模块位于 `@pocketjs/framework/net` package path namespace:根模块只提供公共类型、`AbortController`、真正可用于 `instanceof` 的 `NetworkError` class,以及只读的 `getNetworkLimits()`;导入根模块不会装配 I/O 能力。各协议子模块重导出的公共类型必须保持同一对象身份。 + +compiler 为每个 value export 记录 surface demand:`fetch` 对应 HTTP Client,HTTP `serve` 对应 HTTP Server,WebSocket `upgrade` 同时对应 HTTP Server、WebSocket Server 和 upgrade。resolver 再按目标把 Client demand 匹配到端侧 capability 或第 22 节的 Browser capability;manifest 必须通过 flat requirement 或 `requiresOneOf` 显式列出允许的 provider。type-only import 不产生 demand;re-export 传递 demand;namespace import 或无法静态解析的属性访问按该模块全部 value export 检查。tree shaking 不能删除 manifest 已声明的能力或权限。 + +manifest 的 `engine.capabilities.requires/requiresOneOf/enhances` 是授权真源。compiler 只验证 bundle demand 已被 manifest 覆盖,不能因为看见 import 自动授予能力。 + +### 2.1 公共支持类型 + +`@pocketjs/framework/net` 在所有协议模块间提供同一对象身份: + +```ts +type NetworkData = string | ArrayBuffer | ArrayBufferView; + +type NetworkAddress = { + family: "ipv4" | "ipv6"; + address: string; + port: number; +}; + +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"; +}; +``` + +`URL`、`AbortController`、`AbortSignal` 和 `NetworkError` 都由该模块提供,不依赖 QuickJS 是否预装浏览器 Web API。`TlsOptions.credential` 是 manifest/Host 配置中的 credential id;Guest 只能传 id,native Host 把它解析为 opaque handle,private key 不进入 JS。`serverName` 在 v1 只能省略或等于已授权 hostname。Server 使用 credential 属于基础 Server TLS;Client credential 与 Server `clientCertificate` policy 需要 `.tls.client-auth`,`ca/alpn/minVersion=1.3/revocation=required` 分别需要第 8 节对应 capability。`development-insecure` 只受第 9/14 节的双重开发开关控制。 + +`TlsOptions.ca` 在同步调用内 snapshot,并受 CA byte/count hard limit 约束。输入只能是一个 DER 编码 X.509 certificate,或只包含一个以上 `-----BEGIN CERTIFICATE-----` block 的 UTF-8 PEM bundle;DER 尾随数据、PEM private-key/其他 block、无效 base64 和 block 外非空白文本都在连接前拒绝。custom CA **追加到 Host trust store**,不替换系统 roots;v1 不提供 replace-only trust mode。解析后的 certificate 只进入 native `TlsPolicy`,不会保留 JS buffer 引用。 + +`NetworkData` 的 string 按 UTF-8 编码;ArrayBufferView 只 snapshot 当前 `byteOffset/byteLength` 窗口。detached buffer 在任何 command 进入 native 前以 `invalid_state` 失败。 + +`getNetworkLimits()` 返回冻结的 `NetworkLimits` snapshot。它是 capability/profile 查询,不执行动态协商。 + +下列 Bun 能力不进入本次网络接口: + +- 通过 `fetch` 读取文件、S3、Blob 或其他非网络 scheme; +- `BunFile`、Node.js stream 或 Node.js socket 兼容对象; +- HTTP routes、热更新和开发服务器功能; +- Unix domain socket; +- 隐式环境代理、隐式 cookie jar 和隐式 HTTP cache。 + +这些能力如有需要,应在文件、存储、开发服务器或兼容层中单独设计。 + +## 3. HTTP + +### 3.1 Fetch 对象基线 + +`Headers`、`Request`、`Response`、body mixin 和 `RequestInit` 的 Web-compatible 行为固定到 [WHATWG Fetch snapshot `586cd2a`](https://github.com/whatwg/fetch/commit/586cd2a44c2a865b37c166dc0740f3fb8bb220d6)。仓库必须把选中的 Fetch API conformance cases 固定到 [web-platform-tests snapshot `6437d68`](https://github.com/web-platform-tests/wpt/commit/6437d68e10721ed4b9b68101ec1ab1a1b67a3995) 并记录 allowlist;升级任一 snapshot 需要显式修改公共 contract,不能跟随 living standard 漂移。 + +首批 HTTP Client 公共对象精确包括以下成员;snapshot 中没有列出的 `blob/formData/bytes/trailers` 等成员不进入 prototype,provider 也不能额外暴露: + +- `Headers` constructor、`append/delete/get/has/set`、`entries/keys/values/forEach`、iterator 和 `getSetCookie()`;field name/value 的规范化、guard、重复值组合、`Set-Cookie` 分列和迭代顺序遵循上述 snapshot; +- `Request` constructor,以及只读 `method/url/headers/body/bodyUsed/signal/redirect`、`clone()`、`arrayBuffer()`、`text()` 和 `json()`; +- `Response` constructor,以及只读 `status/statusText/ok/headers/body/bodyUsed/url/redirected`、`clone()`、`arrayBuffer()`、`text()`、`json()`、`Response.json()` 和 `Response.redirect()`; +- `HeadersInit = Headers | Record | Iterable`;HTTP body input 是 `NetworkData | BodyStream | AsyncIterable | null`; +- `RequestInit` 的标准字段限于 `method/headers/body/signal/redirect`,PocketJS 扩展字段固定为 `timeouts/maxRedirects/tls/limits/ref`。未知字段按普通 JS dictionary 语义忽略;已识别但当前 provider 不支持的字段必须在 I/O 前以 `unsupported` 失败。 + +PocketJS 的明确偏差是:body 锁定、重复消费、无效 streaming clone 和 detached input 使用稳定 `NetworkError`,而不是依赖平台 `TypeError` 文案;网络、权限、timeout 和资源失败也统一为第 19 节 `NetworkError`。JSON syntax error 仍是 `SyntaxError`。`clone()` 必须在 `bodyUsed=false` 时创建有界双分支 tee;任一分支达到 hard limit 时向上游施加背压,不能无界缓存。**应用不能先顺序读完一个大分支再读取另一个分支;不再使用的分支必须立即 `cancel()`,否则滞后分支达到上限后会按设计背压上游。** Browser adapter 必须把 native Fetch 对象包装成本模块对象并规范化上述行为,不能把 browser prototype、header guard 差异或错误文案直接泄漏为另一个公共 API。 + +### 3.2 Client + +`@pocketjs/framework/net/http` 提供以下入口;它使用 Fetch 的 Request/Response 形态,具体偏差由本节固定: + +```ts +function fetch(input: string | URL | Request, init?: RequestInit): Promise; +function serve(options: HttpServeOptions): Promise; + +interface BodyStream extends AsyncIterable { + readInto(destination: Uint8Array): Promise<{ bytes: number; done: boolean }>; + cancel(reason?: unknown): Promise; +} +``` + +- 支持 `http:` 和在具备 TLS 能力时支持 `https:`。 +- method 接受合法 HTTP token,但拒绝 `CONNECT` 和 `TRACE`;HTTP 4xx/5xx 是成功 exchange,`fetch()` resolve,状态通过 `Response.status/ok` 表达。 +- request body 和 response body 使用有界流;`fetch()` 在响应头可用后 resolve,不等待完整 body。 +- `Request.body` 和 `Response.body` 是模块定义的单消费者 `BodyStream`,支持 async iteration、`readInto()` 和 `cancel()`。第 3.1 节定义的 `NetworkData`、`BodyStream` 和 `AsyncIterable` 可以作为 request/response body 输入。 +- 首批不暴露 `Request.trailers` / `Response.trailers`。正常读取到 terminal chunk/message end 时,HTTP parser 必须完整解析 incoming chunked trailer、执行第 21 节的字段与资源校验,然后丢弃已验证内容。应用 cancel/abort、redirect 放弃 body 或 peer 提前断开时不要求解析尚未到达的 trailer;Core 必须关闭连接,或只在有界 drain 已读到 message end 并完成同一验证后才允许复用。 +- Client `Request`/`RequestInit` 的 `Trailer` request header 遵循选定 Fetch snapshot 的 request guard,不会被接受或发送。staged Server `Response` 或任何异常 internal command 如果仍携带 outgoing `Trailer`,必须在进入 wire encoder 前以 `unsupported` 失败。公共 outbound trailer producer/API 只有在出现明确的端侧用户故事并补齐 streaming/lifetime conformance 后才能作为独立扩展晋级。 +- `BodyStream.readInto()` 同时最多一个 pending read,空 destination 被拒绝,只有 `{ bytes: 0, done: true }` 表示 EOF。Backend 报告下游容量后,Core 才能向 Guest Binding 发出下一次 producer credit。 +- JS `AsyncIterable` 由 Guest Binding 持有。Core 只有在下游可写时发出 `BODY_PULL(maxBytes)` credit;每个 body 最多一个 pending `iterator.next()`。Binding 收到 chunk 后先检查 `maxBodyChunkBytes`,再把整个 chunk snapshot 到有界 BufferLease chain;超过上限以 `resource_limit` 失败,不能只借用 JS buffer。credit 小于 chunk 时,剩余部分留在 native lease 中等待下一次 pull,不再次调用 iterator。 +- iterator 完成映射为 `BODY_END`,reject 映射为 body error;cancel、redirect 放弃 body 或 peer 断开时,Binding 至多调用一次 `iterator.return()`。return 自身失败只进入诊断,不能覆盖原始 cancel/error。 +- body 一旦被 reader、async iterator 或聚合 helper 锁定,其他读取方式返回 `invalid_state`;取消 reader 会传播到 HTTP Core 和 Backend。 +- `AbortSignal` 可以取消 DNS、连接、TLS handshake、发送、等待响应头和读取 body。 +- redirect 默认使用 `follow`,每一次跳转都重新执行权限检查、scheme 检查和资源预算检查。 +- redirect 支持 `follow`、`manual` 和 `error`,默认最多 5 跳且只能由应用降低。301/302 只把 POST 改为 GET,303 把 HEAD 之外的方法改为 GET,307/308 保留方法和 body。改为 GET 时移除 body 及其 content headers;跨 origin 时剥离 `Authorization`、`Proxy-Authorization` 和显式 Cookie header。 +- redirect 需要重发 body 而 body 是已经消费的 `BodyStream` 或一次性 `AsyncIterable` 时,以 `invalid_state` 失败,不发送下一跳。 +- timeout 分为 `connect`、`headers`、`idle` 和 `total`;所有 timeout 使用 Host 单调时钟。 +- `Response.arrayBuffer()`、`text()` 和 `json()` 是有界聚合 helper,超过调用或 Host 限制时以 `resource_limit` 失败。 +- 不提供 ambient cookie、cache 或 proxy。应用需要显式传入 header;Host 如需代理,应通过独立、可审计的配置提供。 +- string header 按 HTTP 语法校验,禁止 CR/LF 注入;header count、name/value bytes 和总 block 都受 `NetworkLimits` 约束。 +- SDK/Guest Binding 统一处理 body 锁定与 Promise settlement;Protocol Core 处理 redirect、取消、deadline、权限、预算和有序 completion。Backend 不自行增加重试或 redirect。 + +### 3.3 Server + +`serve(options)` 在 bind、权限检查和可选 TLS credential/handshake 配置完成后 resolve `HttpServer`;任何启动失败都 reject,应用不会拿到半初始化 server: + +```ts +const server = await serve({ + hostname: "127.0.0.1", + port: 8080, + tls, + limits, + fetch(request, server) { + return new Response("ok"); + }, + error(error) { + return new Response("internal error", { status: 500 }); + }, +}); +``` + +- handler 接收本模块定义的 `Request` 并返回 `Response`、`WebSocketUpgrade` 或相应的 Promise。 +- HTTP Core 等待 handler Promise,但受 server handler deadline 和 inflight budget 约束;peer 断开时触发 `request.signal` abort。 +- request 和 response body 均可流式传输;Backend 必须将 transport 背压传播到 body producer。 +- `HttpServer.stop({ graceful, timeout })` 停止接收新连接;graceful 模式等待已接受请求完成,达到 timeout 后强制关闭剩余连接。 +- `ref()` / `unref()` 控制 server 是否保持无 UI frame 的运行时存活,不改变 server 自身状态。 +- handler 在发送响应头前抛错时调用 `error`。响应已经开始后发生错误时,Core 终止当前 body/连接,不尝试发送第二个响应。 +- `error` handler 缺失、抛错或返回非法值时,未开始的响应使用固定 500 空 body 并关闭连接。 +- HTTP Client Backend 可以维护有界连接池;HTTP Server Backend 支持有界 keep-alive。v1 禁用 pipelining,前一响应完成前不向 Guest 投递同一连接上的下一请求。 +- v1 不支持 HTTP/2、HTTP/3、server push 或 transparent compression;没有对应 capability 时 native Backend 不得启用这些可观察行为。 + +## 4. WebSocket + +`@pocketjs/framework/net/websocket` 同时提供 Client、独立 Server 和 HTTP upgrade。 + +```ts +function connect(url: string | URL, options: WebSocketConnectOptions): Promise; +function serve(options: WebSocketServeOptions): Promise; +function upgrade(request: Request, options: WebSocketUpgradeOptions): WebSocketUpgrade; + +interface WebSocket { + readonly readyState: "connecting" | "open" | "closing" | "closed"; + readonly bufferedAmount: number; + send(data: NetworkData): WebSocketSendResult; + ping(data?: NetworkData): boolean; + pong(data?: NetworkData): boolean; + close(code?: number, reason?: string): void; + terminate(): void; + ref(): this; + unref(): this; +} + +type WebSocketSendResult = + | { status: "accepted"; bytes: number; needsDrain: boolean } + | { status: "backpressure"; bytes: 0 } + | { status: "closed"; bytes: 0 }; +``` + +所有 WebSocket handler 的返回类型是 `void`,返回 Promise 不会延迟下一条协议事件。handler 抛出的异常属于 Guest execution error,不会被包装成 `NetworkError`;Host 的 Guest error policy 决定是否终止 runtime。 + +### 4.1 Client + +`connect(url, options)` 在握手成功后 resolve `WebSocket`。options 包含 `headers`、`protocols`、TLS options、timeout、limits 和 handler: + +```ts +const socket = await connect("wss://example.com/socket", { + protocols: ["telemetry.v1"], + socket: { + open(socket) {}, + message(socket, data) {}, + drain(socket) {}, + ping(socket, data) {}, + pong(socket, data) {}, + close(socket, code, reason) {}, + error(socket, error) {}, + }, +}); +``` + +连接成功的 service turn 先把 socket 状态设为 open,再调用 `open`,最后 resolve connect Promise;对应 Promise continuation 在同一 logical turn 的 microtask checkpoint 中运行。握手前失败只 reject connect Promise,不调用尚未发布 socket 的 `error` / `close` handler。 + +### 4.2 Server 与 upgrade + +- `serve(options)` 在 bind、权限和可选 TLS credential 检查成功后 resolve 独立 `WebSocketServer`。 +- `upgrade(request, options)` 在 HTTP handler 中返回一次性的 `WebSocketUpgrade`,HTTP Core 随后将连接交给 WebSocket Core。 +- upgrade 使用 `HttpUpgradeLease` 传递 transport、HTTP parser 未消费的 bytes、peer metadata 和 Backend handoff kind。 +- lease 只能消费一次。reference HTTP Backend 可以交给兼容的 reference WebSocket Backend;native HTTP Backend 只有在 native WebSocket Backend 声明相同 handoff kind 时才能交接。 +- upgrade 需要 `network.websocket.server.upgrade`。Build Plan 在构建时验证 HTTP Server / WebSocket Server 的 handoff kind;不兼容的 Host 不提供该 capability,因此静态使用 `upgrade` 会构建失败,不把可预知的不兼容推迟到运行时。 +- JavaScript 永远不能访问 lease、native handle 或 parser 内部 buffer。 +- `WebSocketServer.stop({ graceful, timeout })`、server/socket `ref()` / `unref()` 与 HTTP/TCP 使用同一存活语义。 + +### 4.3 消息与背压 + +- `message` 数据为 string 或独立的 `Uint8Array` snapshot;应用修改该对象不会修改 native buffer。 +- `send(data)` 保持一个 WebSocket message 的原子边界。`accepted` 表示完整 message 已接受,`bytes` 可以为 0,因此合法空 text/binary message 没有歧义;`needsDrain` 表示已越过 high watermark。`backpressure` 表示 hard capacity 不足且 message 未接受;`closed` 表示连接已关闭。没有部分接受。 +- `bufferedAmount` 只统计已经由 Core 接受、尚未交给 transport 的 payload。 +- `ping()`、`pong()`、`close(code, reason)` 和 `terminate()` 是显式操作。 +- `ping/pong` payload 遵守 RFC 6455 的 125-byte control-frame 上限,返回值表示完整 frame 是否被接受;不允许部分 control frame。 +- receive queue、send queue、单 message 大小和 fragmented message 聚合都有硬上限。 +- message 超过单条上限时发送 close code 1009;完整 message 无法进入 receive queue 时发送 1013 并关闭。send 参数本身超过上限时同步抛出 `NetworkError(code="message_too_large")`。 +- `drain` 在 `send()` 返回 `backpressure` 或 `accepted/needsDrain=true` 后 arm;send queue 降到 descriptor 中固定的 `sendLowWaterBytes` 以下时触发一次。再次触发前必须重新 arm。 +- v1 不启用 per-message compression;native Backend 不得自行协商。Client/Server 分别需要 `network.websocket.client.compression` / `network.websocket.server.compression`。 + +## 5. MQTT + +`@pocketjs/framework/net/mqtt` 导出 `connect(options)`;下例在导入时把它命名为 `connectMqtt`。v1 使用 MQTT 3.1.1,支持 QoS 0/1、clean session、retained publish、Last Will、keepalive、topic wildcard 和可选重连。 + +```ts +function connect(options: MqttConnectOptions): Promise; + +interface MqttClient { + publish(topic: string, payload: NetworkData, options?: PublishOptions): Promise; + subscribe(filter: string | readonly Subscription[], options?: SubscribeOptions): Promise; + unsubscribe(filter: string | readonly string[]): Promise; + end(options?: { force?: boolean; timeoutMs?: number }): Promise; + ref(): this; + unref(): this; +} + +type MqttMessageInfo = + | { qos: 0; retain: boolean; dup: boolean; packetId?: never } + | { qos: 1; retain: boolean; dup: boolean; packetId: number }; +``` + +MQTT handler 返回 `void`,返回 Promise 不阻塞协议 ACK 或后续 event。transport/protocol error 时,Core 先 settle 本次不再保留的 operation,再调用 `error` 和 `disconnect`;启用重连时随后调用 `reconnect(client, attempt, delayMs)` 表示已经安排下一次尝试。 + +默认值是 `cleanSession=true`、publish/subscribe QoS 0、`retain=false`、keepalive 60 秒、自动重连关闭。收到的 topic 是 string,payload 是独立 `Uint8Array` snapshot。 + +```ts +const client = await connectMqtt({ + url: "mqtts://broker.example.com:8883", + clientId: "sensor-42", + username, + password, + cleanSession: true, + keepAliveSeconds: 30, + pingResponseTimeoutMs: 10_000, + ackTimeoutMs: 30_000, + will: { + topic: "devices/sensor-42/status", + payload: "offline", + qos: 1, + retain: true, + }, + reconnect: { + enabled: true, + minDelayMs: 500, + maxDelayMs: 30_000, + multiplier: 2, + jitter: 0.2, + maxAttempts: 20, + resubscribe: true, + }, + tls, + limits, + mqtt: { + connect(client, info) {}, + message(client, topic, payload, packet) {}, + disconnect(client, reason) {}, + reconnect(client, attempt, delayMs) {}, + drain(client) {}, + error(client, error) {}, + }, +}); + +await client.subscribe("devices/+/command", { qos: 1 }); +await client.publish("devices/sensor-42/status", "online", { + qos: 1, + retain: true, +}); +await client.unsubscribe("devices/+/command"); +await client.end({ force: false }); +``` + +### 5.1 Promise 与确认语义 + +- `connect()` 在收到成功 CONNACK 后先调用 `connect(client, { reconnected: false, sessionPresent })`,再 resolve;首次 DNS/TCP/TLS/CONNACK 失败立即 reject,自动重连 policy 只在至少一次成功 CONNACK 后生效。 +- QoS 0 `publish()` 在完整 packet 被有界 transport queue 接受后 resolve,不代表 broker 已经收到。 +- QoS 1 `publish()` 在收到匹配的 PUBACK 后 resolve。packet identifier、重发标志、ACK timeout 和 inflight table 由 MQTT Backend 管理。 +- send queue 或 QoS 1 inflight table 已满时,新的 `publish()` 立即以 `busy` 失败;容量恢复后触发 `drain`,不会把未接受的 publish 放入第二条等待队列。 +- `subscribe()` 在 SUBACK 后 resolve,并返回每个 topic filter 的实际 granted QoS;任何 failure code 都保留在结果中。 +- `unsubscribe()` 在 UNSUBACK 后 resolve。 +- 来自 broker 的 QoS 1 packet 只有在 payload 成功进入有界 receive queue 后才发送 PUBACK。队列已满时,Backend 在 ACK 前断开连接,使 broker 能按协议重投。 +- duplicate packet 可以再次触发 `message`,`packet.dup` 暴露协议标志。PocketJS 不承诺 exactly-once。 +- `subscribe(filter, options)` 是单 filter 简写;`subscribe([{ filter, qos }, ...])` 批量发送并返回同顺序结果。packet identifier 只有在对应 ACK 完成或 operation 终止后才能复用。 +- ACK timeout 不在仍存活的 transport 上主动重发。Backend 关闭 transport,Core 再按 reconnect policy 决定是否重连;这使 DUP 重发只发生在 MQTT 3.1.1 定义的 session 恢复路径。 + +### 5.2 输入与协议校验 + +- `clientId` 必须是有效 MQTT UTF-8、非空并满足 Host byte 上限;U+0000、surrogate、non-character 和协议禁止的 control character 被拒绝。 +- username 是 MQTT UTF-8;MQTT 3.1.1 不允许只发送 password flag,因此提供 password 而没有 username 时在 I/O 前失败。password、payload 和 Will payload 接受完整 `NetworkData`,string 按 UTF-8 snapshot,ArrayBuffer/View 按 bytes 处理。 +- publish topic 必须非空且不能含 `+` / `#`;subscription filter 按 MQTT 层级规则校验 `+` / `#` 位置。编码后长度同时受 65,535-byte 协议上限和更小的 Host 限制约束。 +- CONNACK refusal 分别映射 `mqtt_unacceptable_protocol_version`、`mqtt_identifier_rejected`、`mqtt_server_unavailable`、`mqtt_bad_credentials` 和 `mqtt_not_authorized`,并在 `NetworkError` 中保留规范 reason code。 +- Backend 在一个 keepalive 周期内没有发送其他 control packet 时发送 PINGREQ,并在 `pingResponseTimeoutMs` 内等待 PINGRESP;超时按 transport loss 处理。keepalive 与 PINGRESP deadline 不依赖 UI frame。 +- `end()` 进入 `EndRequested` 时原子禁用自动重连并取消尚未开始的 reconnect timer/attempt;此后不会调用 `reconnect` handler。若进入时 transport 已连接,graceful `end({ force: false, timeoutMs })` 停止接受新 operation,等待 send queue、QoS 1 PUBACK、SUBACK 和 UNSUBACK operation 全部完成;成功时发送 DISCONNECT,等待 Backend 确认 transport 已关闭后 resolve,Broker 不应发布 Will。 +- 若进入 `EndRequested` 时已经处于 disconnected/reconnecting,先前 transport-loss claim 仍是唯一的 `error/disconnect` 通知来源;Core 取消 reconnect、使保留的 pending operation 以 `closed` 失败,并在 resource release 后 resolve `end()`,不能合成第二次通知。这包括应用在 `disconnect` handler 内调用 `end()`。 +- 若进入 `EndRequested` 时 transport 已连接,但在发送 DISCONNECT 前发生新的 loss,该 loss 只按 transport-loss 顺序调用一次 `error/disconnect`;不再重连,`end()` 与其余 pending operation 以 `closed` 失败,Broker 可以发布 Will。达到 end timeout 时不发送 DISCONNECT,abort transport、以 `timed_out` reject end,并使其余 pending operation 以 `closed` 失败。`force: true` 立即禁用重连并 abort,不发送 DISCONNECT,使已有 pending operation 以 `closed` 失败,并在 Backend 确认 transport 已关闭后 resolve `end()`。 +- retained QoS 0/1 publish 都受相同确认规则;零长度 retained payload 表示删除 Broker 上的 retained message,不在 PocketJS 侧特殊丢弃。 +- Will topic 使用与 publish Topic Name 相同的非空、无 `+/#` 和 UTF-8 校验。没有 `will` 时 CONNECT 的 Will Flag、Will QoS 和 Will Retain 都为 0;提供 `will` 时 Will Flag 为 1,QoS 只允许 0/1,retain 来自显式 boolean,其他 flag 组合在 I/O 前拒绝。 +- `message(client, topic, payload, packet)` 的 `packet` 是冻结的 `MqttMessageInfo`;QoS 0 不暴露 packet id,QoS 1 必须暴露本次 PUBLISH 的 1..65,535 packet id。`retain/dup` 保留收到的 wire flag,不根据本地 session 推断。 + +### 5.3 Session、重连与离线行为 + +- 自动重连默认关闭。启用后,backoff、jitter、最大尝试次数和重新订阅由 MQTT Core 执行,native Backend 不得使用不可见的重连策略。 +- 每一次重连都会重新检查 hostname、解析后的地址、端口、TLS 和应用权限。 +- v1 不提供离线 publish queue。断线期间调用 `publish()` 以 `closed` 失败,避免不可见的持久化和无界增长。 +- transport 断开且没有启用重连时,所有未收到 PUBACK 的 QoS 1 publish 都以 `closed` 失败。 +- 任意 transport 断开都会使 pending SUBSCRIBE/UNSUBSCRIBE operation 以 `closed` 失败;只有已经 SUBACK 确认的 subscription 进入 resubscribe 集合。 +- `cleanSession=true` 时 CONNACK `sessionPresent` 必须为 false。断线后旧 inflight publish 失败;重连成功且 `resubscribe=true` 时重新建立已确认订阅,false 时清空 active subscription 状态,不把旧 publish 当作 session resume 重发。 +- `cleanSession=false` 时,原 `MqttClientBackend` 实例跨 transport reconnect 保留 packet identifier 与 wire inflight table,Core 保留对应 operation 与已确认 subscription。未确认 QoS 1 总是使用原 packet identifier 和 DUP=1 重发;CONNACK `sessionPresent=true` 时沿用 Broker session,为 false 且 `resubscribe=true` 时重新建立已确认订阅,为 false 且 `resubscribe=false` 时清空 active subscription 状态。 +- 每次成功 reconnect 收到 CONNACK 并完成上述 session decision 后调用 `connect(client, { reconnected: true, sessionPresent, resubscribed })`;应用可以据此重新声明未自动恢复的订阅。 +- 启用重连时,保留状态仍受原 inflight 上限、operation total timeout 和 maxAttempts 限制;超过任一上限后失败。 +- 本地 persistent session 只覆盖当前 `MqttClient` 对象和 runtime 生命周期。v1 不把 inflight、subscription 或 credential 写入 durable store;设备重启后的 session 恢复不在保证范围内。 +- MQTT Core 拥有 reconnect policy、JS operation 和已确认 subscription;同一个 Backend resource 拥有 wire session、packet identifier、ACK/retransmit 和 transport replacement。native SDK 无法禁用内部重连、控制 ACK 或保留 packet identifier 时,不能声明 v1 `network.mqtt.client` capability。 +- `ref()` / `unref()` 控制 client 和已安排重连是否保持运行时存活。 +- MQTT 5、QoS 2 和持久化离线队列是未来独立能力,不属于 v1。 + +## 6. TCP + +`@pocketjs/framework/net/tcp` 提供 handler 驱动与 pull 读取两种明确模式: + +```ts +function connect(options: TcpConnectOptions): Promise; +function listen(options: TcpListenOptions): Promise; +``` + +```ts +const socket = await connectTcp({ + hostname: "example.com", + port: 443, + tls, + socket: { + open(socket) {}, + data(socket, data) {}, + drain(socket) {}, + timeout(socket) {}, + end(socket) {}, + close(socket, error) {}, + error(socket, error) {}, + }, +}); + +const listener = await listen({ + hostname: "127.0.0.1", + port: 9000, + tls, + socket: { + open(socket) {}, + data(socket, data) {}, + drain(socket) {}, + end(socket) {}, + close(socket, error) {}, + error(socket, error) {}, + }, +}); +``` + +- 一个 socket 在创建时选择 handler 模式或 `readInto(destination)` 模式,二者不能混用。 +- `readInto(destination)` 返回 `Promise<{ bytes: number; done: boolean }>`。destination 不能为空;同一 socket 最多一个 pending read,第二个以 `busy` 失败;只有 `{ bytes: 0, done: true }` 表示 EOF。 +- handler 模式在 NetworkServiceTurn 中收到独立的 `Uint8Array` snapshot;snapshot 在 handler 返回后仍是普通 JS 对象,应用可以修改它,但它不引用 native buffer。 +- pull 模式只在 owner thread 的 NetworkServiceTurn 中把 native bytes 复制到调用方 `Uint8Array`。 +- `write(data: NetworkData)` 在调用返回前 snapshot JS 输入。string 先完整 UTF-8 编码并采用 all-or-none:返回完整 byte length 或 `0`,不会在多字节字符中间部分接受。二进制输入可以返回已接受的 prefix bytes;`0` 是没有接受,`-1` 是连接已关闭。 +- `flush()` 等待当前已接受 bytes 交给 transport,`shutdown()` 半关闭写方向,`close()` 请求正常关闭,`terminate()` 请求立即 abort。 +- write 返回 `0`,或二进制返回值小于输入 byte length 时,都会 arm `drain`;send queue 降到 descriptor 的 low watermark 后触发一次。所有 socket handler 返回 `void`;返回 Promise 不阻塞后续 I/O event。 +- connect 成功时先把 socket 设为 open,再调用 `open`,最后 resolve connect Promise。连接建立前失败只 reject Promise。远端有序 EOF 先调用 `end`;双方关闭或 abort 后再调用一次 `close`。 +- transport/protocol error 先使 socket 进入终态并 reject pending read/flush operation,再调用 `error`,最后调用 `close(error)`。正常关闭不调用 `error`。 +- `terminate()` 立即使 JS socket 进入终态并请求 native abort;native transport、late callback 和 BufferLease 在相关 operation retired 后释放。 +- `setTimeout(ms)` 是 idle timeout,不代替 connect、TLS 或 total timeout;`0` 禁用。收到 inbound byte 或 native 实际写出 outbound byte 时重置。一个 idle period 只触发一次 `timeout` handler,且不自动关闭;后续 I/O 会重新 arm。 +- `setNoDelay(enabled)` 和 `setKeepAlive(enabled, initialDelayMs)` 映射到可移植 socket option;不支持的平台在创建前通过 descriptor/Build Plan 报告。v1 不支持已连接 plain socket 的 in-place STARTTLS/upgradeTLS。 +- socket 和 listener 均提供 `ref()` / `unref()`;listener 提供 `stop({ graceful, timeout })`。 +- `listen()` 在 bind、权限和 TLS credential 检查完成后 resolve;失败只 reject Promise。accept queue 达到上限时暂停 accept,平台 backlog 溢出由平台拒绝,不创建备用 queue。 +- TCP Client 与 Server 的 TLS 能力分别声明;Server credential 通过 Host opaque handle 引用。 + +## 7. UDP + +`@pocketjs/framework/net/udp` 提供 connected 与 unconnected datagram socket: + +```ts +function udpSocket(options: UdpSocketOptions): Promise; +``` + +```ts +const socket = await udpSocket({ + connect: { hostname: "collector.example.com", port: 9000 }, + socket: { + data(socket, data, remoteAddress) {}, + drain(socket) {}, + error(socket, error) {}, + }, + limits, +}); +``` + +- 一个 socket 在创建时选择 `data` handler 或 `receiveInto(destination)` pull 模式,二者不能混用。 +- `receiveInto(destination)` 消费一个 datagram,返回 copied bytes、原始 datagram length、remote address 和 `truncated`。destination 太小时只复制前缀并丢弃剩余部分;零长度 datagram 仍返回一条带 remote address 的结果。 +- 保持 datagram 边界;零长度 datagram 是有效数据,不表示 EOF。 +- connected socket 的 `send(data)` 只能发送到固定 peer。unconnected socket 的 `send(data, address)` 每次执行权限检查。 +- connected socket 可以在异步创建时解析 hostname。unconnected `send` 的 address 必须是 numeric IP;v1 不在同步 send 路径中启动 DNS。 +- connected Client 未显式指定 local bind 时,由 connect permission 同时覆盖 Host 自动选择的 local ephemeral port。显式 bind 使用 port 0 时需要匹配 listen rule 的 `port: ephemeral`;远端 address 的 port 0 永远无效。 +- `send()` 返回 datagram 是否完整进入有界 send queue;UDP 不接受部分 datagram。`false` 只表示容量不足并 arm `drain`;socket 已关闭时同步抛出 `NetworkError(code="closed")`,不把两种状态压成同一个返回值。 +- `sendMany()` 先验证并 snapshot 整批 address、权限、单包大小和总 byte budget,任何无效项都会在发送前失败;验证成功后返回按输入顺序完整接受的 datagram 数量,遇到第一个无容量项时停止。 +- receive queue 满时丢弃最新 datagram,并增加可查询的 `droppedDatagrams`;不会覆盖已经排队的数据。 +- v1 单个 IPv4 datagram payload 上限为 65,507 bytes,Host 可以设置更小上限。 +- broadcast、multicast 和 IPv6 需要额外能力与权限;未声明时相关选项返回 `unsupported`。 +- `close()`、`ref()` / `unref()` 和本地/远端地址查询是公共能力。 +- `udpSocket()` 在 bind、connect 和权限检查完成后 resolve;启动失败 reject Promise。handler 返回 `void`;`drain` 只在 send 曾返回 `false` 且容量恢复后触发一次。 + +## 8. 构建能力 + +构建能力按协议、角色和 TLS 拆分: + +```text +network.http.client +network.http.client.tls +network.http.server +network.http.server.tls + +network.websocket.client +network.websocket.client.tls +network.websocket.server +network.websocket.server.tls +network.websocket.server.upgrade + +network.mqtt.client +network.mqtt.client.tls + +network.tcp.client +network.tcp.client.tls +network.tcp.server +network.tcp.server.tls + +network.udp +``` + +TLS capability 是附加项。应用使用 HTTPS Client 时必须同时声明 `network.http.client` 和 `network.http.client.tls`,其余协议与角色遵循同一规则。 + +独立 WSS Server 需要 `network.websocket.server.tls`。在 HTTPS handler 中执行 upgrade 时,TLS 已由 HTTP Server 持有,因此需要 `network.http.server.tls`、`network.websocket.server` 和 `network.websocket.server.upgrade`,不重复要求 WebSocket Server TLS。 + +基础 `.tls` 只承诺 Host trust store、授权 hostname 对应的 SNI、证书验证、TLS 1.2+ 协商和端侧 Server opaque credential。下列可观察扩展继续按协议与角色拆分: + +```text +network.http.client.h2 +network.http.server.h2 +network.http.client.h3 +network.http.server.h3 +network.http.client.compression +network.http.server.compression +network.websocket.client.compression +network.websocket.server.compression +network.mqtt.client.v5 +network.mqtt.client.qos2 +network.tcp.client.ipv6 +network.tcp.server.ipv6 +network.tcp.client.socket-options +network.tcp.server.socket-options +network.udp.ipv6 +network.udp.broadcast +network.udp.multicast + +network...tls.custom-ca +network...tls.client-auth +network...tls.alpn +network...tls.v1-3 +network...tls.revocation +``` + +只在某个协议/角色可用的 IPv6 必须使用相应 role-specific id,不能用一个全局 `network.ipv6` 扩大其他模块。TLS 1.3 如果只是 Host 可自动协商的更高版本,可以只出现在对应 `TlsRoleDescriptor.versions`;应用要求最低 TLS 1.3 时才需要 `.tls.v1-3`。 + +命名空间为未来保留 `network...tls.psk` 与 `network.discovery.mdns`,但它们不属于当前 manifest format 3 的可声明 capability;resolver 收到时必须报未知/不支持。保留名字不固定 JS options、secret provisioning 或 discovery API,也不允许 Host 提前用基础 `.tls` / endpoint permission 代替。 + +capability 保留 `requires/enhances` 规则,并在 manifest format 3 增加 provider alternative: + +- 静态 value import demand 对应的 required capability 缺失时构建失败; +- `enhances` 在目标不可用时由 Build Plan 记录为 false; +- 动态 URL 或 option 请求的 capability 不在 ResolvedBuildPlan 中时,在任何 I/O 前返回 `unsupported`; +- Backend descriptor 可以缩小目标能力,不能在运行期扩大 Build Plan; +- `network.websocket.server.upgrade` 只有在 Host 的 HTTP/WS handoff compatible 时才会进入目标 profile。 + +同一 app 同时面向端侧与 Browser 时使用 `requiresOneOf`。每个 `options` 元素是一组必须同时满足的 capability;resolver 按 manifest 顺序选择第一个被目标完整提供的 option,并把选择写入 canonical Build Plan: + +```yaml +engine: + capabilities: + requires: [ui.core] + requiresOneOf: + - options: + - [network.http.client, network.http.client.tls] + - [network.browser.http.client] + - options: + - [network.websocket.client, network.websocket.client.tls] + - [network.browser.websocket.client] +``` + +没有 option 可满足时构建失败;compiler surface demand 只有被 selected option 覆盖才算满足。只面向单一目标的 app 可以继续把实际 provider 写入 flat `requires`。 + +Browser Host 使用独立的 `network.browser.http.client` 和 `network.browser.websocket.client`,因为浏览器 API 不能兑现端侧 Client 的完整 option 和权限合同。它们复用同一公共模块,但不是 `network.http.client` / `network.websocket.client` 的等价实现。 + +## 9. 应用权限 + +构建能力表示代码和 Host 是否具备某项实现,应用权限表示这次构建允许访问哪些网络目标。获得 capability 不等于获得访问权限。 + +当前 Pocket manifest format 2 严格拒绝未知字段,因此网络权限需要 format 3;不能把未验证 JSON 旁路交给 Host。format 3 固定新增 `engine.capabilities.requiresOneOf`、顶层 `permissions.network` 与顶层 `resources.network`。以下是相关字段节选: + +```yaml +$schema: https://pocketjs.dev/schema/pocket-3.json +pocket: 3 +engine: + capabilities: + requires: + - network.http.client + - network.http.client.tls + - network.mqtt.client + - network.mqtt.client.tls + - network.http.server + - network.websocket.server + - network.websocket.server.upgrade + - network.tcp.server + - network.udp +permissions: + network: + connect: + - protocol: https + host: api.example.com + port: 443 + - protocol: https + host: "*.devices.example.com" + port: 443 + - protocol: mqtts + host: broker.example.com + port: 8883 + - protocol: udp + host: collector.example.com + port: 9000 + listen: + - protocol: http + address: 127.0.0.1 + port: 8080 + - protocol: ws + address: 127.0.0.1 + port: 8080 + - protocol: tcp + address: 127.0.0.1 + port: 9000 + localNetwork: false + insecureTransport: true + broadcast: false + multicast: false + allowInvalidTlsForDevelopment: false + browserAmbientCredentials: false + browserOpaqueWebSocketRedirects: false + credentials: [] +resources: + network: + minimum: + runtime: { connections: 4, nativeBufferBytes: 524288 } + stream: { receiveQueueBytes: 32768, sendQueueBytes: 32768 } + http: { headerBytes: 8192, bufferedBodyBytes: 262144 } + websocket: { messageBytes: 131072 } + mqtt: { packetBytes: 65536, qos1Inflight: 8 } + udp: { datagramBytes: 1472 } +``` + +仓库中的 manifest validator 同时接受 format 2 和 format 3。format 3 resolver 按 manifest 顺序选择每个 `requiresOneOf` provider option,规范化 endpoint tuple,并把 `ResolvedNetworkPolicy`、`ResolvedNetworkProviders` 和 resource minimum 写入 `ResolvedBuildPlan` 的 `planHash` 输入。网络 plan 经 `extractHostBuildInputs()` 投影为深度冻结的 `HostNetworkBuildInputs`;自定义 native Host 的环境投影包含同一 `planHash` 和 canonical network JSON。 + +**format 3 admission 基础不会自行发布网络 capability。** Stock target registry 仍不声明 `network.http.client`,compiler 的 HTTP value surface 仍保持 staged;只有公共 Guest Binding、descriptor 聚合和本节 conformance gate 完成后才移除该 gate。 + +每个 endpoint rule 是一个不可拆分的 `(protocol, host/address, port-or-range)` tuple,不对多个 protocol、host 和 port 做笛卡尔积。format 3 schema 对未知字段、非法 protocol、空 host 和越界 port 报错;resolver 在规范化后拒绝反向 range 和重复 rule。 + +v1 protocol token 固定为 `http`、`https`、`ws`、`wss`、`mqtt`、`mqtts`、`tcp`、`tcp-tls` 和 `udp`;Client 使用 host rule,Server/bind 使用 address rule。一个 rule 的 `port` 可以是单个整数、显式 `{ min, max }` 或 listen-only 的 `ephemeral`,不能是与其他字段组合的数组。 + +bind port 0 只匹配 `ephemeral`。Host 在 OS 返回实际 nonzero port 后核对它属于 Host profile 的 ephemeral range,失败则关闭 native handle;检查完成前不向 Guest 发布 server/listener/socket。`localAddress` 只在发布后返回实际端口。 + +`permissions.network.credentials` 只列出应用可引用的 Host credential id,不包含 key material。`TlsOptions.credential` 必须同时匹配该列表、当前协议角色的 TLS capability 和 Host policy;Host 再解析成 opaque handle。 + +HTTP upgrade 不创建第二个监听 socket,但会把公开协议从 HTTP/HTTPS 变为 WS/WSS;相同 address/port 必须同时匹配 HTTP 与 WS 的两个 listen tuple,Core 在消费 upgrade lease 前检查后者。 + +resolver 将 manifest rule 规范化为 `ResolvedNetworkPolicy`,并把 resource minimum 与目标/Backend hard limit 比较;任一 minimum 不可满足时构建失败。role → Backend/TLS source 的稳定 id selection、NetDriver id、resolved minimum 和 policy 都写入 `ResolvedBuildPlan` 的 canonical content 与 `planHash`。稳定 `HostBuildInputs` 必须携带 resolved features、`ResolvedNetworkProviders` selection、resource plan、policy 和 policy version;Host 只从已经验证的 package/plan 创建 runtime,并把它们作为不可变 native 数据传给 Protocol Core。Guest JS 不能提供、替换或扩大这些值。 + +`planHash` 只检查 canonical plan 是否意外损坏,不是授权签名。分发包的真实性仍由 Host/package admission 的签名或信任机制建立;不能把 Guest 传来的自带 hash 当作授权。 + +hostname 和地址在 resolver/runtime 中使用同一套规范化: + +- DNS name 转为小写 IDNA A-label,并移除一个末尾 root dot; +- `*.example.com` 只匹配恰好一个非空 label(如 `a.example.com`),不匹配根域或 `a.b.example.com`; +- IP literal 解析成 canonical binary address 后比较,不能使用 wildcard; +- URL 省略端口时,HTTP/WS 使用 80,HTTPS/WSS 使用 443,MQTT 使用 1883,MQTTS 使用 8883;TCP/UDP 必须显式给端口; +- v1 的 TLS SNI 必须等于已经授权的 normalized DNS hostname,IP literal 不自动替换成其他 SNI。custom SNI 不在 v1 基线。 + +运行期权限检查遵循以下顺序: + +1. 检查 ResolvedBuildPlan 中的协议角色、TLS capability 和 scheme; +2. 规范化并匹配一个完整 endpoint tuple; +3. 通过 Host resolver 解析; +4. 检查每一个候选 IP,过滤 loopback、link-local、private、multicast 和未授权地址; +5. 只连接通过检查的地址; +6. redirect、WebSocket upgrade、MQTT reconnect 和 DNS 重新解析后重复检查。 + +`localNetwork: false` 时,公开 hostname 解析到 loopback、link-local 或 private 地址也不能绕过限制。`localNetwork: true` 只允许已经匹配的 endpoint rule 解析到这些地址,不构成对整个私网的授权。 + +`insecureTransport: false` 优先于明文 HTTP、WS、MQTT 和 TCP rule,匹配 rule 也会被拒绝;设为 true 仍只能访问已列出的 endpoint。UDP 没有 TLS 对应项,不受该 flag 控制,只由显式 UDP rule、broadcast/multicast flag 和 capability 控制。TLS 验证失败不能转为明文重试。 + +`allowInvalidTlsForDevelopment` 只有在 Host 明确标记 development build 时才可生效,并且仍需调用方显式 option;production package admission 必须拒绝 true。它不允许明文 fallback。 + +## 10. 总体结构 + +```mermaid +flowchart TB + APP["JavaScript 应用"] + + subgraph MODULES["显式公共模块"] + HTTP["HTTP Client / Server"] + WS["WebSocket Client / Server"] + MQTT["MQTT Client"] + TCP["TCP Client / Server"] + UDP["UDP"] + end + + subgraph CORES["独立 Core"] + HC["HTTP Core"] + WC["WebSocket Core"] + MC["MQTT Core"] + TC["TCP Core"] + UC["UDP Core"] + end + + BINDING["Network Guest Binding / private ABI"] + + subgraph BACKENDS["按角色拆分的 Backend"] + HCB["HttpClientBackend"] + HSB["HttpServerBackend"] + WCB["WebSocketClientBackend"] + WSB["WebSocketServerBackend"] + MCB["MqttClientBackend"] + end + + REF["PocketJS reference protocol implementations"] + NATIVE_PROTOCOL["Native protocol adapters"] + DRIVER["NetDriver: resolver / stream / listener / datagram"] + TLS["TlsProvider"] + NATIVE_NET["Native network primitives"] + ASYNC["Shared Async Runtime"] + POLICY["Capability / Permission / Resource Policy"] + + APP --> MODULES + MODULES --> BINDING + BINDING --> HC + BINDING --> WC + BINDING --> MC + BINDING --> TC + BINDING --> UC + + HC --> HCB + HC --> HSB + WC --> WCB + WC --> WSB + MC --> MCB + + HCB --> REF + HSB --> REF + WCB --> REF + WSB --> REF + MCB --> REF + + HCB --> NATIVE_PROTOCOL + HSB --> NATIVE_PROTOCOL + WCB --> NATIVE_PROTOCOL + WSB --> NATIVE_PROTOCOL + MCB --> NATIVE_PROTOCOL + + REF --> DRIVER + REF --> TLS + TC --> DRIVER + TC --> TLS + UC --> DRIVER + DRIVER --> NATIVE_NET + TLS --> DRIVER + TLS --> NATIVE_NET + NATIVE_PROTOCOL --> NATIVE_NET + + ASYNC -.-> CORES + ASYNC -.-> BINDING + ASYNC -.-> BACKENDS + ASYNC -.-> DRIVER + POLICY -.-> MODULES + POLICY -.-> CORES + POLICY -.-> BACKENDS +``` + +一个 Host 可以按角色混合装配。例如,HTTP Client 使用 native Backend,HTTP Server 使用 reference Backend,WebSocket 和 MQTT 使用 reference Backend。装配在 runtime 创建时完成,运行中不能替换。Network Guest Binding 只看 spec-pinned ABI,不知道 Backend 是 reference 还是 native。 + +## 11. 分层职责 + +| 层级 | 负责 | 不负责 | +|---|---|---| +| 公共 SDK | JS 对象、构造参数、body lock、类型和显式导入 | native handle、平台 callback | +| Network Guest Binding | JS Promise/handler 表、command marshal、completion dispatch、private ABI | wire state、权限来源、平台 callback | +| Protocol Core | native 生命周期、operation、事件顺序、redirect/reconnect policy、deadline、取消、权限和预算 | JS 引用、wire parser、socket API | +| Protocol Backend | 协议连接、parser/framing、连接池、ACK、keepalive、协议级 retransmit | JS 对象、公共自动重试、权限决策 | +| NetDriver | resolver、plain stream、listener、datagram | TLS、HTTP、WebSocket、MQTT | +| TlsProvider | TLS Client / Server handshake、加密 I/O、证书验证 | 明文 fallback、应用权限 | +| Shared Async Runtime | operation、completion、buffer、timer、wake、typed turn | 协议状态机 | +| Host | Backend 装配、线程、时钟、熵、trust store、credential handle、硬上限 | 改变公共语义 | + +**Network Guest Binding 是唯一能创建和完成 JavaScript Promise、调用网络 handler 或持有对应 JS 引用的层。** Protocol Core、native callback、Backend worker 和 NetDriver worker 只处理 spec 数据和 native completion,永远不调用 Guest。这保持 PocketJS 的 SDK → Spec → Core 边界。 + +### 11.1 Private Guest ABI + +删除 `globalThis.net` 后,framework 源码使用 compiler-only specifier `pocketjs:internal/network-v1` 接入。应用 resolver 必须拒绝应用源码直接导入任何 `pocketjs:internal/*`;只有随 engine 版本发布的 framework 源码可以引用。 + +该 specifier 不作为 ES module import 留在产物中。规范只固定以下可观察边界: + +- private binding 必须在任何应用 module initializer、应用 callback 或应用可触发的 microtask 之前完成注入; +- framework closure 捕获冻结的 binding table 后,应用代码不能按名字导入或从持久 `globalThis` property 取得它; +- mount 前完成 ABI version 与 Build Plan feature-set 校验,失败时不执行应用入口; +- binding 隐藏只提供 API hygiene,**不是授权边界**;native Core 始终重新执行 Build Plan 与 ResolvedNetworkPolicy。 + +bundle factory 是目标注入机制,但它会改变所有 Guest artifact 与 loader 的调用约定,因此必须在独立的 build/artifact ABI 变更中固定,不能作为网络协议实现内部顺手修改。该方案把 private import 降为 framework module closure 内的 binding slot:Host eval 得到 factory function 后,用 QuickJS `JS_Call` 传入冻结的 native binding table;Browser build 使用同一 factory 参数传入 JS adapter。 + +现有 global-script loader 可以在实现 spike 中评估“bootstrap 临时安装 → framework capture → 删除 property → 执行应用入口”的过渡适配器,但不能预设它与 factory 等价。只有当每条 loader 路径都能证明整个窗口内不会运行应用 initializer/callback/microtask、删除后不可重新取得 binding、异常路径也会删除 property,并通过同一 private-ABI conformance 时,选定 Host 才可使用该适配器;否则 Phase 1 必须等待 bundle factory。无论采用哪种机制,网络运行期都不能保留 `globalThis.net` 或其他应用可达的 native binding。 + +private ABI 仍由 `contracts/spec/network/*.ts` 固定并生成 native 常量: + +- command 使用 append-only numeric opcode、resource/operation id、规范化 metadata 和 borrowed input buffer; +- native ABI adapter 在 owner thread 的同步 command 内把 borrowed input 复制到有界 pool;Protocol Core 只接收 owned `BufferLease`,borrow 不越过 adapter; +- completion 使用 append-only event code、generation、稳定 sequence、result metadata 和可领取的 BufferLease id; +- streaming body 固定 `BODY_PULL`、`BODY_CHUNK`、`BODY_END`、`BODY_ERROR` 和 `BODY_CANCEL` credit/event;Protocol Core 不持有或调用 JS iterator; +- Guest Binding 通过 `take/readInto` 在 owner thread 复制 payload; +- mount 时同时核对 ABI major/minor 与 Build Plan feature set,major 不匹配拒绝启动。 + +framework mount 时通过 binding slot 向 native runtime 注册一个 service dispatcher,Host 保存私有 QuickJS function handle。NetworkServiceTurn 调用该 handle,Guest Binding drain 已经由 Core 排序的 completion,并按 spec 顺序更新 Promise/handler 表。binding slot 不是安全授权边界;即使 bundle 被篡改,native Core 仍只执行 Build Plan 与 ResolvedNetworkPolicy 已授权的 command。 + +Protocol Core 对 Guest Binding 的 command/event 是唯一 Host 边界。reference/native Backend、NetDriver、TlsProvider 的对象和错误码都不能越过它。 + +## 12. Backend 契约与装配 + +高层协议按角色定义五个独立契约: + +- `HttpClientBackend`; +- `HttpServerBackend`; +- `WebSocketClientBackend`; +- `WebSocketServerBackend`; +- `MqttClientBackend`。 + +每个 Backend 在 runtime 创建时注册带稳定 id 的不可变 descriptor: + +```ts +type BackendRole = + | "http.client" + | "http.server" + | "websocket.client" + | "websocket.server" + | "mqtt.client"; + +type BackendDescriptorBase = { + id: string; + protocolVersion: string; + features: readonly string[]; + alpnProtocols?: readonly string[]; + handoffKind?: string; + hardLimits: Readonly; + defaultLimits: Readonly; +}; + +type BackendDescriptor = + | (BackendDescriptorBase & { + kind: "reference"; + internalTls?: never; + }) + | (BackendDescriptorBase & { + kind: "native"; + internalTls?: TlsRoleDescriptor; + }); + +type NetworkTlsRole = + | "http.client" + | "http.server" + | "websocket.client" + | "websocket.server" + | "mqtt.client" + | "tcp.client" + | "tcp.server"; + +type TlsRoleDescriptor = { + versions: readonly ("1.2" | "1.3")[]; + features: readonly ( + | "host-trust" + | "hostname-verification" + | "sni" + | "server-credential" + | "custom-ca" + | "client-auth" + | "alpn" + | "revocation" + )[]; + alpnProtocols?: readonly string[]; + hardLimits: Readonly<{ caBytes: number; caCertificates: number }>; +}; + +type TlsProviderDescriptor = { + id: string; + kind: "pocket" | "native"; + roles: Readonly>>; +}; + +type NetDriverDescriptor = { + id: string; + features: readonly ("ipv4" | "ipv6" | "socket-options" | "broadcast" | "multicast")[]; + hardLimits: Readonly; +}; + +type ResolvedNetworkProviders = { + backendByRole: Readonly>>; + tlsByRole: Readonly>>; + netDriverId: string; +}; + +type HostNetworkDescriptor = { + runtime: Readonly<{ + jobBudget: "quickjs-counted" | "browser-checkpoint"; + turnGuarantee: "quickjs-serialized" | "browser-task"; + }>; + backends: Readonly>>; + tlsProviders: Readonly>; + netDriver: NetDriverDescriptor; + selection: ResolvedNetworkProviders; +}; +``` + +reference 与 native Backend 必须接受相同的规范化 command,产生相同的 completion 和稳定错误。Core 不包含 `if native` 一类的平台分支。 + +`NetworkTlsRole` 使用 `http.client`、`http.server`、`websocket.client`、`websocket.server`、`mqtt.client`、`tcp.client` 和 `tcp.server`。reference protocol 与 TCP 使用 `TlsProviderDescriptor.roles[role]`,其 selection `source` 是 `provider`;内部完成 TLS 的 native protocol Backend 使用自己的 `internalTls`,其 `source` 是 `backend` 且 id 等于对应 `BackendDescriptor.id`。`source="backend"` 只允许 `kind="native"` 且存在该 role `internalTls`;reference Backend 携带 `internalTls` 或被选为 backend TLS source 都是 startup error。Host resolver 把 role → Backend、role → TLS source/provider 与 NetDriver id 写入 `ResolvedNetworkProviders`,再把选中的 descriptor 合成为不可变 `HostNetworkDescriptor`。 + +protocol/backend feature 与 TLS role feature 取交集生成有效能力。`alpn` feature 出现时,相关 ALPN 列表必须非空且去重:HTTP、WebSocket 与 MQTT role 的有效集合取 `BackendDescriptor.alpnProtocols` 和所选 `TlsRoleDescriptor.alpnProtocols` 的交集;TCP 没有 Protocol Backend,其 Guest 自己定义 stream 上层协议,因此有效集合直接使用所选 `TlsRoleDescriptor.alpnProtocols`,并仍受 Build Plan 中 role-specific `.tls.alpn` 与 Host allowlist 限制。应用提供的每一个 ALPN token 都必须属于对应有效集合,否则在 I/O 前以 `unsupported` 失败,不能删掉不支持的 token 后继续。HTTP/1.1 与 RFC 6455 over upgrade 的 v1 Backend 只允许 `http/1.1`;HTTP Client 提供 `h2` 还必须同时具备 `network.http.client.h2`、Backend `h2` feature 和双方 `h2` ALPN id。WebSocket over HTTP/2 没有独立 capability,不得因 HTTP h2 可用而自动启用。 + +Client 基础 `.tls` 要求该 role descriptor 同时包含 TLS 1.2、`host-trust`、`hostname-verification` 和 `sni`;Server 基础 `.tls` 要求 TLS 1.2 与 `server-credential`。`.custom-ca/.client-auth/.alpn/.revocation/.v1-3` 分别由同一个 selected role descriptor 验证,不能从另一个协议角色借用。即使多个 role 复用同一个 provider implementation,每个 role 也必须独立出现在 descriptor/selection 中并通过对应 conformance;HTTP Client TLS admission 不能被 WSS、MQTTS 或 TCP TLS 借用。 + +`ResolvedNetworkProviders` 进入 ResolvedBuildPlan canonical content 与 `planHash`;Host 在 bundle eval 前要求 `HostNetworkDescriptor.selection` 与它完全一致。`backends[role].id` 必须匹配 `backendByRole`;TLS selection 的 `source=provider` 必须匹配 `tlsProviders[id].id`,`source=backend` 必须匹配该 role 对应且 `kind="native"` 的 Backend id/internalTls;`netDriver.id` 必须匹配 `netDriverId`。Host 随后验证 aggregate descriptor 覆盖所有 required feature、handoff、TLS version/feature、ALPN id 和最低资源保证。重复 id、缺 role、非法 reference/internalTls 组合、id/role/selection 不匹配都是 Host configuration/startup failure;启动后不能替换 provider,也不能让已经构建成功的 required API 在调用时退化为 `unsupported`。descriptor 不能增加 Build Plan 未授予的 feature。 + +Backend 负责 wire 层状态: + +- HTTP 连接复用、request/response 编码和 parser; +- WebSocket framing、fragmentation、control frame 和 close handshake; +- MQTT packet identifier、QoS ACK、协议 retransmit、keepalive 和 session flag; +- native 平台 API 与 PocketJS completion 的映射。 + +Core 负责公共 policy: + +- redirect、MQTT reconnect 和 retry 是否发生; +- Guest Binding 应观察到的 handler、Promise 和 close/error spec event 顺序; +- capability、permission 和 resource budget; +- cancellation、timeout 和 teardown; +- JavaScript 可观察状态对应的 native resource 状态。 + +Backend 不得增加隐藏的自动重连、无限 queue、平台默认 cookie、平台默认 proxy 或公共接口未声明的协议扩展。 + +## 13. NetDriver + +NetDriver 是 plain network transport 边界,只提供: + +- hostname resolver; +- byte stream connect/read/write/shutdown/close; +- byte stream listener accept/close; +- datagram bind/connect/send/receive/close; +- local/remote address metadata; +- operation cancellation; +- completion queue 和 wake 接入。 + +NetDriver 不提供 SecureStream,也不包含 HTTP request、WebSocket message 或 MQTT packet。把 TLS 从 NetDriver 分离后,reference protocol、TCP 和 Host credential policy 使用同一个 `TlsProvider` 接口;该接口可以由 PocketJS TLS over plain stream 或 Host native secure primitive 实现。native protocol Backend 虽可内部调用平台 TLS,仍必须接受同一份 `TlsPolicy` 并产生同一类 TLS error。 + +### 13.1 Connectivity hint 与发现边界 + +NetDriver 可以向 Host scheduler 提供内部、非权威的 connectivity hint,至少区分 interface link 与可用 IP address generation。link/address 变化可以合并 wake,促使 pending connect 重新求值,或让 staged MQTT reconnect 提前结束 backoff;每次实际尝试仍必须重新执行 DNS、逐 IP permission 与真实 connect。**link up 不证明 Internet 或目标 endpoint 可达,link down 也不能替代已经发生的 operation terminal outcome。** + +首批不向 Guest 暴露 connectivity status API,避免把板级瞬时状态伪装成网络可达性真相。应用可见 surface 将来如有需要,应使用独立 module/capability,明确状态的非权威性质;不能扩充 `fetch()` 的成功语义。ESP-IDF 的 ESP-NETIF event 与 PSP 的 WLAN/APCTL event 都只适配到这条 Host 内部边界。 + +v1 resolver 不执行 mDNS query、service browse 或 service advertise,`.local` 名称也不能因为平台 resolver 偶然支持而隐式生效。遇到 `.local` 时在 I/O 前以 `unsupported` 失败。未来的解析/发现必须使用独立 `network.discovery.mdns` capability、明确的 local-network permission 和独立资源预算;endpoint permission 仍在解析后逐地址检查,发现结果不自动授权连接。 + +## 14. TLS 边界 + +```mermaid +flowchart LR + CORE["Protocol Core 或 TCP Core"] --> POLICY["TlsPolicy"] + CORE --> PROVIDER{"TlsProvider 接口"} + PROVIDER --> POCKET["PocketTlsProvider"] + POCKET --> STREAM["NetDriver plain stream"] + PROVIDER --> HOSTTLS["NativeTlsProvider"] + HOSTTLS --> SECURE["Host native secure primitives"] + CORE --> NATIVE["Native protocol Backend 内部 TLS"] + POLICY --> PROVIDER + POLICY --> NATIVE + HOST["Host trust / entropy / clock / credential handles"] --> PROVIDER + HOST --> NATIVE +``` + +`TlsPolicy` 至少包含: + +- Client hostname、SNI、ALPN、最低/最高 TLS version; +- 是否使用 Host trust store 或应用附加 CA; +- 可选 client credential handle; +- Server credential handle、client certificate policy 和 ALPN; +- Core 计算的 handshake deadline 与 cancel token。 + +### 14.1 ESP-IDF Phase 1B TLS source + +ESP-IDF Phase 1A 先固定 plain `NetDriver` 与 HTTP Backend;Phase 1B 再固定以下 TLS 装配候选,并把最终选择写入 canonical Build Plan/descriptor: + +- `NetDriver` 使用 lwIP socket 与 ESP-NETIF;BSP 必须先提供已初始化的具体 network interface,PocketJS 不负责配网 UI; +- TLS implementation 使用 [ESP-TLS](https://docs.espressif.com/projects/esp-idf/en/v6.0/esp32p4/api-reference/protocols/esp_tls.html) 及其默认 Mbed TLS backend。ESP-TLS 提供非阻塞连接、CA verification、SNI 和 ALPN 等 native primitive;Phase 1 不实现 `PocketTlsProvider`; +- 使用 reference HTTP/1.1 Backend 时,ESP-TLS 适配为独立 `NativeTlsProvider`,`tlsByRole["http.client"] = { source: "provider", id: }`; +- 使用 [ESP HTTP Client](https://docs.espressif.com/projects/esp-idf/en/v6.0/esp32p4/api-reference/protocols/esp_http_client.html) native Backend 时,HTTPS 由该 Backend 内部的 ESP-TLS 数据路径完成,descriptor 必须声明 `internalTls`,并选择 `tlsByRole["http.client"] = { source: "backend", id: }`;不能同时伪装为经过独立 `NativeTlsProvider`。 + +ESP HTTP Client 只作为 `HttpClientBackend` 候选。它必须证明 DNS candidate policy、逐跳 redirect、完整重复 header、严格 framing、streaming/backpressure、取消、timeout 和隐藏重试都能受 Core 控制;任一项无法兑现时,Phase 1 改用 reference HTTP/1.1 Backend over lwIP,并在 Phase 1B 接入 ESP-TLS。两种路径只能选择一个并写入 plan;选择变化会改变 `ResolvedNetworkProviders`/`planHash`,必须重跑 Phase 1A HTTP suite;若 TLS source 或最终 Backend 也变化,还必须重跑 Phase 1B TLS suite,不能放宽公共合同。 + +对锁定的 ESP-IDF v6.0.2 实现审阅后,ESP HTTP Client 当前只可作为 experimental transport spike。它在调用 response header callback 前会动态累积 parser/header 状态,公开 API 不提供任意 method token 或 wire reason phrase,同步 DNS/connect/TLS 没有安全的跨 task 取消点,`esp_http_client_cancel_request()` 也不能从 owner task 与 worker 并发调用。spike 必须把所有 handle 操作固定在同一个 worker,使用手动 `open/write/fetch_headers/read/close` 路径,关闭自动 redirect/auth retry,并在 descriptor 中把这些缺口标为 unsupported。解决有界 parser、method/status text、DNS policy 和可中断连接前,不能把该 Backend 选入正式 Build Plan。 + +Phase 1A 只要求 IPv4 与 HTTP/1.1,并且只接受 manifest 明确允许的明文 endpoint;它通过前,ESP32-P4+C6 与 ESP32-S3 profile 都不能在 target registry 中广告 `network.http.client`。Phase 1B 要求 TLS 1.2、Host trust、hostname verification 与 SNI;它通过前只能保留已经准入的明文 capability,不能广告 `.tls`。TLS 1.3 requirement、custom CA、client certificate、custom ALPN 和 revocation capability 默认 staged;只有对应测试和资源预算通过后,ESP-IDF descriptor 才能逐项增加 feature。 + +ESP-IDF Host 必须提供显式的 wall-clock trust 状态。时间可以由产品 provisioning、持久化 RTC 或 [ESP-NETIF SNTP](https://docs.espressif.com/projects/esp-idf/en/v6.0/esp32p4/api-reference/system/system_time.html) 建立,但“系统有一个数值”不等于可信。需要检查证书有效期而 wall clock 尚未可信时,TLS handshake fail closed 并映射为 `tls_certificate_invalid`,平台细节只进入脱敏的 `causeCode`;生产构建不能通过关闭有效期验证继续连接。 + +### 14.2 外部 TLS-PSK + +外部部署 PSK 与 TLS session resumption PSK 是不同能力。本文 v1 不把 external PSK identity/key 放入 `TlsOptions`,也不让 key material 进入 Guest。未来如提供,只能使用 role-specific `network...tls.psk` capability、opaque Host credential id、独立 permission 与 provider descriptor feature;当前 resolver 必须把该 capability 视为未知/不支持,不能借用基础 `.tls`。 + +必须满足: + +- v1 最低支持 TLS 1.2;Host 可以提供 TLS 1.3。 +- Client 默认验证完整证书链、有效期、hostname 和用途。 +- 生产构建不能关闭证书验证。开发构建的关闭验证选项必须同时经过显式 build flag 和应用权限。 +- TLS handshake 失败、证书错误或 ALPN 不匹配不会回退到明文。 +- Host 提供密码学熵、单调时钟、必要的 wall-clock、trust store 和 credential handle。 +- 端侧 Server private key 默认保存在 Host opaque credential handle 中,不进入 QuickJS heap、manifest 明文或错误信息。 +- native protocol Backend 可以使用平台 TLS,但不能改变验证、错误、timeout 和权限语义。 +- Protocol Core 是公共 connect/idle/total deadline 的唯一所有者;TlsProvider 只执行给定 handshake deadline 和 cancel token,不再启动第二套公共 timer。 +- v1 禁止 TLS early data / 0-RTT。应用附加 CA、mTLS、自定义 ALPN 或要求最低 TLS 1.3 时,必须声明第 8 节对应的 role-specific capability。 +- v1 不提供 cipher suite、signature algorithm 或 key-exchange group 的应用选择;Host security policy 必须禁用 anonymous/NULL/export/RC4/3DES,TLS 1.2 renegotiation 固定关闭。安全的 session resumption 可以启用,但仍执行相同 hostname、trust 和 permission policy。 +- base TLS capability 不承诺在线证书 revocation 查询;selected `TlsRoleDescriptor` 报告 `revocation` feature。应用设置 `revocation: "required"` 时需要 role-specific `.tls.revocation`,目标无法兑现则构建失败或在动态 option 使用前返回 `unsupported`。 +- UDP 不使用 TLS。未来 DTLS 需要单独模块、能力和 Backend。 + +## 15. Shared Async Runtime + +bundle eval、framework mount、private ABI registration 和初始 microtask checkpoint 属于一次性 bootstrap phase。bootstrap 完成后,Runtime 在同一个 QuickJS owner thread 上串行执行三种 application logical turn: + +端侧 QuickJS profile 的 `HostNetworkDescriptor.runtime` 固定为 `jobBudget: "quickjs-counted"`、`turnGuarantee: "quickjs-serialized"`;该字段属于整个 Shared Async Runtime,不由混合装配的单个 Backend 覆盖。 + +- `FrameTurn`:处理一个 fixed-step tick、输入与通用 effect delivery,最后允许 render/present; +- `NetworkServiceTurn`:投递网络 completion、handler 和 Promise checkpoint,不推进虚拟 frame 时间,也不 present; +- `ShutdownTurn`:执行有界取消与生命周期收尾。 + +DevTools 可以额外请求串行的 `DebugControlTurn` 做 inspect/evaluate;它不是应用 turn,也不能投递被冻结的网络 handler。native worker、平台 callback、ISR 和 debug transport 永远不能直接调用 QuickJS。 + +不包含任何网络 capability 的构建不会装配 Guest Binding、network wake 或 NetworkServiceTurn,继续保留现有纯 FrameTurn 行为与成本。 + +**本设计会修改 `docs/RUNTIMES.md` 的 Law 3。新约束是“同一 runtime 同时最多执行一个 logical guest turn;每个 Host execution slice 只属于一个 turn”;只有 FrameTurn 对应 fixed-step host tick。** Law 1 的 SDK → Spec → Core 边界和 Law 2 的 command/completion 边界保持不变。网络事实只能在 NetworkServiceTurn 边界进入 Guest。 + +`docs/DETERMINISM.md` 中“所有外部事实只在 frame boundary 进入”和“应用不等待 Promise”的表述继续适用于没有异步 capability 的 frame-only profile;网络模块基线使用本节定义的有序 `turnId` 与 sequence。只有声明网络 replay 保证的 Host 才使用第 23 节完整 `TurnRecord`。现有 `@pocketjs/framework/effects` 仍只在 FrameTurn delivery,网络 completion 不通过 effect shell。 + +### 15.1 Logical turn 与 execution slice + +一个 logical turn 可以由多个 execution slice 完成,但不能与另一个 logical turn 交错: + +1. scheduler 在 turn 边界选择一种 turn,并分配 `turnId`; +2. 该 turn 向 Guest 投递输入/completion; +3. 在同一总 job/instruction budget 内重复“drain QuickJS jobs 到当前为空 → `flushGuestTurn(kind)` 执行 renderer reactive work”,直到 pending job 与 framework reactive work 同时为空; +4. `endGuestTurn(kind)` 只执行 sweep/native commit,不调用应用代码,也不能再排入 QuickJS job; +5. turn 到此才完成,scheduler 才能选择下一个 turn。 + +event/byte budget 决定一个 NetworkServiceTurn 开始时最多接纳多少 completion。job count 与 instruction count 是确定性上限。执行到 job 边界需要暂时把控制权还给 Host 时,下一 slice 仍使用相同 `turnId` 并拥有最高续跑优先级;FrameTurn 或另一 NetworkServiceTurn 不能插入 Promise chain 中间。达到该 turn 的总 job/instruction 上限时以 Host `runaway_guest` termination reason 终止,不能把无限 microtask chain 当作正常分片继续。 + +wall-time 只作为非语义 watchdog,用于中断一个不返回的 JS invocation 或单个 job;它不决定正常 turn 切分,也不作为可移植 replay 输入。 + +### 15.2 Turn 选择 + +```mermaid +flowchart TD + EVENT["Frame deadline、network wake 或 shutdown"] --> READY["更新 ready state"] + READY --> CONT{"存在未完成 turn?"} + CONT -->|是| RESUME["续跑同一 turnId"] + CONT -->|否| SELECT{"选择新 logical turn"} + SELECT -->|Frame 与 service 同时 ready| FRAME["FrameTurn 优先"] + SELECT -->|仅 service ready| SERVICE["NetworkServiceTurn"] + SELECT -->|shutdown| SHUTDOWN["ShutdownTurn"] + FRAME --> AGAIN["返回 Host scheduler"] + SERVICE --> AGAIN + SHUTDOWN --> STOP["runtime 失效"] +``` + +- 同一次 ready 观察中 Frame 与 network 同时就绪时先开始 FrameTurn。Network ready 保持置位,FrameTurn 完成后立即开始 NetworkServiceTurn,不等待下一帧。 +- continuation slice 优先于任何新 turn,因此 frame-first 不会切断 Promise checkpoint。 +- NetworkServiceTurn 不推进 UI virtual clock、animation timeline、frame counter、input、gesture 或 `onFrame`。 +- Promise continuation 与 handler 即使在屏幕关闭或暂时没有 frame deadline 时也会运行。 + +### 15.3 Framework commit 边界 + +Host 和 framework 增加统一的 `beginGuestTurn(kind)` / `flushGuestTurn(kind)` / `endGuestTurn(kind)` transaction: + +- `FrameTurn` 的顺序是 virtual clock → input/touch → frame-boundary `__drainEffects()` → gesture/app frame handler → job/reactive quiescence loop → sweep → native core tick/render/present; +- `NetworkServiceTurn` 的顺序是有界 completion delivery → network handler/Promise settlement → job/reactive quiescence loop → sweep → non-presenting mutation commit; +- `ShutdownTurn` 只允许 cancellation/lifecycle delivery,不接受新的网络 command。 + +Solid、Vue Vapor 和 Octane 各自实现同一 quiescence/commit contract,不能把 Octane job flush 或 Solid sweep 留在只由 `globalThis.frame` 调用的路径中。`flushGuestTurn` 可以产生新 jobs,因此必须回到 quiescence loop;`endGuestTurn` 不得调用用户 callback。`__drainEffects()` 明确不在 NetworkServiceTurn 中运行。 + +网络 handler 引起的 reactive UI 修改发生在 non-presenting mutation transaction 中。UI Core 可以接受 create/set/destroy command 并提交内存状态,但不推进 layout animation、core tick 或 display pipeline;下一 FrameTurn render 时才形成可见像素。流水化 Host 可以在物理阶段 present 前一条已完成 draw list,但 logical FrameTurn 的 commit/tick/render 顺序不变。 + +### 15.4 NetworkServiceTurn + +开始一个新的 NetworkServiceTurn 时: + +1. Shared Async Runtime 按稳定 sequence 选择 event/byte budget 内的 terminal completion、ready resource item 和到期 Core timer; +2. Protocol Core 验证 runtime/resource generation,并把有序 spec event 交给 Guest Binding; +3. Guest Binding 更新 JS resource 状态,按规范顺序调用 handler、resolve/reject Promise; +4. 完成第 15.1 节的 job/reactive quiescence loop 与 framework commit; +5. 若仍有 completion、到期 timer 或 ready resource,保留 service ready 并重新 wake。 + +NetworkServiceTurn 不轮询所有 resource。第 17 节的 preallocated ready-resource list 负责定位有数据的 resource。 + +Core 的 connect、headers、idle、total、keepalive 和 reconnect timer 存在 Host 单调时钟驱动的有界 timer heap 中。到期通过 Shared Async Runtime 产生 timer outcome/ready event 并 wake;timer thread 不调用 QuickJS。operation terminal race 使用第 16 节的原子 claim,不因 DevTools pause 或缺少 UI frame 改变 wall-clock deadline。 + +### 15.5 DevTools pause + +debug transport 和 pause/resume control pump 必须移到 Host-native 路径,因此暂停 Guest 后仍能收到 resume。需要访问 JS 的 inspect/evaluate 使用 `DebugControlTurn`,并与 application turn 串行。 + +pause 时: + +- 不开始新的 application logical turn;已经开始的 turn 先到达安全 checkpoint 或由 watchdog 终止; +- native I/O、TLS、协议 ACK、MQTT keepalive、operation deadline 和必要的 close handshake 可以继续; +- JS handler、Promise job、redirect 和 Core 管理的 reconnect delivery 冻结; +- 数据只能进入既定的有界 native buffer;达到上限后按协议 pause read、断开或丢包; +- `stepTurn` 完整执行 scheduler 选择的下一个 logical turn,`stepFrame` 只完整执行下一个 FrameTurn;二者结束后继续 pause; +- resume 后按已 claim outcome 和稳定 sequence 投递,不伪造 pause 期间的 UI frame。 + +### 15.6 Headless 与存活 + +v1 的 headless 表示 **已经完成 UI Guest bootstrap/mount,但 Host 当前没有 frame deadline**,例如屏幕关闭、窗口挂起或只运行后台连接。`globalThis.frame` 和 manifest viewport 仍是 v1 Guest 要求;真正没有 UI framework/viewport 的 daemon execution class 不在本阶段范围内。 + +listener、server、socket、MQTT client 和独立 fetch/connect operation 默认 referenced,会保持 Host event loop 与 QuickJS runtime 存活。`unref()` 只取消存活引用,不关闭资源;resource 上创建的 read/write/flush operation 继承该 resource 当前 ref 状态,reconnect timer 继承 MQTT client 的 ref 状态。创建期尚未发布 resource 的 operation 使用 options 中的 `ref`,默认 true。 + +当不存在 referenced resource、referenced timer、referenced operation 或待完成 logical turn 时,Host 可以结束 runtime。unreferenced 工作可能因此被取消且不再向 JS delivery;native release 仍必须完成。UI Host 不能把“没有下一帧”本身视为退出条件。 + +`ref()` 只阻止 PocketJS 主动结束 runtime,**不是屏幕、CPU 或 radio wake lock**,也不覆盖固件/操作系统 suspend。“没有 UI frame 仍运行”只保证 Host process/owner task 仍可调度时的 delivery;目标如果会在 suspend 中停止 CPU 或网络,Host profile 必须明确写出该限制,不能广告持续后台时延保证。 + +resume 时,Host 先根据单调时钟处理已经到期的 deadline、底层连接丢失和 generation 变化,再通过一个或多个有界 NetworkServiceTurn 投递结果;不能把 sleep 时长从 timeout、WebSocket close 或 staged MQTT keepalive 中悄悄扣除,也不能为节电暗中改大公共 interval。link 恢复只产生第 13.1 节的非权威 hint,真正的 reconnect 仍执行完整 policy。需要保持 radio 唤醒的产品必须使用 Host/firmware 的独立电源策略,它不由网络 resource 的 `ref()` 隐式取得。 + +## 16. Operation 状态机 + +每个异步 command 创建带 generation 的 OperationId: + +```text +OperationId = runtimeGeneration + resourceGeneration + sequence +``` + +状态只有一条终止路径: + +```mermaid +stateDiagram-v2 + [*] --> Pending + Pending --> ClaimedSucceeded: success 赢得 claim + Pending --> ClaimedFailed: error 赢得 claim + Pending --> ClaimedAborted: cancel 赢得 claim + Pending --> ClaimedTimedOut: timeout 赢得 claim + Pending --> ClaimedClosed: resource close 赢得 claim + ClaimedSucceeded --> Delivered + ClaimedFailed --> Delivered + ClaimedAborted --> Delivered + ClaimedTimedOut --> Delivered + ClaimedClosed --> Delivered + Delivered --> Retired + Retired --> [*] +``` + +- success、error、abort、timeout 和 resource close 通过 Shared Async Runtime 的 native operation table 竞争同一个原子 claim,只有胜者产生 terminal completion。 +- Backend worker、Host timer 或 owner thread 可以执行 native claim,但都不能 settlement Promise;Guest Binding 只消费已经 claim 的结果并完成 JS Promise。 +- operation 开始前必须预留一个 terminal completion credit;长生命周期 resource 创建前也必须预留一个 lifecycle terminal credit。因此 Promise 终止和 resource close/error completion 永远不能因 queue 满而丢失。 +- abort/timeout 获胜后,Core 请求 Backend 取消底层工作;晚到 completion 只释放 native resource,不再次投递。 +- runtime 或 resource generation 不匹配的 completion 只能 cleanup。 +- 用户 `AbortSignal` 只产生 `aborted`;resource 在 operation 完成前正常关闭产生 `closed`;transport/protocol failure 产生其稳定 error code。它们不会互相改写。 +- 已发布 resource 的 terminal 顺序固定为:先使 resource 状态不可再接受 command并 settle 所有 pending operation,再在有底层错误时调用一次 `error`,最后调用一次 `close(error?)`。正常关闭不调用 `error`。TCP readable EOF 的 `end` 位于最终 `close` 之前;MQTT 的 `disconnect` 位于 reconnect decision 之前。 +- connect/listen/serve 在 resource 发布前失败只 reject 创建 Promise,不调用一个应用尚未取得的 resource handler。conformance test 验证这些规范,而不是自行决定顺序。 + +## 17. Completion queue 与 wake + +本节区分规范不变量与并发参考协议。Host 必须满足 capacity/credit 守恒、单终态、同一 ready node 至多在 list 中一次、payload 的 release/acquire 可见性、无 lost wake、稳定 merge 顺序和有界过载行为;这些结果是公共 substrate 合同。 + +### 17.1 规范不变量 + +- terminal completion 使用固定容量的 MPSC queue,由 QuickJS owner thread 单消费者读取。terminal credit 的生命周期固定为 `reserved → queued → delivering → retired`;任意时刻必须满足: + +```text +reservedTerminal + queuedTerminal + deliveringTerminal + queuedNonTerminal + <= completionQueueCapacity +``` + +- operation/resource 创建只有在能原子增加 `reservedTerminal` 时才成功。terminal enqueue 原子执行 `reservedTerminal → queuedTerminal`;owner dequeue 原子执行 `queuedTerminal → deliveringTerminal`。dequeue 会释放物理 ring cell,但该 admission credit 在 Guest Binding 完成 delivery、Operation 进入 `Retired` 或 stale-generation cleanup 结束前仍由 `deliveringTerminal` 持有;只有随后原子递减 `deliveringTerminal` 才归还 credit。 +- 非终态 descriptor 只有在上述总和仍小于 capacity 时才能原子增加 `queuedNonTerminal`,dequeue 后立即归还其 credit。四个计数使用一个 packed atomic ledger 或具备等价线性化点的同步;实现不能用分开的先减后增留下可被 producer 观察的空档。 +- 一个 resource lifecycle terminal descriptor 同时携带最终 error/close outcome,Guest Binding 根据第 16 节顺序展开为至多一次 `error` 和一次 `close`;因此每个 resource 一个 lifecycle credit 足够。 +- payload 不进入 descriptor,保存在 resource 的有界 `BufferLease` queue 中。 +- 每个 live resource 在创建时还预分配一个 intrusive ready node,并持有原子 `readyState = idle | listed | draining` 与原子 readiness mask。独立 MPSC ready-resource list 的容量等于可创建 resource 上限,因此不需要分配,也不扫描全部 resource;同一 node 任意时刻最多出现一次。 + +### 17.2 并发 MPSC 参考同步协议 + +下列 CAS、memory order 与 clear-then-recheck 是 concurrent MPSC Host 的参考实现和 model-checking 对象,不是唯一允许的指令序列。单线程 producer、mutex queue 或其他同步实现可以替代,但必须给出等价的线性化点与 happens-before 关系,并通过第 25 节在每个竞态点注入 producer 的 linearizability/lost-wake/model test;“平台通常不会撞到”不能作为替代证明。 + +- producer 先用 release enqueue 把 payload/accept item 放入有界 resource queue,并用 release `fetch_or(mask)` 发布对应 data/accept/writable readiness bit,再以 release 成功序尝试 `CAS(idle, listed)`。CAS 成功者把预分配 node 入列并发 wake;观察到 `listed` 或 `draining` 的 producer 不重复入列,因为 consumer 必须在释放 node 前重新检查 readiness。 +- consumer pop 一个 `listed` node 后以 acquire 成功序执行 `CAS(listed, draining)`,再用 acq_rel `exchange(0)` 原子领取本轮 readiness bits;不能用普通 load 后 store 清零。consumer 通过 acquire queue pop 取得与 producer release enqueue 对应的 item,把 node 加入 owner-thread merge/heap 并在本 slice 预算内消费。exchange 之后 producer 设置的新 bit 保留给本轮结束检查;本地已领取但因预算未消费的 bit 必须先用 release `fetch_or(mask)` 重新发布。仍有 item、`readinessMask.load(acquire) != 0` 或预算耗尽时,consumer 执行 `CAS(draining, listed)`、重新入列同一 node 并发 wake。 +- consumer 观察到 resource 已空时先执行 `CAS(draining, idle)`,随后用 acquire 顺序重新检查所有 queue 与 readiness bit;若又变为 ready,则尝试 `CAS(idle, listed)` 并重新入列。producer 若在转为 `idle` 后竞争成功,会自行入列;producer 若在此前观察到 `draining`,其 release 写入会被 consumer 的 recheck 观察到。该 clear-then-recheck 协议同时避免 lost wake 和重复链接。 + +### 17.3 顺序与过载 + +- 每个 payload/item 在进入 resource queue 时获得 runtime 单调 sequence。consumer 先把 ready-resource list 中的 node 放入 owner-thread bounded min-heap,再按各 resource 队首 sequence 每次取一个 item;resource 仍有数据时更新同一 heap entry。这样在不扫描全部 resource 的前提下保留跨 resource 的稳定顺序。 +- owner thread 按 sequence 对 terminal queue 队首、ready-resource min-heap 队首和 timer outcome 做有界 merge;同 sequence 使用固定 kind/resource tie-break,规则写入 spec。 +- queue/list 达到相应硬上限时,Backend 必须停止读、拒绝新 operation 或按协议关闭,不能创建备用无界 queue。 +- wake 是可合并信号;正确性依赖 terminal queue、ready list、`readyState` 和 clear-then-recheck,不依赖每个 event 对应一次 wake。descriptor 和 ready node 都不携带大 payload。 + +## 18. Buffer 所有权 + +**native worker 永远不能读取会被 JavaScript 并发修改的内存,也不能写 QuickJS heap。** + +- worker 和 Backend 只持有 native `BufferLease`。 +- JS 调用 `write`、`send` 或 `publish` 时,Guest Binding/private ABI 在 owner thread 上同步 snapshot 输入到有界 native pool,Protocol Core 只接收 owned buffer,然后函数才返回。 +- handler 模式由 Guest Binding 在 service turn 中把 native payload 复制为新的 JS string 或 `Uint8Array`。 +- `readInto` / `receiveInto` 由 Guest Binding 在 owner thread 上把 `BufferLease` 复制到调用方 buffer。 +- v1 不向 JavaScript 暴露 external ArrayBuffer 或零拷贝 native view,避免 detach、GC finalizer 和跨线程生命周期进入公共契约。 +- lease 在 queue、operation 或 protocol parser 间移动所有权;所有终止、取消、晚到 completion 和 teardown 路径都必须释放一次且仅一次。 +- pool exhausted 返回 `resource_limit` 或触发该协议的背压规则,不回退到普通 heap 的无界分配。 + +## 19. 错误模型 + +公共错误是 `@pocketjs/framework/net` 导出的真正 `NetworkError extends Error` class,支持 `instanceof NetworkError`,至少包含: + +```ts +class NetworkError extends Error { + readonly category: "runtime" | "resolver" | "transport" | "tls" | "protocol"; + readonly code: string; + readonly operation: string; + readonly temporary: boolean; + readonly address?: string; + readonly port?: number; + readonly protocol?: "http" | "websocket" | "mqtt" | "tcp" | "udp"; + readonly causeCode?: string; + readonly reasonCode?: number; +} +``` + +稳定 code 包括: + +| 分类 | code | +|---|---| +| Runtime | `aborted`、`timed_out`、`closed`、`invalid_state`、`busy`、`resource_limit`、`unsupported`、`permission_denied` | +| Resolver | `dns_not_found`、`dns_temporary_failure`、`dns_refused` | +| Transport | `connection_refused`、`connection_reset`、`network_unreachable`、`address_in_use`、`broken_pipe` | +| TLS | `tls_certificate_invalid`、`tls_hostname_mismatch`、`tls_handshake_failed`、`tls_version_unsupported`、`tls_alert` | +| Protocol | `http_protocol_error`、`websocket_protocol_error`、`mqtt_unacceptable_protocol_version`、`mqtt_identifier_rejected`、`mqtt_server_unavailable`、`mqtt_bad_credentials`、`mqtt_not_authorized`、`mqtt_protocol_error`、`message_too_large` | +| Fallback | `system_error` | + +平台 errno、TLS library code 和 native error 可以放在 `causeCode` 供诊断,但不能作为应用需要匹配的主 code。MQTT、TLS alert 等规范数值可以放在 `reasonCode`。`temporary` 只表示底层状况可能是暂时的,不授权自动 retry;Core 仍根据 method 幂等性、body 可重放性、public policy 和 deadline 决定 redirect/reconnect/retry。 + +`runaway_guest` 是 Host runtime diagnostic/termination reason,不是可向已经失去执行能力的 Guest 投递的 `NetworkError`。错误 message 不包含 credential、Authorization、Cookie、完整证书或 MQTT password。 + +## 20. 资源预算与过载行为 + +Host、Backend descriptor 和 Build Plan 都可以收紧限制: + +```text +effectiveHard = min(hostHard, backendHard, buildPlanHard) +baselineDefault = min(hostDefault, backendDefault, effectiveHard) +effectiveDefault = max(baselineDefault, resolvedManifestMinimum) +``` + +resolver 必须先证明 `resolvedManifestMinimum <= effectiveHard`;manifest minimum 因而可以在构建期把 admitted default 提高到 hard 以内。运行期应用 options 只能在 `resolvedManifestMinimum` 与 `effectiveDefault` 之间降低选定容量,不能低于 minimum,也不能临时提高,因为提高会绕过包构建时的内存准入。需要更大容量的 app 必须修改 `resources.network.minimum` 并重新解析 Build Plan。 + +Guest 可以通过只读 `getNetworkLimits(protocol?, role?)` 查询当前 build 的 effective default/hard、admitted minimum 和 feature flags,用于选择 chunk/queue 大小;返回值不能被用来扩大 policy。 + +预算至少覆盖: + +- runtime 总 connection、listener、pending operation、completion descriptor 和 native buffer bytes; +- 每协议 connection、server、inflight request 和 timer; +- 每 resource receive/send queue bytes 与 item count; +- HTTP header block、header count、URL、buffered helper body; +- WebSocket frame、message、fragment count 和 queue; +- MQTT packet、topic、subscription、QoS 1 inflight 和 receive queue; +- TCP chunk 与 queue; +- UDP datagram 与 queue; +- 每个 NetworkServiceTurn 的 event、byte、job 与 instruction budget,以及非语义 wall-time watchdog。 + +reference desktop/simulator profile 的建议默认值如下;它们不是所有端侧 Host 的可移植下限: + +| 项目 | 建议默认 | +|---|---:| +| completion descriptors | 1024 | +| service turn events / bytes | 128 / 256 KiB | +| 单 stream receive / send queue | 256 KiB / 256 KiB | +| HTTP header block / count | 64 KiB / 128 | +| HTTP buffered helper body | 8 MiB | +| WebSocket message / queued messages | 1 MiB / 64 | +| MQTT packet / QoS 1 inflight | 1 MiB / 32 | +| UDP datagram / receive queue | 65,507 bytes / 256 datagrams | + +ESP-IDF 与 PSP **不能继承这组 desktop 默认值**。每个目标 profile 在 capability admission 前必须提交可复现的资源报告: + +- ESP-IDF 分别记录 flash、internal DRAM、PSRAM、每个 FreeRTOS task stack、lwIP/ESP-NETIF pool、TLS handshake 峰值、HTTP buffer 与 display/framebuffer 并发峰值;可以放入 PSRAM 的 buffer 不能掩盖必须驻留 internal RAM 的 queue/task/native object; +- PSP 分别记录 QuickJS/Rust/newlib 共享 arena、1 MiB owner worker stack、PSP SDK network/HTTP/TLS native pool、socket buffer、额外 thread stack 和 arena 外 2 MiB safety margin; +- 两者都以目标硬件上的冷连接、复用连接、最大合法 header/body chunk、取消、redirect、反复 TLS handshake、queue overflow 与长时间 soak 的高水位决定 `hostHard/hostDefault`,不能从模拟器或单次 happy path 推导; +- 如果最小 HTTP Client profile 不能在保留 UI/display 与 Host safety margin 后满足这些上限,Build Plan 必须拒绝 network capability;不得用隐藏 heap、临时取消 limit 或关闭证书验证通过测试。 + +过载行为固定为: + +| 场景 | 行为 | +|---|---| +| 新 operation 或连接超预算 | 在调用边界以 `resource_limit` 失败 | +| listener accept queue 满 | 暂停 accept;平台 backlog 满后由平台拒绝连接 | +| HTTP handler/inflight 满 | 在能够安全响应时返回 503 并关闭;否则关闭连接,不调用 Guest handler | +| WebSocket upgrade capacity 满 | `upgrade()` 在消费 lease 前以 `resource_limit` 失败,HTTP handler 可以返回 503 | +| TCP/HTTP receive queue 满 | 暂停底层 read;若协议/平台不能暂停则关闭并报告 `resource_limit` | +| WebSocket message 超限 / receive queue 满 | 分别发送 1009 / 1013 后关闭 | +| MQTT QoS 0 receive queue 满 | 断开连接并报告 `resource_limit` | +| MQTT QoS 1 receive queue 满 | 在未 ACK 当前 packet 前断开并报告 `resource_limit` | +| UDP receive queue 满 | 丢弃最新 datagram,增加 drop counter | +| TCP send queue 满 | `write()` 返回 `0`,容量恢复后触发一次 `drain` | +| WebSocket send queue 满 | `send()` 返回 `{ status: "backpressure", bytes: 0 }`,完整 message 未接受,降到 low watermark 后触发一次 `drain` | +| UDP send queue 满 | `send()` 返回 `false`,`sendMany()` 返回已接受前缀数 | +| MQTT send/inflight 满 | 新 `publish()` 以 `busy` 失败,容量恢复后触发一次 `drain` | +| logical turn 超过 job/instruction 上限 | Host 以 `runaway_guest` 终止;仍完成 native teardown | + +## 21. 角色级协议基线与交付状态 + +本节的 wire pin 只约束已经通过 admission、并由端侧 Host descriptor 实际声明的 `network.*` role。规范锚点固定为 [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110) 的 HTTP semantics、[RFC 9112](https://www.rfc-editor.org/rfc/rfc9112) 的 HTTP/1.1 message syntax/framing、[RFC 6455](https://www.rfc-editor.org/rfc/rfc6455) 的 WebSocket,以及 [OASIS MQTT 3.1.1](https://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html)。PocketJS API 语义由本文定义;端侧 wire 收发不能偏离这些固定版本。 + +TLS 1.2 wire 固定到 [RFC 5246](https://www.rfc-editor.org/rfc/rfc5246),可选 TLS 1.3 固定到 [RFC 8446](https://www.rfc-editor.org/rfc/rfc8446),certificate path validation 固定到 [RFC 5280](https://www.rfc-editor.org/rfc/rfc5280),Client service identity 固定到 [RFC 9525](https://www.rfc-editor.org/rfc/rfc9525) 的 DNS-ID/IP-ID 规则并受本文更严格的 SNI/permission 限制。第 14 节禁止的 protocol/cipher/renegotiation 行为优先于 RFC 中的可选项。 + +| role | 声明该 capability 时必须满足的 v1 contract | 交付状态 | 未来扩展 | +|---|---|---|---| +| HTTP Client | HTTP/1.1、streaming body、keep-alive、redirect、AbortSignal;禁用 pipelining | ESP-IDF Phase 1A;PSP Phase 2A parity | HTTP/2、HTTP/3、compression、public trailers | +| HTTP Server | HTTP/1.1、streaming body、keep-alive、graceful stop;禁用 pipelining | staged | HTTP/2、HTTP/3、server push | +| WebSocket Client | RFC 6455、fragmentation、text/binary、ping/pong、close;WSS 使用独立 `websocket.client` TLS role admission | ESP-IDF Phase 1C;PSP Phase 2C parity | per-message compression | +| WebSocket Server / upgrade | RFC 6455 Server 与显式 HTTP upgrade lease | staged | per-message compression | +| MQTT Client | 3.1.1、QoS 0/1、retain、will、keepalive、当前 client/runtime 内的 clean/persistent session | staged | MQTT 5、QoS 2、durable offline/session store | +| TCP Client / Server | IPv4、half-close、handler/pull 两种读取模式 | staged public API;NetDriver plain stream 仍是 substrate | IPv6、Unix socket | +| UDP | IPv4 connected/unconnected、sendMany、handler/pull 两种读取模式 | staged public API;NetDriver datagram 仍是 substrate | IPv6、broadcast、multicast、DTLS | +| TLS | TLS 1.2、Host trust/hostname verification 或 Server credential、禁用 0-RTT | ESP-IDF HTTP Client Phase 1B;PSP HTTP Client Phase 2B;其他 role 随对应阶段 | 可自动协商 TLS 1.3;应用要求 1.3 需独立 capability | + +本表固定每个 role 的 v1 contract,不要求目标一次实现全部 role。前文各 API 章节定义 capability 被声明后的可观察语义;处于 staged 的 role 不能出现在 Host descriptor 中。Host Backend descriptor 必须准确报告扩展。静态 required 扩展缺失时构建失败,动态 option 未进入 ResolvedBuildPlan 时返回 `unsupported`,不能静默降级或用行为不同的模拟实现。 + +HTTP/1.1 parser 使用统一的严格 framing profile: + +- 同一 message 同时出现 `Transfer-Encoding` 与 `Content-Length` 时拒绝并关闭连接; +- `Transfer-Encoding` 只接受恰好一次且作为唯一 coding 的 `chunked`,未知 coding、组合 coding 和重复 `chunked` 都拒绝; +- 多个 `Content-Length` field line 或 comma-list 一律拒绝,即使数值相同;这是 PocketJS v1 为减少 parser 差异采用的更严格规则; +- 请求与响应中的 `obs-fold` 一律拒绝,不执行兼容性展开;header/trailer 的 byte、field-count、line-length 和 chunk-size 解析都受第 20 节 hard limit 约束; +- 正常到达 message end 时,chunked trailer 会被完整解析与验证;首批 HTTP Client 在验证后丢弃 trailer,不创建公共 Promise。`Content-Length`、`Transfer-Encoding`、`Host`、`Connection`、`Trailer`、`Upgrade`、`Authorization` 和 `Proxy-Authorization` 等 framing、routing、connection-control 或 authentication field 出现在 trailer 时按 protocol error 拒绝。body 被放弃且 trailer 尚未到达时,连接必须关闭;只有有界 drain 完成整个 message 和 trailer 验证后才可复用。 + +WebSocket framing profile 固定为:Client 发出的每个 frame 都 masked,Server 收到未 masked Client frame 时以 1002 关闭;Server 不 masked,Client 收到 masked Server frame 时以 1002 关闭。fragmented control frame、control payload 超过 125 bytes、非法 opcode、保留 bit 未协商、无效 close code 或无效 close payload layout 使用 1002;text message 或 close reason 的 UTF-8 无效时使用 1007;message 超过 hard limit 时使用 1009。 + +## 22. Browser Host + +本节是 staged target,不进入 ESP-IDF/PSP 的首批交付。未来 Browser Host 只报告 `network.browser.http.client` / `network.browser.websocket.client`,不报告端侧同名角色 capability。两种 profile 共享 SDK 类型,但按 descriptor 参加不同的 conformance 子集: + +| 公共行为 | Browser HTTP Client | Browser WebSocket Client | +|---|---|---| +| 底层 API | browser `fetch` | browser `WebSocket` | +| credentials | 强制 `credentials: "omit"` | 浏览器可能附带 ambient cookie,无法关闭 | +| cache | 强制 `cache: "no-store"`;仍受 Service Worker 控制 | 不适用 | +| redirect | native fetch 固定 `manual`;任何 `opaqueredirect` 都以 `unsupported` 失败,不跟随也不尝试取得 target | 浏览器隐藏握手 redirect,不能承诺逐跳检查 | +| response headers | 只暴露 CORS 与 forbidden-response-header 过滤后的集合;`Set-Cookie` 不可见,`getSetCookie()` 固定返回 `[]` | 握手 response header 不可见 | +| custom headers | 浏览器允许的 Fetch header 子集 | 不支持 | +| TLS options/certificate | 不支持,完全由浏览器安全上下文控制 | 不支持,完全由浏览器安全上下文控制 | +| ping/pong control | 不适用 | 不支持 | +| backpressure | ReadableStream/BodyStream adapter | 只基于 `bufferedAmount` 的 Browser profile 语义,不声称端侧 `drain` 保证 | +| peer/socket metadata | 不提供 | 不提供 | + +使用 Browser WebSocket capability 时,manifest 必须同时设置 `browserAmbientCredentials: true` 和 `browserOpaqueWebSocketRedirects: true`。前者表示应用接受 [WebSocket opening handshake](https://websockets.spec.whatwg.org/#opening-handshake) 可能携带**适用于目标 WebSocket URL 的 ambient credentials/cookies**;跨 origin 连接使用目标 URL 在浏览器 policy 下可发送的 cookie,不是当前页面 origin 的 cookie。后者表示浏览器可能在 Host 无法逐跳核对 manifest endpoint 时处理握手 redirect;否则 Build Plan 拒绝该 demand。这些 flag 不能减弱浏览器 CORS、CSP、mixed-content 或 cookie policy。 + +**直接后果是:Browser HTTP profile 中 endpoint 只要实际返回 redirect response,本次 `fetch()` 就以 `NetworkError(code="unsupported")` 结束。** Browser Host 不跟随、不暴露 target,也不能把不透明结果伪装成普通 3xx `Response`;需要跳转的服务必须向应用提供最终 URL,或在未来另行设计可审计的 Browser redirect policy。 + +unsupported option 必须在创建底层 browser object 前以 `unsupported` 失败。Browser HTTP 可以保持第 3.1 节的对象形状、状态和可见 header 规范化,但不能恢复浏览器安全模型已经隐藏的 response header 或 redirect target;上表是这两项内容差异的完整 v1 边界。浏览器自动选择 HTTP/2/3、TLS 版本、content decoding 或连接池可以作为不可见 transport 实现细节;第 21 节端侧 wire pin 不适用于 Browser capability。Browser descriptor 不能把相应 wire control 宣称为应用能力。 + +Browser WebSocket 只接受 `protocols` 以及 open/message/close/error handler;提供 `headers`、`tls`、ping/pong handler 或 `drain` handler 会在创建前失败,调用 `ping()/pong()/terminate()/ref()/unref()` 会同步抛出 `NetworkError(code="unsupported")`。`close()` 仍执行浏览器的正常 close handshake,不能用来模拟 endpoint `terminate()`。Browser profile 的 send queue 可以在调用 browser `send` 前执行 PocketJS hard-limit 检查,但容量恢复只能通过 `bufferedAmount` 观察,不承诺端侧 drain edge。 + +browser native callback 只把 completion 放入 Browser Host 的有界队列,再通过 task/message channel 安排 service dispatch;不能在 native callback 栈内 resolve 公共 Promise 或调用 handler。当前主线程 Browser Host 无法观察 microtask queue 真正 quiescent 的时刻,也不能抢占不返回的 task,因此 `HostNetworkDescriptor.runtime` 固定为 `jobBudget: "browser-checkpoint"`、`turnGuarantee: "browser-task"`: + +- delivery task 内的 Promise/microtask 与 reactive commit 由浏览器/framework scheduler 决定,不声明 QuickJS 的 serialized logical-turn/commit 保证; +- wall-time 只能在 task 返回后记录为诊断,不能作为可中断 watchdog; +- Browser TurnRecord 记录 task/inbound/outbound 顺序,但不承诺 byte-exact microtask 或 sweep replay。 + +浏览器冻结 background page 时可能延迟 task 或在浏览器内部缓存数据,Browser profile 不声明端侧 headless latency/内存保证;恢复后 PocketJS 仍只按自身有界队列投递。 + +如果 MQTT 未来在浏览器通过 WebSocket transport 提供,应声明独立的 `network.mqtt.client.websocket` 能力,并同时满足 WebSocket/TLS 权限;它不是 v1 MQTT raw TCP 的隐式 fallback。 + +## 23. 确定性与 record/replay + +完整 record/replay tooling 是 staged target,不阻塞 ESP-IDF HTTP Client 的首批 capability admission。Phase 1 仍必须生成稳定 `turnId`、sequence、`HostNetworkDescriptor.selection` 与 `ResolvedBuildPlan.planHash` 及有界诊断 metadata,以证明调度和 completion 顺序;只有实现本节全部记录、secret policy、replay 注入与 outbound 校验后,Host 才能声明网络运行具备可重放保证。 + +独立 NetworkServiceTurn 把确定性输入扩展为带 envelope、typed external-input tape 和有序 turn 的 replay log: + +```ts +type ReplayTraceEnvelope = { + schemaVersion: 1; + engineBuildHash: string; + artifactHash: string; + planHash: string; + providerSelection: ResolvedNetworkProviders; + hostProfileHash: string; + initialGuestStateHash: string; + limitsHash: string; + tracePolicyHash: string; +}; + +type RecordedPayload = + | { sha256: string; storage: "inline"; bytes: Uint8Array } + | { sha256: string; storage: "protected-ref"; protectedRef: string } + | { sha256: string; storage: "omitted" }; + +type RecordedCompletion = { + sequence: number; + event: number; + operationId: string; + metadata: RecordedPayload; + payload?: RecordedPayload; +}; + +type RecordedExternalInput = + | { sequence: number; kind: "frame"; frameId: number; deadlineNs: string; inputRef?: RecordedPayload } + | { sequence: number; kind: "effect"; effectId: string; result: RecordedPayload } + | { sequence: number; kind: "scheduler"; ready: readonly string[]; selected: string } + | { sequence: number; kind: "dns"; queryHash: string; candidates: readonly string[]; selected?: string } + | { sequence: number; kind: "timer"; timerId: string; deadlineNs: string; outcome: string } + | { sequence: number; kind: "rng"; source: string; value: string } + | { sequence: number; kind: "debug"; action: RecordedPayload } + | { sequence: number; kind: "lifecycle"; state: "suspend" | "resume" | "shutdown" }; + +type TurnRecord = { + turnId: number; + kind: "frame" | "network" | "shutdown" | "debug"; + selectedAtMonotonicNs: string; + externalInputStart: number; + externalInputEnd: number; + budgetMode: "quickjs-counted" | "browser-checkpoint"; + slices: readonly { + index: number; + events: number; + bytes: number; + jobs: number | null; + instructions: number | null; + continued: boolean; + }[]; + inbound: readonly RecordedCompletion[]; + outbound: readonly { command: number; operationId: string; metadataHash: string; bodyHash?: string }[]; + claimResults: readonly { operationId: string; outcome: string; sequence: number }[]; +}; + +type NetworkReplayLog = { + envelope: ReplayTraceEnvelope; + externalInputs: readonly RecordedExternalInput[]; + turns: readonly TurnRecord[]; +}; +``` + +record 模式把 frame/input、effect result、DNS candidate 与选择结果、连接/协议 metadata、payload bytes 或可验证的受保护引用、frame/network 同时 ready 的选择、timer deadline、suspend/resume/debug action,以及 MQTT reconnect jitter 等 Host RNG sample 写入 `NetworkReplayLog`。`TurnRecord.externalInputStart/externalInputEnd` 引用该 turn 消费的连续 tape 区间。wall-time watchdog 数值只进入诊断,不参与正常 turn 切分或 replay。 + +replay 模式不访问真实网络、真实 effect source、Host RNG 或 wall clock,只按有序 tape 注入 frame/input/effect、completion、DNS、timer、lifecycle、debug 和 ready state,并校验 Guest 发出的 outbound command/hash。对 `quickjs-counted` profile,只有 envelope 全部 hash/selection 相同、初始状态相同且 input tape 完整时,`NetworkReplayLog` 才必须产生相同的 handler、Promise continuation、claim 结果与 UI present 序列;单独的 `TurnRecord[]` 不具备该保证。 + +`browser-checkpoint` 记录的 `jobs/instructions` 为 null;replay 只验证 browser task 边界、inbound/outbound、Promise settlement 和同步公开 handler 顺序,不保证 Promise continuation、reactive sweep 或 UI present 序列。它不能与 `quickjs-counted` 日志混作同一保证等级。 + +敏感 header、credential 和 payload 的记录由单独的 trace policy 控制;默认不能把 secret 写入普通日志。若 policy 把 replay 所需内容记录为 `storage: "omitted"`,或任何 frame/input/effect/debug/timer/RNG/lifecycle 区间缺失,本次运行只能标记为不可完整 replay,不能用空数据冒充。`DebugControlTurn` 和 step action 也必须进入 typed tape,否则该段运行不具备完整 replay 保证。 + +## 24. Teardown + +Runtime teardown 分为三步: + +1. **Quiesce**:把 runtime 标记为 closing,冻结当前 generation,拒绝新 operation,清除所有 ref keepalive,并请求 Backend/NetDriver/TlsProvider 取消; +2. **ShutdownTurn**:用同一 `turnId` 重复执行有界 slice,投递必要的 abort、close 和生命周期回调并完成 microtask checkpoint;直到清空或达到 Host shutdown deadline,不接受新网络工作; +3. **Release**:推进 runtime generation,使 JS handle 失效,释放 operation credit、BufferLease、parser、timer、credential reference 和 native resource。晚到 completion 只执行 generation 检查与 cleanup。 + +达到 shutdown deadline、Host 强制终止或 runaway guest 时可以跳过剩余 JavaScript delivery,但不能跳过 native release。普通 `server.stop`、`socket.close` 和 `client.end` 仍通过 NetworkServiceTurn 完成,不触发 runtime teardown。 + +## 25. Conformance + +conformance harness 在首个协议实现之前建立,不是所有协议完成后的集成步骤。**每个 role 只有依次通过共同 substrate suite、该 role 的 Core/API suite、资源与竞态 suite、独立 peer wire suite、目标 Host build/hardware suite 后,才能写入 Host descriptor 或 target registry。** staged role 的测试条目是它未来晋级时的门槛,不阻塞 HTTP Client 首批交付。 + +reference 与 native Backend 对共同 feature 使用同一套断言;native Backend 不能以“平台 SDK 已测试”为由跳过。测试至少覆盖: + +- 公共模块需要显式 import,缺 capability 的构建失败; +- private Guest ABI version、opcode/event 生成值、borrow/copy 和应用 import 拒绝; +- manifest format 3 → ResolvedBuildPlan → HostBuildInputs → immutable runtime policy 的 canonical/hash/拒绝路径; +- Backend/NetDriver/TlsProvider 稳定 id、role selection、`ResolvedBuildPlan.planHash`、descriptor 聚合、runtime 调度属性、role feature 交集、基础 TLS 与扩展 capability 的 startup 拒绝路径; +- capability 与 permission 的 endpoint tuple、IDNA/wildcard/default port、协议、角色、TLS、redirect、reconnect 和私网边界; +- 没有 UI frame 时 Promise、handler、timeout、MQTT keepalive 和 reconnect; +- FrameTurn 与 NetworkServiceTurn 同时 ready 的顺序、continuation slice 优先级、完整 microtask checkpoint 和不饥饿; +- FrameTurn-only `__drainEffects()`、两种 turn 的 reactive flush/sweep 与 non-presenting mutation commit; +- success/error/abort/timeout race 只 settle 一次; +- completion capacity invariant、terminal credit、ready-resource list、sequence merge、重复 wake 和丢失 wake; +- pause/resume、headless `ref/unref` 和 runtime teardown; +- write snapshot、readInto copy、BufferLease 泄漏和 late completion; +- HTTP streaming、redirect、body cancel、normal-EOF incoming trailer 解析/禁用字段/验证后丢弃、放弃 body 后 close-or-bounded-drain-before-reuse、request `Trailer` guard、server graceful stop 和 upgrade lease; +- Fetch snapshot 的 constructor/Headers guard 与多值迭代、bodyUsed/clone tee/helper/error,以及 endpoint 与 Browser wrapper 的共同 API 断言; +- WebSocket fragmentation、空 text/binary message、control frame、backpressure、close handshake 和 oversized message; +- MQTT clean-session/session-present 矩阵、同 packet id + DUP 重发、packet-id 回收、混合 SUBACK、CONNACK reason、QoS 0/1 overflow、graceful/force end × reconnect × transport loss、pending ACK、Will flag/topic、`MqttMessageInfo`、零长度 retained publish、keepalive 和非法 UTF-8/保留 flags; +- TCP half-close、partial write、drain、listener shutdown; +- UDP zero datagram、边界保持、sendMany、drop counter; +- TLS hostname、trust chain、expiry、ALPN、client/server credential、custom CA DER/PEM/append 语义、无明文 fallback; +- stable `NetworkError` 映射和 secret redaction; +- Phase 1 的稳定 `turnId`/sequence、`HostNetworkDescriptor.selection`、`ResolvedBuildPlan.planHash` 与有界诊断 metadata; +- 只有声明网络 replay 保证的 Host 才额外要求完整 TurnRecord、slice continuation、replay 注入和 outbound command 校验。 +- Browser HTTP/WS capability matrix、unsupported option 早期失败、所有 opaque redirect 拒绝、filtered response header/空 `getSetCookie()`、目标 WebSocket URL ambient credential opt-in 和 browser microtask profile。 + +测试工具提供 deterministic fake clock、fake wake、固定容量 queue、受控 BufferLease pool 和 scriptable Backend。协议 parser 使用分片输入、恶意长度、半包、重复 packet 和 fuzz corpus 测试。completion queue 测试必须强制覆盖 `reserved → queued → delivering → retired` 各阶段并发 admission;ready-list 测试必须在 `draining → idle → recheck` 的每个竞态点注入 producer。reference 与 native Backend 对其 descriptor 声明的共同 feature 运行相同可观察断言;Browser profile 只运行其 capability matrix 对应断言。 + +除 fake Backend 和 PocketJS reference self-test 外,每个晋级 role 还需要独立实现参与的真实 wire 黑盒互操作:HTTP Client 对独立 Server、HTTP Server 对独立 Client、WebSocket 运行 Autobahn Testsuite 或同等级独立 RFC 6455 suite、MQTT 连接独立 3.1.1 broker(例如 Mosquitto),TCP/UDP 分别使用独立 echo/half-close peer 与 datagram peer。PocketJS TLS Client 必须对独立 TLS Server 测试,PocketJS TLS Server 也必须对独立 TLS Client 测试;只让 reference 与 native Backend 互测不能作为 wire conformance 通过条件。 + +Phase 1A 的 ESP-IDF 明文 HTTP admission 额外要求: + +- 固定 ESP-IDF v6.0.2 commit `7101770dc6db2667b3c477cc31365dd1acd6db4e`、具体 board/BSP 和具体 Ethernet/Wi-Fi expansion;AtomS3R 与 Tab5 分别完成 firmware build/link,并分别记录 flash、internal DRAM、PSRAM、task stack 与 native pool; +- 真机证明停止 UI frame 后 HTTP Promise/handler 仍运行,所有 QuickJS 调用只在 owner task,frame/network 同 ready、取消/timeout/success race 与 teardown 符合第 15–18 节; +- 冷启动、DHCP、DNS 多候选、link down/up、私网过滤,以及没有 BSP network interface 时 capability admission 失败; +- 独立 HTTP peer 覆盖 streaming、chunked/trailer validation、TE+CL、重复 CL、obs-fold、逐跳 redirect permission、各阶段 abort/timeout、4xx/5xx 与连接复用,并证明没有隐藏 retry/redirect/auth/cookie/proxy/compression; +- 最大并发、queue overflow、反复连接/取消、长流和至少一个规定时长的 soak,全部资源高水位保持在声明 hard limit 内且无泄漏/碎片持续增长。 + +Phase 1B 的 ESP-IDF HTTP TLS admission 在 Phase 1A 全部断言之上额外要求: + +- 独立 TLS PKI 覆盖 TLS 1.2、有效链、未知 CA、expired/not-yet-valid、hostname mismatch、SNI、无可信 wall clock 与无明文 fallback; +- TLS handshake 的取消、timeout、反复连接与至少一个规定时长的 soak,握手内存、native allocation、task stack 和连接资源高水位保持在声明 hard limit 内; +- HTTPS 重跑 Phase 1A 的 redirect、streaming、framing、错误与 teardown 断言,证明 TLS wrapper 不改变 HTTP 可观察语义。 + +PSP Phase 2 的 admission 额外要求: + +- 在仓库锁定的 rust-psp/QuickJS/PSP SDK/toolchain 上完成 PRX build/link、network module load/unload、AP connect/disconnect 与 WLAN-off 故障注入;无 capability 构建不加载 network module、不保留 network wake 成本; +- fake scheduler 与 PSP-1000 真机都证明无 frame delivery、frame/network 同 ready、lost/duplicate wake、budget/runaway、app-switch late completion 和三阶段 teardown;native callback 不调用 QuickJS; +- PSP-1000 真机记录共享 arena、native pool、thread stack、socket 数与 2 MiB 外部 safety margin,覆盖超限 body、abort/redirect、冷/热连接和至少 30 分钟 soak;PPSSPP 不能替代该 gate; +- Phase 2A 对独立 LAN HTTP Server 运行与 ESP-IDF 相同的 HTTP/framing/permission/resource suite;Phase 2B 如果更换 `HttpClientBackend.id`,先对最终 Backend 重跑这套 suite,再对独立 TLS PKI 覆盖 TLS 1.2、拒绝 TLS 1.0/1.1、chain/hostname/expiry/not-yet-valid/SNI/错误时钟/大证书链/无明文 fallback,并记录握手峰值; +- 包缺 capability/endpoint permission 时在 module load/open socket 前拒绝;`HostNetworkDescriptor.selection` 与 `ResolvedNetworkProviders` 不完全相等,或 `ResolvedBuildPlan.planHash` 不匹配时,在 bundle eval 前拒绝。 + +后续 role 按其晋级顺序增加对应 wire suite:WebSocket mask/UTF-8/control/close 与长连接;声明 WSS 时还要为 `websocket.client` TLS role 运行独立 TLS peer/资源 suite,不能借用 HTTP TLS 结果;MQTT 覆盖 reconnect session/Will/ACK 边界,TCP 覆盖 half-close,UDP 覆盖 datagram boundary,Server TLS 使用独立 Client。尚未晋级的整套 suite 不应被写成 Phase 1 已完成事实。 + +## 26. 从当前 NET v1 迁移 + +当前仓库中 `@pocketjs/framework/net` 的旧导出、`globalThis.net`、`net.http`、`contracts/spec/net.ts`、`framework/src/net-api.ts`、`engine/crates/pocket-net` 以及 frame 后轮询 service 的实现,都是迁移输入,不是新公共契约。迁移完成后 package path `@pocketjs/framework/net` 由新的公共支持模块复用,但这不构成旧 value export 的兼容承诺。 + +建议按以下顺序落地: + +1. **Phase 0:冻结旧 NET。** 立即停止增加旧 API,在首个新 capability 合入前移除 `@pocketjs/framework/net` 的旧 value export、manifest capability 与 Host 注入形成的应用可达 `globalThis.net` 和 `net.http`;只有有明确迁移用途的 parser、bounded allocation 或 fixture 可以暂留内部。旧导出清除后,新的公共支持模块与协议子路径才可以接管 `@pocketjs/framework/net` namespace。 +2. **先建 gate。** 建立 fake clock/wake、竞态/model、HTTP/TLS 独立 peer 与硬件资源 harness;锁定 AtomS3R 与 Tab5 的 network interface/BSP、ESP-IDF v6.0.2 commit、HTTP Backend 候选与下一阶段的 ESP-TLS TLS source。两块板先运行独立 build/link gate,再运行板间 smoke 和各自的独立 peer/resource gate;板间互通不能替代 conformance。 +3. **独立固定 private ABI 注入。** 在跨仓库 build/artifact 变更中选择并验证第 11.1 节的 bundle factory 或满足全部约束的过渡适配器;覆盖所有实际参与 Phase 1 的 compiler/loader/test 路径。 +4. 固定 manifest format 3、ResolvedNetworkPolicy、ResolvedNetworkProviders、Build Plan/HostBuildInputs 传递、package admission、NetworkLimits 与稳定错误。 +5. 落地 Network Guest Binding、Shared Async Runtime、logical turn/slice、operation claim、completion credit、ready-resource list、BufferLease 和三阶段 teardown;同时把 framework/ESP-IDF Host 接到统一 `beginGuestTurn/flushGuestTurn/endGuestTurn`、non-presenting mutation commit 与 headless wake。 +6. **ESP-IDF Phase 1A:** 只打通明文 HTTP Client、lwIP/ESP-NETIF 与选定 HTTP Backend;实现与独立 HTTP peer、竞态、资源和目标硬件 gate 同步完成。全部通过后只广告 `network.http.client`。 +7. **ESP-IDF Phase 1B:** 在同一 HTTP contract 上接入第 14.1 节的 ESP-TLS source;完成独立 TLS peer、可信时钟、无明文 fallback、握手竞态与资源 gate 后才增加 `network.http.client.tls`,Phase 1A 的明文结果不能替代该 gate。 +8. **ESP-IDF Phase 1C:** 只有出现 WebSocket Client 使用方并通过独立 RFC 6455/长连接/背压 gate 后才开放;WSS 另行选择并准入 `websocket.client` TLS role,不能复用 HTTP role 的通过结果,也不能顺带加入 Server、MQTT 或公共 TCP/UDP。 +9. **PSP Phase 2:** 先完成第 1.3 节 Host substrate,再依次执行明文 HTTP Client、HTTP TLS/HTTPS 和 WebSocket Client gate;WSS 使用独立 TLS source/descriptor/peer gate。每一步保持 capability 默认关闭,直到 PSP-1000 真机验收。 +10. 后续 staged role 每个单独提出需求、实现、独立 peer conformance、资源报告和 capability admission;Browser profile 与 replay 也分别接入,不能作为端侧 Phase 1 的隐含范围。 +11. 删除剩余迁移代码与旧 opcode,执行 repo-wide 零残留审计;内部迁移素材不能无限期保留为第二套网络栈。 + +每一步都必须保持 capability 默认关闭。迁移期间不能同时把新旧模块装入同一个 runtime,以免产生两套权限与调度语义。仓库内当前没有生产使用方,只能说明清理成本低;不能据此假设仓库外 bundle 获得兼容承诺,因此 Phase 0 仍需要清晰的 breaking-change release note。 + +如果选择 bundle factory,独立 artifact 变更必须覆盖 `tools/build.ts` 的产物形式、Pocket Mod loader、PSP/Vita 的 `JS_Eval` 路径、sim/web 的 eval 路径以及测试 harness,并验证“eval 返回 factory → Host/Browser 注入 binding → factory 执行 → bootstrap checkpoint”。如果 Phase 1 只迁移实际参与的 loader,其他 target 在迁移前必须构建期拒绝网络 demand,不能混用新 binding 与旧立即执行 IIFE。 + +同一变更序列必须更新 `docs/RUNTIMES.md`、`docs/DETERMINISM.md`、`docs/DEVTOOLS.md`、`docs/DB.md`、`docs/FS.md`、`docs/AUDIO.md`、`docs/NET.md`、`site/content/docs/concepts.md`、`site/content/docs/native-contract.md`、`site/content/docs/net.md`、manifest/platform contract 文档、`contracts/spec/{db,fs,audio}.ts`,以及 `framework/src/effects.ts` / `services.ts` 中只允许 frame-boundary delivery 的注释。还要对仓库中 `Law 3`、`one guest turn`、`frame-boundary delivery` 和等价表述做全量搜索,逐项区分 frame-only profile 与带 Shared Async Runtime 的 network profile。frame-only profile 的原保证仍应单独保留,不能把网络 profile 的 wall-clock 输入写成所有 PocketJS 应用的默认行为。 + +Phase 0 的 public cleanup gate 覆盖 framework package/subpath exports、manifest capability 表和 Host sim/web injection,完成后仓库中不能再生成 `@pocketjs/framework/net` 的旧 API、应用可达的 `globalThis.net` 或 `net.http`。新的根支持模块和协议子路径不属于旧 API。最终 cleanup 再覆盖 `contracts/spec/net.ts` 与生成常量、Cargo crate/dependency、Host adapter、example、test fixture 和旧 opcode;保留的迁移素材必须逐项有删除条件。 + +## 27. 最终边界 + +**PocketJS 网络公共能力来自显式模块导入,并由按协议、角色和 TLS 拆分的构建能力及运行权限共同授权。** + +**目标合同覆盖 HTTP、WebSocket、MQTT、TCP 和 UDP,但 Host 只能广告已经单独通过 conformance 与资源 admission 的 role;ESP-IDF HTTP Client 先行,PSP 按明文 HTTP、HTTP TLS、WebSocket 的顺序验证,WSS 再做独立 TLS role admission,其余 role 保持 staged。** + +**HTTP、WebSocket 和 MQTT 的 reference 与 native 实现使用相同的 role-specific Backend 契约;TCP、UDP 直接使用 NetDriver,TLS 通过独立 TlsProvider 或声明 internal TLS 的 native Backend 接入。** + +**正常网络 completion、Promise settlement 和 handler 只在串行的 NetworkServiceTurn 中运行;runtime teardown 的 cancellation 与 lifecycle delivery 可以在 ShutdownTurn 中运行。两种 turn 都由同一 QuickJS owner thread 串行执行,且不推进 UI 虚拟时间或直接 present。** + +**所有 queue、buffer、operation 和单 turn 工作量都有 Host 硬上限;任何 Backend 都不能通过隐藏重试、无界缓存或平台默认行为绕过 Core policy。** diff --git a/framework/compiler/jsx-plugin.ts b/framework/compiler/jsx-plugin.ts index b7c786d7..835569bc 100644 --- a/framework/compiler/jsx-plugin.ts +++ b/framework/compiler/jsx-plugin.ts @@ -5,7 +5,9 @@ import type { ParserOptions } from "@babel/parser"; import solidPreset from "babel-preset-solid"; import tsPreset from "@babel/preset-typescript"; // untyped - see framework/compiler/ambient.d.ts import { transformVueJsxVapor } from "vue-jsx-vapor/api"; -import { existsSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; +import { isAbsolute, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; import { compileVueSfc } from "./vue-sfc-compile.ts"; import { propsHelperCode, @@ -28,6 +30,12 @@ import babelCorePkg from "@babel/core/package.json"; import tsPresetPkg from "@babel/preset-typescript/package.json"; import type { PocketFramework } from "../src/config.ts"; import { POCKET_FRAMEWORKS, SUBPATHS } from "./subpaths.ts"; +import { + NETWORK_BINDING_RESERVED_IDENTIFIER, + NETWORK_PRIVATE_PREFIX, + NETWORK_PRIVATE_SPECIFIER, + type NetworkPrivateBuildContext, +} from "./network-private.ts"; export type { PocketFramework }; @@ -83,6 +91,40 @@ const PACKAGE_NAME = "@pocketjs/framework"; const CACHE_DIR = new URL("../../.cache/transforms/", import.meta.url).pathname; const CACHE_VERSION = "2"; // manual backstop; compiler sources are hashed in below const COMPILER_DIR = new URL("./", import.meta.url).pathname; +const NETWORK_HTTP_BINDING_FRAMEWORK_SOURCE = realpathSync(new URL( + "../src/net/http-binding.ts", + import.meta.url, +).pathname); +const NETWORK_V1_BINDING_FRAMEWORK_SOURCE = realpathSync(new URL( + "../src/net/network-v1-binding.ts", + import.meta.url, +).pathname); +const NETWORK_LIMITS_FRAMEWORK_SOURCE = realpathSync(new URL( + "../src/net/network-limits.ts", + import.meta.url, +).pathname); +const NETWORK_HTTP_FRAMEWORK_SOURCE = realpathSync(new URL( + "../src/net/http.ts", + import.meta.url, +).pathname); +const NETWORK_INDEX_FRAMEWORK_SOURCE = realpathSync(new URL( + "../src/net/index.ts", + import.meta.url, +).pathname); +const NETWORK_PRIVATE_FRAMEWORK_SOURCES = new Set([ + NETWORK_HTTP_BINDING_FRAMEWORK_SOURCE, + NETWORK_V1_BINDING_FRAMEWORK_SOURCE, + NETWORK_LIMITS_FRAMEWORK_SOURCE, +]); +const NETWORK_PUBLIC_FRAMEWORK_SPECIFIER_BY_SOURCE = new Map([ + [realpathSync(new URL("../src/net/http.ts", import.meta.url).pathname), `${PACKAGE_NAME}/net/http`], + [realpathSync(new URL("../src/net/websocket.ts", import.meta.url).pathname), `${PACKAGE_NAME}/net/websocket`], + [realpathSync(new URL("../src/net/mqtt.ts", import.meta.url).pathname), `${PACKAGE_NAME}/net/mqtt`], + [realpathSync(new URL("../src/net/tcp.ts", import.meta.url).pathname), `${PACKAGE_NAME}/net/tcp`], + [realpathSync(new URL("../src/net/udp.ts", import.meta.url).pathname), `${PACKAGE_NAME}/net/udp`], +]); +const FRAMEWORK_SOURCE_ROOT = realpathSync(new URL("../src/", import.meta.url).pathname); +const NETWORK_PRIVATE_NAMESPACE = "pocketjs-network-private-v1"; /** * Hash of this package's own compiler sources: transform behavior lives here, @@ -216,6 +258,343 @@ interface Collected { export type BuildFeatures = Readonly>; +const NETWORK_SURFACE_DEMANDS: Readonly< + Record>> +> = { + [`${PACKAGE_NAME}/net/http`]: { + fetch: ["network.http.client"], + serve: ["network.http.server"], + }, + [`${PACKAGE_NAME}/net/websocket`]: { + connect: ["network.websocket.client"], + serve: ["network.websocket.server"], + upgrade: [ + "network.http.server", + "network.websocket.server", + "network.websocket.server.upgrade", + ], + }, + [`${PACKAGE_NAME}/net/mqtt`]: { + connect: ["network.mqtt.client"], + }, + [`${PACKAGE_NAME}/net/tcp`]: { + connect: ["network.tcp.client"], + listen: ["network.tcp.server"], + }, + [`${PACKAGE_NAME}/net/udp`]: { + udpSocket: ["network.udp"], + }, +}; + +/** Value implementations that are declared for type-checking but cannot be + * linked into a PocketJS application until their Guest Binding is complete. */ +const STAGED_NETWORK_VALUES: Readonly>> = { + [`${PACKAGE_NAME}/net/http`]: new Set(["fetch", "serve", "Headers", "Request", "Response"]), + [`${PACKAGE_NAME}/net/websocket`]: new Set(["connect", "serve", "upgrade"]), + [`${PACKAGE_NAME}/net/mqtt`]: new Set(["connect"]), + [`${PACKAGE_NAME}/net/tcp`]: new Set(["connect", "listen"]), + [`${PACKAGE_NAME}/net/udp`]: new Set(["udpSocket"]), +}; + +function makeNetworkDemandGate( + features: BuildFeatures | undefined, + testOnlyStagedHttpClientFetch = false, +): PluginObj { + const requireCapabilities = ( + path: { buildCodeFrameError(message: string): Error }, + source: string, + exportNames: readonly string[] | null, + ): void => { + const surface = NETWORK_SURFACE_DEMANDS[source]; + if (!surface) return; + const names = exportNames ?? [ + ...new Set([...Object.keys(surface), ...(STAGED_NETWORK_VALUES[source] ?? [])]), + ]; + const demanded = new Set(); + for (const name of names) { + for (const capability of surface[name] ?? []) demanded.add(capability); + } + const missing = [...demanded].filter((capability) => features?.[capability] !== true); + if (missing.length === 0) return; + const selected = exportNames === null ? "namespace/all value exports" : + exportNames.map((name) => `\`${name}\``).join(", "); + throw path.buildCodeFrameError( + `PocketJS: ${source} ${selected} requires admitted ` + + `${missing.length === 1 ? "capability" : "capabilities"} ${missing.map((id) => `\`${id}\``).join(", ")}. ` + + "The ResolvedBuildPlan does not provide them.", + ); + }; + + const requireReadySurface = ( + path: { buildCodeFrameError(message: string): Error }, + source: string, + exportNames: readonly string[] | null, + ): void => { + const staged = STAGED_NETWORK_VALUES[source]; + if (!staged) return; + const names = exportNames ?? [...staged]; + const unavailable = names.filter((name) => + staged.has(name) && !( + testOnlyStagedHttpClientFetch && + source === `${PACKAGE_NAME}/net/http` && + name === "fetch" + ) + ); + if (unavailable.length === 0) return; + throw path.buildCodeFrameError( + `PocketJS: ${source} ${unavailable.map((name) => `\`${name}\``).join(", ")} ` + + "is a staged surface whose Guest Binding is not available in this build.", + ); + }; + + const checkSurface = ( + path: { buildCodeFrameError(message: string): Error }, + source: string, + exportNames: readonly string[] | null, + ): void => { + requireCapabilities(path, source, exportNames); + requireReadySurface(path, source, exportNames); + }; + + return { + name: "pocketjs-network-demand-gate", + visitor: { + ImportDeclaration(path) { + const source = path.node.source.value; + if (!NETWORK_SURFACE_DEMANDS[source] || path.node.importKind === "type") return; + let allValues = path.node.specifiers.length === 0; + const names: string[] = []; + for (const specifier of path.node.specifiers) { + if (specifier.type === "ImportSpecifier") { + if (specifier.importKind === "type") continue; + names.push( + specifier.imported.type === "Identifier" + ? specifier.imported.name + : specifier.imported.value, + ); + } else { + allValues = true; + } + } + checkSurface(path, source, allValues ? null : names); + }, + ExportNamedDeclaration(path) { + const source = path.node.source?.value; + if (!source || !NETWORK_SURFACE_DEMANDS[source] || path.node.exportKind === "type") return; + let allValues = false; + const names: string[] = []; + for (const specifier of path.node.specifiers) { + if (specifier.type !== "ExportSpecifier") { + allValues = true; + continue; + } + if (specifier.exportKind === "type") continue; + const local = specifier.local as + | { readonly type: "Identifier"; readonly name: string } + | { readonly type: "StringLiteral"; readonly value: string }; + names.push(local.type === "Identifier" ? local.name : local.value); + } + checkSurface(path, source, allValues ? null : names); + }, + ExportAllDeclaration(path) { + const source = path.node.source.value; + if (path.node.exportKind === "type") return; + checkSurface(path, source, null); + }, + CallExpression(path) { + const dynamicImport = path.node.callee.type === "Import"; + const commonJsRequire = path.node.callee.type === "Identifier" && + path.node.callee.name === "require"; + if (!dynamicImport && !commonJsRequire) return; + + const argumentPaths = path.get("arguments"); + const argumentPath = Array.isArray(argumentPaths) && argumentPaths.length === 1 + ? argumentPaths[0] + : undefined; + const evaluated = argumentPath?.evaluate(); + if (!evaluated?.confident || typeof evaluated.value !== "string") { + throw path.buildCodeFrameError( + "PocketJS: dynamic import and require specifiers must be compile-time strings; " + + "an unresolved specifier could bypass network capability admission.", + ); + } + checkSurface(path, evaluated.value, null); + }, + }, + }; +} + +function canonicalFile(path: string): string | null { + if (path === "") return null; + try { + return realpathSync(path); + } catch { + return null; + } +} + +function resolveFileLikeSpecifier(path: string, resolveDir: string): string | null { + let candidate: string; + if (path.startsWith("file:")) { + try { + const url = new globalThis.URL(path); + if (url.protocol !== "file:") return null; + // Bun treats search/hash as module identity suffixes while loading the + // same file. Canonicalize the underlying file before applying the + // private-module policy. + url.search = ""; + url.hash = ""; + candidate = fileURLToPath(url); + } catch { + return null; + } + } else { + if (!isAbsolute(path) && !path.startsWith(".")) return null; + const suffix = path.search(/[?#]/u); + candidate = suffix === -1 ? path : path.slice(0, suffix); + } + try { + return Bun.resolveSync(candidate, resolveDir); + } catch { + return null; + } +} + +function isNetworkBindingFrameworkSource(path: string): boolean { + const canonical = canonicalFile(path); + return canonical !== null && NETWORK_PRIVATE_FRAMEWORK_SOURCES.has(canonical); +} + +function isAllowedPrivateNetworkSourceImport( + target: string, + importer: string, +): boolean { + if (importer === NETWORK_PRIVATE_SPECIFIER) { + return target === NETWORK_V1_BINDING_FRAMEWORK_SOURCE; + } + const canonicalImporter = canonicalFile(importer); + if (target === NETWORK_HTTP_BINDING_FRAMEWORK_SOURCE) { + return canonicalImporter === NETWORK_HTTP_FRAMEWORK_SOURCE || + canonicalImporter === NETWORK_V1_BINDING_FRAMEWORK_SOURCE; + } + if (target === NETWORK_LIMITS_FRAMEWORK_SOURCE) { + return canonicalImporter === NETWORK_INDEX_FRAMEWORK_SOURCE || + canonicalImporter === NETWORK_V1_BINDING_FRAMEWORK_SOURCE; + } + return false; +} + +function isFrameworkSource(path: string): boolean { + const canonical = canonicalFile(path); + if (canonical === null) return false; + const fromRoot = relative(FRAMEWORK_SOURCE_ROOT, canonical); + return fromRoot !== "" && fromRoot !== ".." && + !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot); +} + +function preparePrivateNetworkFrameworkSource( + path: string, + source: string, + enabled: boolean, +): string { + void path; + void enabled; + return source; +} + +function makePrivateNetworkIdentifierGate( + path: string, + context: NetworkPrivateBuildContext | undefined, +): PluginObj { + // Only the compiler-rewritten capture module may name the private slot. + // Treat every other source path—including an application deliberately + // placed below framework/src—as untrusted input. + const frameworkOwned = isNetworkBindingFrameworkSource(path); + const reservedIdentifiers = context === undefined + ? undefined + : new Set([ + NETWORK_BINDING_RESERVED_IDENTIFIER, + context.takeIdentifier, + context.bindingIdentifier, + context.pendingIdentifier, + context.argumentsIdentifier, + ]); + return { + name: "pocketjs-private-network-identifier", + visitor: { + Program(program) { + if (reservedIdentifiers === undefined || frameworkOwned) return; + program.traverse({ + Identifier(identifierPath) { + if (!reservedIdentifiers.has(identifierPath.node.name)) return; + throw identifierPath.buildCodeFrameError( + "PocketJS: application source cannot reference the private network binding identifier.", + ); + }, + ReferencedIdentifier(identifierPath) { + if (identifierPath.node.name !== "arguments") return; + const argumentOwner = identifierPath.findParent( + (parent) => parent.isFunction() && !parent.isArrowFunctionExpression(), + ); + if (argumentOwner) return; + throw identifierPath.buildCodeFrameError( + "PocketJS: top-level application arguments cannot reference the private network binding factory.", + ); + }, + CallExpression(callPath) { + if ( + callPath.node.callee.type !== "Identifier" || + callPath.node.callee.name !== "eval" + ) return; + // Network artifacts contain compiler-owned lexical capture state. + // Preserve eval of global code while preventing application code + // from inspecting any surrounding bundler/factory scope. + callPath.node.callee = { + type: "SequenceExpression", + expressions: [ + { type: "NumericLiteral", value: 0 }, + { type: "Identifier", name: "eval" }, + ], + }; + }, + }); + }, + }, + }; +} + +async function transformPrivateNetworkJavaScript( + path: string, + source: string, + context: NetworkPrivateBuildContext, +): Promise { + const transformed = await transformAsync(source, { + filename: path, + sourceType: "unambiguous", + presets: [], + parserOpts: JSX_PARSER_OPTS, + plugins: [makePrivateNetworkIdentifierGate(path, context)], + babelrc: false, + configFile: false, + sourceMaps: false, + }); + if (!transformed?.code && transformed?.code !== "") { + throw new TypeError(`PocketJS transform produced no output for ${path}`); + } + return transformed.code; +} + +function privateNetworkModuleSource(context: NetworkPrivateBuildContext): string { + return ` +import { mountNetworkV1HttpBinding } from ${JSON.stringify(NETWORK_V1_BINDING_FRAMEWORK_SOURCE)}; +const ${context.bindingIdentifier} = ${context.takeIdentifier}(); +mountNetworkV1HttpBinding(${context.bindingIdentifier}, Object.freeze({ + planHashBytes: Object.freeze(${JSON.stringify(context.planHashBytes)}), + featureIds: Object.freeze(${JSON.stringify(context.featureIds)}), +})); +`; +} + /** Fold only calls proven to reference the public platform import. */ function makeFeatureFolder(features: BuildFeatures): PluginObj { return { @@ -334,6 +713,9 @@ async function hashKey( src: string, framework: PocketFramework, features: BuildFeatures | undefined, + privateNetworkEnabled: boolean, + testOnlyStagedHttpClientFetch: boolean, + testOnlyStagedHttpsClientFetch: boolean, ): Promise { const h = new Bun.CryptoHasher("sha256"); h.update( @@ -365,6 +747,14 @@ async function hashKey( Object.entries(features).sort(([left], [right]) => left.localeCompare(right)), )) + "\0" + + (privateNetworkEnabled ? "private-network-v1" : "no-private-network") + + "\0" + + (testOnlyStagedHttpClientFetch + ? "test-only-staged-http-client-fetch" + : testOnlyStagedHttpsClientFetch + ? "test-only-staged-https-client-fetch" + : "staged-network-closed") + + "\0" + path + "\0", ); @@ -387,17 +777,21 @@ function resolvePackageSubpath(spec: string): string | null { /** * Resolve an `@pocketjs/framework[/…]` import to a module file, or null to * let Bun's package.json resolution (or an error) take over. A framework - * prefix (`vue-vapor/audio`) pins that framework's view; a bare subpath - * resolves through the ACTIVE framework — both are lookups into the same - * SUBPATHS-derived table, so the registry is the only authority. + * prefix (`vue-vapor/audio`) pins that framework's published alias; a bare + * subpath resolves through the ACTIVE framework. Both are lookups into the + * same SUBPATHS-derived table, so an absent `aliases` declaration cannot be + * bypassed by compiler resolution. */ export function packagePath(spec: string, framework: PocketFramework): string | null { const subpath = resolvePackageSubpath(spec); if (subpath === null) return null; for (const fw of POCKET_FRAMEWORKS) { - if (subpath === fw) return RESOLVED[fw][""] ?? null; + if (subpath === fw) { + return SUBPATHS[""]?.aliases?.includes(fw) ? (RESOLVED[fw][""] ?? null) : null; + } if (subpath.startsWith(fw + "/")) { - return RESOLVED[fw][subpath.slice(fw.length + 1)] ?? null; + const name = subpath.slice(fw.length + 1); + return SUBPATHS[name]?.aliases?.includes(fw) ? (RESOLVED[fw][name] ?? null) : null; } } return RESOLVED[framework][subpath] ?? null; @@ -429,8 +823,16 @@ export async function transformFile( path: string, src: string, framework: PocketFramework, - options: { features?: BuildFeatures } = {}, + options: { + features?: BuildFeatures; + networkPrivate?: NetworkPrivateBuildContext; + } = {}, ): Promise { + src = preparePrivateNetworkFrameworkSource( + path, + src, + options.networkPrivate !== undefined, + ); const isVueSfc = path.endsWith(".vue"); if (isVueSfc && framework !== "vue-vapor") { throw new Error( @@ -438,7 +840,15 @@ export async function transformFile( `(set app.framework in pocket.json or pass --framework=vue-vapor)`, ); } - const key = await hashKey(path, src, framework, options.features); + const key = await hashKey( + path, + src, + framework, + options.features, + options.networkPrivate !== undefined, + options.networkPrivate?.testOnlyStagedHttpClientFetch === true, + options.networkPrivate?.testOnlyStagedHttpsClientFetch === true, + ); const cacheFile = CACHE_DIR + key + ".json"; const cached = (await Bun.file(cacheFile).json().catch(() => null)) as CacheEntry | null; if (cached && typeof cached.code === "string") { @@ -457,6 +867,12 @@ export async function transformFile( presets: [], parserOpts: JSX_PARSER_OPTS, plugins: [ + makePrivateNetworkIdentifierGate(path, options.networkPrivate), + makeNetworkDemandGate( + options.features, + options.networkPrivate?.testOnlyStagedHttpClientFetch === true || + options.networkPrivate?.testOnlyStagedHttpsClientFetch === true, + ), ...(options.features === undefined ? [] : [makeFeatureFolder(options.features)]), makeCollector(collected, framework), ], @@ -483,6 +899,12 @@ export async function transformFile( const collected: Collected = { classStrings: [], textCodepoints: new Set() }; const opts = transformOptions(framework); const plugins = [ + makePrivateNetworkIdentifierGate(path, options.networkPrivate), + makeNetworkDemandGate( + options.features, + options.networkPrivate?.testOnlyStagedHttpClientFetch === true || + options.networkPrivate?.testOnlyStagedHttpsClientFetch === true, + ), ...(options.features === undefined ? [] : [makeFeatureFolder(options.features)]), makeCollector(collected, framework), ]; @@ -567,11 +989,84 @@ export function jsxPlugin( entry?: string; features?: BuildFeatures; generatedStyles?: string; + networkPrivate?: NetworkPrivateBuildContext; } = {}, ): BunPlugin { return { name: `pocketjs-${framework}-jsx`, setup(build) { + // Private installer/capture modules are inaccessible in every artifact + // mode, including a plain build with no network factory. Resolve the + // canonical target so absolute paths and symlink aliases are equivalent. + build.onResolve({ filter: /.*/ }, (args) => { + const resolved = resolveFileLikeSpecifier(args.path, args.resolveDir); + if (resolved === null) return undefined; + const target = canonicalFile(resolved); + if (target === null) return undefined; + const publicSpecifier = NETWORK_PUBLIC_FRAMEWORK_SPECIFIER_BY_SOURCE.get(target); + if (publicSpecifier !== undefined) { + throw new TypeError( + `PocketJS: application resolver rejected direct access to ${publicSpecifier}; ` + + `import the canonical ${publicSpecifier} package surface so capability demand can be verified.`, + ); + } + if (!NETWORK_PRIVATE_FRAMEWORK_SOURCES.has(target)) return undefined; + if (isAllowedPrivateNetworkSourceImport(target, args.importer)) return undefined; + throw new TypeError( + "PocketJS: application resolver rejected direct access to a private network binding module.", + ); + }); + if (opts.networkPrivate !== undefined) { + const context = opts.networkPrivate; + if (!opts.entry) { + throw new TypeError("PocketJS compiler: network factory requires an application entry"); + } + build.onResolve({ filter: /^pocketjs:network-bootstrap-v1-/ }, (args) => { + if (args.path !== context.bootstrapSpecifier || args.importer !== "") { + throw new TypeError( + "PocketJS: application resolver rejected the private network bootstrap.", + ); + } + return { path: context.bootstrapSpecifier, namespace: NETWORK_PRIVATE_NAMESPACE }; + }); + build.onLoad( + { filter: /.*/, namespace: NETWORK_PRIVATE_NAMESPACE }, + (args) => { + if (args.path === context.bootstrapSpecifier) { + return { + contents: + `import ${JSON.stringify(NETWORK_PRIVATE_SPECIFIER)};\n` + + `import ${JSON.stringify(opts.entry)};\n`, + loader: "js", + }; + } + if (args.path === NETWORK_PRIVATE_SPECIFIER) { + return { contents: privateNetworkModuleSource(context), loader: "js" }; + } + return undefined; + }, + ); + build.onResolve({ filter: /^pocketjs:internal\// }, (args) => { + const fromBootstrap = + args.importer === context.bootstrapSpecifier; + const fromFrameworkBinding = isNetworkBindingFrameworkSource(args.importer); + if ( + args.path !== NETWORK_PRIVATE_SPECIFIER || + (!fromBootstrap && !fromFrameworkBinding) + ) { + throw new TypeError( + `PocketJS: application resolver rejected private network binding import ${JSON.stringify(args.path)}.`, + ); + } + return { path: NETWORK_PRIVATE_SPECIFIER, namespace: NETWORK_PRIVATE_NAMESPACE }; + }); + } else { + build.onResolve({ filter: /^pocketjs:internal\// }, (args) => { + throw new TypeError( + `PocketJS: application resolver rejected private network binding import ${JSON.stringify(args.path)}.`, + ); + }); + } // External applications may have their own node_modules. Resolve both // sides of the renderer boundary to PocketJS's browser-mode Solid copy, // otherwise identical packages at different paths form two reactive @@ -633,14 +1128,33 @@ export function jsxPlugin( if (framework === "vue-vapor" && args.path === opts.entry) { src = `import "@pocketjs/framework/prelude";\n${src}`; } - const { code } = await transformFile(args.path, src, framework, { features: opts.features }); + const { code } = await transformFile(args.path, src, framework, { + features: opts.features, + networkPrivate: opts.networkPrivate, + }); return { contents: code, loader: "js" }; }); build.onLoad({ filter: /\.vue$/ }, async (args) => { const src = await Bun.file(args.path).text(); - const { code } = await transformFile(args.path, src, framework, { features: opts.features }); + const { code } = await transformFile(args.path, src, framework, { + features: opts.features, + networkPrivate: opts.networkPrivate, + }); return { contents: code, loader: "js" }; }); + build.onLoad({ filter: /\.[cm]?jsx?$/ }, async (args) => { + const context = opts.networkPrivate; + if (context === undefined || isFrameworkSource(args.path)) return undefined; + const source = await Bun.file(args.path).text(); + const needsPrivateCheck = !args.path.includes("/node_modules/") || + source.includes(context.token) || + source.includes(NETWORK_BINDING_RESERVED_IDENTIFIER) || + /\b(?:arguments|eval)\b/.test(source) || + /\\u(?:\{[0-9a-fA-F]+\}|[0-9a-fA-F]{4})/.test(source); + if (!needsPrivateCheck) return undefined; + const code = await transformPrivateNetworkJavaScript(args.path, source, context); + return { contents: code, loader: args.path.endsWith("x") ? "jsx" : "js" }; + }); }, }; } diff --git a/framework/compiler/network-private.ts b/framework/compiler/network-private.ts new file mode 100644 index 00000000..9790d818 --- /dev/null +++ b/framework/compiler/network-private.ts @@ -0,0 +1,37 @@ +/** Compiler-only module id. It is never a package export or a disk module. */ +export const NETWORK_PRIVATE_SPECIFIER = "pocketjs:internal/network-v1"; + +/** All specifiers in this namespace are reserved to the PocketJS compiler. */ +export const NETWORK_PRIVATE_PREFIX = "pocketjs:internal/"; + +/** Legacy source sentinel rewritten only while compiling engine-owned code. */ +export const NETWORK_BINDING_RESERVED_IDENTIFIER = + "__POCKET_NETWORK_BINDING_V1__"; + +/** Name used by the unsafe first factory spike; retained only as an attack probe. */ +export const LEGACY_NETWORK_FACTORY_PARAMETER = + "__pocketNetworkBindingV1FactoryParameter"; + +/** Per-artifact names shared by the compiler plugin and factory finalizer. */ +export interface NetworkPrivateBuildContext { + readonly token: string; + readonly bootstrapSpecifier: string; + readonly takeIdentifier: string; + readonly bindingIdentifier: string; + readonly pendingIdentifier: string; + readonly argumentsIdentifier: string; + /** Raw digest bytes from the already-verified ResolvedBuildPlan. */ + readonly planHashBytes: readonly number[]; + /** Exact, sorted numeric projection of true network.* plan features. */ + readonly featureIds: readonly number[]; + /** + * Compiler-internal permit for the exact ESP formal hardware-smoke + * artifact. Normal build-context creation never sets this field. + */ + readonly testOnlyStagedHttpClientFetch?: true; + /** + * Compiler-internal permit for the exact ESP formal TLS hardware-smoke + * artifact. Normal build-context creation never sets this field. + */ + readonly testOnlyStagedHttpsClientFetch?: true; +} diff --git a/framework/compiler/subpaths.ts b/framework/compiler/subpaths.ts index 493d81dc..4c66318d 100644 --- a/framework/compiler/subpaths.ts +++ b/framework/compiler/subpaths.ts @@ -82,7 +82,12 @@ 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 }, + net: { file: "framework/src/net/index.ts" }, + "net/http": { file: "framework/src/net/http.ts" }, + "net/websocket": { file: "framework/src/net/websocket.ts" }, + "net/mqtt": { file: "framework/src/net/mqtt.ts" }, + "net/tcp": { file: "framework/src/net/tcp.ts" }, + "net/udp": { file: "framework/src/net/udp.ts" }, 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/manifest/host-build-inputs.ts b/framework/src/manifest/host-build-inputs.ts index 844bd7d8..0e0e9b55 100644 --- a/framework/src/manifest/host-build-inputs.ts +++ b/framework/src/manifest/host-build-inputs.ts @@ -3,7 +3,21 @@ import { type PresentationMode, type Viewport, } from "../../../contracts/spec/platforms.ts"; -import { verifyPlanHash, type ResolvedBuildPlan } from "./plan.ts"; +import { + NETWORK_CONNECT_PROTOCOLS, + NETWORK_LISTEN_PROTOCOLS, +} from "../../../contracts/spec/pocket-manifest.ts"; +import { + canonicalJson, + verifyPlanHash, + type ResolvedBuildPlan, + type ResolvedNetworkBuildPlan, +} from "./plan.ts"; + +export interface HostNetworkBuildInputs extends ResolvedNetworkBuildPlan { + readonly planHash: string; + readonly features: Readonly>; +} /** Stable subset of the internal build plan consumed by custom native hosts. */ export interface HostBuildInputs { @@ -16,6 +30,8 @@ export interface HostBuildInputs { readonly presentation: PresentationMode; readonly rasterDensity: number; }; + /** Present only for a format-3 plan with admitted network authority. */ + readonly network?: HostNetworkBuildInputs; } export interface ExtractHostBuildInputsOptions { @@ -37,6 +53,127 @@ function isViewport(value: unknown): value is Viewport { ); } +function hasOnlyKeys(value: Record, allowed: readonly string[]): boolean { + const known = new Set(allowed); + return Object.keys(value).every((key) => known.has(key)); +} + +function isResolvedPort(value: unknown, ephemeral: boolean): boolean { + if (!isRecord(value)) return false; + if (ephemeral && value.ephemeral === true && hasOnlyKeys(value, ["ephemeral"])) return true; + return hasOnlyKeys(value, ["min", "max"]) && + Number.isInteger(value.min) && Number.isInteger(value.max) && + (value.min as number) >= 1 && (value.max as number) <= 65535 && + (value.min as number) <= (value.max as number); +} + +function isConnectPermission(value: unknown): boolean { + return isRecord(value) && hasOnlyKeys(value, ["protocol", "host", "port"]) && + NETWORK_CONNECT_PROTOCOLS.includes(value.protocol as never) && + typeof value.host === "string" && value.host.length > 0 && + isResolvedPort(value.port, false); +} + +function isListenPermission(value: unknown): boolean { + return isRecord(value) && hasOnlyKeys(value, ["protocol", "address", "port"]) && + NETWORK_LISTEN_PROTOCOLS.includes(value.protocol as never) && + typeof value.address === "string" && value.address.length > 0 && + isResolvedPort(value.port, true); +} + +const RESOURCE_KEYS: Readonly> = { + runtime: ["connections", "pendingOperations", "completionDescriptors", "nativeBufferBytes"], + stream: ["receiveQueueBytes", "sendQueueBytes"], + http: ["connections", "inflightRequests", "headerBytes", "headerCount", "bufferedBodyBytes"], + websocket: ["connections", "messageBytes", "queuedMessages"], + mqtt: ["connections", "packetBytes", "qos1Inflight", "receiveQueueBytes"], + udp: ["sockets", "datagramBytes", "receiveDatagrams"], +}; + +function isResourceMinimum(value: unknown): boolean { + if (!isRecord(value) || !hasOnlyKeys(value, Object.keys(RESOURCE_KEYS))) return false; + return Object.entries(value).every(([group, limits]) => + isRecord(limits) && hasOnlyKeys(limits, RESOURCE_KEYS[group]!) && + Object.values(limits).every((limit) => Number.isSafeInteger(limit) && (limit as number) > 0) + ); +} + +function hasNetworkPlanShape(value: unknown): value is ResolvedNetworkBuildPlan { + if (!isRecord(value) || !isRecord(value.policy) || !isRecord(value.providers) || + !isRecord(value.resources)) return false; + if (!hasOnlyKeys(value, ["policy", "providers", "resources"]) || + !hasOnlyKeys(value.policy, [ + "version", + "connect", + "listen", + "localNetwork", + "insecureTransport", + "broadcast", + "multicast", + "allowInvalidTlsForDevelopment", + "browserAmbientCredentials", + "browserOpaqueWebSocketRedirects", + "credentials", + ]) || !hasOnlyKeys(value.providers, ["backendByRole", "tlsByRole", "netDriverId"]) || + !hasOnlyKeys(value.resources, ["minimum"])) return false; + if (value.policy.version !== 1 || !Array.isArray(value.policy.connect) || + !Array.isArray(value.policy.listen) || !Array.isArray(value.policy.credentials)) return false; + if (!value.policy.connect.every(isConnectPermission) || + !value.policy.listen.every(isListenPermission)) return false; + for (const key of [ + "localNetwork", + "insecureTransport", + "broadcast", + "multicast", + "allowInvalidTlsForDevelopment", + "browserAmbientCredentials", + "browserOpaqueWebSocketRedirects", + ]) { + if (typeof value.policy[key] !== "boolean") return false; + } + if (!value.policy.credentials.every((credential) => typeof credential === "string")) return false; + if (!isRecord(value.providers.backendByRole) || !isRecord(value.providers.tlsByRole) || + typeof value.providers.netDriverId !== "string" || value.providers.netDriverId.length === 0) return false; + if (!hasOnlyKeys(value.providers.backendByRole, [ + "http.client", + "http.server", + "websocket.client", + "websocket.server", + "mqtt.client", + ]) || !hasOnlyKeys(value.providers.tlsByRole, [ + "http.client", + "http.server", + "websocket.client", + "websocket.server", + "mqtt.client", + "tcp.client", + "tcp.server", + ])) return false; + // This boundary verifies the resolved plan's structure and checksum. The + // resolver already validates provider-id syntax; Host descriptor admission + // later requires exact id/role selection equality. + if (!Object.values(value.providers.backendByRole).every((id) => typeof id === "string")) return false; + if (!Object.values(value.providers.tlsByRole).every((selection) => + isRecord(selection) && hasOnlyKeys(selection, ["source", "id"]) && + (selection.source === "provider" || selection.source === "backend") && + typeof selection.id === "string" + )) return false; + return isResourceMinimum(value.resources.minimum); +} + +function cloneAndFreeze(value: T): T { + if (Array.isArray(value)) { + return Object.freeze(value.map((item) => cloneAndFreeze(item))) as T; + } + if (isRecord(value)) { + const clone = Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, cloneAndFreeze(child)]), + ); + return Object.freeze(clone) as T; + } + return value; +} + function hasHostInputShape(input: unknown): input is ResolvedBuildPlan { if (!isRecord(input) || !isRecord(input.app) || !isRecord(input.target)) return false; if (!isRecord(input.viewport) || !isRecord(input.features)) return false; @@ -55,7 +192,13 @@ 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; - return Object.values(input.features).every((available) => typeof available === "boolean"); + if (!Object.values(input.features).every((available) => typeof available === "boolean")) return false; + if (input.selectedCapabilityOptions !== undefined && + (!Array.isArray(input.selectedCapabilityOptions) || + !input.selectedCapabilityOptions.every((option) => + Array.isArray(option) && option.every((capability) => typeof capability === "string") + ))) return false; + return input.network === undefined || hasNetworkPlanShape(input.network); } function readVerifiedPlan(input: unknown): ResolvedBuildPlan { @@ -89,7 +232,7 @@ export function extractHostBuildInputs( `PocketJS host build: expected target ${options.expectedTarget}, got ${plan.target.id}`, ); } - return { + const projected: HostBuildInputs = { appOutput: plan.app.output, target: plan.target.id, hostAbi: plan.target.hostAbi, @@ -99,7 +242,17 @@ export function extractHostBuildInputs( presentation: plan.viewport.presentation, rasterDensity: plan.viewport.rasterDensity, }, + ...(plan.network + ? { + network: cloneAndFreeze({ + planHash: plan.planHash, + features: plan.features, + ...plan.network, + }), + } + : {}), }; + return projected; } /** Build the target-neutral environment shared by framework and custom crates. */ @@ -119,5 +272,11 @@ export function hostBuildEnvironment( POCKETJS_PHYSICAL_HEIGHT: String(inputs.viewport.physical[1]), POCKETJS_PRESENTATION: inputs.viewport.presentation, POCKETJS_RASTER_DENSITY: String(inputs.viewport.rasterDensity), + ...(inputs.network + ? { + POCKETJS_PLAN_HASH: inputs.network.planHash, + POCKETJS_NETWORK_BUILD_INPUTS: canonicalJson(inputs.network), + } + : {}), }; } diff --git a/framework/src/manifest/index.ts b/framework/src/manifest/index.ts index 78a30f89..84b72881 100644 --- a/framework/src/manifest/index.ts +++ b/framework/src/manifest/index.ts @@ -1,9 +1,25 @@ /** Stable API for Pocket manifests and custom native hosts. */ export { + NETWORK_CONNECT_PROTOCOLS, + NETWORK_LISTEN_PROTOCOLS, POCKET_MANIFEST_SCHEMA_ID, POCKET_MANIFEST_VERSION, + POCKET_MANIFEST_V3_SCHEMA_ID, + POCKET_MANIFEST_V3_VERSION, pocketManifestV2Schema, + pocketManifestV3Schema, + type CapabilityProviderAlternative, + type NetworkConnectPermission, + type NetworkConnectProtocol, + type NetworkListenPermission, + type NetworkListenPort, + type NetworkListenProtocol, + type NetworkPermissions, + type NetworkPort, + type NetworkResourceMinimum, + type PocketManifest, type PocketManifestV2, + type PocketManifestV3, } from "../../../contracts/spec/pocket-manifest.ts"; export { type PocketCapabilityId, @@ -16,7 +32,9 @@ export { type ExtractHostBuildInputsOptions, type HostBuildEnvironmentOptions, type HostBuildInputs, + type HostNetworkBuildInputs, } from "./host-build-inputs.ts"; +export type { HostNetworkResolutionProfile } from "./network.ts"; export { validatePocketManifest, type ContractDiagnostic, diff --git a/framework/src/manifest/network.ts b/framework/src/manifest/network.ts new file mode 100644 index 00000000..8798c0c1 --- /dev/null +++ b/framework/src/manifest/network.ts @@ -0,0 +1,551 @@ +import { isIP } from "node:net"; +import { domainToASCII } from "node:url"; +import type { + NetworkConnectPermission, + NetworkListenPermission, + NetworkPermissions, + NetworkPort, + NetworkResourceMinimum, + PocketManifest, +} from "../../../contracts/spec/pocket-manifest.ts"; +import { + canonicalJson, + type NetworkBackendRole, + type NetworkTlsRole, + type ResolvedNetworkBuildPlan, + type ResolvedNetworkConnectPermission, + type ResolvedNetworkListenPermission, + type ResolvedNetworkPolicy, + type ResolvedNetworkPort, + type ResolvedNetworkProviders, +} from "./plan.ts"; +import type { ContractDiagnostic } from "./validate.ts"; + +export interface HostNetworkResolutionProfile { + readonly providers: ResolvedNetworkProviders; + readonly hardLimits: NetworkResourceMinimum; + readonly developmentBuild: boolean; +} + +const PROVIDER_ID = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; + +const BACKEND_CAPABILITIES: Readonly> = { + "http.client": "network.http.client", + "http.server": "network.http.server", + "websocket.client": "network.websocket.client", + "websocket.server": "network.websocket.server", + "mqtt.client": "network.mqtt.client", +}; + +const TLS_CAPABILITIES: Readonly> = { + "http.client": "network.http.client.tls", + "http.server": "network.http.server.tls", + "websocket.client": "network.websocket.client.tls", + "websocket.server": "network.websocket.server.tls", + "mqtt.client": "network.mqtt.client.tls", + "tcp.client": "network.tcp.client.tls", + "tcp.server": "network.tcp.server.tls", +}; + +const BASE_CAPABILITIES_BY_TLS_ROLE: Readonly> = { + "http.client": "network.http.client", + "http.server": "network.http.server", + "websocket.client": "network.websocket.client", + "websocket.server": "network.websocket.server", + "mqtt.client": "network.mqtt.client", + "tcp.client": "network.tcp.client", + "tcp.server": "network.tcp.server", +}; + +const NATIVE_NETWORK_CAPABILITIES = new Set([ + ...Object.values(BACKEND_CAPABILITIES), + ...Object.values(TLS_CAPABILITIES), + "network.websocket.server.upgrade", + "network.tcp.client", + "network.tcp.server", + "network.udp", + "network.http.client.h2", + "network.http.server.h2", + "network.http.client.h3", + "network.http.server.h3", + "network.http.client.compression", + "network.http.server.compression", + "network.websocket.client.compression", + "network.websocket.server.compression", + "network.mqtt.client.v5", + "network.mqtt.client.qos2", + "network.tcp.client.ipv6", + "network.tcp.server.ipv6", + "network.tcp.client.socket-options", + "network.tcp.server.socket-options", + "network.udp.ipv6", + "network.udp.broadcast", + "network.udp.multicast", + ...Object.values(TLS_CAPABILITIES).flatMap((capability) => [ + `${capability}.custom-ca`, + `${capability}.client-auth`, + `${capability}.alpn`, + `${capability}.v1-3`, + `${capability}.revocation`, + ]), +]); + +const CAPABILITY_DEPENDENCIES: Readonly> = { + ...Object.fromEntries( + Object.entries(TLS_CAPABILITIES).map(([role, capability]) => [ + capability, + [BASE_CAPABILITIES_BY_TLS_ROLE[role as NetworkTlsRole]], + ]), + ), + ...Object.fromEntries( + Object.values(TLS_CAPABILITIES).flatMap((capability) => + ["custom-ca", "client-auth", "alpn", "v1-3", "revocation"].map((feature) => [ + `${capability}.${feature}`, + [capability], + ]) + ), + ), + "network.websocket.server.upgrade": [ + "network.http.server", + "network.websocket.server", + ], + "network.http.client.h2": ["network.http.client"], + "network.http.server.h2": ["network.http.server"], + "network.http.client.h3": ["network.http.client"], + "network.http.server.h3": ["network.http.server"], + "network.http.client.compression": ["network.http.client"], + "network.http.server.compression": ["network.http.server"], + "network.websocket.client.compression": ["network.websocket.client"], + "network.websocket.server.compression": ["network.websocket.server"], + "network.mqtt.client.v5": ["network.mqtt.client"], + "network.mqtt.client.qos2": ["network.mqtt.client"], + "network.tcp.client.ipv6": ["network.tcp.client"], + "network.tcp.server.ipv6": ["network.tcp.server"], + "network.tcp.client.socket-options": ["network.tcp.client"], + "network.tcp.server.socket-options": ["network.tcp.server"], + "network.udp.ipv6": ["network.udp"], + "network.udp.broadcast": ["network.udp"], + "network.udp.multicast": ["network.udp"], +}; + +const RESOURCE_KEYS = { + runtime: ["connections", "pendingOperations", "completionDescriptors", "nativeBufferBytes"], + stream: ["receiveQueueBytes", "sendQueueBytes"], + http: ["connections", "inflightRequests", "headerBytes", "headerCount", "bufferedBodyBytes"], + websocket: ["connections", "messageBytes", "queuedMessages"], + mqtt: ["connections", "packetBytes", "qos1Inflight", "receiveQueueBytes"], + udp: ["sockets", "datagramBytes", "receiveDatagrams"], +} as const; + +function diagnostic( + diagnostics: ContractDiagnostic[], + code: string, + path: string, + message: string, +): void { + diagnostics.push({ code, path, message }); +} + +function canonicalIpv4(address: string): string | null { + const parts = address.split("."); + if (parts.length !== 4) return null; + const numbers: number[] = []; + for (const part of parts) { + if (!/^(?:0|[1-9][0-9]{0,2})$/.test(part)) return null; + const value = Number(part); + if (value > 255) return null; + numbers.push(value); + } + return numbers.join("."); +} + +function ipv6Groups(address: string): number[] | null { + if (address.includes("%") || address.startsWith("[") || address.endsWith("]")) return null; + const halves = address.split("::"); + if (halves.length > 2) return null; + + const parseSide = (side: string): number[] | null => { + if (side === "") return []; + const pieces = side.split(":"); + const groups: number[] = []; + for (let index = 0; index < pieces.length; index += 1) { + const piece = pieces[index]!; + if (piece.includes(".")) { + if (index !== pieces.length - 1) return null; + const ipv4 = canonicalIpv4(piece); + if (!ipv4) return null; + const octets = ipv4.split(".").map(Number); + groups.push((octets[0]! << 8) | octets[1]!, (octets[2]! << 8) | octets[3]!); + } else { + if (!/^[0-9a-fA-F]{1,4}$/.test(piece)) return null; + groups.push(Number.parseInt(piece, 16)); + } + } + return groups; + }; + + const left = parseSide(halves[0]!); + const right = parseSide(halves[1] ?? ""); + if (!left || !right) return null; + if (halves.length === 1) return left.length === 8 ? left : null; + const omitted = 8 - left.length - right.length; + if (omitted < 1) return null; + return [...left, ...Array(omitted).fill(0), ...right]; +} + +function canonicalIpv6(address: string): string | null { + const groups = ipv6Groups(address); + if (!groups || groups.length !== 8) return null; + + let bestStart = -1; + let bestLength = 0; + for (let index = 0; index < groups.length;) { + if (groups[index] !== 0) { + index += 1; + continue; + } + let end = index + 1; + while (end < groups.length && groups[end] === 0) end += 1; + if (end - index > bestLength && end - index >= 2) { + bestStart = index; + bestLength = end - index; + } + index = end; + } + + const before = groups.slice(0, bestStart < 0 ? groups.length : bestStart) + .map((part) => part.toString(16)).join(":"); + if (bestStart < 0) return before; + const after = groups.slice(bestStart + bestLength).map((part) => part.toString(16)).join(":"); + return `${before}::${after}`; +} + +function canonicalIp(address: string): string | null { + const family = isIP(address); + if (family === 4) return canonicalIpv4(address); + if (family === 6) return canonicalIpv6(address); + return null; +} + +function canonicalDnsName(host: string): string | null { + const withoutRoot = host.endsWith(".") ? host.slice(0, -1) : host; + if (withoutRoot.length === 0 || withoutRoot.endsWith(".")) return null; + const ascii = domainToASCII(withoutRoot).toLowerCase(); + if (ascii.length === 0 || ascii.length > 253) return null; + const labels = ascii.split("."); + if (labels.some((label) => + label.length === 0 || label.length > 63 || + !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label) + )) return null; + return ascii; +} + +function canonicalConnectHost(host: string): string | null { + const ip = canonicalIp(host); + if (ip) return ip; + if (host.startsWith("*.")) { + const suffix = canonicalDnsName(host.slice(2)); + return suffix ? `*.${suffix}` : null; + } + if (host.includes("*")) return null; + return canonicalDnsName(host); +} + +function canonicalPort(port: NetworkPort): Readonly<{ min: number; max: number }> | null { + if (typeof port === "number") return { min: port, max: port }; + if (port.min > port.max) return null; + return { min: port.min, max: port.max }; +} + +function requiredCapabilitiesForProtocol( + protocol: NetworkConnectPermission["protocol"] | NetworkListenPermission["protocol"], + direction: "connect" | "listen", +): readonly string[] { + const role = direction === "connect" ? "client" : "server"; + switch (protocol) { + case "http": return [`network.http.${role}`]; + case "https": return [`network.http.${role}`, `network.http.${role}.tls`]; + case "ws": return [`network.websocket.${role}`]; + case "wss": return [`network.websocket.${role}`, `network.websocket.${role}.tls`]; + case "mqtt": return ["network.mqtt.client"]; + case "mqtts": return ["network.mqtt.client", "network.mqtt.client.tls"]; + case "tcp": return [`network.tcp.${role}`]; + case "tcp-tls": return [`network.tcp.${role}`, `network.tcp.${role}.tls`]; + case "udp": return ["network.udp"]; + } +} + +function protocolIsPlaintext(protocol: string): boolean { + return protocol === "http" || protocol === "ws" || protocol === "mqtt" || protocol === "tcp"; +} + +function normalizePolicy( + permissions: NetworkPermissions, + features: Readonly>, + profile: HostNetworkResolutionProfile, + diagnostics: ContractDiagnostic[], +): ResolvedNetworkPolicy { + const connect: ResolvedNetworkConnectPermission[] = []; + const connectSeen = new Set(); + permissions.connect.forEach((rule, index) => { + const path = `/permissions/network/connect/${index}`; + const host = canonicalConnectHost(rule.host); + if (!host) { + diagnostic(diagnostics, "network.invalidHost", `${path}/host`, "host must be a DNS name, one-label wildcard, or canonicalizable IP literal"); + return; + } + const port = canonicalPort(rule.port); + if (!port) { + diagnostic(diagnostics, "network.invalidPortRange", `${path}/port`, "port range min must not exceed max"); + return; + } + for (const capability of requiredCapabilitiesForProtocol(rule.protocol, "connect")) { + if (features[capability] !== true) { + diagnostic(diagnostics, "network.permissionCapabilityMissing", path, `${rule.protocol} connect permission requires selected capability ${capability}`); + } + } + if (protocolIsPlaintext(rule.protocol) && !permissions.insecureTransport) { + diagnostic(diagnostics, "network.insecureTransportDisabled", path, `${rule.protocol} permission is unusable while insecureTransport is false`); + } + const resolved: ResolvedNetworkConnectPermission = { protocol: rule.protocol, host, port }; + const key = canonicalJson(resolved); + if (connectSeen.has(key)) { + diagnostic(diagnostics, "network.duplicateEndpoint", path, "endpoint duplicates an earlier connect rule after normalization"); + return; + } + connectSeen.add(key); + connect.push(resolved); + }); + + const listen: ResolvedNetworkListenPermission[] = []; + const listenSeen = new Set(); + permissions.listen.forEach((rule, index) => { + const path = `/permissions/network/listen/${index}`; + const address = canonicalIp(rule.address); + if (!address) { + diagnostic(diagnostics, "network.invalidListenAddress", `${path}/address`, "listen address must be an IPv4 or IPv6 literal without a zone id"); + return; + } + let port: ResolvedNetworkPort | null; + if (rule.port === "ephemeral") port = { ephemeral: true }; + else port = canonicalPort(rule.port); + if (!port) { + diagnostic(diagnostics, "network.invalidPortRange", `${path}/port`, "port range min must not exceed max"); + return; + } + for (const capability of requiredCapabilitiesForProtocol(rule.protocol, "listen")) { + if (features[capability] !== true) { + diagnostic(diagnostics, "network.permissionCapabilityMissing", path, `${rule.protocol} listen permission requires selected capability ${capability}`); + } + } + if (protocolIsPlaintext(rule.protocol) && !permissions.insecureTransport) { + diagnostic(diagnostics, "network.insecureTransportDisabled", path, `${rule.protocol} permission is unusable while insecureTransport is false`); + } + const resolved: ResolvedNetworkListenPermission = { protocol: rule.protocol, address, port }; + const key = canonicalJson(resolved); + if (listenSeen.has(key)) { + diagnostic(diagnostics, "network.duplicateEndpoint", path, "endpoint duplicates an earlier listen rule after normalization"); + return; + } + listenSeen.add(key); + listen.push(resolved); + }); + + if (permissions.broadcast && features["network.udp.broadcast"] !== true) { + diagnostic(diagnostics, "network.permissionCapabilityMissing", "/permissions/network/broadcast", "broadcast permission requires selected capability network.udp.broadcast"); + } + if (permissions.multicast && features["network.udp.multicast"] !== true) { + diagnostic(diagnostics, "network.permissionCapabilityMissing", "/permissions/network/multicast", "multicast permission requires selected capability network.udp.multicast"); + } + const hasTls = Object.values(TLS_CAPABILITIES).some((capability) => features[capability] === true); + if (permissions.credentials.length > 0 && !hasTls) { + diagnostic(diagnostics, "network.credentialsWithoutTls", "/permissions/network/credentials", "credential ids require a selected role-specific TLS capability"); + } + if (permissions.allowInvalidTlsForDevelopment && !hasTls) { + diagnostic(diagnostics, "network.invalidTlsWithoutTls", "/permissions/network/allowInvalidTlsForDevelopment", "invalid-TLS development authority requires a selected role-specific TLS capability"); + } + if (permissions.allowInvalidTlsForDevelopment && !profile.developmentBuild) { + diagnostic(diagnostics, "network.productionInvalidTls", "/permissions/network/allowInvalidTlsForDevelopment", "production network profiles reject invalid-TLS development authority"); + } + if (permissions.browserAmbientCredentials && + features["network.browser.http.client"] !== true && + features["network.browser.websocket.client"] !== true) { + diagnostic(diagnostics, "network.permissionCapabilityMissing", "/permissions/network/browserAmbientCredentials", "browser ambient credentials require a selected Browser network capability"); + } + if (permissions.browserOpaqueWebSocketRedirects && + features["network.browser.websocket.client"] !== true) { + diagnostic(diagnostics, "network.permissionCapabilityMissing", "/permissions/network/browserOpaqueWebSocketRedirects", "opaque WebSocket redirects require selected capability network.browser.websocket.client"); + } + + const compareCanonical = (left: unknown, right: unknown): number => { + const a = canonicalJson(left); + const b = canonicalJson(right); + return a < b ? -1 : a > b ? 1 : 0; + }; + connect.sort(compareCanonical); + listen.sort(compareCanonical); + return { + version: 1, + connect, + listen, + localNetwork: permissions.localNetwork, + insecureTransport: permissions.insecureTransport, + broadcast: permissions.broadcast, + multicast: permissions.multicast, + allowInvalidTlsForDevelopment: permissions.allowInvalidTlsForDevelopment, + browserAmbientCredentials: permissions.browserAmbientCredentials, + browserOpaqueWebSocketRedirects: permissions.browserOpaqueWebSocketRedirects, + credentials: [...permissions.credentials].sort(), + }; +} + +function validProviderId( + value: unknown, + path: string, + diagnostics: ContractDiagnostic[], +): value is string { + if (typeof value === "string" && PROVIDER_ID.test(value)) return true; + diagnostic(diagnostics, "network.invalidProviderId", path, "provider id must be a stable dotted identifier"); + return false; +} + +function resolveProviders( + features: Readonly>, + input: ResolvedNetworkProviders, + diagnostics: ContractDiagnostic[], +): ResolvedNetworkProviders { + const backendByRole: Partial> = {}; + for (const role of Object.keys(BACKEND_CAPABILITIES).sort() as NetworkBackendRole[]) { + if (features[BACKEND_CAPABILITIES[role]] !== true) continue; + const id = input.backendByRole[role]; + if (id === undefined) { + diagnostic(diagnostics, "network.backendMissing", `/networkProfile/providers/backendByRole/${role}`, `selected capability ${BACKEND_CAPABILITIES[role]} requires a ${role} backend`); + } else if (validProviderId(id, `/networkProfile/providers/backendByRole/${role}`, diagnostics)) { + backendByRole[role] = id; + } + } + + const tlsByRole: Partial> = {}; + for (const role of Object.keys(TLS_CAPABILITIES).sort() as NetworkTlsRole[]) { + const tlsCapability = TLS_CAPABILITIES[role]; + if (features[tlsCapability] !== true) continue; + if (features[BASE_CAPABILITIES_BY_TLS_ROLE[role]] !== true) { + diagnostic(diagnostics, "network.tlsBaseMissing", `/engine/capabilities`, `${tlsCapability} requires ${BASE_CAPABILITIES_BY_TLS_ROLE[role]}`); + } + const selection = input.tlsByRole[role]; + if (!selection) { + diagnostic(diagnostics, "network.tlsProviderMissing", `/networkProfile/providers/tlsByRole/${role}`, `selected capability ${tlsCapability} requires a ${role} TLS source`); + continue; + } + if (selection.source !== "provider" && selection.source !== "backend") { + diagnostic(diagnostics, "network.invalidTlsSource", `/networkProfile/providers/tlsByRole/${role}/source`, "TLS source must be provider or backend"); + continue; + } + if (!validProviderId(selection.id, `/networkProfile/providers/tlsByRole/${role}/id`, diagnostics)) continue; + if (selection.source === "backend") { + const backendId = backendByRole[role as NetworkBackendRole]; + if (!backendId || backendId !== selection.id) { + diagnostic(diagnostics, "network.backendTlsMismatch", `/networkProfile/providers/tlsByRole/${role}`, "backend TLS source id must equal the selected backend id for that role"); + continue; + } + } + tlsByRole[role] = { source: selection.source, id: selection.id }; + } + + const netDriverId = validProviderId(input.netDriverId, "/networkProfile/providers/netDriverId", diagnostics) + ? input.netDriverId + : "invalid"; + return { backendByRole, tlsByRole, netDriverId }; +} + +function resolveResources( + requested: NetworkResourceMinimum, + hard: NetworkResourceMinimum, + diagnostics: ContractDiagnostic[], +): NetworkResourceMinimum { + const resolved: Record> = {}; + for (const group of Object.keys(RESOURCE_KEYS) as Array) { + const requestedGroup = requested[group] as Readonly> | undefined; + if (!requestedGroup) continue; + const hardGroup = hard[group] as Readonly> | undefined; + const output: Record = {}; + for (const key of RESOURCE_KEYS[group]) { + const value = requestedGroup[key]; + if (value === undefined) continue; + const maximum = hardGroup?.[key]; + const path = `/resources/network/minimum/${group}/${key}`; + if (!Number.isSafeInteger(maximum) || maximum! <= 0) { + diagnostic(diagnostics, "network.resourceUnsupported", path, `target network profile does not provide a hard limit for ${group}.${key}`); + } else if (value > maximum!) { + diagnostic(diagnostics, "network.resourceExceeded", path, `requested minimum ${value} exceeds target hard limit ${maximum}`); + } + output[key] = value; + } + if (Object.keys(output).length > 0) resolved[group] = output; + } + return resolved as NetworkResourceMinimum; +} + +function validateSelectedNetworkCapabilities( + features: Readonly>, + diagnostics: ContractDiagnostic[], +): boolean { + let selected = false; + for (const [capability, available] of Object.entries(features)) { + // Browser network profiles are a staged target and never produce the + // native provider/policy projection consumed by custom device Hosts. + if (!available || !capability.startsWith("network.") || + capability.startsWith("network.browser.")) continue; + if (!NATIVE_NETWORK_CAPABILITIES.has(capability)) { + diagnostic(diagnostics, "network.capabilityUnsupported", "/engine/capabilities", `${capability} is not a format-3 native network capability`); + continue; + } + selected = true; + for (const dependency of CAPABILITY_DEPENDENCIES[capability] ?? []) { + if (features[dependency] !== true) { + diagnostic(diagnostics, "network.capabilityDependencyMissing", "/engine/capabilities", `${capability} requires selected capability ${dependency}`); + } + } + } + return selected; +} + +export function resolveNetworkBuildPlan( + manifest: PocketManifest, + features: Readonly>, + profile: HostNetworkResolutionProfile | undefined, + diagnostics: ContractDiagnostic[], +): ResolvedNetworkBuildPlan | undefined { + const permissions = manifest.pocket === 3 ? manifest.permissions?.network : undefined; + const resources = manifest.pocket === 3 ? manifest.resources?.network : undefined; + const selected = validateSelectedNetworkCapabilities(features, diagnostics); + + if (!selected) { + if (permissions || resources) { + diagnostic(diagnostics, "network.authorityUnused", "/permissions/network", "network permissions/resources require a selected native network capability"); + } + return undefined; + } + if (manifest.pocket !== 3) { + diagnostic(diagnostics, "network.manifestVersion", "/pocket", "native network capabilities require Pocket manifest format 3"); + return undefined; + } + if (!permissions) { + diagnostic(diagnostics, "network.permissionsMissing", "/permissions/network", "selected native network capabilities require permissions.network"); + } + if (!resources) { + diagnostic(diagnostics, "network.resourcesMissing", "/resources/network", "selected native network capabilities require resources.network.minimum"); + } + if (!profile) { + diagnostic(diagnostics, "network.profileMissing", "/target", "target did not supply a network resolution profile"); + } + if (!permissions || !resources || !profile) return undefined; + + return { + policy: normalizePolicy(permissions, features, profile, diagnostics), + providers: resolveProviders(features, profile.providers, diagnostics), + resources: { + minimum: resolveResources(resources.minimum, profile.hardLimits, diagnostics), + }, + }; +} diff --git a/framework/src/manifest/plan.ts b/framework/src/manifest/plan.ts index ab2718b8..5fabac42 100644 --- a/framework/src/manifest/plan.ts +++ b/framework/src/manifest/plan.ts @@ -1,10 +1,73 @@ import { createHash } from "node:crypto"; -import type { PocketManifestV2 } from "../../../contracts/spec/pocket-manifest.ts"; +import type { + NetworkConnectProtocol, + NetworkListenProtocol, + NetworkResourceMinimum, + PocketManifest, +} from "../../../contracts/spec/pocket-manifest.ts"; import type { PresentationMode, Viewport } from "../../../contracts/spec/platforms.ts"; +export type NetworkBackendRole = + | "http.client" + | "http.server" + | "websocket.client" + | "websocket.server" + | "mqtt.client"; + +export type NetworkTlsRole = NetworkBackendRole | "tcp.client" | "tcp.server"; + +export interface ResolvedNetworkProviders { + readonly backendByRole: Readonly>>; + readonly tlsByRole: Readonly>>>; + readonly netDriverId: string; +} + +export type ResolvedNetworkPort = + | Readonly<{ min: number; max: number }> + | Readonly<{ ephemeral: true }>; + +export interface ResolvedNetworkConnectPermission { + readonly protocol: NetworkConnectProtocol; + readonly host: string; + readonly port: Exclude>; +} + +export interface ResolvedNetworkListenPermission { + readonly protocol: NetworkListenProtocol; + readonly address: string; + readonly port: ResolvedNetworkPort; +} + +export interface ResolvedNetworkPolicy { + readonly version: 1; + readonly connect: readonly ResolvedNetworkConnectPermission[]; + readonly listen: readonly ResolvedNetworkListenPermission[]; + readonly localNetwork: boolean; + readonly insecureTransport: boolean; + readonly broadcast: boolean; + readonly multicast: boolean; + readonly allowInvalidTlsForDevelopment: boolean; + readonly browserAmbientCredentials: boolean; + readonly browserOpaqueWebSocketRedirects: boolean; + readonly credentials: readonly string[]; +} + +export interface ResolvedNetworkResourcePlan { + readonly minimum: NetworkResourceMinimum; +} + +export interface ResolvedNetworkBuildPlan { + readonly policy: ResolvedNetworkPolicy; + readonly providers: ResolvedNetworkProviders; + readonly resources: ResolvedNetworkResourcePlan; +} + export interface ResolvedBuildPlanContent { - readonly app: Pick & - Pick & { + readonly app: Pick & + Pick & { readonly output: string; }; readonly target: { @@ -20,6 +83,10 @@ export interface ResolvedBuildPlanContent { }; /** Required APIs are true; enhancements reflect target availability. */ readonly features: Readonly>; + /** One selected provider option per format-3 requiresOneOf group. */ + readonly selectedCapabilityOptions?: readonly (readonly string[])[]; + /** Present only when the build has admitted network authority. */ + readonly network?: ResolvedNetworkBuildPlan; } export interface ResolvedBuildPlan extends ResolvedBuildPlanContent { diff --git a/framework/src/manifest/resolve.ts b/framework/src/manifest/resolve.ts index 0953ea81..5e695623 100644 --- a/framework/src/manifest/resolve.ts +++ b/framework/src/manifest/resolve.ts @@ -1,5 +1,5 @@ import { DYNAMIC_FORMS, TARGET_FORMS } from "../../../contracts/spec/platforms.ts"; -import type { PocketManifestV2 } from "../../../contracts/spec/pocket-manifest.ts"; +import type { PocketManifest } from "../../../contracts/spec/pocket-manifest.ts"; import { POCKET_PLATFORM_CONTRACTS, type PlatformContractRegistry, @@ -12,10 +12,15 @@ import { type ResolvedBuildPlan, type ResolvedBuildPlanContent, } from "./plan.ts"; +import { + resolveNetworkBuildPlan, + type HostNetworkResolutionProfile, +} from "./network.ts"; import { validatePocketManifest, type ContractDiagnostic } from "./validate.ts"; export interface ResolveBuildRequest { readonly target: string; + readonly network?: HostNetworkResolutionProfile; } export type ResolutionResult = @@ -32,7 +37,7 @@ function sameViewport(left: Viewport, right: Viewport): boolean { /** The app's viewport intent, normalized: the bare `{logical, presentation}` * spelling is shorthand for `{fixed: ...}`. */ -function normalizeViewport(viewport: PocketManifestV2["app"]["viewport"]): { +function normalizeViewport(viewport: PocketManifest["app"]["viewport"]): { fixed?: { logical: Viewport; presentation: PresentationMode }; dynamic?: { default: Viewport; min?: Viewport; max?: Viewport }; } { @@ -53,7 +58,7 @@ const within = (v: Viewport, min: Viewport, max: Viewport): boolean => * pushing diagnostics. */ function resolveViewport( - manifest: PocketManifestV2, + manifest: PocketManifest, profile: TargetProfile, diagnostics: ContractDiagnostic[], ): { logical: Viewport; presentation: PresentationMode; physical: Viewport } | null { @@ -246,7 +251,7 @@ export function validatePlatformContractRegistry( } export function resolveBuildPlan( - manifest: PocketManifestV2, + manifest: PocketManifest, request: ResolveBuildRequest, registry: PlatformContractRegistry = POCKET_PLATFORM_CONTRACTS, ): ResolutionResult { @@ -279,45 +284,112 @@ export function resolveBuildPlan( const provided = new Set(profile.capabilities); const seen = new Map(); const featureAvailability = new Map(); + const selectedCapabilityOptions: string[][] = []; - for (const [kind, capabilities] of [ - ["requires", manifest.engine.capabilities.requires], - ["enhances", manifest.engine.capabilities.enhances ?? []], - ] as const) { - capabilities.forEach((capability, index) => { - const path = capabilityPath(kind, index); - const previous = seen.get(capability); - if (previous) { - diagnostics.push({ - code: "capability.duplicate", - path, - message: `capability was already declared at ${previous}`, - }); - return; - } - seen.set(capability, path); + manifest.engine.capabilities.requires.forEach((capability, index) => { + const path = capabilityPath("requires", index); + const previous = seen.get(capability); + if (previous) { + diagnostics.push({ + code: "capability.duplicate", + path, + message: `capability was already declared at ${previous}`, + }); + return; + } + seen.set(capability, path); - if (!known.has(capability)) { - diagnostics.push({ - code: "capability.unknown", - path, - message: `unknown capability ${JSON.stringify(capability)}`, - }); - return; + if (!known.has(capability)) { + diagnostics.push({ + code: "capability.unknown", + path, + message: `unknown capability ${JSON.stringify(capability)}`, + }); + return; + } + if (!provided.has(capability)) { + diagnostics.push({ + code: "capability.unavailable", + path, + message: `target ${request.target} does not provide ${capability}`, + }); + return; + } + featureAvailability.set(capability, true); + }); + + if (manifest.pocket === 3) { + for (const [groupIndex, group] of (manifest.engine.capabilities.requiresOneOf ?? []).entries()) { + const groupPath = `/engine/capabilities/requiresOneOf/${groupIndex}`; + const normalizedOptions: string[][] = []; + let groupValid = true; + for (const [optionIndex, option] of group.options.entries()) { + const optionPath = `${groupPath}/options/${optionIndex}`; + const normalized = [...option].sort(); + normalizedOptions.push(normalized); + for (const [capabilityIndex, capability] of option.entries()) { + const path = `${optionPath}/${capabilityIndex}`; + const previous = seen.get(capability); + if (previous && !previous.startsWith(groupPath + "/")) { + diagnostics.push({ + code: "capability.duplicate", + path, + message: `capability was already declared at ${previous}`, + }); + groupValid = false; + } else if (!previous) { + seen.set(capability, path); + } + if (!known.has(capability)) { + diagnostics.push({ + code: "capability.unknown", + path, + message: `unknown capability ${JSON.stringify(capability)}`, + }); + groupValid = false; + } else if (!featureAvailability.has(capability)) { + featureAvailability.set(capability, false); + } + } } - const available = provided.has(capability); - const required = kind === "requires"; - if (required && !available) { + const selected = groupValid + ? normalizedOptions.find((option) => option.every((capability) => provided.has(capability))) + : undefined; + if (!selected) { diagnostics.push({ - code: "capability.unavailable", - path, - message: `target ${request.target} does not provide ${capability}`, + code: "capability.noProviderAlternative", + path: `${groupPath}/options`, + message: `target ${request.target} does not provide every capability in any listed option`, }); - return; + continue; } - featureAvailability.set(capability, required || available); - }); + selectedCapabilityOptions.push(selected); + for (const capability of selected) featureAvailability.set(capability, true); + } + } + + for (const [index, capability] of (manifest.engine.capabilities.enhances ?? []).entries()) { + const path = capabilityPath("enhances", index); + const previous = seen.get(capability); + if (previous) { + diagnostics.push({ + code: "capability.duplicate", + path, + message: `capability was already declared at ${previous}`, + }); + continue; + } + seen.set(capability, path); + if (!known.has(capability)) { + diagnostics.push({ + code: "capability.unknown", + path, + message: `unknown capability ${JSON.stringify(capability)}`, + }); + continue; + } + featureAvailability.set(capability, provided.has(capability)); } // A derived output must satisfy the same artifact-name contract an explicit @@ -334,15 +406,17 @@ export function resolveBuildPlan( }); } - if (diagnostics.length > 0 || !resolvedViewport) return { ok: false, diagnostics }; - - const logical: Viewport = [resolvedViewport.logical[0], resolvedViewport.logical[1]]; - const physical: Viewport = [resolvedViewport.physical[0], resolvedViewport.physical[1]]; // Plain codepoint sort — the same ordering canonicalJson uses for the plan // hash, so the pretty plan.json never depends on ICU collation. const features = Object.fromEntries( [...featureAvailability.entries()].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)), ); + const network = resolveNetworkBuildPlan(manifest, features, request.network, diagnostics); + + if (diagnostics.length > 0 || !resolvedViewport) return { ok: false, diagnostics }; + + const logical: Viewport = [resolvedViewport.logical[0], resolvedViewport.logical[1]]; + const physical: Viewport = [resolvedViewport.physical[0], resolvedViewport.physical[1]]; const content: ResolvedBuildPlanContent = { app: { @@ -363,6 +437,8 @@ export function resolveBuildPlan( rasterDensity: profile.display.rasterDensity, }, features, + ...(selectedCapabilityOptions.length > 0 ? { selectedCapabilityOptions } : {}), + ...(network ? { network } : {}), }; return { ok: true, plan: finalizeBuildPlan(content) }; } diff --git a/framework/src/manifest/validate.ts b/framework/src/manifest/validate.ts index 33efaaf4..c623f51d 100644 --- a/framework/src/manifest/validate.ts +++ b/framework/src/manifest/validate.ts @@ -1,8 +1,10 @@ import { + POCKET_MANIFEST_V3_SCHEMA_ID, + pocketManifestV3Schema, pocketManifestV2Schema, type JsonSchema, type JsonSchemaObject, - type PocketManifestV2, + type PocketManifest, } from "../../../contracts/spec/pocket-manifest.ts"; export interface ContractDiagnostic { @@ -174,9 +176,18 @@ function validateSchema( } } -export function validatePocketManifest(input: unknown): ValidationResult { +export function validatePocketManifest(input: unknown): ValidationResult { const diagnostics: ContractDiagnostic[] = []; - validateSchema(input, pocketManifestV2Schema, "", diagnostics); + const record = input !== null && typeof input === "object" && !Array.isArray(input) + ? input as Record + : undefined; + const format3 = record?.pocket === 3 || record?.$schema === POCKET_MANIFEST_V3_SCHEMA_ID; + validateSchema( + input, + format3 ? pocketManifestV3Schema : pocketManifestV2Schema, + "", + diagnostics, + ); if (diagnostics.length > 0) return { ok: false, diagnostics }; - return { ok: true, value: input as PocketManifestV2 }; + return { ok: true, value: input as PocketManifest }; } diff --git a/framework/src/net/abort.ts b/framework/src/net/abort.ts new file mode 100644 index 00000000..4489617e --- /dev/null +++ b/framework/src/net/abort.ts @@ -0,0 +1,147 @@ +import { NetworkError } from "./index.ts"; + +export interface AbortEvent { + readonly type: "abort"; + readonly target: AbortSignal; + readonly currentTarget: AbortSignal; +} + +export type AbortListener = (event: AbortEvent) => void; +type AbortAlgorithm = () => void; + +const ABORT_SIGNAL_TOKEN = Symbol("pocketjs.net.AbortSignal"); + +interface AbortState { + aborted: boolean; + reason: unknown; + readonly listeners: Set; + readonly algorithms: Set; +} + +const abortStates = new WeakMap(); + +function abortState(signal: AbortSignal): AbortState { + const state = abortStates.get(signal); + if (!state) throw new TypeError("Illegal invocation"); + return state; +} + +function abortSignal(signal: AbortSignal, reason: unknown): void { + const state = abortState(signal); + if (state.aborted) return; + state.aborted = true; + state.reason = reason; + + // Snapshot and clear before running application code. A throwing public + // listener cannot prevent native cancellation or strand later listeners. + const algorithms = [...state.algorithms]; + const listeners = [...state.listeners]; + state.algorithms.clear(); + state.listeners.clear(); + for (const algorithm of algorithms) { + try { + algorithm(); + } catch { + // Internal abort algorithms are best-effort cleanup and must not block + // the remaining algorithms. + } + } + + const event = Object.freeze({ + type: "abort" as const, + target: signal, + currentTarget: signal, + }); + for (const listener of listeners) { + try { + listener(event); + } catch { + // EventTarget reports listener exceptions without making abort() throw. + } + } +} + +/** Abort signal supplied by PocketJS rather than an ambient browser global. */ +export class AbortSignal { + constructor(token?: symbol) { + if (token !== ABORT_SIGNAL_TOKEN) { + throw new TypeError("Illegal constructor"); + } + abortStates.set(this, { + aborted: false, + reason: undefined, + listeners: new Set(), + algorithms: new Set(), + }); + } + + get aborted(): boolean { + return abortState(this).aborted; + } + + get reason(): unknown { + return abortState(this).reason; + } + + addEventListener(type: "abort", listener: AbortListener): void { + if (type === "abort") abortState(this).listeners.add(listener); + } + + removeEventListener(type: "abort", listener: AbortListener): void { + if (type === "abort") abortState(this).listeners.delete(listener); + } + + throwIfAborted(): void { + const state = abortState(this); + if (state.aborted) throw state.reason; + } +} + +export class AbortController { + readonly signal = new AbortSignal(ABORT_SIGNAL_TOKEN); + + abort(reason?: unknown): void { + abortSignal(this.signal, reason ?? new NetworkError("The operation was aborted", { + category: "runtime", + code: "aborted", + operation: "abort", + })); + } +} + +export function abortSignalAborted(signal: AbortSignal): boolean { + return abortState(signal).aborted; +} + +export function abortSignalReason(signal: AbortSignal): unknown { + return abortState(signal).reason; +} + +export function addAbortAlgorithm( + signal: AbortSignal, + algorithm: AbortAlgorithm, +): () => void { + const state = abortState(signal); + if (state.aborted) { + algorithm(); + return () => {}; + } + state.algorithms.add(algorithm); + return () => state.algorithms.delete(algorithm); +} + +export function createDependentAbortSignal(source?: AbortSignal): { + readonly signal: AbortSignal; + readonly detach: () => void; +} { + const controller = new AbortController(); + if (!source) return { signal: controller.signal, detach: () => {} }; + if (abortSignalAborted(source)) { + controller.abort(abortSignalReason(source)); + return { signal: controller.signal, detach: () => {} }; + } + const detach = addAbortAlgorithm(source, () => { + controller.abort(abortSignalReason(source)); + }); + return { signal: controller.signal, detach }; +} diff --git a/framework/src/net/http-STAGED.md b/framework/src/net/http-STAGED.md new file mode 100644 index 00000000..ac207e2d --- /dev/null +++ b/framework/src/net/http-STAGED.md @@ -0,0 +1,56 @@ +# HTTP SDK admission status + +**`net/http` remains behind the compiler staged-surface gate.** The object +implementation does not add a target capability and does not admit ESP-IDF or +another Host. + +**`http-binding.ts` is the high-level SDK seam, while +`network-v1-binding.ts` adapts the generated private ABI 1.1 vocabulary to that +seam.** The generated TypeScript and C constants, BufferLease ownership, +owner-thread completion dispatch, exact Build Plan handshake, and admitted +limits snapshot are fixed. The ESP-IDF native binding table, descriptor +aggregation, and product scheduler integration remain required before +admission. + +**A bound response prefetches at most the admitted tee window: the smaller of +four admitted chunks, 256 KiB, and `http.bufferedBodyBytes`.** A small response +therefore reaches `BODY_END`, releases every BufferLease, retires its operation, +and detaches its abort listener even when application code never reads the +body. The formal integration test retains nine such responses across an +eight-slot adapter. A response larger than that window stops receiving credit +until application code reads or cancels it. There is no Guest finalizer, so +dropping an arbitrary large response is not proof that its native operation was +released. + +**Resolving a response-headers event is the request-upload terminal claim.** +The binding must stop further upload credit and cancel an upload producer that +has not reached EOF before it resolves that event, including early HEAD, +content-length-zero, 204, 205, and 304 responses. The SDK performs an additional +best-effort producer cancel, but that does not replace the Host/Core claim. + +**HTTP URL normalization is fixed to the special-URL behavior adapted from +`whatwg-url@17.1.0` and UTS #46 data from `tr46@6.0.0` / Unicode 17.0.0.** It +supports IDNA A-labels, IPv4, IPv6, dot segments, default ports, percent +encoding, query strings, and fragments under explicit input and serialization +ceilings. PocketJS additionally rejects userinfo, requires strict DNS labels, +and removes one DNS root dot because the canonical host is also a permission +and TLS identity. + +**Body helpers, chunks, header storage, clone tee branches, and response +prefetch use the admitted HTTP/client defaults and the lower SDK hard ceiling.** +The constants remain SDK ceilings, not ESP32 resource measurements. + +**TLS and per-operation dictionary snapshots also have staged SDK ceilings.** +The current limits are 64 KiB for a CA input, 16 ALPN tokens / 1 KiB total ALPN +bytes, 253 bytes for `serverName`, 256 bytes for a credential id, and 32 limit +overrides with 64-byte names. Native descriptor limits can only lower them. The +required DER/PEM CA structural validation is not implemented by this staged JS +surface and remains an admission blocker for custom-CA capability. + +The directly ported Fetch/WPT cases are recorded in +`tests/http-fetch-wpt-allowlist.json`. That file is a partial staged allowlist, +not the conformance report required to remove the compiler gate. + +**ABI 1.1 does not yet expose the selected provider's negotiated ALPN +snapshot.** Custom ALPN therefore fails before I/O and cannot be advertised as +a capability. diff --git a/framework/src/net/http-binding.ts b/framework/src/net/http-binding.ts new file mode 100644 index 00000000..24119e51 --- /dev/null +++ b/framework/src/net/http-binding.ts @@ -0,0 +1,477 @@ +import type { + AbortSignal, + NetworkLimitOverrides, + TlsOptions, +} from "./index.ts"; +import type { BodyStream, HttpBodyProducer } from "./http-body.ts"; +import { + NETWORK_V1_ABI_MAJOR, + NETWORK_V1_ABI_MINOR, + NetworkV1CommandOpcode as FormalNetworkV1CommandOpcode, + NetworkV1EventCode as FormalNetworkV1EventCode, +} from "../../../contracts/spec/network/network-v1.ts"; + +/** + * High-level seam consumed by the Fetch implementation. Production installs + * an adapter over the formal `pocketjs:internal/network-v1` table before any + * application initializer. Tests may still install this seam directly without + * manufacturing native completion and BufferLease traffic. + */ +export { NETWORK_V1_ABI_MAJOR, NETWORK_V1_ABI_MINOR }; + +/** Formal append-only command ids, retained under the SDK seam name. */ +export enum NetworkV1CommandOpcode { + OperationCancel = 0x0001, + BodyPull = 0x0010, + BodyChunk = 0x0011, + BodyEnd = 0x0012, + BodyError = 0x0013, + BodyCancel = 0x0014, + HttpRequestStart = 0x0100, +} + +/** Formal append-only completion ids, retained under the SDK seam name. */ +export enum NetworkV1EventCode { + BodyPull = 0x0010, + BodyChunk = 0x0011, + BodyEnd = 0x0012, + BodyError = 0x0013, + BodyCancel = 0x0014, + HttpResponseHeaders = 0x0100, + HttpRequestError = 0x0101, +} + +// Compile-time/runtime drift guard for the compatibility-shaped high-level seam. +if ( + NetworkV1CommandOpcode.OperationCancel !== FormalNetworkV1CommandOpcode.OperationCancel || + NetworkV1CommandOpcode.BodyPull !== FormalNetworkV1CommandOpcode.BodyPull || + NetworkV1CommandOpcode.BodyChunk !== FormalNetworkV1CommandOpcode.BodyChunk || + NetworkV1CommandOpcode.BodyEnd !== FormalNetworkV1CommandOpcode.BodyEnd || + NetworkV1CommandOpcode.BodyError !== FormalNetworkV1CommandOpcode.BodyError || + NetworkV1CommandOpcode.BodyCancel !== FormalNetworkV1CommandOpcode.BodyCancel || + NetworkV1CommandOpcode.HttpRequestStart !== FormalNetworkV1CommandOpcode.HttpRequestStart || + NetworkV1EventCode.BodyPull !== FormalNetworkV1EventCode.BodyPull || + NetworkV1EventCode.BodyChunk !== FormalNetworkV1EventCode.BodyChunk || + NetworkV1EventCode.BodyEnd !== FormalNetworkV1EventCode.BodyEnd || + NetworkV1EventCode.BodyError !== FormalNetworkV1EventCode.BodyError || + NetworkV1EventCode.BodyCancel !== FormalNetworkV1EventCode.BodyCancel || + NetworkV1EventCode.HttpResponseHeaders !== FormalNetworkV1EventCode.HttpResponseHeaders || + NetworkV1EventCode.HttpRequestError !== FormalNetworkV1EventCode.HttpRequestError +) { + throw new TypeError("PocketJS HTTP high-level seam drifted from network ABI v1"); +} + +export interface HttpBindingHeader { + readonly name: string; + readonly value: string; +} + +export interface HttpRequestStartCommand { + readonly opcode: NetworkV1CommandOpcode.HttpRequestStart; + readonly operationId: number; + readonly url: string; + readonly method: string; + readonly headers: readonly HttpBindingHeader[]; + readonly hasBody: boolean; + readonly redirect: "follow" | "manual" | "error"; + readonly timeouts?: Readonly<{ + connect?: number; + headers?: number; + idle?: number; + total?: number; + }>; + readonly maxRedirects: number; + readonly tls?: TlsOptions; + readonly limits?: NetworkLimitOverrides; + readonly ref: boolean; +} + +export interface OperationCancelCommand { + readonly opcode: NetworkV1CommandOpcode.OperationCancel; + readonly operationId: number; +} + +export interface BodyPullCommand { + readonly opcode: NetworkV1CommandOpcode.BodyPull; + readonly operationId: number; + readonly bodyId: number; + readonly maxBytes: number; +} + +export interface BodyChunkCommand { + readonly opcode: NetworkV1CommandOpcode.BodyChunk; + readonly operationId: number; + readonly bodyId: number; + readonly bytes: Uint8Array; +} + +export interface BodyEndCommand { + readonly opcode: NetworkV1CommandOpcode.BodyEnd; + readonly operationId: number; + readonly bodyId: number; +} + +export interface BodyErrorCommand { + readonly opcode: NetworkV1CommandOpcode.BodyError; + readonly operationId: number; + readonly bodyId: number; + readonly code: string; +} + +export interface BodyCancelCommand { + readonly opcode: NetworkV1CommandOpcode.BodyCancel; + readonly operationId: number; + readonly bodyId: number; +} + +export type NetworkV1HttpCommand = + | HttpRequestStartCommand + | OperationCancelCommand + | BodyPullCommand + | BodyChunkCommand + | BodyEndCommand + | BodyErrorCommand + | BodyCancelCommand; + +export interface HttpResponseHeadersEvent { + readonly eventCode: NetworkV1EventCode.HttpResponseHeaders; + readonly operationId: number; + readonly status: number; + readonly statusText: string; + readonly headers: readonly HttpBindingHeader[]; + readonly url: string; + readonly redirected: boolean; + /** Always wrapped as the PocketJS BodyStream identity before publication. */ + readonly body?: BodyStream | null; + /** Host-admitted aggregation ceiling; the SDK ceiling can only lower it. */ + readonly bufferedBodyBytes?: number; +} + +export interface HttpRequestErrorEvent { + readonly eventCode: NetworkV1EventCode.HttpRequestError; + readonly operationId: number; + readonly category: "runtime" | "resolver" | "transport" | "tls" | "protocol"; + readonly code: string; + readonly message: string; + readonly temporary?: boolean; + readonly causeCode?: string; + readonly reasonCode?: number; +} + +export interface BodyPullEvent { + readonly eventCode: NetworkV1EventCode.BodyPull; + readonly operationId: number; + readonly bodyId: number; + readonly maxBytes: number; +} + +export interface BodyChunkEvent { + readonly eventCode: NetworkV1EventCode.BodyChunk; + readonly operationId: number; + readonly bodyId: number; + readonly leaseId: number; + readonly byteLength: number; +} + +export interface BodyEndEvent { + readonly eventCode: NetworkV1EventCode.BodyEnd; + readonly operationId: number; + readonly bodyId: number; +} + +export interface BodyErrorEvent { + readonly eventCode: NetworkV1EventCode.BodyError; + readonly operationId: number; + readonly bodyId: number; + readonly code: string; +} + +export interface BodyCancelEvent { + readonly eventCode: NetworkV1EventCode.BodyCancel; + readonly operationId: number; + readonly bodyId: number; +} + +export type NetworkV1HttpEvent = + | HttpResponseHeadersEvent + | HttpRequestErrorEvent + | BodyPullEvent + | BodyChunkEvent + | BodyEndEvent + | BodyErrorEvent + | BodyCancelEvent; + +export interface HttpClientBindingOperation { + /** + * Resolving response headers is the terminal claim for the request-upload + * direction. Before resolution, the binding must stop BODY_PULL credit and + * cancel a producer that has not reached EOF. The response body has its own + * lifetime and remains live until EOF, cancellation, or operation abort. + */ + readonly response: Promise; + cancel(command: OperationCancelCommand): void; +} + +export interface HttpClientLimitEntry { + readonly name: string; + readonly default: number; + readonly hard: number; + readonly minimum: number; +} + +export interface HttpClientLimitsSnapshot { + readonly values: readonly Readonly[]; + readonly features: readonly string[]; +} + +export interface HttpClientPrivateBinding { + readonly abiMajor: number; + readonly abiMinor: number; + readonly featureSet: readonly string[]; + /** Exact admitted HTTP/client limits, detached before the app initializer. */ + readonly httpClientLimits: Readonly; + /** Effective ALPN intersection for this admitted HTTP Client role. */ + readonly alpnProtocols?: readonly string[]; + start( + command: HttpRequestStartCommand, + requestBody: HttpBodyProducer | null, + signal: AbortSignal, + ): HttpClientBindingOperation; +} + +const objectFreeze = Object.freeze; +const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const objectIsFrozen = Object.isFrozen; +const arrayIsArray = Array.isArray; +const numberIsSafeInteger = Number.isSafeInteger; +const reflectApply = Reflect.apply; +const regExpTest = RegExp.prototype.test; +const REQUIRED_HTTP_CLIENT_LIMITS = objectFreeze([ + "http.bufferedBodyBytes", + "http.headerBytes", + "http.maxBodyChunkBytes", + "http.maxOperations", + "runtime.nativeBufferBytes", +]); + +function ownDataField(value: object, key: PropertyKey, label: string): unknown { + const descriptor = objectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + throw new TypeError(`PocketJS HTTP client binding ${label} must be an own data property`); + } + return descriptor.value; +} + +function tokenMatches(pattern: RegExp, value: string): boolean { + return reflectApply(regExpTest, pattern, [value]) as boolean; +} + +function snapshotTokens( + input: unknown, + label: string, + maximumCount: number, + maximumTokenBytes: number, +): readonly string[] { + if (!arrayIsArray(input) || !objectIsFrozen(input)) { + throw new TypeError(`PocketJS HTTP client binding ${label} must be a frozen array`); + } + const count = input.length; + if (!numberIsSafeInteger(count) || count > maximumCount) { + throw new TypeError(`PocketJS HTTP client binding ${label} exceeds its ceiling`); + } + const output: string[] = []; + for (let index = 0; index < count; index++) { + const token = ownDataField(input, String(index), `${label}[${index}]`); + if (typeof token !== "string" || token.length === 0 || + token.length > maximumTokenBytes) { + throw new TypeError(`PocketJS HTTP client binding ${label} is invalid`); + } + for (let seenIndex = 0; seenIndex < output.length; seenIndex++) { + if (output[seenIndex] === token) { + throw new TypeError(`PocketJS HTTP client binding ${label} is invalid`); + } + } + output[output.length] = token; + } + return objectFreeze(output); +} + +function snapshotHttpClientLimits( + input: unknown, + featureSet: readonly string[], +): Readonly { + if (typeof input !== "object" || input === null || !objectIsFrozen(input)) { + throw new TypeError("PocketJS HTTP client binding limits must be a frozen object"); + } + const rawValues = ownDataField(input, "values", "limits.values"); + const rawFeatures = ownDataField(input, "features", "limits.features"); + if (!arrayIsArray(rawValues) || !objectIsFrozen(rawValues) || rawValues.length > 64) { + throw new TypeError("PocketJS HTTP client binding limits.values is invalid"); + } + const values: Readonly[] = []; + let previousName = ""; + for (let index = 0; index < rawValues.length; index++) { + const rawEntry = ownDataField(rawValues, String(index), `limits.values[${index}]`); + if (typeof rawEntry !== "object" || rawEntry === null || !objectIsFrozen(rawEntry)) { + throw new TypeError("PocketJS HTTP client binding limit entry is invalid"); + } + const name = ownDataField(rawEntry, "name", `limits.values[${index}].name`); + const defaultValue = ownDataField( + rawEntry, + "default", + `limits.values[${index}].default`, + ); + const hard = ownDataField(rawEntry, "hard", `limits.values[${index}].hard`); + const minimum = ownDataField( + rawEntry, + "minimum", + `limits.values[${index}].minimum`, + ); + if (typeof name !== "string" || name.length === 0 || name.length > 128 || + !tokenMatches(/^[a-z][A-Za-z0-9]*(?:\.[a-z][A-Za-z0-9]*)*$/, name) || + name <= previousName || !numberIsSafeInteger(defaultValue) || + !numberIsSafeInteger(hard) || !numberIsSafeInteger(minimum) || + (minimum as number) < 0 || (minimum as number) > (defaultValue as number) || + (defaultValue as number) > (hard as number)) { + throw new TypeError("PocketJS HTTP client binding limit entry is invalid"); + } + values[values.length] = objectFreeze({ + name, + default: defaultValue as number, + hard: hard as number, + minimum: minimum as number, + }); + previousName = name; + } + for (let requiredIndex = 0; + requiredIndex < REQUIRED_HTTP_CLIENT_LIMITS.length; + requiredIndex++) { + const requiredName = REQUIRED_HTTP_CLIENT_LIMITS[requiredIndex]!; + let present = false; + for (let valueIndex = 0; valueIndex < values.length; valueIndex++) { + if (values[valueIndex]!.name === requiredName) { + if (values[valueIndex]!.default < 1) { + throw new TypeError( + `PocketJS HTTP client binding required limit ${requiredName} is zero`, + ); + } + present = true; + break; + } + } + if (!present) { + throw new TypeError( + `PocketJS HTTP client binding limits omit required ${requiredName}`, + ); + } + } + const features = snapshotTokens(rawFeatures, "limits feature set", 64, 128); + if (features.length !== featureSet.length) { + throw new TypeError("PocketJS HTTP client binding limits feature set disagrees"); + } + for (let index = 0; index < features.length; index++) { + if (features[index] !== featureSet[index]) { + throw new TypeError("PocketJS HTTP client binding limits feature set disagrees"); + } + } + return objectFreeze({ values: objectFreeze(values), features }); +} + +function validateBinding(value: unknown): HttpClientPrivateBinding { + if (typeof value !== "object" || value === null) { + throw new TypeError("PocketJS HTTP client binding table is missing"); + } + const binding = value as HttpClientPrivateBinding; + if (!objectIsFrozen(binding)) { + throw new TypeError("PocketJS HTTP client binding table must be frozen"); + } + const abiMajor = ownDataField(binding, "abiMajor", "abiMajor"); + const abiMinor = ownDataField(binding, "abiMinor", "abiMinor"); + const featureSetValue = ownDataField(binding, "featureSet", "featureSet"); + const limitsValue = ownDataField(binding, "httpClientLimits", "httpClientLimits"); + const alpnDescriptor = objectGetOwnPropertyDescriptor(binding, "alpnProtocols"); + if (alpnDescriptor && !("value" in alpnDescriptor)) { + throw new TypeError("PocketJS HTTP client binding alpnProtocols must be a data property"); + } + const alpnProtocolsValue = alpnDescriptor?.value; + const start = ownDataField(binding, "start", "start"); + const featureSet = snapshotTokens(featureSetValue, "feature set", 64, 128); + const httpClientLimits = snapshotHttpClientLimits(limitsValue, featureSet); + const alpnProtocols = alpnProtocolsValue === undefined + ? undefined + : snapshotTokens(alpnProtocolsValue, "ALPN set", 16, 255); + if ( + (() => { + for (let index = 0; index < featureSet.length; index++) { + if (!tokenMatches(/^[a-z0-9][a-z0-9._-]*$/, featureSet[index]!)) return true; + } + return false; + })() + ) { + throw new TypeError("PocketJS HTTP client binding feature set is invalid"); + } + if ( + abiMajor !== NETWORK_V1_ABI_MAJOR || + typeof abiMinor !== "number" || + !numberIsSafeInteger(abiMinor) || + abiMinor < NETWORK_V1_ABI_MINOR + ) { + throw new TypeError( + `PocketJS network ABI mismatch: expected ${NETWORK_V1_ABI_MAJOR}.${NETWORK_V1_ABI_MINOR}`, + ); + } + if (typeof start !== "function") { + throw new TypeError("PocketJS HTTP client binding has no start function"); + } + return objectFreeze({ + abiMajor, + abiMinor, + featureSet, + httpClientLimits, + ...(alpnProtocols === undefined ? {} : { alpnProtocols }), + start: ( + command: HttpRequestStartCommand, + requestBody: HttpBodyProducer | null, + signal: AbortSignal, + ) => reflectApply(start, binding, [command, requestBody, signal]), + }); +} + +let capturedHttpClientBinding: HttpClientPrivateBinding | undefined; + +/** Compiler-only runtime mount target; the raw formal table never enters here. */ +export function installHttpClientBindingForRuntime( + binding: HttpClientPrivateBinding, +): void { + if (capturedHttpClientBinding) { + throw new TypeError("PocketJS HTTP client binding is already installed"); + } + capturedHttpClientBinding = validateBinding(binding); +} + +/** + * Compiler/Host seam. This module is not a package export: public application + * code receives only the values re-exported by `net/http`. + * + * The runtime installation is one-shot. The returned cleanup exists solely so + * isolated conformance tests can restore the unbound staged profile. + */ +export function installHttpClientBindingForTesting( + binding: HttpClientPrivateBinding, +): () => void { + if (capturedHttpClientBinding) { + throw new TypeError("PocketJS HTTP client binding is already installed"); + } + const captured = validateBinding(binding); + capturedHttpClientBinding = captured; + return () => { + if (capturedHttpClientBinding !== captured) { + throw new TypeError("PocketJS HTTP client binding identity changed"); + } + capturedHttpClientBinding = undefined; + }; +} + +export function getHttpClientBinding(): HttpClientPrivateBinding | undefined { + return capturedHttpClientBinding; +} diff --git a/framework/src/net/http-body.ts b/framework/src/net/http-body.ts new file mode 100644 index 00000000..c7eb322d --- /dev/null +++ b/framework/src/net/http-body.ts @@ -0,0 +1,1465 @@ +import { NetworkError } from "./index.ts"; +import { encodeUtf8 } from "./utf8.ts"; + +/** + * SDK-side ceilings used before a Host-admitted limit table is attached. + * Targets may only lower these values through the private binding metadata. + */ +export const HTTP_BODY_CHUNK_BYTES = 64 * 1024; +export const HTTP_BODY_TEE_BRANCH_BYTES = 256 * 1024; +export const HTTP_BODY_HELPER_BYTES = 8 * 1024 * 1024; +const HTTP_BUFFERED_BODY_INPUT_BYTES = 8 * 1024 * 1024; +const HTTP_BODY_EMPTY_CHUNK_LIMIT = 1024; +const HTTP_BODY_CLONE_BRANCHES = 8; +const HTTP_BODY_TEE_SEGMENT_SLOTS = 5; + +export interface HttpBodyLimits { + /** Maximum bytes retained by a buffered input or aggregation helper. */ + readonly bufferedBytes: number; + /** Maximum single source/read credit exposed inside the Guest. */ + readonly chunkBytes: number; + /** Maximum queued bytes for either live tee branch. */ + readonly teeBranchBytes: number; +} + +export interface BodyStream extends AsyncIterable { + readInto(destination: Uint8Array): Promise<{ bytes: number; done: boolean }>; + cancel(reason?: unknown): Promise; +} + +export interface HttpBodyProducer { + pull(maxBytes: number): Promise; + cancel(reason?: unknown): Promise; +} + +interface BodyCloneGroup { + branches: number; + readonly controllers: Set; + readonly terminalCallbacks: Set<() => void>; +} + +interface BodySource { + pull(maxBytes: number): Promise; + cancel(reason?: unknown): Promise; + onTerminal?(callback: () => void): () => void; +} + +const Uint8ArrayIntrinsic = Uint8Array; +const PromiseIntrinsic = Promise; +const SetIntrinsic = Set; +const ArrayBufferIntrinsic = ArrayBuffer; +const TypeErrorIntrinsic = TypeError; +const reflectApply = Reflect.apply; +const mathMin = Math.min; +const mathMax = Math.max; +const numberIsSafeInteger = Number.isSafeInteger; +const objectFreeze = Object.freeze; +const asyncIteratorSymbol = Symbol.asyncIterator; +const typedArrayPrototype = Object.getPrototypeOf(Uint8ArrayIntrinsic.prototype) as object; +const typedArrayByteLength = Object.getOwnPropertyDescriptor( + typedArrayPrototype, + "byteLength", +)!.get!; +const typedArrayByteOffset = Object.getOwnPropertyDescriptor( + typedArrayPrototype, + "byteOffset", +)!.get!; +const typedArrayBuffer = Object.getOwnPropertyDescriptor( + typedArrayPrototype, + "buffer", +)!.get!; +const typedArrayTag = Object.getOwnPropertyDescriptor( + typedArrayPrototype, + Symbol.toStringTag, +)!.get!; +const dataViewByteLength = Object.getOwnPropertyDescriptor( + DataView.prototype, + "byteLength", +)!.get!; +const dataViewByteOffset = Object.getOwnPropertyDescriptor( + DataView.prototype, + "byteOffset", +)!.get!; +const dataViewBuffer = Object.getOwnPropertyDescriptor( + DataView.prototype, + "buffer", +)!.get!; +const arrayBufferByteLength = Object.getOwnPropertyDescriptor( + ArrayBufferIntrinsic.prototype, + "byteLength", +)!.get!; +const uint8ArraySet = Uint8ArrayIntrinsic.prototype.set; +const uint8ArraySubarray = Uint8ArrayIntrinsic.prototype.subarray; +const uint8ArraySlice = Uint8ArrayIntrinsic.prototype.slice; +const arrayBufferIsView = ArrayBufferIntrinsic.isView; +const setAdd = SetIntrinsic.prototype.add; +const setDelete = SetIntrinsic.prototype.delete; +const setClear = SetIntrinsic.prototype.clear; +const setForEach = SetIntrinsic.prototype.forEach; +const setSize = Object.getOwnPropertyDescriptor(SetIntrinsic.prototype, "size")!.get!; +const promiseResolve = PromiseIntrinsic.resolve; +const promiseThen = PromiseIntrinsic.prototype.then; +const promiseAllSettled = PromiseIntrinsic.allSettled; + +export const DEFAULT_HTTP_BODY_LIMITS: Readonly = objectFreeze({ + bufferedBytes: HTTP_BUFFERED_BODY_INPUT_BYTES, + chunkBytes: HTTP_BODY_CHUNK_BYTES, + teeBranchBytes: HTTP_BODY_TEE_BRANCH_BYTES, +}); + +function ignoreBodyRejection(value: unknown): void { + const promise = reflectApply(promiseResolve, PromiseIntrinsic, [value]); + void reflectApply(promiseThen, promise, [undefined, () => undefined]); +} + +function byteLengthOf(value: Uint8Array): number { + return reflectApply(typedArrayByteLength, value, []) as number; +} + +function subarrayOf(value: Uint8Array, start: number, end?: number): Uint8Array { + return reflectApply( + uint8ArraySubarray, + value, + end === undefined ? [start] : [start, end], + ); +} + +function sliceOf(value: Uint8Array, start: number, end?: number): Uint8Array { + return reflectApply( + uint8ArraySlice, + value, + end === undefined ? [start] : [start, end], + ); +} + +function addSetValue(set: Set, value: T): void { + reflectApply(setAdd, set, [value]); +} + +function deleteSetValue(set: Set, value: T): boolean { + return reflectApply(setDelete, set, [value]) as boolean; +} + +function clearSetValues(set: Set): void { + reflectApply(setClear, set, []); +} + +function setValueCount(set: Set): number { + return reflectApply(setSize, set, []) as number; +} + +function snapshotSetValues(set: Set): T[] { + const values: T[] = []; + reflectApply(setForEach, set, [ + (value: T) => { + values[values.length] = value; + }, + ]); + return values; +} + +interface IntrinsicViewSnapshot { + readonly buffer: ArrayBuffer; + readonly byteOffset: number; + readonly byteLength: number; +} + +function intrinsicUint8ArraySnapshot( + value: unknown, + label: string, +): IntrinsicViewSnapshot { + let tag: unknown; + let snapshot: IntrinsicViewSnapshot; + try { + tag = reflectApply(typedArrayTag, value, []); + snapshot = intrinsicViewSnapshot(value as ArrayBufferView); + } catch { + throw new TypeErrorIntrinsic(`${label} must be a Uint8Array`); + } + if (tag !== "Uint8Array") { + throw new TypeErrorIntrinsic(`${label} must be a Uint8Array`); + } + return snapshot; +} + +function intrinsicViewSnapshot(view: ArrayBufferView): IntrinsicViewSnapshot { + try { + return { + buffer: reflectApply(typedArrayBuffer, view, []) as ArrayBuffer, + byteOffset: reflectApply(typedArrayByteOffset, view, []) as number, + byteLength: reflectApply(typedArrayByteLength, view, []) as number, + }; + } catch { + return { + buffer: reflectApply(dataViewBuffer, view, []) as ArrayBuffer, + byteOffset: reflectApply(dataViewByteOffset, view, []) as number, + byteLength: reflectApply(dataViewByteLength, view, []) as number, + }; + } +} + +function copyIntrinsicBytes(snapshot: IntrinsicViewSnapshot): Uint8Array { + const source = new Uint8ArrayIntrinsic( + snapshot.buffer, + snapshot.byteOffset, + snapshot.byteLength, + ); + const copy = new Uint8ArrayIntrinsic(snapshot.byteLength); + reflectApply(uint8ArraySet, copy, [source]); + return copy; +} + +/** Snapshot a genuine Uint8Array without invoking user-visible getters or iteration. */ +export function snapshotUint8Array( + value: unknown, + maximumBytes: number, + label: string, +): Uint8Array { + const snapshot = intrinsicUint8ArraySnapshot(value, label); + if (snapshot.byteLength > maximumBytes) { + throw bodyError( + "resource_limit", + "http.body", + `${label} exceeds ${maximumBytes} bytes`, + ); + } + try { + return copyIntrinsicBytes(snapshot); + } catch { + throw bodyError( + "invalid_state", + "http.body", + `${label} uses a detached buffer`, + ); + } +} + +/** Return the backing buffer of an SDK-owned, exact-length byte array. */ +export function ownedUint8ArrayBuffer(value: Uint8Array): ArrayBuffer { + return reflectApply(typedArrayBuffer, value, []) as ArrayBuffer; +} + +type ReaderKind = "readInto" | "iterator" | "helper" | "binding"; + +function bodyError( + code: "busy" | "invalid_state" | "resource_limit", + operation: string, + message: string, +): NetworkError { + return new NetworkError(message, { + category: "runtime", + code, + operation, + protocol: "http", + }); +} + +function normalizedBodyLimits( + input: Readonly = DEFAULT_HTTP_BODY_LIMITS, +): Readonly { + const bufferedBytes = input.bufferedBytes; + const chunkBytes = input.chunkBytes; + const teeBranchBytes = input.teeBranchBytes; + if (!numberIsSafeInteger(bufferedBytes) || bufferedBytes <= 0 || + !numberIsSafeInteger(chunkBytes) || chunkBytes <= 0 || + !numberIsSafeInteger(teeBranchBytes) || teeBranchBytes <= 0) { + throw bodyError( + "resource_limit", + "http.body", + "HTTP body limits must be positive safe integers", + ); + } + const effectiveBufferedBytes = mathMin(bufferedBytes, HTTP_BUFFERED_BODY_INPUT_BYTES); + const effectiveChunkBytes = mathMin(chunkBytes, HTTP_BODY_CHUNK_BYTES); + return objectFreeze({ + bufferedBytes: effectiveBufferedBytes, + chunkBytes: effectiveChunkBytes, + // Four fixed segments per branch bound both retained bytes and object count. + teeBranchBytes: mathMin( + teeBranchBytes, + HTTP_BODY_TEE_BRANCH_BYTES, + effectiveBufferedBytes, + effectiveChunkBytes * (HTTP_BODY_TEE_SEGMENT_SLOTS - 1), + ), + }); +} + +function assertPositiveCapacity(maxBytes: number, operation: string): void { + if (!numberIsSafeInteger(maxBytes) || maxBytes <= 0) { + throw bodyError( + "invalid_state", + operation, + "HTTP body capacity must be a positive safe integer", + ); + } +} + +function copyView(view: ArrayBufferView): Uint8Array { + try { + return copyIntrinsicBytes(intrinsicViewSnapshot(view)); + } catch { + throw bodyError( + "invalid_state", + "http.body", + "HTTP body input uses a detached buffer", + ); + } +} + +function copyArrayBuffer(buffer: ArrayBuffer): Uint8Array { + try { + const byteLength = reflectApply(arrayBufferByteLength, buffer, []) as number; + return copyIntrinsicBytes({ buffer, byteOffset: 0, byteLength }); + } catch { + throw bodyError( + "invalid_state", + "http.body", + "HTTP body input uses a detached buffer", + ); + } +} + +class MemoryBodySource implements BodySource { + #offset = 0; + + constructor(private readonly bytes: Uint8Array) {} + + async pull(maxBytes: number): Promise { + const byteLength = byteLengthOf(this.bytes); + if (this.#offset === byteLength) return null; + const end = mathMin(byteLength, this.#offset + maxBytes); + const result = subarrayOf(this.bytes, this.#offset, end); + this.#offset = end; + return result; + } + + async cancel(): Promise { + this.#offset = byteLengthOf(this.bytes); + } +} + +class AsyncIterableBodySource implements BodySource { + #iteratorRecord: BodyAsyncIteratorRecord | undefined; + #remainder: Uint8Array | undefined; + #done = false; + #returnCalled = false; + + constructor( + private readonly iterable: AsyncIterable, + private readonly iteratorFactory: () => AsyncIterator, + private readonly chunkBytes: number, + ) {} + + async pull(maxBytes: number): Promise { + if (this.#done) return null; + if (this.#remainder) return this.#takeRemainder(maxBytes); + + this.#iteratorRecord ??= snapshotBodyAsyncIterator( + reflectApply(this.iteratorFactory, this.iterable, []), + ); + const iteratorRecord = this.#iteratorRecord; + let emptyChunks = 0; + for (;;) { + const item = await reflectApply(iteratorRecord.next, iteratorRecord.iterator, []); + if (typeof item !== "object" || item === null) { + throw bodyError( + "invalid_state", + "http.body.pull", + "HTTP async body iterator returned an invalid result", + ); + } + const done = !!item.done; + if (done) { + this.#done = true; + return null; + } + const value = item.value; + const chunk = snapshotUint8Array(value, this.chunkBytes, "HTTP body chunk"); + if (byteLengthOf(chunk) === 0) { + emptyChunks++; + if (emptyChunks > HTTP_BODY_EMPTY_CHUNK_LIMIT) { + throw bodyError( + "resource_limit", + "http.body.pull", + "HTTP async body produced too many empty chunks", + ); + } + continue; + } + this.#remainder = chunk; + return this.#takeRemainder(maxBytes); + } + } + + async cancel(reason?: unknown): Promise { + if (this.#done || this.#returnCalled) return; + this.#done = true; + this.#remainder = undefined; + const iteratorRecord = this.#iteratorRecord; + if (!iteratorRecord?.returnMethod) return; + this.#returnCalled = true; + // A producer return failure is diagnostic-only and never replaces the + // cancellation or transport failure which caused it. + try { + await reflectApply(iteratorRecord.returnMethod, iteratorRecord.iterator, [reason]); + } catch { + // Deliberately ignored at this public SDK boundary. + } + } + + #takeRemainder(maxBytes: number): Uint8Array { + const remainder = this.#remainder!; + const remainderLength = byteLengthOf(remainder); + const count = mathMin(remainderLength, maxBytes); + const result = subarrayOf(remainder, 0, count); + this.#remainder = count === remainderLength + ? undefined + : subarrayOf(remainder, count); + return result; + } +} + +interface BodyAsyncIteratorRecord { + readonly iterator: object; + readonly next: () => PromiseLike> | IteratorResult; + readonly returnMethod?: ( + reason?: unknown, + ) => PromiseLike> | IteratorResult; +} + +function snapshotBodyAsyncIterator(iterator: unknown): BodyAsyncIteratorRecord { + if ((typeof iterator !== "object" && typeof iterator !== "function") || iterator === null) { + throw bodyError( + "invalid_state", + "http.body.pull", + "HTTP async body returned an invalid iterator", + ); + } + const next = (iterator as { readonly next?: unknown }).next; + const returnMethod = (iterator as { readonly return?: unknown }).return; + if (typeof next !== "function" || + (returnMethod !== undefined && returnMethod !== null && typeof returnMethod !== "function")) { + throw bodyError( + "invalid_state", + "http.body.pull", + "HTTP async body returned an invalid iterator", + ); + } + return { + iterator, + next: next as BodyAsyncIteratorRecord["next"], + ...(typeof returnMethod === "function" + ? { returnMethod: returnMethod as NonNullable } + : {}), + }; +} + +class ExternalBodyStreamSource implements BodySource { + #done = false; + #cancelled = false; + + constructor( + private readonly stream: BodyStream, + private readonly readIntoMethod: BodyStream["readInto"], + private readonly cancelMethod: BodyStream["cancel"], + ) {} + + async pull(maxBytes: number): Promise { + if (this.#done) return null; + const destination = new Uint8ArrayIntrinsic(maxBytes); + const result = await reflectApply(this.readIntoMethod, this.stream, [destination]); + if (typeof result !== "object" || result === null) { + throw bodyError( + "invalid_state", + "http.body.pull", + "HTTP BodyStream returned an invalid readInto result", + ); + } + const bytes = result.bytes; + const done = result.done; + if ( + !numberIsSafeInteger(bytes) || + bytes < 0 || + bytes > maxBytes || + typeof done !== "boolean" || + (done && bytes !== 0) || + (!done && bytes === 0) + ) { + throw bodyError( + "invalid_state", + "http.body.pull", + "HTTP BodyStream returned an invalid readInto result", + ); + } + if (done) { + this.#done = true; + return null; + } + return subarrayOf(destination, 0, bytes); + } + + async cancel(reason?: unknown): Promise { + if (this.#cancelled || this.#done) return; + this.#cancelled = true; + this.#done = true; + await reflectApply(this.cancelMethod, this.stream, [reason]); + } +} + +/** + * Eagerly advances a binding response within the admitted tee-sized window. + * Small ignored responses therefore reach BODY_END and retire their native + * operation; larger responses stop at a fixed byte/segment ceiling. + */ +class PrefetchBodySource implements BodySource { + readonly #queue: TeeSegment[] = []; + readonly #waiters = new SetIntrinsic<() => void>(); + readonly #terminalCallbacks = new SetIntrinsic<() => void>(); + readonly #chunkBytes: number; + readonly #bufferLimit: number; + #bufferedBytes = 0; + #pump: Promise | undefined; + #done = false; + #cancelled = false; + #hasError = false; + #error: unknown; + #sourceTerminal = false; + #cancelPromise: Promise | undefined; + + constructor( + private readonly source: BodySource, + limits: Readonly, + ) { + this.#chunkBytes = limits.chunkBytes; + this.#bufferLimit = limits.teeBranchBytes; + this.#schedulePump(); + } + + async pull(maxBytes: number): Promise { + for (;;) { + if (this.#queue.length > 0) { + const chunk = this.#dequeue(maxBytes); + this.#schedulePump(); + return chunk; + } + if (this.#hasError) throw this.#error; + if (this.#done || this.#cancelled) return null; + this.#schedulePump(); + await this.#waitForChange(); + } + } + + cancel(reason?: unknown): Promise { + if (this.#cancelPromise) return this.#cancelPromise; + if (this.#cancelled) return PromiseIntrinsic.resolve(); + const sourceNeedsCancel = !this.#done && !this.#hasError; + this.#cancelled = true; + this.#queue.length = 0; + this.#bufferedBytes = 0; + this.#notify(); + if (!sourceNeedsCancel) return PromiseIntrinsic.resolve(); + this.#cancelPromise = (async () => { + try { + await this.source.cancel(reason); + } finally { + this.#markSourceTerminal(); + this.#notify(); + } + })(); + return this.#cancelPromise; + } + + onTerminal(callback: () => void): () => void { + if (this.#sourceTerminal) { + callback(); + return () => {}; + } + addSetValue(this.#terminalCallbacks, callback); + return () => { + deleteSetValue(this.#terminalCallbacks, callback); + }; + } + + #dequeue(maxBytes: number): Uint8Array { + const first = this.#queue[0]!; + const available = first.end - first.start; + const count = mathMin(available, maxBytes); + const result = subarrayOf(first.bytes, first.start, first.start + count); + first.start += count; + this.#bufferedBytes -= count; + if (first.start === first.end) { + for (let index = 1; index < this.#queue.length; index++) { + this.#queue[index - 1] = this.#queue[index]!; + } + this.#queue.length--; + } + return result; + } + + #enqueue(chunk: Uint8Array): void { + let offset = 0; + const chunkLength = byteLengthOf(chunk); + while (offset < chunkLength) { + let tail = this.#queue[this.#queue.length - 1]; + if (!tail || tail.end === byteLengthOf(tail.bytes)) { + if (this.#queue.length >= HTTP_BODY_TEE_SEGMENT_SLOTS) { + throw bodyError( + "resource_limit", + "http.body.prefetch", + "HTTP response prefetch exceeded its fixed segment slots", + ); + } + tail = { + bytes: new Uint8ArrayIntrinsic(this.#chunkBytes), + start: 0, + end: 0, + }; + this.#queue[this.#queue.length] = tail; + } + const count = mathMin( + byteLengthOf(tail.bytes) - tail.end, + chunkLength - offset, + ); + reflectApply(uint8ArraySet, tail.bytes, [ + subarrayOf(chunk, offset, offset + count), + tail.end, + ]); + tail.end += count; + offset += count; + } + this.#bufferedBytes += chunkLength; + } + + #schedulePump(): void { + if (this.#pump || this.#done || this.#cancelled || this.#hasError || + this.#bufferedBytes >= this.#bufferLimit) return; + this.#pump = (async () => { + try { + while (!this.#done && !this.#cancelled && !this.#hasError && + this.#bufferedBytes < this.#bufferLimit) { + const credit = mathMin( + this.#chunkBytes, + this.#bufferLimit - this.#bufferedBytes, + ); + const chunk = await this.source.pull(credit); + if (this.#cancelled) break; + if (chunk === null) { + this.#done = true; + this.#markSourceTerminal(); + break; + } + const length = byteLengthOf(chunk); + if (length === 0 || length > credit) { + throw bodyError( + length > credit ? "resource_limit" : "invalid_state", + "http.body.prefetch", + "HTTP response source violated prefetch credit", + ); + } + this.#enqueue(chunk); + } + } catch (error) { + if (!this.#cancelled) { + this.#hasError = true; + this.#error = error; + this.#notify(); + try { + await this.source.cancel(error); + } catch { + // The original body/credit failure remains authoritative. + } + this.#markSourceTerminal(); + } + } finally { + this.#pump = undefined; + this.#notify(); + } + })(); + } + + #waitForChange(): Promise { + return new PromiseIntrinsic((resolve) => addSetValue(this.#waiters, resolve)); + } + + #notify(): void { + const waiters = snapshotSetValues(this.#waiters); + clearSetValues(this.#waiters); + for (let index = 0; index < waiters.length; index++) waiters[index]!(); + } + + #markSourceTerminal(): void { + if (this.#sourceTerminal) return; + this.#sourceTerminal = true; + const callbacks = snapshotSetValues(this.#terminalCallbacks); + clearSetValues(this.#terminalCallbacks); + for (let index = 0; index < callbacks.length; index++) { + try { + callbacks[index]!(); + } catch { + // Transport retirement remains authoritative over observer diagnostics. + } + } + } +} + +interface TeeSegment { + readonly bytes: Uint8Array; + start: number; + end: number; +} + +interface TeeBranchState { + readonly queue: TeeSegment[]; + spare?: Uint8Array; + bufferedBytes: number; + cancelled: boolean; +} + +interface TeeState { + readonly source: BodySource; + readonly branches: readonly [TeeBranchState, TeeBranchState]; + readonly waiters: Set<() => void>; + readonly chunkBytes: number; + readonly branchBytes: number; + readPromise?: Promise; + done: boolean; + hasError: boolean; + error: unknown; + sourceCancelCalled: boolean; +} + +function newTeeBranchState(): TeeBranchState { + return { + queue: [], + bufferedBytes: 0, + cancelled: false, + }; +} + +function notifyTee(state: TeeState): void { + const waiters = snapshotSetValues(state.waiters); + clearSetValues(state.waiters); + for (let index = 0; index < waiters.length; index++) waiters[index]!(); +} + +function waitForTeeChange(state: TeeState): Promise { + return new PromiseIntrinsic((resolve) => addSetValue(state.waiters, resolve)); +} + +function dequeueTee(branch: TeeBranchState, maxBytes: number): Uint8Array { + const first = branch.queue[0]!; + const available = first.end - first.start; + const count = mathMin(available, maxBytes); + const result = subarrayOf(first.bytes, first.start, first.start + count); + first.start += count; + branch.bufferedBytes -= count; + if (first.start === first.end) { + for (let index = 1; index < branch.queue.length; index++) { + branch.queue[index - 1] = branch.queue[index]!; + } + branch.queue.length--; + if (branch.queue.length === 0) branch.spare = first.bytes; + } + return result; +} + +function enqueueTee(state: TeeState, branch: TeeBranchState, chunk: Uint8Array): void { + let offset = 0; + const chunkLength = byteLengthOf(chunk); + while (offset < chunkLength) { + let tail = branch.queue[branch.queue.length - 1]; + if (!tail || tail.end === byteLengthOf(tail.bytes)) { + if (branch.queue.length >= HTTP_BODY_TEE_SEGMENT_SLOTS) { + throw bodyError( + "resource_limit", + "http.body.tee", + "HTTP body tee exceeded its fixed segment slots", + ); + } + tail = { + bytes: branch.spare ?? new Uint8ArrayIntrinsic(state.chunkBytes), + start: 0, + end: 0, + }; + branch.spare = undefined; + branch.queue[branch.queue.length] = tail; + } + const count = mathMin(byteLengthOf(tail.bytes) - tail.end, chunkLength - offset); + reflectApply(uint8ArraySet, tail.bytes, [ + subarrayOf(chunk, offset, offset + count), + tail.end, + ]); + tail.end += count; + offset += count; + } + branch.bufferedBytes += chunkLength; +} + +function recordTeeFault(state: TeeState, error: unknown): void { + if (!state.hasError) { + state.hasError = true; + state.error = error; + } + if (state.sourceCancelCalled) return; + state.sourceCancelCalled = true; + try { + ignoreBodyRejection(reflectApply(state.source.cancel, state.source, [state.error])); + } catch { + // The source contract fault remains authoritative. + } +} + +function fillTee(state: TeeState): Promise | undefined { + if (state.readPromise) return state.readPromise; + const active: TeeBranchState[] = []; + for (let index = 0; index < state.branches.length; index++) { + const branch = state.branches[index]!; + if (!branch.cancelled) active[active.length] = branch; + } + if (active.length === 0 || state.done || state.hasError) return; + let credit = state.chunkBytes; + for (let index = 0; index < active.length; index++) { + credit = mathMin( + credit, + state.branchBytes - active[index]!.bufferedBytes, + ); + } + if (credit <= 0) return; + + state.readPromise = (async () => { + try { + const chunk = await state.source.pull(credit); + if (chunk === null) { + state.done = true; + } else { + const chunkLength = byteLengthOf(chunk); + if (chunkLength === 0 || chunkLength > credit) { + recordTeeFault(state, bodyError( + chunkLength > credit ? "resource_limit" : "invalid_state", + "http.body.tee", + "HTTP body source violated bounded tee credit", + )); + } else { + for (let index = 0; index < active.length; index++) { + const branch = active[index]!; + if (branch.cancelled) continue; + enqueueTee(state, branch, chunk); + } + } + } + } catch (error) { + recordTeeFault(state, error); + } finally { + state.readPromise = undefined; + notifyTee(state); + } + })(); + return state.readPromise; +} + +class TeeBodySource implements BodySource { + constructor( + private readonly state: TeeState, + private readonly branchIndex: 0 | 1, + ) {} + + async pull(maxBytes: number): Promise { + const branch = this.state.branches[this.branchIndex]; + for (;;) { + if (branch.cancelled) return null; + if (branch.bufferedBytes > 0) { + const chunk = dequeueTee(branch, maxBytes); + notifyTee(this.state); + return chunk; + } + if (this.state.hasError) throw this.state.error; + if (this.state.done) return null; + + let activeCount = 0; + let canRead = true; + for (let index = 0; index < this.state.branches.length; index++) { + const candidate = this.state.branches[index]!; + if (candidate.cancelled) continue; + activeCount++; + if (candidate.bufferedBytes >= this.state.branchBytes) canRead = false; + } + canRead = activeCount > 0 && canRead; + if (this.state.readPromise || canRead) { + await fillTee(this.state); + continue; + } + // The other live branch is at its fixed ceiling. Waiting here is the + // required backpressure; consuming or cancelling that branch resumes us. + await waitForTeeChange(this.state); + } + } + + async cancel(reason?: unknown): Promise { + const branch = this.state.branches[this.branchIndex]; + if (branch.cancelled) return; + branch.cancelled = true; + branch.queue.length = 0; + branch.spare = undefined; + branch.bufferedBytes = 0; + notifyTee(this.state); + if ( + !this.state.sourceCancelCalled && + this.state.branches[0].cancelled && this.state.branches[1].cancelled + ) { + this.state.sourceCancelCalled = true; + await this.state.source.cancel(reason); + } + } +} + +function teeBodySource( + source: BodySource, + limits: Readonly, +): readonly [BodySource, BodySource] { + const state: TeeState = { + source, + branches: [newTeeBranchState(), newTeeBranchState()], + waiters: new SetIntrinsic(), + chunkBytes: limits.chunkBytes, + branchBytes: limits.teeBranchBytes, + done: false, + hasError: false, + error: undefined, + sourceCancelCalled: false, + }; + return [new TeeBodySource(state, 0), new TeeBodySource(state, 1)]; +} + +class BodyAsyncIterator implements AsyncIterableIterator { + #closed = false; + + constructor(private readonly controller: BodyController) {} + + [Symbol.asyncIterator](): AsyncIterableIterator { + return this; + } + + async next(): Promise> { + if (this.#closed) return { value: undefined, done: true }; + const destination = new Uint8ArrayIntrinsic(this.controller.chunkBytes); + const result = await this.controller.readInto("iterator", destination); + if (result.done) { + this.#closed = true; + return { value: undefined, done: true }; + } + return { value: sliceOf(destination, 0, result.bytes), done: false }; + } + + async return(): Promise> { + if (!this.#closed) { + this.#closed = true; + await this.controller.cancel(); + } + return { value: undefined, done: true }; + } +} + +class BodyStreamValue implements BodyStream { + constructor(private readonly controller: BodyController) {} + + readInto(destination: Uint8Array): Promise<{ bytes: number; done: boolean }> { + return this.controller.readInto("readInto", destination); + } + + cancel(reason?: unknown): Promise { + return this.controller.cancel(reason); + } + + [Symbol.asyncIterator](): AsyncIterableIterator { + return this.controller.createIterator(); + } +} + +export class BodyController { + readonly stream: BodyStream; + readonly aggregateLimit: number; + readonly chunkBytes: number; + readonly limits: Readonly; + #source: BodySource; + #readerKind: ReaderKind | undefined; + #pending = false; + #used = false; + #terminal = false; + #cancelled = false; + #iteratorCreated = false; + #helperCreated = false; + #producerCreated = false; + #leftover: Uint8Array | undefined; + readonly #cloneGroup: BodyCloneGroup; + + constructor( + source: BodySource, + limits: Readonly = DEFAULT_HTTP_BODY_LIMITS, + cloneGroup: BodyCloneGroup = { + branches: 1, + controllers: new SetIntrinsic(), + terminalCallbacks: new SetIntrinsic(), + }, + ) { + this.limits = normalizedBodyLimits(limits); + this.#source = source; + this.#cloneGroup = cloneGroup; + addSetValue(this.#cloneGroup.controllers, this); + this.aggregateLimit = this.limits.bufferedBytes; + this.chunkBytes = this.limits.chunkBytes; + this.stream = new BodyStreamValue(this); + } + + get bodyUsed(): boolean { + return this.#used; + } + + get unusable(): boolean { + return this.#readerKind !== undefined || this.#used; + } + + onTerminal(callback: () => void): () => void { + const sourceTerminal = this.#source.onTerminal; + if (sourceTerminal) { + return reflectApply(sourceTerminal, this.#source, [callback]) as () => void; + } + if (setValueCount(this.#cloneGroup.controllers) === 0) { + callback(); + return () => {}; + } + addSetValue(this.#cloneGroup.terminalCallbacks, callback); + return () => deleteSetValue(this.#cloneGroup.terminalCallbacks, callback); + } + + async cancelGraph(reason?: unknown): Promise { + const controllers = snapshotSetValues(this.#cloneGroup.controllers); + const cancellations: Promise[] = []; + for (let index = 0; index < controllers.length; index++) { + cancellations[index] = controllers[index]!.cancel(reason); + } + const results = await reflectApply(promiseAllSettled, PromiseIntrinsic, [ + cancellations, + ]); + for (let index = 0; index < results.length; index++) { + const result = results[index]!; + if (result.status === "rejected") throw result.reason; + } + } + + createIterator(): AsyncIterableIterator { + this.#claim("iterator"); + if (this.#iteratorCreated) { + throw bodyError( + "invalid_state", + "http.body.iterator", + "HTTP body already has an async iterator", + ); + } + this.#iteratorCreated = true; + return new BodyAsyncIterator(this); + } + + async readInto( + kind: ReaderKind, + destination: Uint8Array, + ): Promise<{ bytes: number; done: boolean }> { + let destinationSnapshot: IntrinsicViewSnapshot; + try { + destinationSnapshot = intrinsicUint8ArraySnapshot( + destination, + "HTTP body destination", + ); + // Constructing an intrinsic view detects a detached backing buffer in + // runtimes where the typed-array slot getters still report zero. + new Uint8ArrayIntrinsic( + destinationSnapshot.buffer, + destinationSnapshot.byteOffset, + destinationSnapshot.byteLength, + ); + } catch { + throw bodyError( + "invalid_state", + "http.body.readInto", + "HTTP body destination uses a detached buffer", + ); + } + const destinationLength = destinationSnapshot.byteLength; + if (destinationLength === 0) { + throw bodyError( + "invalid_state", + "http.body.readInto", + "HTTP body destination must not be empty", + ); + } + this.#claim(kind); + this.#used = true; + if (this.#pending) { + throw bodyError( + "busy", + "http.body.readInto", + "HTTP body already has a pending read", + ); + } + if (this.#terminal || this.#cancelled) return { bytes: 0, done: true }; + + this.#pending = true; + try { + let emptyChunks = 0; + for (;;) { + if (this.#leftover) { + const leftoverLength = byteLengthOf(this.#leftover); + const count = mathMin(destinationLength, leftoverLength); + reflectApply(uint8ArraySet, destination, [subarrayOf(this.#leftover, 0, count), 0]); + this.#leftover = count === leftoverLength + ? undefined + : subarrayOf(this.#leftover, count); + return { bytes: count, done: false }; + } + const chunk = await this.#source.pull( + mathMin(destinationLength, this.chunkBytes), + ); + if (this.#cancelled) return { bytes: 0, done: true }; + if (chunk === null) { + this.#markTerminal(); + return { bytes: 0, done: true }; + } + const chunkLength = byteLengthOf(chunk); + if (chunkLength === 0) { + emptyChunks++; + if (emptyChunks > HTTP_BODY_EMPTY_CHUNK_LIMIT) { + throw bodyError( + "resource_limit", + "http.body.readInto", + "HTTP body source produced too many empty chunks", + ); + } + continue; + } + if (chunkLength > this.chunkBytes) { + throw bodyError( + "resource_limit", + "http.body.readInto", + `HTTP body chunk exceeds ${this.chunkBytes} bytes`, + ); + } + const count = mathMin(destinationLength, chunkLength); + reflectApply(uint8ArraySet, destination, [subarrayOf(chunk, 0, count), 0]); + if (count < chunkLength) this.#leftover = subarrayOf(chunk, count); + return { bytes: count, done: false }; + } + } catch (error) { + this.#markTerminal(); + try { + ignoreBodyRejection(reflectApply(this.#source.cancel, this.#source, [error])); + } catch { + // The source error remains authoritative. + } + throw error; + } finally { + this.#pending = false; + } + } + + async cancel(reason?: unknown): Promise { + if (this.#cancelled || this.#terminal) return; + this.#used = true; + this.#cancelled = true; + this.#leftover = undefined; + try { + await this.#source.cancel(reason); + } finally { + this.#markTerminal(); + } + } + + createProducer(): HttpBodyProducer { + if (this.#producerCreated) { + throw bodyError( + "invalid_state", + "http.body.binding", + "HTTP body already has a binding producer", + ); + } + this.#producerCreated = true; + this.#claim("binding"); + return objectFreeze({ + pull: async (maxBytes: number) => { + assertPositiveCapacity(maxBytes, "http.body.pull"); + const capacity = mathMin(maxBytes, this.chunkBytes); + const destination = new Uint8ArrayIntrinsic(capacity); + const result = await this.readInto("binding", destination); + return result.done ? null : sliceOf(destination, 0, result.bytes); + }, + cancel: (reason?: unknown) => this.cancel(reason), + }); + } + + tee(): BodyController { + if (this.unusable) { + throw bodyError( + "invalid_state", + "http.body.clone", + "Cannot clone a locked or consumed HTTP body", + ); + } + if (this.#cloneGroup.branches >= HTTP_BODY_CLONE_BRANCHES) { + throw bodyError( + "resource_limit", + "http.body.clone", + `HTTP body clone graph exceeds ${HTTP_BODY_CLONE_BRANCHES} branches`, + ); + } + this.#cloneGroup.branches++; + const [first, second] = teeBodySource(this.#source, this.limits); + this.#source = first; + return new BodyController(second, this.limits, this.#cloneGroup); + } + + transfer(): BodyController { + if (this.unusable) { + throw bodyError( + "invalid_state", + "http.Request", + "Cannot construct from a locked or consumed Request body", + ); + } + const source = this.#source; + const transferred = new BodyController( + source, + this.limits, + this.#cloneGroup, + ); + this.#readerKind = "binding"; + this.#used = true; + this.#source = new MemoryBodySource(new Uint8ArrayIntrinsic()); + this.#markTerminal(); + return transferred; + } + + async aggregate(operation: string): Promise { + if (this.#helperCreated) { + throw bodyError( + "invalid_state", + operation, + "HTTP body was already consumed by an aggregation helper", + ); + } + this.#helperCreated = true; + this.#claim("helper"); + const maximum = this.aggregateLimit; + let output = new Uint8ArrayIntrinsic(mathMin(this.chunkBytes, maximum + 1)); + let total = 0; + for (;;) { + const outputLength = byteLengthOf(output); + if (total === outputLength) { + const nextLength = mathMin( + maximum + 1, + mathMax(outputLength + 1, outputLength * 2), + ); + const grown = new Uint8ArrayIntrinsic(nextLength); + reflectApply(uint8ArraySet, grown, [output]); + output = grown; + } + const destination = subarrayOf( + output, + total, + mathMin(byteLengthOf(output), total + this.chunkBytes), + ); + const result = await this.readInto("helper", destination); + if (result.done) break; + total += result.bytes; + if (total > maximum) { + await this.cancel(); + throw bodyError( + "resource_limit", + operation, + `HTTP body helper exceeds ${maximum} bytes`, + ); + } + } + if (total === byteLengthOf(output)) return output; + const exact = new Uint8ArrayIntrinsic(total); + reflectApply(uint8ArraySet, exact, [subarrayOf(output, 0, total)]); + return exact; + } + + #claim(kind: ReaderKind): void { + if (this.#readerKind === undefined) { + this.#readerKind = kind; + return; + } + if (this.#readerKind !== kind) { + throw bodyError( + "invalid_state", + `http.body.${kind}`, + "HTTP body is locked by another reader", + ); + } + } + + #markTerminal(): void { + if (this.#terminal) return; + this.#terminal = true; + deleteSetValue(this.#cloneGroup.controllers, this); + if (setValueCount(this.#cloneGroup.controllers) !== 0) return; + const callbacks = snapshotSetValues(this.#cloneGroup.terminalCallbacks); + clearSetValues(this.#cloneGroup.terminalCallbacks); + for (let index = 0; index < callbacks.length; index++) callbacks[index]!(); + } +} + +export interface ExtractedBody { + readonly controller: BodyController; + readonly contentType?: string; +} + +function bufferedBodySource( + bytes: Uint8Array, + limits: Readonly, +): BodyController { + if (byteLengthOf(bytes) > limits.bufferedBytes) { + throw bodyError( + "resource_limit", + "http.body", + `Buffered HTTP body exceeds ${limits.bufferedBytes} bytes`, + ); + } + return new BodyController(new MemoryBodySource(bytes), limits); +} + +function assertBufferedBodySize(byteLength: number, maximumBytes: number): void { + if (byteLength > maximumBytes) { + throw bodyError( + "resource_limit", + "http.body", + `Buffered HTTP body exceeds ${maximumBytes} bytes`, + ); + } +} + +interface BodyStreamMethods { + readonly readInto: BodyStream["readInto"]; + readonly cancel: BodyStream["cancel"]; +} + +interface BodyObjectMethodsSnapshot { + readonly readInto: unknown; + readonly cancel: unknown; + readonly iteratorFactory: unknown; +} + +function snapshotBodyObjectMethods(value: object): BodyObjectMethodsSnapshot { + return { + readInto: (value as Partial).readInto, + cancel: (value as Partial).cancel, + iteratorFactory: (value as Record)[asyncIteratorSymbol], + }; +} + +function bodyStreamMethods(snapshot: BodyObjectMethodsSnapshot): BodyStreamMethods | null { + return typeof snapshot.readInto === "function" && + typeof snapshot.cancel === "function" && + typeof snapshot.iteratorFactory === "function" + ? { + readInto: snapshot.readInto as BodyStream["readInto"], + cancel: snapshot.cancel as BodyStream["cancel"], + } + : null; +} + +export function extractBody( + input: string | ArrayBuffer | ArrayBufferView | BodyStream | AsyncIterable, + requestedLimits: Readonly = DEFAULT_HTTP_BODY_LIMITS, +): ExtractedBody { + const limits = normalizedBodyLimits(requestedLimits); + if (typeof input === "string") { + const bytes = encodeUtf8(input, limits.bufferedBytes); + if (bytes === null) { + throw bodyError( + "resource_limit", + "http.body", + `Buffered HTTP body exceeds ${limits.bufferedBytes} bytes`, + ); + } + return { + controller: bufferedBodySource(bytes, limits), + contentType: "text/plain;charset=UTF-8", + }; + } + let arrayBufferLength: number | undefined; + try { + arrayBufferLength = reflectApply(arrayBufferByteLength, input, []) as number; + } catch { + arrayBufferLength = undefined; + } + if (arrayBufferLength !== undefined) { + const buffer = input as ArrayBuffer; + const byteLength = arrayBufferLength; + assertBufferedBodySize(byteLength, limits.bufferedBytes); + return { + controller: bufferedBodySource(copyArrayBuffer(buffer), limits), + }; + } + if (reflectApply(arrayBufferIsView, ArrayBufferIntrinsic, [input])) { + const snapshot = intrinsicViewSnapshot(input as ArrayBufferView); + assertBufferedBodySize(snapshot.byteLength, limits.bufferedBytes); + return { + controller: bufferedBodySource(copyIntrinsicBytes(snapshot), limits), + }; + } + if (typeof input === "object" && input !== null) { + const methodSnapshot = snapshotBodyObjectMethods(input); + const methods = bodyStreamMethods(methodSnapshot); + if (methods) { + return { + controller: new BodyController( + new ExternalBodyStreamSource( + input as BodyStream, + methods.readInto, + methods.cancel, + ), + limits, + ), + }; + } + const iteratorFactory = methodSnapshot.iteratorFactory; + if (typeof iteratorFactory !== "function") { + throw new TypeErrorIntrinsic( + "HTTP body must be a string, ArrayBuffer, ArrayBufferView, BodyStream, or AsyncIterable", + ); + } + return { + controller: new BodyController( + new AsyncIterableBodySource( + input as AsyncIterable, + iteratorFactory as () => AsyncIterator, + limits.chunkBytes, + ), + limits, + ), + }; + } + throw new TypeErrorIntrinsic( + "HTTP body must be a string, ArrayBuffer, ArrayBufferView, BodyStream, or AsyncIterable", + ); +} + +export function bodyFromBinding( + input: BodyStream, + limits: Readonly = DEFAULT_HTTP_BODY_LIMITS, +): BodyController { + if (typeof input !== "object" || input === null) { + throw new TypeErrorIntrinsic("Private HTTP binding response body must be a BodyStream"); + } + const methods = bodyStreamMethods(snapshotBodyObjectMethods(input)); + if (!methods) { + throw new TypeErrorIntrinsic("Private HTTP binding response body must be a BodyStream"); + } + return new BodyController( + new PrefetchBodySource( + new ExternalBodyStreamSource(input, methods.readInto, methods.cancel), + normalizedBodyLimits(limits), + ), + limits, + ); +} diff --git a/framework/src/net/http-url.ts b/framework/src/net/http-url.ts new file mode 100644 index 00000000..0de84662 --- /dev/null +++ b/framework/src/net/http-url.ts @@ -0,0 +1,604 @@ +/** + * Allocation-bounded absolute HTTP(S) URL parser. + * + * The state transitions are a focused adaptation of the WHATWG special-URL + * algorithms as implemented by pinned `whatwg-url@17.1.0`. UTS #46 data is + * pinned separately to tr46 6.0.0 / Unicode 17.0.0. PocketJS deliberately + * rejects credentials and applies strict DNS wire-length/STD3 checks because + * the resulting hostname is also the manifest permission and TLS identity. + * Sources and licenses are recorded in `vendor/url/README.md`. + */ + +import { + domainToAscii, + IDNA_INPUT_CODE_UNITS_LIMIT, +} from "./vendor/url/tr46.ts"; + +export const HTTP_URL_INPUT_CODE_UNITS_LIMIT = 8192; +export const HTTP_URL_SERIALIZED_BYTES_LIMIT = 8192; +export const HTTP_URL_HOST_CODE_UNITS_LIMIT = IDNA_INPUT_CODE_UNITS_LIMIT; +export const HTTP_URL_PATH_SEGMENT_LIMIT = 1024; + +export interface CanonicalHttpUrl { + /** Serialized URL without a fragment, used for the HTTP wire target. */ + readonly href: string; + readonly scheme: "http" | "https"; + /** Canonical DNS A-label or IP literal without IPv6 brackets. */ + readonly hostname: string; + readonly hasFragment: boolean; + /** Empty, or a serialized fragment beginning with `#`. */ + readonly fragment: string; +} + +const functionCall = Function.prototype.call; +const bindCall = ( + operation: (...args: Args) => Result, +): ((receiver: unknown, ...args: Args) => Result) => + functionCall.bind(operation) as (receiver: unknown, ...args: Args) => Result; +const objectFreeze = Object.freeze; +const uint8ArrayConstructor = Uint8Array; +const arrayPop = bindCall(Array.prototype.pop); +const arrayPush = bindCall(Array.prototype.push); +const arraySlice = bindCall(Array.prototype.slice); +const numberToString = bindCall(Number.prototype.toString); +const stringCharCodeAt = bindCall(String.prototype.charCodeAt); +const stringCodePointAt = bindCall(String.prototype.codePointAt); +const stringIncludes = bindCall(String.prototype.includes); +const stringIndexOf = bindCall(String.prototype.indexOf); +const stringSlice = bindCall(String.prototype.slice); +const stringSplit = bindCall(String.prototype.split) as unknown as ( + receiver: string, + separator: string, +) => string[]; +const stringStartsWith = bindCall(String.prototype.startsWith); +const stringToLowerCase = bindCall(String.prototype.toLowerCase); +const stringFromCharCode = String.fromCharCode; +const stringFromCodePoint = String.fromCodePoint; +const mathFloor = Math.floor; + +function charCodeAt(value: string, index: number): number { + return stringCharCodeAt(value, index); +} + +function isAsciiAlpha(code: number): boolean { + return (code >= 65 && code <= 90) || (code >= 97 && code <= 122); +} + +function isAsciiDigit(code: number): boolean { + return code >= 48 && code <= 57; +} + +function isAsciiAlphaNumeric(code: number): boolean { + return isAsciiAlpha(code) || isAsciiDigit(code); +} + +function isAsciiHex(code: number): boolean { + return isAsciiDigit(code) || (code >= 65 && code <= 70) || (code >= 97 && code <= 102); +} + +function hexValue(code: number): number { + if (code <= 57) return code - 48; + if (code <= 70) return code - 55; + return code - 87; +} + +function asciiLower(code: number): string { + return stringFromCharCode(code >= 65 && code <= 90 ? code + 32 : code); +} + +function stripUrlWhitespace(input: string): string { + let start = 0; + let end = input.length; + while (start < end && charCodeAt(input, start) <= 0x20) start++; + while (end > start && charCodeAt(input, end - 1) <= 0x20) end--; + const value = stringSlice(input, start, end); + let found = false; + for (let index = 0; index < value.length; index++) { + const code = charCodeAt(value, index); + if (code === 9 || code === 10 || code === 13) { + found = true; + break; + } + } + if (!found) return value; + let cleaned = ""; + for (let index = 0; index < value.length; index++) { + const code = charCodeAt(value, index); + if (code !== 9 && code !== 10 && code !== 13) cleaned += value[index]!; + } + return cleaned; +} + +function appendUtf8(bytes: Uint8Array, offset: number, codePoint: number): number { + if (codePoint >= 0xd800 && codePoint <= 0xdfff) codePoint = 0xfffd; + if (codePoint <= 0x7f) { + bytes[offset++] = codePoint; + } else if (codePoint <= 0x7ff) { + bytes[offset++] = 0xc0 | (codePoint >>> 6); + bytes[offset++] = 0x80 | (codePoint & 0x3f); + } else if (codePoint <= 0xffff) { + bytes[offset++] = 0xe0 | (codePoint >>> 12); + bytes[offset++] = 0x80 | ((codePoint >>> 6) & 0x3f); + bytes[offset++] = 0x80 | (codePoint & 0x3f); + } else { + bytes[offset++] = 0xf0 | (codePoint >>> 18); + bytes[offset++] = 0x80 | ((codePoint >>> 12) & 0x3f); + bytes[offset++] = 0x80 | ((codePoint >>> 6) & 0x3f); + bytes[offset++] = 0x80 | (codePoint & 0x3f); + } + return offset; +} + +function utf8PercentDecode(input: string): string | null { + if (input.length > HTTP_URL_HOST_CODE_UNITS_LIMIT) return null; + const bytes = new uint8ArrayConstructor(input.length * 3); + let byteLength = 0; + for (let index = 0; index < input.length;) { + const codePoint = stringCodePointAt(input, index)!; + byteLength = appendUtf8(bytes, byteLength, codePoint); + index += codePoint > 0xffff ? 2 : 1; + } + let decodedLength = 0; + for (let read = 0; read < byteLength; read++) { + const byte = bytes[read]!; + if (byte === 0x25 && read + 2 < byteLength && + isAsciiHex(bytes[read + 1]!) && isAsciiHex(bytes[read + 2]!)) { + bytes[decodedLength++] = (hexValue(bytes[read + 1]!) << 4) | + hexValue(bytes[read + 2]!); + read += 2; + } else { + bytes[decodedLength++] = byte; + } + } + + let output = ""; + for (let index = 0; index < decodedLength;) { + const first = bytes[index++]!; + let codePoint: number; + let needed: number; + let minimum: number; + if (first <= 0x7f) { + codePoint = first; + needed = 0; + minimum = 0; + } else if (first >= 0xc2 && first <= 0xdf) { + codePoint = first & 0x1f; + needed = 1; + minimum = 0x80; + } else if (first >= 0xe0 && first <= 0xef) { + codePoint = first & 0x0f; + needed = 2; + minimum = 0x800; + } else if (first >= 0xf0 && first <= 0xf4) { + codePoint = first & 7; + needed = 3; + minimum = 0x10000; + } else { + return null; + } + if (index + needed > decodedLength) return null; + for (let count = 0; count < needed; count++) { + const continuation = bytes[index++]!; + if ((continuation & 0xc0) !== 0x80) return null; + codePoint = (codePoint << 6) | (continuation & 0x3f); + } + if (codePoint < minimum || codePoint > 0x10ffff || + (codePoint >= 0xd800 && codePoint <= 0xdfff)) return null; + output += stringFromCodePoint(codePoint); + } + return output; +} + +function parseIpv4Number(input: string): number | null { + if (input.length === 0) return null; + let radix = 10; + let offset = 0; + if (input.length >= 2 && input[0] === "0" && + (input[1] === "x" || input[1] === "X")) { + radix = 16; + offset = 2; + } else if (input.length >= 2 && input[0] === "0") { + radix = 8; + offset = 1; + } + if (offset === input.length) return 0; + let value = 0; + for (; offset < input.length; offset++) { + const code = charCodeAt(input, offset); + let digit: number; + if (code >= 48 && code <= 57) digit = code - 48; + else if (code >= 65 && code <= 70) digit = code - 55; + else if (code >= 97 && code <= 102) digit = code - 87; + else return null; + if (digit >= radix || value > mathFloor((0xffffffff - digit) / radix)) return null; + value = value * radix + digit; + } + return value; +} + +function endsInNumber(input: string): boolean { + let end = input.length; + if (end > 0 && input[end - 1] === ".") end--; + let start = end; + while (start > 0 && input[start - 1] !== ".") start--; + const last = stringSlice(input, start, end); + if (last.length === 0) return false; + let decimal = true; + for (let index = 0; index < last.length; index++) { + if (!isAsciiDigit(charCodeAt(last, index))) { + decimal = false; + break; + } + } + return decimal || parseIpv4Number(last) !== null; +} + +function canonicalIpv4(input: string): string | null { + const parts = stringSplit(input, "."); + if (parts[parts.length - 1] === "" && parts.length > 1) arrayPop(parts); + if (parts.length === 0 || parts.length > 4) return null; + const numbers: number[] = []; + for (let index = 0; index < parts.length; index++) { + const number = parseIpv4Number(parts[index]!); + if (number === null) return null; + arrayPush(numbers, number); + } + for (let index = 0; index < numbers.length - 1; index++) { + if (numbers[index]! > 255) return null; + } + const lastLimit = 256 ** (5 - numbers.length); + if (numbers[numbers.length - 1]! >= lastLimit) return null; + let address = numbers[numbers.length - 1]!; + for (let index = 0; index < numbers.length - 1; index++) { + address += numbers[index]! * 256 ** (3 - index); + } + let output = ""; + for (let index = 0; index < 4; index++) { + const divisor = 256 ** (3 - index); + if (index > 0) output += "."; + output += numberToString(mathFloor(address / divisor) % 256); + } + return output; +} + +function parseEmbeddedIpv4(input: string): readonly [number, number] | null { + const parts = stringSplit(input, "."); + if (parts.length !== 4) return null; + const octets: number[] = []; + for (let index = 0; index < 4; index++) { + const part = parts[index]!; + if (part.length === 0 || (part.length > 1 && part[0] === "0")) return null; + let value = 0; + for (let cursor = 0; cursor < part.length; cursor++) { + const code = charCodeAt(part, cursor); + if (!isAsciiDigit(code)) return null; + value = value * 10 + code - 48; + if (value > 255) return null; + } + arrayPush(octets, value); + } + return [ + (octets[0]! << 8) | octets[1]!, + (octets[2]! << 8) | octets[3]!, + ]; +} + +function parseIpv6Side(side: string): number[] | null { + if (side === "") return []; + const pieces = stringSplit(side, ":"); + const output: number[] = []; + for (let index = 0; index < pieces.length; index++) { + const piece = pieces[index]!; + if (stringIncludes(piece, ".")) { + if (index !== pieces.length - 1) return null; + const embedded = parseEmbeddedIpv4(piece); + if (embedded === null) return null; + arrayPush(output, embedded[0], embedded[1]); + continue; + } + if (piece.length === 0 || piece.length > 4) return null; + let value = 0; + for (let cursor = 0; cursor < piece.length; cursor++) { + const code = charCodeAt(piece, cursor); + if (!isAsciiHex(code)) return null; + value = value * 16 + hexValue(code); + } + arrayPush(output, value); + } + return output; +} + +function ipv6Groups(input: string): number[] | null { + if (stringIncludes(input, "%") || stringIncludes(input, "[") || stringIncludes(input, "]")) { + return null; + } + const separator = stringIndexOf(input, "::"); + if (separator >= 0 && stringIndexOf(input, "::", separator + 2) >= 0) return null; + if (separator < 0) { + const groups = parseIpv6Side(input); + return groups?.length === 8 ? groups : null; + } + const left = parseIpv6Side(stringSlice(input, 0, separator)); + const right = parseIpv6Side(stringSlice(input, separator + 2)); + if (left === null || right === null) return null; + const omitted = 8 - left.length - right.length; + if (omitted < 1) return null; + const groups = arraySlice(left) as number[]; + for (let index = 0; index < omitted; index++) arrayPush(groups, 0); + for (let index = 0; index < right.length; index++) arrayPush(groups, right[index]!); + return groups; +} + +function canonicalIpv6(input: string): string | null { + const groups = ipv6Groups(input); + if (groups === null || groups.length !== 8) return null; + let bestStart = -1; + let bestLength = 1; + for (let start = 0; start < groups.length;) { + if (groups[start] !== 0) { + start++; + continue; + } + let end = start + 1; + while (end < groups.length && groups[end] === 0) end++; + if (end - start > bestLength) { + bestStart = start; + bestLength = end - start; + } + start = end; + } + if (bestStart < 0) { + let output = ""; + for (let index = 0; index < groups.length; index++) { + if (index > 0) output += ":"; + output += numberToString(groups[index]!, 16); + } + return output; + } + let before = ""; + for (let index = 0; index < bestStart; index++) { + if (index > 0) before += ":"; + before += numberToString(groups[index]!, 16); + } + let after = ""; + for (let index = bestStart + bestLength; index < groups.length; index++) { + if (after !== "") after += ":"; + after += numberToString(groups[index]!, 16); + } + return `${before}::${after}`; +} + +function canonicalDomain(rawHost: string): string | null { + const decoded = utf8PercentDecode(rawHost); + if (decoded === null || decoded.length === 0) return null; + let withoutRoot = decoded; + const last = decoded[decoded.length - 1]; + if (last === "." || last === "\u3002" || last === "\uff0e" || last === "\uff61") { + withoutRoot = stringSlice(decoded, 0, -1); + } + if (withoutRoot.length === 0) return null; + const beforeLast = withoutRoot[withoutRoot.length - 1]; + if (beforeLast === "." || beforeLast === "\u3002" || + beforeLast === "\uff0e" || beforeLast === "\uff61") return null; + return domainToAscii(withoutRoot); +} + +function percentEncodedByte(byte: number): string { + const hex = "0123456789ABCDEF"; + return `%${hex[(byte >>> 4) & 15]}${hex[byte & 15]}`; +} + +type EncodeSet = "path" | "query" | "fragment"; + +function shouldPercentEncode(byte: number, set: EncodeSet): boolean { + if (byte <= 0x1f || byte > 0x7e || byte === 0x20 || byte === 0x22 || + byte === 0x3c || byte === 0x3e) return true; + if (set === "fragment") return byte === 0x60; + if (byte === 0x23) return true; + if (set === "query") return byte === 0x27; + return byte === 0x3f || byte === 0x60 || byte === 0x7b || + byte === 0x7d || byte === 0x5e; +} + +function encodeComponent(input: string, set: EncodeSet): string { + let output = ""; + const bytes = new uint8ArrayConstructor(4); + for (let cursor = 0; cursor < input.length;) { + const codePoint = stringCodePointAt(input, cursor)!; + const length = appendUtf8(bytes, 0, codePoint); + cursor += codePoint > 0xffff ? 2 : 1; + for (let index = 0; index < length; index++) { + const byte = bytes[index]!; + output += shouldPercentEncode(byte, set) + ? percentEncodedByte(byte) + : stringFromCharCode(byte); + if (output.length > HTTP_URL_SERIALIZED_BYTES_LIMIT) { + throw new TypeError("HTTP URL serialization exceeds the 8192-byte safety ceiling"); + } + } + } + return output; +} + +function appendSerialized(current: string, addition: string): string { + if (current.length + addition.length > HTTP_URL_SERIALIZED_BYTES_LIMIT) { + throw new TypeError("HTTP URL serialization exceeds the 8192-byte safety ceiling"); + } + return current + addition; +} + +function isSingleDot(segment: string): boolean { + return segment === "." || stringToLowerCase(segment) === "%2e"; +} + +function isDoubleDot(segment: string): boolean { + const lower = stringToLowerCase(segment); + return lower === ".." || lower === ".%2e" || lower === "%2e." || lower === "%2e%2e"; +} + +interface ParsedTail { + readonly pathAndQuery: string; + readonly hasFragment: boolean; + readonly fragment: string; +} + +function parseTail(input: string, start: number): ParsedTail { + let fragmentStart = -1; + let queryStart = -1; + for (let index = start; index < input.length; index++) { + const character = input[index]!; + if (character === "#") { + fragmentStart = index; + break; + } + if (character === "?" && queryStart < 0) queryStart = index; + } + const pathEnd = queryStart >= 0 ? queryStart : fragmentStart >= 0 ? fragmentStart : input.length; + let rawPath = ""; + for (let index = start; index < pathEnd; index++) { + rawPath += input[index] === "\\" ? "/" : input[index]!; + } + const segments: string[] = []; + let cursor = stringStartsWith(rawPath, "/") ? 1 : 0; + let segmentStart = cursor; + for (; cursor <= rawPath.length; cursor++) { + if (cursor < rawPath.length && rawPath[cursor] !== "/") continue; + const segment = encodeComponent(stringSlice(rawPath, segmentStart, cursor), "path"); + const slash = cursor < rawPath.length; + if (isDoubleDot(segment)) { + if (segments.length > 0) arrayPop(segments); + if (!slash) arrayPush(segments, ""); + } else if (isSingleDot(segment)) { + if (!slash) arrayPush(segments, ""); + } else { + arrayPush(segments, segment); + } + if (segments.length > HTTP_URL_PATH_SEGMENT_LIMIT) { + throw new TypeError("HTTP URL exceeds the 1024 path-segment safety ceiling"); + } + segmentStart = cursor + 1; + } + if (segments.length === 0) arrayPush(segments, ""); + let pathAndQuery = ""; + for (let index = 0; index < segments.length; index++) { + pathAndQuery = appendSerialized(pathAndQuery, `/${segments[index]}`); + } + if (queryStart >= 0) { + const queryEnd = fragmentStart >= 0 ? fragmentStart : input.length; + pathAndQuery = appendSerialized( + pathAndQuery, + `?${encodeComponent(stringSlice(input, queryStart + 1, queryEnd), "query")}`, + ); + } + const fragment = fragmentStart < 0 + ? "" + : `#${encodeComponent(stringSlice(input, fragmentStart + 1), "fragment")}`; + return { pathAndQuery, hasFragment: fragmentStart >= 0, fragment }; +} + +function parsePort(input: string, scheme: "http" | "https"): string | null { + if (input === "") return ""; + let value = 0; + for (let index = 0; index < input.length; index++) { + const code = charCodeAt(input, index); + if (!isAsciiDigit(code)) return null; + value = value * 10 + code - 48; + if (value > 65535) return null; + } + if ((scheme === "http" && value === 80) || (scheme === "https" && value === 443)) { + return ""; + } + return `:${value}`; +} + +/** Parse and serialize an absolute WHATWG special `http:` or `https:` URL. */ +export function canonicalizeHttpUrl(input: string): CanonicalHttpUrl { + if (input.length > HTTP_URL_INPUT_CODE_UNITS_LIMIT) { + throw new TypeError("HTTP URL exceeds the 8192-code-unit input safety ceiling"); + } + const source = stripUrlWhitespace(input); + if (source.length === 0 || !isAsciiAlpha(charCodeAt(source, 0))) { + throw new TypeError("HTTP URL must be an absolute http(s) URL"); + } + let cursor = 0; + let rawScheme = ""; + while (cursor < source.length) { + const code = charCodeAt(source, cursor); + if (code === 0x3a) break; + if (cursor > 0 && !(isAsciiAlphaNumeric(code) || code === 0x2b || code === 0x2d || code === 0x2e)) { + throw new TypeError("HTTP URL has an invalid scheme"); + } + rawScheme += asciiLower(code); + cursor++; + } + if (cursor >= source.length || source[cursor] !== ":" || + (rawScheme !== "http" && rawScheme !== "https")) { + throw new TypeError("HTTP URL must use http: or https:"); + } + const scheme = rawScheme; + cursor++; + while (cursor < source.length && (source[cursor] === "/" || source[cursor] === "\\")) cursor++; + const authorityStart = cursor; + let insideBrackets = false; + while (cursor < source.length) { + const character = source[cursor]!; + if (character === "[") insideBrackets = true; + else if (character === "]") insideBrackets = false; + if (!insideBrackets && (character === "/" || character === "\\" || + character === "?" || character === "#")) break; + cursor++; + } + const authority = stringSlice(source, authorityStart, cursor); + if (authority.length === 0) throw new TypeError("HTTP URL must have a hostname"); + if (stringIncludes(authority, "@")) { + throw new TypeError("PocketJS HTTP URLs must not contain credentials"); + } + + let hostname: string; + let serializedHost: string; + let rawPort: string | undefined; + if (authority[0] === "[") { + const close = stringIndexOf(authority, "]"); + if (close <= 1) throw new TypeError("HTTP URL has an invalid IPv6 address"); + const ipv6 = canonicalIpv6(stringSlice(authority, 1, close)); + if (ipv6 === null) throw new TypeError("HTTP URL has an invalid IPv6 address"); + const suffix = stringSlice(authority, close + 1); + if (suffix !== "" && suffix[0] !== ":") { + throw new TypeError("HTTP URL has an invalid IPv6 authority"); + } + hostname = ipv6; + serializedHost = `[${ipv6}]`; + if (suffix !== "") rawPort = stringSlice(suffix, 1); + } else { + const colon = stringIndexOf(authority, ":"); + const rawHost = colon < 0 ? authority : stringSlice(authority, 0, colon); + if (colon >= 0) rawPort = stringSlice(authority, colon + 1); + if (rawHost.length === 0 || rawHost.length > HTTP_URL_HOST_CODE_UNITS_LIMIT) { + throw new TypeError("HTTP URL hostname exceeds its safety ceiling"); + } + const domain = canonicalDomain(rawHost); + if (domain === null) throw new TypeError("HTTP URL has an invalid IDNA hostname"); + if (endsInNumber(domain)) { + const ipv4 = canonicalIpv4(domain); + if (ipv4 === null) throw new TypeError("HTTP URL has an invalid IPv4 address"); + hostname = ipv4; + } else { + hostname = domain; + } + serializedHost = hostname; + } + const port = rawPort === undefined ? "" : parsePort(rawPort, scheme); + if (port === null) throw new TypeError("HTTP URL has an invalid port"); + const tail = parseTail(source, cursor); + const href = `${scheme}://${serializedHost}${port}${tail.pathAndQuery}`; + if (href.length + tail.fragment.length > HTTP_URL_SERIALIZED_BYTES_LIMIT) { + throw new TypeError("HTTP URL serialization exceeds the 8192-byte safety ceiling"); + } + return objectFreeze({ + href, + scheme, + hostname, + hasFragment: tail.hasFragment, + fragment: tail.fragment, + }) as CanonicalHttpUrl; +} diff --git a/framework/src/net/http.ts b/framework/src/net/http.ts new file mode 100644 index 00000000..7b80ad42 --- /dev/null +++ b/framework/src/net/http.ts @@ -0,0 +1,2052 @@ +import type { + NetworkData, + NetworkLimitOverrides, + TlsOptions, +} from "./index.ts"; +import { + AbortController, + AbortSignal, + NetworkError, + URL, +} from "./index.ts"; +import { unsupportedNetworkPromise } from "./internal.ts"; +import type { WebSocketUpgrade } from "./websocket.ts"; +import { + abortSignalAborted, + addAbortAlgorithm, + createDependentAbortSignal, +} from "./abort.ts"; +import { + BodyController, + bodyFromBinding, + extractBody, + HTTP_BODY_CHUNK_BYTES, + HTTP_BODY_HELPER_BYTES, + HTTP_BODY_TEE_BRANCH_BYTES, + ownedUint8ArrayBuffer, + snapshotUint8Array, +} from "./http-body.ts"; +import type { + BodyStream as HttpBodyStream, + HttpBodyLimits, +} from "./http-body.ts"; +import { + getHttpClientBinding, + NetworkV1CommandOpcode, + NetworkV1EventCode, +} from "./http-binding.ts"; +import { + canonicalizeHttpUrl, + type CanonicalHttpUrl, +} from "./http-url.ts"; +import { decodeUtf8 } from "./utf8.ts"; +import type { + HttpBindingHeader, + HttpClientPrivateBinding, + HttpClientBindingOperation, + HttpRequestErrorEvent, + HttpRequestStartCommand, + HttpResponseHeadersEvent, + OperationCancelCommand, +} from "./http-binding.ts"; + +export { + AbortController, + AbortSignal, + NetworkError, + URL, +} from "./index.ts"; +export type { + NetworkAddress, + NetworkData, + NetworkErrorCategory, + NetworkErrorCode, + NetworkErrorOptions, + NetworkLimit, + NetworkLimitOverrides, + NetworkLimits, + NetworkProtocol, + NetworkRole, + TlsOptions, +} from "./index.ts"; +export type { BodyStream } from "./http-body.ts"; + +export type HeadersInit = + | Headers + | Record + | Iterable; + +export type BodyInit = + | NetworkData + | HttpBodyStream + | AsyncIterable + | null; +export type RequestRedirect = "follow" | "manual" | "error"; + +export interface HttpTimeouts { + readonly connect?: number; + readonly headers?: number; + readonly idle?: number; + readonly total?: number; +} + +export interface RequestInit { + readonly method?: string; + readonly headers?: HeadersInit; + readonly body?: BodyInit; + readonly signal?: AbortSignal; + readonly redirect?: RequestRedirect; + readonly timeouts?: HttpTimeouts; + readonly maxRedirects?: number; + readonly tls?: TlsOptions; + readonly limits?: NetworkLimitOverrides; + readonly ref?: boolean; +} + +export interface ResponseInit { + readonly status?: number; + readonly statusText?: string; + readonly headers?: HeadersInit; +} + +type HeadersGuard = "none" | "request" | "response" | "immutable"; + +interface HeaderEntry { + readonly name: string; + readonly value: string; +} + +interface HeadersState { + guard: HeadersGuard; + readonly list: HeaderEntry[]; + byteLimit: number; +} + +const functionCall = Function.prototype.call; +const bindCall = ( + operation: (...args: Args) => Result, +): ((receiver: unknown, ...args: Args) => Result) => + functionCall.bind(operation) as (receiver: unknown, ...args: Args) => Result; +const arrayIncludes = bindCall(Array.prototype.includes); +const arrayIsArray = Array.isArray; +const arrayJoin = bindCall(Array.prototype.join); +const arrayPush = bindCall(Array.prototype.push); +const arraySort = bindCall(Array.prototype.sort); +const arraySplice = bindCall(Array.prototype.splice); +const jsonStringify = JSON.stringify; +const objectCreate = Object.create; +const objectFreeze = Object.freeze; +const objectKeys = Object.keys; +const numberIsFinite = Number.isFinite; +const numberIsInteger = Number.isInteger; +const numberIsSafeInteger = Number.isSafeInteger; +const mathMin = Math.min; +const PromiseIntrinsic = Promise; +const promiseResolve = Promise.resolve; +const promiseThen = Promise.prototype.then; +const reflectApply = Reflect.apply; +const regExpTest = bindCall(RegExp.prototype.test); +const setHas = bindCall(Set.prototype.has); +const stringCharCodeAt = bindCall(String.prototype.charCodeAt); +const stringIncludes = bindCall(String.prototype.includes); +const stringReplace = bindCall(String.prototype.replace) as unknown as ( + receiver: string, + pattern: string | RegExp, + replacement: string, +) => string; +const stringStartsWith = bindCall(String.prototype.startsWith); +const stringToLowerCase = bindCall(String.prototype.toLowerCase); +const stringToUpperCase = bindCall(String.prototype.toUpperCase); +const webIdlBoolean = Boolean; +const webIdlNumber = Number; +const webIdlString = String; +const mathTrunc = Math.trunc; + +const headerStates = new WeakMap(); +const HTTP_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +const HTTP_HEADER_COUNT = 128; +const HTTP_HEADER_BYTES = 64 * 1024; +const FORBIDDEN_REQUEST_NAMES = new Set([ + "accept-charset", + "accept-encoding", + "access-control-request-headers", + "access-control-request-method", + "connection", + "content-length", + "cookie", + "cookie2", + "date", + "dnt", + "expect", + "host", + "keep-alive", + "origin", + "referer", + "set-cookie", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "via", +]); +const FORBIDDEN_RESPONSE_NAMES = new Set(["set-cookie", "set-cookie2"]); +const NORMALIZED_METHODS = new Set(["DELETE", "GET", "HEAD", "OPTIONS", "POST", "PUT"]); +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); + +interface EffectiveHttpSdkLimits { + readonly headerBytes: number; + readonly body: Readonly; +} + +const UNBOUND_HTTP_SDK_LIMITS: Readonly = objectFreeze({ + headerBytes: HTTP_HEADER_BYTES, + body: objectFreeze({ + bufferedBytes: HTTP_BODY_HELPER_BYTES, + chunkBytes: HTTP_BODY_CHUNK_BYTES, + teeBranchBytes: HTTP_BODY_TEE_BRANCH_BYTES, + }), +}); + +function admittedLimitDefault( + binding: HttpClientPrivateBinding, + name: string, +): number { + const values = binding.httpClientLimits.values; + for (let index = 0; index < values.length; index++) { + const entry = values[index]!; + if (entry.name === name) return entry.default; + } + throw new TypeError(`PocketJS HTTP client binding is missing ${name}`); +} + +function effectiveHttpSdkLimits( + binding: HttpClientPrivateBinding | undefined = getHttpClientBinding(), +): Readonly { + if (!binding) return UNBOUND_HTTP_SDK_LIMITS; + const bufferedBytes = mathMin( + admittedLimitDefault(binding, "http.bufferedBodyBytes"), + HTTP_BODY_HELPER_BYTES, + ); + const chunkBytes = mathMin( + admittedLimitDefault(binding, "http.maxBodyChunkBytes"), + HTTP_BODY_CHUNK_BYTES, + ); + return objectFreeze({ + headerBytes: mathMin( + admittedLimitDefault(binding, "http.headerBytes"), + HTTP_HEADER_BYTES, + ), + body: objectFreeze({ + bufferedBytes, + chunkBytes, + teeBranchBytes: mathMin( + bufferedBytes, + HTTP_BODY_TEE_BRANCH_BYTES, + chunkBytes * 4, + ), + }), + }); +} + +function headersState(headers: Headers): HeadersState { + const state = headerStates.get(headers); + if (!state) throw new TypeError("Illegal invocation"); + return state; +} + +function hasNormalizedHeader(headers: Headers, name: string): boolean { + for (const entry of headersState(headers).list) { + if (entry.name === name) return true; + } + return false; +} + +function byteString(value: unknown, label: string): string { + const string = webIdlString(value); + for (let index = 0; index < string.length; index++) { + if (stringCharCodeAt(string, index) > 0xff) { + throw new TypeError(`${label} contains a character outside ByteString`); + } + } + return string; +} + +function normalizeHeaderName(value: unknown): string { + const name = byteString(value, "HTTP header name"); + if (!regExpTest(HTTP_TOKEN, name)) throw new TypeError(`Invalid HTTP header name: ${name}`); + return stringToLowerCase(name); +} + +function normalizeHeaderValue(value: unknown): string { + const raw = byteString(value, "HTTP header value"); + const normalized = stringReplace(raw, /^[\t\n\r ]+|[\t\n\r ]+$/g, ""); + if (regExpTest(/[\0\r\n]/, normalized)) throw new TypeError("Invalid HTTP header value"); + return normalized; +} + +/** Fetch's header-value get/decode/split algorithm for method override guards. */ +function splitHeaderValue(value: string): string[] { + const values: string[] = []; + let position = 0; + let temporary = ""; + for (;;) { + while (position < value.length && value[position] !== '"' && value[position] !== ",") { + temporary += value[position++]!; + } + if (position < value.length && value[position] === '"') { + temporary += value[position++]!; + for (;;) { + while (position < value.length && value[position] !== '"' && value[position] !== "\\") { + temporary += value[position++]!; + } + if (position >= value.length) break; + const quoteOrBackslash = value[position++]!; + temporary += quoteOrBackslash; + if (quoteOrBackslash === "\\" && position < value.length) { + temporary += value[position++]!; + continue; + } + break; + } + if (position < value.length) continue; + } + arrayPush(values, stringReplace(temporary, /^[\t ]+|[\t ]+$/g, "")); + temporary = ""; + if (position >= value.length) return values; + position++; + } +} + +function guardRejects(guard: HeadersGuard, name: string, value = ""): boolean { + if (guard === "request") { + if (setHas(FORBIDDEN_REQUEST_NAMES, name) || + stringStartsWith(name, "proxy-") || + stringStartsWith(name, "sec-")) return true; + if ( + name === "x-http-method" || + name === "x-http-method-override" || + name === "x-method-override" + ) { + for (const method of splitHeaderValue(value)) { + if (arrayIncludes(["CONNECT", "TRACE", "TRACK"], stringToUpperCase(method))) { + return true; + } + } + return false; + } + return false; + } + return guard === "response" && setHas(FORBIDDEN_RESPONSE_NAMES, name); +} + +function assertMutable(state: HeadersState): void { + if (state.guard === "immutable") throw new TypeError("Headers are immutable"); +} + +function assertHeaderBudget(entries: readonly HeaderEntry[], byteLimit: number): void { + let bytes = 0; + for (const entry of entries) { + bytes += entry.name.length + entry.value.length + 4; + } + if (entries.length > HTTP_HEADER_COUNT || bytes > byteLimit) { + throw new NetworkError("HTTP headers exceed the SDK safety ceiling", { + category: "runtime", + code: "resource_limit", + operation: "http.Headers", + protocol: "http", + }); + } +} + +function appendHeader( + state: HeadersState, + nameValue: unknown, + valueValue: unknown, + bypassGuard = false, +): void { + const name = normalizeHeaderName(nameValue); + const value = normalizeHeaderValue(valueValue); + assertMutable(state); + if (!bypassGuard && guardRejects(state.guard, name, value)) return; + assertHeaderBudget([...state.list, { name, value }], state.byteLimit); + arrayPush(state.list, { name, value }); +} + +function snapshotIteratorResult( + value: unknown, + label: string, +): { readonly done: boolean; readonly value: unknown } { + if (typeof value !== "object" || value === null) { + throw new TypeError(`${label} returned an invalid result`); + } + const result = value as IteratorResult; + const done = webIdlBoolean(result.done); + return { done, value: done ? undefined : result.value }; +} + +function fillHeaders( + state: HeadersState, + init: HeadersInit | undefined, + bypassGuard = false, +): void { + if (init === undefined) return; + if (init instanceof Headers) { + for (const entry of headersState(init).list) { + appendHeader(state, entry.name, entry.value, bypassGuard); + } + return; + } + const iterator = (init as Partial>)[Symbol.iterator]; + if (typeof iterator === "function") { + const outer = reflectApply(iterator, init, []) as Iterator; + const outerNext = typeof outer === "object" && outer !== null + ? outer.next + : undefined; + if (typeof outerNext !== "function") { + throw new TypeError("Headers initializer iterator is invalid"); + } + try { + for (;;) { + const item = snapshotIteratorResult( + reflectApply(outerNext, outer, []), + "Headers initializer iterator", + ); + if (item.done) break; + const pairValue = item.value; + if (typeof pairValue !== "object" || pairValue === null) { + throw new TypeError("Each Headers initializer item must be an iterable pair"); + } + const pairMethod = (pairValue as Partial>)[Symbol.iterator]; + if (typeof pairMethod !== "function") { + throw new TypeError("Each Headers initializer item must be an iterable pair"); + } + const pairIterator = reflectApply(pairMethod, pairValue, []) as Iterator; + const pairNext = typeof pairIterator === "object" && pairIterator !== null + ? pairIterator.next + : undefined; + if (typeof pairNext !== "function") { + throw new TypeError("Each Headers initializer pair iterator is invalid"); + } + const first = snapshotIteratorResult( + reflectApply(pairNext, pairIterator, []), + "Headers initializer pair iterator", + ); + const second = snapshotIteratorResult( + reflectApply(pairNext, pairIterator, []), + "Headers initializer pair iterator", + ); + const extra = snapshotIteratorResult( + reflectApply(pairNext, pairIterator, []), + "Headers initializer pair iterator", + ); + if (first.done || second.done || !extra.done) { + const pairReturn = pairIterator.return; + if (typeof pairReturn === "function") { + reflectApply(pairReturn, pairIterator, []); + } + throw new TypeError("Each Headers initializer item must contain exactly two values"); + } + appendHeader(state, first.value, second.value, bypassGuard); + } + } catch (error) { + try { + const outerReturn = outer.return; + if (typeof outerReturn === "function") reflectApply(outerReturn, outer, []); + } catch { + // Iterator close cannot replace the conversion failure. + } + throw error; + } + return; + } + if (typeof init !== "object" || init === null) { + throw new TypeError("Headers initializer must be a record or iterable"); + } + for (const name of objectKeys(init)) { + appendHeader(state, name, (init as Record)[name], bypassGuard); + } +} + +function sortedCombinedHeaders(state: HeadersState): HeaderEntry[] { + const names: string[] = []; + const seen: Record = objectCreate(null); + for (const entry of state.list) { + if (seen[entry.name]) continue; + seen[entry.name] = true; + arrayPush(names, entry.name); + } + arraySort(names); + const result: HeaderEntry[] = []; + for (const name of names) { + const values: string[] = []; + for (const entry of state.list) { + if (entry.name === name) arrayPush(values, entry.value); + } + if (name === "set-cookie") { + for (const value of values) arrayPush(result, { name, value }); + } else { + arrayPush(result, { name, value: arrayJoin(values, ", ") }); + } + } + return result; +} + +type HeaderIteratorKind = "entry" | "key" | "value"; + +class HeadersIterator implements IterableIterator { + #index = 0; + + constructor( + private readonly headers: Headers, + private readonly kind: HeaderIteratorKind, + ) {} + + [Symbol.iterator](): IterableIterator { + return this; + } + + next(): IteratorResult { + const entry = sortedCombinedHeaders(headersState(this.headers))[this.#index++]; + if (!entry) return { value: undefined, done: true }; + const value = this.kind === "entry" + ? [entry.name, entry.value] + : this.kind === "key" + ? entry.name + : entry.value; + return { value: value as T, done: false }; + } +} + +function createHeaders( + init: HeadersInit | undefined, + guard: HeadersGuard, + byteLimit = effectiveHttpSdkLimits().headerBytes, +): Headers { + const headers = new Headers(); + const state = headersState(headers); + state.byteLimit = byteLimit; + state.guard = guard === "immutable" ? "none" : guard; + fillHeaders(state, init); + state.guard = guard; + return headers; +} + +function createBindingHeaders( + entries: readonly HttpBindingHeader[], + byteLimit: number, +): Headers { + const headers = new Headers(); + const state = headersState(headers); + state.byteLimit = byteLimit; + for (const entry of entries) appendHeader(state, entry.name, entry.value, true); + state.guard = "immutable"; + return headers; +} + +function cloneHeaders(headers: Headers): Headers { + const source = headersState(headers); + const clone = new Headers(); + const target = headersState(clone); + target.byteLimit = source.byteLimit; + for (const entry of source.list) { + arrayPush(target.list, { name: entry.name, value: entry.value }); + } + target.guard = source.guard; + return clone; +} + +function bindingHeaders(headers: Headers): readonly HttpBindingHeader[] { + const source = headersState(headers).list; + const output = new Array(source.length); + for (let index = 0; index < source.length; index++) { + const entry = source[index]!; + output[index] = objectFreeze({ name: entry.name, value: entry.value }); + } + return objectFreeze(output); +} + +export class Headers implements Iterable<[string, string]> { + constructor(init: HeadersInit | undefined = undefined) { + const state: HeadersState = { + guard: "none", + list: [], + byteLimit: effectiveHttpSdkLimits().headerBytes, + }; + headerStates.set(this, state); + fillHeaders(state, init); + } + + append(name: string, value: string): void { + appendHeader(headersState(this), name, value); + } + + delete(nameValue: string): void { + const state = headersState(this); + const name = normalizeHeaderName(nameValue); + assertMutable(state); + if (guardRejects(state.guard, name)) return; + for (let index = state.list.length - 1; index >= 0; index--) { + if (state.list[index]!.name === name) arraySplice(state.list, index, 1); + } + } + + get(nameValue: string): string | null { + const state = headersState(this); + const name = normalizeHeaderName(nameValue); + const values: string[] = []; + for (const entry of state.list) { + if (entry.name === name) arrayPush(values, entry.value); + } + return values.length === 0 ? null : arrayJoin(values, ", "); + } + + has(nameValue: string): boolean { + const state = headersState(this); + const name = normalizeHeaderName(nameValue); + for (const entry of state.list) { + if (entry.name === name) return true; + } + return false; + } + + set(nameValue: string, valueValue: string): void { + const state = headersState(this); + const name = normalizeHeaderName(nameValue); + const value = normalizeHeaderValue(valueValue); + assertMutable(state); + if (guardRejects(state.guard, name, value)) return; + const next: HeaderEntry[] = []; + let inserted = false; + for (const entry of state.list) { + if (entry.name !== name) arrayPush(next, entry); + else if (!inserted) { + arrayPush(next, { name, value }); + inserted = true; + } + } + if (!inserted) arrayPush(next, { name, value }); + assertHeaderBudget(next, state.byteLimit); + arraySplice(state.list, 0, state.list.length, ...next); + } + + entries(): IterableIterator<[string, string]> { + headersState(this); + return new HeadersIterator<[string, string]>(this, "entry"); + } + + keys(): IterableIterator { + headersState(this); + return new HeadersIterator(this, "key"); + } + + values(): IterableIterator { + headersState(this); + return new HeadersIterator(this, "value"); + } + + forEach( + callback: (value: string, key: string, headers: Headers) => void, + thisArg?: unknown, + ): void { + headersState(this); + if (typeof callback !== "function") throw new TypeError("callback must be a function"); + for (const [name, value] of this.entries()) { + reflectApply(callback, thisArg, [value, name, this]); + } + } + + getSetCookie(): string[] { + const values: string[] = []; + for (const entry of headersState(this).list) { + if (entry.name === "set-cookie") arrayPush(values, entry.value); + } + return values; + } + + [Symbol.iterator](): IterableIterator<[string, string]> { + return this.entries(); + } +} + +interface RequestExtensions { + readonly timeouts?: Readonly; + readonly maxRedirects: number; + readonly tls?: TlsOptions; + readonly limits?: NetworkLimitOverrides; + readonly ref: boolean; +} + +interface RequestState extends RequestExtensions { + readonly method: string; + readonly url: string; + readonly headers: Headers; + readonly body: BodyController | null; + readonly signal: AbortSignal; + readonly detachSignal: () => void; + readonly redirect: RequestRedirect; +} + +interface ResponseState { + readonly status: number; + readonly statusText: string; + readonly headers: Headers; + readonly body: BodyController | null; + readonly url: string; + readonly redirected: boolean; +} + +const requestStates = new WeakMap(); +const responseStates = new WeakMap(); +const HTTP_LIMIT_OVERRIDE_COUNT = 32; +const HTTP_LIMIT_NAME_BYTES = 64; +const HTTP_TLS_CA_BYTES = 64 * 1024; +const HTTP_TLS_ALPN_COUNT = 16; +const HTTP_TLS_ALPN_BYTES = 1024; +const HTTP_TLS_SERVER_NAME_BYTES = 253; +const HTTP_TLS_CREDENTIAL_ID_BYTES = 128; +interface RequestInitSnapshot { + readonly body: BodyInit | undefined; + readonly headers: HeadersInit | undefined; + readonly limits: NetworkLimitOverrides | undefined; + readonly maxRedirects: number | undefined; + readonly method: string | undefined; + readonly redirect: RequestRedirect | undefined; + readonly ref: boolean | undefined; + readonly signal: AbortSignal | undefined; + readonly timeouts: HttpTimeouts | undefined; + readonly tls: TlsOptions | undefined; +} + +interface ResponseInitSnapshot { + readonly headers: HeadersInit | undefined; + readonly status: unknown; + readonly statusText: unknown; +} + +/** WebIDL dictionaries read every recognized member exactly once, in order. */ +function snapshotRequestInit(init: RequestInit | null | undefined): RequestInitSnapshot { + if (init === undefined || init === null) { + return { + body: undefined, + headers: undefined, + limits: undefined, + maxRedirects: undefined, + method: undefined, + redirect: undefined, + ref: undefined, + signal: undefined, + timeouts: undefined, + tls: undefined, + }; + } + const dictionary = Object(init) as RequestInit; + return { + body: dictionary.body, + headers: dictionary.headers, + limits: dictionary.limits, + maxRedirects: dictionary.maxRedirects, + method: dictionary.method, + redirect: dictionary.redirect, + ref: dictionary.ref, + signal: dictionary.signal, + timeouts: dictionary.timeouts, + tls: dictionary.tls, + }; +} + +function snapshotResponseInit(init: ResponseInit | null | undefined): ResponseInitSnapshot { + if (init === undefined || init === null) { + return { headers: undefined, status: undefined, statusText: undefined }; + } + const dictionary = Object(init) as ResponseInit; + return { + headers: dictionary.headers, + status: dictionary.status, + statusText: dictionary.statusText, + }; +} + +function requestState(request: Request): RequestState { + const state = requestStates.get(request); + if (!state) throw new TypeError("Illegal invocation"); + return state; +} + +function responseState(response: Response): ResponseState { + const state = responseStates.get(response); + if (!state) throw new TypeError("Illegal invocation"); + return state; +} + +function runtimeError( + code: "aborted" | "invalid_state" | "resource_limit" | "unsupported" | "system_error", + operation: string, + message: string, +): NetworkError { + return new NetworkError(message, { + category: "runtime", + code, + operation, + protocol: "http", + }); +} + +function normalizeMethod(value: unknown): string { + const method = byteString(value, "HTTP method"); + if (!regExpTest(HTTP_TOKEN, method)) throw new TypeError(`Invalid HTTP method: ${method}`); + const upper = stringToUpperCase(method); + if (upper === "CONNECT" || upper === "TRACE" || upper === "TRACK") { + throw new TypeError(`Forbidden HTTP method: ${upper}`); + } + return setHas(NORMALIZED_METHODS, upper) + ? upper + : method; +} + +function normalizeRedirect(value: unknown): RequestRedirect { + const mode = webIdlString(value); + if (mode === "follow" || mode === "manual" || mode === "error") return mode; + throw new TypeError(`Invalid HTTP redirect mode: ${mode}`); +} + +function normalizeTimeouts(value: HttpTimeouts | undefined): Readonly | undefined { + if (value === undefined) return undefined; + if (value === null) return objectFreeze({}); + const dictionary = Object(value) as HttpTimeouts; + const snapshot = { + connect: dictionary.connect, + headers: dictionary.headers, + idle: dictionary.idle, + total: dictionary.total, + }; + const output: Record = {}; + for (const key of ["connect", "headers", "idle", "total"] as const) { + const timeout = snapshot[key]; + if (timeout === undefined) continue; + if (!numberIsSafeInteger(timeout) || timeout <= 0) { + throw new TypeError(`HTTP ${key} timeout must be a positive safe integer`); + } + output[key] = timeout; + } + return objectFreeze(output); +} + +function normalizeMaxRedirects(value: number | undefined): number { + const result = value ?? 5; + if (!numberIsSafeInteger(result) || result < 0 || result > 5) { + throw new TypeError("HTTP maxRedirects must be an integer from 0 through 5"); + } + return result; +} + +function normalizeLimits( + limits: NetworkLimitOverrides | undefined, + binding: HttpClientPrivateBinding | undefined, +): NetworkLimitOverrides | undefined { + if (limits === undefined) return undefined; + if (typeof limits !== "object" || limits === null) { + throw new TypeError("HTTP limits must be an object"); + } + const output: Record = objectCreate(null); + let count = 0; + for (const key of objectKeys(limits)) { + count++; + if (count > HTTP_LIMIT_OVERRIDE_COUNT) { + throw new TypeError(`HTTP limits cannot exceed ${HTTP_LIMIT_OVERRIDE_COUNT} entries`); + } + if ( + key.length === 0 || + key.length > HTTP_LIMIT_NAME_BYTES || + !regExpTest(/^[A-Za-z][A-Za-z0-9._-]*$/, key) + ) { + throw new TypeError("HTTP limit name is invalid or exceeds the safety ceiling"); + } + const value = limits[key]; + if (!numberIsSafeInteger(value) || value <= 0) { + throw new TypeError(`HTTP limit ${key} must be a positive safe integer`); + } + if (binding) { + let admitted: Readonly<{ + name: string; + default: number; + minimum: number; + }> | undefined; + const values = binding.httpClientLimits.values; + for (let index = 0; index < values.length; index++) { + if (values[index]!.name === key) { + admitted = values[index]!; + break; + } + } + if (!admitted || value < admitted.minimum || value > admitted.default) { + throw new TypeError( + `HTTP limit ${key} is outside its admitted minimum/default range`, + ); + } + } + output[key] = value; + } + return objectFreeze(output); +} + +function normalizeTls(tls: TlsOptions | undefined): TlsOptions | undefined { + if (tls === undefined) return undefined; + if (tls === null) return objectFreeze({}); + const dictionary = Object(tls) as TlsOptions; + const snapshot = { + alpn: dictionary.alpn, + ca: dictionary.ca, + clientCertificate: dictionary.clientCertificate, + credential: dictionary.credential, + maxVersion: dictionary.maxVersion, + minVersion: dictionary.minVersion, + revocation: dictionary.revocation, + serverName: dictionary.serverName, + verification: dictionary.verification, + }; + const minVersion = snapshot.minVersion; + const maxVersion = snapshot.maxVersion; + if (minVersion !== undefined && minVersion !== "1.2" && minVersion !== "1.3") { + throw new TypeError("Invalid TLS minimum version"); + } + if (maxVersion !== undefined && maxVersion !== "1.2" && maxVersion !== "1.3") { + throw new TypeError("Invalid TLS maximum version"); + } + if (minVersion === "1.3" && maxVersion === "1.2") { + throw new TypeError("TLS minimum version exceeds maximum version"); + } + let ca: Uint8Array | undefined; + if (snapshot.ca !== undefined) { + // Capture the intrinsic typed-array slots once; own accessors and custom + // iterators cannot under-report the trust material's real size. + try { + ca = snapshotUint8Array(snapshot.ca, HTTP_TLS_CA_BYTES, "TLS custom CA"); + } catch (error) { + if (error instanceof NetworkError && error.code === "resource_limit") { + throw new TypeError(`TLS custom CA exceeds ${HTTP_TLS_CA_BYTES} bytes`); + } + throw error; + } + } + if ( + snapshot.clientCertificate !== undefined && + snapshot.clientCertificate !== "none" && + snapshot.clientCertificate !== "optional" && + snapshot.clientCertificate !== "required" + ) { + throw new TypeError("Invalid TLS client certificate policy"); + } + if ( + snapshot.verification !== undefined && + snapshot.verification !== "full" && + snapshot.verification !== "development-insecure" + ) { + throw new TypeError("Invalid TLS verification policy"); + } + if ( + snapshot.revocation !== undefined && + snapshot.revocation !== "host-default" && + snapshot.revocation !== "required" + ) { + throw new TypeError("Invalid TLS revocation policy"); + } + let alpn: string[] | undefined; + if (snapshot.alpn !== undefined) { + alpn = []; + let totalBytes = 0; + for (const token of snapshot.alpn) { + if (alpn.length >= HTTP_TLS_ALPN_COUNT) { + throw new TypeError(`TLS ALPN cannot exceed ${HTTP_TLS_ALPN_COUNT} tokens`); + } + const value = byteString(token, "TLS ALPN token"); + if (value.length === 0 || value.length > 255 || regExpTest(/[\0]/, value)) { + throw new TypeError("Invalid TLS ALPN token"); + } + totalBytes += value.length; + if (totalBytes > HTTP_TLS_ALPN_BYTES) { + throw new TypeError(`TLS ALPN exceeds ${HTTP_TLS_ALPN_BYTES} bytes`); + } + arrayPush(alpn, value); + } + } + if (alpn && new Set(alpn).size !== alpn.length) { + throw new TypeError("TLS ALPN tokens must be unique"); + } + const serverName = snapshot.serverName === undefined + ? undefined + : byteString(snapshot.serverName, "TLS serverName"); + if ( + serverName !== undefined && + (serverName.length === 0 || serverName.length > HTTP_TLS_SERVER_NAME_BYTES) + ) throw new TypeError("TLS serverName is empty or exceeds the safety ceiling"); + const credential = snapshot.credential === undefined + ? undefined + : byteString(snapshot.credential, "TLS credential id"); + if ( + credential !== undefined && + (credential.length === 0 || + credential.length > HTTP_TLS_CREDENTIAL_ID_BYTES || + !regExpTest(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/, credential)) + ) throw new TypeError("TLS credential id is empty or exceeds the safety ceiling"); + return objectFreeze({ + ...(serverName === undefined ? {} : { serverName }), + ...(minVersion === undefined ? {} : { minVersion }), + ...(maxVersion === undefined ? {} : { maxVersion }), + ...(alpn === undefined ? {} : { alpn: objectFreeze(alpn) }), + ...(ca === undefined ? {} : { ca }), + ...(credential === undefined ? {} : { credential }), + ...(snapshot.clientCertificate === undefined + ? {} + : { clientCertificate: snapshot.clientCertificate }), + ...(snapshot.verification === undefined + ? {} + : { verification: snapshot.verification }), + ...(snapshot.revocation === undefined ? {} : { revocation: snapshot.revocation }), + }); +} + +function requestExtensions( + init: RequestInitSnapshot, + inherited?: RequestState, + binding: HttpClientPrivateBinding | undefined = getHttpClientBinding(), +): RequestExtensions { + return { + timeouts: init.timeouts === undefined + ? inherited?.timeouts + : normalizeTimeouts(init.timeouts), + maxRedirects: init.maxRedirects === undefined + ? inherited?.maxRedirects ?? 5 + : normalizeMaxRedirects(init.maxRedirects), + tls: init.tls === undefined ? inherited?.tls : normalizeTls(init.tls), + limits: init.limits === undefined + ? inherited?.limits + : normalizeLimits(init.limits, binding), + ref: init.ref === undefined ? inherited?.ref ?? true : webIdlBoolean(init.ref), + }; +} + +type ParsedAbsoluteUrl = CanonicalHttpUrl; + +function parseAbsoluteUrl(input: string | URL): CanonicalHttpUrl { + const source = input instanceof URL ? input.href : new URL(webIdlString(input)).href; + if (source.length > 8192) { + throw runtimeError( + "resource_limit", + "http.Request", + "HTTP URL exceeds the 8192-byte SDK safety ceiling", + ); + } + return canonicalizeHttpUrl(source); +} + +function newRequestFromState(state: RequestState): Request { + const request = objectCreate(Request.prototype) as Request; + requestStates.set(request, state); + return request; +} + +export class Request { + constructor( + input: string | URL | Request, + init: RequestInit | null | undefined = undefined, + ) { + const binding = getHttpClientBinding(); + const sdkLimits = effectiveHttpSdkLimits(binding); + const snapshot = snapshotRequestInit(init); + const inherited = input instanceof Request ? requestState(input) : undefined; + const method = normalizeMethod(snapshot.method ?? inherited?.method ?? "GET"); + const parsedInput = inherited === undefined + ? parseAbsoluteUrl(input as string | URL) + : undefined; + const url = inherited?.url ?? `${parsedInput!.href}${parsedInput!.fragment}`; + const redirect = normalizeRedirect(snapshot.redirect ?? inherited?.redirect ?? "follow"); + const sourceSignal = snapshot.signal ?? inherited?.signal; + if (sourceSignal !== undefined && !(sourceSignal instanceof AbortSignal)) { + throw new TypeError("HTTP Request signal must be a PocketJS AbortSignal"); + } + const headers = snapshot.headers === undefined + ? createHeaders(inherited?.headers, "request", sdkLimits.headerBytes) + : createHeaders(snapshot.headers, "request", sdkLimits.headerBytes); + const extensions = requestExtensions(snapshot, inherited, binding); + + // Fetch treats a null RequestInit body like no override; it does not erase + // an input Request's body. + const hasExplicitBody = snapshot.body !== undefined && snapshot.body !== null; + const wouldHaveBody = hasExplicitBody || inherited?.body !== null && + inherited?.body !== undefined; + if ((method === "GET" || method === "HEAD") && wouldHaveBody) { + throw new TypeError(`${method} Request cannot have a body`); + } + let body: BodyController | null = null; + let contentType: string | undefined; + if (hasExplicitBody) { + const extracted = extractBody(snapshot.body!, sdkLimits.body); + body = extracted.controller; + contentType = extracted.contentType; + } else if (!hasExplicitBody && inherited?.body) { + body = inherited.body.transfer(); + } + if (contentType !== undefined && !hasNormalizedHeader(headers, "content-type")) { + appendHeader(headersState(headers), "content-type", contentType); + } + const signalDependency = createDependentAbortSignal(sourceSignal); + requestStates.set(this, { + method, + url, + headers, + body, + signal: signalDependency.signal, + detachSignal: signalDependency.detach, + redirect, + ...extensions, + }); + } + + get method(): string { + return requestState(this).method; + } + + get url(): string { + return requestState(this).url; + } + + get headers(): Headers { + return requestState(this).headers; + } + + get body(): HttpBodyStream | null { + return requestState(this).body?.stream ?? null; + } + + get bodyUsed(): boolean { + return requestState(this).body?.bodyUsed ?? false; + } + + get signal(): AbortSignal { + return requestState(this).signal; + } + + get redirect(): RequestRedirect { + return requestState(this).redirect; + } + + clone(): Request { + const state = requestState(this); + const signalDependency = createDependentAbortSignal(state.signal); + try { + const cloneBody = state.body?.tee() ?? null; + return newRequestFromState({ + ...state, + headers: cloneHeaders(state.headers), + body: cloneBody, + signal: signalDependency.signal, + detachSignal: signalDependency.detach, + }); + } catch (error) { + signalDependency.detach(); + throw error; + } + } + + arrayBuffer(): Promise { + return consumeArrayBuffer(requestState(this).body, "http.Request.arrayBuffer"); + } + + text(): Promise { + return consumeText(requestState(this).body, "http.Request.text"); + } + + json(): Promise { + return consumeJson(requestState(this).body, "http.Request.json"); + } +} + +function toUnsignedShort(value: unknown): number { + const number = webIdlNumber(value); + if (!numberIsFinite(number) || number === 0) return 0; + const integer = mathTrunc(number); + return ((integer % 0x1_0000) + 0x1_0000) % 0x1_0000; +} + +function normalizeStatus(value: unknown): number { + const status = value === undefined ? 200 : toUnsignedShort(value); + if (status < 200 || status > 599) { + throw new RangeError("HTTP Response status must be from 200 through 599"); + } + return status; +} + +function normalizeStatusText(value: unknown): string { + if (value === undefined) return ""; + const text = byteString(value, "HTTP statusText"); + if (regExpTest(/[\0\r\n]/, text)) throw new TypeError("Invalid HTTP statusText"); + for (let index = 0; index < text.length; index++) { + const code = stringCharCodeAt(text, index); + if ((code < 0x20 && code !== 0x09) || code === 0x7f) { + throw new TypeError("Invalid HTTP statusText"); + } + } + return text; +} + +function newResponseFromState(state: ResponseState): Response { + const response = objectCreate(Response.prototype) as Response; + responseStates.set(response, state); + return response; +} + +export class Response { + constructor(body: BodyInit = null, init: ResponseInit | null = {}) { + const sdkLimits = effectiveHttpSdkLimits(); + const snapshot = snapshotResponseInit(init); + const status = normalizeStatus(snapshot.status); + const statusText = normalizeStatusText(snapshot.statusText); + const headers = createHeaders(snapshot.headers, "response", sdkLimits.headerBytes); + let controller: BodyController | null = null; + if (body !== null) { + if (status === 204 || status === 205 || status === 304) { + throw new TypeError(`HTTP status ${status} cannot have a body`); + } + const extracted = extractBody(body, sdkLimits.body); + controller = extracted.controller; + if (extracted.contentType !== undefined && + !hasNormalizedHeader(headers, "content-type")) { + appendHeader(headersState(headers), "content-type", extracted.contentType); + } + } + responseStates.set(this, { + status, + statusText, + headers, + body: controller, + url: "", + redirected: false, + }); + } + + get status(): number { + return responseState(this).status; + } + + get statusText(): string { + return responseState(this).statusText; + } + + get ok(): boolean { + const status = responseState(this).status; + return status >= 200 && status <= 299; + } + + get headers(): Headers { + return responseState(this).headers; + } + + get body(): HttpBodyStream | null { + return responseState(this).body?.stream ?? null; + } + + get bodyUsed(): boolean { + return responseState(this).body?.bodyUsed ?? false; + } + + get url(): string { + return responseState(this).url; + } + + get redirected(): boolean { + return responseState(this).redirected; + } + + clone(): Response { + const state = responseState(this); + const cloneBody = state.body?.tee() ?? null; + return newResponseFromState({ + ...state, + headers: cloneHeaders(state.headers), + body: cloneBody, + }); + } + + arrayBuffer(): Promise { + return consumeArrayBuffer(responseState(this).body, "http.Response.arrayBuffer"); + } + + text(): Promise { + return consumeText(responseState(this).body, "http.Response.text"); + } + + json(): Promise { + return consumeJson(responseState(this).body, "http.Response.json"); + } + + static json(data: unknown, init: ResponseInit | null = {}): Response { + const serialized = jsonStringify(data); + if (serialized === undefined) { + throw new TypeError("Response.json data is not JSON serializable"); + } + const snapshot = snapshotResponseInit(init); + const headers = new Headers(snapshot.headers); + if (!hasNormalizedHeader(headers, "content-type")) { + appendHeader(headersState(headers), "content-type", "application/json"); + } + return new Response(serialized, { + headers, + status: snapshot.status as number | undefined, + statusText: snapshot.statusText as string | undefined, + }); + } + + static redirect(url: string | URL, status: number = 302): Response { + const convertedStatus = toUnsignedShort(status); + if (!setHas(REDIRECT_STATUSES, convertedStatus)) { + throw new RangeError("Invalid HTTP redirect status"); + } + const parsed = parseAbsoluteUrl(url); + const sdkLimits = effectiveHttpSdkLimits(); + return newResponseFromState({ + status: convertedStatus, + statusText: "", + headers: createBindingHeaders([ + { name: "location", value: `${parsed.href}${parsed.fragment}` }, + ], sdkLimits.headerBytes), + body: null, + url: "", + redirected: false, + }); + } +} + +async function consumeBytes( + body: BodyController | null, + operation: string, +): Promise { + return body ? body.aggregate(operation) : new Uint8Array(); +} + +async function consumeArrayBuffer( + body: BodyController | null, + operation: string, +): Promise { + return ownedUint8ArrayBuffer(await consumeBytes(body, operation)); +} + +async function consumeText( + body: BodyController | null, + operation: string, +): Promise { + const decoded = decodeUtf8( + await consumeBytes(body, operation), + HTTP_BODY_HELPER_BYTES, + ); + if (decoded === null) { + throw runtimeError( + "resource_limit", + operation, + `Buffered HTTP body exceeds ${HTTP_BODY_HELPER_BYTES} bytes`, + ); + } + return decoded; +} + +async function consumeJson( + body: BodyController | null, + operation: string, +): Promise { + return JSON.parse(await consumeText(body, operation)); +} + +export interface HttpServerStopOptions { + readonly graceful?: boolean; + readonly timeout?: number; +} + +export interface HttpServer { + readonly address: import("./index.ts").NetworkAddress; + stop(options?: HttpServerStopOptions): Promise; + ref(): this; + unref(): this; +} + +export type HttpFetchResult = Response | WebSocketUpgrade; + +export interface HttpServeOptions { + readonly hostname?: string; + readonly port: number; + readonly tls?: TlsOptions; + readonly limits?: NetworkLimitOverrides; + readonly ref?: boolean; + readonly fetch: ( + request: Request, + server: HttpServer, + ) => HttpFetchResult | Promise; + readonly error?: (error: unknown) => Response | Promise; +} + +let nextOperationId = 1; + +function allocateOperationId(): number { + if (nextOperationId > 0xffff_ffff) { + throw runtimeError( + "resource_limit", + "http.fetch", + "HTTP operation id space is exhausted for this runtime", + ); + } + const result = nextOperationId; + nextOperationId++; + return result; +} + +function validateFetchUrl(url: string): ParsedAbsoluteUrl { + let parsed: ParsedAbsoluteUrl; + try { + parsed = parseAbsoluteUrl(url); + } catch { + throw runtimeError( + "invalid_state", + "http.fetch", + "HTTP fetch URL is malformed", + ); + } + if (parsed.scheme !== "http" && parsed.scheme !== "https") { + throw runtimeError( + "unsupported", + "http.fetch", + "HTTP fetch supports only absolute http: and https: URLs", + ); + } + if (`${parsed.href}${parsed.fragment}` !== url) { + throw runtimeError( + "invalid_state", + "http.fetch", + "HTTP fetch URL must be canonical", + ); + } + return parsed; +} + +function abortError(): NetworkError { + return runtimeError("aborted", "http.fetch", "HTTP request was aborted"); +} + +function unsupportedOption(message: string): never { + throw runtimeError("unsupported", "http.fetch", message); +} + +function requireFeature( + binding: HttpClientPrivateBinding, + feature: string, + option: string, +): void { + if (!arrayIncludes(binding.featureSet, feature)) { + unsupportedOption(`${option} requires ${feature}`); + } +} + +function normalizeHostnameForComparison(hostname: string): string { + return stringReplace(stringToLowerCase(hostname), /\.$/, ""); +} + +function preflightBindingFeatures( + binding: HttpClientPrivateBinding, + state: RequestState, + parsed: ParsedAbsoluteUrl, +): void { + requireFeature(binding, "network.http.client", "HTTP fetch"); + if (parsed.scheme === "http") { + if (state.tls !== undefined) unsupportedOption("TLS options are invalid for plaintext HTTP"); + return; + } + + requireFeature(binding, "network.http.client.tls", "HTTPS fetch"); + const tls = state.tls; + if (!tls) return; + if (tls.ca !== undefined) { + requireFeature( + binding, + "network.http.client.tls.custom-ca", + "TLS custom CA", + ); + } + if (tls.credential !== undefined) { + requireFeature( + binding, + "network.http.client.tls.client-auth", + "TLS client credential", + ); + } + if (tls.clientCertificate !== undefined) { + unsupportedOption("TLS clientCertificate is a server-only option"); + } + if (tls.alpn !== undefined) { + requireFeature(binding, "network.http.client.tls.alpn", "TLS ALPN"); + if (!binding.alpnProtocols) { + unsupportedOption("TLS ALPN token is not supported by the admitted provider"); + } + for (const token of tls.alpn) { + if (!arrayIncludes(binding.alpnProtocols, token)) { + unsupportedOption("TLS ALPN token is not supported by the admitted provider"); + } + } + } + if (tls.minVersion === "1.3") { + requireFeature(binding, "network.http.client.tls.v1-3", "TLS 1.3 minimum"); + } + if (tls.revocation === "required") { + requireFeature( + binding, + "network.http.client.tls.revocation", + "required TLS revocation", + ); + } + if (tls.verification === "development-insecure") { + unsupportedOption( + "development-insecure TLS verification requires Host development policy", + ); + } + if ( + tls.serverName !== undefined && + normalizeHostnameForComparison(tls.serverName) !== + normalizeHostnameForComparison(parsed.hostname) + ) { + unsupportedOption("TLS serverName must equal the authorized URL hostname"); + } +} + +const NETWORK_ERROR_CATEGORIES = new Set([ + "runtime", + "resolver", + "transport", + "tls", + "protocol", +]); +const HTTP_ERROR_CODES_BY_CATEGORY = objectFreeze({ + runtime: new Set([ + "aborted", + "timed_out", + "closed", + "invalid_state", + "busy", + "resource_limit", + "unsupported", + "permission_denied", + "system_error", + ]), + resolver: new Set(["dns_not_found", "dns_temporary_failure", "dns_refused"]), + transport: new Set([ + "connection_refused", + "connection_reset", + "network_unreachable", + "address_in_use", + "broken_pipe", + ]), + tls: new Set([ + "tls_certificate_invalid", + "tls_hostname_mismatch", + "tls_handshake_failed", + "tls_version_unsupported", + "tls_alert", + ]), + protocol: new Set(["http_protocol_error", "message_too_large"]), +}); + +function isHttpCategoryCode(category: unknown, code: unknown): category is + import("./index.ts").NetworkErrorCategory { + return typeof category === "string" && + typeof code === "string" && + setHas(NETWORK_ERROR_CATEGORIES, category) && + setHas( + HTTP_ERROR_CODES_BY_CATEGORY[ + category as keyof typeof HTTP_ERROR_CODES_BY_CATEGORY + ], + code, + ); +} + +function bindingProtocolError(message: string): NetworkError { + return new NetworkError(message, { + category: "protocol", + code: "http_protocol_error", + operation: "http.fetch", + protocol: "http", + }); +} + +function safeCauseCode(value: unknown): value is string { + return typeof value === "string" && regExpTest(/^[A-Za-z0-9_.:-]{1,64}$/, value); +} + +function normalizeBindingAddress(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string" || value.length === 0 || value.length > 253) { + throw new TypeError("Invalid binding error address"); + } + const authority = stringIncludes(value, ":") && !stringStartsWith(value, "[") + ? `[${value}]` + : value; + const parsed = canonicalizeHttpUrl(`http://${authority}/`); + return parsed.hostname; +} + +function safeReasonCode(value: unknown): value is number { + return numberIsInteger(value) && (value as number) >= 0 && + (value as number) <= 0xffff_ffff; +} + +function publicBindingMessage(code: string): string { + return `HTTP request failed with ${code}`; +} + +function normalizeBindingError(error: unknown, operationId?: number): NetworkError { + if (error instanceof NetworkError) { + const snapshot = { + address: error.address, + category: error.category, + causeCode: error.causeCode, + code: error.code, + operation: error.operation, + port: error.port, + protocol: error.protocol, + reasonCode: error.reasonCode, + temporary: error.temporary, + }; + let address: string | undefined; + try { + address = normalizeBindingAddress(snapshot.address); + } catch { + return bindingProtocolError("Invalid NetworkError from private binding"); + } + if (!(isHttpCategoryCode(snapshot.category, snapshot.code) && + typeof snapshot.operation === "string" && + snapshot.operation.length <= 64 && + typeof snapshot.temporary === "boolean" && + (snapshot.protocol === undefined || snapshot.protocol === "http") && + (snapshot.port === undefined || + (numberIsInteger(snapshot.port) && snapshot.port >= 1 && snapshot.port <= 65_535)) && + !(snapshot.port !== undefined && address === undefined) && + (snapshot.causeCode === undefined || safeCauseCode(snapshot.causeCode)) && + (snapshot.reasonCode === undefined || safeReasonCode(snapshot.reasonCode)))) { + return bindingProtocolError("Invalid NetworkError from private binding"); + } + return new NetworkError(publicBindingMessage(snapshot.code as string), { + category: snapshot.category, + code: snapshot.code, + operation: "http.fetch", + temporary: snapshot.temporary, + address, + port: snapshot.port, + protocol: "http", + causeCode: snapshot.causeCode, + reasonCode: snapshot.reasonCode, + }); + } + if (typeof error === "object" && error !== null) { + const candidate = error as Partial; + const snapshot = { + eventCode: candidate.eventCode, + operationId: candidate.operationId, + category: candidate.category, + code: candidate.code, + message: candidate.message, + temporary: candidate.temporary, + causeCode: candidate.causeCode, + reasonCode: candidate.reasonCode, + }; + if (snapshot.eventCode !== NetworkV1EventCode.HttpRequestError) { + return new NetworkError("PocketJS HTTP binding failed", { + category: "runtime", + code: "system_error", + operation: "http.fetch", + protocol: "http", + }); + } + if ( + snapshot.operationId !== operationId || + !isHttpCategoryCode(snapshot.category, snapshot.code) || + typeof snapshot.message !== "string" || + (snapshot.temporary !== undefined && typeof snapshot.temporary !== "boolean") || + (snapshot.causeCode !== undefined && !safeCauseCode(snapshot.causeCode)) || + (snapshot.reasonCode !== undefined && !safeReasonCode(snapshot.reasonCode)) + ) { + return bindingProtocolError("Invalid HTTP error event from private binding"); + } + return new NetworkError(publicBindingMessage(snapshot.code as string), { + category: snapshot.category, + code: snapshot.code as import("./index.ts").NetworkErrorCode, + operation: "http.fetch", + temporary: snapshot.temporary, + protocol: "http", + causeCode: snapshot.causeCode, + reasonCode: snapshot.reasonCode, + }); + } + return new NetworkError("PocketJS HTTP binding failed", { + category: "runtime", + code: "system_error", + operation: "http.fetch", + protocol: "http", + causeCode: error instanceof Error ? "binding_exception" : undefined, + }); +} + +function snapshotResponseEvent(value: unknown): HttpResponseHeadersEvent { + if (typeof value !== "object" || value === null) { + throw bindingProtocolError("Invalid HTTP response event from private binding"); + } + const event = value as Partial; + // Body is read last: if any earlier metadata getter fails, no response body + // has been acquired and therefore none can be stranded by cleanup. + const eventCode = event.eventCode; + const operationId = event.operationId; + const status = event.status; + const statusText = event.statusText; + const headersValue = event.headers; + const url = event.url; + const redirected = event.redirected; + const bufferedBodyBytes = event.bufferedBodyBytes; + if (!arrayIsArray(headersValue)) { + throw bindingProtocolError("Invalid HTTP response headers from private binding"); + } + const headerArray = headersValue as readonly HttpBindingHeader[]; + const headerCount = headerArray.length; + if (!numberIsSafeInteger(headerCount) || headerCount > HTTP_HEADER_COUNT) { + throw bindingProtocolError("Invalid HTTP response headers from private binding"); + } + const headers: HttpBindingHeader[] = []; + for (let index = 0; index < headerCount; index++) { + const entry = headerArray[index]; + if (typeof entry !== "object" || entry === null) { + throw bindingProtocolError("Invalid HTTP response header from private binding"); + } + const name = entry.name; + const headerValue = entry.value; + if (typeof name !== "string" || typeof headerValue !== "string") { + throw bindingProtocolError("HTTP binding headers must contain strings"); + } + headers[headers.length] = objectFreeze({ name, value: headerValue }); + } + const body = event.body; + return objectFreeze({ + eventCode: eventCode as NetworkV1EventCode.HttpResponseHeaders, + operationId: operationId as number, + status: status as number, + statusText: statusText as string, + headers: objectFreeze(headers), + url: url as string, + redirected: redirected as boolean, + ...(body === undefined ? {} : { body }), + ...(bufferedBodyBytes === undefined ? {} : { bufferedBodyBytes }), + }); +} + +function validateResponseEvent( + event: HttpResponseHeadersEvent, + operationId: number, + requestMethod: string, + sdkLimits: Readonly, +): void { + if ( + event.eventCode !== NetworkV1EventCode.HttpResponseHeaders || + event.operationId !== operationId || + !numberIsInteger(event.status) || + event.status < 200 || + event.status > 599 || + typeof event.statusText !== "string" || + !arrayIsArray(event.headers) || + typeof event.url !== "string" || + typeof event.redirected !== "boolean" + ) { + throw new NetworkError("Invalid HTTP response event from private binding", { + category: "protocol", + code: "http_protocol_error", + operation: "http.fetch", + protocol: "http", + }); + } + if ( + event.body !== undefined && + event.body !== null && + (requestMethod === "HEAD" || + event.status === 204 || + event.status === 205 || + event.status === 304) + ) { + throw bindingProtocolError( + "HTTP response event carries a body forbidden by its method or status", + ); + } + try { + normalizeStatusText(event.statusText); + const responseUrl = validateFetchUrl(event.url); + if (responseUrl.hasFragment) throw new TypeError("Response URL contains a fragment"); + } catch { + throw bindingProtocolError("Invalid HTTP response metadata from private binding"); + } + if ( + event.bufferedBodyBytes !== undefined && + (!numberIsSafeInteger(event.bufferedBodyBytes) || event.bufferedBodyBytes <= 0 || + event.bufferedBodyBytes > sdkLimits.body.bufferedBytes) + ) { + throw new NetworkError("Invalid HTTP body limit from private binding", { + category: "protocol", + code: "http_protocol_error", + operation: "http.fetch", + protocol: "http", + }); + } +} + +function responseFromBinding( + event: HttpResponseHeadersEvent, + sdkLimits: Readonly, +): Response { + let headers: Headers; + let body: BodyController | null = null; + try { + headers = createBindingHeaders(event.headers, sdkLimits.headerBytes); + if (event.body !== undefined && event.body !== null) { + const bufferedBytes = mathMin( + event.bufferedBodyBytes ?? sdkLimits.body.bufferedBytes, + sdkLimits.body.bufferedBytes, + ); + body = bodyFromBinding( + event.body, + objectFreeze({ + ...sdkLimits.body, + bufferedBytes, + teeBranchBytes: mathMin(sdkLimits.body.teeBranchBytes, bufferedBytes), + }), + ); + } + } catch { + throw new NetworkError("Invalid HTTP metadata from private binding", { + category: "protocol", + code: "http_protocol_error", + operation: "http.fetch", + protocol: "http", + }); + } + return newResponseFromState({ + status: event.status, + statusText: event.statusText, + headers, + body, + url: event.url, + redirected: event.redirected, + }); +} + +function makeRequestCommand(state: RequestState, operationId: number): HttpRequestStartCommand { + const wireUrl = parseAbsoluteUrl(state.url).href; + return objectFreeze({ + opcode: NetworkV1CommandOpcode.HttpRequestStart, + operationId, + url: wireUrl, + method: state.method, + headers: bindingHeaders(state.headers), + hasBody: state.body !== null, + redirect: state.redirect, + ...(state.timeouts === undefined ? {} : { timeouts: state.timeouts }), + maxRedirects: state.maxRedirects, + ...(state.tls === undefined ? {} : { tls: state.tls }), + ...(state.limits === undefined ? {} : { limits: state.limits }), + ref: state.ref, + }); +} + +function cancelCommand(operationId: number): OperationCancelCommand { + return objectFreeze({ + opcode: NetworkV1CommandOpcode.OperationCancel, + operationId, + }); +} + +function snapshotBindingOperation(value: unknown): HttpClientBindingOperation { + if (typeof value !== "object" || value === null) { + throw runtimeError( + "system_error", + "http.fetch", + "PocketJS HTTP binding returned an invalid operation", + ); + } + const candidate = value as Partial; + const cancel = candidate.cancel; + const response = candidate.response; + if (typeof cancel !== "function" || response === undefined) { + throw runtimeError( + "system_error", + "http.fetch", + "PocketJS HTTP binding returned an invalid operation", + ); + } + let normalizedResponse: Promise; + try { + normalizedResponse = reflectApply(promiseThen, response, [ + (event: HttpResponseHeadersEvent) => event, + (error: unknown) => { throw error; }, + ]) as Promise; + } catch { + throw runtimeError( + "system_error", + "http.fetch", + "PocketJS HTTP binding returned a non-Promise response", + ); + } + return objectFreeze({ + response: normalizedResponse, + cancel: (command: OperationCancelCommand) => reflectApply(cancel, value, [command]), + }); +} + +function ignoreRejectedPromise(value: unknown): void { + try { + const promise = reflectApply(promiseResolve, PromiseIntrinsic, [value]); + void reflectApply(promiseThen, promise, [undefined, () => undefined]); + } catch { + // Cleanup failures are diagnostic-only. + } +} + +function bestEffortCancelProducer( + producer: ReturnType | null, + reason: unknown, +): void { + if (!producer) return; + try { + const cancel = producer.cancel; + if (typeof cancel !== "function") return; + ignoreRejectedPromise(reflectApply(cancel, producer, [reason])); + } catch { + // Producer cleanup failures are diagnostic-only. + } +} + +function bestEffortCancelBindingBody( + event: HttpResponseHeadersEvent | undefined, + reason: unknown, +): void { + try { + const body = event?.body; + if (!body) return; + const cancel = (body as Partial).cancel; + if (typeof cancel !== "function") return; + ignoreRejectedPromise(reflectApply(cancel, body, [reason])); + } catch { + // Binding cleanup failures are diagnostic-only. + } +} + +export async function fetch( + input: string | URL | Request, + init?: RequestInit, +): Promise { + const binding = getHttpClientBinding(); + if (!binding) return unsupportedNetworkPromise("http.fetch", "http"); + + const sdkLimits = effectiveHttpSdkLimits(binding); + + const request = new Request(input, init); + const state = requestState(request); + try { + const parsedUrl = validateFetchUrl(state.url); + preflightBindingFeatures(binding, state, parsedUrl); + if (state.limits !== undefined) normalizeLimits(state.limits, binding); + if (abortSignalAborted(state.signal)) throw abortError(); + } catch (error) { + state.detachSignal(); + if (state.body) void state.body.cancel(error).catch(() => {}); + throw error; + } + + let operationId: number; + let producer: ReturnType | null; + try { + operationId = allocateOperationId(); + producer = state.body?.createProducer() ?? null; + } catch (error) { + state.detachSignal(); + if (state.body) void state.body.cancel(error).catch(() => {}); + throw error; + } + let operation: HttpClientBindingOperation; + try { + operation = snapshotBindingOperation(binding.start( + makeRequestCommand(state, operationId), + producer, + state.signal, + )); + } catch (error) { + const normalized = normalizeBindingError(error, operationId); + state.detachSignal(); + bestEffortCancelProducer(producer, normalized); + throw normalized; + } + + let rejectAbort!: (error: NetworkError) => void; + const aborted = new Promise((_resolve, reject) => { + rejectAbort = reject; + }); + let responseBody: BodyController | null = null; + let cleaned = false; + let cancelDispatched = false; + let detachAbortAlgorithm = () => {}; + const dispatchCancel = () => { + if (cancelDispatched) return; + cancelDispatched = true; + try { + operation.cancel(cancelCommand(operationId)); + } catch { + // Cancellation diagnostics cannot replace the stable public result. + } + }; + const cleanup = () => { + if (cleaned) return; + cleaned = true; + detachAbortAlgorithm(); + state.detachSignal(); + }; + const onAbort = () => { + const error = abortError(); + dispatchCancel(); + if (responseBody) void responseBody.cancelGraph(error).catch(() => {}); + rejectAbort(error); + }; + detachAbortAlgorithm = addAbortAlgorithm(state.signal, onAbort); + + let receivedEvent: HttpResponseHeadersEvent | undefined; + try { + const event = snapshotResponseEvent(await Promise.race([operation.response, aborted])); + receivedEvent = event; + if (abortSignalAborted(state.signal)) throw abortError(); + validateResponseEvent(event, operationId, state.method, sdkLimits); + const response = responseFromBinding(event, sdkLimits); + responseBody = responseState(response).body; + if (abortSignalAborted(state.signal)) throw abortError(); + // A response-headers event claims the request-upload direction terminal. + // The binding must already have stopped pull credit; this additionally + // retires a Guest producer if a test seam or faulty Host left it live. + bestEffortCancelProducer(producer, new NetworkError( + "HTTP request upload ended when response headers arrived", + { + category: "runtime", + code: "closed", + operation: "http.fetch.upload", + protocol: "http", + }, + )); + if (responseBody) responseBody.onTerminal(cleanup); + else cleanup(); + return response; + } catch (error) { + cleanup(); + const normalized = normalizeBindingError(error, operationId); + dispatchCancel(); + bestEffortCancelProducer(producer, normalized); + bestEffortCancelBindingBody(receivedEvent, normalized); + throw normalized; + } +} + +export function serve(_options: HttpServeOptions): Promise { + return unsupportedNetworkPromise("http.serve", "http"); +} diff --git a/framework/src/net/index.ts b/framework/src/net/index.ts new file mode 100644 index 00000000..debced38 --- /dev/null +++ b/framework/src/net/index.ts @@ -0,0 +1,262 @@ +import { canonicalizeHttpUrl } from "./http-url.ts"; + +const freezeNetworkValue = Object.freeze; +const createNetworkValue = Object.create; +const defineNetworkProperty = Object.defineProperty; +const networkString = String; + +/** Shared support values and types for the PocketJS network package namespace. + * + * Importing this module does not mount a transport or read a public global. + * Protocol capabilities live in the sibling protocol modules. + */ + +import { queryInstalledNetworkLimits } from "./network-limits.ts"; + +export type NetworkData = string | ArrayBuffer | ArrayBufferView; + +export type NetworkProtocol = "http" | "websocket" | "mqtt" | "tcp" | "udp"; +export type NetworkRole = "client" | "server"; + +export interface NetworkAddress { + readonly family: "ipv4" | "ipv6"; + readonly address: string; + readonly port: number; +} + +export interface TlsOptions { + readonly serverName?: string; + readonly minVersion?: "1.2" | "1.3"; + readonly maxVersion?: "1.2" | "1.3"; + readonly alpn?: readonly string[]; + readonly ca?: Uint8Array; + readonly credential?: string; + readonly clientCertificate?: "none" | "optional" | "required"; + readonly verification?: "full" | "development-insecure"; + readonly revocation?: "host-default" | "required"; +} + +/** Per-operation limits can only lower capacities admitted by the build plan. */ +export type NetworkLimitOverrides = Readonly>; + +export interface NetworkLimit { + readonly default: number; + readonly hard: number; + readonly minimum: number; +} + +/** A frozen snapshot of capacities admitted for a protocol and role. */ +export interface NetworkLimits { + readonly protocol?: NetworkProtocol; + readonly role?: NetworkRole; + readonly values: Readonly>>; + readonly features: Readonly>; +} + +export type NetworkErrorCategory = + | "runtime" + | "resolver" + | "transport" + | "tls" + | "protocol"; + +export type NetworkErrorCode = + | "aborted" + | "timed_out" + | "closed" + | "invalid_state" + | "busy" + | "resource_limit" + | "unsupported" + | "permission_denied" + | "dns_not_found" + | "dns_temporary_failure" + | "dns_refused" + | "connection_refused" + | "connection_reset" + | "network_unreachable" + | "address_in_use" + | "broken_pipe" + | "tls_certificate_invalid" + | "tls_hostname_mismatch" + | "tls_handshake_failed" + | "tls_version_unsupported" + | "tls_alert" + | "http_protocol_error" + | "websocket_protocol_error" + | "mqtt_unacceptable_protocol_version" + | "mqtt_identifier_rejected" + | "mqtt_server_unavailable" + | "mqtt_bad_credentials" + | "mqtt_not_authorized" + | "mqtt_protocol_error" + | "message_too_large" + | "system_error"; + +export interface NetworkErrorOptions { + readonly category: NetworkErrorCategory; + readonly code: NetworkErrorCode; + readonly operation: string; + readonly temporary?: boolean; + readonly address?: string; + readonly port?: number; + readonly protocol?: NetworkProtocol; + readonly causeCode?: string; + readonly reasonCode?: number; +} + +/** Stable public error type shared by every network protocol module. */ +export class NetworkError extends Error { + readonly category: NetworkErrorCategory; + readonly code: NetworkErrorCode; + readonly operation: string; + readonly temporary: boolean; + readonly address?: string; + readonly port?: number; + readonly protocol?: NetworkProtocol; + readonly causeCode?: string; + readonly reasonCode?: number; + + constructor(message: string, options: NetworkErrorOptions) { + super(message); + this.name = "NetworkError"; + this.category = options.category; + this.code = options.code; + this.operation = options.operation; + this.temporary = options.temporary ?? false; + this.address = options.address; + this.port = options.port; + this.protocol = options.protocol; + this.causeCode = options.causeCode; + this.reasonCode = options.reasonCode; + } +} + +/** + * A canonical absolute HTTP(S) URL value. Relative and non-HTTP URL schemes + * are outside the first public network profile. + */ +export class URL { + readonly href: string; + + constructor(input: string | URL) { + const source = input instanceof URL ? input.href : networkString(input); + const parsed = canonicalizeHttpUrl(source); + this.href = `${parsed.href}${parsed.fragment}`; + freezeNetworkValue(this); + } + + toString(): string { + return this.href; + } + + toJSON(): string { + return this.href; + } +} + +export { + AbortController, + AbortSignal, + type AbortEvent, + type AbortListener, +} from "./abort.ts"; + +/** + * Returns the build-admitted limits once a Network Guest Binding is present. + * The public fallback fails explicitly and never probes `globalThis.net`. + */ +export function getNetworkLimits( + protocol?: NetworkProtocol, + role?: NetworkRole, +): NetworkLimits { + if (protocol !== undefined && protocol !== "http" && protocol !== "websocket" && + protocol !== "mqtt" && protocol !== "tcp" && protocol !== "udp") { + throw new TypeError("PocketJS getNetworkLimits protocol is invalid"); + } + if (role !== undefined && role !== "client" && role !== "server") { + throw new TypeError("PocketJS getNetworkLimits role is invalid"); + } + const snapshot = queryInstalledNetworkLimits(protocol, role); + if (snapshot === undefined || snapshot.features.length === 0) { + throw new NetworkError("Network limits are unavailable for an unadmitted scope", { + category: "runtime", + code: "unsupported", + operation: "getNetworkLimits", + }); + } + + const snapshotValues = snapshot.values; + const snapshotFeatures = snapshot.features; + const valueCount = snapshotValues.length; + const featureCount = snapshotFeatures.length; + const values = createNetworkValue(null) as Record>; + for (let index = 0; index < valueCount; index++) { + const entry = snapshotValues[index]!; + const limit = createNetworkValue(null) as NetworkLimit; + defineNetworkProperty(limit, "default", { + configurable: false, + enumerable: true, + writable: false, + value: entry.default, + }); + defineNetworkProperty(limit, "hard", { + configurable: false, + enumerable: true, + writable: false, + value: entry.hard, + }); + defineNetworkProperty(limit, "minimum", { + configurable: false, + enumerable: true, + writable: false, + value: entry.minimum, + }); + defineNetworkProperty(values, entry.name, { + configurable: false, + enumerable: true, + writable: false, + value: freezeNetworkValue(limit), + }); + } + const features = createNetworkValue(null) as Record; + for (let index = 0; index < featureCount; index++) { + const feature = snapshotFeatures[index]!; + defineNetworkProperty(features, feature, { + configurable: false, + enumerable: true, + writable: false, + value: true, + }); + } + const result = createNetworkValue(null) as NetworkLimits; + if (protocol !== undefined) { + defineNetworkProperty(result, "protocol", { + configurable: false, + enumerable: true, + writable: false, + value: protocol, + }); + } + if (role !== undefined) { + defineNetworkProperty(result, "role", { + configurable: false, + enumerable: true, + writable: false, + value: role, + }); + } + defineNetworkProperty(result, "values", { + configurable: false, + enumerable: true, + writable: false, + value: freezeNetworkValue(values), + }); + defineNetworkProperty(result, "features", { + configurable: false, + enumerable: true, + writable: false, + value: freezeNetworkValue(features), + }); + return freezeNetworkValue(result); +} diff --git a/framework/src/net/internal.ts b/framework/src/net/internal.ts new file mode 100644 index 00000000..24789fe7 --- /dev/null +++ b/framework/src/net/internal.ts @@ -0,0 +1,23 @@ +import { + NetworkError, + type NetworkProtocol, +} from "./index.ts"; + +export function unsupportedNetworkOperation( + operation: string, + protocol: NetworkProtocol, +): NetworkError { + return new NetworkError(`${operation} is not admitted by this PocketJS build`, { + category: "runtime", + code: "unsupported", + operation, + protocol, + }); +} + +export function unsupportedNetworkPromise( + operation: string, + protocol: NetworkProtocol, +): Promise { + return Promise.reject(unsupportedNetworkOperation(operation, protocol)); +} diff --git a/framework/src/net/mqtt.ts b/framework/src/net/mqtt.ts new file mode 100644 index 00000000..a3e30c47 --- /dev/null +++ b/framework/src/net/mqtt.ts @@ -0,0 +1,144 @@ +import type { + NetworkData, + NetworkLimitOverrides, + TlsOptions, + URL, +} from "./index.ts"; +import { unsupportedNetworkPromise } from "./internal.ts"; + +export { + AbortController, + AbortSignal, + NetworkError, + URL, +} from "./index.ts"; +export type { + NetworkAddress, + NetworkData, + NetworkErrorCategory, + NetworkErrorCode, + NetworkErrorOptions, + NetworkLimit, + NetworkLimitOverrides, + NetworkLimits, + NetworkProtocol, + NetworkRole, + TlsOptions, +} from "./index.ts"; + +export type MqttQos = 0 | 1; + +export type MqttMessageInfo = + | { + readonly qos: 0; + readonly retain: boolean; + readonly dup: boolean; + readonly packetId?: never; + } + | { + readonly qos: 1; + readonly retain: boolean; + readonly dup: boolean; + readonly packetId: number; + }; + +export interface PublishOptions { + readonly qos?: MqttQos; + readonly retain?: boolean; +} + +export interface Subscription { + readonly filter: string; + readonly qos?: MqttQos; +} + +export interface SubscribeOptions { + readonly qos?: MqttQos; +} + +export type SubscriptionResult = + | { + readonly filter: string; + readonly grantedQos: MqttQos; + } + | { + readonly filter: string; + readonly grantedQos: null; + readonly reasonCode: number; + }; + +export interface MqttWill { + readonly topic: string; + readonly payload: NetworkData; + readonly qos?: MqttQos; + readonly retain?: boolean; +} + +export interface MqttReconnectOptions { + readonly enabled: boolean; + readonly minDelayMs?: number; + readonly maxDelayMs?: number; + readonly multiplier?: number; + readonly jitter?: number; + readonly maxAttempts?: number; + readonly resubscribe?: boolean; +} + +export interface MqttConnectInfo { + readonly reconnected: boolean; + readonly sessionPresent: boolean; + readonly resubscribed?: boolean; +} + +export interface MqttHandlers { + readonly connect?: (client: MqttClient, info: MqttConnectInfo) => void; + readonly message?: ( + client: MqttClient, + topic: string, + payload: Uint8Array, + packet: MqttMessageInfo, + ) => void; + readonly disconnect?: ( + client: MqttClient, + reason: import("./index.ts").NetworkError, + ) => void; + readonly reconnect?: (client: MqttClient, attempt: number, delayMs: number) => void; + readonly drain?: (client: MqttClient) => void; + readonly error?: ( + client: MqttClient, + error: import("./index.ts").NetworkError, + ) => void; +} + +export interface MqttConnectOptions { + readonly url: string | URL; + readonly clientId: string; + readonly username?: string; + readonly password?: NetworkData; + readonly cleanSession?: boolean; + readonly keepAliveSeconds?: number; + readonly pingResponseTimeoutMs?: number; + readonly ackTimeoutMs?: number; + readonly will?: MqttWill; + readonly reconnect?: MqttReconnectOptions; + readonly tls?: TlsOptions; + readonly limits?: NetworkLimitOverrides; + readonly ref?: boolean; + readonly mqtt: MqttHandlers; +} + +export interface MqttClient { + publish(topic: string, payload: NetworkData, options?: PublishOptions): Promise; + subscribe( + filter: string | readonly Subscription[], + options?: SubscribeOptions, + ): Promise; + unsubscribe(filter: string | readonly string[]): Promise; + end(options?: { readonly force?: boolean; readonly timeoutMs?: number }): Promise; + ref(): this; + unref(): this; +} + +export function connect(_options: MqttConnectOptions): Promise { + return unsupportedNetworkPromise("mqtt.connect", "mqtt"); +} diff --git a/framework/src/net/network-limits.ts b/framework/src/net/network-limits.ts new file mode 100644 index 00000000..07670bb7 --- /dev/null +++ b/framework/src/net/network-limits.ts @@ -0,0 +1,41 @@ +/** Private storage for the build-admitted network limits projection. */ + +export interface InstalledNetworkLimit { + readonly default: number; + readonly hard: number; + readonly minimum: number; +} + +export interface InstalledNetworkLimitsSnapshot { + readonly values: readonly Readonly<{ + readonly name: string; + readonly default: number; + readonly hard: number; + readonly minimum: number; + }>[]; + readonly features: readonly string[]; +} + +export type InstalledNetworkLimitsProvider = ( + protocol: "http" | "websocket" | "mqtt" | "tcp" | "udp" | undefined, + role: "client" | "server" | undefined, +) => InstalledNetworkLimitsSnapshot; + +let installedProvider: InstalledNetworkLimitsProvider | undefined; + +/** Compiler-private one-shot mount hook; this file is not a package export. */ +export function installNetworkLimitsProvider( + provider: InstalledNetworkLimitsProvider, +): void { + if (installedProvider !== undefined) { + throw new TypeError("PocketJS network limits provider is already installed"); + } + installedProvider = provider; +} + +export function queryInstalledNetworkLimits( + protocol: "http" | "websocket" | "mqtt" | "tcp" | "udp" | undefined, + role: "client" | "server" | undefined, +): InstalledNetworkLimitsSnapshot | undefined { + return installedProvider?.(protocol, role); +} diff --git a/framework/src/net/network-v1-binding.ts b/framework/src/net/network-v1-binding.ts new file mode 100644 index 00000000..c038e8a7 --- /dev/null +++ b/framework/src/net/network-v1-binding.ts @@ -0,0 +1,1982 @@ +/** + * Owner-thread adapter from the formal private network ABI to the Fetch SDK's + * small high-level binding seam. This module is compiler-owned and is not a + * package export. + */ + +import { + NETWORK_V1_ABI_MAJOR, + NETWORK_V1_ABI_MINOR, + NETWORK_V1_ERROR_CATEGORY_NAME_BY_ID, + NETWORK_V1_ERROR_NAME_BY_ID, + NETWORK_V1_FEATURE_CAPABILITY_BY_ID, + NETWORK_V1_LIMIT_PROTOCOL_ANY, + NETWORK_V1_LIMIT_ROLE_ANY, + NETWORK_V1_SEQUENCE_MAX, + NETWORK_V1_UINT32_MAX, + NetworkV1BorrowedInputKind, + NetworkV1ClientCertificateMode, + NetworkV1CommandOpcode, + NetworkV1CompletionPollStatus, + NetworkV1DispatchStatus, + NetworkV1ErrorCategory, + NetworkV1ErrorCode, + NetworkV1EventCode, + NetworkV1FeatureId, + NetworkV1HttpRedirectMode, + NetworkV1LimitProtocol, + NetworkV1LimitRole, + NetworkV1ServiceTurnKind, + NetworkV1ServiceTurnStatus, + NetworkV1TlsRevocation, + NetworkV1TlsVerification, + NetworkV1TlsVersion, + assertNetworkV1BindingTable, + assertNetworkV1CompletionPollResult, + assertNetworkV1NextSequence, + assertNetworkV1RuntimeGeneration, + assertNetworkV1ServiceTurnResult, + networkV1HandleIsAbsent, + networkV1SameHandle, + snapshotNetworkV1Limits, + type NetworkV1AsyncCommand, + type NetworkV1BindingTable, + type NetworkV1BufferLeaseReadIntoCommand, + type NetworkV1BufferLeaseReleaseCommand, + type NetworkV1BufferLeaseTakeCommand, + type NetworkV1CommandIdentity, + type NetworkV1Completion, + type NetworkV1CompletionPollResult, + type NetworkV1CompletionIdentity, + type NetworkV1ErrorMetadata, + type NetworkV1Handle, + type NetworkV1Handshake, + type NetworkV1HttpRequestMetadata, + type NetworkV1HttpResponseMetadata, + type NetworkV1LimitEntry, + type NetworkV1LimitsSnapshot, + type NetworkV1ServiceDispatcher, + type NetworkV1ServiceTurnRequest, + type NetworkV1ServiceTurnResult, +} from "../../../contracts/spec/network/network-v1.ts"; +import { NetworkError } from "./index.ts"; +import { HTTP_BODY_CHUNK_BYTES, snapshotUint8Array } from "./http-body.ts"; +import { canonicalizeHttpUrl } from "./http-url.ts"; +import { + installHttpClientBindingForRuntime, + type BodyCancelCommand, + type HttpBindingHeader, + type HttpClientBindingOperation, + type HttpClientPrivateBinding, + type HttpRequestErrorEvent, + type HttpRequestStartCommand, + type HttpResponseHeadersEvent, + type OperationCancelCommand, +} from "./http-binding.ts"; +import type { BodyStream, HttpBodyProducer } from "./http-body.ts"; +import { installNetworkLimitsProvider } from "./network-limits.ts"; + +const MAX_OPERATIONS = 8; +const MAX_HEADERS = 128; +const MAX_HEADER_NAME_BYTES = 256; +const MAX_HEADER_VALUE_BYTES = 16 * 1024; +const MAX_HEADER_BLOCK_BYTES = 64 * 1024; +const MAX_LIMIT_OVERRIDES = 32; +const MAX_LIMIT_NAME_BYTES = 64; +const MAX_ALPN_TOKENS = 16; +const MAX_ALPN_BYTES = 1024; +const MAX_CA_BYTES = 64 * 1024; +const MAX_URL_BYTES = 8192; +const MAX_OPERATION_LABEL_BYTES = 64; +const MAX_CAUSE_CODE_BYTES = 64; +const MAX_SERVICE_TURN_EVENTS = 128; +const MAX_SERVICE_TURN_PAYLOAD_BYTES = 256 * 1024; + +const Uint8ArrayIntrinsic = Uint8Array; +const mathMin = Math.min; +const functionCall = Function.prototype.call; +const bindCall = ( + operation: (...args: Args) => Result, +): ((receiver: unknown, ...args: Args) => Result) => + functionCall.bind(operation) as (receiver: unknown, ...args: Args) => Result; +const objectFreeze = Object.freeze; +const objectKeys = Object.keys; +const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const objectIsFrozen = Object.isFrozen; +const arrayIsArray = Array.isArray; +const arrayIncludes = bindCall(Array.prototype.includes) as ( + receiver: readonly string[], + value: string, +) => boolean; +const arraySort = bindCall(Array.prototype.sort) as ( + receiver: string[], +) => string[]; +const numberIsSafeInteger = Number.isSafeInteger; +const numberIsInteger = Number.isInteger; +const reflectApply = Reflect.apply; +const regExpTest = bindCall(RegExp.prototype.test); +const stringCharCodeAt = bindCall(String.prototype.charCodeAt); +const stringIncludes = bindCall(String.prototype.includes); +const stringFromCharCode = String.fromCharCode; +const PromiseIntrinsic = Promise; +const promiseResolve = Promise.resolve; +const promiseThen = Promise.prototype.then; +const uint8ArraySlice = Uint8ArrayIntrinsic.prototype.slice; +const uint8ArraySubarray = Uint8ArrayIntrinsic.prototype.subarray; +const typedArrayPrototype = Object.getPrototypeOf(Uint8ArrayIntrinsic.prototype) as object; +const typedArrayByteLength = Object.getOwnPropertyDescriptor( + typedArrayPrototype, + "byteLength", +)!.get!; + +const ABSENT_HANDLE: Readonly = objectFreeze({ id: 0, generation: 0 }); +const HTTP_RESOURCE: Readonly = objectFreeze({ id: 1, generation: 1 }); + +export interface NetworkV1CompiledExpectation { + readonly planHashBytes: readonly number[]; + readonly featureIds: readonly number[]; +} + +interface PendingBodyRead { + readonly destination: Uint8Array; + readonly capacity: number; + readonly resolve: (result: { bytes: number; done: boolean }) => void; + readonly reject: (error: unknown) => void; +} + +interface ResponseBodyState { + phase: "open" | "ended" | "errored"; + error?: unknown; +} + +type SlotPhase = "headers" | "response" | "retired"; + +interface OperationSlot { + readonly slotId: number; + generation: number; + phase: SlotPhase; + highOperationId: number; + operation: Readonly; + requestBody: Readonly; + responseBody: Readonly; + producer: HttpBodyProducer | null; + uploadCredit: number; + uploadPullPending: boolean; + uploadTerminal: boolean; + pendingRead?: PendingBodyRead; + responseBodyState?: ResponseBodyState; + responseResolve?: (event: HttpResponseHeadersEvent) => void; + responseReject?: (error: unknown) => void; + cancelSent: boolean; +} + +interface SafeBindingMethods { + readonly getLimits: NetworkV1BindingTable["getLimits"]; + readonly dispatch: NetworkV1BindingTable["dispatch"]; + readonly nextCompletion: NetworkV1BindingTable["nextCompletion"]; + readonly leaseTake: NetworkV1BindingTable["leaseTake"]; + readonly leaseReadInto: NetworkV1BindingTable["leaseReadInto"]; + readonly leaseRelease: NetworkV1BindingTable["leaseRelease"]; + readonly registerServiceDispatcher: NetworkV1BindingTable["registerServiceDispatcher"]; +} + +interface AdapterTestOptions { + readonly maxOperations?: number; + readonly initialSlotGeneration?: number; +} + +interface ProjectedNetworkLimits { + readonly values: readonly Readonly[]; + readonly features: readonly string[]; +} + +interface AdmittedHttpClientBinding extends HttpClientPrivateBinding { + /** Private immutable snapshot; the high-level seam must explicitly preserve it. */ + readonly httpClientLimits: ProjectedNetworkLimits; +} + +function abiFault(detail: string): TypeError { + return new TypeError(`PocketJS network ABI adapter: ${detail}`); +} + +function integer( + value: unknown, + minimum: number, + maximum: number, + label: string, +): number { + if (!numberIsSafeInteger(value) || (value as number) < minimum || + (value as number) > maximum) { + throw abiFault(`${label} is outside [${minimum}, ${maximum}]`); + } + return value as number; +} + +function byteString(value: unknown, maximum: number, label: string): string { + if (typeof value !== "string" || value.length > maximum) { + throw abiFault(`${label} is not a bounded string`); + } + for (let index = 0; index < value.length; index += 1) { + if (stringCharCodeAt(value, index) > 0xff) throw abiFault(`${label} is not ByteString`); + } + return value; +} + +function tokenCode(value: unknown, label: string): string { + const token = byteString(value, MAX_CAUSE_CODE_BYTES, label); + if (token.length === 0 || + !regExpTest(/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/, token)) { + throw abiFault(`${label} is not a bounded token`); + } + return token; +} + +function canonicalAddress(value: unknown, label: string): string { + const address = byteString(value, 253, label); + if (address.length === 0) throw abiFault(`${label} is empty`); + const ipv6 = stringIncludes(address, ":"); + let parsed: ReturnType; + try { + parsed = canonicalizeHttpUrl(ipv6 ? `http://[${address}]/` : `http://${address}/`); + } catch { + throw abiFault(`${label} is not a canonical network address`); + } + if (parsed.hostname !== address) { + throw abiFault(`${label} is not a canonical network address`); + } + return address; +} + +function canonicalServerNameForComparison(value: unknown): string { + const source = byteString(value, 253, "TLS serverName"); + if (source.length === 0) throw abiFault("TLS serverName is empty"); + const end = source[source.length - 1] === "." ? source.length - 1 : source.length; + let normalized = ""; + for (let index = 0; index < end; index += 1) { + const code = stringCharCodeAt(source, index); + normalized += code >= 0x41 && code <= 0x5a + ? stringFromCharCode(code + 0x20) + : source[index]!; + } + if (normalized.length === 0) throw abiFault("TLS serverName is empty"); + let parsed: ReturnType; + try { + parsed = canonicalizeHttpUrl( + stringIncludes(normalized, ":") + ? `http://[${normalized}]/` + : `http://${normalized}/`, + ); + } catch { + throw abiFault("TLS serverName is not a canonical hostname"); + } + if (parsed.hostname !== normalized) { + throw abiFault("TLS serverName is not a canonical hostname"); + } + return normalized; +} + +function snapshotHandle(value: unknown, label: string): Readonly { + if (typeof value !== "object" || value === null) throw abiFault(`${label} is missing`); + const candidate = value as Partial; + const id = integer(candidate.id, 0, NETWORK_V1_UINT32_MAX, `${label}.id`); + const generation = integer( + candidate.generation, + 0, + NETWORK_V1_UINT32_MAX, + `${label}.generation`, + ); + if ((id === 0) !== (generation === 0)) throw abiFault(`${label} is a partial handle`); + return objectFreeze({ id, generation }); +} + +function snapshotIdentity( + value: unknown, + label: string, +): Readonly { + if (typeof value !== "object" || value === null) throw abiFault(`${label} is missing`); + const candidate = value as Partial; + const runtimeGeneration = integer( + candidate.runtimeGeneration, + 1, + NETWORK_V1_UINT32_MAX, + `${label}.runtimeGeneration`, + ); + const resource = snapshotHandle(candidate.resource, `${label}.resource`); + const operation = snapshotHandle(candidate.operation, `${label}.operation`); + const body = snapshotHandle(candidate.body, `${label}.body`); + const sequence = integer(candidate.sequence, 1, NETWORK_V1_SEQUENCE_MAX, `${label}.sequence`); + return objectFreeze({ runtimeGeneration, resource, operation, body, sequence }); +} + +function snapshotHeaders(value: unknown, label: string): readonly HttpBindingHeader[] { + if (!arrayIsArray(value)) throw abiFault(`${label} is not an array`); + const length = integer(value.length, 0, MAX_HEADERS, `${label}.length`); + const headers: HttpBindingHeader[] = []; + let blockBytes = 0; + for (let index = 0; index < length; index += 1) { + const entry = value[index] as unknown; + if (typeof entry !== "object" || entry === null) throw abiFault(`${label}[${index}] is invalid`); + const candidate = entry as Partial; + const name = byteString(candidate.name, MAX_HEADER_NAME_BYTES, `${label}[${index}].name`); + const headerValue = byteString( + candidate.value, + MAX_HEADER_VALUE_BYTES, + `${label}[${index}].value`, + ); + blockBytes += name.length + headerValue.length; + if (blockBytes > MAX_HEADER_BLOCK_BYTES) throw abiFault(`${label} exceeds its block ceiling`); + headers[headers.length] = objectFreeze({ name, value: headerValue }); + } + return objectFreeze(headers); +} + +function snapshotError(value: unknown, label: string): Readonly { + if (typeof value !== "object" || value === null) throw abiFault(`${label} is missing`); + const candidate = value as Partial; + const rawCategory = candidate.category; + const rawCode = candidate.code; + const rawOperation = candidate.operation; + const rawTemporary = candidate.temporary; + const rawAddress = candidate.address; + const rawPort = candidate.port; + const rawCauseCode = candidate.causeCode; + const rawReasonCode = candidate.reasonCode; + const category = integer(rawCategory, 1, 5, `${label}.category`) as NetworkV1ErrorMetadata["category"]; + const code = integer(rawCode, 1, 0x7fff, `${label}.code`) as NetworkV1ErrorMetadata["code"]; + if ((NETWORK_V1_ERROR_CATEGORY_NAME_BY_ID as Readonly>)[category] === undefined || + (NETWORK_V1_ERROR_NAME_BY_ID as Readonly>)[code] === undefined) { + throw abiFault(`${label} uses an unknown category or code`); + } + const operation = byteString( + rawOperation, + MAX_OPERATION_LABEL_BYTES, + `${label}.operation`, + ); + if (operation.length === 0 || typeof rawTemporary !== "boolean") { + throw abiFault(`${label} has invalid stable metadata`); + } + const address = rawAddress === undefined + ? undefined + : canonicalAddress(rawAddress, `${label}.address`); + const port = rawPort === undefined + ? undefined + : integer(rawPort, 1, 65_535, `${label}.port`); + const causeCode = rawCauseCode === undefined + ? undefined + : tokenCode(rawCauseCode, `${label}.causeCode`); + const reasonCode = rawReasonCode === undefined + ? undefined + : integer(rawReasonCode, 0, NETWORK_V1_UINT32_MAX, `${label}.reasonCode`); + return objectFreeze({ + category, + code, + operation, + temporary: rawTemporary, + ...(address === undefined ? {} : { address }), + ...(port === undefined ? {} : { port }), + ...(causeCode === undefined ? {} : { causeCode }), + ...(reasonCode === undefined ? {} : { reasonCode }), + }); +} + +function snapshotResponseMetadata(value: unknown): Readonly { + if (typeof value !== "object" || value === null) throw abiFault("response metadata is missing"); + const candidate = value as Partial; + const rawStatus = candidate.status; + const rawStatusText = candidate.statusText; + const rawHeaders = candidate.headers; + const rawUrl = candidate.url; + const rawRedirected = candidate.redirected; + const rawBufferedBodyBytes = candidate.bufferedBodyBytes; + const status = integer(rawStatus, 200, 599, "response.status"); + const statusText = byteString(rawStatusText, 1024, "response.statusText"); + const headers = snapshotHeaders(rawHeaders, "response.headers"); + const url = byteString(rawUrl, MAX_URL_BYTES, "response.url"); + let parsedUrl: ReturnType; + try { + parsedUrl = canonicalizeHttpUrl(url); + } catch { + throw abiFault("response.url is not a canonical HTTP(S) URL"); + } + if (parsedUrl.href !== url || parsedUrl.hasFragment) { + throw abiFault("response.url is not a canonical HTTP(S) URL"); + } + if (typeof rawRedirected !== "boolean") throw abiFault("response.redirected is invalid"); + const bufferedBodyBytes = integer( + rawBufferedBodyBytes, + 1, + NETWORK_V1_UINT32_MAX, + "response.bufferedBodyBytes", + ); + return objectFreeze({ + status, + statusText, + headers, + url, + redirected: rawRedirected, + bufferedBodyBytes, + }); +} + +function snapshotServiceTurnRequest( + value: unknown, +): Readonly { + if (typeof value !== "object" || value === null) { + throw abiFault("service turn request is missing"); + } + const candidate = value as Partial; + const rawRuntimeGeneration = candidate.runtimeGeneration; + const rawTurnId = candidate.turnId; + const rawKind = candidate.kind; + const rawMaxEvents = candidate.maxEvents; + const rawMaxPayloadBytes = candidate.maxPayloadBytes; + const runtimeGeneration = integer( + rawRuntimeGeneration, + 1, + NETWORK_V1_UINT32_MAX, + "serviceTurn.runtimeGeneration", + ); + const turnId = integer(rawTurnId, 1, NETWORK_V1_SEQUENCE_MAX, "serviceTurn.turnId"); + if (rawKind !== NetworkV1ServiceTurnKind.Network && + rawKind !== NetworkV1ServiceTurnKind.Shutdown) { + throw abiFault("serviceTurn.kind is unknown"); + } + const maxEvents = integer( + rawMaxEvents, + 1, + MAX_SERVICE_TURN_EVENTS, + "serviceTurn.maxEvents", + ); + const maxPayloadBytes = integer( + rawMaxPayloadBytes, + 1, + MAX_SERVICE_TURN_PAYLOAD_BYTES, + "serviceTurn.maxPayloadBytes", + ); + return objectFreeze({ + runtimeGeneration, + turnId, + kind: rawKind, + maxEvents, + maxPayloadBytes, + }); +} + +function snapshotCompletion(value: unknown): NetworkV1Completion { + if (typeof value !== "object" || value === null) throw abiFault("completion is missing"); + const candidate = value as Partial; + const eventCode = candidate.eventCode; + const identity = snapshotIdentity(candidate.identity, "completion.identity"); + switch (eventCode) { + case NetworkV1EventCode.BodyPull: + return objectFreeze({ + eventCode, + identity, + maxBytes: integer( + (candidate as Partial>).maxBytes, + 1, + HTTP_BODY_CHUNK_BYTES, + "BODY_PULL.maxBytes", + ), + }); + case NetworkV1EventCode.BodyChunk: { + const rawPayload = (candidate as Partial>).payload; + if (typeof rawPayload !== "object" || rawPayload === null) { + throw abiFault("BODY_CHUNK payload is missing"); + } + const payloadCandidate = rawPayload as { + readonly runtimeGeneration?: unknown; + readonly lease?: unknown; + readonly byteLength?: unknown; + }; + const payload = objectFreeze({ + runtimeGeneration: integer( + payloadCandidate.runtimeGeneration, + 1, + NETWORK_V1_UINT32_MAX, + "BODY_CHUNK.runtimeGeneration", + ), + lease: snapshotHandle(payloadCandidate.lease, "BODY_CHUNK.lease"), + byteLength: integer( + payloadCandidate.byteLength, + 1, + NETWORK_V1_UINT32_MAX, + "BODY_CHUNK.byteLength", + ), + }); + return objectFreeze({ eventCode, identity, payload }); + } + case NetworkV1EventCode.BodyEnd: + case NetworkV1EventCode.BodyCancel: + return objectFreeze({ eventCode, identity }) as NetworkV1Completion; + case NetworkV1EventCode.BodyError: + return objectFreeze({ + eventCode, + identity, + error: snapshotError( + (candidate as Partial>).error, + "BODY_ERROR.error", + ), + }); + case NetworkV1EventCode.HttpResponseHeaders: + return objectFreeze({ + eventCode, + identity, + metadata: snapshotResponseMetadata( + (candidate as Partial>).metadata, + ), + }); + case NetworkV1EventCode.HttpRequestError: + return objectFreeze({ + eventCode, + identity, + error: snapshotError( + (candidate as Partial>).error, + "HTTP_REQUEST_ERROR.error", + ), + }); + default: + throw abiFault("completion uses an unknown event code"); + } +} + +function actualUint8ArrayLength(value: unknown, label: string): number { + try { + return reflectApply(typedArrayByteLength, value, []) as number; + } catch { + throw abiFault(`${label} must be a Uint8Array`); + } +} + +function snapshotExpectedHandshake( + value: NetworkV1CompiledExpectation, + runtimeGeneration: number, +): Readonly { + if (typeof value !== "object" || value === null) throw abiFault("compiled expectation is missing"); + if (!arrayIsArray(value.planHashBytes) || value.planHashBytes.length !== 32) { + throw abiFault("compiled plan hash must contain 32 bytes"); + } + const planHash = new Uint8ArrayIntrinsic(32); + for (let index = 0; index < 32; index += 1) { + planHash[index] = integer(value.planHashBytes[index], 0, 0xff, `planHash[${index}]`); + } + if (!arrayIsArray(value.featureIds)) throw abiFault("compiled feature ids must be an array"); + const featureIds: NetworkV1FeatureId[] = []; + let previous = 0; + for (let index = 0; index < value.featureIds.length; index += 1) { + const id = integer(value.featureIds[index], 1, 0xffff, `featureIds[${index}]`); + if (id <= previous || + (NETWORK_V1_FEATURE_CAPABILITY_BY_ID as Readonly>)[id] === undefined) { + throw abiFault("compiled feature ids are unknown, duplicate, or unsorted"); + } + featureIds[featureIds.length] = id as NetworkV1FeatureId; + previous = id; + } + return objectFreeze({ + abiMajor: NETWORK_V1_ABI_MAJOR, + abiMinor: NETWORK_V1_ABI_MINOR, + runtimeGeneration, + planHash, + featureIds: objectFreeze(featureIds), + }); +} + +function adoptRuntimeGeneration(value: unknown): number { + if (typeof value !== "object" || value === null || !objectIsFrozen(value)) { + throw abiFault("binding table must be frozen"); + } + const handshakeDescriptor = objectGetOwnPropertyDescriptor(value, "handshake"); + if (!handshakeDescriptor || !("value" in handshakeDescriptor)) { + throw abiFault("binding handshake must be an own data property"); + } + const handshake = handshakeDescriptor.value; + if (typeof handshake !== "object" || handshake === null || !objectIsFrozen(handshake)) { + throw abiFault("binding handshake must be frozen"); + } + const runtimeDescriptor = objectGetOwnPropertyDescriptor(handshake, "runtimeGeneration"); + if (!runtimeDescriptor || !("value" in runtimeDescriptor)) { + throw abiFault("runtimeGeneration must be an own data property"); + } + assertNetworkV1RuntimeGeneration(runtimeDescriptor.value); + return runtimeDescriptor.value as number; +} + +function captureMethods(table: NetworkV1BindingTable): Readonly { + const method = (key: Key): SafeBindingMethods[Key] => { + const descriptor = objectGetOwnPropertyDescriptor(table, key); + if (!descriptor || !("value" in descriptor) || typeof descriptor.value !== "function") { + throw abiFault(`bindingTable.${key} changed after handshake`); + } + return descriptor.value as SafeBindingMethods[Key]; + }; + return objectFreeze({ + getLimits: method("getLimits"), + dispatch: method("dispatch"), + nextCompletion: method("nextCompletion"), + leaseTake: method("leaseTake"), + leaseReadInto: method("leaseReadInto"), + leaseRelease: method("leaseRelease"), + registerServiceDispatcher: method("registerServiceDispatcher"), + }); +} + +function httpErrorCompatibility(category: number, code: number): boolean { + if (category === NetworkV1ErrorCategory.Runtime) { + return code >= NetworkV1ErrorCode.Aborted && code <= NetworkV1ErrorCode.PermissionDenied || + code === NetworkV1ErrorCode.SystemError; + } + if (category === NetworkV1ErrorCategory.Resolver) { + return code >= NetworkV1ErrorCode.DnsNotFound && code <= NetworkV1ErrorCode.DnsRefused; + } + if (category === NetworkV1ErrorCategory.Transport) { + return code >= NetworkV1ErrorCode.ConnectionRefused && code <= NetworkV1ErrorCode.BrokenPipe; + } + if (category === NetworkV1ErrorCategory.Tls) { + return code >= NetworkV1ErrorCode.TlsCertificateInvalid && code <= NetworkV1ErrorCode.TlsAlert; + } + return category === NetworkV1ErrorCategory.Protocol && + (code === NetworkV1ErrorCode.HttpProtocolError || code === NetworkV1ErrorCode.MessageTooLarge); +} + +function publicError( + metadata: NetworkV1ErrorMetadata, + operation = "http.fetch", +): NetworkError { + const category = (NETWORK_V1_ERROR_CATEGORY_NAME_BY_ID as Readonly>)[metadata.category]; + const code = (NETWORK_V1_ERROR_NAME_BY_ID as Readonly>)[metadata.code]; + if (!category || !code || !httpErrorCompatibility(metadata.category, metadata.code)) { + return new NetworkError("Invalid error metadata from private network binding", { + category: "protocol", + code: "http_protocol_error", + operation: "http.fetch", + protocol: "http", + }); + } + return new NetworkError(`HTTP request failed with ${code}`, { + category: category as ConstructorParameters[1]["category"], + code: code as ConstructorParameters[1]["code"], + operation, + temporary: metadata.temporary, + address: metadata.address, + port: metadata.port, + protocol: "http", + causeCode: metadata.causeCode, + reasonCode: metadata.reasonCode, + }); +} + +function highErrorEvent( + metadata: NetworkV1ErrorMetadata, + operationId: number, +): Readonly { + const category = (NETWORK_V1_ERROR_CATEGORY_NAME_BY_ID as Readonly>)[metadata.category]; + const code = (NETWORK_V1_ERROR_NAME_BY_ID as Readonly>)[metadata.code]; + if (!category || !code || !httpErrorCompatibility(metadata.category, metadata.code)) { + return objectFreeze({ + eventCode: NetworkV1EventCode.HttpRequestError, + operationId, + category: "protocol", + code: "http_protocol_error", + message: "HTTP request failed with http_protocol_error", + temporary: false, + }); + } + return objectFreeze({ + eventCode: NetworkV1EventCode.HttpRequestError, + operationId, + category: category as HttpRequestErrorEvent["category"], + code, + message: `HTTP request failed with ${code}`, + temporary: metadata.temporary, + ...(metadata.causeCode === undefined ? {} : { causeCode: metadata.causeCode }), + ...(metadata.reasonCode === undefined ? {} : { reasonCode: metadata.reasonCode }), + }); +} + +function localErrorMetadata( + code: typeof NetworkV1ErrorCode.ResourceLimit | typeof NetworkV1ErrorCode.SystemError | + typeof NetworkV1ErrorCode.InvalidState | typeof NetworkV1ErrorCode.Closed, + operation: string, +): Readonly { + return objectFreeze({ + category: NetworkV1ErrorCategory.Runtime, + code, + operation, + temporary: false, + }); +} + +class NetworkV1HttpAdapter { + readonly binding: AdmittedHttpClientBinding | undefined; + readonly dispatcher: NetworkV1ServiceDispatcher; + readonly #table: NetworkV1BindingTable; + readonly #methods: Readonly; + readonly #runtimeGeneration: number; + readonly #handshake: Readonly; + readonly #slots: OperationSlot[]; + readonly #httpClientLimits: ProjectedNetworkLimits; + #commandSequence = 0; + #completionSequence = 0; + #inServiceTurn = false; + #poisoned = false; + #poisonError: unknown; + + constructor( + table: NetworkV1BindingTable, + expected: NetworkV1CompiledExpectation, + options: AdapterTestOptions = {}, + ) { + this.#runtimeGeneration = adoptRuntimeGeneration(table); + const expectedHandshake = snapshotExpectedHandshake(expected, this.#runtimeGeneration); + assertNetworkV1BindingTable(table, expectedHandshake); + this.#handshake = expectedHandshake; + this.#table = table; + this.#methods = captureMethods(table); + + const maxOperations = options.maxOperations === undefined + ? MAX_OPERATIONS + : integer(options.maxOperations, 1, MAX_OPERATIONS, "maxOperations"); + const initialGeneration = options.initialSlotGeneration === undefined + ? 0 + : integer( + options.initialSlotGeneration, + 0, + NETWORK_V1_UINT32_MAX, + "initialSlotGeneration", + ); + this.#slots = []; + for (let index = 0; index < maxOperations; index += 1) { + this.#slots[this.#slots.length] = { + slotId: index + 1, + generation: initialGeneration, + phase: "retired", + highOperationId: 0, + operation: ABSENT_HANDLE, + requestBody: ABSENT_HANDLE, + responseBody: ABSENT_HANDLE, + producer: null, + uploadCredit: 0, + uploadPullPending: false, + uploadTerminal: true, + cancelSent: false, + }; + } + + this.#httpClientLimits = this.#queryLimits( + NetworkV1LimitProtocol.Http, + NetworkV1LimitRole.Client, + ); + let httpClientAdmitted = false; + for (let index = 0; index < expectedHandshake.featureIds.length; index++) { + const id = expectedHandshake.featureIds[index]!; + if (id === NetworkV1FeatureId.HttpClient) { + httpClientAdmitted = true; + break; + } + } + this.dispatcher = (request) => this.#serviceTurn(request); + if (httpClientAdmitted) { + this.binding = objectFreeze({ + abiMajor: NETWORK_V1_ABI_MAJOR, + abiMinor: NETWORK_V1_ABI_MINOR, + featureSet: this.#httpClientLimits.features, + httpClientLimits: this.#httpClientLimits, + // v1.0 has no selected-provider ALPN snapshot. Keeping this absent makes + // custom ALPN fail before I/O instead of trusting Host implementation data. + start: ( + command: HttpRequestStartCommand, + requestBody: HttpBodyProducer | null, + ): HttpClientBindingOperation => this.#start(command, requestBody), + }); + reflectApply(this.#methods.registerServiceDispatcher, this.#table, [this.dispatcher]); + } + } + + #queryLimits( + protocol: typeof NETWORK_V1_LIMIT_PROTOCOL_ANY | NetworkV1LimitProtocol, + role: typeof NETWORK_V1_LIMIT_ROLE_ANY | NetworkV1LimitRole, + ): ProjectedNetworkLimits { + const query = objectFreeze({ + runtimeGeneration: this.#runtimeGeneration, + protocol, + role, + }); + let snapshot: Readonly; + try { + const raw = reflectApply(this.#methods.getLimits, this.#table, [query]); + snapshot = snapshotNetworkV1Limits(query, raw, this.#handshake); + } catch (error) { + throw this.#poison(error); + } + const features: string[] = []; + for (let index = 0; index < snapshot.featureIds.length; index++) { + const id = snapshot.featureIds[index]!; + features[features.length] = ( + NETWORK_V1_FEATURE_CAPABILITY_BY_ID as Readonly> + )[id]!; + } + return objectFreeze({ + values: snapshot.values, + features: objectFreeze(features), + }); + } + + limits( + protocol: "http" | "websocket" | "mqtt" | "tcp" | "udp" | undefined, + role: "client" | "server" | undefined, + ): Readonly<{ + values: readonly Readonly<{ + name: string; + default: number; + hard: number; + minimum: number; + }>[]; + features: readonly string[]; + }> { + this.#assertHealthy(); + const protocolId = protocol === undefined + ? NETWORK_V1_LIMIT_PROTOCOL_ANY + : protocol === "http" + ? NetworkV1LimitProtocol.Http + : protocol === "websocket" + ? NetworkV1LimitProtocol.WebSocket + : protocol === "mqtt" + ? NetworkV1LimitProtocol.Mqtt + : protocol === "tcp" + ? NetworkV1LimitProtocol.Tcp + : NetworkV1LimitProtocol.Udp; + const roleId = role === undefined + ? NETWORK_V1_LIMIT_ROLE_ANY + : role === "client" + ? NetworkV1LimitRole.Client + : NetworkV1LimitRole.Server; + if (protocolId === NetworkV1LimitProtocol.Http && + roleId === NetworkV1LimitRole.Client) { + return this.#httpClientLimits; + } + return this.#queryLimits(protocolId, roleId); + } + + #nextCommandSequence(): number { + if (this.#commandSequence >= NETWORK_V1_SEQUENCE_MAX) { + const error = new NetworkError("Network command sequence is exhausted", { + category: "runtime", + code: "resource_limit", + operation: "network.dispatch", + protocol: "http", + }); + throw this.#poison(error); + } + this.#commandSequence += 1; + return this.#commandSequence; + } + + #identity( + slot: OperationSlot, + body: Readonly, + ): Readonly { + return objectFreeze({ + runtimeGeneration: this.#runtimeGeneration, + resource: HTTP_RESOURCE, + operation: slot.operation, + body, + commandSequence: this.#nextCommandSequence(), + }); + } + + #allocate(highOperationId: number): OperationSlot { + integer(highOperationId, 1, NETWORK_V1_SEQUENCE_MAX, "operationId"); + for (let index = 0; index < this.#slots.length; index++) { + const active = this.#slots[index]!; + if (active.phase !== "retired" && active.highOperationId === highOperationId) { + throw new NetworkError("HTTP operation id is already active", { + category: "runtime", + code: "invalid_state", + operation: "http.fetch", + protocol: "http", + }); + } + } + for (let index = 0; index < this.#slots.length; index++) { + const slot = this.#slots[index]!; + if (slot.phase !== "retired" || slot.generation === NETWORK_V1_UINT32_MAX) continue; + slot.generation += 1; + slot.phase = "headers"; + slot.highOperationId = highOperationId; + slot.operation = objectFreeze({ id: slot.slotId, generation: slot.generation }); + slot.requestBody = ABSENT_HANDLE; + slot.responseBody = ABSENT_HANDLE; + slot.producer = null; + slot.uploadCredit = 0; + slot.uploadPullPending = false; + slot.uploadTerminal = true; + slot.pendingRead = undefined; + slot.responseBodyState = undefined; + slot.responseResolve = undefined; + slot.responseReject = undefined; + slot.cancelSent = false; + return slot; + } + throw new NetworkError("HTTP operation capacity is exhausted", { + category: "runtime", + code: "resource_limit", + operation: "http.fetch", + protocol: "http", + }); + } + + #retire(slot: OperationSlot): void { + if (slot.phase === "retired") return; + slot.phase = "retired"; + slot.highOperationId = 0; + slot.operation = ABSENT_HANDLE; + slot.requestBody = ABSENT_HANDLE; + slot.responseBody = ABSENT_HANDLE; + slot.producer = null; + slot.uploadCredit = 0; + slot.uploadPullPending = false; + slot.uploadTerminal = true; + slot.pendingRead = undefined; + slot.responseResolve = undefined; + slot.responseReject = undefined; + slot.cancelSent = false; + } + + #start( + command: HttpRequestStartCommand, + requestBody: HttpBodyProducer | null, + ): HttpClientBindingOperation { + this.#assertHealthy(); + if (command.opcode !== NetworkV1CommandOpcode.HttpRequestStart) { + throw abiFault("high-level start used the wrong opcode"); + } + let prepared: ReturnType; + if (!this.binding) throw abiFault("HTTP client start is not admitted"); + prepared = prepareRequestMetadata( + command, + this.binding.featureSet, + this.#httpClientLimits.values, + ); + if (prepared.metadata.hasBody !== (requestBody !== null)) { + throw abiFault("request body presence disagrees with metadata"); + } + const slot = this.#allocate(command.operationId); + try { + if (requestBody !== null) { + slot.requestBody = objectFreeze({ id: slot.slotId, generation: slot.generation }); + slot.producer = requestBody; + slot.uploadTerminal = false; + } + } catch (error) { + this.#retire(slot); + throw error; + } + + const response = new PromiseIntrinsic((resolve, reject) => { + slot.responseResolve = resolve; + slot.responseReject = reject; + }); + const formalCommand: NetworkV1AsyncCommand = objectFreeze({ + opcode: NetworkV1CommandOpcode.HttpRequestStart, + identity: this.#identity(slot, slot.requestBody), + metadata: prepared.metadata, + ...(prepared.customCa === undefined + ? {} + : { + input: objectFreeze({ + kind: NetworkV1BorrowedInputKind.CustomCa, + bytes: prepared.customCa, + }), + }), + }); + let refused: NetworkV1ErrorMetadata | undefined; + try { + refused = this.#dispatch(formalCommand); + } catch (error) { + this.#retire(slot); + throw this.#poison(error); + } + if (refused) { + const error = highErrorEvent(refused, command.operationId); + this.#retire(slot); + throw error; + } + + return objectFreeze({ + response, + cancel: (cancel: OperationCancelCommand) => this.#cancel(slot, cancel), + }); + } + + #dispatch(command: NetworkV1AsyncCommand): NetworkV1ErrorMetadata | undefined { + const raw = reflectApply(this.#methods.dispatch, this.#table, [command]) as unknown; + if (typeof raw !== "object" || raw === null) throw abiFault("dispatch result is missing"); + const candidate = raw as { readonly status?: unknown; readonly error?: unknown }; + const status = candidate.status; + if (status === NetworkV1DispatchStatus.Accepted) return undefined; + if (status === NetworkV1DispatchStatus.Refused) { + return snapshotError(candidate.error, "dispatch.error"); + } + throw abiFault("async dispatch returned an invalid status"); + } + + #cancel(slot: OperationSlot, command: OperationCancelCommand): void { + this.#assertHealthy(); + if (slot.phase === "retired" || slot.cancelSent) return; + if (command.opcode !== NetworkV1CommandOpcode.OperationCancel || + command.operationId !== slot.highOperationId) { + throw abiFault("cancel identity does not match its operation"); + } + slot.cancelSent = true; + const formal: NetworkV1AsyncCommand = objectFreeze({ + opcode: NetworkV1CommandOpcode.OperationCancel, + identity: this.#identity(slot, this.#currentBody(slot)), + }); + let refused: NetworkV1ErrorMetadata | undefined; + try { + refused = this.#dispatch(formal); + } catch (error) { + throw this.#poison(error); + } + if (refused) this.#failSlot(slot, publicError(refused)); + } + + #currentBody(slot: OperationSlot): Readonly { + return slot.phase === "response" ? slot.responseBody : slot.requestBody; + } + + #serviceTurn(request: NetworkV1ServiceTurnRequest): NetworkV1ServiceTurnResult { + this.#assertHealthy(); + if (this.#inServiceTurn) throw this.#poison(abiFault("service dispatcher is reentrant")); + let snapshot: Readonly; + try { + snapshot = snapshotServiceTurnRequest(request); + if (snapshot.runtimeGeneration !== this.#runtimeGeneration) { + throw abiFault("service turn uses a stale runtime generation"); + } + } catch (error) { + throw this.#poison(error); + } + this.#inServiceTurn = true; + let eventsDelivered = 0; + let payloadBytesDelivered = 0; + let status: typeof NetworkV1ServiceTurnStatus.Drained | + typeof NetworkV1ServiceTurnStatus.MoreReady = NetworkV1ServiceTurnStatus.MoreReady; + try { + if (snapshot.kind === NetworkV1ServiceTurnKind.Shutdown) { + for (let index = 0; index < this.#slots.length; index++) { + const slot = this.#slots[index]!; + if (slot.phase === "retired" || slot.cancelSent) continue; + slot.cancelSent = true; + const refused = this.#dispatch(objectFreeze({ + opcode: NetworkV1CommandOpcode.OperationCancel, + identity: this.#identity(slot, this.#currentBody(slot)), + })); + if (refused) this.#failSlot(slot, publicError(refused)); + } + } + + while (eventsDelivered < snapshot.maxEvents) { + const remainingBytes = snapshot.maxPayloadBytes - payloadBytesDelivered; + const polled = this.#poll(remainingBytes); + if (polled.status === NetworkV1CompletionPollStatus.Drained) { + status = NetworkV1ServiceTurnStatus.Drained; + break; + } + if (polled.status === NetworkV1CompletionPollStatus.BudgetExhausted) { + status = NetworkV1ServiceTurnStatus.MoreReady; + break; + } + const item = polled as Extract; + if (remainingBytes === 0) { + this.#cleanupSelectedLease(item.completion); + throw abiFault("zero-byte readiness probe consumed a completion"); + } + try { + assertNetworkV1NextSequence(this.#completionSequence, item.completion.identity.sequence); + } catch (error) { + this.#cleanupSelectedLease(item.completion); + throw error; + } + this.#completionSequence = item.completion.identity.sequence; + payloadBytesDelivered += item.payloadBytesDelivered; + eventsDelivered += 1; + this.#deliver(item.completion); + } + + if (eventsDelivered === snapshot.maxEvents && status !== NetworkV1ServiceTurnStatus.Drained) { + const probe = this.#poll(0); + if (probe.status === NetworkV1CompletionPollStatus.Item) { + this.#cleanupSelectedLease(probe.completion); + throw abiFault("zero-byte readiness probe consumed a completion"); + } + status = probe.status === NetworkV1CompletionPollStatus.Drained + ? NetworkV1ServiceTurnStatus.Drained + : NetworkV1ServiceTurnStatus.MoreReady; + } + + const result = objectFreeze({ + status, + eventsDelivered, + payloadBytesDelivered, + lastSequence: eventsDelivered === 0 ? 0 : this.#completionSequence, + }); + assertNetworkV1ServiceTurnResult(snapshot, result); + return result; + } catch (error) { + throw this.#poison(error); + } finally { + this.#inServiceTurn = false; + } + } + + #poll(maxPayloadBytes: number): NetworkV1CompletionPollResult { + const pollRequest = objectFreeze({ + runtimeGeneration: this.#runtimeGeneration, + maxPayloadBytes, + }); + const raw = reflectApply(this.#methods.nextCompletion, this.#table, [pollRequest]) as unknown; + if (typeof raw !== "object" || raw === null) throw abiFault("completion poll result is missing"); + const candidate = raw as { + readonly status?: unknown; + readonly completion?: unknown; + readonly payloadBytesDelivered?: unknown; + }; + const status = candidate.status; + let result: NetworkV1CompletionPollResult; + let selectedCompletion: NetworkV1Completion | undefined; + if (status === NetworkV1CompletionPollStatus.Item) { + selectedCompletion = snapshotCompletion(candidate.completion); + try { + const payload = candidate.payloadBytesDelivered; + result = objectFreeze({ + status, + completion: selectedCompletion, + payloadBytesDelivered: integer( + payload, + 0, + maxPayloadBytes, + "completionPoll.payloadBytesDelivered", + ), + }); + assertNetworkV1CompletionPollResult(pollRequest, result); + } catch (error) { + try { + this.#cleanupSelectedLease(selectedCompletion); + } catch (releaseError) { + throw releaseError; + } + throw error; + } + return result; + } else if (status === NetworkV1CompletionPollStatus.Drained || + status === NetworkV1CompletionPollStatus.BudgetExhausted) { + const payload = candidate.payloadBytesDelivered; + if (payload !== 0) throw abiFault("non-item completion poll consumed payload bytes"); + result = objectFreeze({ status, payloadBytesDelivered: 0 }); + } else { + throw abiFault("completion poll returned an unknown status"); + } + assertNetworkV1CompletionPollResult(pollRequest, result); + return result; + } + + #slotFor(identity: NetworkV1CompletionIdentity): OperationSlot | undefined { + const index = identity.operation.id - 1; + if (index < 0 || index >= this.#slots.length) return undefined; + const slot = this.#slots[index]!; + if (slot.phase === "retired" || !networkV1SameHandle(identity.operation, slot.operation)) { + return undefined; + } + return slot; + } + + #deliver(completion: NetworkV1Completion): void { + const slot = this.#slotFor(completion.identity); + if (!slot) { + this.#cleanupSelectedLease(completion); + return; + } + if (!networkV1SameHandle(completion.identity.resource, HTTP_RESOURCE)) { + this.#cleanupSelectedLease(completion); + throw abiFault("completion resource does not match the active HTTP client"); + } + + switch (completion.eventCode) { + case NetworkV1EventCode.BodyPull: + this.#onUploadPull(slot, completion.identity, completion.maxBytes); + return; + case NetworkV1EventCode.BodyCancel: + this.#onUploadCancel(slot, completion.identity); + return; + case NetworkV1EventCode.HttpResponseHeaders: + this.#onHeaders(slot, completion.identity, completion.metadata); + return; + case NetworkV1EventCode.HttpRequestError: + this.#onRequestError(slot, completion.error); + return; + case NetworkV1EventCode.BodyChunk: + this.#onResponseChunk(slot, completion); + return; + case NetworkV1EventCode.BodyEnd: + this.#onResponseEnd(slot, completion.identity); + return; + case NetworkV1EventCode.BodyError: + this.#onResponseError(slot, completion.identity, completion.error); + return; + } + } + + #onUploadPull( + slot: OperationSlot, + identity: NetworkV1CompletionIdentity, + maxBytes: number, + ): void { + if (slot.phase !== "headers" || slot.uploadTerminal || !slot.producer || + !networkV1SameHandle(identity.body, slot.requestBody) || + slot.uploadCredit !== 0 || slot.uploadPullPending) { + throw abiFault("BODY_PULL violates request-body credit state"); + } + slot.uploadCredit = maxBytes; + slot.uploadPullPending = true; + let pulled: unknown; + try { + pulled = slot.producer.pull(maxBytes); + } catch (error) { + this.#completeUploadPull(slot, maxBytes, undefined, error); + return; + } + const normalized = reflectApply(promiseResolve, PromiseIntrinsic, [pulled]) as Promise; + reflectApply(promiseThen, normalized, [ + (chunk: unknown) => this.#settleUploadPull(slot, maxBytes, chunk, undefined), + (error: unknown) => this.#settleUploadPull(slot, maxBytes, undefined, error), + ]); + } + + #settleUploadPull( + slot: OperationSlot, + credit: number, + chunk: unknown, + pullError: unknown, + ): void { + try { + this.#completeUploadPull(slot, credit, chunk, pullError); + } catch (error) { + this.#poison(error); + } + } + + #completeUploadPull( + slot: OperationSlot, + credit: number, + chunk: unknown, + pullError: unknown, + ): void { + if (slot.phase !== "headers" || slot.uploadTerminal || + !slot.uploadPullPending || slot.uploadCredit !== credit) return; + slot.uploadPullPending = false; + slot.uploadCredit = 0; + try { + let command: NetworkV1AsyncCommand; + if (pullError !== undefined) { + slot.uploadTerminal = true; + command = objectFreeze({ + opcode: NetworkV1CommandOpcode.BodyError, + identity: this.#identity(slot, slot.requestBody), + error: objectFreeze({ + ...localErrorMetadata(NetworkV1ErrorCode.SystemError, "http.fetch.upload"), + causeCode: "guest_body", + }), + }); + } else if (chunk === null) { + slot.uploadTerminal = true; + command = objectFreeze({ + opcode: NetworkV1CommandOpcode.BodyEnd, + identity: this.#identity(slot, slot.requestBody), + }); + } else { + let bytes: Uint8Array; + try { + bytes = snapshotUint8Array(chunk, credit, "HTTP request body chunk"); + if (bytes.byteLength === 0) throw abiFault("request BODY_CHUNK must not be empty"); + command = objectFreeze({ + opcode: NetworkV1CommandOpcode.BodyChunk, + identity: this.#identity(slot, slot.requestBody), + input: objectFreeze({ + kind: NetworkV1BorrowedInputKind.BodyChunk, + bytes, + }), + }); + } catch { + slot.uploadTerminal = true; + command = objectFreeze({ + opcode: NetworkV1CommandOpcode.BodyError, + identity: this.#identity(slot, slot.requestBody), + error: localErrorMetadata(NetworkV1ErrorCode.InvalidState, "http.fetch.upload"), + }); + } + } + const refused = this.#dispatch(command); + if (refused) this.#failSlot(slot, publicError(refused, "http.fetch.upload")); + } catch (error) { + throw this.#poison(error); + } + } + + #stopUpload(slot: OperationSlot, reason: unknown): void { + if (slot.uploadTerminal) return; + slot.uploadTerminal = true; + slot.uploadCredit = 0; + slot.uploadPullPending = false; + const producer = slot.producer; + slot.producer = null; + if (!producer) return; + try { + const cancelled = producer.cancel(reason); + const normalized = reflectApply(promiseResolve, PromiseIntrinsic, [cancelled]) as Promise; + reflectApply(promiseThen, normalized, [() => undefined, () => undefined]); + } catch { + // The Core's terminal claim remains authoritative. + } + } + + #onUploadCancel(slot: OperationSlot, identity: NetworkV1CompletionIdentity): void { + if (slot.phase !== "headers" || !networkV1SameHandle(identity.body, slot.requestBody)) { + throw abiFault("BODY_CANCEL does not identify the request producer"); + } + this.#stopUpload(slot, publicError(localErrorMetadata( + NetworkV1ErrorCode.Closed, + "http.fetch.upload", + ) as NetworkV1ErrorMetadata)); + } + + #onHeaders( + slot: OperationSlot, + identity: NetworkV1CompletionIdentity, + metadata: NetworkV1HttpResponseMetadata, + ): void { + if (slot.phase !== "headers") throw abiFault("response headers were delivered twice"); + const reason = new NetworkError("HTTP request upload ended at response headers", { + category: "runtime", + code: "closed", + operation: "http.fetch.upload", + protocol: "http", + }); + this.#stopUpload(slot, reason); + slot.phase = "response"; + slot.responseBody = identity.body; + const hasBody = !networkV1HandleIsAbsent(identity.body); + const responseBodyState: ResponseBodyState | undefined = hasBody + ? { phase: "open" } + : undefined; + slot.responseBodyState = responseBodyState; + const event: HttpResponseHeadersEvent = objectFreeze({ + eventCode: NetworkV1EventCode.HttpResponseHeaders, + operationId: slot.highOperationId, + status: metadata.status, + statusText: metadata.statusText, + headers: metadata.headers, + url: metadata.url, + redirected: metadata.redirected, + ...(responseBodyState === undefined + ? {} + : { body: this.#responseBodyStream(slot, responseBodyState) }), + bufferedBodyBytes: metadata.bufferedBodyBytes, + }); + const resolve = slot.responseResolve; + slot.responseResolve = undefined; + slot.responseReject = undefined; + if (!resolve) throw abiFault("response promise is missing"); + resolve(event); + // The native operation is not reusable until its terminal BODY_END arrives. + // This is also true for HEAD and null-body status responses: headers may be + // published before the dedicated transport has finished closing. + } + + #onRequestError(slot: OperationSlot, metadata: NetworkV1ErrorMetadata): void { + const event = highErrorEvent(metadata, slot.highOperationId); + const error = publicError(metadata); + this.#stopUpload(slot, event); + if (slot.phase === "headers") slot.responseReject?.(event); + else { + this.#failResponseBody(slot, error); + slot.pendingRead?.reject(error); + } + this.#retire(slot); + } + + #responseBodyStream(slot: OperationSlot, state: ResponseBodyState): BodyStream { + const readInto = (destination: Uint8Array): Promise<{ bytes: number; done: boolean }> => { + let capacity: number; + try { + capacity = actualUint8ArrayLength(destination, "response body destination"); + integer(capacity, 1, NETWORK_V1_UINT32_MAX, "response body destination length"); + } catch (error) { + return PromiseIntrinsic.reject(error); + } + if (state.phase === "errored") return PromiseIntrinsic.reject(state.error); + if (state.phase === "ended") { + return PromiseIntrinsic.resolve(objectFreeze({ bytes: 0, done: true })); + } + try { + this.#assertHealthy(); + } catch (error) { + return PromiseIntrinsic.reject(error); + } + if (slot.phase !== "response" || slot.responseBodyState !== state || slot.pendingRead) { + return PromiseIntrinsic.reject(new NetworkError("HTTP response body already has a pending read", { + category: "runtime", + code: "busy", + operation: "http.body.readInto", + protocol: "http", + })); + } + return new PromiseIntrinsic((resolve, reject) => { + slot.pendingRead = { destination, capacity, resolve, reject }; + let refused: NetworkV1ErrorMetadata | undefined; + try { + refused = this.#dispatch(objectFreeze({ + opcode: NetworkV1CommandOpcode.BodyPull, + identity: this.#identity(slot, slot.responseBody), + maxBytes: mathMin(capacity, HTTP_BODY_CHUNK_BYTES), + })); + } catch (error) { + reject(this.#poison(error)); + return; + } + if (refused) { + slot.pendingRead = undefined; + reject(publicError(refused, "http.body.readInto")); + } + }); + }; + const cancel = (_reason?: unknown): Promise => { + if (state.phase === "errored") return PromiseIntrinsic.reject(state.error); + if (state.phase === "ended") return PromiseIntrinsic.resolve(); + try { + this.#assertHealthy(); + } catch (error) { + return PromiseIntrinsic.reject(error); + } + if (slot.phase !== "response" || slot.responseBodyState !== state) { + return PromiseIntrinsic.reject(this.#poison( + abiFault("response body stream lost its active operation"), + )); + } + const pending = slot.pendingRead; + let refused: NetworkV1ErrorMetadata | undefined; + try { + refused = this.#dispatch(objectFreeze({ + opcode: NetworkV1CommandOpcode.BodyCancel, + identity: this.#identity(slot, slot.responseBody), + })); + } catch (error) { + return PromiseIntrinsic.reject(this.#poison(error)); + } + if (refused) { + const error = publicError(refused, "http.body.cancel"); + slot.pendingRead = undefined; + pending?.reject(error); + this.#failResponseBody(slot, error); + return PromiseIntrinsic.reject(this.#poison(error)); + } + slot.pendingRead = undefined; + pending?.resolve(objectFreeze({ bytes: 0, done: true })); + state.phase = "ended"; + // Keep the formal slot live until native BODY_END/BODY_ERROR confirms + // that abort/close cleanup has released the dedicated transport. + return PromiseIntrinsic.resolve(); + }; + const stream: BodyStream = { + readInto, + cancel, + [Symbol.asyncIterator](): AsyncIterableIterator { + return { + async next(): Promise> { + const destination = new Uint8ArrayIntrinsic(HTTP_BODY_CHUNK_BYTES); + const result = await readInto(destination); + return result.done + ? { value: undefined, done: true } + : { + value: reflectApply(uint8ArraySlice, destination, [0, result.bytes]), + done: false, + }; + }, + async return(): Promise> { + await cancel(); + return { value: undefined, done: true }; + }, + [Symbol.asyncIterator]() { return this; }, + }; + }, + }; + return objectFreeze(stream); + } + + #onResponseChunk( + slot: OperationSlot, + completion: Extract, + ): void { + if (slot.phase !== "response" || + !networkV1SameHandle(completion.identity.body, slot.responseBody)) { + this.#cleanupSelectedLease(completion); + throw abiFault("response BODY_CHUNK identifies the wrong body"); + } + if (slot.responseBodyState?.phase === "ended") { + this.#cleanupSelectedLease(completion); + return; + } + const pending = slot.pendingRead; + if (!pending || completion.payload.byteLength > + mathMin(pending.capacity, HTTP_BODY_CHUNK_BYTES)) { + this.#cleanupSelectedLease(completion); + throw abiFault("response BODY_CHUNK has no matching credit"); + } + slot.pendingRead = undefined; + try { + this.#copyLease(completion, pending.destination); + pending.resolve(objectFreeze({ bytes: completion.payload.byteLength, done: false })); + } catch (error) { + pending.reject(error); + throw error; + } + } + + #onResponseEnd(slot: OperationSlot, identity: NetworkV1CompletionIdentity): void { + if (slot.phase !== "response" || !networkV1SameHandle(identity.body, slot.responseBody)) { + throw abiFault("response BODY_END identifies the wrong body"); + } + const pending = slot.pendingRead; + slot.pendingRead = undefined; + if (slot.responseBodyState) slot.responseBodyState.phase = "ended"; + pending?.resolve(objectFreeze({ bytes: 0, done: true })); + this.#retire(slot); + } + + #onResponseError( + slot: OperationSlot, + identity: NetworkV1CompletionIdentity, + metadata: NetworkV1ErrorMetadata, + ): void { + if (slot.phase !== "response" || !networkV1SameHandle(identity.body, slot.responseBody)) { + throw abiFault("response BODY_ERROR identifies the wrong body"); + } + if (slot.responseBodyState?.phase === "ended") { + this.#retire(slot); + return; + } + const pending = slot.pendingRead; + slot.pendingRead = undefined; + const error = publicError(metadata, "http.body.readInto"); + this.#failResponseBody(slot, error); + pending?.reject(error); + this.#retire(slot); + } + + #leaseIdentity(completion: NetworkV1CompletionIdentity): Readonly { + return objectFreeze({ + runtimeGeneration: this.#runtimeGeneration, + resource: completion.resource, + operation: completion.operation, + body: completion.body, + commandSequence: this.#nextCommandSequence(), + }); + } + + #copyLease( + completion: Extract, + destination: Uint8Array, + ): void { + const take: NetworkV1BufferLeaseTakeCommand = objectFreeze({ + opcode: NetworkV1CommandOpcode.BufferLeaseTake, + identity: this.#leaseIdentity(completion.identity), + lease: completion.payload.lease, + byteLength: completion.payload.byteLength, + }); + const takeResult = reflectApply(this.#methods.leaseTake, this.#table, [take]) as unknown; + const takeClaim = this.#claimLeaseTake(takeResult); + const takeCompleted = takeClaim.completed; + let takenLength = 0; + let offset = 0; + let failure: unknown; + try { + if (!takeClaim.completed) throw takeClaim.error; + takenLength = integer( + takeClaim.result.byteLength, + 1, + NETWORK_V1_UINT32_MAX, + "leaseTake.byteLength", + ); + if (takenLength !== completion.payload.byteLength) { + throw abiFault("lease take length disagrees with its completion"); + } + while (offset < takenLength) { + const window = reflectApply( + uint8ArraySubarray, + destination, + [offset, takenLength], + ) as Uint8Array; + const read: NetworkV1BufferLeaseReadIntoCommand = objectFreeze({ + opcode: NetworkV1CommandOpcode.BufferLeaseReadInto, + identity: this.#leaseIdentity(completion.identity), + lease: completion.payload.lease, + offset, + maxBytes: window.byteLength, + }); + const raw = reflectApply(this.#methods.leaseReadInto, this.#table, [read, window]) as unknown; + const copied = this.#snapshotLeaseReadResult(raw, window.byteLength); + offset += copied; + } + } catch (error) { + failure = error; + } + if (takeCompleted) { + try { + this.#releaseTakenLease(completion.identity, completion.payload.lease); + } catch (releaseError) { + failure = releaseError; + } + } + if (failure !== undefined) throw failure; + } + + #claimLeaseTake(value: unknown): Readonly< + | { + completed: true; + result: { readonly byteLength?: unknown }; + } + | { completed: false; error: NetworkError } + > { + if (typeof value !== "object" || value === null) throw abiFault("leaseTake result is missing"); + const candidate = value as { readonly status?: unknown; readonly byteLength?: unknown; readonly error?: unknown }; + const status = candidate.status; + if (status === NetworkV1DispatchStatus.Refused) { + return objectFreeze({ + completed: false, + error: publicError( + snapshotError(candidate.error, "leaseTake.error"), + "http.body.readInto", + ), + }); + } + if (status !== NetworkV1DispatchStatus.Completed) throw abiFault("leaseTake did not complete synchronously"); + return objectFreeze({ completed: true, result: candidate }); + } + + #snapshotLeaseReadResult(value: unknown, maximum: number): number { + if (typeof value !== "object" || value === null) throw abiFault("leaseReadInto result is missing"); + const candidate = value as { readonly status?: unknown; readonly bytesCopied?: unknown; readonly error?: unknown }; + const status = candidate.status; + if (status === NetworkV1DispatchStatus.Refused) { + throw publicError(snapshotError(candidate.error, "leaseReadInto.error"), "http.body.readInto"); + } + if (status !== NetworkV1DispatchStatus.Completed) throw abiFault("leaseReadInto did not complete synchronously"); + return integer(candidate.bytesCopied, 1, maximum, "leaseReadInto.bytesCopied"); + } + + #releaseTakenLease( + identity: NetworkV1CompletionIdentity, + lease: NetworkV1Handle, + ): void { + const command: NetworkV1BufferLeaseReleaseCommand = objectFreeze({ + opcode: NetworkV1CommandOpcode.BufferLeaseRelease, + identity: this.#leaseIdentity(identity), + lease, + }); + const raw = reflectApply(this.#methods.leaseRelease, this.#table, [command]) as unknown; + if (typeof raw !== "object" || raw === null) throw abiFault("leaseRelease result is missing"); + const candidate = raw as { readonly status?: unknown; readonly error?: unknown }; + if (candidate.status === NetworkV1DispatchStatus.Refused) { + throw publicError( + snapshotError(candidate.error, "leaseRelease.error"), + "http.body.readInto", + ); + } + if (candidate.status !== NetworkV1DispatchStatus.Completed) { + throw abiFault("leaseRelease did not complete synchronously"); + } + } + + #cleanupSelectedLease(completion: NetworkV1Completion): void { + if (completion.eventCode !== NetworkV1EventCode.BodyChunk) return; + const take: NetworkV1BufferLeaseTakeCommand = objectFreeze({ + opcode: NetworkV1CommandOpcode.BufferLeaseTake, + identity: this.#leaseIdentity(completion.identity), + lease: completion.payload.lease, + byteLength: completion.payload.byteLength, + }); + const raw = reflectApply(this.#methods.leaseTake, this.#table, [take]) as unknown; + const takeClaim = this.#claimLeaseTake(raw); + const takeCompleted = takeClaim.completed; + let failure: unknown; + try { + if (!takeClaim.completed) throw takeClaim.error; + const length = integer( + takeClaim.result.byteLength, + 1, + NETWORK_V1_UINT32_MAX, + "leaseTake.byteLength", + ); + if (length !== completion.payload.byteLength) { + throw abiFault("stale lease take length is inconsistent"); + } + } catch (error) { + failure = error; + } + if (takeCompleted) { + try { + this.#releaseTakenLease(completion.identity, completion.payload.lease); + } catch (releaseError) { + failure = releaseError; + } + } + if (failure !== undefined) throw failure; + } + + #failResponseBody(slot: OperationSlot, error: unknown): void { + const state = slot.responseBodyState; + if (!state || state.phase !== "open") return; + state.phase = "errored"; + state.error = error; + } + + #failSlot(slot: OperationSlot, error: unknown): void { + this.#stopUpload(slot, error); + this.#failResponseBody(slot, error); + slot.responseReject?.(error); + slot.pendingRead?.reject(error); + this.#retire(slot); + } + + #failAll(error: unknown): void { + for (let index = 0; index < this.#slots.length; index++) { + const slot = this.#slots[index]!; + if (slot.phase !== "retired") this.#failSlot(slot, error); + } + } + + #assertHealthy(): void { + if (this.#poisoned) throw this.#poisonError; + } + + #poison(error: unknown): unknown { + if (!this.#poisoned) { + this.#poisoned = true; + this.#poisonError = error; + this.#failAll(error); + } + return this.#poisonError; + } +} + +function prepareRequestMetadata( + command: HttpRequestStartCommand, + featureSet: readonly string[], + admittedLimits: readonly Readonly[], +): Readonly<{ + metadata: NetworkV1HttpRequestMetadata; + customCa?: Uint8Array; +}> { + const url = byteString(command.url, MAX_URL_BYTES, "request.url"); + const parsed = canonicalizeHttpUrl(url); + if (parsed.href !== url || parsed.hasFragment || + (parsed.scheme !== "http" && parsed.scheme !== "https")) { + throw abiFault("request URL is not a canonical HTTP(S) URL without a fragment"); + } + const method = byteString(command.method, 64, "request.method"); + if (!regExpTest(/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/, method) || + method === "CONNECT" || method === "TRACE" || method === "TRACK") { + throw abiFault("request method is invalid"); + } + const headers = snapshotHeaders(command.headers, "request.headers"); + if (typeof command.hasBody !== "boolean" || typeof command.ref !== "boolean") { + throw abiFault("request boolean metadata is invalid"); + } + const redirect = command.redirect === "follow" + ? NetworkV1HttpRedirectMode.Follow + : command.redirect === "manual" + ? NetworkV1HttpRedirectMode.Manual + : command.redirect === "error" + ? NetworkV1HttpRedirectMode.Error + : undefined; + if (redirect === undefined) throw abiFault("request redirect mode is invalid"); + const maxRedirects = integer(command.maxRedirects, 0, 5, "request.maxRedirects"); + + const timeoutSource = command.timeouts; + const timeouts = objectFreeze({ + connectMs: timeoutSource?.connect === undefined + ? 0 + : integer(timeoutSource.connect, 1, NETWORK_V1_UINT32_MAX, "timeouts.connect"), + headersMs: timeoutSource?.headers === undefined + ? 0 + : integer(timeoutSource.headers, 1, NETWORK_V1_UINT32_MAX, "timeouts.headers"), + idleMs: timeoutSource?.idle === undefined + ? 0 + : integer(timeoutSource.idle, 1, NETWORK_V1_UINT32_MAX, "timeouts.idle"), + totalMs: timeoutSource?.total === undefined + ? 0 + : integer(timeoutSource.total, 1, NETWORK_V1_UINT32_MAX, "timeouts.total"), + }); + + const limits: { name: string; value: number }[] = []; + if (command.limits !== undefined) { + const names = objectKeys(command.limits); + if (names.length > MAX_LIMIT_OVERRIDES) throw abiFault("request limits exceed their count ceiling"); + arraySort(names); + for (let nameIndex = 0; nameIndex < names.length; nameIndex++) { + const name = names[nameIndex]!; + if (name.length === 0 || name.length > MAX_LIMIT_NAME_BYTES || + !regExpTest(/^[A-Za-z][A-Za-z0-9._-]*$/, name)) { + throw abiFault("request limit name is invalid"); + } + let admitted: Readonly | undefined; + for (let limitIndex = 0; limitIndex < admittedLimits.length; limitIndex++) { + const entry = admittedLimits[limitIndex]!; + if (entry.name === name) { + admitted = entry; + break; + } + } + if (admitted === undefined) { + throw abiFault(`request limit ${name} is not admitted for the HTTP client`); + } + limits[limits.length] = objectFreeze({ + name, + value: integer( + command.limits[name], + admitted.minimum, + admitted.default, + `request limit ${name}`, + ), + }); + } + } + + let tls: NetworkV1HttpRequestMetadata["tls"] = null; + let customCa: Uint8Array | undefined; + if (parsed.scheme === "https") { + const source = command.tls; + const hasV13 = arrayIncludes(featureSet, "network.http.client.tls.v1-3"); + const minVersion = source?.minVersion === "1.3" + ? NetworkV1TlsVersion.V13 + : NetworkV1TlsVersion.V12; + const maxVersion = source?.maxVersion === "1.2" + ? NetworkV1TlsVersion.V12 + : source?.maxVersion === "1.3" || hasV13 + ? NetworkV1TlsVersion.V13 + : NetworkV1TlsVersion.V12; + if (minVersion > maxVersion) throw abiFault("TLS version interval is empty"); + const alpn: string[] = []; + let alpnBytes = 0; + if (source?.alpn !== undefined) { + if (!arrayIsArray(source.alpn) || source.alpn.length > MAX_ALPN_TOKENS) { + throw abiFault("TLS ALPN list is invalid"); + } + for (let index = 0; index < source.alpn.length; index++) { + const rawToken = source.alpn[index]; + const token = byteString(rawToken, 255, "TLS ALPN token"); + if (token.length === 0 || stringIncludes(token, "\0") || arrayIncludes(alpn, token)) { + throw abiFault("TLS ALPN token is invalid or duplicate"); + } + alpnBytes += token.length; + if (alpnBytes > MAX_ALPN_BYTES) throw abiFault("TLS ALPN list exceeds its byte ceiling"); + alpn[alpn.length] = token; + } + } + if (source?.ca !== undefined) { + customCa = snapshotUint8Array(source.ca, MAX_CA_BYTES, "TLS custom CA"); + if (customCa.byteLength === 0) throw abiFault("TLS custom CA must not be empty"); + } + const hostnameIsIp = stringIncludes(parsed.hostname, ":") || + regExpTest(/^\d+(?:\.\d+){3}$/, parsed.hostname); + const suppliedServerName = source?.serverName; + if (suppliedServerName !== undefined && + canonicalServerNameForComparison(suppliedServerName) !== parsed.hostname) { + throw abiFault("TLS serverName must equal the canonical request hostname"); + } + const serverName = hostnameIsIp ? "" : parsed.hostname; + const credential = source?.credential === undefined + ? "" + : byteString(source.credential, 128, "TLS credential"); + const clientCertificate = source?.clientCertificate === "optional" + ? NetworkV1ClientCertificateMode.Optional + : source?.clientCertificate === "required" + ? NetworkV1ClientCertificateMode.Required + : NetworkV1ClientCertificateMode.None; + const verification = source?.verification === "development-insecure" + ? NetworkV1TlsVerification.DevelopmentInsecure + : NetworkV1TlsVerification.Full; + const revocation = source?.revocation === "required" + ? NetworkV1TlsRevocation.Required + : NetworkV1TlsRevocation.HostDefault; + tls = objectFreeze({ + serverName, + minVersion, + maxVersion, + alpn: objectFreeze(alpn), + credential, + clientCertificate, + verification, + revocation, + customCaBytes: customCa?.byteLength ?? 0, + }); + } else if (command.tls !== undefined) { + throw abiFault("plaintext HTTP request contains TLS metadata"); + } + + return objectFreeze({ + metadata: objectFreeze({ + url, + method, + headers, + hasBody: command.hasBody, + redirect, + timeouts, + maxRedirects, + tls, + limits: objectFreeze(limits), + ref: command.ref, + }), + ...(customCa === undefined ? {} : { customCa }), + }); +} + +/** Test-only constructor; it validates and registers but does not install globally. */ +export function createNetworkV1HttpBindingAdapterForTesting( + table: NetworkV1BindingTable, + expected: NetworkV1CompiledExpectation, + options?: AdapterTestOptions, +): Readonly<{ + binding: AdmittedHttpClientBinding | undefined; + dispatcher: NetworkV1ServiceDispatcher; + limits: NetworkV1HttpAdapter["limits"]; + httpClientLimits: ProjectedNetworkLimits; +}> { + const adapter = new NetworkV1HttpAdapter(table, expected, options); + return objectFreeze({ + binding: adapter.binding, + dispatcher: adapter.dispatcher, + limits: (protocol, role) => adapter.limits(protocol, role), + httpClientLimits: adapter.limits("http", "client"), + }); +} + +let runtimeMounted = false; + +/** Called only by the compiler-generated private module before the app entry. */ +export function mountNetworkV1HttpBinding( + table: unknown, + expected: NetworkV1CompiledExpectation, +): void { + if (runtimeMounted) throw abiFault("runtime binding was already mounted"); + runtimeMounted = true; + const adapter = new NetworkV1HttpAdapter(table as NetworkV1BindingTable, expected); + installNetworkLimitsProvider((protocol, role) => adapter.limits(protocol, role)); + if (adapter.binding !== undefined) installHttpClientBindingForRuntime(adapter.binding); +} diff --git a/framework/src/net/tcp.ts b/framework/src/net/tcp.ts new file mode 100644 index 00000000..0c0cce65 --- /dev/null +++ b/framework/src/net/tcp.ts @@ -0,0 +1,111 @@ +import type { + NetworkAddress, + NetworkData, + NetworkLimitOverrides, + TlsOptions, +} from "./index.ts"; +import { unsupportedNetworkPromise } from "./internal.ts"; + +export { + AbortController, + AbortSignal, + NetworkError, + URL, +} from "./index.ts"; +export type { + NetworkAddress, + NetworkData, + NetworkErrorCategory, + NetworkErrorCode, + NetworkErrorOptions, + NetworkLimit, + NetworkLimitOverrides, + NetworkLimits, + NetworkProtocol, + NetworkRole, + TlsOptions, +} from "./index.ts"; + +export interface TcpReadResult { + readonly bytes: number; + readonly done: boolean; +} + +export interface TcpSocket { + readonly localAddress: NetworkAddress; + readonly remoteAddress: NetworkAddress; + readInto(destination: Uint8Array): Promise; + write(data: NetworkData): number; + flush(): Promise; + shutdown(): Promise; + close(): void; + terminate(): void; + setTimeout(ms: number): this; + setNoDelay(enabled: boolean): this; + setKeepAlive(enabled: boolean, initialDelayMs?: number): this; + ref(): this; + unref(): this; +} + +export interface TcpSocketHandlers { + readonly open?: (socket: TcpSocket) => void; + readonly data?: (socket: TcpSocket, data: Uint8Array) => void; + readonly drain?: (socket: TcpSocket) => void; + readonly timeout?: (socket: TcpSocket) => void; + readonly end?: (socket: TcpSocket) => void; + readonly close?: ( + socket: TcpSocket, + error: import("./index.ts").NetworkError | null, + ) => void; + readonly error?: ( + socket: TcpSocket, + error: import("./index.ts").NetworkError, + ) => void; +} + +export interface TcpTimeouts { + readonly connect?: number; + readonly idle?: number; + readonly total?: number; +} + +export interface TcpConnectOptions { + readonly hostname: string; + readonly port: number; + readonly tls?: TlsOptions; + readonly timeouts?: TcpTimeouts; + readonly limits?: NetworkLimitOverrides; + readonly ref?: boolean; + /** Omit handlers to select pull-based `readInto()` mode. */ + readonly socket?: TcpSocketHandlers; +} + +export interface TcpListenOptions { + readonly hostname?: string; + readonly port: number; + readonly tls?: TlsOptions; + readonly limits?: NetworkLimitOverrides; + readonly ref?: boolean; + /** Omit handlers to select pull-based `readInto()` mode for accepted sockets. */ + readonly socket?: TcpSocketHandlers; +} + +export interface TcpListenerStopOptions { + readonly graceful?: boolean; + readonly timeout?: number; +} + +export interface TcpListener { + readonly address: NetworkAddress; + stop(options?: TcpListenerStopOptions): Promise; + ref(): this; + unref(): this; +} + +export function connect(_options: TcpConnectOptions): Promise { + return unsupportedNetworkPromise("tcp.connect", "tcp"); +} + +export function listen(_options: TcpListenOptions): Promise { + return unsupportedNetworkPromise("tcp.listen", "tcp"); +} diff --git a/framework/src/net/udp.ts b/framework/src/net/udp.ts new file mode 100644 index 00000000..19235f0b --- /dev/null +++ b/framework/src/net/udp.ts @@ -0,0 +1,87 @@ +import type { + NetworkAddress, + NetworkData, + NetworkLimitOverrides, +} from "./index.ts"; +import { unsupportedNetworkPromise } from "./internal.ts"; + +export { + AbortController, + AbortSignal, + NetworkError, + URL, +} from "./index.ts"; +export type { + NetworkAddress, + NetworkData, + NetworkErrorCategory, + NetworkErrorCode, + NetworkErrorOptions, + NetworkLimit, + NetworkLimitOverrides, + NetworkLimits, + NetworkProtocol, + NetworkRole, + TlsOptions, +} from "./index.ts"; + +export interface UdpPeer { + readonly hostname: string; + readonly port: number; +} + +export interface UdpBindOptions { + readonly hostname?: string; + readonly port: number; +} + +export interface UdpReceiveResult { + readonly bytes: number; + readonly datagramBytes: number; + readonly remoteAddress: NetworkAddress; + readonly truncated: boolean; +} + +export interface UdpDatagram { + readonly data: NetworkData; + readonly address?: NetworkAddress; +} + +export interface UdpSocketHandlers { + readonly data?: ( + socket: UdpSocket, + data: Uint8Array, + remoteAddress: NetworkAddress, + ) => void; + readonly drain?: (socket: UdpSocket) => void; + readonly error?: ( + socket: UdpSocket, + error: import("./index.ts").NetworkError, + ) => void; +} + +export interface UdpSocketOptions { + readonly bind?: UdpBindOptions; + readonly connect?: UdpPeer; + readonly limits?: NetworkLimitOverrides; + readonly ref?: boolean; + /** Omit handlers to select pull-based `receiveInto()` mode. */ + readonly socket?: UdpSocketHandlers; +} + +export interface UdpSocket { + readonly localAddress: NetworkAddress; + readonly remoteAddress?: NetworkAddress; + readonly droppedDatagrams: number; + receiveInto(destination: Uint8Array): Promise; + send(data: NetworkData): boolean; + send(data: NetworkData, address: NetworkAddress): boolean; + sendMany(datagrams: readonly UdpDatagram[]): number; + close(): void; + ref(): this; + unref(): this; +} + +export function udpSocket(_options: UdpSocketOptions): Promise { + return unsupportedNetworkPromise("udp.udpSocket", "udp"); +} diff --git a/framework/src/net/utf8.ts b/framework/src/net/utf8.ts new file mode 100644 index 00000000..2a3d7d03 --- /dev/null +++ b/framework/src/net/utf8.ts @@ -0,0 +1,198 @@ +/** + * QuickJS-safe one-shot UTF-8 codecs for the HTTP SDK. These functions do not + * read TextEncoder, TextDecoder, Buffer, or any other optional global. + */ + +const Uint8ArrayIntrinsic = Uint8Array; +const StringIntrinsic = String; +const reflectApply = Reflect.apply; +const stringCharCodeAt = StringIntrinsic.prototype.charCodeAt; +const stringFromCharCode = StringIntrinsic.fromCharCode; +const arrayJoin = Array.prototype.join; +const numberIsSafeInteger = Number.isSafeInteger; +const typedArrayPrototype = Object.getPrototypeOf( + Uint8ArrayIntrinsic.prototype, +) as object; +const typedArrayByteLength = Object.getOwnPropertyDescriptor( + typedArrayPrototype, + "byteLength", +)!.get!; + +const REPLACEMENT_CODE_POINT = 0xfffd; +const DECODE_CHUNK_CODE_UNITS = 256; + +function codeUnitAt(value: string, index: number): number { + return reflectApply(stringCharCodeAt, value, [index]) as number; +} + +function scalarAt(value: string, index: number): number { + const first = codeUnitAt(value, index); + if (first >= 0xd800 && first <= 0xdbff && index + 1 < value.length) { + const second = codeUnitAt(value, index + 1); + if (second >= 0xdc00 && second <= 0xdfff) { + return 0x10000 + ((first - 0xd800) << 10) + (second - 0xdc00); + } + } + return first >= 0xd800 && first <= 0xdfff + ? REPLACEMENT_CODE_POINT + : first; +} + +function encodedScalarBytes(codePoint: number): number { + if (codePoint <= 0x7f) return 1; + if (codePoint <= 0x7ff) return 2; + if (codePoint <= 0xffff) return 3; + return 4; +} + +/** + * Encode with TextEncoder's USVString replacement behavior. A null result + * means the exact output would exceed maximumBytes; no output allocation has + * occurred in that case. + */ +export function encodeUtf8( + value: string, + maximumBytes: number, +): Uint8Array | null { + if (!numberIsSafeInteger(maximumBytes) || maximumBytes < 0) return null; + + let byteLength = 0; + for (let index = 0; index < value.length;) { + const codePoint = scalarAt(value, index); + const required = encodedScalarBytes(codePoint); + if (byteLength > maximumBytes - required) return null; + byteLength += required; + index += codePoint > 0xffff ? 2 : 1; + } + + const output = new Uint8ArrayIntrinsic(byteLength); + let offset = 0; + for (let index = 0; index < value.length;) { + const codePoint = scalarAt(value, index); + index += codePoint > 0xffff ? 2 : 1; + if (codePoint <= 0x7f) { + output[offset++] = codePoint; + } else if (codePoint <= 0x7ff) { + output[offset++] = 0xc0 | (codePoint >> 6); + output[offset++] = 0x80 | (codePoint & 0x3f); + } else if (codePoint <= 0xffff) { + output[offset++] = 0xe0 | (codePoint >> 12); + output[offset++] = 0x80 | ((codePoint >> 6) & 0x3f); + output[offset++] = 0x80 | (codePoint & 0x3f); + } else { + output[offset++] = 0xf0 | (codePoint >> 18); + output[offset++] = 0x80 | ((codePoint >> 12) & 0x3f); + output[offset++] = 0x80 | ((codePoint >> 6) & 0x3f); + output[offset++] = 0x80 | (codePoint & 0x3f); + } + } + return output; +} + +/** + * Decode one complete UTF-8 byte sequence with TextDecoder's default + * replacement behavior. A leading UTF-8 BOM is omitted; later BOMs remain. + * A null result means the input exceeds maximumBytes. + */ +export function decodeUtf8( + bytes: Uint8Array, + maximumBytes: number, +): string | null { + if (!numberIsSafeInteger(maximumBytes) || maximumBytes < 0) return null; + const byteLength = reflectApply(typedArrayByteLength, bytes, []) as number; + if (byteLength > maximumBytes) return null; + + const pieces: string[] = []; + let codeUnits: number[] = []; + let firstScalar = true; + + const flush = (): void => { + if (codeUnits.length === 0) return; + pieces[pieces.length] = reflectApply( + stringFromCharCode, + StringIntrinsic, + codeUnits, + ) as string; + codeUnits = []; + }; + + const append = (codePoint: number): void => { + if (firstScalar) { + firstScalar = false; + if (codePoint === 0xfeff) return; + } + if (codePoint <= 0xffff) { + if (codeUnits.length === DECODE_CHUNK_CODE_UNITS) flush(); + codeUnits[codeUnits.length] = codePoint; + return; + } + if (codeUnits.length > DECODE_CHUNK_CODE_UNITS - 2) flush(); + const scalar = codePoint - 0x10000; + codeUnits[codeUnits.length] = 0xd800 + (scalar >> 10); + codeUnits[codeUnits.length] = 0xdc00 + (scalar & 0x3ff); + }; + + let index = 0; + while (index < byteLength) { + const first = bytes[index]!; + if (first <= 0x7f) { + index++; + append(first); + continue; + } + + let continuationCount = 0; + let codePoint = 0; + let secondMinimum = 0x80; + let secondMaximum = 0xbf; + if (first >= 0xc2 && first <= 0xdf) { + continuationCount = 1; + codePoint = first & 0x1f; + } else if (first >= 0xe0 && first <= 0xef) { + continuationCount = 2; + codePoint = first & 0x0f; + if (first === 0xe0) secondMinimum = 0xa0; + if (first === 0xed) secondMaximum = 0x9f; + } else if (first >= 0xf0 && first <= 0xf4) { + continuationCount = 3; + codePoint = first & 0x07; + if (first === 0xf0) secondMinimum = 0x90; + if (first === 0xf4) secondMaximum = 0x8f; + } else { + index++; + append(REPLACEMENT_CODE_POINT); + continue; + } + + let cursor = index + 1; + let complete = true; + for (let offset = 0; offset < continuationCount; offset++) { + if (cursor === byteLength) { + index = cursor; + append(REPLACEMENT_CODE_POINT); + complete = false; + break; + } + const continuation = bytes[cursor]!; + const minimum = offset === 0 ? secondMinimum : 0x80; + const maximum = offset === 0 ? secondMaximum : 0xbf; + if (continuation < minimum || continuation > maximum) { + // Reconsume the offending byte, matching the replacement-mode UTF-8 + // decoder's maximal-subpart behavior. + index = cursor; + append(REPLACEMENT_CODE_POINT); + complete = false; + break; + } + codePoint = (codePoint << 6) | (continuation & 0x3f); + cursor++; + } + if (complete) { + index = cursor; + append(codePoint); + } + } + + flush(); + return reflectApply(arrayJoin, pieces, [""]) as string; +} diff --git a/framework/src/net/vendor/url/LICENSES.md b/framework/src/net/vendor/url/LICENSES.md new file mode 100644 index 00000000..14eb96e3 --- /dev/null +++ b/framework/src/net/vendor/url/LICENSES.md @@ -0,0 +1,46 @@ +## whatwg-url and tr46 + +The MIT License (MIT) + +Copyright (c) Sebastian Mayr + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Punycode.js + +Copyright Mathias Bynens + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/framework/src/net/vendor/url/README.md b/framework/src/net/vendor/url/README.md new file mode 100644 index 00000000..beb6d78c --- /dev/null +++ b/framework/src/net/vendor/url/README.md @@ -0,0 +1,41 @@ +# Pinned URL and IDNA sources + +The HTTP URL parser is a bounded adaptation of the special-URL algorithms in +`whatwg-url@17.1.0` and only accepts absolute `http:` and `https:` URLs. The +upstream tarball SHA-256 is +`41859594cde0afc9250957eabc6f8bfd66706ea130df702bc3f52edf78c841c0`. + +IDNA processing uses `tr46@6.0.0` with Unicode 17.0.0 data. Its vendored source, +generator, checksums, and license are in `tools/vendor/tr46-6.0.0`. + +Punycode encoding and decoding use `punycode@2.3.1`. `punycode.ts` preserves +the upstream algorithm and captures the intrinsic methods it calls so later +Guest mutation cannot change endpoint normalization. The upstream ES module's SHA-256 is +`8969ae1b78644a33574be197d1a7a9c85b031092f1c1a657d865b7b3c1edd77e`, +and the tarball SHA-256 is +`e4ce59f9fbac44349abab87279ab658f6b4614916bec5e088ae3be9323e193bb`. + +The retained third-party notices are in `LICENSES.md`. + +## Size and heap probe + +The minified browser-target ESM for `http-url.ts` is 181,150 bytes. In the +current HTTP SDK bundle, including this module adds 181,018 bytes (236,550 +bytes with the URL module versus 55,532 bytes with that module externalized). + +`tests/fixtures/http-url-quickjs-entry.ts` and +`tests/fixtures/http-url-quickjs-memory.c` provide a reproducible QuickJS-ng +0.14.0 allocator probe. The macOS host build of the same QuickJS-ng sources +vendored by the ESP-IDF component measured: + +- 290,825 bytes retained after loading the 181,345-byte minified probe IIFE; +- 529,310 bytes peak above an empty runtime while parsing/loading that source; +- 312 bytes retained by the first normalization warmup; +- 18,935 bytes steady-state peak for 64 sequential normalizations, including + a 7.2-KiB serialized Unicode path; +- 7,242 bytes as the largest single operation allocation; and +- zero bytes retained by the measured steady-state operation after GC. + +These host figures bound the JS object behavior but are not an ESP-IDF heap +measurement. The admission run must repeat the probe using the production +precompiled Guest artifact on ESP32-S3 and ESP32-P4. diff --git a/framework/src/net/vendor/url/idna-data.generated.ts b/framework/src/net/vendor/url/idna-data.generated.ts new file mode 100644 index 00000000..0d5c6d12 --- /dev/null +++ b/framework/src/net/vendor/url/idna-data.generated.ts @@ -0,0 +1,831 @@ +// Generated by tools/generate-url-idna-table.ts. Do not edit. +// tr46 6.0.0 / Unicode 17.0.0; source and license: tools/vendor/tr46-6.0.0. +export const TR46_TABLE_ENTRY_COUNT = 9262; +export const TR46_TABLE_RECORDS = + "LAAAAAACLgAAAAACLwAAAAACOQAAAAACQAAAAAACQQAAAAAJQgAAAQAJQwAAAgAJRAAAAwAJRQAABAAJRgAABQAJRwAABgAJSAAA" + + "BwAJSQAACAAJSgAACQAJSwAACgAJTAAACwAJTQAADAAJTgAADQAJTwAADgAJUAAADwAJUQAAEAAJUgAAEQAJUwAAEgAJVAAAEwAJ" + + "VQAAFAAJVgAAFQAJVwAAFgAJWAAAFwAJWQAAGAAJWgAAGQAJYAAAGgACegAAGgACfwAAGgACnwAAGgADoAAAGgAJpwAAGwACqAAA" + + "GwARqQAAHQACqgAAHQAJrAAAHgACrQAAHgAHrgAAHgACrwAAHgARsQAAIAACsgAAIAAJswAAIQAJtAAAIgARtQAAJAAJtgAAJQAC" + + "twAAJQACuAAAJQARuQAAJwAJugAAKAAJuwAAKQACvAAAKQAZvQAALAAZvgAALwAZvwAAMgACwAAAMgAJwQAAMwAJwgAANAAJwwAA" + + "NQAJxAAANgAJxQAANwAJxgAAOAAJxwAAOQAJyAAAOgAJyQAAOwAJygAAPAAJywAAPQAJzAAAPgAJzQAAPwAJzgAAQAAJzwAAQQAJ" + + "0AAAQgAJ0QAAQwAJ0gAARAAJ0wAARQAJ1AAARgAJ1QAARwAJ1gAASAAJ1wAASQAC2AAASQAJ2QAASgAJ2gAASwAJ2wAATAAJ3AAA" + + "TQAJ3QAATgAJ3gAATwAJ3wAAUAAW9gAAUgAC9wAAUgAC/wAAUgACAAEAUgAJAQEAUwACAgEAUwAJAwEAVAACBAEAVAAJBQEAVQAC" + + "BgEAVQAJBwEAVgACCAEAVgAJCQEAVwACCgEAVwAJCwEAWAACDAEAWAAJDQEAWQACDgEAWQAJDwEAWgACEAEAWgAJEQEAWwACEgEA" + + "WwAJEwEAXAACFAEAXAAJFQEAXQACFgEAXQAJFwEAXgACGAEAXgAJGQEAXwACGgEAXwAJGwEAYAACHAEAYAAJHQEAYQACHgEAYQAJ" + + "HwEAYgACIAEAYgAJIQEAYwACIgEAYwAJIwEAZAACJAEAZAAJJQEAZQACJgEAZQAJJwEAZgACKAEAZgAJKQEAZwACKgEAZwAJKwEA" + + "aAACLAEAaAAJLQEAaQACLgEAaQAJLwEAagACMAEAagARMQEAbAACMwEAbAARNAEAbgAJNQEAbwACNgEAbwAJOAEAcAACOQEAcAAJ" + + "OgEAcQACOwEAcQAJPAEAcgACPQEAcgAJPgEAcwACQAEAcwARQQEAdQAJQgEAdgACQwEAdgAJRAEAdwACRQEAdwAJRgEAeAACRwEA" + + "eAAJSAEAeQACSQEAeQARSgEAewAJSwEAfAACTAEAfAAJTQEAfQACTgEAfQAJTwEAfgACUAEAfgAJUQEAfwACUgEAfwAJUwEAgAAC" + + "VAEAgAAJVQEAgQACVgEAgQAJVwEAggACWAEAggAJWQEAgwACWgEAgwAJWwEAhAACXAEAhAAJXQEAhQACXgEAhQAJXwEAhgACYAEA" + + "hgAJYQEAhwACYgEAhwAJYwEAiAACZAEAiAAJZQEAiQACZgEAiQAJZwEAigACaAEAigAJaQEAiwACagEAiwAJawEAjAACbAEAjAAJ" + + "bQEAjQACbgEAjQAJbwEAjgACcAEAjgAJcQEAjwACcgEAjwAJcwEAkAACdAEAkAAJdQEAkQACdgEAkQAJdwEAkgACeAEAkgAJeQEA" + + "kwAJegEAlAACewEAlAAJfAEAlQACfQEAlQAJfgEAlgACfwEAlgAJgAEAlwACgQEAlwAJggEAmAAJgwEAmQAChAEAmQAJhQEAmgAC" + + "hgEAmgAJhwEAmwAJiAEAnAACiQEAnAAJigEAnQAJiwEAngAJjQEAnwACjgEAnwAJjwEAoAAJkAEAoQAJkQEAogAJkgEAowACkwEA" + + "owAJlAEApAAJlQEApQAClgEApQAJlwEApgAJmAEApwAJmwEAqAACnAEAqAAJnQEAqQAJngEAqgACnwEAqgAJoAEAqwAJoQEArAAC" + + "ogEArAAJowEArQACpAEArQAJpQEArgACpgEArgAJpwEArwAJqAEAsAACqQEAsAAJqwEAsQACrAEAsQAJrQEAsgACrgEAsgAJrwEA" + + "swAJsAEAtAACsQEAtAAJsgEAtQAJswEAtgAJtAEAtwACtQEAtwAJtgEAuAACtwEAuAAJuAEAuQAJuwEAugACvAEAugAJwwEAuwAC" + + "xgEAuwARyQEAvQARzAEAvwARzQEAwQAJzgEAwgACzwEAwgAJ0AEAwwAC0QEAwwAJ0gEAxAAC0wEAxAAJ1AEAxQAC1QEAxQAJ1gEA" + + "xgAC1wEAxgAJ2AEAxwAC2QEAxwAJ2gEAyAAC2wEAyAAJ3QEAyQAC3gEAyQAJ3wEAygAC4AEAygAJ4QEAywAC4gEAywAJ4wEAzAAC" + + "5AEAzAAJ5QEAzQAC5gEAzQAJ5wEAzgAC6AEAzgAJ6QEAzwAC6gEAzwAJ6wEA0AAC7AEA0AAJ7QEA0QAC7gEA0QAJ8AEA0gAC8wEA" + + "0gAR9AEA1AAJ9QEA1QAC9gEA1QAJ9wEA1gAJ+AEA1wAJ+QEA2AAC+gEA2AAJ+wEA2QAC/AEA2QAJ/QEA2gAC/gEA2gAJ/wEA2wAC" + + "AAIA2wAJAQIA3AACAgIA3AAJAwIA3QACBAIA3QAJBQIA3gACBgIA3gAJBwIA3wACCAIA3wAJCQIA4AACCgIA4AAJCwIA4QACDAIA" + + "4QAJDQIA4gACDgIA4gAJDwIA4wACEAIA4wAJEQIA5AACEgIA5AAJEwIA5QACFAIA5QAJFQIA5gACFgIA5gAJFwIA5wACGAIA5wAJ" + + "GQIA6AACGgIA6AAJGwIA6QACHAIA6QAJHQIA6gACHgIA6gAJHwIA6wACIAIA6wAJIQIA7AACIgIA7AAJIwIA7QACJAIA7QAJJQIA" + + "7gACJgIA7gAJJwIA7wACKAIA7wAJKQIA8AACKgIA8AAJKwIA8QACLAIA8QAJLQIA8gACLgIA8gAJLwIA8wACMAIA8wAJMQIA9AAC" + + "MgIA9AAJMwIA9QACNgIA9QACOQIA9QACOgIA9QAJOwIA9gAJPAIA9wACPQIA9wAJPgIA+AAJQAIA+QACQQIA+QAJQgIA+gACQwIA" + + "+gAJRAIA+wAJRQIA/AAJRgIA/QAJRwIA/gACSAIA/gAJSQIA/wACSgIA/wAJSwIAAAECTAIAAAEJTQIAAQECTgIAAQEJTwIAAgEC" + + "qAIAAgECrQIAAgECrwIAAgECsAIAAgEJsQIAAwEJsgIABAEJswIABQEJtAIABgEJtQIABwEJtgIACAEJtwIACQEJuAIACgEJwQIA" + + "CwECxQIACwEC0QIACwEC1wIACwEC2AIACwER2QIADQER2gIADwER2wIAEQER3AIAEwER3QIAFQER3gIAFwEC3wIAFwEC4AIAFwEJ" + + "4QIAGAEJ4gIAGQEJ4wIAGgEJ5AIAGwEJ6QIAHAEC6wIAHAEC7AIAHAEC7QIAHAEC7gIAHAEC/wIAHAECPwMAHAECQAMAHAEJQQMA" + + "HQEJQgMAHgECQwMAHgEJRAMAHwERRQMAIQEJTgMAIgECTwMAIgEHVwMAIgECXAMAIgECXwMAIgECYQMAIgECYgMAIgECbwMAIgEC" + + "cAMAIgEJcQMAIwECcgMAIwEJcwMAJAECdAMAJAEJdQMAJQECdgMAJQEJdwMAJgECeQMAJgEDegMAJgERfQMAKAECfgMAKAEJfwMA" + + "KQEJgwMAKgEDhAMAKgERhQMALAEZhgMALwEJhwMAMAEJiAMAMQEJiQMAMgEJigMAMwEJiwMANAEDjAMANAEJjQMANQEDjgMANQEJ" + + "jwMANgEJkAMANwECkQMANwEJkgMAOAEJkwMAOQEJlAMAOgEJlQMAOwEJlgMAPAEJlwMAPQEJmAMAPgEJmQMAPwEJmgMAQAEJmwMA" + + "QQEJnAMAQgEJnQMAQwEJngMARAEJnwMARQEJoAMARgEJoQMARwEJogMASAEDowMASAEJpAMASQEJpQMASgEJpgMASwEJpwMATAEJ" + + "qAMATQEJqQMATgEJqgMATwEJqwMAUAEJwQMAUQECwgMAUQEOzgMAUgECzwMAUgEJ0AMAUwEJ0QMAVAEJ0gMAVQEJ0wMAVgEJ1AMA" + + "VwEJ1QMAWAEJ1gMAWQEJ1wMAWgEC2AMAWgEJ2QMAWwEC2gMAWwEJ2wMAXAEC3AMAXAEJ3QMAXQEC3gMAXQEJ3wMAXgEC4AMAXgEJ" + + "4QMAXwEC4gMAXwEJ4wMAYAEC5AMAYAEJ5QMAYQEC5gMAYQEJ5wMAYgEC6AMAYgEJ6QMAYwEC6gMAYwEJ6wMAZAEC7AMAZAEJ7QMA" + + "ZQEC7gMAZQEJ7wMAZgEC8AMAZgEJ8QMAZwEJ8gMAaAEJ8wMAaQEC9AMAaQEJ9QMAagEJ9gMAawEC9wMAawEJ+AMAbAEC+QMAbAEJ" + + "+gMAbQEJ+wMAbgEC/AMAbgEC/QMAbgEJ/gMAbwEJ/wMAcAEJAAQAcQEJAQQAcgEJAgQAcwEJAwQAdAEJBAQAdQEJBQQAdgEJBgQA" + + "dwEJBwQAeAEJCAQAeQEJCQQAegEJCgQAewEJCwQAfAEJDAQAfQEJDQQAfgEJDgQAfwEJDwQAgAEJEAQAgQEJEQQAggEJEgQAgwEJ" + + "EwQAhAEJFAQAhQEJFQQAhgEJFgQAhwEJFwQAiAEJGAQAiQEJGQQAigEJGgQAiwEJGwQAjAEJHAQAjQEJHQQAjgEJHgQAjwEJHwQA" + + "kAEJIAQAkQEJIQQAkgEJIgQAkwEJIwQAlAEJJAQAlQEJJQQAlgEJJgQAlwEJJwQAmAEJKAQAmQEJKQQAmgEJKgQAmwEJKwQAnAEJ" + + "LAQAnQEJLQQAngEJLgQAnwEJLwQAoAEJTwQAoQECUAQAoQECXAQAoQECXQQAoQECXwQAoQECYAQAoQEJYQQAogECYgQAogEJYwQA" + + "owECZAQAowEJZQQApAECZgQApAEJZwQApQECaAQApQEJaQQApgECagQApgEJawQApwECbAQApwEJbQQAqAECbgQAqAEJbwQAqQEC" + + "cAQAqQEJcQQAqgECcgQAqgEJcwQAqwECdAQAqwEJdQQArAECdgQArAEJdwQArQECeAQArQEJeQQArgECegQArgEJewQArwECfAQA" + + "rwEJfQQAsAECfgQAsAEJfwQAsQECgAQAsQEJgQQAsgECggQAsgEChgQAsgEChwQAsgECiQQAsgECigQAsgEJiwQAswECjAQAswEJ" + + "jQQAtAECjgQAtAEJjwQAtQECkAQAtQEJkQQAtgECkgQAtgEJkwQAtwEClAQAtwEJlQQAuAEClgQAuAEJlwQAuQECmAQAuQEJmQQA" + + "ugECmgQAugEJmwQAuwECnAQAuwEJnQQAvAECngQAvAEJnwQAvQECoAQAvQEJoQQAvgECogQAvgEJowQAvwECpAQAvwEJpQQAwAEC" + + "pgQAwAEJpwQAwQECqAQAwQEJqQQAwgECqgQAwgEJqwQAwwECrAQAwwEJrQQAxAECrgQAxAEJrwQAxQECsAQAxQEJsQQAxgECsgQA" + + "xgEJswQAxwECtAQAxwEJtQQAyAECtgQAyAEJtwQAyQECuAQAyQEJuQQAygECugQAygEJuwQAywECvAQAywEJvQQAzAECvgQAzAEJ" + + "vwQAzQECwAQAzQEJwQQAzgEJwgQAzwECwwQAzwEJxAQA0AECxQQA0AEJxgQA0QECxwQA0QEJyAQA0gECyQQA0gEJygQA0wECywQA" + + "0wEJzAQA1AECzQQA1AEJzgQA1QECzwQA1QEC0AQA1QEJ0QQA1gEC0gQA1gEJ0wQA1wEC1AQA1wEJ1QQA2AEC1gQA2AEJ1wQA2QEC" + + "2AQA2QEJ2QQA2gEC2gQA2gEJ2wQA2wEC3AQA2wEJ3QQA3AEC3gQA3AEJ3wQA3QEC4AQA3QEJ4QQA3gEC4gQA3gEJ4wQA3wEC5AQA" + + "3wEJ5QQA4AEC5gQA4AEJ5wQA4QEC6AQA4QEJ6QQA4gEC6gQA4gEJ6wQA4wEC7AQA4wEJ7QQA5AEC7gQA5AEJ7wQA5QEC8AQA5QEJ" + + "8QQA5gEC8gQA5gEJ8wQA5wEC9AQA5wEJ9QQA6AEC9gQA6AEJ9wQA6QEC+AQA6QEJ+QQA6gEC+gQA6gEJ+wQA6wEC/AQA6wEJ/QQA" + + "7AEC/gQA7AEJ/wQA7QECAAUA7QEJAQUA7gECAgUA7gEJAwUA7wECBAUA7wEJBQUA8AECBgUA8AEJBwUA8QECCAUA8QEJCQUA8gEC" + + "CgUA8gEJCwUA8wECDAUA8wEJDQUA9AECDgUA9AEJDwUA9QECEAUA9QEJEQUA9gECEgUA9gEJEwUA9wECFAUA9wEJFQUA+AECFgUA" + + "+AEJFwUA+QECGAUA+QEJGQUA+gECGgUA+gEJGwUA+wECHAUA+wEJHQUA/AECHgUA/AEJHwUA/QECIAUA/QEJIQUA/gECIgUA/gEJ" + + "IwUA/wECJAUA/wEJJQUAAAICJgUAAAIJJwUAAQICKAUAAQIJKQUAAgICKgUAAgIJKwUAAwICLAUAAwIJLQUABAICLgUABAIJLwUA" + + "BQICMAUABQIDMQUABQIJMgUABgIJMwUABwIJNAUACAIJNQUACQIJNgUACgIJNwUACwIJOAUADAIJOQUADQIJOgUADgIJOwUADwIJ" + + "PAUAEAIJPQUAEQIJPgUAEgIJPwUAEwIJQAUAFAIJQQUAFQIJQgUAFgIJQwUAFwIJRAUAGAIJRQUAGQIJRgUAGgIJRwUAGwIJSAUA" + + "HAIJSQUAHQIJSgUAHgIJSwUAHwIJTAUAIAIJTQUAIQIJTgUAIgIJTwUAIwIJUAUAJAIJUQUAJQIJUgUAJgIJUwUAJwIJVAUAKAIJ" + + "VQUAKQIJVgUAKgIJWAUAKwIDWQUAKwICXwUAKwICYAUAKwIChgUAKwIChwUAKwIRiAUALQICiQUALQICigUALQICjAUALQIDjgUA" + + "LQICjwUALQICkAUALQIDoQUALQICogUALQICrwUALQICuQUALQICugUALQICvQUALQICvgUALQICvwUALQICwAUALQICwgUALQIC" + + "wwUALQICxAUALQICxQUALQICxgUALQICxwUALQICzwUALQID6gUALQIC7gUALQID7wUALQIC9AUALQIC/wUALQIDAwYALQIDBAYA" + + "LQIDBQYALQIDCgYALQICCwYALQICDAYALQICDwYALQICFQYALQICGgYALQICGwYALQICHAYALQIDHQYALQICHgYALQICHwYALQIC" + + "IAYALQICOgYALQICPwYALQICQAYALQICUgYALQICVQYALQICWAYALQICXgYALQICXwYALQICaQYALQICbQYALQICbwYALQICdAYA" + + "LQICdQYALQIRdgYALwIRdwYAMQIReAYAMwIRtwYANQICuQYANQICvgYANQICvwYANQICzgYANQICzwYANQIC0wYANQIC1AYANQIC" + + "3AYANQIC3QYANQID3gYANQIC6AYANQIC6QYANQIC7QYANQIC7wYANQIC+QYANQIC/gYANQIC/wYANQICDQcANQICDgcANQIDDwcA" + + "NQIDLAcANQICLwcANQICSgcANQICTAcANQIDTwcANQICbQcANQICfwcANQICsAcANQICsQcANQICvwcANQID9QcANQIC+gcANQIC" + + "/AcANQID/QcANQIC/wcANQICLQgANQICLwgANQIDPggANQICPwgANQIDWwgANQICXQgANQIDXggANQICXwgANQIDaggANQICbwgA" + + "NQIDhwgANQICiAgANQICjggANQICjwgANQICkQgANQIDlggANQIDlwgANQICnwgANQICoAgANQICoQgANQICrAgANQICsggANQIC" + + "tAgANQICtQgANQICvQgANQICxwgANQIC0ggANQIC0wgANQIC4QgANQIC4ggANQID4wgANQIC/ggANQIC/wgANQICAAkANQICAwkA" + + "NQICBAkANQICOQkANQICOwkANQICTQkANQICTgkANQICTwkANQICVAkANQICVQkANQICVwkANQICWAkANQIRWQkANwIRWgkAOQIR" + + "WwkAOwIRXAkAPQIRXQkAPwIRXgkAQQIRXwkAQwIRYwkARQICZQkARQICbwkARQICcAkARQICcgkARQICdwkARQICeAkARQICegkA" + + "RQICfAkARQICfQkARQICfwkARQICgAkARQICgwkARQIChAkARQIDjAkARQICjgkARQIDkAkARQICkgkARQIDqAkARQICqQkARQID" + + "sAkARQICsQkARQIDsgkARQICtQkARQIDuQkARQICuwkARQIDvAkARQICvQkARQICxAkARQICxgkARQIDyAkARQICygkARQIDzQkA" + + "RQICzgkARQIC1gkARQID1wkARQIC2wkARQID3AkARQIR3QkARwIR3gkASQID3wkASQIR4wkASwIC5QkASwID8QkASwIC+gkASwIC" + + "+wkASwIC/AkASwIC/QkASwIC/gkASwICAAoASwIDAQoASwICAgoASwICAwoASwICBAoASwIDCgoASwICDgoASwIDEAoASwICEgoA" + + "SwIDKAoASwICKQoASwIDMAoASwICMQoASwIDMgoASwICMwoASwIRNAoATQIDNQoATQICNgoATQIRNwoATwIDOQoATwICOwoATwID" + + "PAoATwICPQoATwIDQgoATwICRgoATwIDSAoATwICSgoATwIDTQoATwICUAoATwIDUQoATwICWAoATwIDWQoATwIRWgoAUQIRWwoA" + + "UwIRXAoAVQICXQoAVQIDXgoAVQIRZQoAVwIDdAoAVwICdQoAVwICdgoAVwICgAoAVwIDgwoAVwIChAoAVwIDiwoAVwICjAoAVwIC" + + "jQoAVwICjgoAVwIDkQoAVwICkgoAVwIDqAoAVwICqQoAVwIDsAoAVwICsQoAVwIDswoAVwICtAoAVwIDuQoAVwICuwoAVwIDxQoA" + + "VwICxgoAVwIDyQoAVwICygoAVwIDzQoAVwICzwoAVwID0AoAVwIC3woAVwID4AoAVwIC4woAVwIC5QoAVwID7woAVwIC8AoAVwIC" + + "8QoAVwIC+AoAVwID+QoAVwIC/woAVwICAAsAVwIDAwsAVwICBAsAVwIDDAsAVwICDgsAVwIDEAsAVwICEgsAVwIDKAsAVwICKQsA" + + "VwIDMAsAVwICMQsAVwIDMwsAVwICNAsAVwIDNQsAVwICOQsAVwICOwsAVwIDQwsAVwICRAsAVwICRgsAVwIDSAsAVwICSgsAVwID" + + "TQsAVwICVAsAVwIDVQsAVwICVwsAVwICWwsAVwIDXAsAVwIRXQsAWQIRXgsAWwIDYQsAWwICYwsAWwICZQsAWwIDbwsAWwICcAsA" + + "WwICcQsAWwICdwsAWwICgQsAWwIDgwsAWwIChAsAWwIDigsAWwICjQsAWwIDkAsAWwICkQsAWwIDlQsAWwICmAsAWwIDmgsAWwIC" + + "mwsAWwIDnAsAWwICnQsAWwIDnwsAWwICogsAWwIDpAsAWwICpwsAWwIDqgsAWwICrQsAWwIDtQsAWwICtgsAWwICuQsAWwICvQsA" + + "WwIDwgsAWwICxQsAWwIDyAsAWwICyQsAWwIDzQsAWwICzwsAWwID0AsAWwIC1gsAWwID1wsAWwIC5QsAWwID5gsAWwIC7wsAWwIC" + + "8gsAWwIC+gsAWwIC/wsAWwIDAAwAWwICAwwAWwICBAwAWwICDAwAWwICDQwAWwIDEAwAWwICEQwAWwIDKAwAWwICKQwAWwIDMwwA" + + "WwICNAwAWwICOQwAWwICOwwAWwIDPAwAWwICPQwAWwICRAwAWwICRQwAWwIDSAwAWwICSQwAWwIDTQwAWwICVAwAWwIDVgwAWwIC" + + "VwwAWwIDWQwAWwICWgwAWwICWwwAWwIDXAwAWwICXQwAWwICXwwAWwIDYQwAWwICYwwAWwICZQwAWwIDbwwAWwICdgwAWwIDdwwA" + + "WwICfwwAWwICgAwAWwICgQwAWwICgwwAWwIChAwAWwICjAwAWwICjQwAWwIDkAwAWwICkQwAWwIDqAwAWwICqQwAWwIDswwAWwIC" + + "tAwAWwIDuQwAWwICuwwAWwIDvQwAWwICxAwAWwICxQwAWwIDyAwAWwICyQwAWwIDzQwAWwIC1AwAWwID1gwAWwIC2wwAWwID3AwA" + + "WwIC3QwAWwIC3gwAWwIC3wwAWwID4QwAWwIC4wwAWwIC5QwAWwID7wwAWwIC8AwAWwID8gwAWwIC8wwAWwIC/wwAWwIDAA0AWwIC" + + "AQ0AWwICAw0AWwICBA0AWwICDA0AWwICDQ0AWwIDEA0AWwICEQ0AWwIDKA0AWwICKQ0AWwICOQ0AWwICOg0AWwICPA0AWwICPQ0A" + + "WwICQw0AWwICRA0AWwICRQ0AWwIDSA0AWwICSQ0AWwIDTQ0AWwICTg0AWwICTw0AWwICUw0AWwIDVg0AWwICVw0AWwICXg0AWwIC" + + "Xw0AWwICYQ0AWwICYw0AWwICZQ0AWwIDbw0AWwICdQ0AWwICeA0AWwICeQ0AWwICfw0AWwICgA0AWwIDgQ0AWwICgw0AWwIChA0A" + + "WwIDlg0AWwICmQ0AWwIDsQ0AWwICsg0AWwIDuw0AWwICvA0AWwIDvQ0AWwICvw0AWwIDxg0AWwICyQ0AWwIDyg0AWwICzg0AWwID" + + "1A0AWwIC1Q0AWwID1g0AWwIC1w0AWwID3w0AWwIC5Q0AWwID7w0AWwIC8Q0AWwID8w0AWwIC9A0AWwICAA4AWwIDMg4AWwICMw4A" + + "WwIROg4AXQICPg4AXQIDPw4AXQICTg4AXQICTw4AXQICWQ4AXQICWw4AXQICgA4AXQIDgg4AXQICgw4AXQIDhA4AXQIChQ4AXQID" + + "hg4AXQICiA4AXQICiQ4AXQICig4AXQICiw4AXQIDjA4AXQICjQ4AXQICkw4AXQIClw4AXQICmA4AXQICnw4AXQICoA4AXQICow4A" + + "XQICpA4AXQIDpQ4AXQICpg4AXQIDpw4AXQICqQ4AXQICqw4AXQICrA4AXQICsg4AXQICsw4AXQIRuQ4AXwICug4AXwICvQ4AXwIC" + + "vw4AXwIDxA4AXwICxQ4AXwIDxg4AXwICxw4AXwIDzQ4AXwICzg4AXwICzw4AXwID2Q4AXwIC2w4AXwID3A4AXwIR3Q4AYQIR3w4A" + + "YwIC/w4AYwIDAA8AYwICCg8AYwICCw8AYwICDA8AYwIJFw8AZAICGQ8AZAICHw8AZAICKQ8AZAICNA8AZAICNQ8AZAICNg8AZAIC" + + "Nw8AZAICOA8AZAICOQ8AZAICPQ8AZAICQg8AZAICQw8AZAIRRw8AZgICSA8AZgIDTA8AZgICTQ8AZgIRUQ8AaAICUg8AaAIRVg8A" + + "agICVw8AagIRWw8AbAICXA8AbAIRaA8AbgICaQ8AbgIRag8AcAICbA8AcAICcA8AcAIDcg8AcAICcw8AcAIRdA8AcgICdQ8AcgIR" + + "dg8AdAIRdw8AdgIZeA8AeQIReQ8AewIZgA8AfgICgQ8AfgIRhA8AgAIChQ8AgAICiw8AgAICjw8AgAICkg8AgAICkw8AgAIRlQ8A" + + "ggIClg8AggIClw8AggICmA8AggIDnA8AggICnQ8AggIRoQ8AhAICog8AhAIRpg8AhgICpw8AhgIRqw8AiAICrA8AiAIRrQ8AigIC" + + "sA8AigICtw8AigICuA8AigICuQ8AigIRvA8AjAICvQ8AjAIDxQ8AjAICxg8AjAICzA8AjAICzQ8AjAIDzg8AjAICzw8AjAIC0Q8A" + + "jAIC1A8AjAIC2A8AjAIC2g8AjAIC/w8AjAIDIRAAjAICIhAAjAICJxAAjAICKBAAjAICKhAAjAICKxAAjAICMhAAjAICNRAAjAIC" + + "ORAAjAICPxAAjAICSRAAjAICTxAAjAICWRAAjAICmRAAjAICnRAAjAICnxAAjAICoBAAjAIJoRAAjQIJohAAjgIJoxAAjwIJpBAA" + + "kAIJpRAAkQIJphAAkgIJpxAAkwIJqBAAlAIJqRAAlQIJqhAAlgIJqxAAlwIJrBAAmAIJrRAAmQIJrhAAmgIJrxAAmwIJsBAAnAIJ" + + "sRAAnQIJshAAngIJsxAAnwIJtBAAoAIJtRAAoQIJthAAogIJtxAAowIJuBAApAIJuRAApQIJuhAApgIJuxAApwIJvBAAqAIJvRAA" + + "qQIJvhAAqgIJvxAAqwIJwBAArAIJwRAArQIJwhAArgIJwxAArwIJxBAAsAIJxRAAsQIJxhAAsgIDxxAAsgIJzBAAswIDzRAAswIJ" + + "zxAAtAID9hAAtAIC+BAAtAIC+hAAtAIC+xAAtAIC/BAAtAIJ/xAAtQICWREAtQICXhEAtQICYBEAtQIHohEAtQICpxEAtQIC+REA" + + "tQIC/xEAtQICBhIAtQICBxIAtQICRhIAtQICRxIAtQICSBIAtQICSRIAtQIDTRIAtQICTxIAtQIDVhIAtQICVxIAtQIDWBIAtQIC" + + "WRIAtQIDXRIAtQICXxIAtQIDhhIAtQIChxIAtQICiBIAtQICiRIAtQIDjRIAtQICjxIAtQIDrhIAtQICrxIAtQICsBIAtQICsRIA" + + "tQIDtRIAtQICtxIAtQIDvhIAtQICvxIAtQIDwBIAtQICwRIAtQIDxRIAtQICxxIAtQIDzhIAtQICzxIAtQIC1hIAtQIC1xIAtQID" + + "7hIAtQIC7xIAtQICDhMAtQICDxMAtQICEBMAtQICERMAtQIDFRMAtQICFxMAtQIDHhMAtQICHxMAtQICRhMAtQICRxMAtQICWhMA" + + "tQICXBMAtQIDXhMAtQICXxMAtQICYBMAtQICfBMAtQICfxMAtQIDjxMAtQICmRMAtQICnxMAtQID9BMAtQIC9RMAtQIC9xMAtQID" + + "+BMAtQIJ+RMAtgIJ+hMAtwIJ+xMAuAIJ/BMAuQIJ/RMAugIJ/xMAuwIDABQAuwICbBYAuwICbhYAuwICdhYAuwICfxYAuwICgBYA" + + "uwIDmhYAuwICnBYAuwICnxYAuwID6hYAuwIC8BYAuwIC+BYAuwIC/xYAuwIDDBcAuwICDRcAuwICFBcAuwICFRcAuwICHhcAuwID" + + "HxcAuwICNBcAuwICNhcAuwICPxcAuwIDUxcAuwICXxcAuwIDbBcAuwICbRcAuwIDcBcAuwICcRcAuwIDcxcAuwICfxcAuwIDsxcA" + + "uwICtRcAuwIH0xcAuwIC1hcAuwIC1xcAuwIC2xcAuwIC3BcAuwIC3RcAuwIC3xcAuwID6RcAuwIC7xcAuwID+RcAuwIC/xcAuwID" + + "ChgAuwICDhgAuwIHDxgAuwIHGRgAuwICHxgAuwIDdxgAuwICeBgAuwICfxgAuwIDqRgAuwICqhgAuwICrxgAuwID9RgAuwIC/xgA" + + "uwIDHBkAuwICHhkAuwICHxkAuwIDKxkAuwICLxkAuwIDOxkAuwICPxkAuwIDQBkAuwICQxkAuwIDRRkAuwICbRkAuwICbxkAuwID" + + "dBkAuwICfxkAuwIDqRkAuwICqxkAuwICrxkAuwIDyRkAuwICzxkAuwID2RkAuwIC2hkAuwIC3RkAuwID3xkAuwIC/xkAuwICGxoA" + + "uwICHRoAuwIDHxoAuwICXhoAuwICXxoAuwIDfBoAuwICfhoAuwIDiRoAuwICjxoAuwIDmRoAuwICnxoAuwIDphoAuwICpxoAuwIC" + + "rRoAuwICrxoAuwIDvRoAuwICvhoAuwICwBoAuwICzhoAuwIC3RoAuwIC3xoAuwID6xoAuwIC/xoAuwIDSxsAuwICTBsAuwICTRsA" + + "uwIDTxsAuwICWRsAuwICahsAuwICcxsAuwICfBsAuwICfhsAuwICfxsAuwICqhsAuwICrRsAuwICuRsAuwICvxsAuwIC8xsAuwIC" + + "+xsAuwID/xsAuwICNxwAuwICOhwAuwIDPxwAuwICSRwAuwICTBwAuwIDfRwAuwICfxwAuwICgBwAuwIJgRwAvAIJghwAvQIJgxwA" + + "vgIJhRwAvwIJhhwAwAIJhxwAwQIJiBwAwgIJiRwAwwIJihwAxAICjxwAxAIDkBwAxAIJkRwAxQIJkhwAxgIJkxwAxwIJlBwAyAIJ" + + "lRwAyQIJlhwAygIJlxwAywIJmBwAzAIJmRwAzQIJmhwAzgIJmxwAzwIJnBwA0AIJnRwA0QIJnhwA0gIJnxwA0wIJoBwA1AIJoRwA" + + "1QIJohwA1gIJoxwA1wIJpBwA2AIJpRwA2QIJphwA2gIJpxwA2wIJqBwA3AIJqRwA3QIJqhwA3gIJqxwA3wIJrBwA4AIJrRwA4QIJ" + + "rhwA4gIJrxwA4wIJsBwA5AIJsRwA5QIJshwA5gIJsxwA5wIJtBwA6AIJtRwA6QIJthwA6gIJtxwA6wIJuBwA7AIJuRwA7QIJuhwA" + + "7gIJvBwA7wIDvRwA7wIJvhwA8AIJvxwA8QIJxxwA8gICzxwA8gID0hwA8gIC0xwA8gIC8hwA8gIC9hwA8gIC9xwA8gIC+RwA8gIC" + + "+hwA8gIC/xwA8gIDKx0A8gICLB0A8gIJLR0A8wIJLh0A9AIJLx0A9QICMB0A9QIJMR0A9gIJMh0A9wIJMx0A+AIJNB0A+QIJNR0A" + + "+gIJNh0A+wIJNx0A/AIJOB0A/QIJOR0A/gIJOh0A/wIJOx0AAAMCPB0AAAMJPR0AAQMJPh0AAgMJPx0AAwMJQB0ABAMJQR0ABQMJ" + + "Qh0ABgMJQx0ABwMJRB0ACAMJRR0ACQMJRh0ACgMJRx0ACwMJSB0ADAMJSR0ADQMJSh0ADgMJSx0ADwMJTB0AEAMJTR0AEQMJTh0A" + + "EgMCTx0AEgMJUB0AEwMJUR0AFAMJUh0AFQMJUx0AFgMJVB0AFwMJVR0AGAMJVh0AGQMJVx0AGgMJWB0AGwMJWR0AHAMJWh0AHQMJ" + + "Wx0AHgMJXB0AHwMJXR0AIAMJXh0AIQMJXx0AIgMJYB0AIwMJYR0AJAMJYh0AJQMJYx0AJgMJZB0AJwMJZR0AKAMJZh0AKQMJZx0A" + + "KgMJaB0AKwMJaR0ALAMJah0ALQMJax0ALgMCdx0ALgMCeB0ALgMJmh0ALwMCmx0ALwMJnB0AMAMJnR0AMQMJnh0AMgMJnx0AMwMJ" + + "oB0ANAMJoR0ANQMJoh0ANgMJox0ANwMJpB0AOAMJpR0AOQMJph0AOgMJpx0AOwMJqB0APAMJqR0APQMJqh0APgMJqx0APwMJrB0A" + + "QAMJrR0AQQMJrh0AQgMJrx0AQwMJsB0ARAMJsR0ARQMJsh0ARgMJsx0ARwMJtB0ASAMJtR0ASQMJth0ASgMJtx0ASwMJuB0ATAMJ" + + "uR0ATQMJuh0ATgMJux0ATwMJvB0AUAMJvR0AUQMJvh0AUgMJvx0AUwMJwx0AVAMCyh0AVAMC5h0AVAMC9R0AVAMC+R0AVAMC+h0A" + + "VAMC+x0AVAMC/B0AVAMC/R0AVAMC/x0AVAMCAB4AVAMJAR4AVQMCAh4AVQMJAx4AVgMCBB4AVgMJBR4AVwMCBh4AVwMJBx4AWAMC" + + "CB4AWAMJCR4AWQMCCh4AWQMJCx4AWgMCDB4AWgMJDR4AWwMCDh4AWwMJDx4AXAMCEB4AXAMJER4AXQMCEh4AXQMJEx4AXgMCFB4A" + + "XgMJFR4AXwMCFh4AXwMJFx4AYAMCGB4AYAMJGR4AYQMCGh4AYQMJGx4AYgMCHB4AYgMJHR4AYwMCHh4AYwMJHx4AZAMCIB4AZAMJ" + + "IR4AZQMCIh4AZQMJIx4AZgMCJB4AZgMJJR4AZwMCJh4AZwMJJx4AaAMCKB4AaAMJKR4AaQMCKh4AaQMJKx4AagMCLB4AagMJLR4A" + + "awMCLh4AawMJLx4AbAMCMB4AbAMJMR4AbQMCMh4AbQMJMx4AbgMCNB4AbgMJNR4AbwMCNh4AbwMJNx4AcAMCOB4AcAMJOR4AcQMC" + + "Oh4AcQMJOx4AcgMCPB4AcgMJPR4AcwMCPh4AcwMJPx4AdAMCQB4AdAMJQR4AdQMCQh4AdQMJQx4AdgMCRB4AdgMJRR4AdwMCRh4A" + + "dwMJRx4AeAMCSB4AeAMJSR4AeQMCSh4AeQMJSx4AegMCTB4AegMJTR4AewMCTh4AewMJTx4AfAMCUB4AfAMJUR4AfQMCUh4AfQMJ" + + "Ux4AfgMCVB4AfgMJVR4AfwMCVh4AfwMJVx4AgAMCWB4AgAMJWR4AgQMCWh4AgQMJWx4AggMCXB4AggMJXR4AgwMCXh4AgwMJXx4A" + + "hAMCYB4AhAMJYR4AhQMCYh4AhQMJYx4AhgMCZB4AhgMJZR4AhwMCZh4AhwMJZx4AiAMCaB4AiAMJaR4AiQMCah4AiQMJax4AigMC" + + "bB4AigMJbR4AiwMCbh4AiwMJbx4AjAMCcB4AjAMJcR4AjQMCch4AjQMJcx4AjgMCdB4AjgMJdR4AjwMCdh4AjwMJdx4AkAMCeB4A" + + "kAMJeR4AkQMCeh4AkQMJex4AkgMCfB4AkgMJfR4AkwMCfh4AkwMJfx4AlAMCgB4AlAMJgR4AlQMCgh4AlQMJgx4AlgMChB4AlgMJ" + + "hR4AlwMChh4AlwMJhx4AmAMCiB4AmAMJiR4AmQMCih4AmQMJix4AmgMCjB4AmgMJjR4AmwMCjh4AmwMJjx4AnAMCkB4AnAMJkR4A" + + "nQMCkh4AnQMJkx4AngMClB4AngMJmR4AnwMCmh4AnwMRmx4AoQMJnR4AogMCnh4AogMJnx4AowMCoB4AowMJoR4ApAMCoh4ApAMJ" + + "ox4ApQMCpB4ApQMJpR4ApgMCph4ApgMJpx4ApwMCqB4ApwMJqR4AqAMCqh4AqAMJqx4AqQMCrB4AqQMJrR4AqgMCrh4AqgMJrx4A" + + "qwMCsB4AqwMJsR4ArAMCsh4ArAMJsx4ArQMCtB4ArQMJtR4ArgMCth4ArgMJtx4ArwMCuB4ArwMJuR4AsAMCuh4AsAMJux4AsQMC" + + "vB4AsQMJvR4AsgMCvh4AsgMJvx4AswMCwB4AswMJwR4AtAMCwh4AtAMJwx4AtQMCxB4AtQMJxR4AtgMCxh4AtgMJxx4AtwMCyB4A" + + "twMJyR4AuAMCyh4AuAMJyx4AuQMCzB4AuQMJzR4AugMCzh4AugMJzx4AuwMC0B4AuwMJ0R4AvAMC0h4AvAMJ0x4AvQMC1B4AvQMJ" + + "1R4AvgMC1h4AvgMJ1x4AvwMC2B4AvwMJ2R4AwAMC2h4AwAMJ2x4AwQMC3B4AwQMJ3R4AwgMC3h4AwgMJ3x4AwwMC4B4AwwMJ4R4A" + + "xAMC4h4AxAMJ4x4AxQMC5B4AxQMJ5R4AxgMC5h4AxgMJ5x4AxwMC6B4AxwMJ6R4AyAMC6h4AyAMJ6x4AyQMC7B4AyQMJ7R4AygMC" + + "7h4AygMJ7x4AywMC8B4AywMJ8R4AzAMC8h4AzAMJ8x4AzQMC9B4AzQMJ9R4AzgMC9h4AzgMJ9x4AzwMC+B4AzwMJ+R4A0AMC+h4A" + + "0AMJ+x4A0QMC/B4A0QMJ/R4A0gMC/h4A0gMJ/x4A0wMCBx8A0wMCCB8A0wMJCR8A1AMJCh8A1QMJCx8A1gMJDB8A1wMJDR8A2AMJ" + + "Dh8A2QMJDx8A2gMJFR8A2wMCFx8A2wMDGB8A2wMJGR8A3AMJGh8A3QMJGx8A3gMJHB8A3wMJHR8A4AMJHx8A4QMDJx8A4QMCKB8A" + + "4QMJKR8A4gMJKh8A4wMJKx8A5AMJLB8A5QMJLR8A5gMJLh8A5wMJLx8A6AMJNx8A6QMCOB8A6QMJOR8A6gMJOh8A6wMJOx8A7AMJ" + + "PB8A7QMJPR8A7gMJPh8A7wMJPx8A8AMJRR8A8QMCRx8A8QMDSB8A8QMJSR8A8gMJSh8A8wMJSx8A9AMJTB8A9QMJTR8A9gMJTx8A" + + "9wMDVx8A9wMCWB8A9wMDWR8A9wMJWh8A+AMDWx8A+AMJXB8A+QMDXR8A+QMJXh8A+gMDXx8A+gMJZx8A+wMCaB8A+wMJaR8A/AMJ" + + "ah8A/QMJax8A/gMJbB8A/wMJbR8AAAQJbh8AAQQJbx8AAgQJcB8AAwQCcR8AAwQJch8ABAQCcx8ABAQJdB8ABQQCdR8ABQQJdh8A" + + "BgQCdx8ABgQJeB8ABwQCeR8ABwQJeh8ACAQCex8ACAQJfB8ACQQCfR8ACQQJfx8ACgQDgB8ACgQRgR8ADAQRgh8ADgQRgx8AEAQR" + + "hB8AEgQRhR8AFAQRhh8AFgQRhx8AGAQRiB8AGgQRiR8AHAQRih8AHgQRix8AIAQRjB8AIgQRjR8AJAQRjh8AJgQRjx8AKAQRkB8A" + + "KgQRkR8ALAQRkh8ALgQRkx8AMAQRlB8AMgQRlR8ANAQRlh8ANgQRlx8AOAQRmB8AOgQRmR8APAQRmh8APgQRmx8AQAQRnB8AQgQR" + + "nR8ARAQRnh8ARgQRnx8ASAQRoB8ASgQRoR8ATAQRoh8ATgQRox8AUAQRpB8AUgQRpR8AVAQRph8AVgQRpx8AWAQRqB8AWgQRqR8A" + + "XAQRqh8AXgQRqx8AYAQRrB8AYgQRrR8AZAQRrh8AZgQRrx8AaAQRsR8AagQCsh8AagQRsx8AbAQRtB8AbgQRtR8AcAQDth8AcAQC" + + "tx8AcAQRuB8AcgQJuR8AcwQJuh8AdAQJux8AdQQJvB8AdgQRvR8AeAQRvh8AegQJvx8AewQRwB8AfQQRwR8AfwQZwh8AggQRwx8A" + + "hAQRxB8AhgQRxR8AiAQDxh8AiAQCxx8AiAQRyB8AigQJyR8AiwQJyh8AjAQJyx8AjQQJzB8AjgQRzR8AkAQZzh8AkwQZzx8AlgQZ" + + "0h8AmQQC0x8AmQQJ1R8AmgQD1x8AmgQC2B8AmgQJ2R8AmwQJ2h8AnAQJ2x8AnQQJ3B8AngQD3R8AngQZ3h8AoQQZ3x8ApAQZ4h8A" + + "pwQC4x8ApwQJ5x8AqAQC6B8AqAQJ6R8AqQQJ6h8AqgQJ6x8AqwQJ7B8ArAQJ7R8ArQQZ7h8AsAQZ7x8AswQJ8R8AtAQD8h8AtAQR" + + "8x8AtgQR9B8AuAQR9R8AugQD9h8AugQC9x8AugQR+B8AvAQJ+R8AvQQJ+h8AvgQJ+x8AvwQJ/B8AwAQR/R8AwgQR/h8AxAQR/x8A" + + "xgQDCiAAxgQJCyAAxwQHDSAAxwQGDyAAxwQDECAAxwQCESAAxwQJFiAAyAQCFyAAyAQRIyAAygQCJiAAygQDJyAAygQCLiAAygQD" + + "LyAAygQJMiAAywQCMyAAywQRNCAAzQQZNSAA0AQCNiAA0AQRNyAA0gQZOyAA1QQCPCAA1QQRPSAA1wQCPiAA1wQRRiAA2QQCRyAA" + + "2QQRSCAA2wQRSSAA3QQRTSAA3wQCUiAA3wQCVCAA3wQCViAA3wQCVyAA3wQhXiAA4wQCXyAA4wQJYyAA5AQHZCAA5AQHZSAA5AQD" + + "aSAA5AQDbyAA5AQHcCAA5AQJcSAA5QQJcyAA5gQDdCAA5gQJdSAA5wQJdiAA6AQJdyAA6QQJeCAA6gQJeSAA6wQJeiAA7AQJeyAA" + + "7QQJfCAA7gQJfSAA7wQJfiAA8AQJfyAA8QQJgCAA8gQJgSAA8wQJgiAA9AQJgyAA9QQJhCAA9gQJhSAA9wQJhiAA+AQJhyAA+QQJ" + + "iCAA+gQJiSAA+wQJiiAA/AQJiyAA/QQJjCAA/gQJjSAA/wQJjiAAAAUJjyAAAQUDkCAAAQUJkSAAAgUJkiAAAwUJkyAABAUJlCAA" + + "BQUJlSAABgUJliAABwUJlyAACAUJmCAACQUJmSAACgUJmiAACwUJmyAADAUJnCAADQUJnyAADgUDpyAADgUCqCAADgURqiAAEAUC" + + "qyAAEAUCrCAAEAUCryAAEAUCsSAAEAUCtSAAEAUCuCAAEAUCuSAAEAUCuiAAEAUCvSAAEAUCviAAEAUCvyAAEAUCwCAAEAUCwSAA" + + "EAUCzyAAEAUD4SAAEAUC4yAAEAUC6iAAEAUC6yAAEAUC7yAAEAUC8CAAEAUC/yAAEAUDACEAEAUZASEAEwUZAiEAFgUJAyEAFwUR" + + "BCEAGQUCBSEAGQUZBiEAHAUZByEAHwUJCCEAIAUCCSEAIAURCiEAIgUJDiEAIwUJDyEAJAUJESEAJQUJEyEAJgUJFCEAJwUCFSEA" + + "JwUJFiEAKAURGCEAKgUCGSEAKgUJGiEAKwUJHSEALAUJHyEALQUCICEALQURISEALwUZIiEAMgURIyEANAUCJCEANAUJJSEANQUC" + + "JiEANQUJJyEANgUCKCEANgUJKSEANwUCKiEANwUJKyEAOAUJLCEAOQUJLSEAOgUJLiEAOwUCMCEAOwUJMSEAPAUJMiEAPQUJMyEA" + + "PgUJNCEAPwUJNSEAQAUJNiEAQQUJNyEAQgUJOCEAQwUJOSEARAUJOiEARQUCOyEARQUZPCEASAUJPiEASQUJPyEASgUJQCEASwUJ" + + "RCEATAUCRiEATAUJRyEATQUJSCEATgUJSSEATwUJSyEAUAUCTCEAUAUCTSEAUAUCTiEAUAUCTyEAUAUCUCEAUAUZUSEAUwUZUiEA" + + "VgUhUyEAWgUZVCEAXQUZVSEAYAUZViEAYwUZVyEAZgUZWCEAaQUZWSEAbAUZWiEAbwUZWyEAcgUZXCEAdQUZXSEAeAUZXiEAewUZ" + + "XyEAfgURYCEAgAUJYSEAgQURYiEAgwUZYyEAhgURZCEAiAUJZSEAiQURZiEAiwUZZyEAjgUhaCEAkgURaSEAlAUJaiEAlQURayEA" + + "lwUZbCEAmgUJbSEAmwUJbiEAnAUJbyEAnQUJcCEAngUJcSEAnwURciEAoQUZcyEApAURdCEApgUJdSEApwURdiEAqQUZdyEArAUh" + + "eCEAsAUReSEAsgUJeiEAswUReyEAtQUZfCEAuAUJfSEAuQUJfiEAugUJfyEAuwUJgiEAvAUCgyEAvAUJhCEAvQUCiCEAvQUCiSEA" + + "vQUZiyEAwAUCjyEAwAUD6iEAwAUC8yEAwAUC/yEAwAUCKyIAwAUCLCIAwAURLSIAwgUZLiIAxQUCLyIAxQURMCIAxwUZ8SIAygUC" + + "/yIAygUCACMAygUCASMAygUCKCMAygUCKSMAygUJKiMAywUJeiMAzAUCeyMAzAUCfCMAzAUCmiMAzAUCziMAzAUC0CMAzAUC2yMA" + + "zAUC5yMAzAUC6CMAzAUC8yMAzAUC+iMAzAUC/iMAzAUC/yMAzAUCJCQAzAUCJiQAzAUCKSQAzAUCPyQAzAUDSiQAzAUCXyQAzAUD" + + "YCQAzAUJYSQAzQUJYiQAzgUJYyQAzwUJZCQA0AUJZSQA0QUJZiQA0gUJZyQA0wUJaCQA1AUJaSQA1QURaiQA1wURayQA2QURbCQA" + + "2wURbSQA3QURbiQA3wURbyQA4QURcCQA4wURcSQA5QURciQA5wURcyQA6QURdCQA6wUZdSQA7gUZdiQA8QUZdyQA9AUZeCQA9wUZ" + + "eSQA+gUZeiQA/QUZeyQAAAYZfCQAAwYZfSQABgYhfiQACgYhfyQADgYhgCQAEgYhgSQAFgYhgiQAGgYhgyQAHgYhhCQAIgYhhSQA" + + "JgYhhiQAKgYhhyQALgYhmyQAMgYDnCQAMgYZnSQANQYZniQAOAYZnyQAOwYZoCQAPgYZoSQAQQYZoiQARAYZoyQARwYZpCQASgYZ" + + "pSQATQYZpiQAUAYZpyQAUwYZqCQAVgYZqSQAWQYZqiQAXAYZqyQAXwYZrCQAYgYZrSQAZQYZriQAaAYZryQAawYZsCQAbgYZsSQA" + + "cQYZsiQAdAYZsyQAdwYZtCQAegYZtSQAfQYZtiQAgAYJtyQAgQYJuCQAggYJuSQAgwYJuiQAhAYJuyQAhQYJvCQAhgYJvSQAhwYJ" + + "viQAiAYJvyQAiQYJwCQAigYJwSQAiwYJwiQAjAYJwyQAjQYJxCQAjgYJxSQAjwYJxiQAkAYJxyQAkQYJyCQAkgYJySQAkwYJyiQA" + + "lAYJyyQAlQYJzCQAlgYJzSQAlwYJziQAmAYJzyQAmQYJ0CQAmgYJ0SQAmwYJ0iQAnAYJ0yQAnQYJ1CQAngYJ1SQAnwYJ1iQAoAYJ" + + "1yQAoQYJ2CQAogYJ2SQAowYJ2iQApAYJ2yQApQYJ3CQApgYJ3SQApwYJ3iQAqAYJ3yQAqQYJ4CQAqgYJ4SQAqwYJ4iQArAYJ4yQA" + + "rQYJ5CQArgYJ5SQArwYJ5iQAsAYJ5yQAsQYJ6CQAsgYJ6SQAswYJ6iQAtAYJ/iQAtQYC/yQAtQYClSUAtQYCnyUAtQYC7yUAtQYC" + + "9yUAtQYC/yUAtQYCEyYAtQYCFSYAtQYCFyYAtQYCGCYAtQYCGSYAtQYCbyYAtQYCcSYAtQYCfSYAtQYCfyYAtQYCiSYAtQYCkSYA" + + "tQYCnCYAtQYCnSYAtQYCnyYAtQYCoSYAtQYCsSYAtQYCsiYAtQYCvCYAtQYCvyYAtQYCwyYAtQYCzSYAtQYCziYAtQYC4SYAtQYC" + + "4iYAtQYC4yYAtQYC5yYAtQYC/yYAtQYCACcAtQYCBCcAtQYCBScAtQYCCScAtQYCCycAtQYCJycAtQYCKCcAtQYCSycAtQYCTCcA" + + "tQYCTScAtQYCTicAtQYCUicAtQYCVScAtQYCVicAtQYCVycAtQYCXicAtQYCYCcAtQYCZycAtQYCdScAtQYClCcAtQYClycAtQYC" + + "rycAtQYCsCcAtQYCvicAtQYCvycAtQYCxicAtQYCyicAtQYCyycAtQYCzCcAtQYCzScAtQYCzycAtQYC6ycAtQYC7ycAtQYC/ycA" + + "tQYC/ygAtQYCCyoAtQYCDCoAtQYhcyoAuQYCdCoAuQYZdSoAvAYRdioAvgYZ2yoAwQYC3CoAwQYR/yoAwwYCDSsAwwYCEysAwwYC" + + "GisAwwYCHysAwwYCIysAwwYCTCsAwwYCTysAwwYCVCsAwwYCWSsAwwYCcysAwwYCdSsAwwYDlSsAwwYClisAwwYClysAwwYCuSsA" + + "wwYCvCsAwwYCyCsAwwYCySsAwwYC0SsAwwYC0isAwwYC6ysAwwYC7ysAwwYC/isAwwYC/ysAwwYCACwAwwYJASwAxAYJAiwAxQYJ" + + "AywAxgYJBCwAxwYJBSwAyAYJBiwAyQYJBywAygYJCCwAywYJCSwAzAYJCiwAzQYJCywAzgYJDCwAzwYJDSwA0AYJDiwA0QYJDywA" + + "0gYJECwA0wYJESwA1AYJEiwA1QYJEywA1gYJFCwA1wYJFSwA2AYJFiwA2QYJFywA2gYJGCwA2wYJGSwA3AYJGiwA3QYJGywA3gYJ" + + "HCwA3wYJHSwA4AYJHiwA4QYJHywA4gYJICwA4wYJISwA5AYJIiwA5QYJIywA5gYJJCwA5wYJJSwA6AYJJiwA6QYJJywA6gYJKCwA" + + "6wYJKSwA7AYJKiwA7QYJKywA7gYJLCwA7wYJLSwA8AYJLiwA8QYJLywA8gYJXiwA8wYCXywA8wYCYCwA8wYJYSwA9AYCYiwA9AYJ" + + "YywA9QYJZCwA9gYJZiwA9wYCZywA9wYJaCwA+AYCaSwA+AYJaiwA+QYCaywA+QYJbCwA+gYCbSwA+gYJbiwA+wYJbywA/AYJcCwA" + + "/QYJcSwA/gYCciwA/gYJcywA/wYCdCwA/wYCdSwA/wYJdywAAAcCeywAAAcCfCwAAAcJfSwAAQcJfiwAAgcJfywAAwcJgCwABAcJ" + + "gSwABQcCgiwABQcJgywABgcChCwABgcJhSwABwcChiwABwcJhywACAcCiCwACAcJiSwACQcCiiwACQcJiywACgcCjCwACgcJjSwA" + + "CwcCjiwACwcJjywADAcCkCwADAcJkSwADQcCkiwADQcJkywADgcClCwADgcJlSwADwcCliwADwcJlywAEAcCmCwAEAcJmSwAEQcC" + + "miwAEQcJmywAEgcCnCwAEgcJnSwAEwcCniwAEwcJnywAFAcCoCwAFAcJoSwAFQcCoiwAFQcJoywAFgcCpCwAFgcJpSwAFwcCpiwA" + + "FwcJpywAGAcCqCwAGAcJqSwAGQcCqiwAGQcJqywAGgcCrCwAGgcJrSwAGwcCriwAGwcJrywAHAcCsCwAHAcJsSwAHQcCsiwAHQcJ" + + "sywAHgcCtCwAHgcJtSwAHwcCtiwAHwcJtywAIAcCuCwAIAcJuSwAIQcCuiwAIQcJuywAIgcCvCwAIgcJvSwAIwcCviwAIwcJvywA" + + "JAcCwCwAJAcJwSwAJQcCwiwAJQcJwywAJgcCxCwAJgcJxSwAJwcCxiwAJwcJxywAKAcCyCwAKAcJySwAKQcCyiwAKQcJyywAKgcC" + + "zCwAKgcJzSwAKwcCziwAKwcJzywALAcC0CwALAcJ0SwALQcC0iwALQcJ0ywALgcC1CwALgcJ1SwALwcC1iwALwcJ1ywAMAcC2CwA" + + "MAcJ2SwAMQcC2iwAMQcJ2ywAMgcC3CwAMgcJ3SwAMwcC3iwAMwcJ3ywANAcC4CwANAcJ4SwANQcC4iwANQcJ5CwANgcC6iwANgcC" + + "6ywANgcJ7CwANwcC7SwANwcJ8SwAOAcC8iwAOAcJ8ywAOQcC+CwAOQcD/ywAOQcCJS0AOQcCJi0AOQcDJy0AOQcCLC0AOQcDLS0A" + + "OQcCLy0AOQcDZS0AOQcCZy0AOQcCbi0AOQcDby0AOQcJcC0AOgcCfi0AOgcDfy0AOgcCli0AOgcCny0AOgcDpi0AOgcCpy0AOgcD" + + "ri0AOgcCry0AOgcDti0AOgcCty0AOgcDvi0AOgcCvy0AOgcDxi0AOgcCxy0AOgcDzi0AOgcCzy0AOgcD1i0AOgcC1y0AOgcD3i0A" + + "OgcC3y0AOgcD/y0AOgcCFy4AOgcCGy4AOgcCHS4AOgcCLi4AOgcCLy4AOgcCMC4AOgcCMS4AOgcCOy4AOgcCQi4AOgcCRC4AOgcC" + + "SS4AOgcCTi4AOgcCTy4AOgcCUi4AOgcCXS4AOgcCfy4AOgcDmS4AOgcCmi4AOgcDni4AOgcCny4AOgcJ8i4AOwcC8y4AOwcJ/y4A" + + "PAcDAC8APAcJAS8APQcJAi8APgcJAy8APwcJBC8AQAcJBS8AQQcJBi8AQgcJBy8AQwcJCC8ARAcJCS8ARQcJCi8ARgcJCy8ARwcJ" + + "DC8ASAcJDS8ASQcJDi8ASgcJDy8ASwcJEC8ATAcJES8ATQcJEi8ATgcJEy8ATwcJFC8AUAcJFS8AUQcJFi8AUgcJFy8AUwcJGC8A" + + "VAcJGS8AVQcJGi8AVgcJGy8AVwcJHC8AWAcJHS8AWQcJHi8AWgcJHy8AWwcJIC8AXAcJIS8AXQcJIi8AXgcJIy8AXwcJJC8AYAcJ" + + "JS8AYQcJJi8AYgcJJy8AYwcJKC8AZAcJKS8AZQcJKi8AZgcJKy8AZwcJLC8AaAcJLS8AaQcJLi8AagcJLy8AawcJMC8AbAcJMS8A" + + "bQcJMi8AbgcJMy8AbwcJNC8AcAcJNS8AcQcJNi8AcgcJNy8AcwcJOC8AdAcJOS8AdQcJOi8AdgcJOy8AdwcJPC8AeAcJPS8AeQcJ" + + "Pi8AegcJPy8AewcJQC8AfAcJQS8AfQcJQi8AfgcJQy8AfwcJRC8AgAcJRS8AgQcJRi8AggcJRy8AgwcJSC8AhAcJSS8AhQcJSi8A" + + "hgcJSy8AhwcJTC8AiAcJTS8AiQcJTi8AigcJTy8AiwcJUC8AjAcJUS8AjQcJUi8AjgcJUy8AjwcJVC8AkAcJVS8AkQcJVi8AkgcJ" + + "Vy8AkwcJWC8AlAcJWS8AlQcJWi8AlgcJWy8AlwcJXC8AmAcJXS8AmQcJXi8AmgcJXy8AmwcJYC8AnAcJYS8AnQcJYi8AngcJYy8A" + + "nwcJZC8AoAcJZS8AoQcJZi8AogcJZy8AowcJaC8ApAcJaS8ApQcJai8ApgcJay8ApwcJbC8AqAcJbS8AqQcJbi8AqgcJby8AqwcJ" + + "cC8ArAcJcS8ArQcJci8ArgcJcy8ArwcJdC8AsAcJdS8AsQcJdi8AsgcJdy8AswcJeC8AtAcJeS8AtQcJei8AtgcJey8AtwcJfC8A" + + "uAcJfS8AuQcJfi8AugcJfy8AuwcJgC8AvAcJgS8AvQcJgi8AvgcJgy8AvwcJhC8AwAcJhS8AwQcJhi8AwgcJhy8AwwcJiC8AxAcJ" + + "iS8AxQcJii8AxgcJiy8AxwcJjC8AyAcJjS8AyQcJji8AygcJjy8AywcJkC8AzAcJkS8AzQcJki8AzgcJky8AzwcJlC8A0AcJlS8A" + + "0QcJli8A0gcJly8A0wcJmC8A1AcJmS8A1QcJmi8A1gcJmy8A1wcJnC8A2AcJnS8A2QcJni8A2gcJny8A2wcJoC8A3AcJoS8A3QcJ" + + "oi8A3gcJoy8A3wcJpC8A4AcJpS8A4QcJpi8A4gcJpy8A4wcJqC8A5AcJqS8A5QcJqi8A5gcJqy8A5wcJrC8A6AcJrS8A6QcJri8A" + + "6gcJry8A6wcJsC8A7AcJsS8A7QcJsi8A7gcJsy8A7wcJtC8A8AcJtS8A8QcJti8A8gcJty8A8wcJuC8A9AcJuS8A9QcJui8A9gcJ" + + "uy8A9wcJvC8A+AcJvS8A+QcJvi8A+gcJvy8A+wcJwC8A/AcJwS8A/QcJwi8A/gcJwy8A/wcJxC8AAAgJxS8AAQgJxi8AAggJxy8A" + + "AwgJyC8ABAgJyS8ABQgJyi8ABggJyy8ABwgJzC8ACAgJzS8ACQgJzi8ACggJzy8ACwgJ0C8ADAgJ0S8ADQgJ0i8ADggJ0y8ADwgJ" + + "1C8AEAgJ1S8AEQgJ7y8AEggD+y8AEggD/y8AEggDADAAEggJATAAEwgCAjAAEwgJBDAAFAgCBzAAFAgCKTAAFAgCLTAAFAgCNTAA" + + "FAgCNjAAFAgJNzAAFQgCODAAFQgJOTAAFggJOjAAFwgJOzAAGAgCPDAAGAgCPTAAGAgCPjAAGAgCPzAAGAgCQDAAGAgDlDAAGAgC" + + "ljAAGAgCmDAAGAgDmjAAGAgCmzAAGAgRnDAAGggRnjAAHAgCnzAAHAgRoDAAHggC/jAAHggC/zAAHggRBDEAIAgDLDEAIAgCLTEA" + + "IAgCLjEAIAgCLzEAIAgCMDEAIAgDMTEAIAgJMjEAIQgJMzEAIggJNDEAIwgJNTEAJAgJNjEAJQgJNzEAJggJODEAJwgJOTEAKAgJ" + + "OjEAKQgJOzEAKggJPDEAKwgJPTEALAgJPjEALQgJPzEALggJQDEALwgJQTEAMAgJQjEAMQgJQzEAMggJRDEAMwgJRTEANAgJRjEA" + + "NQgJRzEANggJSDEANwgJSTEAOAgJSjEAOQgJSzEAOggJTDEAOwgJTTEAPAgJTjEAPQgJTzEAPggJUDEAPwgJUTEAQAgJUjEAQQgJ" + + "UzEAQggJVDEAQwgJVTEARAgJVjEARQgJVzEARggJWDEARwgJWTEASAgJWjEASQgJWzEASggJXDEASwgJXTEATAgJXjEATQgJXzEA" + + "TggJYDEATwgJYTEAUAgJYjEAUQgJYzEAUggJZDEAUwgHZTEAUwgJZjEAVAgJZzEAVQgJaDEAVggJaTEAVwgJajEAWAgJazEAWQgJ" + + "bDEAWggJbTEAWwgJbjEAXAgJbzEAXQgJcDEAXggJcTEAXwgJcjEAYAgJczEAYQgJdDEAYggJdTEAYwgJdjEAZAgJdzEAZQgJeDEA" + + "ZggJeTEAZwgJejEAaAgJezEAaQgJfDEAaggJfTEAawgJfjEAbAgJfzEAbQgJgDEAbggJgTEAbwgJgjEAcAgJgzEAcQgJhDEAcggJ" + + "hTEAcwgJhjEAdAgJhzEAdQgJiDEAdggJiTEAdwgJijEAeAgJizEAeQgJjDEAeggJjTEAewgJjjEAfAgJjzEAfQgDkTEAfQgCkjEA" + + "fQgJkzEAfggJlDEAfwgJlTEAgAgJljEAgQgJlzEAgggJmDEAgwgJmTEAhAgJmjEAhQgJmzEAhggJnDEAhwgJnTEAiAgJnjEAiQgJ" + + "nzEAiggJtzEAiwgCujEAiwgCvzEAiwgCzzEAiwgC4zEAiwgC5TEAiwgC7jEAiwgD7zEAiwgD/zEAiwgCADIAiwgZATIAjggZAjIA" + + "kQgZAzIAlAgZBDIAlwgZBTIAmggZBjIAnQgZBzIAoAgZCDIAowgZCTIApggZCjIAqQgZCzIArAgZDDIArwgZDTIAsggZDjIAtQgZ" + + "DzIAuAgZEDIAuwgZETIAvggZEjIAwQgZEzIAxAgZFDIAxwgZFTIAyggZFjIAzQgZFzIA0AgZGDIA0wgZGTIA1ggZGjIA2QgZGzIA" + + "3AgZHDIA3wgZHTIA4gghHjIA5gghHzIA6ggDIDIA6ggZITIA7QgZIjIA8AgZIzIA8wgZJDIA9ggZJTIA+QgZJjIA/AgZJzIA/wgZ" + + "KDIAAgkZKTIABQkZKjIACAkZKzIACwkZLDIADgkZLTIAEQkZLjIAFAkZLzIAFwkZMDIAGgkZMTIAHQkZMjIAIAkZMzIAIwkZNDIA" + + "JgkZNTIAKQkZNjIALAkZNzIALwkZODIAMgkZOTIANQkZOjIAOAkZOzIAOwkZPDIAPgkZPTIAQQkZPjIARAkZPzIARwkZQDIASgkZ" + + "QTIATQkZQjIAUAkZQzIAUwkZRDIAVgkJRTIAVwkJRjIAWAkJRzIAWQkJTzIAWgkCUDIAWgkZUTIAXQkRUjIAXwkRUzIAYQkRVDIA" + + "YwkRVTIAZQkRVjIAZwkRVzIAaQkRWDIAawkRWTIAbQkRWjIAbwkRWzIAcQkRXDIAcwkRXTIAdQkRXjIAdwkRXzIAeQkRYDIAewkJ" + + "YTIAfAkJYjIAfQkJYzIAfgkJZDIAfwkJZTIAgAkJZjIAgQkJZzIAggkJaDIAgwkJaTIAhAkJajIAhQkJazIAhgkJbDIAhwkJbTIA" + + "iAkJbjIAiQkJbzIAigkJcDIAiwkJcTIAjAkJcjIAjQkJczIAjgkJdDIAjwkJdTIAkAkJdjIAkQkJdzIAkgkJeDIAkwkJeTIAlAkJ" + + "ejIAlQkJezIAlgkJfDIAlwkRfTIAmQkRfjIAmwkJfzIAnAkCgDIAnAkJgTIAnQkJgjIAngkJgzIAnwkJhDIAoAkJhTIAoQkJhjIA" + + "ogkJhzIAowkJiDIApAkJiTIApQkJijIApgkJizIApwkJjDIAqAkJjTIAqQkJjjIAqgkJjzIAqwkJkDIArAkJkTIArQkJkjIArgkJ" + + "kzIArwkJlDIAsAkJlTIAsQkJljIAsgkJlzIAswkJmDIAtAkJmTIAtQkJmjIAtgkJmzIAtwkJnDIAuAkJnTIAuQkJnjIAugkJnzIA" + + "uwkJoDIAvAkJoTIAvQkJojIAvgkJozIAvwkJpDIAwAkJpTIAwQkJpjIAwgkJpzIAwwkJqDIAxAkJqTIAxQkJqjIAxgkJqzIAxwkJ" + + "rDIAyAkJrTIAyQkJrjIAygkJrzIAywkJsDIAzAkJsTIAzQkRsjIAzwkRszIA0QkRtDIA0wkRtTIA1QkRtjIA1wkRtzIA2QkRuDIA" + + "2wkRuTIA3QkRujIA3wkRuzIA4QkRvDIA4wkRvTIA5QkRvjIA5wkRvzIA6QkRwDIA6wkRwTIA7QkRwjIA7wkRwzIA8QkRxDIA8wkR" + + "xTIA9QkRxjIA9wkRxzIA+QkRyDIA+wkRyTIA/QkZyjIAAAoZyzIAAwoZzDIABgoRzTIACAoZzjIACwoRzzIADQoZ0DIAEAoJ0TIA" + + "EQoJ0jIAEgoJ0zIAEwoJ1DIAFAoJ1TIAFQoJ1jIAFgoJ1zIAFwoJ2DIAGAoJ2TIAGQoJ2jIAGgoJ2zIAGwoJ3DIAHAoJ3TIAHQoJ" + + "3jIAHgoJ3zIAHwoJ4DIAIAoJ4TIAIQoJ4jIAIgoJ4zIAIwoJ5DIAJAoJ5TIAJQoJ5jIAJgoJ5zIAJwoJ6DIAKAoJ6TIAKQoJ6jIA" + + "KgoJ6zIAKwoJ7DIALAoJ7TIALQoJ7jIALgoJ7zIALwoJ8DIAMAoJ8TIAMQoJ8jIAMgoJ8zIAMwoJ9DIANAoJ9TIANQoJ9jIANgoJ" + + "9zIANwoJ+DIAOAoJ+TIAOQoJ+jIAOgoJ+zIAOwoJ/DIAPAoJ/TIAPQoJ/jIAPgoJ/zIAPwoRADMAQQohATMARQohAjMASQohAzMA" + + "TQoZBDMAUAohBTMAVAoZBjMAVwoZBzMAWgopCDMAXwohCTMAYwoZCjMAZgoZCzMAaQoZDDMAbAohDTMAcAohDjMAdAoZDzMAdwoZ" + + "EDMAegoRETMAfAoZEjMAfwohEzMAgwohFDMAhwoRFTMAiQopFjMAjgoxFzMAlAopGDMAmQoZGTMAnAopGjMAoQopGzMApgohHDMA" + + "qgoZHTMArQoZHjMAsAoZHzMAswohIDMAtwopITMAvAohIjMAwAoZIzMAwwoZJDMAxgoZJTMAyQoRJjMAywoRJzMAzQoRKDMAzwoR" + + "KTMA0QoZKjMA1AoZKzMA1wopLDMA3AoZLTMA3wohLjMA4wopLzMA6AoZMDMA6woRMTMA7QoRMjMA7wopMzMA9AohNDMA+AopNTMA" + + "/QoZNjMAAAspNzMABQsRODMABwsZOTMACgsZOjMADQsZOzMAEAsZPDMAEwsZPTMAFgshPjMAGgsZPzMAHQsRQDMAHwsZQTMAIgsZ" + + "QjMAJQsZQzMAKAshRDMALAsZRTMALwsZRjMAMgsZRzMANQspSDMAOgshSTMAPgsRSjMAQAspSzMARQsRTDMARwshTTMASwshTjMA" + + "TwsZTzMAUgsZUDMAVQsZUTMAWAshUjMAXAsRUzMAXgsZVDMAYQshVTMAZQsRVjMAZwspVzMAbAsZWDMAbwsRWTMAcQsRWjMAcwsR" + + "WzMAdQsRXDMAdwsRXTMAeQsRXjMAewsRXzMAfQsRYDMAfwsRYTMAgQsRYjMAgwsZYzMAhgsZZDMAiQsZZTMAjAsZZjMAjwsZZzMA" + + "kgsZaDMAlQsZaTMAmAsZajMAmwsZazMAngsZbDMAoQsZbTMApAsZbjMApwsZbzMAqgsZcDMArQsZcTMAsAsZcjMAswsRczMAtQsR" + + "dDMAtwsZdTMAugsRdjMAvAsRdzMAvgsReDMAwAsZeTMAwwsZejMAxgsRezMAyAsRfDMAygsRfTMAzAsRfjMAzgsRfzMA0AshgDMA" + + "1AsRgTMA1gsRgjMA2AsRgzMA2gsRhDMA3AsRhTMA3gsRhjMA4AsRhzMA4gsRiDMA5AsZiTMA5wshijMA6wsRizMA7QsRjDMA7wsR" + + "jTMA8QsRjjMA8wsRjzMA9QsRkDMA9wsRkTMA+QsZkjMA/AsZkzMA/wsZlDMAAgwZlTMABQwRljMABwwRlzMACQwRmDMACwwRmTMA" + + "DQwRmjMADwwRmzMAEQwRnDMAEwwRnTMAFQwRnjMAFwwRnzMAGQwZoDMAHAwZoTMAHwwRojMAIQwZozMAJAwZpDMAJwwZpTMAKgwR" + + "pjMALAwZpzMALwwZqDMAMgwhqTMANgwRqjMAOAwZqzMAOwwZrDMAPgwZrTMAQQwZrjMARAwprzMASQwxsDMATwwRsTMAUQwRsjMA" + + "UwwRszMAVQwRtDMAVwwRtTMAWQwRtjMAWwwRtzMAXQwRuDMAXwwRuTMAYQwRujMAYwwRuzMAZQwRvDMAZwwRvTMAaQwRvjMAawwR" + + "vzMAbQwRwDMAbwwRwTMAcQwRwjMAcwwDwzMAcwwRxDMAdQwRxTMAdwwRxjMAeQwhxzMAfQwDyDMAfQwRyTMAfwwRyjMAgQwRyzMA" + + "gwwRzDMAhQwRzTMAhwwRzjMAiQwRzzMAiwwR0DMAjQwR0TMAjwwR0jMAkQwZ0zMAlAwR1DMAlgwR1TMAmAwZ1jMAmwwZ1zMAngwR" + + "2DMAoAwD2TMAoAwZ2jMAowwR2zMApQwR3DMApwwR3TMAqQwR3jMAqwwZ3zMArgwZ4DMAsQwR4TMAswwR4jMAtQwR4zMAtwwR5DMA" + + "uQwR5TMAuwwR5jMAvQwR5zMAvwwR6DMAwQwR6TMAwwwZ6jMAxgwZ6zMAyQwZ7DMAzAwZ7TMAzwwZ7jMA0gwZ7zMA1QwZ8DMA2AwZ" + + "8TMA2wwZ8jMA3gwZ8zMA4QwZ9DMA5AwZ9TMA5wwZ9jMA6gwZ9zMA7QwZ+DMA8AwZ+TMA8wwZ+jMA9gwZ+zMA+QwZ/DMA/AwZ/TMA" + + "/wwZ/jMAAg0Z/zMABQ0ZtU0ACA0Cv00ACA0C/00ACA0CpZ8ACA0Cu58ACA0Cw58ACA0Cy58ACA0CzJ8ACA0C1Z8ACA0C6p8ACA0C" + + "758ACA0C/J8ACA0C/58ACA0CjKQACA0Cj6QACA0DoaQACA0Co6QACA0Cs6QACA0CtKQACA0CwKQACA0CwaQACA0CxKQACA0CxaQA" + + "CA0CxqQACA0Cz6QACA0D/aQACA0C/6QACA0CDKYACA0CD6YACA0CK6YACA0CP6YACA0DQKYACA0JQaYACQ0CQqYACQ0JQ6YACg0C" + + "RKYACg0JRaYACw0CRqYACw0JR6YADA0CSKYADA0JSaYADQ0CSqYADQ0JS6YADg0CTKYADg0JTaYADw0CTqYADw0JT6YAEA0CUKYA" + + "EA0JUaYAEQ0CUqYAEQ0JU6YAEg0CVKYAEg0JVaYAEw0CVqYAEw0JV6YAFA0CWKYAFA0JWaYAFQ0CWqYAFQ0JW6YAFg0CXKYAFg0J" + + "XaYAFw0CXqYAFw0JX6YAGA0CYKYAGA0JYaYAGQ0CYqYAGQ0JY6YAGg0CZKYAGg0JZaYAGw0CZqYAGw0JZ6YAHA0CaKYAHA0JaaYA" + + "HQ0CaqYAHQ0Ja6YAHg0CbKYAHg0Jb6YAHw0Cc6YAHw0Ce6YAHw0CfaYAHw0CfqYAHw0Cf6YAHw0CgKYAHw0JgaYAIA0CgqYAIA0J" + + "g6YAIQ0ChKYAIQ0JhaYAIg0ChqYAIg0Jh6YAIw0CiKYAIw0JiaYAJA0CiqYAJA0Ji6YAJQ0CjKYAJQ0JjaYAJg0CjqYAJg0Jj6YA" + + "Jw0CkKYAJw0JkaYAKA0CkqYAKA0Jk6YAKQ0ClKYAKQ0JlaYAKg0ClqYAKg0Jl6YAKw0CmKYAKw0JmaYALA0CmqYALA0Jm6YALQ0C" + + "nKYALQ0JnaYALg0JnqYALw0Cn6YALw0C5aYALw0C76YALw0C8aYALw0C96YALw0C/6YALw0DFqcALw0CGqcALw0CH6cALw0CIacA" + + "Lw0CIqcALw0JI6cAMA0CJKcAMA0JJacAMQ0CJqcAMQ0JJ6cAMg0CKKcAMg0JKacAMw0CKqcAMw0JK6cANA0CLKcANA0JLacANQ0C" + + "LqcANQ0JMacANg0CMqcANg0JM6cANw0CNKcANw0JNacAOA0CNqcAOA0JN6cAOQ0COKcAOQ0JOacAOg0COqcAOg0JO6cAOw0CPKcA" + + "Ow0JPacAPA0CPqcAPA0JP6cAPQ0CQKcAPQ0JQacAPg0CQqcAPg0JQ6cAPw0CRKcAPw0JRacAQA0CRqcAQA0JR6cAQQ0CSKcAQQ0J" + + "SacAQg0CSqcAQg0JS6cAQw0CTKcAQw0JTacARA0CTqcARA0JT6cARQ0CUKcARQ0JUacARg0CUqcARg0JU6cARw0CVKcARw0JVacA" + + "SA0CVqcASA0JV6cASQ0CWKcASQ0JWacASg0CWqcASg0JW6cASw0CXKcASw0JXacATA0CXqcATA0JX6cATQ0CYKcATQ0JYacATg0C" + + "YqcATg0JY6cATw0CZKcATw0JZacAUA0CZqcAUA0JZ6cAUQ0CaKcAUQ0JaacAUg0CaqcAUg0Ja6cAUw0CbKcAUw0JbacAVA0CbqcA" + + "VA0Jb6cAVQ0CcKcAVQ0JeKcAVg0CeacAVg0JeqcAVw0Ce6cAVw0JfKcAWA0CfacAWA0JfqcAWQ0Jf6cAWg0CgKcAWg0JgacAWw0C" + + "gqcAWw0Jg6cAXA0ChKcAXA0JhacAXQ0ChqcAXQ0JiKcAXg0CiqcAXg0Ci6cAXg0JjKcAXw0CjacAXw0JjqcAYA0Cj6cAYA0CkKcA" + + "YA0JkacAYQ0CkqcAYQ0Jk6cAYg0ClacAYg0ClqcAYg0Jl6cAYw0CmKcAYw0JmacAZA0CmqcAZA0Jm6cAZQ0CnKcAZQ0JnacAZg0C" + + "nqcAZg0Jn6cAZw0CoKcAZw0JoacAaA0CoqcAaA0Jo6cAaQ0CpKcAaQ0JpacAag0CpqcAag0Jp6cAaw0CqKcAaw0JqacAbA0CqqcA" + + "bA0Jq6cAbQ0JrKcAbg0JracAbw0JrqcAcA0Jr6cAcQ0CsKcAcQ0JsacAcg0JsqcAcw0Js6cAdA0JtKcAdQ0JtacAdg0CtqcAdg0J" + + "t6cAdw0CuKcAdw0JuacAeA0CuqcAeA0Ju6cAeQ0CvKcAeQ0JvacAeg0CvqcAeg0Jv6cAew0CwKcAew0JwacAfA0CwqcAfA0Jw6cA" + + "fQ0CxKcAfQ0JxacAfg0JxqcAfw0Jx6cAgA0JyKcAgQ0CyacAgQ0JyqcAgg0Cy6cAgg0JzKcAgw0JzacAhA0CzqcAhA0Jz6cAhQ0C" + + "0KcAhQ0J0acAhg0C0qcAhg0J06cAhw0C1KcAhw0J1acAiA0C1qcAiA0J16cAiQ0C2KcAiQ0J2acAig0C2qcAig0J26cAiw0C3KcA" + + "iw0J8KcAjA0D8acAjA0J8qcAjQ0J86cAjg0J9KcAjw0J9acAkA0J9qcAkQ0C96cAkQ0C+KcAkQ0J+acAkg0J+qcAkw0C/6cAkw0C" + + "J6gAkw0CK6gAkw0CLKgAkw0CL6gAkw0DOagAkw0CP6gAkw0Dc6gAkw0Cd6gAkw0Cf6gAkw0DxKgAkw0CxagAkw0CzagAkw0Dz6gA" + + "kw0C2agAkw0C36gAkw0D96gAkw0C+qgAkw0C+6gAkw0C/KgAkw0C/agAkw0C/6gAkw0CLakAkw0CL6kAkw0CU6kAkw0CXqkAkw0D" + + "X6kAkw0CfKkAkw0Cf6kAkw0DwKkAkw0CzakAkw0CzqkAkw0D2akAkw0C3akAkw0D36kAkw0C/qkAkw0C/6kAkw0DNqoAkw0CP6oA" + + "kw0DTaoAkw0CT6oAkw0DWaoAkw0CW6oAkw0DX6oAkw0CdqoAkw0CeaoAkw0Ce6oAkw0Cf6oAkw0CwqoAkw0C2qoAkw0D3aoAkw0C" + + "36oAkw0C76oAkw0C8aoAkw0C9qoAkw0CAKsAkw0DBqsAkw0CCKsAkw0DDqsAkw0CEKsAkw0DFqsAkw0CH6sAkw0DJqsAkw0CJ6sA" + + "kw0DLqsAkw0CL6sAkw0DWqsAkw0CW6sAkw0CXKsAkw0JXasAlA0JXqsAlQ0JX6sAlg0JY6sAlw0CZasAlw0CZ6sAlw0CaKsAlw0C" + + "aasAlw0Ja6sAmA0Cb6sAmA0DcKsAmA0JcasAmQ0JcqsAmg0Jc6sAmw0JdKsAnA0JdasAnQ0JdqsAng0Jd6sAnw0JeKsAoA0JeasA" + + "oQ0JeqsAog0Je6sAow0JfKsApA0JfasApQ0JfqsApg0Jf6sApw0JgKsAqA0JgasAqQ0JgqsAqg0Jg6sAqw0JhKsArA0JhasArQ0J" + + "hqsArg0Jh6sArw0JiKsAsA0JiasAsQ0JiqsAsg0Ji6sAsw0JjKsAtA0JjasAtQ0JjqsAtg0Jj6sAtw0JkKsAuA0JkasAuQ0JkqsA" + + "ug0Jk6sAuw0JlKsAvA0JlasAvQ0JlqsAvg0Jl6sAvw0JmKsAwA0JmasAwQ0JmqsAwg0Jm6sAww0JnKsAxA0JnasAxQ0JnqsAxg0J" + + "n6sAxw0JoKsAyA0JoasAyQ0JoqsAyg0Jo6sAyw0JpKsAzA0JpasAzQ0JpqsAzg0Jp6sAzw0JqKsA0A0JqasA0Q0JqqsA0g0Jq6sA" + + "0w0JrKsA1A0JrasA1Q0JrqsA1g0Jr6sA1w0JsKsA2A0JsasA2Q0JsqsA2g0Js6sA2w0JtKsA3A0JtasA3Q0JtqsA3g0Jt6sA3w0J" + + "uKsA4A0JuasA4Q0JuqsA4g0Ju6sA4w0JvKsA5A0JvasA5Q0JvqsA5g0Jv6sA5w0J6qsA6A0C66sA6A0C7asA6A0C76sA6A0D+asA" + + "6A0C/6sA6A0Do9cA6A0Cr9cA6A0DxtcA6A0CytcA6A0D+9cA6A0C/9cA6A0D/98A6A0D//gA6A0DAPkA6A0JAfkA6Q0JAvkA6g0J" + + "A/kA6w0JBPkA7A0JBfkA7Q0JBvkA7g0JCPkA7w0JCfkA8A0JCvkA8Q0JC/kA8g0JDPkA8w0JDfkA9A0JDvkA9Q0JD/kA9g0JEPkA" + + "9w0JEfkA+A0JEvkA+Q0JE/kA+g0JFPkA+w0JFfkA/A0JFvkA/Q0JF/kA/g0JGPkA/w0JGfkAAA4JGvkAAQ4JG/kAAg4JHPkAAw4J" + + "HfkABA4JHvkABQ4JH/kABg4JIPkABw4JIfkACA4JIvkACQ4JI/kACg4JJPkACw4JJfkADA4JJvkADQ4JJ/kADg4JKPkADw4JKfkA" + + "EA4JKvkAEQ4JK/kAEg4JLPkAEw4JLfkAFA4JLvkAFQ4JL/kAFg4JMPkAFw4JMfkAGA4JMvkAGQ4JM/kAGg4JNPkAGw4JNfkAHA4J" + + "NvkAHQ4JN/kAHg4JOPkAHw4JOfkAIA4JOvkAIQ4JO/kAIg4JPPkAIw4JPfkAJA4JPvkAJQ4JP/kAJg4JQPkAJw4JQfkAKA4JQvkA" + + "KQ4JQ/kAKg4JRPkAKw4JRfkALA4JRvkALQ4JR/kALg4JSPkALw4JSfkAMA4JSvkAMQ4JS/kAMg4JTPkAMw4JTfkANA4JTvkANQ4J" + + "T/kANg4JUPkANw4JUfkAOA4JUvkAOQ4JU/kAOg4JVPkAOw4JVfkAPA4JVvkAPQ4JV/kAPg4JWPkAPw4JWfkAQA4JWvkAQQ4JW/kA" + + "Qg4JXPkAQw4JXfkARA4JXvkARQ4JX/kARg4JYPkARw4JYfkASA4JYvkASQ4JY/kASg4JZPkASw4JZfkATA4JZvkATQ4JZ/kATg4J" + + "aPkATw4JafkAUA4JavkAUQ4Ja/kAUg4JbPkAUw4JbfkAVA4JbvkAVQ4Jb/kAVg4JcPkAVw4JcfkAWA4JcvkAWQ4Jc/kAWg4JdPkA" + + "Ww4JdfkAXA4JdvkAXQ4Jd/kAXg4JePkAXw4JefkAYA4JevkAYQ4Je/kAYg4JfPkAYw4JffkAZA4JfvkAZQ4Jf/kAZg4JgPkAZw4J" + + "gfkAaA4JgvkAaQ4Jg/kAag4JhPkAaw4JhfkAbA4JhvkAbQ4Jh/kAbg4JiPkAbw4JifkAcA4JivkAcQ4Ji/kAcg4JjPkAcw4JjfkA" + + "dA4JjvkAdQ4Jj/kAdg4JkPkAdw4JkfkAeA4JkvkAeQ4Jk/kAeg4JlPkAew4JlfkAfA4JlvkAfQ4Jl/kAfg4JmPkAfw4JmfkAgA4J" + + "mvkAgQ4Jm/kAgg4JnPkAgw4JnfkAhA4JnvkAhQ4Jn/kAhg4JoPkAhw4JofkAiA4JovkAiQ4Jo/kAig4JpPkAiw4JpfkAjA4JpvkA" + + "jQ4Jp/kAjg4JqPkAjw4JqfkAkA4JqvkAkQ4Jq/kAkg4JrPkAkw4JrfkAlA4JrvkAlQ4Jr/kAlg4JsPkAlw4JsfkAmA4JsvkAmQ4J" + + "s/kAmg4JtPkAmw4JtfkAnA4JtvkAnQ4Jt/kAng4JuPkAnw4JufkAoA4JuvkAoQ4Ju/kAog4JvPkAow4JvfkApA4JvvkApQ4Jv/kA" + + "pg4JwPkApw4JwfkAqA4JwvkAqQ4Jw/kAqg4JxPkAqw4JxfkArA4JxvkArQ4Jx/kArg4JyPkArw4JyfkAsA4JyvkAsQ4Jy/kAsg4J" + + "zPkAsw4JzfkAtA4JzvkAtQ4Jz/kAtg4J0PkAtw4J0fkAuA4J0vkAuQ4J0/kAug4J1PkAuw4J1fkAvA4J1vkAvQ4J1/kAvg4J2PkA" + + "vw4J2fkAwA4J2vkAwQ4J2/kAwg4J3PkAww4J3fkAxA4J3vkAxQ4J3/kAxg4J4PkAxw4J4fkAyA4J4vkAyQ4J4/kAyg4J5PkAyw4J" + + "5fkAzA4J5vkAzQ4J5/kAzg4J6PkAzw4J6fkA0A4J6vkA0Q4J6/kA0g4J7PkA0w4J7fkA1A4J7vkA1Q4J7/kA1g4J8PkA1w4J8fkA" + + "2A4J8vkA2Q4J8/kA2g4J9PkA2w4J9fkA3A4J9vkA3Q4J9/kA3g4J+PkA3w4J+fkA4A4J+vkA4Q4J+/kA4g4J/PkA4w4J/fkA5A4J" + + "/vkA5Q4J//kA5g4JAPoA5w4JAfoA6A4JAvoA6Q4JA/oA6g4JBPoA6w4JBfoA7A4JBvoA7Q4JB/oA7g4JCPoA7w4JCfoA8A4JCvoA" + + "8Q4JC/oA8g4JDPoA8w4JDfoA9A4JD/oA9Q4CEPoA9Q4JEfoA9g4CEvoA9g4JFPoA9w4CFfoA9w4JFvoA+A4JF/oA+Q4JGPoA+g4J" + + "GfoA+w4JGvoA/A4JG/oA/Q4JHPoA/g4JHfoA/w4JHvoAAA8JH/oAAQ8CIPoAAQ8JIfoAAg8CIvoAAg8JJPoAAw8CJfoAAw8JJvoA" + + "BA8JKfoABQ8CKvoABQ8JK/oABg8JLPoABw8JLfoACA8JLvoACQ8JL/oACg8JMPoACw8JMfoADA8JMvoADQ8JM/oADg8JNPoADw8J" + + "NfoAEA8JNvoAEQ8JN/oAEg8JOPoAEw8JOfoAFA8JOvoAFQ8JO/oAFg8JPPoAFw8JPfoAGA8JPvoAGQ8JP/oAGg8JQPoAGw8JQfoA" + + "HA8JQvoAHQ8JQ/oAHg8JRPoAHw8JRfoAIA8JRvoAIQ8JR/oAIg8JSPoAIw8JSfoAJA8JSvoAJQ8JS/oAJg8JTPoAJw8JTfoAKA8J" + + "TvoAKQ8JT/oAKg8JUPoAKw8JUfoALA8JUvoALQ8JU/oALg8JVPoALw8JVfoAMA8JVvoAMQ8JV/oAMg8JWPoAMw8JWfoANA8JWvoA" + + "NQ8JW/oANg8JXPoANw8JXvoAOA8JX/oAOQ8JYPoAOg8JYfoAOw8JYvoAPA8JY/oAPQ8JZPoAPg8JZfoAPw8JZvoAQA8JZ/oAQQ8J" + + "aPoAQg8JafoAQw8JavoARA8Ja/oARQ8JbPoARg8RbfoASA8Jb/oASQ8DcPoASQ8JcfoASg8JcvoASw8Jc/oATA8JdPoATQ8JdfoA" + + "Tg8JdvoATw8Jd/oAUA8JePoAUQ8JefoAUg8JevoAUw8Je/oAVA8JfPoAVQ8JffoAVg8JfvoAVw8Jf/oAWA8JgPoAWQ8JgfoAWg8J" + + "gvoAWw8Jg/oAXA8JhPoAXQ8JhfoAXg8JhvoAXw8Jh/oAYA8JiPoAYQ8JifoAYg8JivoAYw8Ji/oAZA8JjPoAZQ8JjfoAZg8JjvoA" + + "Zw8Jj/oAaA8JkPoAaQ8JkfoAag8JkvoAaw8Jk/oAbA8JlPoAbQ8JlfoAbg8JlvoAbw8Jl/oAcA8JmPoAcQ8JmfoAcg8JmvoAcw8J" + + "m/oAdA8JnPoAdQ8JnfoAdg8JnvoAdw8Jn/oAeA8JoPoAeQ8JofoAeg8JovoAew8Jo/oAfA8JpPoAfQ8JpfoAfg8JpvoAfw8Jp/oA" + + "gA8JqPoAgQ8JqfoAgg8JqvoAgw8Jq/oAhA8JrPoAhQ8JrfoAhg8JrvoAhw8Jr/oAiA8JsPoAiQ8JsfoAig8JsvoAiw8Js/oAjA8J" + + "tPoAjQ8JtfoAjg8JtvoAjw8Jt/oAkA8JuPoAkQ8JufoAkg8JuvoAkw8Ju/oAlA8JvPoAlQ8JvfoAlg8JvvoAlw8Jv/oAmA8JwPoA" + + "mQ8JwfoAmg8JwvoAmw8Jw/oAnA8JxPoAnQ8JxfoAng8JxvoAnw8Jx/oAoA8JyPoAoQ8JyfoAog8JyvoAow8Jy/oApA8JzPoApQ8J" + + "zfoApg8JzvoApw8Jz/oAqA8R0PoAqg8R0foArA8R0voArg8J0/oArw8J1PoAsA8J1foAsQ8R1voAsw8R1/oAtQ8R2PoAtw8J2foA" + + "uA8J//oAuQ8DAPsAuQ8RAfsAuw8RAvsAvQ8RA/sAvw8ZBPsAwg8ZBvsAxQ8REvsAxw8DE/sAxw8RFPsAyQ8RFfsAyw8RFvsAzQ8R" + + "F/sAzw8RHPsA0Q8DHfsA0Q8RHvsA0w8CH/sA0w8RIPsA1Q8JIfsA1g8JIvsA1w8JI/sA2A8JJPsA2Q8JJfsA2g8JJvsA2w8JJ/sA" + + "3A8JKPsA3Q8JKfsA3g8JKvsA3w8RK/sA4Q8RLPsA4w8ZLfsA5g8ZLvsA6Q8RL/sA6w8RMPsA7Q8RMfsA7w8RMvsA8Q8RM/sA8w8R" + + "NPsA9Q8RNfsA9w8RNvsA+Q8RN/sA+w8DOPsA+w8ROfsA/Q8ROvsA/w8RO/sAARARPPsAAxARPfsABRADPvsABRARP/sABxADQPsA" + + "BxARQfsACRARQvsACxADQ/sACxARRPsADRARRfsADxADRvsADxARR/sAERARSPsAExARSfsAFRARSvsAFxARS/sAGRARTPsAGxAR" + + "TfsAHRARTvsAHxART/sAIRARUfsAIxAJVfsAJBAJWfsAJRAJXfsAJhAJYfsAJxAJZfsAKBAJafsAKRAJbfsAKhAJcfsAKxAJdfsA" + + "LBAJefsALRAJffsALhAJgfsALxAJg/sAMBAJhfsAMRAJh/sAMhAJifsAMxAJi/sANBAJjfsANRAJkfsANhAJlfsANxAJmfsAOBAJ" + + "nfsAORAJn/sAOhAJo/sAOxAJpfsAPBAJqfsAPRAJrfsAPhAJr/sAPxAJsfsAQBAJwfsAQRACwvsAQRAC0vsAQRAC1vsAQRAJ2PsA" + + "QhAJ2vsAQxAJ3PsARBAJ3fsARRAR3/sARxAJ4fsASBAJ4/sASRAJ5/sAShAJ6fsASxAJ6/sATBAR7fsAThAR7/sAUBAR8fsAUhAR" + + "8/sAVBAR9fsAVhAR+PsAWBAR+/sAWhAR//sAXBAJAPwAXRARAfwAXxARAvwAYRARA/wAYxARBPwAZRARBfwAZxARBvwAaRARB/wA" + + "axARCPwAbRARCfwAbxARCvwAcRARC/wAcxARDPwAdRARDfwAdxARDvwAeRARD/wAexAREPwAfRAREfwAfxAREvwAgRARE/wAgxAR" + + "FPwAhRARFfwAhxARFvwAiRARF/wAixARGPwAjRARGfwAjxARGvwAkRARG/wAkxARHPwAlRARHfwAlxARHvwAmRARH/wAmxARIPwA" + + "nRARIfwAnxARIvwAoRARI/wAoxARJPwApRARJfwApxARJvwAqRARJ/wAqxARKPwArRARKfwArxARKvwAsRARK/wAsxARLPwAtRAR" + + "LfwAtxARLvwAuRARL/wAuxARMPwAvRARMfwAvxARMvwAwRARM/wAwxARNPwAxRARNfwAxxARNvwAyRARN/wAyxAROPwAzRAROfwA" + + "zxAROvwA0RARO/wA0xARPPwA1RARPfwA1xARPvwA2RARP/wA2xARQPwA3RARQfwA3xARQvwA4RARQ/wA4xARRPwA5RARRfwA5xAR" + + "RvwA6RARR/wA6xARSPwA7RARSfwA7xARSvwA8RARS/wA8xARTPwA9RARTfwA9xARTvwA+RART/wA+xARUPwA/RARUfwA/xARUvwA" + + "ARERU/wAAxERVPwABRERVfwABxERVvwACRERV/wACxERWPwADRERWfwADxERWvwAERERW/wAExERXPwAFRERXfwAFxERXvwAGREZ" + + "X/wAHBEZYPwAHxEZYfwAIhEZYvwAJREZY/wAKBEZZPwAKxERZfwALRERZvwALxERZ/wAMRERaPwAMxERafwANRERavwANxERa/wA" + + "ORERbPwAOxERbfwAPRERbvwAPxERb/wAQRERcPwAQxERcfwARRERcvwARxERc/wASRERdPwASxERdfwATRERdvwATxERd/wAURER" + + "ePwAUxERefwAVRERevwAVxERe/wAWRERfPwAWxERffwAXRERfvwAXxERf/wAYRERgPwAYxERgfwAZRERgvwAZxERg/wAaRERhPwA" + + "axERhfwAbRERhvwAbxERh/wAcRERiPwAcxERifwAdRERivwAdxERi/wAeRERjPwAexERjfwAfRERjvwAfxERj/wAgRERkPwAgxER" + + "kfwAhRERkvwAhxERk/wAiRERlPwAixERlfwAjRERlvwAjxERl/wAkRERmPwAkxERmfwAlRERmvwAlxERm/wAmRERnPwAmxERnfwA" + + "nRERnvwAnxERn/wAoRERoPwAoxERofwApRERovwApxERo/wAqRERpPwAqxERpfwArRERpvwArxERp/wAsRERqPwAsxERqfwAtRER" + + "qvwAtxERq/wAuRERrPwAuxERrfwAvRERrvwAvxERr/wAwRERsPwAwxERsfwAxRERsvwAxxERs/wAyRERtPwAyxERtfwAzRERtvwA" + + "zxERt/wA0RERuPwA0xERufwA1RERuvwA1xERu/wA2RERvPwA2xERvfwA3RERvvwA3xERv/wA4RERwPwA4xERwfwA5RERwvwA5xER" + + "w/wA6RERxPwA6xERxfwA7RERxvwA7xERx/wA8RERyPwA8xERyfwA9RERyvwA9xERy/wA+RERzPwA+xERzfwA/RERzvwA/xERz/wA" + + "ARIR0PwAAxIR0fwABRIR0vwABxIR0/wACRIR1PwACxIR1fwADRIR1vwADxIR1/wAERIR2PwAExIR2fwAFRIR2vwAFxIR2/wAGRIR" + + "3PwAGxIR3fwAHRIR3vwAHxIR3/wAIRIR4PwAIxIR4fwAJRIR4vwAJxIR4/wAKRIR5PwAKxIR5fwALRIR5vwALxIR5/wAMRIR6PwA" + + "MxIR6fwANRIR6vwANxIR6/wAORIR7PwAOxIR7fwAPRIR7vwAPxIR7/wAQRIR8PwAQxIR8fwARRIR8vwARxIZ8/wAShIZ9PwATRIZ" + + "9fwAUBIR9vwAUhIR9/wAVBIR+PwAVhIR+fwAWBIR+vwAWhIR+/wAXBIR/PwAXhIR/fwAYBIR/vwAYhIR//wAZBIRAP0AZhIRAf0A" + + "aBIRAv0AahIRA/0AbBIRBP0AbhIRBf0AcBIRBv0AchIRB/0AdBIRCP0AdhIRCf0AeBIRCv0AehIRC/0AfBIRDP0AfhIRDf0AgBIR" + + "Dv0AghIRD/0AhBIREP0AhhIREf0AiBIREv0AihIRE/0AjBIRFP0AjhIRFf0AkBIRFv0AkhIRF/0AlBIRGP0AlhIRGf0AmBIRGv0A" + + "mhIRG/0AnBIRHP0AnhIRHf0AoBIRHv0AohIRH/0ApBIRIP0AphIRIf0AqBIRIv0AqhIRI/0ArBIRJP0ArhIRJf0AsBIRJv0AshIR" + + "J/0AtBIRKP0AthIRKf0AuBIRKv0AuhIRK/0AvBIRLP0AvhIRLf0AwBIRLv0AwhIRL/0AxBIRMP0AxhIRMf0AyBIRMv0AyhIRM/0A" + + "zBIRNP0AzhIRNf0A0BIRNv0A0hIRN/0A1BIROP0A1hIROf0A2BIROv0A2hIRO/0A3BIRPf0A3hIRP/0A4BICT/0A4BICUP0A4BIZ" + + "Uv0A4xIZU/0A5hIZVP0A6RIZVf0A7BIZVv0A7xIZV/0A8hIZWf0A9RIZWv0A+BIZW/0A+xIZXP0A/hIZXf0AARMZXv0ABBMZYP0A" + + "BxMZYf0AChMZY/0ADRMZZf0AEBMZZv0AExMZaP0AFhMZaf0AGRMZa/0AHBMZbf0AHxMZbv0AIhMZcP0AJRMZcv0AKBMZc/0AKxMZ" + + "dP0ALhMZdf0AMRMZd/0ANBMZeP0ANxMZef0AOhMZev0APRMZe/0AQBMZff0AQxMZfv0ARhMZf/0ASRMZgP0ATBMZgf0ATxMZgv0A" + + "UhMZhP0AVRMZhv0AWBMZiP0AWxMZif0AXhMZiv0AYRMZi/0AZBMZjP0AZxMZjf0AahMZjv0AbRMZj/0AcBMZkf0AcxMCkv0AcxMZ" + + "k/0AdhMZlP0AeRMZlf0AfBMZlv0AfxMZmP0AghMZmf0AhRMZmv0AiBMZm/0AixMZnf0AjhMZnv0AkRMZn/0AlBMZoP0AlxMZof0A" + + "mhMZov0AnRMZo/0AoBMZpP0AoxMZpf0AphMZpv0AqRMZp/0ArBMZqP0ArxMZqf0AshMZqv0AtRMZq/0AuBMZrP0AuxMZrf0AvhMZ" + + "rv0AwRMZr/0AxBMZsP0AxxMZsf0AyhMZsv0AzRMZs/0A0BMZtP0A0xMZtf0A1hMZtv0A2RMZt/0A3BMZuP0A3xMZuf0A4hMZuv0A" + + "5RMZu/0A6BMZvP0A6xMZvf0A7hMZvv0A8RMZv/0A9BMZwP0A9xMZwf0A+hMZwv0A/RMZw/0AABQZxP0AAxQZxf0ABhQZxv0ACRQZ" + + "x/0ADBQZzv0ADxQCz/0ADxQC7/0ADxQD8P0ADxQZ8f0AEhQZ8v0AFRQh8/0AGRQh9P0AHRQh9f0AIRQh9v0AJRQh9/0AKRQh+P0A" + + "LRQh+f0AMRQZ+v0ANBSR+/0ARhRB/P0AThQh/f0AUhQC//0AUhQCD/4AUhQHEP4AUhQJEf4AUxQJEv4AVBQDE/4AVBQJFP4AVRQJ" + + "Ff4AVhQJFv4AVxQJF/4AWBQJGP4AWRQJGf4AWhQDH/4AWhQDI/4AWhQCJv4AWhQCLf4AWhQCL/4AWhQCMP4AWhQDMf4AWhQJMv4A" + + "WxQJNP4AXBQJNf4AXRQJNv4AXhQJN/4AXxQJOP4AYBQJOf4AYRQJOv4AYhQJO/4AYxQJPP4AZBQJPf4AZRQJPv4AZhQJP/4AZxQJ" + + "QP4AaBQJQf4AaRQJQv4AahQJQ/4AaxQJRP4AbBQJRv4AbRQCR/4AbRQJSP4AbhQJTP4AbxQRT/4AcRQJUP4AchQJUf4AcxQJUv4A" + + "dBQDU/4AdBQDVP4AdBQJVf4AdRQJVv4AdhQJV/4AdxQJWP4AeBQJWf4AeRQJWv4AehQJW/4AexQJXP4AfBQJXf4AfRQJXv4AfhQJ" + + "X/4AfxQJYP4AgBQJYf4AgRQJYv4AghQJY/4AgxQJZP4AhBQJZf4AhRQJZv4AhhQJZ/4AhxQDaP4AhxQJaf4AiBQJav4AiRQJa/4A" + + "ihQJb/4AixQDcP4AixQRcf4AjRQRcv4AjxQRc/4AkRQCdP4AkRQRdf4AkxQDdv4AkxQRd/4AlRQReP4AlxQRef4AmRQRev4AmxQR" + + "e/4AnRQRfP4AnxQRff4AoRQRfv4AoxQRf/4ApRQRgP4ApxQJgv4AqBQJhP4AqRQJhv4AqhQJiP4AqxQJjP4ArBQJjv4ArRQJkv4A" + + "rhQJlP4ArxQJmP4AsBQJnP4AsRQJoP4AshQJpP4AsxQJqP4AtBQJqv4AtRQJrP4AthQJrv4AtxQJsP4AuBQJtP4AuRQJuP4AuhQJ" + + "vP4AuxQJwP4AvBQJxP4AvRQJyP4AvhQJzP4AvxQJ0P4AwBQJ1P4AwRQJ2P4AwhQJ3P4AwxQJ4P4AxBQJ5P4AxRQJ6P4AxhQJ7P4A" + + "xxQJ7v4AyBQJ8P4AyRQJ9P4AyhQJ9v4AyxQR+P4AzRQR+v4AzxQR/P4A0RQR/v4A0xQD//4A0xQHAP8A0xQDAf8A0xQJAv8A1BQJ" + + "A/8A1RQJBP8A1hQJBf8A1xQJBv8A2BQJB/8A2RQJCP8A2hQJCf8A2xQJCv8A3BQJC/8A3RQJDP8A3hQJDf8A3xQJDv8A4BQJD/8A" + + "4RQJEP8A4hQJEf8A4xQJEv8A5BQJE/8A5RQJFP8A5hQJFf8A5xQJFv8A6BQJF/8A6RQJGP8A6hQJGf8A6xQJGv8A7BQJG/8A7RQJ" + + "HP8A7hQJHf8A7xQJHv8A8BQJH/8A8RQJIP8A8hQJIf8A8xQJIv8A9BQJI/8A9RQJJP8A9hQJJf8A9xQJJv8A+BQJJ/8A+RQJKP8A" + + "+hQJKf8A+xQJKv8A/BQJK/8A/RQJLP8A/hQJLf8A/xQJLv8AABUJL/8AARUJMP8AAhUJMf8AAxUJMv8ABBUJM/8ABRUJNP8ABhUJ" + + "Nf8ABxUJNv8ACBUJN/8ACRUJOP8AChUJOf8ACxUJOv8ADBUJO/8ADRUJPP8ADhUJPf8ADxUJPv8AEBUJP/8AERUJQP8AEhUJQf8A" + + "ExUJQv8AFBUJQ/8AFRUJRP8AFhUJRf8AFxUJRv8AGBUJR/8AGRUJSP8AGhUJSf8AGxUJSv8AHBUJS/8AHRUJTP8AHhUJTf8AHxUJ" + + "Tv8AIBUJT/8AIRUJUP8AIhUJUf8AIxUJUv8AJBUJU/8AJRUJVP8AJhUJVf8AJxUJVv8AKBUJV/8AKRUJWP8AKhUJWf8AKxUJWv8A" + + "LBUJW/8ALRUJXP8ALhUJXf8ALxUJXv8AMBUJX/8AMRUJYP8AMhUJYf8AMxUJYv8ANBUJY/8ANRUJZP8ANhUJZf8ANxUJZv8AOBUJ" + + "Z/8AORUJaP8AOhUJaf8AOxUJav8APBUJa/8APRUJbP8APhUJbf8APxUJbv8AQBUJb/8AQRUJcP8AQhUJcf8AQxUJcv8ARBUJc/8A" + + "RRUJdP8ARhUJdf8ARxUJdv8ASBUJd/8ASRUJeP8AShUJef8ASxUJev8ATBUJe/8ATRUJfP8AThUJff8ATxUJfv8AUBUJf/8AURUJ" + + "gP8AUhUJgf8AUxUJgv8AVBUJg/8AVRUJhP8AVhUJhf8AVxUJhv8AWBUJh/8AWRUJiP8AWhUJif8AWxUJiv8AXBUJi/8AXRUJjP8A" + + "XhUJjf8AXxUJjv8AYBUJj/8AYRUJkP8AYhUJkf8AYxUJkv8AZBUJk/8AZRUJlP8AZhUJlf8AZxUJlv8AaBUJl/8AaRUJmP8AahUJ" + + "mf8AaxUJmv8AbBUJm/8AbRUJnP8AbhUJnf8AbxUJnv8AcBUJn/8AcRUJoP8AchUHof8AchUJov8AcxUJo/8AdBUJpP8AdRUJpf8A" + + "dhUJpv8AdxUJp/8AeBUJqP8AeRUJqf8AehUJqv8AexUJq/8AfBUJrP8AfRUJrf8AfhUJrv8AfxUJr/8AgBUJsP8AgRUJsf8AghUJ" + + "sv8AgxUJs/8AhBUJtP8AhRUJtf8AhhUJtv8AhxUJt/8AiBUJuP8AiRUJuf8AihUJuv8AixUJu/8AjBUJvP8AjRUJvf8AjhUJvv8A" + + "jxUJwf8AkBUDwv8AkBUJw/8AkRUJxP8AkhUJxf8AkxUJxv8AlBUJx/8AlRUJyf8AlhUDyv8AlhUJy/8AlxUJzP8AmBUJzf8AmRUJ" + + "zv8AmhUJz/8AmxUJ0f8AnBUD0v8AnBUJ0/8AnRUJ1P8AnhUJ1f8AnxUJ1v8AoBUJ1/8AoRUJ2f8AohUD2v8AohUJ2/8AoxUJ3P8A" + + "pBUJ3/8ApRUD4P8ApRUJ4f8AphUJ4v8ApxUJ4/8AqBUR5P8AqhUJ5f8AqxUJ5v8ArBUJ5/8ArRUD6P8ArRUJ6f8ArhUJ6v8ArxUJ" + + "6/8AsBUJ7P8AsRUJ7f8AshUJ7v8AsxUJ+P8AtBUD+/8AtBUD/P8AtBUD/f8AtBUD//8AtBUDCwABtBUCDAABtBUDJgABtBUCJwAB" + + "tBUDOgABtBUCOwABtBUDPQABtBUCPgABtBUDTQABtBUCTwABtBUDXQABtBUCfwABtBUD+gABtBUC/wABtBUDAgEBtBUCBgEBtBUD" + + "MwEBtBUCNgEBtBUDPwEBtBUCigEBtBUCjAEBtBUCjgEBtBUCjwEBtBUDmwEBtBUCnAEBtBUCnwEBtBUDoAEBtBUCzwEBtBUD/AEB" + + "tBUC/QEBtBUCfwIBtBUDnAIBtBUCnwIBtBUD0AIBtBUC3wIBtBUD4AIBtBUC+wIBtBUC/wIBtBUDHgMBtBUCHwMBtBUCIwMBtBUC" + + "LAMBtBUDLwMBtBUCQAMBtBUCQQMBtBUCSQMBtBUCSgMBtBUCTwMBtBUDegMBtBUCfwMBtBUDnQMBtBUCngMBtBUDnwMBtBUCwwMB" + + "tBUCxwMBtBUDzwMBtBUC1QMBtBUC/wMBtBUDAAQBtBURAQQBthURAgQBuBURAwQBuhURBAQBvBURBQQBvhURBgQBwBURBwQBwhUR" + + "CAQBxBURCQQBxhURCgQByBURCwQByhURDAQBzBURDQQBzhURDgQB0BURDwQB0hUREAQB1BUREQQB1hUREgQB2BUREwQB2hURFAQB" + + "3BURFQQB3hURFgQB4BURFwQB4hURGAQB5BURGQQB5hURGgQB6BURGwQB6hURHAQB7BURHQQB7hURHgQB8BURHwQB8hURIAQB9BUR" + + "IQQB9hURIgQB+BURIwQB+hURJAQB/BURJQQB/hURJgQBABYRJwQBAhYRTQQBBBYCnQQBBBYCnwQBBBYDqQQBBBYCrwQBBBYDsAQB" + + "BBYRsQQBBhYRsgQBCBYRswQBChYRtAQBDBYRtQQBDhYRtgQBEBYRtwQBEhYRuAQBFBYRuQQBFhYRugQBGBYRuwQBGhYRvAQBHBYR" + + "vQQBHhYRvgQBIBYRvwQBIhYRwAQBJBYRwQQBJhYRwgQBKBYRwwQBKhYRxAQBLBYRxQQBLhYRxgQBMBYRxwQBMhYRyAQBNBYRyQQB" + + "NhYRygQBOBYRywQBOhYRzAQBPBYRzQQBPhYRzgQBQBYRzwQBQhYR0AQBRBYR0QQBRhYR0gQBSBYR0wQBShYR1wQBTBYD+wQBTBYC" + + "/wQBTBYDJwUBTBYCLwUBTBYDYwUBTBYCbgUBTBYDbwUBTBYCcAUBTBYRcQUBThYRcgUBUBYRcwUBUhYRdAUBVBYRdQUBVhYRdgUB" + + "WBYRdwUBWhYReAUBXBYReQUBXhYRegUBYBYRewUBYhYDfAUBYhYRfQUBZBYRfgUBZhYRfwUBaBYRgAUBahYRgQUBbBYRggUBbhYR" + + "gwUBcBYRhAUBchYRhQUBdBYRhgUBdhYRhwUBeBYRiAUBehYRiQUBfBYRigUBfhYRiwUBgBYDjAUBgBYRjQUBghYRjgUBhBYRjwUB" + + "hhYRkAUBiBYRkQUBihYRkgUBjBYRkwUBjhYDlAUBjhYRlQUBkBYRlgUBkhYDoQUBkhYCogUBkhYDsQUBkhYCsgUBkhYDuQUBkhYC" + + "ugUBkhYDvAUBkhYCvwUBkhYD8wUBkhYC/wUBkhYDNgcBkhYCPwcBkhYDVQcBkhYCXwcBkhYDZwcBkhYCfwcBkhYDgAcBkhYCgQcB" + + "khYJggcBkxYJgwcBlBYJhAcBlRYJhQcBlhYJhgcBlxYDhwcBlxYJiAcBmBYJiQcBmRYJigcBmhYJiwcBmxYJjAcBnBYJjQcBnRYJ" + + "jgcBnhYJjwcBnxYJkAcBoBYJkQcBoRYJkgcBohYJkwcBoxYJlAcBpBYJlQcBpRYJlgcBphYJlwcBpxYJmAcBqBYJmQcBqRYJmgcB" + + "qhYJmwcBqxYJnAcBrBYRnQcBrhYJngcBrxYJnwcBsBYRoAcBshYJoQcBsxYRogcBtRYJowcBthYJpAcBtxYJpQcBuBYJpgcBuRYJ" + + "pwcBuhYRqAcBvBYJqQcBvRYJqgcBvhYJqwcBvxYJrAcBwBYJrQcBwRYJrgcBwhYJrwcBwxYJsAcBxBYJsQcBxRYDsgcBxRYJswcB" + + "xhYJtAcBxxYJtQcByBYJtgcByRYJtwcByhYJuAcByxYJuQcBzBYRugcBzhYR/wcB0BYDBQgB0BYCBwgB0BYDCAgB0BYCCQgB0BYD" + + "NQgB0BYCNggB0BYDOAgB0BYCOwgB0BYDPAgB0BYCPggB0BYDPwgB0BYCVQgB0BYCVggB0BYDXwgB0BYCdggB0BYCfwgB0BYCnggB" + + "0BYCpggB0BYDrwgB0BYC3wgB0BYD8ggB0BYC8wgB0BYD9QgB0BYC+ggB0BYD/wgB0BYCFQkB0BYCGQkB0BYCGwkB0BYCHgkB0BYD" + + "HwkB0BYCOQkB0BYCPgkB0BYDPwkB0BYCWQkB0BYCfwkB0BYDtwkB0BYCuwkB0BYDvQkB0BYCvwkB0BYCzwkB0BYC0QkB0BYD/wkB" + + "0BYCAwoB0BYCBAoB0BYDBgoB0BYCCwoB0BYDEwoB0BYCFAoB0BYDFwoB0BYCGAoB0BYDMwoB0BYCNQoB0BYCNwoB0BYDOgoB0BYC" + + "PgoB0BYDPwoB0BYCRwoB0BYCSAoB0BYCTwoB0BYDWAoB0BYCXwoB0BYDfAoB0BYCfwoB0BYCnAoB0BYCnwoB0BYCvwoB0BYDxwoB" + + "0BYCyAoB0BYC5goB0BYC6goB0BYD9goB0BYC/woB0BYDNQsB0BYCOAsB0BYDPwsB0BYCVQsB0BYCVwsB0BYDXwsB0BYCcgsB0BYC" + + "dwsB0BYDfwsB0BYCkQsB0BYCmAsB0BYDnAsB0BYCqAsB0BYDrwsB0BYC/wsB0BYDSAwB0BYCfwwB0BYDgAwB0BYRgQwB0hYRggwB" + + "1BYRgwwB1hYRhAwB2BYRhQwB2hYRhgwB3BYRhwwB3hYRiAwB4BYRiQwB4hYRigwB5BYRiwwB5hYRjAwB6BYRjQwB6hYRjgwB7BYR" + + "jwwB7hYRkAwB8BYRkQwB8hYRkgwB9BYRkwwB9hYRlAwB+BYRlQwB+hYRlgwB/BYRlwwB/hYRmAwBABcRmQwBAhcRmgwBBBcRmwwB" + + "BhcRnAwBCBcRnQwBChcRngwBDBcRnwwBDhcRoAwBEBcRoQwBEhcRogwBFBcRowwBFhcRpAwBGBcRpQwBGhcRpgwBHBcRpwwBHhcR" + + "qAwBIBcRqQwBIhcRqgwBJBcRqwwBJhcRrAwBKBcRrQwBKhcRrgwBLBcRrwwBLhcRsAwBMBcRsQwBMhcRsgwBNBcRvwwBNhcD8gwB" + + "NhcC+QwBNhcD/wwBNhcCJw0BNhcCLw0BNhcDOQ0BNhcCPw0BNhcDTw0BNhcCUA0BNhcRUQ0BOBcRUg0BOhcRUw0BPBcRVA0BPhcR" + + "VQ0BQBcRVg0BQhcRVw0BRBcRWA0BRhcRWQ0BSBcRWg0BShcRWw0BTBcRXA0BThcRXQ0BUBcRXg0BUhcRXw0BVBcRYA0BVhcRYQ0B" + + "WBcRYg0BWhcRYw0BXBcRZA0BXhcRZQ0BYBcRaA0BYhcDbQ0BYhcCbg0BYhcChQ0BYhcCjQ0BYhcDjw0BYhcCXw4BYhcDfg4BYhcC" + + "fw4BYhcDqQ4BYhcCqg4BYhcDrA4BYhcCrQ4BYhcCrw4BYhcDsQ4BYhcCwQ4BYhcDxA4BYhcCxw4BYhcCzw4BYhcD2A4BYhcC+Q4B" + + "YhcD+w4BYhcC/A4BYhcC/w4BYhcCHA8BYhcCJg8BYhcCJw8BYhcCLw8BYhcDUA8BYhcCWQ8BYhcCbw8BYhcDhQ8BYhcCiQ8BYhcC" + + "rw8BYhcDxA8BYhcCyw8BYhcC3w8BYhcD9g8BYhcC/w8BYhcDRhABYhcCTRABYhcCURABYhcDZRABYhcCbxABYhcCdRABYhcCfhAB" + + "YhcDfxABYhcCuhABYhcCvBABYhcCvRABYhcDwRABYhcCwhABYhcCzBABYhcDzRABYhcDzxABYhcD6BABYhcC7xABYhcD+RABYhcC" + + "/xABYhcDNBEBYhcCNREBYhcDPxEBYhcCQxEBYhcCRhEBYhcCRxEBYhcCTxEBYhcDcxEBYhcCdREBYhcCdhEBYhcCfxEBYhcDxBEB" + + "YhcCyBEBYhcCzBEBYhcCzREBYhcCzxEBYhcC2REBYhcC2hEBYhcC2xEBYhcC3BEBYhcC3xEBYhcC4BEBYhcD9BEBYhcC/xEBYhcD" + + "ERIBYhcCEhIBYhcDNxIBYhcCPRIBYhcCPhIBYhcCQRIBYhcCfxIBYhcDhhIBYhcChxIBYhcDiBIBYhcCiRIBYhcDjRIBYhcCjhIB" + + "YhcDnRIBYhcCnhIBYhcDqBIBYhcCqRIBYhcCrxIBYhcD6hIBYhcC7xIBYhcD+RIBYhcC/xIBYhcDABMBYhcCAxMBYhcCBBMBYhcD" + + "DBMBYhcCDhMBYhcDEBMBYhcCEhMBYhcDKBMBYhcCKRMBYhcDMBMBYhcCMRMBYhcDMxMBYhcCNBMBYhcDORMBYhcCOhMBYhcDOxMB" + + "YhcCRBMBYhcCRhMBYhcDSBMBYhcCShMBYhcDTRMBYhcCTxMBYhcDUBMBYhcCVhMBYhcDVxMBYhcCXBMBYhcDYxMBYhcCZRMBYhcD" + + "bBMBYhcCbxMBYhcDdBMBYhcCfxMBYhcDiRMBYhcCihMBYhcDixMBYhcCjRMBYhcDjhMBYhcCjxMBYhcDtRMBYhcCthMBYhcDwBMB" + + "YhcCwRMBYhcDwhMBYhcCxBMBYhcDxRMBYhcCxhMBYhcDyhMBYhcCyxMBYhcD0xMBYhcC1RMBYhcC1hMBYhcD2BMBYhcC4BMBYhcD" + + "4hMBYhcC/xMBYhcDShQBYhcCTxQBYhcCWRQBYhcCWhQBYhcCWxQBYhcCXBQBYhcDXRQBYhcCXhQBYhcCXxQBYhcCYRQBYhcCfxQB" + + "YhcDxRQBYhcCxhQBYhcCxxQBYhcCzxQBYhcD2RQBYhcCfxUBYhcDtRUBYhcCtxUBYhcDwBUBYhcCyRUBYhcC1xUBYhcC3RUBYhcC" + + "/xUBYhcDQBYBYhcCQxYBYhcCRBYBYhcCTxYBYhcDWRYBYhcCXxYBYhcDbBYBYhcCfxYBYhcDtxYBYhcCuBYBYhcCuRYBYhcCvxYB" + + "YhcDyRYBYhcCzxYBYhcD4xYBYhcC/xYBYhcDGRcBYhcCGhcBYhcCHBcBYhcDKxcBYhcCLxcBYhcDORcBYhcCPxcBYhcCRhcBYhcC" + + "/xcBYhcDOhgBYhcCOxgBYhcCnxgBYhcDoBgBYhcRoRgBZBcRohgBZhcRoxgBaBcRpBgBahcRpRgBbBcRphgBbhcRpxgBcBcRqBgB" + + "chcRqRgBdBcRqhgBdhcRqxgBeBcRrBgBehcRrRgBfBcRrhgBfhcRrxgBgBcRsBgBghcRsRgBhBcRshgBhhcRsxgBiBcRtBgBihcR" + + "tRgBjBcRthgBjhcRtxgBkBcRuBgBkhcRuRgBlBcRuhgBlhcRuxgBmBcRvBgBmhcRvRgBnBcRvhgBnhcRvxgBoBcR6RgBohcC8hgB" + + "ohcC/hgBohcD/xgBohcCBhkBohcCCBkBohcDCRkBohcCCxkBohcDExkBohcCFBkBohcDFhkBohcCFxkBohcDNRkBohcCNhkBohcD" + + "OBkBohcCOhkBohcDQxkBohcCRhkBohcCTxkBohcDWRkBohcCnxkBohcDpxkBohcCqRkBohcD1xkBohcC2RkBohcD4RkBohcC4hkB" + + "ohcC5BkBohcC/xkBohcDPhoBohcCRhoBohcCRxoBohcCTxoBohcDgxoBohcChRoBohcCmRoBohcCnBoBohcCnRoBohcCohoBohcC" + + "rxoBohcDvxoBohcC+BoBohcC/xoBohcDCRsBohcCXxsBohcDZxsBohcCvxsBohcD4BsBohcC4RsBohcC7xsBohcD+RsBohcC/xsB" + + "ohcDCBwBohcCCRwBohcDNhwBohcCNxwBohcDQBwBohcCRRwBohcCTxwBohcDWRwBohcCbBwBohcCbxwBohcDcRwBohcCjxwBohcC" + + "kRwBohcDpxwBohcCqBwBohcDthwBohcC/xwBohcDBh0BohcCBx0BohcDCR0BohcCCh0BohcDNh0BohcCOR0BohcDOh0BohcCOx0B" + + "ohcDPR0BohcCPh0BohcDRx0BohcCTx0BohcDWR0BohcCXx0BohcDZR0BohcCZh0BohcDaB0BohcCaR0BohcDjh0BohcCjx0BohcD" + + "kR0BohcCkh0BohcDmB0BohcCnx0BohcDqR0BohcCrx0BohcD2x0BohcC3x0BohcD6R0BohcC3x4BohcD9h4BohcC+B4BohcC/x4B" + + "ohcDEB8BohcCER8BohcDOh8BohcCPR8BohcDQh8BohcCTx8BohcCWR8BohcCWh8BohcCrx8BohcDsB8BohcCvx8BohcD8R8BohcC" + + "/h8BohcD/x8BohcCbiMBohcCmCMBohcCmSMBohcC/yMBohcDYiQBohcCbiQBohcCbyQBohcDcyQBohcCdCQBohcCfyQBohcDQyUB" + + "ohcCjy8BohcD8C8BohcC8i8BohcC/y8BohcDLjQBohcCLzQBohcCODQBohcDPzQBohcDVTQBohcCXzQBohcD+kMBohcC/0MBohcD" + + "RkYBohcC/2ABohcDOWEBohcC/2cBohcDOGoBohcCP2oBohcDXmoBohcCX2oBohcDaWoBohcCbWoBohcDb2oBohcCvmoBohcCv2oB" + + "ohcDyWoBohcCz2oBohcD7WoBohcC72oBohcD9GoBohcC9WoBohcC/2oBohcDNmsBohcCP2sBohcCQ2sBohcCRWsBohcCT2sBohcD" + + "WWsBohcCWmsBohcDYWsBohcCYmsBohcDd2sBohcCfGsBohcDj2sBohcCP20BohcDbG0BohcCb20BohcCeW0BohcCP24BohcDQG4B" + + "ohcRQW4BpBcRQm4BphcRQ24BqBcRRG4BqhcRRW4BrBcRRm4BrhcRR24BsBcRSG4BshcRSW4BtBcRSm4BthcRS24BuBcRTG4BuhcR" + + "TW4BvBcRTm4BvhcRT24BwBcRUG4BwhcRUW4BxBcRUm4BxhcRU24ByBcRVG4ByhcRVW4BzBcRVm4BzhcRV24B0BcRWG4B0hcRWW4B" + + "1BcRWm4B1hcRW24B2BcRXG4B2hcRXW4B3BcRXm4B3hcRX24B4BcRf24B4hcCmm4B4hcCn24B4hcDoG4B4hcRoW4B5BcRom4B5hcR" + + "o24B6BcRpG4B6hcRpW4B7BcRpm4B7hcRp24B8BcRqG4B8hcRqW4B9BcRqm4B9hcRq24B+BcRrG4B+hcRrW4B/BcRrm4B/hcRr24B" + + "ABgRsG4BAhgRsW4BBBgRsm4BBhgRs24BCBgRtG4BChgRtW4BDBgRtm4BDhgRt24BEBgRuG4BEhgRum4BFBgD024BFBgC/24BFBgD" + + "RG8BFBgCSm8BFBgCTm8BFBgDT28BFBgCfm8BFBgCh28BFBgCjm8BFBgDn28BFBgC328BFBgD4G8BFBgC4W8BFBgC4m8BFBgC428B" + + "FBgC5G8BFBgC728BFBgD8W8BFBgC828BFBgC9m8BFBgC/28BFBgD7IcBFBgC8YcBFBgC94cBFBgC/4cBFBgC8ooBFBgC1YwBFBgC" + + "/owBFBgD/4wBFBgCCI0BFBgCHo0BFBgCf40BFBgD8o0BFBgC768BFBgD868BFBgC9K8BFBgD+68BFBgC/K8BFBgD/q8BFBgC/68B" + + "FBgDAbABFBgCHrEBFBgCIrEBFBgCMbEBFBgDMrEBFBgCT7EBFBgDUrEBFBgCVLEBFBgDVbEBFBgCY7EBFBgDZ7EBFBgCb7EBFBgD" + + "+7IBFBgC/7sBFBgDarwBFBgCb7wBFBgDfLwBFBgCf7wBFBgDiLwBFBgCj7wBFBgDmbwBFBgCm7wBFBgDnLwBFBgCnrwBFBgCn7wB" + + "FBgCo7wBFBgH/8sBFBgD1cwBFBgC1swBFBgJ18wBFRgJ2MwBFhgJ2cwBFxgJ2swBGBgJ28wBGRgJ3MwBGhgJ3cwBGxgJ3swBHBgJ" + + "38wBHRgJ4MwBHhgJ4cwBHxgJ4swBIBgJ48wBIRgJ5MwBIhgJ5cwBIxgJ5swBJBgJ58wBJRgJ6MwBJhgJ6cwBJxgJ6swBKBgJ68wB" + + "KRgJ7MwBKhgJ7cwBKxgJ7swBLBgJ78wBLRgJ8MwBLhgJ8cwBLxgJ8swBMBgJ88wBMRgJ9MwBMhgJ9cwBMxgJ9swBNBgJ98wBNRgJ" + + "+MwBNhgJ+cwBNxgJ/MwBOBgC/8wBOBgDs84BOBgCuc4BOBgD0M4BOBgC384BOBgD8M4BOBgC/84BOBgDLc8BOBgCL88BOBgDRs8B" + + "OBgCT88BOBgDw88BOBgC/88BOBgD9dABOBgC/9ABOBgDJtEBOBgCKNEBOBgDKdEBOBgCXdEBOBgCXtEBOBghX9EBPBghYNEBQBgx" + + "YdEBRhgxYtEBTBgxY9EBUhgxZNEBWBgxctEBXhgCetEBXhgHutEBXhgCu9EBXhghvNEBYhghvdEBZhgxvtEBbBgxv9EBchgxwNEB" + + "eBgx3dEBfhgC6NEBfhgC6tEBfhgC/9EBfhgDRdIBfhgCv9IBfhgD09IBfhgC39IBfhgD89IBfhgC/9IBfhgDVtMBfhgCX9MBfhgD" + + "cdMBfhgCeNMBfhgC/9MBfhgDANQBfhgJAdQBfxgJAtQBgBgJA9QBgRgJBNQBghgJBdQBgxgJBtQBhBgJB9QBhRgJCNQBhhgJCdQB" + + "hxgJCtQBiBgJC9QBiRgJDNQBihgJDdQBixgJDtQBjBgJD9QBjRgJENQBjhgJEdQBjxgJEtQBkBgJE9QBkRgJFNQBkhgJFdQBkxgJ" + + "FtQBlBgJF9QBlRgJGNQBlhgJGdQBlxgJGtQBmBgJG9QBmRgJHNQBmhgJHdQBmxgJHtQBnBgJH9QBnRgJINQBnhgJIdQBnxgJItQB" + + "oBgJI9QBoRgJJNQBohgJJdQBoxgJJtQBpBgJJ9QBpRgJKNQBphgJKdQBpxgJKtQBqBgJK9QBqRgJLNQBqhgJLdQBqxgJLtQBrBgJ" + + "L9QBrRgJMNQBrhgJMdQBrxgJMtQBsBgJM9QBsRgJNNQBshgJNdQBsxgJNtQBtBgJN9QBtRgJONQBthgJOdQBtxgJOtQBuBgJO9QB" + + "uRgJPNQBuhgJPdQBuxgJPtQBvBgJP9QBvRgJQNQBvhgJQdQBvxgJQtQBwBgJQ9QBwRgJRNQBwhgJRdQBwxgJRtQBxBgJR9QBxRgJ" + + "SNQBxhgJSdQBxxgJStQByBgJS9QByRgJTNQByhgJTdQByxgJTtQBzBgJT9QBzRgJUNQBzhgJUdQBzxgJUtQB0BgJU9QB0RgJVNQB" + + "0hgJVdQB0xgDVtQB0xgJV9QB1BgJWNQB1RgJWdQB1hgJWtQB1xgJW9QB2BgJXNQB2RgJXdQB2hgJXtQB2xgJX9QB3BgJYNQB3RgJ" + + "YdQB3hgJYtQB3xgJY9QB4BgJZNQB4RgJZdQB4hgJZtQB4xgJZ9QB5BgJaNQB5RgJadQB5hgJatQB5xgJa9QB6BgJbNQB6RgJbdQB" + + "6hgJbtQB6xgJb9QB7BgJcNQB7RgJcdQB7hgJctQB7xgJc9QB8BgJdNQB8RgJddQB8hgJdtQB8xgJd9QB9BgJeNQB9RgJedQB9hgJ" + + "etQB9xgJe9QB+BgJfNQB+RgJfdQB+hgJftQB+xgJf9QB/BgJgNQB/RgJgdQB/hgJgtQB/xgJg9QBABkJhNQBARkJhdQBAhkJhtQB" + + "AxkJh9QBBBkJiNQBBRkJidQBBhkJitQBBxkJi9QBCBkJjNQBCRkJjdQBChkJjtQBCxkJj9QBDBkJkNQBDRkJkdQBDhkJktQBDxkJ" + + "k9QBEBkJlNQBERkJldQBEhkJltQBExkJl9QBFBkJmNQBFRkJmdQBFhkJmtQBFxkJm9QBGBkJnNQBGRkJndQBGhkDntQBGhkJn9QB" + + "GxkJodQBHBkDotQBHBkJpNQBHRkDpdQBHRkJptQBHhkJqNQBHxkDqdQBHxkJqtQBIBkJq9QBIRkJrNQBIhkJrdQBIxkDrtQBIxkJ" + + "r9QBJBkJsNQBJRkJsdQBJhkJstQBJxkJs9QBKBkJtNQBKRkJtdQBKhkJttQBKxkJt9QBLBkJuNQBLRkJudQBLhkJutQBLxkDu9QB" + + "LxkJvNQBMBkDvdQBMBkJvtQBMRkJv9QBMhkJwNQBMxkJwdQBNBkJwtQBNRkJw9QBNhkJxNQBNxkDxdQBNxkJxtQBOBkJx9QBORkJ" + + "yNQBOhkJydQBOxkJytQBPBkJy9QBPRkJzNQBPhkJzdQBPxkJztQBQBkJz9QBQRkJ0NQBQhkJ0dQBQxkJ0tQBRBkJ09QBRRkJ1NQB" + + "RhkJ1dQBRxkJ1tQBSBkJ19QBSRkJ2NQBShkJ2dQBSxkJ2tQBTBkJ29QBTRkJ3NQBThkJ3dQBTxkJ3tQBUBkJ39QBURkJ4NQBUhkJ" + + "4dQBUxkJ4tQBVBkJ49QBVRkJ5NQBVhkJ5dQBVxkJ5tQBWBkJ59QBWRkJ6NQBWhkJ6dQBWxkJ6tQBXBkJ69QBXRkJ7NQBXhkJ7dQB" + + "XxkJ7tQBYBkJ79QBYRkJ8NQBYhkJ8dQBYxkJ8tQBZBkJ89QBZRkJ9NQBZhkJ9dQBZxkJ9tQBaBkJ99QBaRkJ+NQBahkJ+dQBaxkJ" + + "+tQBbBkJ+9QBbRkJ/NQBbhkJ/dQBbxkJ/tQBcBkJ/9QBcRkJANUBchkJAdUBcxkJAtUBdBkJA9UBdRkJBNUBdhkJBdUBdxkJBtUB" + + "eBkDB9UBeBkJCNUBeRkJCdUBehkJCtUBexkJDNUBfBkDDdUBfBkJDtUBfRkJD9UBfhkJENUBfxkJEdUBgBkJEtUBgRkJE9UBghkJ" + + "FNUBgxkJFdUBhBkDFtUBhBkJF9UBhRkJGNUBhhkJGdUBhxkJGtUBiBkJG9UBiRkJHNUBihkJHdUBixkDHtUBixkJH9UBjBkJINUB" + + "jRkJIdUBjhkJItUBjxkJI9UBkBkJJNUBkRkJJdUBkhkJJtUBkxkJJ9UBlBkJKNUBlRkJKdUBlhkJKtUBlxkJK9UBmBkJLNUBmRkJ" + + "LdUBmhkJLtUBmxkJL9UBnBkJMNUBnRkJMdUBnhkJMtUBnxkJM9UBoBkJNNUBoRkJNdUBohkJNtUBoxkJN9UBpBkJONUBpRkJOdUB" + + "phkJOtUBpxkDO9UBpxkJPNUBqBkJPdUBqRkJPtUBqhkJP9UBqxkDQNUBqxkJQdUBrBkJQtUBrRkJQ9UBrhkJRNUBrxkJRdUBsBkD" + + "RtUBsBkJSdUBsRkDStUBsRkJS9UBshkJTNUBsxkJTdUBtBkJTtUBtRkJT9UBthkJUNUBtxkJUdUBuBkDUtUBuBkJU9UBuRkJVNUB" + + "uhkJVdUBuxkJVtUBvBkJV9UBvRkJWNUBvhkJWdUBvxkJWtUBwBkJW9UBwRkJXNUBwhkJXdUBwxkJXtUBxBkJX9UBxRkJYNUBxhkJ" + + "YdUBxxkJYtUByBkJY9UByRkJZNUByhkJZdUByxkJZtUBzBkJZ9UBzRkJaNUBzhkJadUBzxkJatUB0BkJa9UB0RkJbNUB0hkJbdUB" + + "0xkJbtUB1BkJb9UB1RkJcNUB1hkJcdUB1xkJctUB2BkJc9UB2RkJdNUB2hkJddUB2xkJdtUB3BkJd9UB3RkJeNUB3hkJedUB3xkJ" + + "etUB4BkJe9UB4RkJfNUB4hkJfdUB4xkJftUB5BkJf9UB5RkJgNUB5hkJgdUB5xkJgtUB6BkJg9UB6RkJhNUB6hkJhdUB6xkJhtUB" + + "7BkJh9UB7RkJiNUB7hkJidUB7xkJitUB8BkJi9UB8RkJjNUB8hkJjdUB8xkJjtUB9BkJj9UB9RkJkNUB9hkJkdUB9xkJktUB+BkJ" + + "k9UB+RkJlNUB+hkJldUB+xkJltUB/BkJl9UB/RkJmNUB/hkJmdUB/xkJmtUBABoJm9UBARoJnNUBAhoJndUBAxoJntUBBBoJn9UB" + + "BRoJoNUBBhoJodUBBxoJotUBCBoJo9UBCRoJpNUBChoJpdUBCxoJptUBDBoJp9UBDRoJqNUBDhoJqdUBDxoJqtUBEBoJq9UBERoJ" + + "rNUBEhoJrdUBExoJrtUBFBoJr9UBFRoJsNUBFhoJsdUBFxoJstUBGBoJs9UBGRoJtNUBGhoJtdUBGxoJttUBHBoJt9UBHRoJuNUB" + + "HhoJudUBHxoJutUBIBoJu9UBIRoJvNUBIhoJvdUBIxoJvtUBJBoJv9UBJRoJwNUBJhoJwdUBJxoJwtUBKBoJw9UBKRoJxNUBKhoJ" + + "xdUBKxoJxtUBLBoJx9UBLRoJyNUBLhoJydUBLxoJytUBMBoJy9UBMRoJzNUBMhoJzdUBMxoJztUBNBoJz9UBNRoJ0NUBNhoJ0dUB" + + "NxoJ0tUBOBoJ09UBORoJ1NUBOhoJ1dUBOxoJ1tUBPBoJ19UBPRoJ2NUBPhoJ2dUBPxoJ2tUBQBoJ29UBQRoJ3NUBQhoJ3dUBQxoJ" + + "3tUBRBoJ39UBRRoJ4NUBRhoJ4dUBRxoJ4tUBSBoJ49UBSRoJ5NUBShoJ5dUBSxoJ5tUBTBoJ59UBTRoJ6NUBThoJ6dUBTxoJ6tUB" + + "UBoJ69UBURoJ7NUBUhoJ7dUBUxoJ7tUBVBoJ79UBVRoJ8NUBVhoJ8dUBVxoJ8tUBWBoJ89UBWRoJ9NUBWhoJ9dUBWxoJ9tUBXBoJ" + + "99UBXRoJ+NUBXhoJ+dUBXxoJ+tUBYBoJ+9UBYRoJ/NUBYhoJ/dUBYxoJ/tUBZBoJ/9UBZRoJANYBZhoJAdYBZxoJAtYBaBoJA9YB" + + "aRoJBNYBahoJBdYBaxoJBtYBbBoJB9YBbRoJCNYBbhoJCdYBbxoJCtYBcBoJC9YBcRoJDNYBchoJDdYBcxoJDtYBdBoJD9YBdRoJ" + + "ENYBdhoJEdYBdxoJEtYBeBoJE9YBeRoJFNYBehoJFdYBexoJFtYBfBoJF9YBfRoJGNYBfhoJGdYBfxoJGtYBgBoJG9YBgRoJHNYB" + + "ghoJHdYBgxoJHtYBhBoJH9YBhRoJINYBhhoJIdYBhxoJItYBiBoJI9YBiRoJJNYBihoJJdYBixoJJtYBjBoJJ9YBjRoJKNYBjhoJ" + + "KdYBjxoJKtYBkBoJK9YBkRoJLNYBkhoJLdYBkxoJLtYBlBoJL9YBlRoJMNYBlhoJMdYBlxoJMtYBmBoJM9YBmRoJNNYBmhoJNdYB" + + "mxoJNtYBnBoJN9YBnRoJONYBnhoJOdYBnxoJOtYBoBoJO9YBoRoJPNYBohoJPdYBoxoJPtYBpBoJP9YBpRoJQNYBphoJQdYBpxoJ" + + "QtYBqBoJQ9YBqRoJRNYBqhoJRdYBqxoJRtYBrBoJR9YBrRoJSNYBrhoJSdYBrxoJStYBsBoJS9YBsRoJTNYBshoJTdYBsxoJTtYB" + + "tBoJT9YBtRoJUNYBthoJUdYBtxoJUtYBuBoJU9YBuRoJVNYBuhoJVdYBuxoJVtYBvBoJV9YBvRoJWNYBvhoJWdYBvxoJWtYBwBoJ" + + "W9YBwRoJXNYBwhoJXdYBwxoJXtYBxBoJX9YBxRoJYNYBxhoJYdYBxxoJYtYByBoJY9YByRoJZNYByhoJZdYByxoJZtYBzBoJZ9YB" + + "zRoJaNYBzhoJadYBzxoJatYB0BoJa9YB0RoJbNYB0hoJbdYB0xoJbtYB1BoJb9YB1RoJcNYB1hoJcdYB1xoJctYB2BoJc9YB2RoJ" + + "dNYB2hoJddYB2xoJdtYB3BoJd9YB3RoJeNYB3hoJedYB3xoJetYB4BoJe9YB4RoJfNYB4hoJfdYB4xoJftYB5BoJf9YB5RoJgNYB" + + "5hoJgdYB5xoJgtYB6BoJg9YB6RoJhNYB6hoJhdYB6xoJhtYB7BoJh9YB7RoJiNYB7hoJidYB7xoJitYB8BoJi9YB8RoJjNYB8hoJ" + + "jdYB8xoJjtYB9BoJj9YB9RoJkNYB9hoJkdYB9xoJktYB+BoJk9YB+RoJlNYB+hoJldYB+xoJltYB/BoJl9YB/RoJmNYB/hoJmdYB" + + "/xoJmtYBABsJm9YBARsJnNYBAhsJndYBAxsJntYBBBsJn9YBBRsJoNYBBhsJodYBBxsJotYBCBsJo9YBCRsJpNYBChsJpdYBCxsJ" + + "p9YBDBsDqNYBDBsJqdYBDRsJqtYBDhsJq9YBDxsJrNYBEBsJrdYBERsJrtYBEhsJr9YBExsJsNYBFBsJsdYBFRsJstYBFhsJs9YB" + + "FxsJtNYBGBsJtdYBGRsJttYBGhsJt9YBGxsJuNYBHBsJudYBHRsJutYBHhsJu9YBHxsJvNYBIBsJvdYBIRsJvtYBIhsJv9YBIxsJ" + + "wNYBJBsJwdYBJRsJwtYBJhsJw9YBJxsJxNYBKBsJxdYBKRsJxtYBKhsJx9YBKxsJyNYBLBsJydYBLRsJytYBLhsJy9YBLxsJzNYB" + + "MBsJzdYBMRsJztYBMhsJz9YBMxsJ0NYBNBsJ0dYBNRsJ0tYBNhsJ1NYBNxsJ1dYBOBsJ1tYBORsJ19YBOhsJ2NYBOxsJ2dYBPBsJ" + + "2tYBPRsJ29YBPhsJ3NYBPxsJ3dYBQBsJ3tYBQRsJ39YBQhsJ4NYBQxsJ4dYBRBsJ4tYBRRsJ49YBRhsJ5NYBRxsJ5dYBSBsJ5tYB" + + "SRsJ59YBShsJ6NYBSxsJ6dYBTBsJ6tYBTRsJ69YBThsJ7NYBTxsJ7dYBUBsJ7tYBURsJ79YBUhsJ8NYBUxsJ8dYBVBsJ8tYBVRsJ" + + "89YBVhsJ9NYBVxsJ9dYBWBsJ9tYBWRsJ99YBWhsJ+NYBWxsJ+dYBXBsJ+tYBXRsJ+9YBXhsJ/NYBXxsJ/dYBYBsJ/tYBYRsJ/9YB" + + "YhsJANcBYxsJAdcBZBsJAtcBZRsJA9cBZhsJBNcBZxsJBdcBaBsJBtcBaRsJB9cBahsJCNcBaxsJCdcBbBsJCtcBbRsJC9cBbhsJ" + + "DNcBbxsJDtcBcBsJD9cBcRsJENcBchsJEdcBcxsJEtcBdBsJE9cBdRsJFNcBdhsJFdcBdxsJFtcBeBsJF9cBeRsJGNcBehsJGdcB" + + "exsJGtcBfBsJG9cBfRsJHNcBfhsJHdcBfxsJHtcBgBsJH9cBgRsJINcBghsJIdcBgxsJItcBhBsJI9cBhRsJJNcBhhsJJdcBhxsJ" + + "JtcBiBsJJ9cBiRsJKNcBihsJKdcBixsJKtcBjBsJK9cBjRsJLNcBjhsJLdcBjxsJLtcBkBsJL9cBkRsJMNcBkhsJMdcBkxsJMtcB" + + "lBsJM9cBlRsJNNcBlhsJNdcBlxsJNtcBmBsJN9cBmRsJONcBmhsJOdcBmxsJOtcBnBsJO9cBnRsJPNcBnhsJPdcBnxsJPtcBoBsJ" + + "P9cBoRsJQNcBohsJQdcBoxsJQtcBpBsJQ9cBpRsJRNcBphsJRdcBpxsJRtcBqBsJSNcBqRsJSdcBqhsJStcBqxsJS9cBrBsJTNcB" + + "rRsJTdcBrhsJTtcBrxsJT9cBsBsJUNcBsRsJUdcBshsJUtcBsxsJU9cBtBsJVNcBtRsJVdcBthsJVtcBtxsJV9cBuBsJWNcBuRsJ" + + "WdcBuhsJWtcBuxsJW9cBvBsJXNcBvRsJXdcBvhsJXtcBvxsJX9cBwBsJYNcBwRsJYdcBwhsJYtcBwxsJY9cBxBsJZNcBxRsJZdcB" + + "xhsJZtcBxxsJZ9cByBsJaNcByRsJadcByhsJatcByxsJa9cBzBsJbNcBzRsJbdcBzhsJbtcBzxsJb9cB0BsJcNcB0RsJcdcB0hsJ" + + "ctcB0xsJc9cB1BsJdNcB1RsJddcB1hsJdtcB1xsJd9cB2BsJeNcB2RsJedcB2hsJetcB2xsJe9cB3BsJfNcB3RsJfdcB3hsJftcB" + + "3xsJf9cB4BsJgNcB4RsJgtcB4hsJg9cB4xsJhNcB5BsJhdcB5RsJhtcB5hsJh9cB5xsJiNcB6BsJidcB6RsJitcB6hsJi9cB6xsJ" + + "jNcB7BsJjdcB7RsJjtcB7hsJj9cB7xsJkNcB8BsJkdcB8RsJktcB8hsJk9cB8xsJlNcB9BsJldcB9RsJltcB9hsJl9cB9xsJmNcB" + + "+BsJmdcB+RsJmtcB+hsJm9cB+xsJnNcB/BsJndcB/RsJntcB/hsJn9cB/xsJoNcBABwJodcBARwJotcBAhwJo9cBAxwJpNcBBBwJ" + + "pdcBBRwJptcBBhwJp9cBBxwJqNcBCBwJqdcBCRwJqtcBChwJq9cBCxwJrNcBDBwJrdcBDRwJrtcBDhwJr9cBDxwJsNcBEBwJsdcB" + + "ERwJstcBEhwJs9cBExwJtNcBFBwJtdcBFRwJttcBFhwJt9cBFxwJuNcBGBwJudcBGRwJutcBGhwJvNcBGxwJvdcBHBwJvtcBHRwJ" + + "v9cBHhwJwNcBHxwJwdcBIBwJwtcBIRwJw9cBIhwJxNcBIxwJxdcBJBwJxtcBJRwJx9cBJhwJyNcBJxwJydcBKBwJy9cBKRwJzdcB" + + "KhwDztcBKhwJz9cBKxwJ0NcBLBwJ0dcBLRwJ0tcBLhwJ09cBLxwJ1NcBMBwJ1dcBMRwJ1tcBMhwJ19cBMxwJ2NcBNBwJ2dcBNRwJ" + + "2tcBNhwJ29cBNxwJ3NcBOBwJ3dcBORwJ3tcBOhwJ39cBOxwJ4NcBPBwJ4dcBPRwJ4tcBPhwJ49cBPxwJ5NcBQBwJ5dcBQRwJ5tcB" + + "QhwJ59cBQxwJ6NcBRBwJ6dcBRRwJ6tcBRhwJ69cBRxwJ7NcBSBwJ7dcBSRwJ7tcBShwJ79cBSxwJ8NcBTBwJ8dcBTRwJ8tcBThwJ" + + "89cBTxwJ9NcBUBwJ9dcBURwJ9tcBUhwJ99cBUxwJ+NcBVBwJ+dcBVRwJ+tcBVhwJ+9cBVxwJ/NcBWBwJ/dcBWRwJ/tcBWhwJ/9cB" + + "WxwJ/9kBXBwCNtoBXBwCOtoBXBwCbNoBXBwCdNoBXBwCddoBXBwCg9oBXBwChNoBXBwCi9oBXBwCmtoBXBwDn9oBXBwCoNoBXBwD" + + "r9oBXBwC/94BXBwDHt8BXBwCJN8BXBwDKt8BXBwC/98BXBwDBuABXBwCB+ABXBwDGOABXBwCGuABXBwDIeABXBwCIuABXBwDJOAB" + + "XBwCJeABXBwDKuABXBwCL+ABXBwDMOABXBwJMeABXRwJMuABXhwJM+ABXxwJNOABYBwJNeABYRwJNuABYhwJN+ABYxwJOOABZBwJ" + + "OeABZRwJOuABZhwJO+ABZxwJPOABaBwJPeABaRwJPuABahwJP+ABaxwJQOABbBwJQeABbRwJQuABbhwJQ+ABbxwJROABcBwJReAB" + + "cRwJRuABchwJR+ABcxwJSOABdBwJSeABdRwJSuABdhwJS+ABdxwJTOABeBwJTeABeRwJTuABehwJT+ABexwJUOABfBwJUeABfRwJ" + + "UuABfhwJU+ABfxwJVOABgBwJVeABgRwJVuABghwJV+ABgxwJWOABhBwJWeABhRwJWuABhhwJW+ABhxwJXOABiBwJXeABiRwJXuAB" + + "ihwJX+ABixwJYOABjBwJYeABjRwJYuABjhwJY+ABjxwJZOABkBwJZeABkRwJZuABkhwJZ+ABkxwJaOABlBwJaeABlRwJauABlhwJ" + + "a+ABlxwJbOABmBwJbeABmRwJjuABmhwDj+ABmhwC/+ABmhwDLOEBmhwCL+EBmhwDPeEBmhwCP+EBmhwDSeEBmhwCTeEBmhwDTuEB" + + "mhwCT+EBmhwCj+IBmhwDruIBmhwCv+IBmhwD+eIBmhwC/uIBmhwD/+IBmhwCz+QBmhwD+eQBmhwCz+UBmhwD+uUBmhwC/uUBmhwD" + + "/+UBmhwCv+YBmhwD3uYBmhwC3+YBmhwD9eYBmhwC/eYBmhwD/+YBmhwC3+cBmhwD5ucBmhwC5+cBmhwD6+cBmhwC7OcBmhwD7ucB" + + "mhwC7+cBmhwD/ucBmhwC/+cBmhwDxOgBmhwCxugBmhwDz+gBmhwC1ugBmhwC/+gBmhwDAOkBmhwRAekBnBwRAukBnhwRA+kBoBwR" + + "BOkBohwRBekBpBwRBukBphwRB+kBqBwRCOkBqhwRCekBrBwRCukBrhwRC+kBsBwRDOkBshwRDekBtBwRDukBthwRD+kBuBwREOkB" + + "uhwREekBvBwREukBvhwRE+kBwBwRFOkBwhwRFekBxBwRFukBxhwRF+kByBwRGOkByhwRGekBzBwRGukBzhwRG+kB0BwRHOkB0hwR" + + "HekB1BwRHukB1hwRH+kB2BwRIOkB2hwRIekB3BwRSukB3hwCS+kB3hwCT+kB3hwDWekB3hwCXekB3hwDX+kB3hwCcOwB3hwDtOwB" + + "3hwCAO0B3hwDPe0B3hwC/+0B3hwDAO4B3hwJAe4B3xwJAu4B4BwJA+4B4RwJBO4B4hwDBe4B4hwJBu4B4xwJB+4B5BwJCO4B5RwJ" + + "Ce4B5hwJCu4B5xwJC+4B6BwJDO4B6RwJDe4B6hwJDu4B6xwJD+4B7BwJEO4B7RwJEe4B7hwJEu4B7xwJE+4B8BwJFO4B8RwJFe4B" + + "8hwJFu4B8xwJF+4B9BwJGO4B9RwJGe4B9hwJGu4B9xwJG+4B+BwJHO4B+RwJHe4B+hwJHu4B+xwJH+4B/BwJIO4B/RwDIe4B/RwJ" + + "Iu4B/hwJI+4B/xwDJO4B/xwJJu4BAB0DJ+4BAB0JKO4BAR0DKe4BAR0JKu4BAh0JK+4BAx0JLO4BBB0JLe4BBR0JLu4BBh0JL+4B" + + "Bx0JMO4BCB0JMe4BCR0JMu4BCh0JM+4BCx0DNO4BCx0JNe4BDB0JNu4BDR0JN+4BDh0JOO4BDx0DOe4BDx0JOu4BEB0DO+4BEB0J" + + "Qe4BER0DQu4BER0JRu4BEh0DR+4BEh0JSO4BEx0DSe4BEx0JSu4BFB0DS+4BFB0JTO4BFR0DTe4BFR0JTu4BFh0JT+4BFx0JUO4B" + + "GB0DUe4BGB0JUu4BGR0JU+4BGh0DVO4BGh0JVu4BGx0DV+4BGx0JWO4BHB0DWe4BHB0JWu4BHR0DW+4BHR0JXO4BHh0DXe4BHh0J" + + "Xu4BHx0DX+4BHx0JYO4BIB0DYe4BIB0JYu4BIR0JY+4BIh0DZO4BIh0JZu4BIx0DZ+4BIx0JaO4BJB0Jae4BJR0Jau4BJh0Ja+4B" + + "Jx0DbO4BJx0Jbe4BKB0Jbu4BKR0Jb+4BKh0JcO4BKx0Jce4BLB0Jcu4BLR0Jc+4BLh0DdO4BLh0Jde4BLx0Jdu4BMB0Jd+4BMR0J" + + "eO4BMh0Dee4BMh0Jeu4BMx0Je+4BNB0JfO4BNR0Jfe4BNh0Dfu4BNh0Jf+4BNx0DgO4BNx0Jge4BOB0Jgu4BOR0Jg+4BOh0JhO4B" + + "Ox0Jhe4BPB0Jhu4BPR0Jh+4BPh0JiO4BPx0Jie4BQB0Jiu4BQR0Di+4BQR0JjO4BQh0Jje4BQx0Jju4BRB0Jj+4BRR0JkO4BRh0J" + + "ke4BRx0Jku4BSB0Jk+4BSR0JlO4BSh0Jle4BSx0Jlu4BTB0Jl+4BTR0JmO4BTh0Jme4BTx0Jmu4BUB0Jm+4BUR0JoO4BUh0Doe4B" + + "Uh0Jou4BUx0Jo+4BVB0JpO4BVR0Dpe4BVR0Jpu4BVh0Jp+4BVx0JqO4BWB0Jqe4BWR0Jqu4BWh0Dq+4BWh0JrO4BWx0Jre4BXB0J" + + "ru4BXR0Jr+4BXh0JsO4BXx0Jse4BYB0Jsu4BYR0Js+4BYh0JtO4BYx0Jte4BZB0Jtu4BZR0Jt+4BZh0JuO4BZx0Jue4BaB0Juu4B" + + "aR0Ju+4Bah0J7+4Bax0D8e4Bax0C/+8Bax0DK/ABax0CL/ABax0Dk/ABax0Cn/ABax0DrvABax0CsPABax0DvvABax0Cv/ABax0C" + + "wPABax0Dz/ABax0C0PABax0D3/ABax0C9fABax0C//ABax0DAPEBax0DAfEBax0RAvEBbR0RA/EBbx0RBPEBcR0RBfEBcx0RBvEB" + + "dR0RB/EBdx0RCPEBeR0RCfEBex0RCvEBfR0RDPEBfx0CD/EBfx0CEPEBfx0ZEfEBgh0ZEvEBhR0ZE/EBiB0ZFPEBix0ZFfEBjh0Z" + + "FvEBkR0ZF/EBlB0ZGPEBlx0ZGfEBmh0ZGvEBnR0ZG/EBoB0ZHPEBox0ZHfEBph0ZHvEBqR0ZH/EBrB0ZIPEBrx0ZIfEBsh0ZIvEB" + + "tR0ZI/EBuB0ZJPEBux0ZJfEBvh0ZJvEBwR0ZJ/EBxB0ZKPEBxx0ZKfEByh0ZKvEBzR0ZK/EB0B0JLPEB0R0JLfEB0h0RLvEB1B0R" + + "L/EB1h0CMPEB1h0JMfEB1x0JMvEB2B0JM/EB2R0JNPEB2h0JNfEB2x0JNvEB3B0JN/EB3R0JOPEB3h0JOfEB3x0JOvEB4B0JO/EB" + + "4R0JPPEB4h0JPfEB4x0JPvEB5B0JP/EB5R0JQPEB5h0JQfEB5x0JQvEB6B0JQ/EB6R0JRPEB6h0JRfEB6x0JRvEB7B0JR/EB7R0J" + + "SPEB7h0JSfEB7x0JSvEB8B0RS/EB8h0RTPEB9B0RTfEB9h0RTvEB+B0ZT/EB+x0RVvEB/R0CV/EB/R0CXvEB/R0CX/EB/R0CafEB" + + "/R0CavEB/R0Ra/EB/x0RbPEBAR4Rb/EBAx4CePEBAx4CefEBAx4CevEBAx4CfPEBAx4CfvEBAx4Cf/EBAx4CifEBAx4CjfEBAx4C" + + "j/EBAx4CkPEBAx4RmvEBBR4CrPEBBR4CrfEBBR4C5fEBBR4D//EBBR4CAPIBBR4RAfIBBx4RAvIBCR4JD/IBCh4DEPIBCh4JEfIB" + + "Cx4JEvIBDB4JE/IBDR4JFPIBDh4JFfIBDx4JFvIBEB4JF/IBER4JGPIBEh4JGfIBEx4JGvIBFB4JG/IBFR4JHPIBFh4JHfIBFx4J" + + "HvIBGB4JH/IBGR4JIPIBGh4JIfIBGx4JIvIBHB4JI/IBHR4JJPIBHh4JJfIBHx4JJvIBIB4JJ/IBIR4JKPIBIh4JKfIBIx4JKvIB" + + "JB4JK/IBJR4JLPIBJh4JLfIBJx4JLvIBKB4JL/IBKR4JMPIBKh4JMfIBKx4JMvIBLB4JM/IBLR4JNPIBLh4JNfIBLx4JNvIBMB4J" + + "N/IBMR4JOPIBMh4JOfIBMx4JOvIBNB4JO/IBNR4JP/IBNh4DQPIBNh4ZQfIBOR4ZQvIBPB4ZQ/IBPx4ZRPIBQh4ZRfIBRR4ZRvIB" + + "SB4ZR/IBSx4ZSPIBTh4ZT/IBUR4DUPIBUR4JUfIBUh4JX/IBUx4DZfIBUx4C//IBUx4DIPMBUx4CLPMBUx4CL/MBUx4CNfMBUx4C" + + "NvMBUx4CfPMBUx4CffMBUx4Cf/MBUx4Ck/MBUx4Cn/MBUx4CxPMBUx4CxfMBUx4CyvMBUx4CzvMBUx4C0/MBUx4C3/MBUx4C8PMB" + + "Ux4C9/MBUx4C//MBUx4CPvQBUx4CP/QBUx4CQPQBUx4CQfQBUx4C9/QBUx4C+PQBUx4C/PQBUx4C/vQBUx4C//QBUx4CPfUBUx4C" + + "P/UBUx4CQ/UBUx4CSvUBUx4CT/UBUx4CZ/UBUx4CefUBUx4CevUBUx4Co/UBUx4CpPUBUx4C+vUBUx4C//UBUx4CAPYBUx4CEPYB" + + "Ux4CEfYBUx4CFPYBUx4CFfYBUx4CFvYBUx4CF/YBUx4CGPYBUx4CGfYBUx4CGvYBUx4CG/YBUx4CHvYBUx4CH/YBUx4CJfYBUx4C" + + "J/YBUx4CK/YBUx4CLPYBUx4CLfYBUx4CL/YBUx4CM/YBUx4CNPYBUx4CQPYBUx4CQvYBUx4CRPYBUx4CT/YBUx4Cf/YBUx4CxfYB" + + "Ux4Cz/YBUx4C0PYBUx4C0vYBUx4C1PYBUx4C1fYBUx4C1/YBUx4C2PYBUx4C2/YBUx4D3PYBUx4C3/YBUx4C7PYBUx4C7/YBUx4D" + + "8/YBUx4C9vYBUx4C+PYBUx4C+fYBUx4C+vYBUx4C/PYBUx4C//YBUx4Dc/cBUx4CdvcBUx4CevcBUx4Cf/cBUx4C1PcBUx4C2PcB" + + "Ux4C2fcBUx4C3/cBUx4D6/cBUx4C7/cBUx4D8PcBUx4C//cBUx4DC/gBUx4CD/gBUx4DR/gBUx4CT/gBUx4DWfgBUx4CX/gBUx4D" + + "h/gBUx4Cj/gBUx4DrfgBUx4Cr/gBUx4DsfgBUx4Cu/gBUx4Cv/gBUx4DwfgBUx4Cz/gBUx4D2PgBUx4C//gBUx4DC/kBUx4CDPkB" + + "Ux4CD/kBUx4CGPkBUx4CHvkBUx4CH/kBUx4CJ/kBUx4CL/kBUx4CMPkBUx4CMvkBUx4CPvkBUx4CP/kBUx4CS/kBUx4CTPkBUx4C" + + "T/kBUx4CXvkBUx4Ca/kBUx4CcPkBUx4CcfkBUx4CcvkBUx4CdvkBUx4CePkBUx4CefkBUx4CevkBUx4Ce/kBUx4Cf/kBUx4ChPkB" + + "Ux4CkfkBUx4Cl/kBUx4CovkBUx4CpPkBUx4CqvkBUx4CrfkBUx4Cr/kBUx4CufkBUx4Cv/kBUx4CwPkBUx4CwvkBUx4CyvkBUx4C" + + "y/kBUx4CzPkBUx4Cz/kBUx4C5vkBUx4C//kBUx4CU/oBUx4CV/oBUx4CX/oBUx4DbfoBUx4Cb/oBUx4Dc/oBUx4CdPoBUx4Cd/oB" + + "Ux4CevoBUx4CfPoBUx4Cf/oBUx4DgvoBUx4ChvoBUx4CiPoBUx4CifoBUx4CivoBUx4CjfoBUx4DjvoBUx4Cj/oBUx4ClfoBUx4C" + + "qPoBUx4CrPoBUx4Cr/oBUx4CtvoBUx4CuvoBUx4CvfoBUx4CvvoBUx4Cv/oBUx4CwvoBUx4CxfoBUx4CxvoBUx4Cx/oBUx4DyPoB" + + "Ux4CzPoBUx4DzfoBUx4Cz/oBUx4C1voBUx4C2foBUx4C2/oBUx4C3PoBUx4C3voBUx4D3/oBUx4C5/oBUx4C6PoBUx4C6foBUx4C" + + "6voBUx4C7voBUx4D7/oBUx4C9voBUx4C+PoBUx4C//oBUx4DkvsBUx4Ck/sBUx4DyvsBUx4C7/sBUx4C8PsBUx4J8fsBVB4J8vsB" + + "VR4J8/sBVh4J9PsBVx4J9fsBWB4J9vsBWR4J9/sBWh4J+PsBWx4J+fsBXB4J+vsBXR4C/f8BXR4D//8BXR4D1qYCXR4C3aYCXR4C" + + "36YCXR4C/6YCXR4DNLcCXR4COLcCXR4CObcCXR4CP7cCXR4CHbgCXR4CH7gCXR4Doc4CXR4Crc4CXR4Cr84CXR4D4OsCXR4C7+sC" + + "XR4DXe4CXR4C//cCXR4DAPgCXR4JAfgCXh4JAvgCXx4JA/gCYB4RBPgCYh4JBfgCYx4JBvgCZB4JB/gCZR4JCPgCZh4JCfgCZx4J" + + "CvgCaB4JC/gCaR4JDPgCah4JDfgCax4RDvgCbR4JD/gCbh4JEPgCbx4JEfgCcB4JEvgCcR4RE/gCcx4JFPgCdB4JFfgCdR4JFvgC" + + "dh4RF/gCeB4JGPgCeR4JGfgCeh4JGvgCex4JG/gCfB4JHPgCfR4RHfgCfx4JHvgCgB4JH/gCgR4JIPgCgh4JIfgCgx4JIvgChB4J" + + "I/gChR4JJPgChh4JJfgChx4JJvgCiB4JJ/gCiR4JKPgCih4JKfgCix4JKvgCjB4JK/gCjR4JLPgCjh4JLfgCjx4JLvgCkB4JL/gC" + + "kR4JMPgCkh4JM/gCkx4JNPgClB4RNfgClh4JNvgClx4JN/gCmB4JOPgCmR4ROfgCmx4JOvgCnB4JO/gCnR4JPPgCnh4JPfgCnx4J" + + "PvgCoB4JP/gCoR4JQPgCoh4JQfgCox4JQvgCpB4JQ/gCpR4JRPgCph4JRvgCpx4JR/gCqB4JSPgCqR4JSfgCqh4JSvgCqx4JS/gC" + + "rB4JTPgCrR4JTfgCrh4JTvgCrx4JT/gCsB4JUPgCsR4JUfgCsh4JUvgCsx4JU/gCtB4JVPgCtR4JVfgCth4JVvgCtx4JV/gCuB4J" + + "WPgCuR4JWfgCuh4RWvgCvB4JW/gCvR4JXPgCvh4JXfgCvx4JXvgCwB4JX/gCwR4JYPgCwh4RYfgCxB4RYvgCxh4JY/gCxx4JZPgC" + + "yB4JZfgCyR4JZvgCyh4JZ/gCyx4JaPgCzB4JafgCzR4Ja/gCzh4JbPgCzx4RbfgC0R4JbvgC0h4Jb/gC0x4JcPgC1B4JcfgC1R4R" + + "cvgC1x4Jc/gC2B4JdPgC2R4JdfgC2h4JdvgC2x4Jd/gC3B4JePgC3R4JefgC3h4JevgC3x4Je/gC4B4RfPgC4h4JffgC4x4RfvgC" + + "5R4Jf/gC5h4JgPgC5x4JgfgC6B4JgvgC6R4Jg/gC6h4JhPgC6x4JhfgC7B4JhvgC7R4Jh/gC7h4JiPgC7x4JifgC8B4RivgC8h4J" + + "i/gC8x4JjPgC9B4JjfgC9R4JjvgC9h4Jj/gC9x4RkPgC+R4JkvgC+h4Rk/gC/B4JlfgC/R4JlvgC/h4Jl/gC/x4RmPgCAR8RmfgC" + + "Ax8JmvgCBB8Jm/gCBR8JnPgCBh8JnfgCBx8JnvgCCB8Jn/gCCR8JoPgCCh8JofgCCx8JovgCDB8Jo/gCDR8JpPgCDh8RpfgCEB8J" + + "pvgCER8Jp/gCEh8JqPgCEx8JqfgCFB8JqvgCFR8Jq/gCFh8JrPgCFx8JrfgCGB8JrvgCGR8Jr/gCGh8JsPgCGx8JsfgCHB8JsvgC" + + "HR8Js/gCHh8JtPgCHx8JtfgCIB8JtvgCIR8Jt/gCIh8JuPgCIx8RufgCJR8JuvgCJh8Ju/gCJx8JvPgCKB8JvfgCKR8JvvgCKh8R" + + "v/gCLB8JwPgCLR8JwfgCLh8JwvgCLx8Jw/gCMB8JxPgCMR8JxfgCMh8JxvgCMx8Jx/gCNB8JyPgCNR8JyfgCNh8JyvgCNx8Ry/gC" + + "OR8JzPgCOh8JzfgCOx8JzvgCPB8Jz/gCPR8J0PgCPh8J0fgCPx8J0vgCQB8J0/gCQR8J1PgCQh8J1fgCQx8J1vgCRB8J1/gCRR8J" + + "2PgCRh8J2fgCRx8J2vgCSB8J2/gCSR8J3PgCSh8J3fgCSx8R3vgCTR8J3/gCTh8J4PgCTx8J4fgCUB8J4vgCUR8J4/gCUh8R5PgC" + + "VB8J5fgCVR8J5vgCVh8J5/gCVx8J6PgCWB8J6fgCWR8J6vgCWh8J6/gCWx8J7PgCXB8R7fgCXh8J7vgCXx8J7/gCYB8J8PgCYR8R" + + "8fgCYx8J8vgCZB8J8/gCZR8J9PgCZh8J9fgCZx8J9vgCaB8J9/gCaR8R+PgCax8R+fgCbR8R+vgCbx8J+/gCcB8R/PgCch8J/fgC" + + "cx8J/vgCdB8J//gCdR8JAPkCdh8JAfkCdx8JAvkCeB8JA/kCeR8JBPkCeh8JBfkCex8JBvkCfB8RB/kCfh8JCPkCfx8JCfkCgB8J" + + "CvkCgR8JC/kCgh8JDPkCgx8JDfkChB8RDvkChh8JD/kChx8JEPkCiB8REfkCih8REvkCjB8JE/kCjR8JFPkCjh8JFfkCjx8JFvkC" + + "kB8JF/kCkR8JGPkCkh8JGfkCkx8JGvkClB8JG/kClR8RHPkClx8JHfkCmB8RHvkCmh8JH/kCmx8RIPkCnR8JIfkCnh8JIvkCnx8J" + + "I/kCoB8RJPkCoh8JJfkCox8JJvkCpB8RJ/kCph8RKPkCqB8JKfkCqR8JKvkCqh8JK/kCqx8JLfkCrB8JLvkCrR8JL/kCrh8JMPkC" + + "rx8JMfkCsB8JMvkCsR8JM/kCsh8JNPkCsx8JNfkCtB8RNvkCth8JN/kCtx8ROPkCuR8JOfkCuh8ROvkCvB8JO/kCvR8RPPkCvx8R" + + "PfkCwR8RPvkCwx8JP/kCxB8JQPkCxR8JQfkCxh8RQvkCyB8RQ/kCyh8RRPkCzB8RRfkCzh8JR/kCzx8JSPkC0B8JSfkC0R8JSvkC" + + "0h8JS/kC0x8JTPkC1B8JTfkC1R8RTvkC1x8JT/kC2B8JUPkC2R8JUfkC2h8JUvkC2x8RU/kC3R8JVPkC3h8RVfkC4B8RVvkC4h8J" + + "V/kC4x8JWPkC5B8JWfkC5R8JWvkC5h8JW/kC5x8JXPkC6B8RXvkC6h8RX/kC7B8JYPkC7R8JYfkC7h8RYvkC8B8JY/kC8R8JZPkC" + + "8h8JZfkC8x8RZvkC9R8JZ/kC9h8JaPkC9x8JafkC+B8JavkC+R8Ja/kC+h8RbPkC/B8JbfkC/R8JbvkC/h8Jb/kC/x8JcPkCACAJ" + + "cfkCASAJcvkCAiARc/kCBCARdPkCBiAJdfkCByARdvkCCSAJd/kCCiARePkCDCAJefkCDSAJevkCDiAJe/kCDyARfPkCESARffkC" + + "EyAJfvkCFCARf/kCFiAJgPkCFyARgfkCGSAJgvkCGiAJg/kCGyAJhPkCHCAJhfkCHSAJhvkCHiAJh/kCHyARiPkCISARifkCIyAR" + + "ivkCJSARi/kCJyAJjPkCKCAJjfkCKSAJjvkCKiAJj/kCKyAJkPkCLCAJkfkCLSAJkvkCLiAJk/kCLyAJlPkCMCAJlfkCMSAJlvkC" + + "MiAJl/kCMyARmPkCNSAJmfkCNiAJmvkCNyAJm/kCOCAJnPkCOSAJnfkCOiAJnvkCOyAJn/kCPCAJoPkCPSAJofkCPiAJovkCPyAJ" + + "o/kCQCAJpPkCQSARpfkCQyARpvkCRSARp/kCRyAJqPkCSCAJqfkCSSAJqvkCSiAJq/kCSyARrPkCTSAJrfkCTiARrvkCUCAJr/kC" + + "USAJsPkCUiARsfkCVCARsvkCViAJs/kCVyAJtPkCWCAJtfkCWSAJtvkCWiAJt/kCWyAJuPkCXCAJufkCXSAJuvkCXiAJu/kCXyAJ" + + "vPkCYCAJvfkCYSAJvvkCYiAJv/kCYyAJwPkCZCAJwfkCZSAJwvkCZiAJw/kCZyAJxPkCaCAJxfkCaSARxvkCayAJx/kCbCAJyPkC" + + "bSAJyfkCbiAJyvkCbyAJy/kCcCARzPkCciARzfkCdCAJzvkCdSAJz/kCdiAJ0PkCdyAJ0fkCeCAJ0vkCeSAJ0/kCeiAR1PkCfCAJ" + + "1fkCfSAJ1vkCfiAJ1/kCfyAJ2PkCgCAR2fkCgiAR2vkChCAJ2/kChSAJ3PkChiAJ3fkChyAR3vkCiSAJ3/kCiiAJ4PkCiyAR4fkC" + + "jSAR4vkCjyAJ4/kCkCAJ5PkCkSAJ5fkCkiAR5vkClCAJ5/kClSAJ6PkCliAJ6fkClyAJ6vkCmCAJ6/kCmSAJ7PkCmiAJ7fkCmyAR" + + "7vkCnSAJ7/kCniAJ8PkCnyAJ8fkCoCAR8vkCoiAJ8/kCoyAJ9PkCpCAJ9fkCpSAJ9vkCpiAR9/kCqCAR+PkCqiAJ+fkCqyAJ+vkC" + + "rCAJ+/kCrSAR/PkCryAJ/fkCsCAR//kCsiAJAPoCsyAJAfoCtCARAvoCtiAJA/oCtyAJBPoCuCAJBfoCuSAJBvoCuiAJB/oCuyAJ" + + "CPoCvCAJCfoCvSARCvoCvyAJC/oCwCAJDPoCwSAJDfoCwiAJDvoCwyAJD/oCxCAJEPoCxSAREfoCxyAJEvoCyCARE/oCyiARFPoC" + + "zCARFfoCziAJFvoCzyAJF/oC0CAJGPoC0SAJGfoC0iAJGvoC0yAJG/oC1CAJHPoC1SAJHfoC1iAR/f8C2CAD//8C2CADShMD2CAC" + + "TxMD2CADryMD2CACeTQD2CAC/f8D2CAD//8D2CAD/f8E2CAD//8E2CAD/f8F2CAD//8F2CAD/f8G2CAD//8G2CAD/f8H2CAD//8H" + + "2CAD/f8I2CAD//8I2CAD/f8J2CAD//8J2CAD/f8K2CAD//8K2CAD/f8L2CAD//8L2CAD/f8M2CAD//8M2CAD/f8N2CAD//8N2CAD" + + "AAAO2CADAQAO2CADHwAO2CADfwAO2CAD/wAO2CAD7wEO2CAH/f8O2CAD//8O2CAD/f8P2CAD//8P2CAD/f8Q2CAD//8Q2CAD"; +export const TR46_MAPPING_BLOB = + "abcdefghijklmnopqrstuvwxyz ̈a ̄23 ́μ ̧1o1⁄41⁄23⁄4àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþssāăąćĉċčďđēĕėęěĝğġģ" + + "ĥħĩīĭįi̇ijĵķĺļľl·łńņňʼnŋōŏőœŕŗřśŝşšţťŧũūŭůűųŵŷÿźżžsɓƃƅɔƈɖɗƌǝəɛƒɠɣɩɨƙɯɲɵơƣƥʀƨʃƭʈưʊʋƴƶʒƹƽdžljnjǎǐǒǔǖǘǚ" + + "ǜǟǡǣǥǧǩǫǭǯdzǵƕƿǹǻǽǿȁȃȅȇȉȋȍȏȑȓȕȗșțȝȟƞȣȥȧȩȫȭȯȱȳⱥȼƚⱦɂƀʉʌɇɉɋɍɏhɦjrɹɻʁwy ̆ ̇ ̊ ̨ ̃ ̋ɣlsxʕ̀́̓̈́ιͱͳʹͷ ι;ϳ ́" + + " ̈́ά·έήίόύώαβγδεζηθικλμνξοπρστυφχψωϊϋσϗβθυύϋφπϙϛϝϟϡϣϥϧϩϫϭϯκρσθεϸσϻͻͼͽѐёђѓєѕіїјљњћќѝўџабвгдежзийклмно" + + "прстуфхцчшщъыьэюяѡѣѥѧѩѫѭѯѱѳѵѷѹѻѽѿҁҋҍҏґғҕҗҙқҝҟҡңҥҧҩҫҭүұҳҵҷҹһҽҿӏӂӄӆӈӊӌӎӑӓӕӗәӛӝӟӡӣӥӧөӫӭӯӱӳӵӷӹӻӽӿԁԃԅԇԉԋԍ" + + "ԏԑԓԕԗԙԛԝԟԡԣԥԧԩԫԭԯաբգդեզէըթժիլխծկհձղճմյնշոչպջռսվտրցւփքօֆեւاٴوٴۇٴيٴक़ख़ग़ज़ड़ढ़फ़य़ড়ঢ়য়ਲ਼ਸ਼ਖ਼ਗ਼ਜ਼ਫ਼ଡ" + + "଼ଢ଼ําໍາຫນຫມ་གྷཌྷདྷབྷཛྷཀྵཱཱིུྲྀྲཱྀླྀླཱཱྀྀྒྷྜྷྡྷྦྷྫྷྐྵⴀⴁⴂⴃⴄⴅⴆⴇⴈⴉⴊⴋⴌⴍⴎⴏⴐⴑⴒⴓⴔⴕⴖⴗⴘⴙⴚⴛⴜⴝⴞⴟⴠⴡⴢⴣⴤⴥⴧⴭნᏰᏱᏲᏳᏴᏵв" + + "достъѣꙋᲊაბგდევზთიკლმნოპჟრსტუფქღყშჩცძწჭხჯჰჱჲჳჴჵჶჷჸჹჺჽჾჿaæbdeǝghijklmnoȣprtuwaɐɑᴂbdeəɛɜgkmŋoɔᴖᴗptuᴝɯvᴥ" + + "βγδφχiruvβγρφχнɒcɕðɜfɟɡɥɨɩɪᵻʝɭᶅʟɱɰɲɳɴɵɸʂʃƫʉʊᴜʋʌzʐʑʒθḁḃḅḇḉḋḍḏḑḓḕḗḙḛḝḟḡḣḥḧḩḫḭḯḱḳḵḷḹḻḽḿṁṃṅṇṉṋṍṏṑṓṕṗṙṛṝṟ" + + "ṡṣṥṧṩṫṭṯṱṳṵṷṹṻṽṿẁẃẅẇẉẋẍẏẑẓẕaʾṡßạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹỻỽỿἀἁἂἃἄἅἆἇἐἑἒἓἔἕἠἡἢἣἤἥἦ" + + "ἧἰἱἲἳἴἵἶἷὀὁὂὃὄὅὑὓὕὗὠὡὢὣὤὥὦὧάέήίόύώἀιἁιἂιἃιἄιἅιἆιἇιἀιἁιἂιἃιἄιἅιἆιἇιἠιἡιἢιἣιἤιἥιἦιἧιἠιἡιἢιἣιἤιἥιἦιἧιὠι" + + "ὡιὢιὣιὤιὥιὦιὧιὠιὡιὢιὣιὤιὥιὦιὧιὰιαιάιᾶιᾰᾱὰάαι ̓ι ̓ ͂ ̈͂ὴιηιήιῆιὲέὴήηι ̓̀ ̓́ ̓͂ΐῐῑὶί ̔̀ ̔́ ̔͂ΰῠῡὺύῥ ̈̀" + + " ̈́`ὼιωιώιῶιὸόὼώωι ́ ̔ ‐ ̳ ′′′′′‵‵‵‵‵!! ̅???!!?′′′′ 0i456789+−=()n0123456789+−=()aeoxəhklmnpstrsa/ca" + + "/sc°cc/oc/uɛ°fghħilnnopqrsmteltmzωzkåbcefⅎmoאבגדifaxπγπ∑deij1⁄71⁄91⁄101⁄32⁄31⁄52⁄53⁄54⁄51⁄65⁄61⁄83⁄8" + + "5⁄87⁄81⁄iiiiiiivvviviiviiiixxxixiilcdmiiiiiiivvviviiviiiixxxixiilcdmↄ0⁄3∫∫∫∫∫∮∮∮∮∮〈〉1234567891011121" + + "314151617181920(1)(2)(3)(4)(5)(6)(7)(8)(9)(10)(11)(12)(13)(14)(15)(16)(17)(18)(19)(20)(a)(b)(c)(d)(e" + + ")(f)(g)(h)(i)(j)(k)(l)(m)(n)(o)(p)(q)(r)(s)(t)(u)(v)(w)(x)(y)(z)abcdefghijklmnopqrstuvwxyzabcdefghij" + + "klmnopqrstuvwxyz0∫∫∫∫::======⫝̸ⰰⰱⰲⰳⰴⰵⰶⰷⰸⰹⰺⰻⰼⰽⰾⰿⱀⱁⱂⱃⱄⱅⱆⱇⱈⱉⱊⱋⱌⱍⱎⱏⱐⱑⱒⱓⱔⱕⱖⱗⱘⱙⱚⱛⱜⱝⱞⱟⱡɫᵽɽⱨⱪⱬɑɱɐɒⱳⱶjvȿɀⲁⲃⲅⲇ" + + "ⲉⲋⲍⲏⲑⲓⲕⲗⲙⲛⲝⲟⲡⲣⲥⲧⲩⲫⲭⲯⲱⲳⲵⲷⲹⲻⲽⲿⳁⳃⳅⳇⳉⳋⳍⳏⳑⳓⳕⳗⳙⳛⳝⳟⳡⳣⳬⳮⳳⵡ母龟一丨丶丿乙亅二亠人儿入八冂冖冫几凵刀力勹匕匚匸十卜卩厂厶又口囗土士夂夊夕大女子宀寸小尢尸屮山巛工" + + "己巾干幺广廴廾弋弓彐彡彳心戈戶手支攴文斗斤方无日曰月木欠止歹殳毋比毛氏气水火爪父爻爿片牙牛犬玄玉瓜瓦甘生用田疋疒癶白皮皿目矛矢石示禸禾穴立竹米糸缶网羊羽老而耒耳聿肉臣自至臼舌舛舟艮色艸虍虫血行衣襾見角" + + "言谷豆豕豸貝赤走足身車辛辰辵邑酉釆里金長門阜隶隹雨靑非面革韋韭音頁風飛食首香馬骨高髟鬥鬯鬲鬼魚鳥鹵鹿麥麻黃黍黑黹黽鼎鼓鼠鼻齊齒龍龜龠 .〒十卄卅 ゙ ゚よりコトᄀᄁᆪᄂᆬᆭᄃᄄᄅᆰᆱᆲᆳᆴᆵᄚᄆᄇᄈᄡ" + + "ᄉᄊᄋᄌᄍᄎᄏᄐᄑ하ᅢᅣᅤᅥᅦᅧᅨᅩᅪᅫᅬᅭᅮᅯᅰᅱᅲᅳᅴᅵᄔᄕᇇᇈᇌᇎᇓᇗᇙᄜᇝᇟᄝᄞᄠᄢᄣᄧᄩᄫᄬᄭᄮᄯᄲᄶᅀᅇᅌᇱᇲᅗᅘᅙᆄᆅᆈᆑᆒᆔᆞᆡ一二三四上中下甲乙丙丁天地人(ᄀ)(ᄂ)(ᄃ)(ᄅ)(" + + "ᄆ)(ᄇ)(ᄉ)(ᄋ)(ᄌ)(ᄎ)(ᄏ)(ᄐ)(ᄑ)(ᄒ)(가)(나)(다)(라)(마)(바)(사)(아)(자)(차)(카)(타)(파)(하)(주)(오전)(오후)(一)(二)(三)(四)(五)(六)" + + "(七)(八)(九)(十)(月)(火)(水)(木)(金)(土)(日)(株)(有)(社)(名)(特)(財)(祝)(労)(代)(呼)(学)(監)(企)(資)(協)(祭)(休)(自)(至)問幼文箏pte212" + + "223242526272829303132333435ᄀᄂᄃᄅᄆᄇᄉᄋᄌᄎᄏᄐᄑᄒ가나다라마바사아자차카타파하참고주의우一二三四五六七八九十月火水木金土日株有社名特財祝労秘男女適優印注項休写正上中下左" + + "右医宗学監企資協夜3637383940414243444546474849501月2月3月4月5月6月7月8月9月10月11月12月hgergevltdアイウエオカキクケコサシスセソタチツテトナニヌネ" + + "ノハヒフヘホマミムメモヤユヨラリルレロワヰヱヲ令和アパートアルファアンペアアールイニングインチウォンエスクードエーカーオンスオームカイリカラットカロリーガロンガンマギガギニーキュリーギルダーキロキログ" + + "ラムキロメートルキロワットグラムグラムトンクルゼイロクローネケースコルナコーポサイクルサンチームシリングセンチセントダースデシドルトンナノノットハイツパーセントパーツバーレルピアストルピクルピコビルフ" + + "ァラッドフィートブッシェルフランヘクタールペソペニヒヘルツペンスページベータポイントボルトホンポンドホールホーンマイクロマイルマッハマルクマンションミクロンミリミリバールメガメガトンメートルヤードヤー" + + "ルユアンリットルリラルピールーブルレムレントゲンワット0点1点2点3点4点5点6点7点8点9点10点11点12点13点14点15点16点17点18点19点20点21点22点23点24点hpadaaub" + + "arovpcdmdm2dm3iu平成昭和大正明治株式会社panaμamakakbmbgbcalkcalpfnfμfμgmgkghzkhzmhzghzthzμlmldlklfmnmμmmmcmkmmm2" + + "cm2m2km2mm3cm3m3km3m∕sm∕s2pakpampagparadrad∕srad∕s2psnsμsmspvnvμvmvkvmvpwnwμwmwkwmwkωmωbqcccdc∕kgdbg" + + "yhahpinkkkmktlmlnloglxmbmilmolphppmprsrsvwbv∕ma∕m1日2日3日4日5日6日7日8日9日10日11日12日13日14日15日16日17日18日19日20日" + + "21日22日23日24日25日26日27日28日29日30日31日galꙁꙃꙅꙇꙉꙋꙍꙏꙑꙓꙕꙗꙙꙛꙝꙟꙡꙣꙥꙧꙩꙫꙭꚁꚃꚅꚇꚉꚋꚍꚏꚑꚓꚕꚗꚙꚛъьꜣꜥꜧꜩꜫꜭꜯꜳꜵꜷꜹꜻꜽꜿꝁꝃꝅꝇꝉꝋꝍꝏꝑꝓꝕ" + + "ꝗꝙꝛꝝꝟꝡꝣꝥꝧꝩꝫꝭꝯꝯꝺꝼᵹꝿꞁꞃꞅꞇꞌɥꞑꞓꞗꞙꞛꞝꞟꞡꞣꞥꞧꞩɦɜɡɬɪʞʇʝꭓꞵꞷꞹꞻꞽꞿꟁꟃꞔʂᶎꟈꟊɤꟍ꟏ꟑꟓꟕꟗꟙꟛƛscfqꟶħœꜧꬷɫꭒʍᎠᎡᎢᎣᎤᎥᎦᎧᎨᎩᎪᎫᎬᎭᎮᎯᎰᎱᎲᎳ" + + "ᎴᎵᎶᎷᎸᎹᎺᎻᎼᎽᎾᎿᏀᏁᏂᏃᏄᏅᏆᏇᏈᏉᏊᏋᏌᏍᏎᏏᏐᏑᏒᏓᏔᏕᏖᏗᏘᏙᏚᏛᏜᏝᏞᏟᏠᏡᏢᏣᏤᏥᏦᏧᏨᏩᏪᏫᏬᏭᏮᏯ豈更車賈滑串句龜契金喇奈懶癩羅蘿螺裸邏樂洛烙珞落酪駱亂卵欄爛蘭鸞嵐濫藍襤拉臘蠟廊" + + "朗浪狼郎來冷勞擄櫓爐盧老蘆虜路露魯鷺碌祿綠菉錄鹿論壟弄籠聾牢磊賂雷壘屢樓淚漏累縷陋勒肋凜凌稜綾菱陵讀拏樂諾丹寧怒率異北磻便復不泌數索參塞省葉說殺辰沈拾若掠略亮兩凉梁糧良諒量勵呂女廬旅濾礪閭驪麗黎力曆歷" + + "轢年憐戀撚漣煉璉秊練聯輦蓮連鍊列劣咽烈裂說廉念捻殮簾獵令囹寧嶺怜玲瑩羚聆鈴零靈領例禮醴隸惡了僚寮尿料樂燎療蓼遼龍暈阮劉杻柳流溜琉留硫紐類六戮陸倫崙淪輪律慄栗率隆利吏履易李梨泥理痢罹裏裡里離匿溺吝燐璘藺" + + "隣鱗麟林淋臨立笠粒狀炙識什茶刺切度拓糖宅洞暴輻行降見廓兀嗀塚晴凞猪益礼神祥福靖精羽蘒諸逸都飯飼館鶴郞隷侮僧免勉勤卑喝嘆器塀墨層屮悔慨憎懲敏既暑梅海渚漢煮爫琢碑社祉祈祐祖祝禍禎穀突節練縉繁署者臭艹著褐視" + + "謁謹賓贈辶逸難響頻恵𤋮舘並况全侀充冀勇勺喝啕喙嗢塚墳奄奔婢嬨廒廙彩徭惘慎愈憎慠懲戴揄搜摒敖晴朗望杖歹殺流滛滋漢瀞煮瞧爵犯猪瑱甆画瘝瘟益盛直睊着磌窱節类絛練缾者荒華蝹襁覆視調諸請謁諾諭謹變贈輸遲醙鉶陼" + + "難靖韛響頋頻鬒龜𢡊𢡄𣏕㮝䀘䀹𥉉𥳐𧻓齃龎fffiflffifflstմնմեմիվնմխיִײַעאדהכלםרת+שׁשׂשּׁשּׂאַאָאּבּגּדּהּוּזּטּיּךּכּל" + + "ּמּנּסּףּפּצּקּרּשּתּוֹבֿכֿפֿאלٱٻپڀٺٿٹڤڦڄڃچڇڍڌڎڈژڑکگڳڱںڻۀہھےۓڭۇۆۈۇٴۋۅۉېىئائەئوئۇئۆئۈئېئىیئجئحئمئىئيب" + + "جبحبخبمبىبيتجتحتختمتىتيثجثمثىثيجحجمحجحمخجخحخمسجسحسخسمصحصمضجضحضخضمطحطمظمعجعمغجغمفجفحفخفمفىفيقحقمقىقيك" + + "اكجكحكخكلكمكىكيلجلحلخلملىليمجمحمخمممىمينجنحنخنمنىنيهجهمهىهييجيحيخيميىييذٰرٰىٰ ٌّ ٍّ َّ ُّ ِّ ّٰئرئزئ" + + "مئنئىئيبربزبمبنبىبيترتزتمتنتىتيثرثزثمثنثىثيفىفيقىقيكاكلكمكىكيلملىليماممنرنزنمنننىنيىٰيريزيمينيىييئجئ" + + "حئخئمئهبجبحبخبمبهتجتحتختمتهثمجحجمحجحمخجخمسجسحسخسمصحصخصمضجضحضخضمطحظمعجعمغجغمفجفحفخفمقحقمكجكحكخكلكملجل" + + "حلخلملهمجمحمخممنجنحنخنمنههجهمهٰيجيحيخيميهئمئهبمبهتمتهثمثهسمسهشمشهكلكملمنمنهيميهـَّـُّـِّطىطيعىعيغىغي" + + "سىسيشىشيحىحيجىجيخىخيصىصيضىضيشجشحشخشمشرسرصرضرطىطيعىعيغىغيسىسيشىشيحىحيجىجيخىخيصىصيضىضيشجشحشخشمشرسرصرضر" + + "شجشحشخشمسهشهطمسجسحسخشجشحشخطمظماًتجمتحجتحمتخمتمجتمحتمخجمححميحمىسحجسجحسجىسمحسمجسممصححصممشحمشجيشمخشممضح" + + "ىضخمطمحطممطميعجمعممعمىغممغميغمىفخمقمحقمملحملحيلحىلججلخملمحمحجمحممحيمجحمجممخجمخممجخهمجهممنحمنحىنجمنجى" + + "نمينمىيممبخيتجيتجىتخيتخىتميتمىجميجحىجمىسخىصحيشحيضحيلجيلمييحييجييميمميقمينحيقمحلحمعميكمينجحمخيلجمكممل" + + "جمنجحجحيحجيمجيفميبحيكممعجمصممسخينجيصلےقلےاللهاكبرمحمدصلعمرسولعليهوسلمصلىصلى الله عليه وسلمجل جلالهری" + + "ال,、:;!?〖〗—–_(){}〔〕【】《》〈〉「」『』[] ̅_,、;:?!—(){}〔〕#&*+-<>=\\$%@ ًـً ٌ ٍ َـَ ُـُ ِـِ ّـّ ْـْءآأؤإئابةتثجح" + + "خدذرزسشصضطظعغفقكلمنهوىيلآلألإلا!\"#$%&'()*+,-./0123456789:;<=>?@abcdefghijklmnopqrstuvwxyz[\\]^_`abcde" + + "fghijklmnopqrstuvwxyz{|}~⦅⦆.「」、・ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙゚ᄀᄁᆪᄂᆬᆭᄃᄄᄅᆰ" + + "ᆱᆲᆳᆴᆵᄚᄆᄇᄈᄡᄉᄊᄋᄌᄍᄎᄏᄐᄑ하ᅢᅣᅤᅥᅦᅧᅨᅩᅪᅫᅬᅭᅮᅯᅰᅱᅲᅳᅴᅵ¢£¬ ̄¦¥₩│←↑→↓■○𐐨𐐩𐐪𐐫𐐬𐐭𐐮𐐯𐐰𐐱𐐲𐐳𐐴𐐵𐐶𐐷𐐸𐐹𐐺𐐻𐐼𐐽" + + "𐐾𐐿𐑀𐑁𐑂𐑃𐑄𐑅𐑆𐑇𐑈𐑉𐑊𐑋𐑌𐑍𐑎𐑏𐓘𐓙𐓚𐓛𐓜𐓝𐓞𐓟𐓠𐓡𐓢𐓣𐓤𐓥𐓦𐓧𐓨𐓩𐓪𐓫𐓬𐓭𐓮𐓯𐓰𐓱𐓲𐓳𐓴𐓵𐓶𐓷" + + "𐓸𐓹𐓺𐓻𐖗𐖘𐖙𐖚𐖛𐖜𐖝𐖞𐖟𐖠𐖡𐖣𐖤𐖥𐖦𐖧𐖨𐖩𐖪𐖫𐖬𐖭𐖮𐖯𐖰𐖱𐖳𐖴𐖵𐖶𐖷𐖸𐖹𐖻𐖼ːˑæʙɓʣꭦʥʤɖɗᶑɘɞʩɤɢɠʛħʜɧ" + + "ʄʪʫɬ𝼄ꞎɮ𝼅ʎ𝼆øɶɷqɺ𝼈ɽɾʀʨʦꭧʧʈⱱʏʡʢʘǀǁǂ𝼊𝼞𐳀𐳁𐳂𐳃𐳄𐳅𐳆𐳇𐳈𐳉𐳊𐳋𐳌𐳍𐳎𐳏𐳐𐳑𐳒𐳓𐳔𐳕𐳖𐳗𐳘𐳙𐳚𐳛𐳜𐳝" + + "𐳞𐳟𐳠𐳡𐳢𐳣𐳤𐳥𐳦𐳧𐳨𐳩𐳪𐳫𐳬𐳭𐳮𐳯𐳰𐳱𐳲𐵰𐵱𐵲𐵳𐵴𐵵𐵶𐵷𐵸𐵹𐵺𐵻𐵼𐵽𐵾𐵿𐶀𐶁𐶂𐶃𐶄𐶅𑣀𑣁𑣂𑣃𑣄𑣅𑣆" + + "𑣇𑣈𑣉𑣊𑣋𑣌𑣍𑣎𑣏𑣐𑣑𑣒𑣓𑣔𑣕𑣖𑣗𑣘𑣙𑣚𑣛𑣜𑣝𑣞𑣟𖹠𖹡𖹢𖹣𖹤𖹥𖹦𖹧𖹨𖹩𖹪𖹫𖹬𖹭𖹮𖹯𖹰𖹱𖹲𖹳𖹴𖹵𖹶𖹷𖹸" + + "𖹹𖹺𖹻𖹼𖹽𖹾𖹿𖺻𖺼𖺽𖺾𖺿𖻀𖻁𖻂𖻃𖻄𖻅𖻆𖻇𖻈𖻉𖻊𖻋𖻌𖻍𖻎𖻏𖻐𖻑𖻒𖻓abcdefghijklmnopqrstuvwxyz0123456789" + + "𝅗𝅥𝅘𝅥𝅘𝅥𝅮𝅘𝅥𝅯𝅘𝅥𝅰𝅘𝅥𝅱𝅘𝅥𝅲𝆹𝅥𝆺𝅥𝆹𝅥𝅮𝆺𝅥𝅮𝆹𝅥𝅯𝆺𝅥𝅯abcdefghijklmnopqrstuvwxyzabcd" + + "efghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefgijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyza" + + "bcdefghijklmnopqrstuvwxyzacdgjknopqstuvwxyzabcdfhijklmnpqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefgh" + + "ijklmnopqrstuvwxyzabdefgjklmnopqstuvwxyabcdefghijklmnopqrstuvwxyzabdefgijklmostuvwxyabcdefghijklmnop" + + "qrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl" + + "mnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefgh" + + "ijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcd" + + "efghijklmnopqrstuvwxyzıȷαβγδεζηθικλμνξοπρθστυφχψω∇αβγδεζηθικλμνξοπρστυφχψω∂εθκφρπαβγδεζηθικλμνξοπρθσ" + + "τυφχψω∇αβγδεζηθικλμνξοπρστυφχψω∂εθκφρπαβγδεζηθικλμνξοπρθστυφχψω∇αβγδεζηθικλμνξοπρστυφχψω∂εθκφρπαβγδε" + + "ζηθικλμνξοπρθστυφχψω∇αβγδεζηθικλμνξοπρστυφχψω∂εθκφρπαβγδεζηθικλμνξοπρθστυφχψω∇αβγδεζηθικλμνξοπρστυφχ" + + "ψω∂εθκφρπϝ01234567890123456789012345678901234567890123456789абвгдежзиклмопрстуфхцчшыэюꚉәіјөүӏабвгдеж" + + "зиклопсуфхцчшъыґіѕџҫꙑұ𞤢𞤣𞤤𞤥𞤦𞤧𞤨𞤩𞤪𞤫𞤬𞤭𞤮𞤯𞤰𞤱𞤲𞤳𞤴𞤵𞤶𞤷𞤸𞤹𞤺𞤻𞤼𞤽𞤾𞤿𞥀𞥁𞥂𞥃ابجدوزحطيك" + + "لمنسعفصقرشتثخذضظغٮںڡٯبجهحيكلمنسعفصقشتثخضغجحيلنسعصقشخضغںٯبجهحطيكمنسعفصقشتثخضظغٮڡابجدهوزحطيلمنسعفصقرشت" + + "ثخذضظغبجدوزحطيلمنسعفصقرشتثخذضظغ0,1,2,3,4,5,6,7,8,9,(a)(b)(c)(d)(e)(f)(g)(h)(i)(j)(k)(l)(m)(n)(o)(p)(" + + "q)(r)(s)(t)(u)(v)(w)(x)(y)(z)〔s〕crcdwzabcdefghijklmnopqrstuvwxyzhvmvsdssppvwcmcmdmrdjほかココサ手字双デ二多解天交映" + + "無料前後再新初終生販声吹演投捕一三遊左中右指走打禁空合満有月申割営配〔本〕〔三〕〔二〕〔安〕〔点〕〔打〕〔盗〕〔勝〕〔敗〕得可0123456789丽丸乁𠄢你侮侻倂偺備僧像㒞𠘺免兔兤具𠔜㒹內再𠕋" + + "冗冤仌冬况𩇟凵刃㓟刻剆割剷㔕勇勉勤勺包匆北卉卑博即卽卿𠨬灰及叟𠭣叫叱吆咞吸呈周咢哶唐啓啣善喙喫喳嗂圖嘆圗噑噴切壮城埴堍型堲報墬𡓤売壷夆多夢奢𡚨𡛪姬娛娧姘婦㛮㛼嬈嬾𡧈寃寘寧寳𡬘寿将当尢㞁" + + "屠屮峀岍𡷤嵃𡷦嵮嵫嵼巡巢㠯巽帨帽幩㡢𢆃㡼庰庳庶廊𪎒廾𢌱舁弢㣇𣊸𦇚形彫㣣徚忍志忹悁㤺㤜悔𢛔惇慈慌慎慌慺憎憲憤憯懞懲懶成戛扝抱拔捐𢬌挽拼捨掃揤𢯱搢揅掩㨮摩摾撝摷㩬敏敬𣀊旣書晉㬙暑㬈㫤" + + "冒冕最暜肭䏙朗望朡杞杓𣏃㭉柺枅桒梅𣑭梎栟椔㮝楂榣槪檨𣚣櫛㰘次𣢧歔㱎歲殟殺殻𣪍𡴋𣫺汎𣲼沿泍汧洖派海流浩浸涅𣴞洴港湮㴳滋滇𣻑淹潮𣽞𣾎濆瀹瀞瀛㶖灊災灷炭𠔥煅𤉣熜𤎫爨爵牐𤘈犀犕" + + "𤜵𤠔獺王㺬玥㺸瑇瑜瑱璅瓊㼛甤𤰶甾𤲒異𢆟瘐𤾡𤾸𥁄㿼䀈直𥃳𥃲𥄙𥄳眞真睊䀹瞋䁆䂖𥐝硎碌磌䃣𥘦祖𥚚𥛅福秫䄯穀穊穏𥥼𥪧竮䈂𥮫篆築䈧𥲀糒䊠糨糣紀𥾆絣䌁緇縂繅䌴𦈨𦉇䍙" + + "𦋙罺𦌾羕翺者𦓚𦔣聠𦖨聰𣍟䏕育脃䐋脾媵𦞧𦞵𣎓𣎜舁舄辞䑫芑芋芝劳花芳芽苦𦬼若茝荣莭茣莽菧著荓菊菌菜𦰶𦵫𦳕䔫蓱蓳蔖𧏊蕤𦼬䕝䕡𦾱𧃒䕫虐虜虧虩蚩蚈蜎蛢蝹蜨蝫螆䗗蟡蠁䗹衠衣𧙧" + + "裗裞䘵裺㒻𧢮𧥦䚾䛇誠諭變豕𧲨貫賁贛起𧼯𠠄跋趼跰𠣞軔輸𨗒𨗭邔郱鄑𨜮鄛鈸鋗鋘鉼鏹鐕𨯺開䦕閷𨵷䧦雃嶲霣𩅅𩈚䩮䩶韠𩐊䪲𩒖頋頩𩖶飢䬳餩馧駂駾䯎𩬰鬒鱀鳽䳎䳭鵧𪃎䳸𪄅𪈎𪊑麻" + + "䵖黹黾鼅鼏鼖鼻𪘀"; diff --git a/framework/src/net/vendor/url/idna-regexes.generated.ts b/framework/src/net/vendor/url/idna-regexes.generated.ts new file mode 100644 index 00000000..0f057deb --- /dev/null +++ b/framework/src/net/vendor/url/idna-regexes.generated.ts @@ -0,0 +1,29 @@ +// @ts-nocheck -- generated from pinned tr46 source. +// Generated by tools/generate-url-idna-table.ts. Do not edit. +const combiningMarks = /[\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u0897-\u089F\u08CA-\u08E1\u08E3-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u09FE\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0AFA-\u0AFF\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B55-\u0B57\u0B62\u0B63\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C00-\u0C04\u0C3C\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81-\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0CF3\u0D00-\u0D03\u0D3B\u0D3C\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D81-\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2\u0DF3\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EBC\u0EC8-\u0ECE\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102B-\u103E\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F\u109A-\u109D\u135D-\u135F\u1712-\u1715\u1732-\u1734\u1752\u1753\u1772\u1773\u17B4-\u17D3\u17DD\u180B-\u180D\u180F\u1885\u1886\u18A9\u1920-\u192B\u1930-\u193B\u1A17-\u1A1B\u1A55-\u1A5E\u1A60-\u1A7C\u1A7F\u1AB0-\u1ADD\u1AE0-\u1AEB\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF4\u1CF7-\u1CF9\u1DC0-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA82C\uA880\uA881\uA8B4-\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA953\uA980-\uA983\uA9B3-\uA9C0\uA9E5\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAA7B-\uAA7D\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEB-\uAAEF\uAAF5\uAAF6\uABE3-\uABEA\uABEC\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F\u{101FD}\u{102E0}\u{10376}-\u{1037A}\u{10A01}-\u{10A03}\u{10A05}\u{10A06}\u{10A0C}-\u{10A0F}\u{10A38}-\u{10A3A}\u{10A3F}\u{10AE5}\u{10AE6}\u{10D24}-\u{10D27}\u{10D69}-\u{10D6D}\u{10EAB}\u{10EAC}\u{10EFA}-\u{10EFF}\u{10F46}-\u{10F50}\u{10F82}-\u{10F85}\u{11000}-\u{11002}\u{11038}-\u{11046}\u{11070}\u{11073}\u{11074}\u{1107F}-\u{11082}\u{110B0}-\u{110BA}\u{110C2}\u{11100}-\u{11102}\u{11127}-\u{11134}\u{11145}\u{11146}\u{11173}\u{11180}-\u{11182}\u{111B3}-\u{111C0}\u{111C9}-\u{111CC}\u{111CE}\u{111CF}\u{1122C}-\u{11237}\u{1123E}\u{11241}\u{112DF}-\u{112EA}\u{11300}-\u{11303}\u{1133B}\u{1133C}\u{1133E}-\u{11344}\u{11347}\u{11348}\u{1134B}-\u{1134D}\u{11357}\u{11362}\u{11363}\u{11366}-\u{1136C}\u{11370}-\u{11374}\u{113B8}-\u{113C0}\u{113C2}\u{113C5}\u{113C7}-\u{113CA}\u{113CC}-\u{113D0}\u{113D2}\u{113E1}\u{113E2}\u{11435}-\u{11446}\u{1145E}\u{114B0}-\u{114C3}\u{115AF}-\u{115B5}\u{115B8}-\u{115C0}\u{115DC}\u{115DD}\u{11630}-\u{11640}\u{116AB}-\u{116B7}\u{1171D}-\u{1172B}\u{1182C}-\u{1183A}\u{11930}-\u{11935}\u{11937}\u{11938}\u{1193B}-\u{1193E}\u{11940}\u{11942}\u{11943}\u{119D1}-\u{119D7}\u{119DA}-\u{119E0}\u{119E4}\u{11A01}-\u{11A0A}\u{11A33}-\u{11A39}\u{11A3B}-\u{11A3E}\u{11A47}\u{11A51}-\u{11A5B}\u{11A8A}-\u{11A99}\u{11B60}-\u{11B67}\u{11C2F}-\u{11C36}\u{11C38}-\u{11C3F}\u{11C92}-\u{11CA7}\u{11CA9}-\u{11CB6}\u{11D31}-\u{11D36}\u{11D3A}\u{11D3C}\u{11D3D}\u{11D3F}-\u{11D45}\u{11D47}\u{11D8A}-\u{11D8E}\u{11D90}\u{11D91}\u{11D93}-\u{11D97}\u{11EF3}-\u{11EF6}\u{11F00}\u{11F01}\u{11F03}\u{11F34}-\u{11F3A}\u{11F3E}-\u{11F42}\u{11F5A}\u{13440}\u{13447}-\u{13455}\u{1611E}-\u{1612F}\u{16AF0}-\u{16AF4}\u{16B30}-\u{16B36}\u{16F4F}\u{16F51}-\u{16F87}\u{16F8F}-\u{16F92}\u{16FE4}\u{16FF0}\u{16FF1}\u{1BC9D}\u{1BC9E}\u{1CF00}-\u{1CF2D}\u{1CF30}-\u{1CF46}\u{1D165}-\u{1D169}\u{1D16D}-\u{1D172}\u{1D17B}-\u{1D182}\u{1D185}-\u{1D18B}\u{1D1AA}-\u{1D1AD}\u{1D242}-\u{1D244}\u{1DA00}-\u{1DA36}\u{1DA3B}-\u{1DA6C}\u{1DA75}\u{1DA84}\u{1DA9B}-\u{1DA9F}\u{1DAA1}-\u{1DAAF}\u{1E000}-\u{1E006}\u{1E008}-\u{1E018}\u{1E01B}-\u{1E021}\u{1E023}\u{1E024}\u{1E026}-\u{1E02A}\u{1E08F}\u{1E130}-\u{1E136}\u{1E2AE}\u{1E2EC}-\u{1E2EF}\u{1E4EC}-\u{1E4EF}\u{1E5EE}\u{1E5EF}\u{1E6E3}\u{1E6E6}\u{1E6EE}\u{1E6EF}\u{1E6F5}\u{1E8D0}-\u{1E8D6}\u{1E944}-\u{1E94A}\u{E0100}-\u{E01EF}]/u; +const combiningClassVirama = /[\u094D\u09CD\u0A4D\u0ACD\u0B4D\u0BCD\u0C4D\u0CCD\u0D3B\u0D3C\u0D4D\u0DCA\u0E3A\u0EBA\u0F84\u1039\u103A\u1714\u1715\u1734\u17D2\u1A60\u1B44\u1BAA\u1BAB\u1BF2\u1BF3\u2D7F\uA806\uA82C\uA8C4\uA953\uA9C0\uAAF6\uABED\u{10A3F}\u{11046}\u{11070}\u{1107F}\u{110B9}\u{11133}\u{11134}\u{111C0}\u{11235}\u{112EA}\u{1134D}\u{113CE}-\u{113D0}\u{11442}\u{114C2}\u{115BF}\u{1163F}\u{116B6}\u{1172B}\u{11839}\u{1193D}\u{1193E}\u{119E0}\u{11A34}\u{11A47}\u{11A99}\u{11C3F}\u{11D44}\u{11D45}\u{11D97}\u{11F41}\u{11F42}\u{1612F}]/u; +const validZWNJ = /[\u0620\u0626\u0628\u062A-\u062E\u0633-\u063F\u0641-\u0647\u0649\u064A\u066E\u066F\u0678-\u0687\u069A-\u06BF\u06C1\u06C2\u06CC\u06CE\u06D0\u06D1\u06FA-\u06FC\u06FF\u0712-\u0714\u071A-\u071D\u071F-\u0727\u0729\u072B\u072D\u072E\u074E-\u0758\u075C-\u076A\u076D-\u0770\u0772\u0775-\u0777\u077A-\u077F\u07CA-\u07EA\u0841-\u0845\u0848\u084A-\u0853\u0855\u0860\u0862-\u0865\u0868\u0886\u0889-\u088D\u088F\u08A0-\u08A9\u08AF\u08B0\u08B3-\u08B8\u08BA-\u08C8\u1807\u1820-\u1878\u1887-\u18A8\u18AA\uA840-\uA872\u{10AC0}-\u{10AC4}\u{10ACD}\u{10AD3}-\u{10ADC}\u{10ADE}-\u{10AE0}\u{10AEB}-\u{10AEE}\u{10B80}\u{10B82}\u{10B86}-\u{10B88}\u{10B8A}\u{10B8B}\u{10B8D}\u{10B90}\u{10BAD}\u{10BAE}\u{10D00}-\u{10D21}\u{10D23}\u{10EC3}\u{10EC4}\u{10EC6}\u{10EC7}\u{10F30}-\u{10F32}\u{10F34}-\u{10F44}\u{10F51}-\u{10F53}\u{10F70}-\u{10F73}\u{10F76}-\u{10F81}\u{10FB0}\u{10FB2}\u{10FB3}\u{10FB8}\u{10FBB}\u{10FBC}\u{10FBE}\u{10FBF}\u{10FC1}\u{10FC4}\u{10FCA}\u{10FCB}\u{1E900}-\u{1E943}][\xAD\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u061C\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u070F\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u0897-\u089F\u08CA-\u08E1\u08E3-\u0902\u093A\u093C\u0941-\u0948\u094D\u0951-\u0957\u0962\u0963\u0981\u09BC\u09C1-\u09C4\u09CD\u09E2\u09E3\u09FE\u0A01\u0A02\u0A3C\u0A41\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81\u0A82\u0ABC\u0AC1-\u0AC5\u0AC7\u0AC8\u0ACD\u0AE2\u0AE3\u0AFA-\u0AFF\u0B01\u0B3C\u0B3F\u0B41-\u0B44\u0B4D\u0B55\u0B56\u0B62\u0B63\u0B82\u0BC0\u0BCD\u0C00\u0C04\u0C3C\u0C3E-\u0C40\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81\u0CBC\u0CBF\u0CC6\u0CCC\u0CCD\u0CE2\u0CE3\u0D00\u0D01\u0D3B\u0D3C\u0D41-\u0D44\u0D4D\u0D62\u0D63\u0D81\u0DCA\u0DD2-\u0DD4\u0DD6\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EBC\u0EC8-\u0ECE\u0F18\u0F19\u0F35\u0F37\u0F39\u0F71-\u0F7E\u0F80-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102D-\u1030\u1032-\u1037\u1039\u103A\u103D\u103E\u1058\u1059\u105E-\u1060\u1071-\u1074\u1082\u1085\u1086\u108D\u109D\u135D-\u135F\u1712-\u1714\u1732\u1733\u1752\u1753\u1772\u1773\u17B4\u17B5\u17B7-\u17BD\u17C6\u17C9-\u17D3\u17DD\u180B-\u180D\u180F\u1885\u1886\u18A9\u1920-\u1922\u1927\u1928\u1932\u1939-\u193B\u1A17\u1A18\u1A1B\u1A56\u1A58-\u1A5E\u1A60\u1A62\u1A65-\u1A6C\u1A73-\u1A7C\u1A7F\u1AB0-\u1ADD\u1AE0-\u1AEB\u1B00-\u1B03\u1B34\u1B36-\u1B3A\u1B3C\u1B42\u1B6B-\u1B73\u1B80\u1B81\u1BA2-\u1BA5\u1BA8\u1BA9\u1BAB-\u1BAD\u1BE6\u1BE8\u1BE9\u1BED\u1BEF-\u1BF1\u1C2C-\u1C33\u1C36\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE0\u1CE2-\u1CE8\u1CED\u1CF4\u1CF8\u1CF9\u1DC0-\u1DFF\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u206A-\u206F\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302D\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA825\uA826\uA82C\uA8C4\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA951\uA980-\uA982\uA9B3\uA9B6-\uA9B9\uA9BC\uA9BD\uA9E5\uAA29-\uAA2E\uAA31\uAA32\uAA35\uAA36\uAA43\uAA4C\uAA7C\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEC\uAAED\uAAF6\uABE5\uABE8\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F\uFEFF\uFFF9-\uFFFB\u{101FD}\u{102E0}\u{10376}-\u{1037A}\u{10A01}-\u{10A03}\u{10A05}\u{10A06}\u{10A0C}-\u{10A0F}\u{10A38}-\u{10A3A}\u{10A3F}\u{10AE5}\u{10AE6}\u{10D24}-\u{10D27}\u{10D69}-\u{10D6D}\u{10EAB}\u{10EAC}\u{10EFA}-\u{10EFF}\u{10F46}-\u{10F50}\u{10F82}-\u{10F85}\u{11001}\u{11038}-\u{11046}\u{11070}\u{11073}\u{11074}\u{1107F}-\u{11081}\u{110B3}-\u{110B6}\u{110B9}\u{110BA}\u{110C2}\u{11100}-\u{11102}\u{11127}-\u{1112B}\u{1112D}-\u{11134}\u{11173}\u{11180}\u{11181}\u{111B6}-\u{111BE}\u{111C9}-\u{111CC}\u{111CF}\u{1122F}-\u{11231}\u{11234}\u{11236}\u{11237}\u{1123E}\u{11241}\u{112DF}\u{112E3}-\u{112EA}\u{11300}\u{11301}\u{1133B}\u{1133C}\u{11340}\u{11366}-\u{1136C}\u{11370}-\u{11374}\u{113BB}-\u{113C0}\u{113CE}\u{113D0}\u{113D2}\u{113E1}\u{113E2}\u{11438}-\u{1143F}\u{11442}-\u{11444}\u{11446}\u{1145E}\u{114B3}-\u{114B8}\u{114BA}\u{114BF}\u{114C0}\u{114C2}\u{114C3}\u{115B2}-\u{115B5}\u{115BC}\u{115BD}\u{115BF}\u{115C0}\u{115DC}\u{115DD}\u{11633}-\u{1163A}\u{1163D}\u{1163F}\u{11640}\u{116AB}\u{116AD}\u{116B0}-\u{116B5}\u{116B7}\u{1171D}\u{1171F}\u{11722}-\u{11725}\u{11727}-\u{1172B}\u{1182F}-\u{11837}\u{11839}\u{1183A}\u{1193B}\u{1193C}\u{1193E}\u{11943}\u{119D4}-\u{119D7}\u{119DA}\u{119DB}\u{119E0}\u{11A01}-\u{11A0A}\u{11A33}-\u{11A38}\u{11A3B}-\u{11A3E}\u{11A47}\u{11A51}-\u{11A56}\u{11A59}-\u{11A5B}\u{11A8A}-\u{11A96}\u{11A98}\u{11A99}\u{11B60}\u{11B62}-\u{11B64}\u{11B66}\u{11C30}-\u{11C36}\u{11C38}-\u{11C3D}\u{11C3F}\u{11C92}-\u{11CA7}\u{11CAA}-\u{11CB0}\u{11CB2}\u{11CB3}\u{11CB5}\u{11CB6}\u{11D31}-\u{11D36}\u{11D3A}\u{11D3C}\u{11D3D}\u{11D3F}-\u{11D45}\u{11D47}\u{11D90}\u{11D91}\u{11D95}\u{11D97}\u{11EF3}\u{11EF4}\u{11F00}\u{11F01}\u{11F36}-\u{11F3A}\u{11F40}\u{11F42}\u{11F5A}\u{13430}-\u{13440}\u{13447}-\u{13455}\u{1611E}-\u{16129}\u{1612D}-\u{1612F}\u{16AF0}-\u{16AF4}\u{16B30}-\u{16B36}\u{16F4F}\u{16F8F}-\u{16F92}\u{16FE4}\u{1BC9D}\u{1BC9E}\u{1BCA0}-\u{1BCA3}\u{1CF00}-\u{1CF2D}\u{1CF30}-\u{1CF46}\u{1D167}-\u{1D169}\u{1D173}-\u{1D182}\u{1D185}-\u{1D18B}\u{1D1AA}-\u{1D1AD}\u{1D242}-\u{1D244}\u{1DA00}-\u{1DA36}\u{1DA3B}-\u{1DA6C}\u{1DA75}\u{1DA84}\u{1DA9B}-\u{1DA9F}\u{1DAA1}-\u{1DAAF}\u{1E000}-\u{1E006}\u{1E008}-\u{1E018}\u{1E01B}-\u{1E021}\u{1E023}\u{1E024}\u{1E026}-\u{1E02A}\u{1E08F}\u{1E130}-\u{1E136}\u{1E2AE}\u{1E2EC}-\u{1E2EF}\u{1E4EC}-\u{1E4EF}\u{1E5EE}\u{1E5EF}\u{1E6E3}\u{1E6E6}\u{1E6EE}\u{1E6EF}\u{1E6F5}\u{1E8D0}-\u{1E8D6}\u{1E944}-\u{1E94B}\u{E0001}\u{E0020}-\u{E007F}\u{E0100}-\u{E01EF}]*\u200C[\xAD\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u061C\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u070F\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u0897-\u089F\u08CA-\u08E1\u08E3-\u0902\u093A\u093C\u0941-\u0948\u094D\u0951-\u0957\u0962\u0963\u0981\u09BC\u09C1-\u09C4\u09CD\u09E2\u09E3\u09FE\u0A01\u0A02\u0A3C\u0A41\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81\u0A82\u0ABC\u0AC1-\u0AC5\u0AC7\u0AC8\u0ACD\u0AE2\u0AE3\u0AFA-\u0AFF\u0B01\u0B3C\u0B3F\u0B41-\u0B44\u0B4D\u0B55\u0B56\u0B62\u0B63\u0B82\u0BC0\u0BCD\u0C00\u0C04\u0C3C\u0C3E-\u0C40\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81\u0CBC\u0CBF\u0CC6\u0CCC\u0CCD\u0CE2\u0CE3\u0D00\u0D01\u0D3B\u0D3C\u0D41-\u0D44\u0D4D\u0D62\u0D63\u0D81\u0DCA\u0DD2-\u0DD4\u0DD6\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EBC\u0EC8-\u0ECE\u0F18\u0F19\u0F35\u0F37\u0F39\u0F71-\u0F7E\u0F80-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102D-\u1030\u1032-\u1037\u1039\u103A\u103D\u103E\u1058\u1059\u105E-\u1060\u1071-\u1074\u1082\u1085\u1086\u108D\u109D\u135D-\u135F\u1712-\u1714\u1732\u1733\u1752\u1753\u1772\u1773\u17B4\u17B5\u17B7-\u17BD\u17C6\u17C9-\u17D3\u17DD\u180B-\u180D\u180F\u1885\u1886\u18A9\u1920-\u1922\u1927\u1928\u1932\u1939-\u193B\u1A17\u1A18\u1A1B\u1A56\u1A58-\u1A5E\u1A60\u1A62\u1A65-\u1A6C\u1A73-\u1A7C\u1A7F\u1AB0-\u1ADD\u1AE0-\u1AEB\u1B00-\u1B03\u1B34\u1B36-\u1B3A\u1B3C\u1B42\u1B6B-\u1B73\u1B80\u1B81\u1BA2-\u1BA5\u1BA8\u1BA9\u1BAB-\u1BAD\u1BE6\u1BE8\u1BE9\u1BED\u1BEF-\u1BF1\u1C2C-\u1C33\u1C36\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE0\u1CE2-\u1CE8\u1CED\u1CF4\u1CF8\u1CF9\u1DC0-\u1DFF\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u206A-\u206F\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302D\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA825\uA826\uA82C\uA8C4\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA951\uA980-\uA982\uA9B3\uA9B6-\uA9B9\uA9BC\uA9BD\uA9E5\uAA29-\uAA2E\uAA31\uAA32\uAA35\uAA36\uAA43\uAA4C\uAA7C\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEC\uAAED\uAAF6\uABE5\uABE8\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F\uFEFF\uFFF9-\uFFFB\u{101FD}\u{102E0}\u{10376}-\u{1037A}\u{10A01}-\u{10A03}\u{10A05}\u{10A06}\u{10A0C}-\u{10A0F}\u{10A38}-\u{10A3A}\u{10A3F}\u{10AE5}\u{10AE6}\u{10D24}-\u{10D27}\u{10D69}-\u{10D6D}\u{10EAB}\u{10EAC}\u{10EFA}-\u{10EFF}\u{10F46}-\u{10F50}\u{10F82}-\u{10F85}\u{11001}\u{11038}-\u{11046}\u{11070}\u{11073}\u{11074}\u{1107F}-\u{11081}\u{110B3}-\u{110B6}\u{110B9}\u{110BA}\u{110C2}\u{11100}-\u{11102}\u{11127}-\u{1112B}\u{1112D}-\u{11134}\u{11173}\u{11180}\u{11181}\u{111B6}-\u{111BE}\u{111C9}-\u{111CC}\u{111CF}\u{1122F}-\u{11231}\u{11234}\u{11236}\u{11237}\u{1123E}\u{11241}\u{112DF}\u{112E3}-\u{112EA}\u{11300}\u{11301}\u{1133B}\u{1133C}\u{11340}\u{11366}-\u{1136C}\u{11370}-\u{11374}\u{113BB}-\u{113C0}\u{113CE}\u{113D0}\u{113D2}\u{113E1}\u{113E2}\u{11438}-\u{1143F}\u{11442}-\u{11444}\u{11446}\u{1145E}\u{114B3}-\u{114B8}\u{114BA}\u{114BF}\u{114C0}\u{114C2}\u{114C3}\u{115B2}-\u{115B5}\u{115BC}\u{115BD}\u{115BF}\u{115C0}\u{115DC}\u{115DD}\u{11633}-\u{1163A}\u{1163D}\u{1163F}\u{11640}\u{116AB}\u{116AD}\u{116B0}-\u{116B5}\u{116B7}\u{1171D}\u{1171F}\u{11722}-\u{11725}\u{11727}-\u{1172B}\u{1182F}-\u{11837}\u{11839}\u{1183A}\u{1193B}\u{1193C}\u{1193E}\u{11943}\u{119D4}-\u{119D7}\u{119DA}\u{119DB}\u{119E0}\u{11A01}-\u{11A0A}\u{11A33}-\u{11A38}\u{11A3B}-\u{11A3E}\u{11A47}\u{11A51}-\u{11A56}\u{11A59}-\u{11A5B}\u{11A8A}-\u{11A96}\u{11A98}\u{11A99}\u{11B60}\u{11B62}-\u{11B64}\u{11B66}\u{11C30}-\u{11C36}\u{11C38}-\u{11C3D}\u{11C3F}\u{11C92}-\u{11CA7}\u{11CAA}-\u{11CB0}\u{11CB2}\u{11CB3}\u{11CB5}\u{11CB6}\u{11D31}-\u{11D36}\u{11D3A}\u{11D3C}\u{11D3D}\u{11D3F}-\u{11D45}\u{11D47}\u{11D90}\u{11D91}\u{11D95}\u{11D97}\u{11EF3}\u{11EF4}\u{11F00}\u{11F01}\u{11F36}-\u{11F3A}\u{11F40}\u{11F42}\u{11F5A}\u{13430}-\u{13440}\u{13447}-\u{13455}\u{1611E}-\u{16129}\u{1612D}-\u{1612F}\u{16AF0}-\u{16AF4}\u{16B30}-\u{16B36}\u{16F4F}\u{16F8F}-\u{16F92}\u{16FE4}\u{1BC9D}\u{1BC9E}\u{1BCA0}-\u{1BCA3}\u{1CF00}-\u{1CF2D}\u{1CF30}-\u{1CF46}\u{1D167}-\u{1D169}\u{1D173}-\u{1D182}\u{1D185}-\u{1D18B}\u{1D1AA}-\u{1D1AD}\u{1D242}-\u{1D244}\u{1DA00}-\u{1DA36}\u{1DA3B}-\u{1DA6C}\u{1DA75}\u{1DA84}\u{1DA9B}-\u{1DA9F}\u{1DAA1}-\u{1DAAF}\u{1E000}-\u{1E006}\u{1E008}-\u{1E018}\u{1E01B}-\u{1E021}\u{1E023}\u{1E024}\u{1E026}-\u{1E02A}\u{1E08F}\u{1E130}-\u{1E136}\u{1E2AE}\u{1E2EC}-\u{1E2EF}\u{1E4EC}-\u{1E4EF}\u{1E5EE}\u{1E5EF}\u{1E6E3}\u{1E6E6}\u{1E6EE}\u{1E6EF}\u{1E6F5}\u{1E8D0}-\u{1E8D6}\u{1E944}-\u{1E94B}\u{E0001}\u{E0020}-\u{E007F}\u{E0100}-\u{E01EF}]*[\u0620\u0622-\u063F\u0641-\u064A\u066E\u066F\u0671-\u0673\u0675-\u06D3\u06D5\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u077F\u07CA-\u07EA\u0840-\u0858\u0860\u0862-\u0865\u0867-\u086A\u0870-\u0882\u0886\u0889-\u088F\u08A0-\u08AC\u08AE-\u08C8\u1807\u1820-\u1878\u1887-\u18A8\u18AA\uA840-\uA871\u{10AC0}-\u{10AC5}\u{10AC7}\u{10AC9}\u{10ACA}\u{10ACE}-\u{10AD6}\u{10AD8}-\u{10AE1}\u{10AE4}\u{10AEB}-\u{10AEF}\u{10B80}-\u{10B91}\u{10BA9}-\u{10BAE}\u{10D01}-\u{10D23}\u{10EC2}-\u{10EC4}\u{10EC6}\u{10EC7}\u{10F30}-\u{10F44}\u{10F51}-\u{10F54}\u{10F70}-\u{10F81}\u{10FB0}\u{10FB2}-\u{10FB6}\u{10FB8}-\u{10FBF}\u{10FC1}-\u{10FC4}\u{10FC9}\u{10FCA}\u{1E900}-\u{1E943}]/u; +const bidiDomain = /[\u05BE\u05C0\u05C3\u05C6\u05D0-\u05EA\u05EF-\u05F4\u0600-\u0605\u0608\u060B\u060D\u061B-\u064A\u0660-\u0669\u066B-\u066F\u0671-\u06D5\u06DD\u06E5\u06E6\u06EE\u06EF\u06FA-\u070D\u070F\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u07FE-\u0815\u081A\u0824\u0828\u0830-\u083E\u0840-\u0858\u085E\u0860-\u086A\u0870-\u0891\u08A0-\u08C9\u08E2\u200F\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBC2\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFC\uFE70-\uFE74\uFE76-\uFEFC\u{10800}-\u{10805}\u{10808}\u{1080A}-\u{10835}\u{10837}\u{10838}\u{1083C}\u{1083F}-\u{10855}\u{10857}-\u{1089E}\u{108A7}-\u{108AF}\u{108E0}-\u{108F2}\u{108F4}\u{108F5}\u{108FB}-\u{1091B}\u{10920}-\u{10939}\u{1093F}-\u{10959}\u{10980}-\u{109B7}\u{109BC}-\u{109CF}\u{109D2}-\u{10A00}\u{10A10}-\u{10A13}\u{10A15}-\u{10A17}\u{10A19}-\u{10A35}\u{10A40}-\u{10A48}\u{10A50}-\u{10A58}\u{10A60}-\u{10A9F}\u{10AC0}-\u{10AE4}\u{10AEB}-\u{10AF6}\u{10B00}-\u{10B35}\u{10B40}-\u{10B55}\u{10B58}-\u{10B72}\u{10B78}-\u{10B91}\u{10B99}-\u{10B9C}\u{10BA9}-\u{10BAF}\u{10C00}-\u{10C48}\u{10C80}-\u{10CB2}\u{10CC0}-\u{10CF2}\u{10CFA}-\u{10D23}\u{10D30}-\u{10D39}\u{10D40}-\u{10D65}\u{10D6F}-\u{10D85}\u{10D8E}\u{10D8F}\u{10E60}-\u{10E7E}\u{10E80}-\u{10EA9}\u{10EAD}\u{10EB0}\u{10EB1}\u{10EC2}-\u{10EC7}\u{10F00}-\u{10F27}\u{10F30}-\u{10F45}\u{10F51}-\u{10F59}\u{10F70}-\u{10F81}\u{10F86}-\u{10F89}\u{10FB0}-\u{10FCB}\u{10FE0}-\u{10FF6}\u{1E800}-\u{1E8C4}\u{1E8C7}-\u{1E8CF}\u{1E900}-\u{1E943}\u{1E94B}\u{1E950}-\u{1E959}\u{1E95E}\u{1E95F}\u{1EC71}-\u{1ECB4}\u{1ED01}-\u{1ED3D}\u{1EE00}-\u{1EE03}\u{1EE05}-\u{1EE1F}\u{1EE21}\u{1EE22}\u{1EE24}\u{1EE27}\u{1EE29}-\u{1EE32}\u{1EE34}-\u{1EE37}\u{1EE39}\u{1EE3B}\u{1EE42}\u{1EE47}\u{1EE49}\u{1EE4B}\u{1EE4D}-\u{1EE4F}\u{1EE51}\u{1EE52}\u{1EE54}\u{1EE57}\u{1EE59}\u{1EE5B}\u{1EE5D}\u{1EE5F}\u{1EE61}\u{1EE62}\u{1EE64}\u{1EE67}-\u{1EE6A}\u{1EE6C}-\u{1EE72}\u{1EE74}-\u{1EE77}\u{1EE79}-\u{1EE7C}\u{1EE7E}\u{1EE80}-\u{1EE89}\u{1EE8B}-\u{1EE9B}\u{1EEA1}-\u{1EEA3}\u{1EEA5}-\u{1EEA9}\u{1EEAB}-\u{1EEBB}]/u; +const bidiS1LTR = /[A-Za-z\xAA\xB5\xBA\xC0-\xD6\xD8-\xF6\xF8-\u02B8\u02BB-\u02C1\u02D0\u02D1\u02E0-\u02E4\u02EE\u0370-\u0373\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0482\u048A-\u052F\u0531-\u0556\u0559-\u0589\u0903-\u0939\u093B\u093D-\u0940\u0949-\u094C\u094E-\u0950\u0958-\u0961\u0964-\u0980\u0982\u0983\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD-\u09C0\u09C7\u09C8\u09CB\u09CC\u09CE\u09D7\u09DC\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\u09FA\u09FC\u09FD\u0A03\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A3E-\u0A40\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A76\u0A83\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD-\u0AC0\u0AC9\u0ACB\u0ACC\u0AD0\u0AE0\u0AE1\u0AE6-\u0AF0\u0AF9\u0B02\u0B03\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B3E\u0B40\u0B47\u0B48\u0B4B\u0B4C\u0B57\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BBE\u0BBF\u0BC1\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCC\u0BD0\u0BD7\u0BE6-\u0BF2\u0C01-\u0C03\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C41-\u0C44\u0C58-\u0C5A\u0C5C\u0C5D\u0C60\u0C61\u0C66-\u0C6F\u0C77\u0C7F\u0C80\u0C82-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD-\u0CC4\u0CC6-\u0CC8\u0CCA\u0CCB\u0CD5\u0CD6\u0CDC-\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0CF1-\u0CF3\u0D02-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D-\u0D40\u0D46-\u0D48\u0D4A-\u0D4C\u0D4E\u0D4F\u0D54-\u0D61\u0D66-\u0D7F\u0D82\u0D83\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DCF-\u0DD1\u0DD8-\u0DDF\u0DE6-\u0DEF\u0DF2-\u0DF4\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E4F-\u0E5B\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00-\u0F17\u0F1A-\u0F34\u0F36\u0F38\u0F3E-\u0F47\u0F49-\u0F6C\u0F7F\u0F85\u0F88-\u0F8C\u0FBE-\u0FC5\u0FC7-\u0FCC\u0FCE-\u0FDA\u1000-\u102C\u1031\u1038\u103B\u103C\u103F-\u1057\u105A-\u105D\u1061-\u1070\u1075-\u1081\u1083\u1084\u1087-\u108C\u108E-\u109C\u109E-\u10C5\u10C7\u10CD\u10D0-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1360-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u167F\u1681-\u169A\u16A0-\u16F8\u1700-\u1711\u1715\u171F-\u1731\u1734-\u1736\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17B6\u17BE-\u17C5\u17C7\u17C8\u17D4-\u17DA\u17DC\u17E0-\u17E9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1923-\u1926\u1929-\u192B\u1930\u1931\u1933-\u1938\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A19\u1A1A\u1A1E-\u1A55\u1A57\u1A61\u1A63\u1A64\u1A6D-\u1A72\u1A80-\u1A89\u1A90-\u1A99\u1AA0-\u1AAD\u1B04-\u1B33\u1B35\u1B3B\u1B3D-\u1B41\u1B43-\u1B4C\u1B4E-\u1B6A\u1B74-\u1B7F\u1B82-\u1BA1\u1BA6\u1BA7\u1BAA\u1BAE-\u1BE5\u1BE7\u1BEA-\u1BEC\u1BEE\u1BF2\u1BF3\u1BFC-\u1C2B\u1C34\u1C35\u1C3B-\u1C49\u1C4D-\u1C8A\u1C90-\u1CBA\u1CBD-\u1CC7\u1CD3\u1CE1\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5-\u1CF7\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u200E\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u214F\u2160-\u2188\u2336-\u237A\u2395\u249C-\u24E9\u26AC\u2800-\u28FF\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D70\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u3005-\u3007\u3021-\u3029\u302E\u302F\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3190-\u31BF\u31F0-\u321C\u3220-\u324F\u3260-\u327B\u327F-\u32B0\u32C0-\u32CB\u32D0-\u3376\u337B-\u33DD\u33E0-\u33FE\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA60C\uA610-\uA62B\uA640-\uA66E\uA680-\uA69D\uA6A0-\uA6EF\uA6F2-\uA6F7\uA722-\uA787\uA789-\uA7DC\uA7F1-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA824\uA827\uA830-\uA837\uA840-\uA873\uA880-\uA8C3\uA8CE-\uA8D9\uA8F2-\uA8FE\uA900-\uA925\uA92E-\uA946\uA952\uA953\uA95F-\uA97C\uA983-\uA9B2\uA9B4\uA9B5\uA9BA\uA9BB\uA9BE-\uA9CD\uA9CF-\uA9D9\uA9DE-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA2F\uAA30\uAA33\uAA34\uAA40-\uAA42\uAA44-\uAA4B\uAA4D\uAA50-\uAA59\uAA5C-\uAA7B\uAA7D-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAAEB\uAAEE-\uAAF5\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB69\uAB70-\uABE4\uABE6\uABE7\uABE9-\uABEC\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uD800-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC\u{10000}-\u{1000B}\u{1000D}-\u{10026}\u{10028}-\u{1003A}\u{1003C}\u{1003D}\u{1003F}-\u{1004D}\u{10050}-\u{1005D}\u{10080}-\u{100FA}\u{10100}\u{10102}\u{10107}-\u{10133}\u{10137}-\u{1013F}\u{1018D}\u{1018E}\u{101D0}-\u{101FC}\u{10280}-\u{1029C}\u{102A0}-\u{102D0}\u{10300}-\u{10323}\u{1032D}-\u{1034A}\u{10350}-\u{10375}\u{10380}-\u{1039D}\u{1039F}-\u{103C3}\u{103C8}-\u{103D5}\u{10400}-\u{1049D}\u{104A0}-\u{104A9}\u{104B0}-\u{104D3}\u{104D8}-\u{104FB}\u{10500}-\u{10527}\u{10530}-\u{10563}\u{1056F}-\u{1057A}\u{1057C}-\u{1058A}\u{1058C}-\u{10592}\u{10594}\u{10595}\u{10597}-\u{105A1}\u{105A3}-\u{105B1}\u{105B3}-\u{105B9}\u{105BB}\u{105BC}\u{105C0}-\u{105F3}\u{10600}-\u{10736}\u{10740}-\u{10755}\u{10760}-\u{10767}\u{10780}-\u{10785}\u{10787}-\u{107B0}\u{107B2}-\u{107BA}\u{11000}\u{11002}-\u{11037}\u{11047}-\u{1104D}\u{11066}-\u{1106F}\u{11071}\u{11072}\u{11075}\u{11082}-\u{110B2}\u{110B7}\u{110B8}\u{110BB}-\u{110C1}\u{110CD}\u{110D0}-\u{110E8}\u{110F0}-\u{110F9}\u{11103}-\u{11126}\u{1112C}\u{11136}-\u{11147}\u{11150}-\u{11172}\u{11174}-\u{11176}\u{11182}-\u{111B5}\u{111BF}-\u{111C8}\u{111CD}\u{111CE}\u{111D0}-\u{111DF}\u{111E1}-\u{111F4}\u{11200}-\u{11211}\u{11213}-\u{1122E}\u{11232}\u{11233}\u{11235}\u{11238}-\u{1123D}\u{1123F}\u{11240}\u{11280}-\u{11286}\u{11288}\u{1128A}-\u{1128D}\u{1128F}-\u{1129D}\u{1129F}-\u{112A9}\u{112B0}-\u{112DE}\u{112E0}-\u{112E2}\u{112F0}-\u{112F9}\u{11302}\u{11303}\u{11305}-\u{1130C}\u{1130F}\u{11310}\u{11313}-\u{11328}\u{1132A}-\u{11330}\u{11332}\u{11333}\u{11335}-\u{11339}\u{1133D}-\u{1133F}\u{11341}-\u{11344}\u{11347}\u{11348}\u{1134B}-\u{1134D}\u{11350}\u{11357}\u{1135D}-\u{11363}\u{11380}-\u{11389}\u{1138B}\u{1138E}\u{11390}-\u{113B5}\u{113B7}-\u{113BA}\u{113C2}\u{113C5}\u{113C7}-\u{113CA}\u{113CC}\u{113CD}\u{113CF}\u{113D1}\u{113D3}-\u{113D5}\u{113D7}\u{113D8}\u{11400}-\u{11437}\u{11440}\u{11441}\u{11445}\u{11447}-\u{1145B}\u{1145D}\u{1145F}-\u{11461}\u{11480}-\u{114B2}\u{114B9}\u{114BB}-\u{114BE}\u{114C1}\u{114C4}-\u{114C7}\u{114D0}-\u{114D9}\u{11580}-\u{115B1}\u{115B8}-\u{115BB}\u{115BE}\u{115C1}-\u{115DB}\u{11600}-\u{11632}\u{1163B}\u{1163C}\u{1163E}\u{11641}-\u{11644}\u{11650}-\u{11659}\u{11680}-\u{116AA}\u{116AC}\u{116AE}\u{116AF}\u{116B6}\u{116B8}\u{116B9}\u{116C0}-\u{116C9}\u{116D0}-\u{116E3}\u{11700}-\u{1171A}\u{1171E}\u{11720}\u{11721}\u{11726}\u{11730}-\u{11746}\u{11800}-\u{1182E}\u{11838}\u{1183B}\u{118A0}-\u{118F2}\u{118FF}-\u{11906}\u{11909}\u{1190C}-\u{11913}\u{11915}\u{11916}\u{11918}-\u{11935}\u{11937}\u{11938}\u{1193D}\u{1193F}-\u{11942}\u{11944}-\u{11946}\u{11950}-\u{11959}\u{119A0}-\u{119A7}\u{119AA}-\u{119D3}\u{119DC}-\u{119DF}\u{119E1}-\u{119E4}\u{11A00}\u{11A07}\u{11A08}\u{11A0B}-\u{11A32}\u{11A39}\u{11A3A}\u{11A3F}-\u{11A46}\u{11A50}\u{11A57}\u{11A58}\u{11A5C}-\u{11A89}\u{11A97}\u{11A9A}-\u{11AA2}\u{11AB0}-\u{11AF8}\u{11B00}-\u{11B09}\u{11B61}\u{11B65}\u{11B67}\u{11BC0}-\u{11BE1}\u{11BF0}-\u{11BF9}\u{11C00}-\u{11C08}\u{11C0A}-\u{11C2F}\u{11C3E}-\u{11C45}\u{11C50}-\u{11C6C}\u{11C70}-\u{11C8F}\u{11CA9}\u{11CB1}\u{11CB4}\u{11D00}-\u{11D06}\u{11D08}\u{11D09}\u{11D0B}-\u{11D30}\u{11D46}\u{11D50}-\u{11D59}\u{11D60}-\u{11D65}\u{11D67}\u{11D68}\u{11D6A}-\u{11D8E}\u{11D93}\u{11D94}\u{11D96}\u{11D98}\u{11DA0}-\u{11DA9}\u{11DB0}-\u{11DDB}\u{11DE0}-\u{11DE9}\u{11EE0}-\u{11EF2}\u{11EF5}-\u{11EF8}\u{11F02}-\u{11F10}\u{11F12}-\u{11F35}\u{11F3E}\u{11F3F}\u{11F41}\u{11F43}-\u{11F59}\u{11FB0}\u{11FC0}-\u{11FD4}\u{11FFF}-\u{12399}\u{12400}-\u{1246E}\u{12470}-\u{12474}\u{12480}-\u{12543}\u{12F90}-\u{12FF2}\u{13000}-\u{1343F}\u{13441}-\u{13446}\u{13460}-\u{143FA}\u{14400}-\u{14646}\u{16100}-\u{1611D}\u{1612A}-\u{1612C}\u{16130}-\u{16139}\u{16800}-\u{16A38}\u{16A40}-\u{16A5E}\u{16A60}-\u{16A69}\u{16A6E}-\u{16ABE}\u{16AC0}-\u{16AC9}\u{16AD0}-\u{16AED}\u{16AF5}\u{16B00}-\u{16B2F}\u{16B37}-\u{16B45}\u{16B50}-\u{16B59}\u{16B5B}-\u{16B61}\u{16B63}-\u{16B77}\u{16B7D}-\u{16B8F}\u{16D40}-\u{16D79}\u{16E40}-\u{16E9A}\u{16EA0}-\u{16EB8}\u{16EBB}-\u{16ED3}\u{16F00}-\u{16F4A}\u{16F50}-\u{16F87}\u{16F93}-\u{16F9F}\u{16FE0}\u{16FE1}\u{16FE3}\u{16FF0}-\u{16FF6}\u{17000}-\u{18CD5}\u{18CFF}-\u{18D1E}\u{18D80}-\u{18DF2}\u{1AFF0}-\u{1AFF3}\u{1AFF5}-\u{1AFFB}\u{1AFFD}\u{1AFFE}\u{1B000}-\u{1B122}\u{1B132}\u{1B150}-\u{1B152}\u{1B155}\u{1B164}-\u{1B167}\u{1B170}-\u{1B2FB}\u{1BC00}-\u{1BC6A}\u{1BC70}-\u{1BC7C}\u{1BC80}-\u{1BC88}\u{1BC90}-\u{1BC99}\u{1BC9C}\u{1BC9F}\u{1CCD6}-\u{1CCEF}\u{1CF50}-\u{1CFC3}\u{1D000}-\u{1D0F5}\u{1D100}-\u{1D126}\u{1D129}-\u{1D166}\u{1D16A}-\u{1D172}\u{1D183}\u{1D184}\u{1D18C}-\u{1D1A9}\u{1D1AE}-\u{1D1E8}\u{1D2C0}-\u{1D2D3}\u{1D2E0}-\u{1D2F3}\u{1D360}-\u{1D378}\u{1D400}-\u{1D454}\u{1D456}-\u{1D49C}\u{1D49E}\u{1D49F}\u{1D4A2}\u{1D4A5}\u{1D4A6}\u{1D4A9}-\u{1D4AC}\u{1D4AE}-\u{1D4B9}\u{1D4BB}\u{1D4BD}-\u{1D4C3}\u{1D4C5}-\u{1D505}\u{1D507}-\u{1D50A}\u{1D50D}-\u{1D514}\u{1D516}-\u{1D51C}\u{1D51E}-\u{1D539}\u{1D53B}-\u{1D53E}\u{1D540}-\u{1D544}\u{1D546}\u{1D54A}-\u{1D550}\u{1D552}-\u{1D6A5}\u{1D6A8}-\u{1D6C0}\u{1D6C2}-\u{1D6DA}\u{1D6DC}-\u{1D6FA}\u{1D6FC}-\u{1D714}\u{1D716}-\u{1D734}\u{1D736}-\u{1D74E}\u{1D750}-\u{1D76E}\u{1D770}-\u{1D788}\u{1D78A}-\u{1D7A8}\u{1D7AA}-\u{1D7C2}\u{1D7C4}-\u{1D7CB}\u{1D800}-\u{1D9FF}\u{1DA37}-\u{1DA3A}\u{1DA6D}-\u{1DA74}\u{1DA76}-\u{1DA83}\u{1DA85}-\u{1DA8B}\u{1DF00}-\u{1DF1E}\u{1DF25}-\u{1DF2A}\u{1E030}-\u{1E06D}\u{1E100}-\u{1E12C}\u{1E137}-\u{1E13D}\u{1E140}-\u{1E149}\u{1E14E}\u{1E14F}\u{1E290}-\u{1E2AD}\u{1E2C0}-\u{1E2EB}\u{1E2F0}-\u{1E2F9}\u{1E4D0}-\u{1E4EB}\u{1E4F0}-\u{1E4F9}\u{1E5D0}-\u{1E5ED}\u{1E5F0}-\u{1E5FA}\u{1E5FF}\u{1E6C0}-\u{1E6DE}\u{1E6E0}-\u{1E6E2}\u{1E6E4}\u{1E6E5}\u{1E6E7}-\u{1E6ED}\u{1E6F0}-\u{1E6F4}\u{1E6FE}\u{1E6FF}\u{1E7E0}-\u{1E7E6}\u{1E7E8}-\u{1E7EB}\u{1E7ED}\u{1E7EE}\u{1E7F0}-\u{1E7FE}\u{1F110}-\u{1F12E}\u{1F130}-\u{1F169}\u{1F170}-\u{1F1AC}\u{1F1E6}-\u{1F202}\u{1F210}-\u{1F23B}\u{1F240}-\u{1F248}\u{1F250}\u{1F251}\u{20000}-\u{2A6DF}\u{2A700}-\u{2B81D}\u{2B820}-\u{2CEAD}\u{2CEB0}-\u{2EBE0}\u{2EBF0}-\u{2EE5D}\u{2F800}-\u{2FA1D}\u{30000}-\u{3134A}\u{31350}-\u{33479}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/u; +const bidiS1RTL = /[\u05BE\u05C0\u05C3\u05C6\u05D0-\u05EA\u05EF-\u05F4\u0608\u060B\u060D\u061B-\u064A\u066D-\u066F\u0671-\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u070D\u070F\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u07FE-\u0815\u081A\u0824\u0828\u0830-\u083E\u0840-\u0858\u085E\u0860-\u086A\u0870-\u088F\u08A0-\u08C9\u200F\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBC2\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFC\uFE70-\uFE74\uFE76-\uFEFC\u{10800}-\u{10805}\u{10808}\u{1080A}-\u{10835}\u{10837}\u{10838}\u{1083C}\u{1083F}-\u{10855}\u{10857}-\u{1089E}\u{108A7}-\u{108AF}\u{108E0}-\u{108F2}\u{108F4}\u{108F5}\u{108FB}-\u{1091B}\u{10920}-\u{10939}\u{1093F}-\u{10959}\u{10980}-\u{109B7}\u{109BC}-\u{109CF}\u{109D2}-\u{10A00}\u{10A10}-\u{10A13}\u{10A15}-\u{10A17}\u{10A19}-\u{10A35}\u{10A40}-\u{10A48}\u{10A50}-\u{10A58}\u{10A60}-\u{10A9F}\u{10AC0}-\u{10AE4}\u{10AEB}-\u{10AF6}\u{10B00}-\u{10B35}\u{10B40}-\u{10B55}\u{10B58}-\u{10B72}\u{10B78}-\u{10B91}\u{10B99}-\u{10B9C}\u{10BA9}-\u{10BAF}\u{10C00}-\u{10C48}\u{10C80}-\u{10CB2}\u{10CC0}-\u{10CF2}\u{10CFA}-\u{10D23}\u{10D4A}-\u{10D65}\u{10D6F}-\u{10D85}\u{10D8E}\u{10D8F}\u{10E80}-\u{10EA9}\u{10EAD}\u{10EB0}\u{10EB1}\u{10EC2}-\u{10EC7}\u{10F00}-\u{10F27}\u{10F30}-\u{10F45}\u{10F51}-\u{10F59}\u{10F70}-\u{10F81}\u{10F86}-\u{10F89}\u{10FB0}-\u{10FCB}\u{10FE0}-\u{10FF6}\u{1E800}-\u{1E8C4}\u{1E8C7}-\u{1E8CF}\u{1E900}-\u{1E943}\u{1E94B}\u{1E950}-\u{1E959}\u{1E95E}\u{1E95F}\u{1EC71}-\u{1ECB4}\u{1ED01}-\u{1ED3D}\u{1EE00}-\u{1EE03}\u{1EE05}-\u{1EE1F}\u{1EE21}\u{1EE22}\u{1EE24}\u{1EE27}\u{1EE29}-\u{1EE32}\u{1EE34}-\u{1EE37}\u{1EE39}\u{1EE3B}\u{1EE42}\u{1EE47}\u{1EE49}\u{1EE4B}\u{1EE4D}-\u{1EE4F}\u{1EE51}\u{1EE52}\u{1EE54}\u{1EE57}\u{1EE59}\u{1EE5B}\u{1EE5D}\u{1EE5F}\u{1EE61}\u{1EE62}\u{1EE64}\u{1EE67}-\u{1EE6A}\u{1EE6C}-\u{1EE72}\u{1EE74}-\u{1EE77}\u{1EE79}-\u{1EE7C}\u{1EE7E}\u{1EE80}-\u{1EE89}\u{1EE8B}-\u{1EE9B}\u{1EEA1}-\u{1EEA3}\u{1EEA5}-\u{1EEA9}\u{1EEAB}-\u{1EEBB}]/u; +const bidiS2 = /^[\0-\x08\x0E-\x1B!-@\[-`\{-\x84\x86-\xA9\xAB-\xB4\xB6-\xB9\xBB-\xBF\xD7\xF7\u02B9\u02BA\u02C2-\u02CF\u02D2-\u02DF\u02E5-\u02ED\u02EF-\u036F\u0374\u0375\u037E\u0384\u0385\u0387\u03F6\u0483-\u0489\u058A\u058D-\u058F\u0591-\u05C7\u05D0-\u05EA\u05EF-\u05F4\u0600-\u070D\u070F-\u074A\u074D-\u07B1\u07C0-\u07FA\u07FD-\u082D\u0830-\u083E\u0840-\u085B\u085E\u0860-\u086A\u0870-\u0891\u0897-\u0902\u093A\u093C\u0941-\u0948\u094D\u0951-\u0957\u0962\u0963\u0981\u09BC\u09C1-\u09C4\u09CD\u09E2\u09E3\u09F2\u09F3\u09FB\u09FE\u0A01\u0A02\u0A3C\u0A41\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81\u0A82\u0ABC\u0AC1-\u0AC5\u0AC7\u0AC8\u0ACD\u0AE2\u0AE3\u0AF1\u0AFA-\u0AFF\u0B01\u0B3C\u0B3F\u0B41-\u0B44\u0B4D\u0B55\u0B56\u0B62\u0B63\u0B82\u0BC0\u0BCD\u0BF3-\u0BFA\u0C00\u0C04\u0C3C\u0C3E-\u0C40\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C78-\u0C7E\u0C81\u0CBC\u0CCC\u0CCD\u0CE2\u0CE3\u0D00\u0D01\u0D3B\u0D3C\u0D41-\u0D44\u0D4D\u0D62\u0D63\u0D81\u0DCA\u0DD2-\u0DD4\u0DD6\u0E31\u0E34-\u0E3A\u0E3F\u0E47-\u0E4E\u0EB1\u0EB4-\u0EBC\u0EC8-\u0ECE\u0F18\u0F19\u0F35\u0F37\u0F39-\u0F3D\u0F71-\u0F7E\u0F80-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102D-\u1030\u1032-\u1037\u1039\u103A\u103D\u103E\u1058\u1059\u105E-\u1060\u1071-\u1074\u1082\u1085\u1086\u108D\u109D\u135D-\u135F\u1390-\u1399\u1400\u169B\u169C\u1712-\u1714\u1732\u1733\u1752\u1753\u1772\u1773\u17B4\u17B5\u17B7-\u17BD\u17C6\u17C9-\u17D3\u17DB\u17DD\u17F0-\u17F9\u1800-\u180F\u1885\u1886\u18A9\u1920-\u1922\u1927\u1928\u1932\u1939-\u193B\u1940\u1944\u1945\u19DE-\u19FF\u1A17\u1A18\u1A1B\u1A56\u1A58-\u1A5E\u1A60\u1A62\u1A65-\u1A6C\u1A73-\u1A7C\u1A7F\u1AB0-\u1ADD\u1AE0-\u1AEB\u1B00-\u1B03\u1B34\u1B36-\u1B3A\u1B3C\u1B42\u1B6B-\u1B73\u1B80\u1B81\u1BA2-\u1BA5\u1BA8\u1BA9\u1BAB-\u1BAD\u1BE6\u1BE8\u1BE9\u1BED\u1BEF-\u1BF1\u1C2C-\u1C33\u1C36\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE0\u1CE2-\u1CE8\u1CED\u1CF4\u1CF8\u1CF9\u1DC0-\u1DFF\u1FBD\u1FBF-\u1FC1\u1FCD-\u1FCF\u1FDD-\u1FDF\u1FED-\u1FEF\u1FFD\u1FFE\u200B-\u200D\u200F-\u2027\u202F-\u205E\u2060-\u2064\u206A-\u2070\u2074-\u207E\u2080-\u208E\u20A0-\u20C1\u20D0-\u20F0\u2100\u2101\u2103-\u2106\u2108\u2109\u2114\u2116-\u2118\u211E-\u2123\u2125\u2127\u2129\u212E\u213A\u213B\u2140-\u2144\u214A-\u214D\u2150-\u215F\u2189-\u218B\u2190-\u2335\u237B-\u2394\u2396-\u2429\u2440-\u244A\u2460-\u249B\u24EA-\u26AB\u26AD-\u27FF\u2900-\u2B73\u2B76-\u2BFF\u2CE5-\u2CEA\u2CEF-\u2CF1\u2CF9-\u2CFF\u2D7F\u2DE0-\u2E5D\u2E80-\u2E99\u2E9B-\u2EF3\u2F00-\u2FD5\u2FF0-\u2FFF\u3001-\u3004\u3008-\u3020\u302A-\u302D\u3030\u3036\u3037\u303D-\u303F\u3099-\u309C\u30A0\u30FB\u31C0-\u31E5\u31EF\u321D\u321E\u3250-\u325F\u327C-\u327E\u32B1-\u32BF\u32CC-\u32CF\u3377-\u337A\u33DE\u33DF\u33FF\u4DC0-\u4DFF\uA490-\uA4C6\uA60D-\uA60F\uA66F-\uA67F\uA69E\uA69F\uA6F0\uA6F1\uA700-\uA721\uA788\uA802\uA806\uA80B\uA825\uA826\uA828-\uA82C\uA838\uA839\uA874-\uA877\uA8C4\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA951\uA980-\uA982\uA9B3\uA9B6-\uA9B9\uA9BC\uA9BD\uA9E5\uAA29-\uAA2E\uAA31\uAA32\uAA35\uAA36\uAA43\uAA4C\uAA7C\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEC\uAAED\uAAF6\uAB6A\uAB6B\uABE5\uABE8\uABED\uFB1D-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFDCF\uFDF0-\uFE19\uFE20-\uFE52\uFE54-\uFE66\uFE68-\uFE6B\uFE70-\uFE74\uFE76-\uFEFC\uFEFF\uFF01-\uFF20\uFF3B-\uFF40\uFF5B-\uFF65\uFFE0-\uFFE6\uFFE8-\uFFEE\uFFF9-\uFFFD\u{10101}\u{10140}-\u{1018C}\u{10190}-\u{1019C}\u{101A0}\u{101FD}\u{102E0}-\u{102FB}\u{10376}-\u{1037A}\u{10800}-\u{10805}\u{10808}\u{1080A}-\u{10835}\u{10837}\u{10838}\u{1083C}\u{1083F}-\u{10855}\u{10857}-\u{1089E}\u{108A7}-\u{108AF}\u{108E0}-\u{108F2}\u{108F4}\u{108F5}\u{108FB}-\u{1091B}\u{1091F}-\u{10939}\u{1093F}-\u{10959}\u{10980}-\u{109B7}\u{109BC}-\u{109CF}\u{109D2}-\u{10A03}\u{10A05}\u{10A06}\u{10A0C}-\u{10A13}\u{10A15}-\u{10A17}\u{10A19}-\u{10A35}\u{10A38}-\u{10A3A}\u{10A3F}-\u{10A48}\u{10A50}-\u{10A58}\u{10A60}-\u{10A9F}\u{10AC0}-\u{10AE6}\u{10AEB}-\u{10AF6}\u{10B00}-\u{10B35}\u{10B39}-\u{10B55}\u{10B58}-\u{10B72}\u{10B78}-\u{10B91}\u{10B99}-\u{10B9C}\u{10BA9}-\u{10BAF}\u{10C00}-\u{10C48}\u{10C80}-\u{10CB2}\u{10CC0}-\u{10CF2}\u{10CFA}-\u{10D27}\u{10D30}-\u{10D39}\u{10D40}-\u{10D65}\u{10D69}-\u{10D85}\u{10D8E}\u{10D8F}\u{10E60}-\u{10E7E}\u{10E80}-\u{10EA9}\u{10EAB}-\u{10EAD}\u{10EB0}\u{10EB1}\u{10EC2}-\u{10EC7}\u{10ED0}-\u{10ED8}\u{10EFA}-\u{10F27}\u{10F30}-\u{10F59}\u{10F70}-\u{10F89}\u{10FB0}-\u{10FCB}\u{10FE0}-\u{10FF6}\u{11001}\u{11038}-\u{11046}\u{11052}-\u{11065}\u{11070}\u{11073}\u{11074}\u{1107F}-\u{11081}\u{110B3}-\u{110B6}\u{110B9}\u{110BA}\u{110C2}\u{11100}-\u{11102}\u{11127}-\u{1112B}\u{1112D}-\u{11134}\u{11173}\u{11180}\u{11181}\u{111B6}-\u{111BE}\u{111C9}-\u{111CC}\u{111CF}\u{1122F}-\u{11231}\u{11234}\u{11236}\u{11237}\u{1123E}\u{11241}\u{112DF}\u{112E3}-\u{112EA}\u{11300}\u{11301}\u{1133B}\u{1133C}\u{11340}\u{11366}-\u{1136C}\u{11370}-\u{11374}\u{113BB}-\u{113C0}\u{113CE}\u{113D0}\u{113D2}\u{113E1}\u{113E2}\u{11438}-\u{1143F}\u{11442}-\u{11444}\u{11446}\u{1145E}\u{114B3}-\u{114B8}\u{114BA}\u{114BF}\u{114C0}\u{114C2}\u{114C3}\u{115B2}-\u{115B5}\u{115BC}\u{115BD}\u{115BF}\u{115C0}\u{115DC}\u{115DD}\u{11633}-\u{1163A}\u{1163D}\u{1163F}\u{11640}\u{11660}-\u{1166C}\u{116AB}\u{116AD}\u{116B0}-\u{116B5}\u{116B7}\u{1171D}\u{1171F}\u{11722}-\u{11725}\u{11727}-\u{1172B}\u{1182F}-\u{11837}\u{11839}\u{1183A}\u{1193B}\u{1193C}\u{1193E}\u{11943}\u{119D4}-\u{119D7}\u{119DA}\u{119DB}\u{119E0}\u{11A01}-\u{11A06}\u{11A09}\u{11A0A}\u{11A33}-\u{11A38}\u{11A3B}-\u{11A3E}\u{11A47}\u{11A51}-\u{11A56}\u{11A59}-\u{11A5B}\u{11A8A}-\u{11A96}\u{11A98}\u{11A99}\u{11B60}\u{11B62}-\u{11B64}\u{11B66}\u{11C30}-\u{11C36}\u{11C38}-\u{11C3D}\u{11C92}-\u{11CA7}\u{11CAA}-\u{11CB0}\u{11CB2}\u{11CB3}\u{11CB5}\u{11CB6}\u{11D31}-\u{11D36}\u{11D3A}\u{11D3C}\u{11D3D}\u{11D3F}-\u{11D45}\u{11D47}\u{11D90}\u{11D91}\u{11D95}\u{11D97}\u{11EF3}\u{11EF4}\u{11F00}\u{11F01}\u{11F36}-\u{11F3A}\u{11F40}\u{11F42}\u{11F5A}\u{11FD5}-\u{11FF1}\u{13440}\u{13447}-\u{13455}\u{1611E}-\u{16129}\u{1612D}-\u{1612F}\u{16AF0}-\u{16AF4}\u{16B30}-\u{16B36}\u{16F4F}\u{16F8F}-\u{16F92}\u{16FE2}\u{16FE4}\u{1BC9D}\u{1BC9E}\u{1BCA0}-\u{1BCA3}\u{1CC00}-\u{1CCD5}\u{1CCF0}-\u{1CCFC}\u{1CD00}-\u{1CEB3}\u{1CEBA}-\u{1CED0}\u{1CEE0}-\u{1CEF0}\u{1CF00}-\u{1CF2D}\u{1CF30}-\u{1CF46}\u{1D167}-\u{1D169}\u{1D173}-\u{1D182}\u{1D185}-\u{1D18B}\u{1D1AA}-\u{1D1AD}\u{1D1E9}\u{1D1EA}\u{1D200}-\u{1D245}\u{1D300}-\u{1D356}\u{1D6C1}\u{1D6DB}\u{1D6FB}\u{1D715}\u{1D735}\u{1D74F}\u{1D76F}\u{1D789}\u{1D7A9}\u{1D7C3}\u{1D7CE}-\u{1D7FF}\u{1DA00}-\u{1DA36}\u{1DA3B}-\u{1DA6C}\u{1DA75}\u{1DA84}\u{1DA9B}-\u{1DA9F}\u{1DAA1}-\u{1DAAF}\u{1E000}-\u{1E006}\u{1E008}-\u{1E018}\u{1E01B}-\u{1E021}\u{1E023}\u{1E024}\u{1E026}-\u{1E02A}\u{1E08F}\u{1E130}-\u{1E136}\u{1E2AE}\u{1E2EC}-\u{1E2EF}\u{1E2FF}\u{1E4EC}-\u{1E4EF}\u{1E5EE}\u{1E5EF}\u{1E6E3}\u{1E6E6}\u{1E6EE}\u{1E6EF}\u{1E6F5}\u{1E800}-\u{1E8C4}\u{1E8C7}-\u{1E8D6}\u{1E900}-\u{1E94B}\u{1E950}-\u{1E959}\u{1E95E}\u{1E95F}\u{1EC71}-\u{1ECB4}\u{1ED01}-\u{1ED3D}\u{1EE00}-\u{1EE03}\u{1EE05}-\u{1EE1F}\u{1EE21}\u{1EE22}\u{1EE24}\u{1EE27}\u{1EE29}-\u{1EE32}\u{1EE34}-\u{1EE37}\u{1EE39}\u{1EE3B}\u{1EE42}\u{1EE47}\u{1EE49}\u{1EE4B}\u{1EE4D}-\u{1EE4F}\u{1EE51}\u{1EE52}\u{1EE54}\u{1EE57}\u{1EE59}\u{1EE5B}\u{1EE5D}\u{1EE5F}\u{1EE61}\u{1EE62}\u{1EE64}\u{1EE67}-\u{1EE6A}\u{1EE6C}-\u{1EE72}\u{1EE74}-\u{1EE77}\u{1EE79}-\u{1EE7C}\u{1EE7E}\u{1EE80}-\u{1EE89}\u{1EE8B}-\u{1EE9B}\u{1EEA1}-\u{1EEA3}\u{1EEA5}-\u{1EEA9}\u{1EEAB}-\u{1EEBB}\u{1EEF0}\u{1EEF1}\u{1F000}-\u{1F02B}\u{1F030}-\u{1F093}\u{1F0A0}-\u{1F0AE}\u{1F0B1}-\u{1F0BF}\u{1F0C1}-\u{1F0CF}\u{1F0D1}-\u{1F0F5}\u{1F100}-\u{1F10F}\u{1F12F}\u{1F16A}-\u{1F16F}\u{1F1AD}\u{1F260}-\u{1F265}\u{1F300}-\u{1F6D8}\u{1F6DC}-\u{1F6EC}\u{1F6F0}-\u{1F6FC}\u{1F700}-\u{1F7D9}\u{1F7E0}-\u{1F7EB}\u{1F7F0}\u{1F800}-\u{1F80B}\u{1F810}-\u{1F847}\u{1F850}-\u{1F859}\u{1F860}-\u{1F887}\u{1F890}-\u{1F8AD}\u{1F8B0}-\u{1F8BB}\u{1F8C0}\u{1F8C1}\u{1F8D0}-\u{1F8D8}\u{1F900}-\u{1FA57}\u{1FA60}-\u{1FA6D}\u{1FA70}-\u{1FA7C}\u{1FA80}-\u{1FA8A}\u{1FA8E}-\u{1FAC6}\u{1FAC8}\u{1FACD}-\u{1FADC}\u{1FADF}-\u{1FAEA}\u{1FAEF}-\u{1FAF8}\u{1FB00}-\u{1FB92}\u{1FB94}-\u{1FBFA}\u{E0001}\u{E0020}-\u{E007F}\u{E0100}-\u{E01EF}]*$/u; +const bidiS3 = /[0-9\xB2\xB3\xB9\u05BE\u05C0\u05C3\u05C6\u05D0-\u05EA\u05EF-\u05F4\u0600-\u0605\u0608\u060B\u060D\u061B-\u064A\u0660-\u0669\u066B-\u066F\u0671-\u06D5\u06DD\u06E5\u06E6\u06EE-\u070D\u070F\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u07FE-\u0815\u081A\u0824\u0828\u0830-\u083E\u0840-\u0858\u085E\u0860-\u086A\u0870-\u0891\u08A0-\u08C9\u08E2\u200F\u2070\u2074-\u2079\u2080-\u2089\u2488-\u249B\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBC2\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFC\uFE70-\uFE74\uFE76-\uFEFC\uFF10-\uFF19\u{102E1}-\u{102FB}\u{10800}-\u{10805}\u{10808}\u{1080A}-\u{10835}\u{10837}\u{10838}\u{1083C}\u{1083F}-\u{10855}\u{10857}-\u{1089E}\u{108A7}-\u{108AF}\u{108E0}-\u{108F2}\u{108F4}\u{108F5}\u{108FB}-\u{1091B}\u{10920}-\u{10939}\u{1093F}-\u{10959}\u{10980}-\u{109B7}\u{109BC}-\u{109CF}\u{109D2}-\u{10A00}\u{10A10}-\u{10A13}\u{10A15}-\u{10A17}\u{10A19}-\u{10A35}\u{10A40}-\u{10A48}\u{10A50}-\u{10A58}\u{10A60}-\u{10A9F}\u{10AC0}-\u{10AE4}\u{10AEB}-\u{10AF6}\u{10B00}-\u{10B35}\u{10B40}-\u{10B55}\u{10B58}-\u{10B72}\u{10B78}-\u{10B91}\u{10B99}-\u{10B9C}\u{10BA9}-\u{10BAF}\u{10C00}-\u{10C48}\u{10C80}-\u{10CB2}\u{10CC0}-\u{10CF2}\u{10CFA}-\u{10D23}\u{10D30}-\u{10D39}\u{10D40}-\u{10D65}\u{10D6F}-\u{10D85}\u{10D8E}\u{10D8F}\u{10E60}-\u{10E7E}\u{10E80}-\u{10EA9}\u{10EAD}\u{10EB0}\u{10EB1}\u{10EC2}-\u{10EC7}\u{10F00}-\u{10F27}\u{10F30}-\u{10F45}\u{10F51}-\u{10F59}\u{10F70}-\u{10F81}\u{10F86}-\u{10F89}\u{10FB0}-\u{10FCB}\u{10FE0}-\u{10FF6}\u{1CCF0}-\u{1CCF9}\u{1D7CE}-\u{1D7FF}\u{1E800}-\u{1E8C4}\u{1E8C7}-\u{1E8CF}\u{1E900}-\u{1E943}\u{1E94B}\u{1E950}-\u{1E959}\u{1E95E}\u{1E95F}\u{1EC71}-\u{1ECB4}\u{1ED01}-\u{1ED3D}\u{1EE00}-\u{1EE03}\u{1EE05}-\u{1EE1F}\u{1EE21}\u{1EE22}\u{1EE24}\u{1EE27}\u{1EE29}-\u{1EE32}\u{1EE34}-\u{1EE37}\u{1EE39}\u{1EE3B}\u{1EE42}\u{1EE47}\u{1EE49}\u{1EE4B}\u{1EE4D}-\u{1EE4F}\u{1EE51}\u{1EE52}\u{1EE54}\u{1EE57}\u{1EE59}\u{1EE5B}\u{1EE5D}\u{1EE5F}\u{1EE61}\u{1EE62}\u{1EE64}\u{1EE67}-\u{1EE6A}\u{1EE6C}-\u{1EE72}\u{1EE74}-\u{1EE77}\u{1EE79}-\u{1EE7C}\u{1EE7E}\u{1EE80}-\u{1EE89}\u{1EE8B}-\u{1EE9B}\u{1EEA1}-\u{1EEA3}\u{1EEA5}-\u{1EEA9}\u{1EEAB}-\u{1EEBB}\u{1F100}-\u{1F10A}\u{1FBF0}-\u{1FBF9}][\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u0897-\u089F\u08CA-\u08E1\u08E3-\u0902\u093A\u093C\u0941-\u0948\u094D\u0951-\u0957\u0962\u0963\u0981\u09BC\u09C1-\u09C4\u09CD\u09E2\u09E3\u09FE\u0A01\u0A02\u0A3C\u0A41\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81\u0A82\u0ABC\u0AC1-\u0AC5\u0AC7\u0AC8\u0ACD\u0AE2\u0AE3\u0AFA-\u0AFF\u0B01\u0B3C\u0B3F\u0B41-\u0B44\u0B4D\u0B55\u0B56\u0B62\u0B63\u0B82\u0BC0\u0BCD\u0C00\u0C04\u0C3C\u0C3E-\u0C40\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81\u0CBC\u0CCC\u0CCD\u0CE2\u0CE3\u0D00\u0D01\u0D3B\u0D3C\u0D41-\u0D44\u0D4D\u0D62\u0D63\u0D81\u0DCA\u0DD2-\u0DD4\u0DD6\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EBC\u0EC8-\u0ECE\u0F18\u0F19\u0F35\u0F37\u0F39\u0F71-\u0F7E\u0F80-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102D-\u1030\u1032-\u1037\u1039\u103A\u103D\u103E\u1058\u1059\u105E-\u1060\u1071-\u1074\u1082\u1085\u1086\u108D\u109D\u135D-\u135F\u1712-\u1714\u1732\u1733\u1752\u1753\u1772\u1773\u17B4\u17B5\u17B7-\u17BD\u17C6\u17C9-\u17D3\u17DD\u180B-\u180D\u180F\u1885\u1886\u18A9\u1920-\u1922\u1927\u1928\u1932\u1939-\u193B\u1A17\u1A18\u1A1B\u1A56\u1A58-\u1A5E\u1A60\u1A62\u1A65-\u1A6C\u1A73-\u1A7C\u1A7F\u1AB0-\u1ADD\u1AE0-\u1AEB\u1B00-\u1B03\u1B34\u1B36-\u1B3A\u1B3C\u1B42\u1B6B-\u1B73\u1B80\u1B81\u1BA2-\u1BA5\u1BA8\u1BA9\u1BAB-\u1BAD\u1BE6\u1BE8\u1BE9\u1BED\u1BEF-\u1BF1\u1C2C-\u1C33\u1C36\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE0\u1CE2-\u1CE8\u1CED\u1CF4\u1CF8\u1CF9\u1DC0-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302D\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA825\uA826\uA82C\uA8C4\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA951\uA980-\uA982\uA9B3\uA9B6-\uA9B9\uA9BC\uA9BD\uA9E5\uAA29-\uAA2E\uAA31\uAA32\uAA35\uAA36\uAA43\uAA4C\uAA7C\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEC\uAAED\uAAF6\uABE5\uABE8\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F\u{101FD}\u{102E0}\u{10376}-\u{1037A}\u{10A01}-\u{10A03}\u{10A05}\u{10A06}\u{10A0C}-\u{10A0F}\u{10A38}-\u{10A3A}\u{10A3F}\u{10AE5}\u{10AE6}\u{10D24}-\u{10D27}\u{10D69}-\u{10D6D}\u{10EAB}\u{10EAC}\u{10EFA}-\u{10EFF}\u{10F46}-\u{10F50}\u{10F82}-\u{10F85}\u{11001}\u{11038}-\u{11046}\u{11070}\u{11073}\u{11074}\u{1107F}-\u{11081}\u{110B3}-\u{110B6}\u{110B9}\u{110BA}\u{110C2}\u{11100}-\u{11102}\u{11127}-\u{1112B}\u{1112D}-\u{11134}\u{11173}\u{11180}\u{11181}\u{111B6}-\u{111BE}\u{111C9}-\u{111CC}\u{111CF}\u{1122F}-\u{11231}\u{11234}\u{11236}\u{11237}\u{1123E}\u{11241}\u{112DF}\u{112E3}-\u{112EA}\u{11300}\u{11301}\u{1133B}\u{1133C}\u{11340}\u{11366}-\u{1136C}\u{11370}-\u{11374}\u{113BB}-\u{113C0}\u{113CE}\u{113D0}\u{113D2}\u{113E1}\u{113E2}\u{11438}-\u{1143F}\u{11442}-\u{11444}\u{11446}\u{1145E}\u{114B3}-\u{114B8}\u{114BA}\u{114BF}\u{114C0}\u{114C2}\u{114C3}\u{115B2}-\u{115B5}\u{115BC}\u{115BD}\u{115BF}\u{115C0}\u{115DC}\u{115DD}\u{11633}-\u{1163A}\u{1163D}\u{1163F}\u{11640}\u{116AB}\u{116AD}\u{116B0}-\u{116B5}\u{116B7}\u{1171D}\u{1171F}\u{11722}-\u{11725}\u{11727}-\u{1172B}\u{1182F}-\u{11837}\u{11839}\u{1183A}\u{1193B}\u{1193C}\u{1193E}\u{11943}\u{119D4}-\u{119D7}\u{119DA}\u{119DB}\u{119E0}\u{11A01}-\u{11A06}\u{11A09}\u{11A0A}\u{11A33}-\u{11A38}\u{11A3B}-\u{11A3E}\u{11A47}\u{11A51}-\u{11A56}\u{11A59}-\u{11A5B}\u{11A8A}-\u{11A96}\u{11A98}\u{11A99}\u{11B60}\u{11B62}-\u{11B64}\u{11B66}\u{11C30}-\u{11C36}\u{11C38}-\u{11C3D}\u{11C92}-\u{11CA7}\u{11CAA}-\u{11CB0}\u{11CB2}\u{11CB3}\u{11CB5}\u{11CB6}\u{11D31}-\u{11D36}\u{11D3A}\u{11D3C}\u{11D3D}\u{11D3F}-\u{11D45}\u{11D47}\u{11D90}\u{11D91}\u{11D95}\u{11D97}\u{11EF3}\u{11EF4}\u{11F00}\u{11F01}\u{11F36}-\u{11F3A}\u{11F40}\u{11F42}\u{11F5A}\u{13440}\u{13447}-\u{13455}\u{1611E}-\u{16129}\u{1612D}-\u{1612F}\u{16AF0}-\u{16AF4}\u{16B30}-\u{16B36}\u{16F4F}\u{16F8F}-\u{16F92}\u{16FE4}\u{1BC9D}\u{1BC9E}\u{1CF00}-\u{1CF2D}\u{1CF30}-\u{1CF46}\u{1D167}-\u{1D169}\u{1D17B}-\u{1D182}\u{1D185}-\u{1D18B}\u{1D1AA}-\u{1D1AD}\u{1D242}-\u{1D244}\u{1DA00}-\u{1DA36}\u{1DA3B}-\u{1DA6C}\u{1DA75}\u{1DA84}\u{1DA9B}-\u{1DA9F}\u{1DAA1}-\u{1DAAF}\u{1E000}-\u{1E006}\u{1E008}-\u{1E018}\u{1E01B}-\u{1E021}\u{1E023}\u{1E024}\u{1E026}-\u{1E02A}\u{1E08F}\u{1E130}-\u{1E136}\u{1E2AE}\u{1E2EC}-\u{1E2EF}\u{1E4EC}-\u{1E4EF}\u{1E5EE}\u{1E5EF}\u{1E6E3}\u{1E6E6}\u{1E6EE}\u{1E6EF}\u{1E6F5}\u{1E8D0}-\u{1E8D6}\u{1E944}-\u{1E94A}\u{E0100}-\u{E01EF}]*$/u; +const bidiS4EN = /[0-9\xB2\xB3\xB9\u06F0-\u06F9\u2070\u2074-\u2079\u2080-\u2089\u2488-\u249B\uFF10-\uFF19\u{102E1}-\u{102FB}\u{1CCF0}-\u{1CCF9}\u{1D7CE}-\u{1D7FF}\u{1F100}-\u{1F10A}\u{1FBF0}-\u{1FBF9}]/u; +const bidiS4AN = /[\u0600-\u0605\u0660-\u0669\u066B\u066C\u06DD\u0890\u0891\u08E2\u{10D30}-\u{10D39}\u{10D40}-\u{10D49}\u{10E60}-\u{10E7E}]/u; +const bidiS5 = /^[\0-\x08\x0E-\x1B!-\x84\x86-\u0377\u037A-\u037F\u0384-\u038A\u038C\u038E-\u03A1\u03A3-\u052F\u0531-\u0556\u0559-\u058A\u058D-\u058F\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0606\u0607\u0609\u060A\u060C\u060E-\u061A\u064B-\u065F\u066A\u0670\u06D6-\u06DC\u06DE-\u06E4\u06E7-\u06ED\u06F0-\u06F9\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07F6-\u07F9\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u0897-\u089F\u08CA-\u08E1\u08E3-\u0983\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BC-\u09C4\u09C7\u09C8\u09CB-\u09CE\u09D7\u09DC\u09DD\u09DF-\u09E3\u09E6-\u09FE\u0A01-\u0A03\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A59-\u0A5C\u0A5E\u0A66-\u0A76\u0A81-\u0A83\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABC-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AD0\u0AE0-\u0AE3\u0AE6-\u0AF1\u0AF9-\u0AFF\u0B01-\u0B03\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3C-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B55-\u0B57\u0B5C\u0B5D\u0B5F-\u0B63\u0B66-\u0B77\u0B82\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD0\u0BD7\u0BE6-\u0BFA\u0C00-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3C-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C58-\u0C5A\u0C5C\u0C5D\u0C60-\u0C63\u0C66-\u0C6F\u0C77-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBC-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CDC-\u0CDE\u0CE0-\u0CE3\u0CE6-\u0CEF\u0CF1-\u0CF3\u0D00-\u0D0C\u0D0E-\u0D10\u0D12-\u0D44\u0D46-\u0D48\u0D4A-\u0D4F\u0D54-\u0D63\u0D66-\u0D7F\u0D81-\u0D83\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DE6-\u0DEF\u0DF2-\u0DF4\u0E01-\u0E3A\u0E3F-\u0E5B\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EBD\u0EC0-\u0EC4\u0EC6\u0EC8-\u0ECE\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00-\u0F47\u0F49-\u0F6C\u0F71-\u0F97\u0F99-\u0FBC\u0FBE-\u0FCC\u0FCE-\u0FDA\u1000-\u10C5\u10C7\u10CD\u10D0-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u135D-\u137C\u1380-\u1399\u13A0-\u13F5\u13F8-\u13FD\u1400-\u167F\u1681-\u169C\u16A0-\u16F8\u1700-\u1715\u171F-\u1736\u1740-\u1753\u1760-\u176C\u176E-\u1770\u1772\u1773\u1780-\u17DD\u17E0-\u17E9\u17F0-\u17F9\u1800-\u1819\u1820-\u1878\u1880-\u18AA\u18B0-\u18F5\u1900-\u191E\u1920-\u192B\u1930-\u193B\u1940\u1944-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u19DE-\u1A1B\u1A1E-\u1A5E\u1A60-\u1A7C\u1A7F-\u1A89\u1A90-\u1A99\u1AA0-\u1AAD\u1AB0-\u1ADD\u1AE0-\u1AEB\u1B00-\u1B4C\u1B4E-\u1BF3\u1BFC-\u1C37\u1C3B-\u1C49\u1C4D-\u1C8A\u1C90-\u1CBA\u1CBD-\u1CC7\u1CD0-\u1CFA\u1D00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FC4\u1FC6-\u1FD3\u1FD6-\u1FDB\u1FDD-\u1FEF\u1FF2-\u1FF4\u1FF6-\u1FFE\u200B-\u200E\u2010-\u2027\u202F-\u205E\u2060-\u2064\u206A-\u2071\u2074-\u208E\u2090-\u209C\u20A0-\u20C1\u20D0-\u20F0\u2100-\u218B\u2190-\u2429\u2440-\u244A\u2460-\u2B73\u2B76-\u2CF3\u2CF9-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D70\u2D7F-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2DE0-\u2E5D\u2E80-\u2E99\u2E9B-\u2EF3\u2F00-\u2FD5\u2FF0-\u2FFF\u3001-\u303F\u3041-\u3096\u3099-\u30FF\u3105-\u312F\u3131-\u318E\u3190-\u31E5\u31EF-\u321E\u3220-\uA48C\uA490-\uA4C6\uA4D0-\uA62B\uA640-\uA6F7\uA700-\uA7DC\uA7F1-\uA82C\uA830-\uA839\uA840-\uA877\uA880-\uA8C5\uA8CE-\uA8D9\uA8E0-\uA953\uA95F-\uA97C\uA980-\uA9CD\uA9CF-\uA9D9\uA9DE-\uA9FE\uAA00-\uAA36\uAA40-\uAA4D\uAA50-\uAA59\uAA5C-\uAAC2\uAADB-\uAAF6\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB6B\uAB70-\uABED\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uD800-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1E\uFB29\uFBC3-\uFBD2\uFD3E-\uFD4F\uFD90\uFD91\uFDC8-\uFDCF\uFDFD-\uFE19\uFE20-\uFE52\uFE54-\uFE66\uFE68-\uFE6B\uFEFF\uFF01-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC\uFFE0-\uFFE6\uFFE8-\uFFEE\uFFF9-\uFFFD\u{10000}-\u{1000B}\u{1000D}-\u{10026}\u{10028}-\u{1003A}\u{1003C}\u{1003D}\u{1003F}-\u{1004D}\u{10050}-\u{1005D}\u{10080}-\u{100FA}\u{10100}-\u{10102}\u{10107}-\u{10133}\u{10137}-\u{1018E}\u{10190}-\u{1019C}\u{101A0}\u{101D0}-\u{101FD}\u{10280}-\u{1029C}\u{102A0}-\u{102D0}\u{102E0}-\u{102FB}\u{10300}-\u{10323}\u{1032D}-\u{1034A}\u{10350}-\u{1037A}\u{10380}-\u{1039D}\u{1039F}-\u{103C3}\u{103C8}-\u{103D5}\u{10400}-\u{1049D}\u{104A0}-\u{104A9}\u{104B0}-\u{104D3}\u{104D8}-\u{104FB}\u{10500}-\u{10527}\u{10530}-\u{10563}\u{1056F}-\u{1057A}\u{1057C}-\u{1058A}\u{1058C}-\u{10592}\u{10594}\u{10595}\u{10597}-\u{105A1}\u{105A3}-\u{105B1}\u{105B3}-\u{105B9}\u{105BB}\u{105BC}\u{105C0}-\u{105F3}\u{10600}-\u{10736}\u{10740}-\u{10755}\u{10760}-\u{10767}\u{10780}-\u{10785}\u{10787}-\u{107B0}\u{107B2}-\u{107BA}\u{1091F}\u{10A01}-\u{10A03}\u{10A05}\u{10A06}\u{10A0C}-\u{10A0F}\u{10A38}-\u{10A3A}\u{10A3F}\u{10AE5}\u{10AE6}\u{10B39}-\u{10B3F}\u{10D24}-\u{10D27}\u{10D69}-\u{10D6E}\u{10EAB}\u{10EAC}\u{10ED0}-\u{10ED8}\u{10EFA}-\u{10EFF}\u{10F46}-\u{10F50}\u{10F82}-\u{10F85}\u{11000}-\u{1104D}\u{11052}-\u{11075}\u{1107F}-\u{110C2}\u{110CD}\u{110D0}-\u{110E8}\u{110F0}-\u{110F9}\u{11100}-\u{11134}\u{11136}-\u{11147}\u{11150}-\u{11176}\u{11180}-\u{111DF}\u{111E1}-\u{111F4}\u{11200}-\u{11211}\u{11213}-\u{11241}\u{11280}-\u{11286}\u{11288}\u{1128A}-\u{1128D}\u{1128F}-\u{1129D}\u{1129F}-\u{112A9}\u{112B0}-\u{112EA}\u{112F0}-\u{112F9}\u{11300}-\u{11303}\u{11305}-\u{1130C}\u{1130F}\u{11310}\u{11313}-\u{11328}\u{1132A}-\u{11330}\u{11332}\u{11333}\u{11335}-\u{11339}\u{1133B}-\u{11344}\u{11347}\u{11348}\u{1134B}-\u{1134D}\u{11350}\u{11357}\u{1135D}-\u{11363}\u{11366}-\u{1136C}\u{11370}-\u{11374}\u{11380}-\u{11389}\u{1138B}\u{1138E}\u{11390}-\u{113B5}\u{113B7}-\u{113C0}\u{113C2}\u{113C5}\u{113C7}-\u{113CA}\u{113CC}-\u{113D5}\u{113D7}\u{113D8}\u{113E1}\u{113E2}\u{11400}-\u{1145B}\u{1145D}-\u{11461}\u{11480}-\u{114C7}\u{114D0}-\u{114D9}\u{11580}-\u{115B5}\u{115B8}-\u{115DD}\u{11600}-\u{11644}\u{11650}-\u{11659}\u{11660}-\u{1166C}\u{11680}-\u{116B9}\u{116C0}-\u{116C9}\u{116D0}-\u{116E3}\u{11700}-\u{1171A}\u{1171D}-\u{1172B}\u{11730}-\u{11746}\u{11800}-\u{1183B}\u{118A0}-\u{118F2}\u{118FF}-\u{11906}\u{11909}\u{1190C}-\u{11913}\u{11915}\u{11916}\u{11918}-\u{11935}\u{11937}\u{11938}\u{1193B}-\u{11946}\u{11950}-\u{11959}\u{119A0}-\u{119A7}\u{119AA}-\u{119D7}\u{119DA}-\u{119E4}\u{11A00}-\u{11A47}\u{11A50}-\u{11AA2}\u{11AB0}-\u{11AF8}\u{11B00}-\u{11B09}\u{11B60}-\u{11B67}\u{11BC0}-\u{11BE1}\u{11BF0}-\u{11BF9}\u{11C00}-\u{11C08}\u{11C0A}-\u{11C36}\u{11C38}-\u{11C45}\u{11C50}-\u{11C6C}\u{11C70}-\u{11C8F}\u{11C92}-\u{11CA7}\u{11CA9}-\u{11CB6}\u{11D00}-\u{11D06}\u{11D08}\u{11D09}\u{11D0B}-\u{11D36}\u{11D3A}\u{11D3C}\u{11D3D}\u{11D3F}-\u{11D47}\u{11D50}-\u{11D59}\u{11D60}-\u{11D65}\u{11D67}\u{11D68}\u{11D6A}-\u{11D8E}\u{11D90}\u{11D91}\u{11D93}-\u{11D98}\u{11DA0}-\u{11DA9}\u{11DB0}-\u{11DDB}\u{11DE0}-\u{11DE9}\u{11EE0}-\u{11EF8}\u{11F00}-\u{11F10}\u{11F12}-\u{11F3A}\u{11F3E}-\u{11F5A}\u{11FB0}\u{11FC0}-\u{11FF1}\u{11FFF}-\u{12399}\u{12400}-\u{1246E}\u{12470}-\u{12474}\u{12480}-\u{12543}\u{12F90}-\u{12FF2}\u{13000}-\u{13455}\u{13460}-\u{143FA}\u{14400}-\u{14646}\u{16100}-\u{16139}\u{16800}-\u{16A38}\u{16A40}-\u{16A5E}\u{16A60}-\u{16A69}\u{16A6E}-\u{16ABE}\u{16AC0}-\u{16AC9}\u{16AD0}-\u{16AED}\u{16AF0}-\u{16AF5}\u{16B00}-\u{16B45}\u{16B50}-\u{16B59}\u{16B5B}-\u{16B61}\u{16B63}-\u{16B77}\u{16B7D}-\u{16B8F}\u{16D40}-\u{16D79}\u{16E40}-\u{16E9A}\u{16EA0}-\u{16EB8}\u{16EBB}-\u{16ED3}\u{16F00}-\u{16F4A}\u{16F4F}-\u{16F87}\u{16F8F}-\u{16F9F}\u{16FE0}-\u{16FE4}\u{16FF0}-\u{16FF6}\u{17000}-\u{18CD5}\u{18CFF}-\u{18D1E}\u{18D80}-\u{18DF2}\u{1AFF0}-\u{1AFF3}\u{1AFF5}-\u{1AFFB}\u{1AFFD}\u{1AFFE}\u{1B000}-\u{1B122}\u{1B132}\u{1B150}-\u{1B152}\u{1B155}\u{1B164}-\u{1B167}\u{1B170}-\u{1B2FB}\u{1BC00}-\u{1BC6A}\u{1BC70}-\u{1BC7C}\u{1BC80}-\u{1BC88}\u{1BC90}-\u{1BC99}\u{1BC9C}-\u{1BCA3}\u{1CC00}-\u{1CCFC}\u{1CD00}-\u{1CEB3}\u{1CEBA}-\u{1CED0}\u{1CEE0}-\u{1CEF0}\u{1CF00}-\u{1CF2D}\u{1CF30}-\u{1CF46}\u{1CF50}-\u{1CFC3}\u{1D000}-\u{1D0F5}\u{1D100}-\u{1D126}\u{1D129}-\u{1D1EA}\u{1D200}-\u{1D245}\u{1D2C0}-\u{1D2D3}\u{1D2E0}-\u{1D2F3}\u{1D300}-\u{1D356}\u{1D360}-\u{1D378}\u{1D400}-\u{1D454}\u{1D456}-\u{1D49C}\u{1D49E}\u{1D49F}\u{1D4A2}\u{1D4A5}\u{1D4A6}\u{1D4A9}-\u{1D4AC}\u{1D4AE}-\u{1D4B9}\u{1D4BB}\u{1D4BD}-\u{1D4C3}\u{1D4C5}-\u{1D505}\u{1D507}-\u{1D50A}\u{1D50D}-\u{1D514}\u{1D516}-\u{1D51C}\u{1D51E}-\u{1D539}\u{1D53B}-\u{1D53E}\u{1D540}-\u{1D544}\u{1D546}\u{1D54A}-\u{1D550}\u{1D552}-\u{1D6A5}\u{1D6A8}-\u{1D7CB}\u{1D7CE}-\u{1DA8B}\u{1DA9B}-\u{1DA9F}\u{1DAA1}-\u{1DAAF}\u{1DF00}-\u{1DF1E}\u{1DF25}-\u{1DF2A}\u{1E000}-\u{1E006}\u{1E008}-\u{1E018}\u{1E01B}-\u{1E021}\u{1E023}\u{1E024}\u{1E026}-\u{1E02A}\u{1E030}-\u{1E06D}\u{1E08F}\u{1E100}-\u{1E12C}\u{1E130}-\u{1E13D}\u{1E140}-\u{1E149}\u{1E14E}\u{1E14F}\u{1E290}-\u{1E2AE}\u{1E2C0}-\u{1E2F9}\u{1E2FF}\u{1E4D0}-\u{1E4F9}\u{1E5D0}-\u{1E5FA}\u{1E5FF}\u{1E6C0}-\u{1E6DE}\u{1E6E0}-\u{1E6F5}\u{1E6FE}\u{1E6FF}\u{1E7E0}-\u{1E7E6}\u{1E7E8}-\u{1E7EB}\u{1E7ED}\u{1E7EE}\u{1E7F0}-\u{1E7FE}\u{1E8D0}-\u{1E8D6}\u{1E944}-\u{1E94A}\u{1EEF0}\u{1EEF1}\u{1F000}-\u{1F02B}\u{1F030}-\u{1F093}\u{1F0A0}-\u{1F0AE}\u{1F0B1}-\u{1F0BF}\u{1F0C1}-\u{1F0CF}\u{1F0D1}-\u{1F0F5}\u{1F100}-\u{1F1AD}\u{1F1E6}-\u{1F202}\u{1F210}-\u{1F23B}\u{1F240}-\u{1F248}\u{1F250}\u{1F251}\u{1F260}-\u{1F265}\u{1F300}-\u{1F6D8}\u{1F6DC}-\u{1F6EC}\u{1F6F0}-\u{1F6FC}\u{1F700}-\u{1F7D9}\u{1F7E0}-\u{1F7EB}\u{1F7F0}\u{1F800}-\u{1F80B}\u{1F810}-\u{1F847}\u{1F850}-\u{1F859}\u{1F860}-\u{1F887}\u{1F890}-\u{1F8AD}\u{1F8B0}-\u{1F8BB}\u{1F8C0}\u{1F8C1}\u{1F8D0}-\u{1F8D8}\u{1F900}-\u{1FA57}\u{1FA60}-\u{1FA6D}\u{1FA70}-\u{1FA7C}\u{1FA80}-\u{1FA8A}\u{1FA8E}-\u{1FAC6}\u{1FAC8}\u{1FACD}-\u{1FADC}\u{1FADF}-\u{1FAEA}\u{1FAEF}-\u{1FAF8}\u{1FB00}-\u{1FB92}\u{1FB94}-\u{1FBFA}\u{20000}-\u{2A6DF}\u{2A700}-\u{2B81D}\u{2B820}-\u{2CEAD}\u{2CEB0}-\u{2EBE0}\u{2EBF0}-\u{2EE5D}\u{2F800}-\u{2FA1D}\u{30000}-\u{3134A}\u{31350}-\u{33479}\u{E0001}\u{E0020}-\u{E007F}\u{E0100}-\u{E01EF}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]*$/u; +const bidiS6 = /[0-9A-Za-z\xAA\xB2\xB3\xB5\xB9\xBA\xC0-\xD6\xD8-\xF6\xF8-\u02B8\u02BB-\u02C1\u02D0\u02D1\u02E0-\u02E4\u02EE\u0370-\u0373\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0482\u048A-\u052F\u0531-\u0556\u0559-\u0589\u06F0-\u06F9\u0903-\u0939\u093B\u093D-\u0940\u0949-\u094C\u094E-\u0950\u0958-\u0961\u0964-\u0980\u0982\u0983\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD-\u09C0\u09C7\u09C8\u09CB\u09CC\u09CE\u09D7\u09DC\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\u09FA\u09FC\u09FD\u0A03\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A3E-\u0A40\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A76\u0A83\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD-\u0AC0\u0AC9\u0ACB\u0ACC\u0AD0\u0AE0\u0AE1\u0AE6-\u0AF0\u0AF9\u0B02\u0B03\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B3E\u0B40\u0B47\u0B48\u0B4B\u0B4C\u0B57\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BBE\u0BBF\u0BC1\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCC\u0BD0\u0BD7\u0BE6-\u0BF2\u0C01-\u0C03\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C41-\u0C44\u0C58-\u0C5A\u0C5C\u0C5D\u0C60\u0C61\u0C66-\u0C6F\u0C77\u0C7F\u0C80\u0C82-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD-\u0CC4\u0CC6-\u0CC8\u0CCA\u0CCB\u0CD5\u0CD6\u0CDC-\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0CF1-\u0CF3\u0D02-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D-\u0D40\u0D46-\u0D48\u0D4A-\u0D4C\u0D4E\u0D4F\u0D54-\u0D61\u0D66-\u0D7F\u0D82\u0D83\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DCF-\u0DD1\u0DD8-\u0DDF\u0DE6-\u0DEF\u0DF2-\u0DF4\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E4F-\u0E5B\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00-\u0F17\u0F1A-\u0F34\u0F36\u0F38\u0F3E-\u0F47\u0F49-\u0F6C\u0F7F\u0F85\u0F88-\u0F8C\u0FBE-\u0FC5\u0FC7-\u0FCC\u0FCE-\u0FDA\u1000-\u102C\u1031\u1038\u103B\u103C\u103F-\u1057\u105A-\u105D\u1061-\u1070\u1075-\u1081\u1083\u1084\u1087-\u108C\u108E-\u109C\u109E-\u10C5\u10C7\u10CD\u10D0-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1360-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u167F\u1681-\u169A\u16A0-\u16F8\u1700-\u1711\u1715\u171F-\u1731\u1734-\u1736\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17B6\u17BE-\u17C5\u17C7\u17C8\u17D4-\u17DA\u17DC\u17E0-\u17E9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1923-\u1926\u1929-\u192B\u1930\u1931\u1933-\u1938\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A19\u1A1A\u1A1E-\u1A55\u1A57\u1A61\u1A63\u1A64\u1A6D-\u1A72\u1A80-\u1A89\u1A90-\u1A99\u1AA0-\u1AAD\u1B04-\u1B33\u1B35\u1B3B\u1B3D-\u1B41\u1B43-\u1B4C\u1B4E-\u1B6A\u1B74-\u1B7F\u1B82-\u1BA1\u1BA6\u1BA7\u1BAA\u1BAE-\u1BE5\u1BE7\u1BEA-\u1BEC\u1BEE\u1BF2\u1BF3\u1BFC-\u1C2B\u1C34\u1C35\u1C3B-\u1C49\u1C4D-\u1C8A\u1C90-\u1CBA\u1CBD-\u1CC7\u1CD3\u1CE1\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5-\u1CF7\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u200E\u2070\u2071\u2074-\u2079\u207F-\u2089\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u214F\u2160-\u2188\u2336-\u237A\u2395\u2488-\u24E9\u26AC\u2800-\u28FF\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D70\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u3005-\u3007\u3021-\u3029\u302E\u302F\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3190-\u31BF\u31F0-\u321C\u3220-\u324F\u3260-\u327B\u327F-\u32B0\u32C0-\u32CB\u32D0-\u3376\u337B-\u33DD\u33E0-\u33FE\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA60C\uA610-\uA62B\uA640-\uA66E\uA680-\uA69D\uA6A0-\uA6EF\uA6F2-\uA6F7\uA722-\uA787\uA789-\uA7DC\uA7F1-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA824\uA827\uA830-\uA837\uA840-\uA873\uA880-\uA8C3\uA8CE-\uA8D9\uA8F2-\uA8FE\uA900-\uA925\uA92E-\uA946\uA952\uA953\uA95F-\uA97C\uA983-\uA9B2\uA9B4\uA9B5\uA9BA\uA9BB\uA9BE-\uA9CD\uA9CF-\uA9D9\uA9DE-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA2F\uAA30\uAA33\uAA34\uAA40-\uAA42\uAA44-\uAA4B\uAA4D\uAA50-\uAA59\uAA5C-\uAA7B\uAA7D-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAAEB\uAAEE-\uAAF5\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB69\uAB70-\uABE4\uABE6\uABE7\uABE9-\uABEC\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uD800-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC\u{10000}-\u{1000B}\u{1000D}-\u{10026}\u{10028}-\u{1003A}\u{1003C}\u{1003D}\u{1003F}-\u{1004D}\u{10050}-\u{1005D}\u{10080}-\u{100FA}\u{10100}\u{10102}\u{10107}-\u{10133}\u{10137}-\u{1013F}\u{1018D}\u{1018E}\u{101D0}-\u{101FC}\u{10280}-\u{1029C}\u{102A0}-\u{102D0}\u{102E1}-\u{102FB}\u{10300}-\u{10323}\u{1032D}-\u{1034A}\u{10350}-\u{10375}\u{10380}-\u{1039D}\u{1039F}-\u{103C3}\u{103C8}-\u{103D5}\u{10400}-\u{1049D}\u{104A0}-\u{104A9}\u{104B0}-\u{104D3}\u{104D8}-\u{104FB}\u{10500}-\u{10527}\u{10530}-\u{10563}\u{1056F}-\u{1057A}\u{1057C}-\u{1058A}\u{1058C}-\u{10592}\u{10594}\u{10595}\u{10597}-\u{105A1}\u{105A3}-\u{105B1}\u{105B3}-\u{105B9}\u{105BB}\u{105BC}\u{105C0}-\u{105F3}\u{10600}-\u{10736}\u{10740}-\u{10755}\u{10760}-\u{10767}\u{10780}-\u{10785}\u{10787}-\u{107B0}\u{107B2}-\u{107BA}\u{11000}\u{11002}-\u{11037}\u{11047}-\u{1104D}\u{11066}-\u{1106F}\u{11071}\u{11072}\u{11075}\u{11082}-\u{110B2}\u{110B7}\u{110B8}\u{110BB}-\u{110C1}\u{110CD}\u{110D0}-\u{110E8}\u{110F0}-\u{110F9}\u{11103}-\u{11126}\u{1112C}\u{11136}-\u{11147}\u{11150}-\u{11172}\u{11174}-\u{11176}\u{11182}-\u{111B5}\u{111BF}-\u{111C8}\u{111CD}\u{111CE}\u{111D0}-\u{111DF}\u{111E1}-\u{111F4}\u{11200}-\u{11211}\u{11213}-\u{1122E}\u{11232}\u{11233}\u{11235}\u{11238}-\u{1123D}\u{1123F}\u{11240}\u{11280}-\u{11286}\u{11288}\u{1128A}-\u{1128D}\u{1128F}-\u{1129D}\u{1129F}-\u{112A9}\u{112B0}-\u{112DE}\u{112E0}-\u{112E2}\u{112F0}-\u{112F9}\u{11302}\u{11303}\u{11305}-\u{1130C}\u{1130F}\u{11310}\u{11313}-\u{11328}\u{1132A}-\u{11330}\u{11332}\u{11333}\u{11335}-\u{11339}\u{1133D}-\u{1133F}\u{11341}-\u{11344}\u{11347}\u{11348}\u{1134B}-\u{1134D}\u{11350}\u{11357}\u{1135D}-\u{11363}\u{11380}-\u{11389}\u{1138B}\u{1138E}\u{11390}-\u{113B5}\u{113B7}-\u{113BA}\u{113C2}\u{113C5}\u{113C7}-\u{113CA}\u{113CC}\u{113CD}\u{113CF}\u{113D1}\u{113D3}-\u{113D5}\u{113D7}\u{113D8}\u{11400}-\u{11437}\u{11440}\u{11441}\u{11445}\u{11447}-\u{1145B}\u{1145D}\u{1145F}-\u{11461}\u{11480}-\u{114B2}\u{114B9}\u{114BB}-\u{114BE}\u{114C1}\u{114C4}-\u{114C7}\u{114D0}-\u{114D9}\u{11580}-\u{115B1}\u{115B8}-\u{115BB}\u{115BE}\u{115C1}-\u{115DB}\u{11600}-\u{11632}\u{1163B}\u{1163C}\u{1163E}\u{11641}-\u{11644}\u{11650}-\u{11659}\u{11680}-\u{116AA}\u{116AC}\u{116AE}\u{116AF}\u{116B6}\u{116B8}\u{116B9}\u{116C0}-\u{116C9}\u{116D0}-\u{116E3}\u{11700}-\u{1171A}\u{1171E}\u{11720}\u{11721}\u{11726}\u{11730}-\u{11746}\u{11800}-\u{1182E}\u{11838}\u{1183B}\u{118A0}-\u{118F2}\u{118FF}-\u{11906}\u{11909}\u{1190C}-\u{11913}\u{11915}\u{11916}\u{11918}-\u{11935}\u{11937}\u{11938}\u{1193D}\u{1193F}-\u{11942}\u{11944}-\u{11946}\u{11950}-\u{11959}\u{119A0}-\u{119A7}\u{119AA}-\u{119D3}\u{119DC}-\u{119DF}\u{119E1}-\u{119E4}\u{11A00}\u{11A07}\u{11A08}\u{11A0B}-\u{11A32}\u{11A39}\u{11A3A}\u{11A3F}-\u{11A46}\u{11A50}\u{11A57}\u{11A58}\u{11A5C}-\u{11A89}\u{11A97}\u{11A9A}-\u{11AA2}\u{11AB0}-\u{11AF8}\u{11B00}-\u{11B09}\u{11B61}\u{11B65}\u{11B67}\u{11BC0}-\u{11BE1}\u{11BF0}-\u{11BF9}\u{11C00}-\u{11C08}\u{11C0A}-\u{11C2F}\u{11C3E}-\u{11C45}\u{11C50}-\u{11C6C}\u{11C70}-\u{11C8F}\u{11CA9}\u{11CB1}\u{11CB4}\u{11D00}-\u{11D06}\u{11D08}\u{11D09}\u{11D0B}-\u{11D30}\u{11D46}\u{11D50}-\u{11D59}\u{11D60}-\u{11D65}\u{11D67}\u{11D68}\u{11D6A}-\u{11D8E}\u{11D93}\u{11D94}\u{11D96}\u{11D98}\u{11DA0}-\u{11DA9}\u{11DB0}-\u{11DDB}\u{11DE0}-\u{11DE9}\u{11EE0}-\u{11EF2}\u{11EF5}-\u{11EF8}\u{11F02}-\u{11F10}\u{11F12}-\u{11F35}\u{11F3E}\u{11F3F}\u{11F41}\u{11F43}-\u{11F59}\u{11FB0}\u{11FC0}-\u{11FD4}\u{11FFF}-\u{12399}\u{12400}-\u{1246E}\u{12470}-\u{12474}\u{12480}-\u{12543}\u{12F90}-\u{12FF2}\u{13000}-\u{1343F}\u{13441}-\u{13446}\u{13460}-\u{143FA}\u{14400}-\u{14646}\u{16100}-\u{1611D}\u{1612A}-\u{1612C}\u{16130}-\u{16139}\u{16800}-\u{16A38}\u{16A40}-\u{16A5E}\u{16A60}-\u{16A69}\u{16A6E}-\u{16ABE}\u{16AC0}-\u{16AC9}\u{16AD0}-\u{16AED}\u{16AF5}\u{16B00}-\u{16B2F}\u{16B37}-\u{16B45}\u{16B50}-\u{16B59}\u{16B5B}-\u{16B61}\u{16B63}-\u{16B77}\u{16B7D}-\u{16B8F}\u{16D40}-\u{16D79}\u{16E40}-\u{16E9A}\u{16EA0}-\u{16EB8}\u{16EBB}-\u{16ED3}\u{16F00}-\u{16F4A}\u{16F50}-\u{16F87}\u{16F93}-\u{16F9F}\u{16FE0}\u{16FE1}\u{16FE3}\u{16FF0}-\u{16FF6}\u{17000}-\u{18CD5}\u{18CFF}-\u{18D1E}\u{18D80}-\u{18DF2}\u{1AFF0}-\u{1AFF3}\u{1AFF5}-\u{1AFFB}\u{1AFFD}\u{1AFFE}\u{1B000}-\u{1B122}\u{1B132}\u{1B150}-\u{1B152}\u{1B155}\u{1B164}-\u{1B167}\u{1B170}-\u{1B2FB}\u{1BC00}-\u{1BC6A}\u{1BC70}-\u{1BC7C}\u{1BC80}-\u{1BC88}\u{1BC90}-\u{1BC99}\u{1BC9C}\u{1BC9F}\u{1CCD6}-\u{1CCF9}\u{1CF50}-\u{1CFC3}\u{1D000}-\u{1D0F5}\u{1D100}-\u{1D126}\u{1D129}-\u{1D166}\u{1D16A}-\u{1D172}\u{1D183}\u{1D184}\u{1D18C}-\u{1D1A9}\u{1D1AE}-\u{1D1E8}\u{1D2C0}-\u{1D2D3}\u{1D2E0}-\u{1D2F3}\u{1D360}-\u{1D378}\u{1D400}-\u{1D454}\u{1D456}-\u{1D49C}\u{1D49E}\u{1D49F}\u{1D4A2}\u{1D4A5}\u{1D4A6}\u{1D4A9}-\u{1D4AC}\u{1D4AE}-\u{1D4B9}\u{1D4BB}\u{1D4BD}-\u{1D4C3}\u{1D4C5}-\u{1D505}\u{1D507}-\u{1D50A}\u{1D50D}-\u{1D514}\u{1D516}-\u{1D51C}\u{1D51E}-\u{1D539}\u{1D53B}-\u{1D53E}\u{1D540}-\u{1D544}\u{1D546}\u{1D54A}-\u{1D550}\u{1D552}-\u{1D6A5}\u{1D6A8}-\u{1D6C0}\u{1D6C2}-\u{1D6DA}\u{1D6DC}-\u{1D6FA}\u{1D6FC}-\u{1D714}\u{1D716}-\u{1D734}\u{1D736}-\u{1D74E}\u{1D750}-\u{1D76E}\u{1D770}-\u{1D788}\u{1D78A}-\u{1D7A8}\u{1D7AA}-\u{1D7C2}\u{1D7C4}-\u{1D7CB}\u{1D7CE}-\u{1D9FF}\u{1DA37}-\u{1DA3A}\u{1DA6D}-\u{1DA74}\u{1DA76}-\u{1DA83}\u{1DA85}-\u{1DA8B}\u{1DF00}-\u{1DF1E}\u{1DF25}-\u{1DF2A}\u{1E030}-\u{1E06D}\u{1E100}-\u{1E12C}\u{1E137}-\u{1E13D}\u{1E140}-\u{1E149}\u{1E14E}\u{1E14F}\u{1E290}-\u{1E2AD}\u{1E2C0}-\u{1E2EB}\u{1E2F0}-\u{1E2F9}\u{1E4D0}-\u{1E4EB}\u{1E4F0}-\u{1E4F9}\u{1E5D0}-\u{1E5ED}\u{1E5F0}-\u{1E5FA}\u{1E5FF}\u{1E6C0}-\u{1E6DE}\u{1E6E0}-\u{1E6E2}\u{1E6E4}\u{1E6E5}\u{1E6E7}-\u{1E6ED}\u{1E6F0}-\u{1E6F4}\u{1E6FE}\u{1E6FF}\u{1E7E0}-\u{1E7E6}\u{1E7E8}-\u{1E7EB}\u{1E7ED}\u{1E7EE}\u{1E7F0}-\u{1E7FE}\u{1F100}-\u{1F10A}\u{1F110}-\u{1F12E}\u{1F130}-\u{1F169}\u{1F170}-\u{1F1AC}\u{1F1E6}-\u{1F202}\u{1F210}-\u{1F23B}\u{1F240}-\u{1F248}\u{1F250}\u{1F251}\u{1FBF0}-\u{1FBF9}\u{20000}-\u{2A6DF}\u{2A700}-\u{2B81D}\u{2B820}-\u{2CEAD}\u{2CEB0}-\u{2EBE0}\u{2EBF0}-\u{2EE5D}\u{2F800}-\u{2FA1D}\u{30000}-\u{3134A}\u{31350}-\u{33479}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}][\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u0897-\u089F\u08CA-\u08E1\u08E3-\u0902\u093A\u093C\u0941-\u0948\u094D\u0951-\u0957\u0962\u0963\u0981\u09BC\u09C1-\u09C4\u09CD\u09E2\u09E3\u09FE\u0A01\u0A02\u0A3C\u0A41\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81\u0A82\u0ABC\u0AC1-\u0AC5\u0AC7\u0AC8\u0ACD\u0AE2\u0AE3\u0AFA-\u0AFF\u0B01\u0B3C\u0B3F\u0B41-\u0B44\u0B4D\u0B55\u0B56\u0B62\u0B63\u0B82\u0BC0\u0BCD\u0C00\u0C04\u0C3C\u0C3E-\u0C40\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81\u0CBC\u0CCC\u0CCD\u0CE2\u0CE3\u0D00\u0D01\u0D3B\u0D3C\u0D41-\u0D44\u0D4D\u0D62\u0D63\u0D81\u0DCA\u0DD2-\u0DD4\u0DD6\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EBC\u0EC8-\u0ECE\u0F18\u0F19\u0F35\u0F37\u0F39\u0F71-\u0F7E\u0F80-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102D-\u1030\u1032-\u1037\u1039\u103A\u103D\u103E\u1058\u1059\u105E-\u1060\u1071-\u1074\u1082\u1085\u1086\u108D\u109D\u135D-\u135F\u1712-\u1714\u1732\u1733\u1752\u1753\u1772\u1773\u17B4\u17B5\u17B7-\u17BD\u17C6\u17C9-\u17D3\u17DD\u180B-\u180D\u180F\u1885\u1886\u18A9\u1920-\u1922\u1927\u1928\u1932\u1939-\u193B\u1A17\u1A18\u1A1B\u1A56\u1A58-\u1A5E\u1A60\u1A62\u1A65-\u1A6C\u1A73-\u1A7C\u1A7F\u1AB0-\u1ADD\u1AE0-\u1AEB\u1B00-\u1B03\u1B34\u1B36-\u1B3A\u1B3C\u1B42\u1B6B-\u1B73\u1B80\u1B81\u1BA2-\u1BA5\u1BA8\u1BA9\u1BAB-\u1BAD\u1BE6\u1BE8\u1BE9\u1BED\u1BEF-\u1BF1\u1C2C-\u1C33\u1C36\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE0\u1CE2-\u1CE8\u1CED\u1CF4\u1CF8\u1CF9\u1DC0-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302D\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA825\uA826\uA82C\uA8C4\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA951\uA980-\uA982\uA9B3\uA9B6-\uA9B9\uA9BC\uA9BD\uA9E5\uAA29-\uAA2E\uAA31\uAA32\uAA35\uAA36\uAA43\uAA4C\uAA7C\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEC\uAAED\uAAF6\uABE5\uABE8\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F\u{101FD}\u{102E0}\u{10376}-\u{1037A}\u{10A01}-\u{10A03}\u{10A05}\u{10A06}\u{10A0C}-\u{10A0F}\u{10A38}-\u{10A3A}\u{10A3F}\u{10AE5}\u{10AE6}\u{10D24}-\u{10D27}\u{10D69}-\u{10D6D}\u{10EAB}\u{10EAC}\u{10EFA}-\u{10EFF}\u{10F46}-\u{10F50}\u{10F82}-\u{10F85}\u{11001}\u{11038}-\u{11046}\u{11070}\u{11073}\u{11074}\u{1107F}-\u{11081}\u{110B3}-\u{110B6}\u{110B9}\u{110BA}\u{110C2}\u{11100}-\u{11102}\u{11127}-\u{1112B}\u{1112D}-\u{11134}\u{11173}\u{11180}\u{11181}\u{111B6}-\u{111BE}\u{111C9}-\u{111CC}\u{111CF}\u{1122F}-\u{11231}\u{11234}\u{11236}\u{11237}\u{1123E}\u{11241}\u{112DF}\u{112E3}-\u{112EA}\u{11300}\u{11301}\u{1133B}\u{1133C}\u{11340}\u{11366}-\u{1136C}\u{11370}-\u{11374}\u{113BB}-\u{113C0}\u{113CE}\u{113D0}\u{113D2}\u{113E1}\u{113E2}\u{11438}-\u{1143F}\u{11442}-\u{11444}\u{11446}\u{1145E}\u{114B3}-\u{114B8}\u{114BA}\u{114BF}\u{114C0}\u{114C2}\u{114C3}\u{115B2}-\u{115B5}\u{115BC}\u{115BD}\u{115BF}\u{115C0}\u{115DC}\u{115DD}\u{11633}-\u{1163A}\u{1163D}\u{1163F}\u{11640}\u{116AB}\u{116AD}\u{116B0}-\u{116B5}\u{116B7}\u{1171D}\u{1171F}\u{11722}-\u{11725}\u{11727}-\u{1172B}\u{1182F}-\u{11837}\u{11839}\u{1183A}\u{1193B}\u{1193C}\u{1193E}\u{11943}\u{119D4}-\u{119D7}\u{119DA}\u{119DB}\u{119E0}\u{11A01}-\u{11A06}\u{11A09}\u{11A0A}\u{11A33}-\u{11A38}\u{11A3B}-\u{11A3E}\u{11A47}\u{11A51}-\u{11A56}\u{11A59}-\u{11A5B}\u{11A8A}-\u{11A96}\u{11A98}\u{11A99}\u{11B60}\u{11B62}-\u{11B64}\u{11B66}\u{11C30}-\u{11C36}\u{11C38}-\u{11C3D}\u{11C92}-\u{11CA7}\u{11CAA}-\u{11CB0}\u{11CB2}\u{11CB3}\u{11CB5}\u{11CB6}\u{11D31}-\u{11D36}\u{11D3A}\u{11D3C}\u{11D3D}\u{11D3F}-\u{11D45}\u{11D47}\u{11D90}\u{11D91}\u{11D95}\u{11D97}\u{11EF3}\u{11EF4}\u{11F00}\u{11F01}\u{11F36}-\u{11F3A}\u{11F40}\u{11F42}\u{11F5A}\u{13440}\u{13447}-\u{13455}\u{1611E}-\u{16129}\u{1612D}-\u{1612F}\u{16AF0}-\u{16AF4}\u{16B30}-\u{16B36}\u{16F4F}\u{16F8F}-\u{16F92}\u{16FE4}\u{1BC9D}\u{1BC9E}\u{1CF00}-\u{1CF2D}\u{1CF30}-\u{1CF46}\u{1D167}-\u{1D169}\u{1D17B}-\u{1D182}\u{1D185}-\u{1D18B}\u{1D1AA}-\u{1D1AD}\u{1D242}-\u{1D244}\u{1DA00}-\u{1DA36}\u{1DA3B}-\u{1DA6C}\u{1DA75}\u{1DA84}\u{1DA9B}-\u{1DA9F}\u{1DAA1}-\u{1DAAF}\u{1E000}-\u{1E006}\u{1E008}-\u{1E018}\u{1E01B}-\u{1E021}\u{1E023}\u{1E024}\u{1E026}-\u{1E02A}\u{1E08F}\u{1E130}-\u{1E136}\u{1E2AE}\u{1E2EC}-\u{1E2EF}\u{1E4EC}-\u{1E4EF}\u{1E5EE}\u{1E5EF}\u{1E6E3}\u{1E6E6}\u{1E6EE}\u{1E6EF}\u{1E6F5}\u{1E8D0}-\u{1E8D6}\u{1E944}-\u{1E94A}\u{E0100}-\u{E01EF}]*$/u; + +export { + combiningMarks, + combiningClassVirama, + validZWNJ, + bidiDomain, + bidiS1LTR, + bidiS1RTL, + bidiS2, + bidiS3, + bidiS4EN, + bidiS4AN, + bidiS5, + bidiS6 +}; diff --git a/framework/src/net/vendor/url/punycode.ts b/framework/src/net/vendor/url/punycode.ts new file mode 100644 index 00000000..30c363fd --- /dev/null +++ b/framework/src/net/vendor/url/punycode.ts @@ -0,0 +1,461 @@ +// @ts-nocheck -- pinned Punycode.js with captured intrinsic calls (see README.md). +'use strict'; + +/** Highest positive signed 32-bit float value */ +const maxInt = 2147483647; // aka. 0x7FFFFFFF or 2^31-1 + +/** Bootstring parameters */ +const base = 36; +const tMin = 1; +const tMax = 26; +const skew = 38; +const damp = 700; +const initialBias = 72; +const initialN = 128; // 0x80 +const delimiter = '-'; // '\x2D' + +/** Regular expressions */ +const regexPunycode = /^xn--/; +const regexNonASCII = /[^\0-\x7F]/; // Note: U+007F DEL is excluded too. +const regexSeparators = /[\x2E\u3002\uFF0E\uFF61]/g; // RFC 3490 separators + +/** Error messages */ +const errors = { + 'overflow': 'Overflow: input needs wider integers to process', + 'not-basic': 'Illegal input >= 0x80 (not a basic code point)', + 'invalid-input': 'Invalid input' +}; + +/** Convenience shortcuts */ +const baseMinusTMin = base - tMin; +const floor = Math.floor; +const stringFromCharCode = String.fromCharCode; +const stringFromCodePoint = String.fromCodePoint; +const functionCall = Function.prototype.call; +const bindCall = operation => functionCall.bind(operation); +const arrayJoin = bindCall(Array.prototype.join); +const arrayPush = bindCall(Array.prototype.push); +const arraySplice = bindCall(Array.prototype.splice); +const regexpTest = bindCall(RegExp.prototype.test); +const stringCharCodeAt = bindCall(String.prototype.charCodeAt); +const stringLastIndexOf = bindCall(String.prototype.lastIndexOf); +const stringReplace = bindCall(String.prototype.replace); +const stringSlice = bindCall(String.prototype.slice); +const stringSplit = bindCall(String.prototype.split); +const stringToLowerCase = bindCall(String.prototype.toLowerCase); + +/*--------------------------------------------------------------------------*/ + +/** + * A generic error utility function. + * @private + * @param {String} type The error type. + * @returns {Error} Throws a `RangeError` with the applicable error message. + */ +function error(type) { + throw new RangeError(errors[type]); +} + +/** + * A generic `Array#map` utility function. + * @private + * @param {Array} array The array to iterate over. + * @param {Function} callback The function that gets called for every array + * item. + * @returns {Array} A new array of values returned by the callback function. + */ +function map(array, callback) { + const result = []; + let length = array.length; + while (length--) { + result[length] = callback(array[length]); + } + return result; +} + +/** + * A simple `Array#map`-like wrapper to work with domain name strings or email + * addresses. + * @private + * @param {String} domain The domain name or email address. + * @param {Function} callback The function that gets called for every + * character. + * @returns {String} A new string of characters returned by the callback + * function. + */ +function mapDomain(domain, callback) { + const parts = stringSplit(domain, '@'); + let result = ''; + if (parts.length > 1) { + // In email addresses, only the domain name should be punycoded. Leave + // the local part (i.e. everything up to `@`) intact. + result = parts[0] + '@'; + domain = parts[1]; + } + // Avoid `split(regex)` for IE8 compatibility. See #17. + domain = stringReplace(domain, regexSeparators, '\x2E'); + const labels = stringSplit(domain, '.'); + const encoded = arrayJoin(map(labels, callback), '.'); + return result + encoded; +} + +/** + * Creates an array containing the numeric code points of each Unicode + * character in the string. While JavaScript uses UCS-2 internally, + * this function will convert a pair of surrogate halves (each of which + * UCS-2 exposes as separate characters) into a single code point, + * matching UTF-16. + * @see `punycode.ucs2.encode` + * @see + * @memberOf punycode.ucs2 + * @name decode + * @param {String} string The Unicode input string (UCS-2). + * @returns {Array} The new array of code points. + */ +function ucs2decode(string) { + const output = []; + let counter = 0; + const length = string.length; + while (counter < length) { + const value = stringCharCodeAt(string, counter++); + if (value >= 0xD800 && value <= 0xDBFF && counter < length) { + // It's a high surrogate, and there is a next character. + const extra = stringCharCodeAt(string, counter++); + if ((extra & 0xFC00) == 0xDC00) { // Low surrogate. + arrayPush(output, ((value & 0x3FF) << 10) + (extra & 0x3FF) + 0x10000); + } else { + // It's an unmatched surrogate; only append this code unit, in case the + // next code unit is the high surrogate of a surrogate pair. + arrayPush(output, value); + counter--; + } + } else { + arrayPush(output, value); + } + } + return output; +} + +/** + * Creates a string based on an array of numeric code points. + * @see `punycode.ucs2.decode` + * @memberOf punycode.ucs2 + * @name encode + * @param {Array} codePoints The array of numeric code points. + * @returns {String} The new Unicode string (UCS-2). + */ +const ucs2encode = codePoints => stringFromCodePoint(...codePoints); + +/** + * Converts a basic code point into a digit/integer. + * @see `digitToBasic()` + * @private + * @param {Number} codePoint The basic numeric code point value. + * @returns {Number} The numeric value of a basic code point (for use in + * representing integers) in the range `0` to `base - 1`, or `base` if + * the code point does not represent a value. + */ +const basicToDigit = function(codePoint) { + if (codePoint >= 0x30 && codePoint < 0x3A) { + return 26 + (codePoint - 0x30); + } + if (codePoint >= 0x41 && codePoint < 0x5B) { + return codePoint - 0x41; + } + if (codePoint >= 0x61 && codePoint < 0x7B) { + return codePoint - 0x61; + } + return base; +}; + +/** + * Converts a digit/integer into a basic code point. + * @see `basicToDigit()` + * @private + * @param {Number} digit The numeric value of a basic code point. + * @returns {Number} The basic code point whose value (when used for + * representing integers) is `digit`, which needs to be in the range + * `0` to `base - 1`. If `flag` is non-zero, the uppercase form is + * used; else, the lowercase form is used. The behavior is undefined + * if `flag` is non-zero and `digit` has no uppercase form. + */ +const digitToBasic = function(digit, flag) { + // 0..25 map to ASCII a..z or A..Z + // 26..35 map to ASCII 0..9 + return digit + 22 + 75 * (digit < 26) - ((flag != 0) << 5); +}; + +/** + * Bias adaptation function as per section 3.4 of RFC 3492. + * https://tools.ietf.org/html/rfc3492#section-3.4 + * @private + */ +const adapt = function(delta, numPoints, firstTime) { + let k = 0; + delta = firstTime ? floor(delta / damp) : delta >> 1; + delta += floor(delta / numPoints); + for (/* no initialization */; delta > baseMinusTMin * tMax >> 1; k += base) { + delta = floor(delta / baseMinusTMin); + } + return floor(k + (baseMinusTMin + 1) * delta / (delta + skew)); +}; + +/** + * Converts a Punycode string of ASCII-only symbols to a string of Unicode + * symbols. + * @memberOf punycode + * @param {String} input The Punycode string of ASCII-only symbols. + * @returns {String} The resulting string of Unicode symbols. + */ +const decode = function(input) { + // Don't use UCS-2. + const output = []; + const inputLength = input.length; + let i = 0; + let n = initialN; + let bias = initialBias; + + // Handle the basic code points: let `basic` be the number of input code + // points before the last delimiter, or `0` if there is none, then copy + // the first basic code points to the output. + + let basic = stringLastIndexOf(input, delimiter); + if (basic < 0) { + basic = 0; + } + + for (let j = 0; j < basic; ++j) { + // if it's not a basic code point + if (stringCharCodeAt(input, j) >= 0x80) { + error('not-basic'); + } + arrayPush(output, stringCharCodeAt(input, j)); + } + + // Main decoding loop: start just after the last delimiter if any basic code + // points were copied; start at the beginning otherwise. + + for (let index = basic > 0 ? basic + 1 : 0; index < inputLength; /* no final expression */) { + + // `index` is the index of the next character to be consumed. + // Decode a generalized variable-length integer into `delta`, + // which gets added to `i`. The overflow checking is easier + // if we increase `i` as we go, then subtract off its starting + // value at the end to obtain `delta`. + const oldi = i; + for (let w = 1, k = base; /* no condition */; k += base) { + + if (index >= inputLength) { + error('invalid-input'); + } + + const digit = basicToDigit(stringCharCodeAt(input, index++)); + + if (digit >= base) { + error('invalid-input'); + } + if (digit > floor((maxInt - i) / w)) { + error('overflow'); + } + + i += digit * w; + const t = k <= bias ? tMin : (k >= bias + tMax ? tMax : k - bias); + + if (digit < t) { + break; + } + + const baseMinusT = base - t; + if (w > floor(maxInt / baseMinusT)) { + error('overflow'); + } + + w *= baseMinusT; + + } + + const out = output.length + 1; + bias = adapt(i - oldi, out, oldi == 0); + + // `i` was supposed to wrap around from `out` to `0`, + // incrementing `n` each time, so we'll fix that now: + if (floor(i / out) > maxInt - n) { + error('overflow'); + } + + n += floor(i / out); + i %= out; + + // Insert `n` at position `i` of the output. + arraySplice(output, i++, 0, n); + + } + + return stringFromCodePoint(...output); +}; + +/** + * Converts a string of Unicode symbols (e.g. a domain name label) to a + * Punycode string of ASCII-only symbols. + * @memberOf punycode + * @param {String} input The string of Unicode symbols. + * @returns {String} The resulting Punycode string of ASCII-only symbols. + */ +const encode = function(input) { + const output = []; + + // Convert the input in UCS-2 to an array of Unicode code points. + input = ucs2decode(input); + + // Cache the length. + const inputLength = input.length; + + // Initialize the state. + let n = initialN; + let delta = 0; + let bias = initialBias; + + // Handle the basic code points. + for (let inputIndex = 0; inputIndex < input.length; inputIndex++) { + const currentValue = input[inputIndex]; + if (currentValue < 0x80) { + arrayPush(output, stringFromCharCode(currentValue)); + } + } + + const basicLength = output.length; + let handledCPCount = basicLength; + + // `handledCPCount` is the number of code points that have been handled; + // `basicLength` is the number of basic code points. + + // Finish the basic string with a delimiter unless it's empty. + if (basicLength) { + arrayPush(output, delimiter); + } + + // Main encoding loop: + while (handledCPCount < inputLength) { + + // All non-basic code points < n have been handled already. Find the next + // larger one: + let m = maxInt; + for (let inputIndex = 0; inputIndex < input.length; inputIndex++) { + const currentValue = input[inputIndex]; + if (currentValue >= n && currentValue < m) { + m = currentValue; + } + } + + // Increase `delta` enough to advance the decoder's state to , + // but guard against overflow. + const handledCPCountPlusOne = handledCPCount + 1; + if (m - n > floor((maxInt - delta) / handledCPCountPlusOne)) { + error('overflow'); + } + + delta += (m - n) * handledCPCountPlusOne; + n = m; + + for (let inputIndex = 0; inputIndex < input.length; inputIndex++) { + const currentValue = input[inputIndex]; + if (currentValue < n && ++delta > maxInt) { + error('overflow'); + } + if (currentValue === n) { + // Represent delta as a generalized variable-length integer. + let q = delta; + for (let k = base; /* no condition */; k += base) { + const t = k <= bias ? tMin : (k >= bias + tMax ? tMax : k - bias); + if (q < t) { + break; + } + const qMinusT = q - t; + const baseMinusT = base - t; + arrayPush(output, + stringFromCharCode(digitToBasic(t + qMinusT % baseMinusT, 0)) + ); + q = floor(qMinusT / baseMinusT); + } + + arrayPush(output, stringFromCharCode(digitToBasic(q, 0))); + bias = adapt(delta, handledCPCountPlusOne, handledCPCount === basicLength); + delta = 0; + ++handledCPCount; + } + } + + ++delta; + ++n; + + } + return arrayJoin(output, ''); +}; + +/** + * Converts a Punycode string representing a domain name or an email address + * to Unicode. Only the Punycoded parts of the input will be converted, i.e. + * it doesn't matter if you call it on a string that has already been + * converted to Unicode. + * @memberOf punycode + * @param {String} input The Punycoded domain name or email address to + * convert to Unicode. + * @returns {String} The Unicode representation of the given Punycode + * string. + */ +const toUnicode = function(input) { + return mapDomain(input, function(string) { + return regexpTest(regexPunycode, string) + ? decode(stringToLowerCase(stringSlice(string, 4))) + : string; + }); +}; + +/** + * Converts a Unicode string representing a domain name or an email address to + * Punycode. Only the non-ASCII parts of the domain name will be converted, + * i.e. it doesn't matter if you call it with a domain that's already in + * ASCII. + * @memberOf punycode + * @param {String} input The domain name or email address to convert, as a + * Unicode string. + * @returns {String} The Punycode representation of the given domain name or + * email address. + */ +const toASCII = function(input) { + return mapDomain(input, function(string) { + return regexpTest(regexNonASCII, string) + ? 'xn--' + encode(string) + : string; + }); +}; + +/*--------------------------------------------------------------------------*/ + +/** Define the public API */ +const punycode = { + /** + * A string representing the current Punycode.js version number. + * @memberOf punycode + * @type String + */ + 'version': '2.3.1', + /** + * An object of methods to convert from JavaScript's internal character + * representation (UCS-2) to Unicode code points, and back. + * @see + * @memberOf punycode + * @type Object + */ + 'ucs2': { + 'decode': ucs2decode, + 'encode': ucs2encode + }, + 'decode': decode, + 'encode': encode, + 'toASCII': toASCII, + 'toUnicode': toUnicode +}; + +export { ucs2decode, ucs2encode, decode, encode, toASCII, toUnicode }; +export default punycode; diff --git a/framework/src/net/vendor/url/tr46.ts b/framework/src/net/vendor/url/tr46.ts new file mode 100644 index 00000000..84cd6591 --- /dev/null +++ b/framework/src/net/vendor/url/tr46.ts @@ -0,0 +1,267 @@ +/** + * Bounded UTS #46 processing for PocketJS HTTP hostnames. + * + * Algorithm and Unicode data are pinned to tr46 6.0.0 / Unicode 17.0.0. + * The packed table is decoded in-place: no 9,262-row JS object graph is ever + * constructed. See README.md and tools/generate-url-idna-table.ts. + */ + +import { decode as punycodeDecode, encode as punycodeEncode } from "./punycode.ts"; +import { + TR46_MAPPING_BLOB, + TR46_TABLE_ENTRY_COUNT, + TR46_TABLE_RECORDS, +} from "./idna-data.generated.ts"; +import { + bidiDomain, + bidiS1LTR, + bidiS1RTL, + bidiS2, + bidiS3, + bidiS4AN, + bidiS4EN, + bidiS5, + bidiS6, + combiningClassVirama, + combiningMarks, + validZWNJ, +} from "./idna-regexes.generated.ts"; + +const STATUS_MAPPED = 1; +const STATUS_VALID = 2; +const STATUS_DISALLOWED = 3; +const STATUS_DEVIATION = 6; +const STATUS_IGNORED = 7; +const RECORD_CHARACTERS = 8; +const LOOKUP_ITERATION_LIMIT = 16; +export const IDNA_INPUT_CODE_UNITS_LIMIT = 1024; +export const IDNA_LABEL_LIMIT = 128; +export const IDNA_ASCII_HOST_BYTES_LIMIT = 253; +export const IDNA_ASCII_LABEL_BYTES_LIMIT = 63; + +const functionCall = Function.prototype.call; +const bindCall = ( + operation: (...args: Args) => Result, +): ((receiver: unknown, ...args: Args) => Result) => + functionCall.bind(operation) as (receiver: unknown, ...args: Args) => Result; +const stringCharCodeAt = bindCall(String.prototype.charCodeAt); +const stringCodePointAt = bindCall(String.prototype.codePointAt); +const stringEndsWith = bindCall(String.prototype.endsWith); +const stringNormalize = bindCall(String.prototype.normalize); +const stringSlice = bindCall(String.prototype.slice); +const stringSplit = bindCall(String.prototype.split) as unknown as ( + receiver: string, + separator: string, +) => string[]; +const stringStartsWith = bindCall(String.prototype.startsWith); +const regexpTest = bindCall(RegExp.prototype.test); +const arrayPush = bindCall(Array.prototype.push); +const stringFromCodePoint = String.fromCodePoint; +const mathFloor = Math.floor; + +function callCharCodeAt(value: string, index: number): number { + return stringCharCodeAt(value, index); +} + +function base64ValueAt(index: number): number { + const code = callCharCodeAt(TR46_TABLE_RECORDS, index); + if (code >= 65 && code <= 90) return code - 65; + if (code >= 97 && code <= 122) return code - 71; + if (code >= 48 && code <= 57) return code + 4; + if (code === 43) return 62; + if (code === 47) return 63; + return 0; +} + +function recordByte(index: number, byte: number): number { + const offset = index * RECORD_CHARACTERS; + const group = byte < 3 ? offset : offset + 4; + const within = byte % 3; + if (within === 0) { + return (base64ValueAt(group) << 2) | (base64ValueAt(group + 1) >>> 4); + } + if (within === 1) { + return ((base64ValueAt(group + 1) & 15) << 4) | + (base64ValueAt(group + 2) >>> 2); + } + return ((base64ValueAt(group + 2) & 3) << 6) | base64ValueAt(group + 3); +} + +function recordEnd(index: number): number { + return recordByte(index, 0) | (recordByte(index, 1) << 8) | + (recordByte(index, 2) << 16); +} + +function findRow(codePoint: number): number { + let start = 0; + let end = TR46_TABLE_ENTRY_COUNT - 1; + for (let iteration = 0; iteration < LOOKUP_ITERATION_LIMIT && start < end; iteration++) { + const middle = mathFloor((start + end) / 2); + if (recordEnd(middle) < codePoint) start = middle + 1; + else end = middle; + } + return start < TR46_TABLE_ENTRY_COUNT && recordEnd(start) >= codePoint ? start : -1; +} + +function recordStatus(index: number): number { + return recordByte(index, 5) & 7; +} + +function recordMapping(index: number): string { + const offset = recordByte(index, 3) | (recordByte(index, 4) << 8); + const length = recordByte(index, 5) >>> 3; + return stringSlice(TR46_MAPPING_BLOB, offset, offset + length); +} + +function regexMatches(expression: RegExp, value: string): boolean { + return regexpTest(expression, value); +} + +function containsNonAscii(value: string): boolean { + for (let index = 0; index < value.length; index++) { + if (callCharCodeAt(value, index) > 0x7f) return true; + } + return false; +} + +function mapCharacters(domain: string): string | null { + let output = ""; + for (let index = 0; index < domain.length;) { + const codePoint = stringCodePointAt(domain, index)!; + const character = stringFromCodePoint(codePoint); + index += codePoint > 0xffff ? 2 : 1; + const row = findRow(codePoint); + if (row < 0) return null; + switch (recordStatus(row)) { + case STATUS_MAPPED: + output += recordMapping(row); + break; + case STATUS_IGNORED: + break; + case STATUS_VALID: + case STATUS_DEVIATION: + case STATUS_DISALLOWED: + output += character; + break; + default: + return null; + } + if (output.length > IDNA_INPUT_CODE_UNITS_LIMIT * 4) return null; + } + return stringNormalize(output, "NFC"); +} + +function isBidiDomain(labels: readonly string[]): boolean { + let decoded = ""; + for (let index = 0; index < labels.length; index++) { + let label = labels[index]!; + if (stringStartsWith(label, "xn--")) { + try { + label = punycodeDecode(stringSlice(label, 4)); + } catch { + label = ""; + } + } + if (index > 0) decoded += "."; + decoded += label; + } + return regexMatches(bidiDomain, decoded); +} + +function validateLabel(label: string, bidi: boolean): boolean { + if (label.length === 0 || stringNormalize(label, "NFC") !== label) return false; + const codePoints: string[] = []; + for (let index = 0; index < label.length;) { + const codePoint = stringCodePointAt(label, index)!; + arrayPush(codePoints, stringFromCodePoint(codePoint)); + index += codePoint > 0xffff ? 2 : 1; + } + if (codePoints.length === 0 || codePoints.length > IDNA_INPUT_CODE_UNITS_LIMIT) return false; + if ((codePoints[2] === "-" && codePoints[3] === "-") || + stringStartsWith(label, "-") || stringEndsWith(label, "-")) return false; + if (regexMatches(combiningMarks, codePoints[0]!)) return false; + + for (let index = 0; index < codePoints.length; index++) { + const character = codePoints[index]!; + const codePoint = stringCodePointAt(character, 0)!; + const row = findRow(codePoint); + if (row < 0) return false; + const status = recordStatus(row); + if (status !== STATUS_VALID && status !== STATUS_DEVIATION) return false; + if (codePoint <= 0x7f && + !((codePoint >= 0x61 && codePoint <= 0x7a) || + (codePoint >= 0x30 && codePoint <= 0x39) || codePoint === 0x2d)) return false; + } + + let lastJoiner = 0; + for (let index = 0; index < codePoints.length; index++) { + const character = codePoints[index]!; + if (character !== "\u200c" && character !== "\u200d") continue; + if (index > 0 && regexMatches(combiningClassVirama, codePoints[index - 1]!)) continue; + if (character === "\u200c") { + let next = -1; + for (let cursor = index + 1; cursor < codePoints.length; cursor++) { + if (codePoints[cursor] === "\u200c") { + next = cursor; + break; + } + } + let context = ""; + const limit = next < 0 ? codePoints.length : next; + for (let cursor = lastJoiner; cursor < limit; cursor++) context += codePoints[cursor]; + if (regexMatches(validZWNJ, context)) { + lastJoiner = index + 1; + continue; + } + } + return false; + } + + if (!bidi) return true; + const first = codePoints[0]!; + if (regexMatches(bidiS1LTR, first)) { + return regexMatches(bidiS5, label) && regexMatches(bidiS6, label); + } + if (!regexMatches(bidiS1RTL, first)) return false; + return regexMatches(bidiS2, label) && + regexMatches(bidiS3, label) && + !(regexMatches(bidiS4EN, label) && regexMatches(bidiS4AN, label)); +} + +/** Strict, non-transitional UTS #46 ToASCII for an endpoint DNS name. */ +export function domainToAscii(domain: string): string | null { + if (domain.length === 0 || domain.length > IDNA_INPUT_CODE_UNITS_LIMIT) return null; + const mapped = mapCharacters(domain); + if (mapped === null || mapped.length === 0) return null; + const labels = stringSplit(mapped, "."); + if (labels.length === 0 || labels.length > IDNA_LABEL_LIMIT) return null; + const bidi = isBidiDomain(labels); + let output = ""; + + for (let index = 0; index < labels.length; index++) { + let label = labels[index]!; + if (stringStartsWith(label, "xn--")) { + if (containsNonAscii(label)) return null; + try { + label = punycodeDecode(stringSlice(label, 4)); + } catch { + return null; + } + if (label.length === 0 || !containsNonAscii(label)) return null; + } + if (!validateLabel(label, bidi)) return null; + let ascii = label; + if (containsNonAscii(label)) { + try { + ascii = `xn--${punycodeEncode(label)}`; + } catch { + return null; + } + } + if (ascii.length === 0 || ascii.length > IDNA_ASCII_LABEL_BYTES_LIMIT) return null; + if (index > 0) output += "."; + output += ascii; + if (output.length > IDNA_ASCII_HOST_BYTES_LIMIT) return null; + } + return output; +} diff --git a/framework/src/net/websocket.ts b/framework/src/net/websocket.ts new file mode 100644 index 00000000..fb1eb6c8 --- /dev/null +++ b/framework/src/net/websocket.ts @@ -0,0 +1,120 @@ +import type { + NetworkAddress, + NetworkData, + NetworkLimitOverrides, + TlsOptions, + URL, +} from "./index.ts"; +import type { HeadersInit, Request } from "./http.ts"; +import { + unsupportedNetworkOperation, + unsupportedNetworkPromise, +} from "./internal.ts"; + +export { + AbortController, + AbortSignal, + NetworkError, + URL, +} from "./index.ts"; +export type { + NetworkAddress, + NetworkData, + NetworkErrorCategory, + NetworkErrorCode, + NetworkErrorOptions, + NetworkLimit, + NetworkLimitOverrides, + NetworkLimits, + NetworkProtocol, + NetworkRole, + TlsOptions, +} from "./index.ts"; + +export type WebSocketReadyState = "connecting" | "open" | "closing" | "closed"; + +export type WebSocketSendResult = + | { readonly status: "accepted"; readonly bytes: number; readonly needsDrain: boolean } + | { readonly status: "backpressure"; readonly bytes: 0 } + | { readonly status: "closed"; readonly bytes: 0 }; + +export interface WebSocket { + readonly readyState: WebSocketReadyState; + readonly bufferedAmount: number; + send(data: NetworkData): WebSocketSendResult; + ping(data?: NetworkData): boolean; + pong(data?: NetworkData): boolean; + close(code?: number, reason?: string): void; + terminate(): void; + ref(): this; + unref(): this; +} + +export interface WebSocketHandlers { + readonly open?: (socket: WebSocket) => void; + readonly message?: (socket: WebSocket, data: string | Uint8Array) => void; + readonly drain?: (socket: WebSocket) => void; + readonly ping?: (socket: WebSocket, data: Uint8Array) => void; + readonly pong?: (socket: WebSocket, data: Uint8Array) => void; + readonly close?: (socket: WebSocket, code: number, reason: string) => void; + readonly error?: (socket: WebSocket, error: import("./index.ts").NetworkError) => void; +} + +export interface WebSocketConnectOptions { + readonly headers?: HeadersInit; + readonly protocols?: readonly string[]; + readonly tls?: TlsOptions; + readonly timeout?: number; + readonly limits?: NetworkLimitOverrides; + readonly ref?: boolean; + readonly socket: WebSocketHandlers; +} + +export interface WebSocketServeOptions { + readonly hostname?: string; + readonly port: number; + readonly tls?: TlsOptions; + readonly limits?: NetworkLimitOverrides; + readonly ref?: boolean; + readonly socket: WebSocketHandlers; +} + +export interface WebSocketUpgradeOptions { + readonly protocols?: readonly string[]; + readonly socket: WebSocketHandlers; +} + +/** Opaque one-use result consumed by the HTTP server binding. */ +export interface WebSocketUpgrade { + readonly protocol?: string; +} + +export interface WebSocketServerStopOptions { + readonly graceful?: boolean; + readonly timeout?: number; +} + +export interface WebSocketServer { + readonly address: NetworkAddress; + stop(options?: WebSocketServerStopOptions): Promise; + ref(): this; + unref(): this; +} + +export function connect( + _url: string | URL, + _options: WebSocketConnectOptions, +): Promise { + return unsupportedNetworkPromise("websocket.connect", "websocket"); +} + +export function serve(_options: WebSocketServeOptions): Promise { + return unsupportedNetworkPromise("websocket.serve", "websocket"); +} + +export function upgrade( + _request: Request, + _options: WebSocketUpgradeOptions, +): WebSocketUpgrade { + throw unsupportedNetworkOperation("websocket.upgrade", "websocket"); +} diff --git a/hosts/esp-idf/cmake/pocketjs_generate_test_artifact.cmake b/hosts/esp-idf/cmake/pocketjs_generate_test_artifact.cmake new file mode 100644 index 00000000..3cfeb228 --- /dev/null +++ b/hosts/esp-idf/cmake/pocketjs_generate_test_artifact.cmake @@ -0,0 +1,63 @@ +function(pocketjs_generate_test_artifact output_variable generator) + set(_pocketjs_output_dir "${CMAKE_CURRENT_BINARY_DIR}/generated") + if(CMAKE_BUILD_EARLY_EXPANSION) + set(${output_variable} "${_pocketjs_output_dir}" PARENT_SCOPE) + return() + endif() + + get_filename_component(_pocketjs_component_dir "${generator}" DIRECTORY) + get_filename_component( + _pocketjs_root + "${_pocketjs_component_dir}/../../../.." + ABSOLUTE + ) + + find_program(_pocketjs_bun NAMES bun) + if(NOT _pocketjs_bun) + message(FATAL_ERROR + "PocketJS test artifact generation requires Bun in PATH") + endif() + + file(MAKE_DIRECTORY "${_pocketjs_output_dir}") + + file(GLOB_RECURSE _pocketjs_artifact_inputs + CONFIGURE_DEPENDS + LIST_DIRECTORIES false + "${_pocketjs_component_dir}/*.json" + "${_pocketjs_component_dir}/*.pem" + "${_pocketjs_component_dir}/*.ts" + "${_pocketjs_root}/contracts/spec/*.ts" + "${_pocketjs_root}/framework/compiler/*.ts" + "${_pocketjs_root}/framework/src/manifest/*.ts" + "${_pocketjs_root}/framework/src/net/*.ts" + "${_pocketjs_root}/hosts/esp-idf/components/pocketjs_net_formal_*_artifact/*.pem" + "${_pocketjs_root}/hosts/esp-idf/components/pocketjs_net_formal_*_artifact/*.ts" + ) + list(APPEND _pocketjs_artifact_inputs + "${_pocketjs_root}/bun.lock" + "${_pocketjs_root}/package.json" + "${_pocketjs_root}/tools/build.ts" + "${_pocketjs_root}/tools/network-bundle-factory.ts" + "${_pocketjs_root}/tools/test-artifact-output.ts" + ) + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + ${_pocketjs_artifact_inputs}) + + execute_process( + COMMAND + "${_pocketjs_bun}" + "${generator}" + "--output-dir=${_pocketjs_output_dir}" + WORKING_DIRECTORY "${_pocketjs_root}" + RESULT_VARIABLE _pocketjs_generate_result + OUTPUT_VARIABLE _pocketjs_generate_stdout + ERROR_VARIABLE _pocketjs_generate_stderr + ) + if(NOT _pocketjs_generate_result EQUAL 0) + message(FATAL_ERROR + "PocketJS test artifact generation failed (${_pocketjs_generate_result})\n" + "${_pocketjs_generate_stdout}${_pocketjs_generate_stderr}") + endif() + + set(${output_variable} "${_pocketjs_output_dir}" PARENT_SCOPE) +endfunction() diff --git a/hosts/esp-idf/components/pocketjs_esp_guest/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_esp_guest/CMakeLists.txt new file mode 100644 index 00000000..e098c7e2 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_guest/CMakeLists.txt @@ -0,0 +1,11 @@ +idf_component_register( + SRCS "src/esp_guest.c" + INCLUDE_DIRS "include" + REQUIRES esp_timer heap quickjs-ng +) + +target_compile_options(${COMPONENT_LIB} PRIVATE + -Wall + -Wextra + -Werror +) diff --git a/hosts/esp-idf/components/pocketjs_esp_guest/README.md b/hosts/esp-idf/components/pocketjs_esp_guest/README.md new file mode 100644 index 00000000..b9eaddf0 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_guest/README.md @@ -0,0 +1,41 @@ +# PocketJS ESP-IDF Guest component + +This component creates one task-owned QuickJS realm for an ESP-IDF product +Host. It installs only `console.*`; the realm has no filesystem, network, +timer, process, or UI authority until another product component installs a +private binding before evaluating the Guest bootstrap. + +The component pins `espressif/quickjs-ng` 0.14.0. That is an explicit ESP-IDF +Host engine choice and is not the same source revision currently used by the +PSP/Vita hosts. A product capability cannot be admitted by build success alone; +it must run its Guest and protocol conformance suites against this engine. + +All realm APIs are owner-task-only. Native workers can wake that task or queue +plain data, but they must never call these functions or QuickJS directly. +Calls from product schedulers into a registered Guest dispatcher use +`pocketjs_esp_guest_call_function()`, which applies the same monotonic timeout +and QuickJS interrupt-check budget as factory, frame, and Promise-job calls. + +Product Hosts mount bundles as factories. The component freezes a private +native binding object and passes it directly to the factory; it never installs +that object on `globalThis`. Each eval, factory call, and Promise-job checkpoint +has optional wall-clock and QuickJS interrupt-check budgets. The scheduler also +receives an explicit pending-jobs result, so it cannot mistake a truncated +checkpoint for quiescence. + +While a bounded Guest execution is active, the component gives FreeRTOS one +scheduler tick at 100 ms intervals. QuickJS bytecode reaches that checkpoint +through its interrupt hook; source parsing reaches it through sampled engine +allocations. **This keeps the idle task and system watchdog live while a large, +verified factory is parsed.** The yield counter is included in Guest stats. +Native code that neither invokes the engine interrupt hook nor allocates engine +memory remains the Host's responsibility and must be independently bounded. + +The engine allocator can require PSRAM. That mode fails closed instead of +silently consuming internal DRAM when external memory is unavailable. Current +and high-water byte/count counters plus QuickJS object/memory totals are +observable through the component API. This is a Host resource measurement, not +a replacement for the network Core's independent queue and lease budgets. + +The current network bring-up uses this component only for a private-ABI smoke. +It does not advertise a public PocketJS network capability. diff --git a/hosts/esp-idf/components/pocketjs_esp_guest/idf_component.yml b/hosts/esp-idf/components/pocketjs_esp_guest/idf_component.yml new file mode 100644 index 00000000..76f5dbcb --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_guest/idf_component.yml @@ -0,0 +1,5 @@ +dependencies: + idf: + version: ">=6.0,<6.1" + espressif/quickjs-ng: + version: "0.14.0" diff --git a/hosts/esp-idf/components/pocketjs_esp_guest/include/pocketjs/esp_guest.h b/hosts/esp-idf/components/pocketjs_esp_guest/include/pocketjs/esp_guest.h new file mode 100644 index 00000000..9bcf07b5 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_guest/include/pocketjs/esp_guest.h @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +#include "esp_err.h" +#include "quickjs.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define POCKETJS_ESP_GUEST_ENGINE_ID "quickjs-ng" +#define POCKETJS_ESP_GUEST_ENGINE_VERSION "0.14.0" + +typedef struct pocketjs_esp_guest pocketjs_esp_guest_t; + +typedef struct { + /** Zero keeps the engine default. */ + size_t memory_limit_bytes; + /** Zero keeps the engine default. */ + size_t stack_limit_bytes; + /** Require every engine allocation to come from external RAM. */ + bool allocate_in_external_memory; + /** Abort one eval, call, or job checkpoint after this many microseconds. */ + uint64_t execution_timeout_us; + /** Abort after this many QuickJS interrupt checks (one check per 10k polls). + */ + uint32_t max_interrupt_checks; +} pocketjs_esp_guest_config_t; + +typedef struct { + size_t allocation_bytes; + size_t allocation_bytes_high_water; + size_t external_allocation_bytes; + size_t external_allocation_bytes_high_water; + size_t allocation_count; + size_t allocation_count_high_water; + size_t engine_memory_used_bytes; + size_t engine_object_count; + /** Total one-tick FreeRTOS yields during bounded Guest execution. */ + uint32_t cooperative_yields; +} pocketjs_esp_guest_stats_t; + +/** + * Create one QuickJS runtime and realm owned by the calling FreeRTOS task. + * Every later call, including destruction, must run on that same task. + */ +esp_err_t pocketjs_esp_guest_create(const pocketjs_esp_guest_config_t *config, + pocketjs_esp_guest_t **out_guest); + +/** + * Return the realm context so a product Host can install private bindings + * before evaluating the Guest bootstrap. The pointer is borrowed and remains + * valid only until pocketjs_esp_guest_destroy(). + */ +JSContext *pocketjs_esp_guest_context(pocketjs_esp_guest_t *guest); + +/** + * Evaluate one NUL-terminated global-script bundle. source_length excludes the + * terminator and source[source_length] must be zero. + */ +esp_err_t pocketjs_esp_guest_eval(pocketjs_esp_guest_t *guest, + const char *label, const char *source, + size_t source_length); + +/** + * Evaluate a bundle whose completion value is a factory function, freeze the + * borrowed private binding object, and call factory(binding). The factory must + * install a callable globalThis.frame, which is cached by the Host. The binding + * is not consumed. A non-NULL out_result receives an owned JSValue; otherwise + * the call result is released before returning. + */ +esp_err_t +pocketjs_esp_guest_mount_factory(pocketjs_esp_guest_t *guest, const char *label, + const char *source, size_t source_length, + JSValueConst binding, JSValue *out_result); + +/** Call the frame function cached by mount_factory(). Arguments are borrowed. + */ +esp_err_t pocketjs_esp_guest_call_frame(pocketjs_esp_guest_t *guest, + size_t argument_count, + JSValueConst *arguments); + +/** + * Call a borrowed Guest function under the same interrupt-check and monotonic + * timeout guard used by bundle, frame, and job execution. The receiver and + * arguments are borrowed. A non-NULL out_result receives an owned JSValue on + * success; it is set to JS_UNDEFINED on entry and remains so on failure. + * phase is a Host-selected non-sensitive diagnostic label. + */ +esp_err_t pocketjs_esp_guest_call_function( + pocketjs_esp_guest_t *guest, const char *phase, JSValueConst function, + JSValueConst receiver, size_t argument_count, JSValueConst *arguments, + JSValue *out_result); + +/** + * Execute at most max_jobs pending Promise jobs on the owner task. A zero + * max_jobs is invalid. out_executed and out_pending may be NULL. A true + * out_pending means the caller must schedule another bounded checkpoint. + */ +esp_err_t pocketjs_esp_guest_execute_jobs(pocketjs_esp_guest_t *guest, + size_t max_jobs, size_t *out_executed, + bool *out_pending); + +/** Snapshot allocator counters on the owner task. */ +esp_err_t pocketjs_esp_guest_get_stats(pocketjs_esp_guest_t *guest, + pocketjs_esp_guest_stats_t *out_stats); + +/** Log and clear the current QuickJS exception on the owner task. */ +void pocketjs_esp_guest_log_exception(pocketjs_esp_guest_t *guest, + const char *phase); + +/** Release the realm and runtime on their owner task. */ +void pocketjs_esp_guest_destroy(pocketjs_esp_guest_t *guest); + +#ifdef __cplusplus +} +#endif diff --git a/hosts/esp-idf/components/pocketjs_esp_guest/src/esp_guest.c b/hosts/esp-idf/components/pocketjs_esp_guest/src/esp_guest.c new file mode 100644 index 00000000..e7eee766 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_guest/src/esp_guest.c @@ -0,0 +1,653 @@ +// SPDX-License-Identifier: MIT + +#include "pocketjs/esp_guest.h" + +#include +#include +#include +#include + +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "esp_memory_utils.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#define POCKETJS_ESP_GUEST_CONSOLE_BYTES 384U +#define POCKETJS_ESP_GUEST_YIELD_INTERVAL_US INT64_C(100000) +#define POCKETJS_ESP_GUEST_ALLOCATIONS_PER_YIELD_CHECK 64U + +static const char *TAG = "pocketjs_guest"; + +struct pocketjs_esp_guest { + JSRuntime *runtime; + JSContext *context; + JSValue frame_function; + TaskHandle_t owner_task; + pocketjs_esp_guest_stats_t stats; + bool allocate_in_external_memory; + bool mounted; + bool executing; + bool interrupted; + uint64_t execution_timeout_us; + int64_t execution_deadline_us; + uint32_t max_interrupt_checks; + uint32_t interrupt_checks_remaining; + uint32_t allocations_until_yield_check; + int64_t cooperative_yield_deadline_us; +}; + +static void guest_maybe_cooperative_yield(pocketjs_esp_guest_t *guest) { + if (!guest->executing || + esp_timer_get_time() < guest->cooperative_yield_deadline_us) { + return; + } + + /* A ready owner task outranks the idle task, so taskYIELD() is insufficient + * to let the idle task service the task watchdog during a large source + * parse. Blocking for one scheduler tick preserves bounded execution while + * keeping parser and bytecode work cooperative with the rest of the Host. */ + vTaskDelay(1); + ++guest->stats.cooperative_yields; + const int64_t now = esp_timer_get_time(); + guest->cooperative_yield_deadline_us = + now > INT64_MAX - POCKETJS_ESP_GUEST_YIELD_INTERVAL_US + ? INT64_MAX + : now + POCKETJS_ESP_GUEST_YIELD_INTERVAL_US; +} + +static void guest_allocation_checkpoint(pocketjs_esp_guest_t *guest) { + if (!guest->executing) { + return; + } + if (guest->allocations_until_yield_check > 1U) { + --guest->allocations_until_yield_check; + return; + } + guest->allocations_until_yield_check = + POCKETJS_ESP_GUEST_ALLOCATIONS_PER_YIELD_CHECK; + guest_maybe_cooperative_yield(guest); +} + +static size_t guest_allocation_size(const void *pointer) { + return pointer != NULL ? heap_caps_get_allocated_size((void *)pointer) : 0; +} + +static void guest_record_allocation(pocketjs_esp_guest_t *guest, + void *pointer) { + if (pointer == NULL) { + return; + } + const size_t size = guest_allocation_size(pointer); + guest->stats.allocation_bytes += size; + ++guest->stats.allocation_count; + if (esp_ptr_external_ram(pointer)) { + guest->stats.external_allocation_bytes += size; + } + if (guest->stats.allocation_bytes > + guest->stats.allocation_bytes_high_water) { + guest->stats.allocation_bytes_high_water = guest->stats.allocation_bytes; + } + if (guest->stats.external_allocation_bytes > + guest->stats.external_allocation_bytes_high_water) { + guest->stats.external_allocation_bytes_high_water = + guest->stats.external_allocation_bytes; + } + if (guest->stats.allocation_count > + guest->stats.allocation_count_high_water) { + guest->stats.allocation_count_high_water = guest->stats.allocation_count; + } + guest_allocation_checkpoint(guest); +} + +static void guest_record_free(pocketjs_esp_guest_t *guest, void *pointer) { + if (pointer == NULL) { + return; + } + const size_t size = guest_allocation_size(pointer); + guest->stats.allocation_bytes -= size; + --guest->stats.allocation_count; + if (esp_ptr_external_ram(pointer)) { + guest->stats.external_allocation_bytes -= size; + } +} + +static void *guest_heap_malloc(void *opaque, size_t size) { + pocketjs_esp_guest_t *guest = opaque; + void *pointer = + guest->allocate_in_external_memory + ? heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT) + : heap_caps_malloc(size, MALLOC_CAP_8BIT); + guest_record_allocation(guest, pointer); + return pointer; +} + +static void *guest_heap_calloc(void *opaque, size_t count, size_t size) { + pocketjs_esp_guest_t *guest = opaque; + if (size != 0 && count > SIZE_MAX / size) { + return NULL; + } + void *pointer = + guest->allocate_in_external_memory + ? heap_caps_calloc(count, size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT) + : heap_caps_calloc(count, size, MALLOC_CAP_8BIT); + guest_record_allocation(guest, pointer); + return pointer; +} + +static void guest_heap_free(void *opaque, void *pointer) { + pocketjs_esp_guest_t *guest = opaque; + guest_record_free(guest, pointer); + heap_caps_free(pointer); +} + +static void *guest_heap_realloc(void *opaque, void *pointer, size_t size) { + pocketjs_esp_guest_t *guest = opaque; + if (size == 0) { + guest_heap_free(opaque, pointer); + return NULL; + } + + const size_t old_size = guest_allocation_size(pointer); + const bool old_external = pointer != NULL && esp_ptr_external_ram(pointer); + void *replacement = + guest->allocate_in_external_memory + ? heap_caps_realloc(pointer, size, + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT) + : heap_caps_realloc(pointer, size, MALLOC_CAP_8BIT); + if (replacement == NULL) { + return NULL; + } + + if (pointer != NULL) { + guest->stats.allocation_bytes -= old_size; + if (old_external) { + guest->stats.external_allocation_bytes -= old_size; + } + --guest->stats.allocation_count; + } + guest_record_allocation(guest, replacement); + return replacement; +} + +static size_t guest_heap_usable_size(const void *pointer) { + return guest_allocation_size(pointer); +} + +static const JSMallocFunctions GUEST_ALLOCATOR = { + .js_calloc = guest_heap_calloc, + .js_malloc = guest_heap_malloc, + .js_free = guest_heap_free, + .js_realloc = guest_heap_realloc, + .js_malloc_usable_size = guest_heap_usable_size, +}; + +static int guest_interrupt(JSRuntime *runtime, void *opaque) { + (void)runtime; + pocketjs_esp_guest_t *guest = opaque; + if (!guest->executing) { + return 0; + } + guest_maybe_cooperative_yield(guest); + if (guest->max_interrupt_checks != 0) { + if (guest->interrupt_checks_remaining == 0) { + guest->interrupted = true; + return 1; + } + --guest->interrupt_checks_remaining; + } + if (guest->execution_timeout_us != 0 && + esp_timer_get_time() >= guest->execution_deadline_us) { + guest->interrupted = true; + return 1; + } + return 0; +} + +static esp_err_t guest_begin_execution(pocketjs_esp_guest_t *guest) { + if (guest->executing) { + return ESP_ERR_INVALID_STATE; + } + guest->executing = true; + guest->interrupted = false; + guest->interrupt_checks_remaining = guest->max_interrupt_checks; + const int64_t now = esp_timer_get_time(); + guest->execution_deadline_us = + guest->execution_timeout_us > (uint64_t)(INT64_MAX - now) + ? INT64_MAX + : now + (int64_t)guest->execution_timeout_us; + guest->allocations_until_yield_check = + POCKETJS_ESP_GUEST_ALLOCATIONS_PER_YIELD_CHECK; + guest->cooperative_yield_deadline_us = + now > INT64_MAX - POCKETJS_ESP_GUEST_YIELD_INTERVAL_US + ? INT64_MAX + : now + POCKETJS_ESP_GUEST_YIELD_INTERVAL_US; + return ESP_OK; +} + +static esp_err_t guest_end_execution(pocketjs_esp_guest_t *guest, + bool exception) { + const bool interrupted = guest->interrupted; + guest->executing = false; + guest->interrupted = false; + if (interrupted) { + return ESP_ERR_TIMEOUT; + } + return exception ? ESP_FAIL : ESP_OK; +} + +static bool guest_is_owner(const pocketjs_esp_guest_t *guest) { + return guest != NULL && guest->owner_task == xTaskGetCurrentTaskHandle(); +} + +static JSValue guest_console(JSContext *context, JSValueConst this_value, + int argument_count, JSValueConst *arguments, + int magic) { + (void)this_value; + + char line[POCKETJS_ESP_GUEST_CONSOLE_BYTES]; + size_t used = 0; + line[0] = '\0'; + + for (int index = 0; index < argument_count; ++index) { + const char *text = JS_ToCString(context, arguments[index]); + if (text == NULL) { + continue; + } + + if (used != 0 && used + 1 < sizeof(line)) { + line[used++] = ' '; + } + const size_t available = sizeof(line) - used - 1; + const size_t length = strnlen(text, available); + memcpy(&line[used], text, length); + used += length; + line[used] = '\0'; + JS_FreeCString(context, text); + } + + switch (magic) { + case ESP_LOG_ERROR: + ESP_LOGE(TAG, "POCKETJS_GUEST_CONSOLE %s", line); + break; + case ESP_LOG_WARN: + ESP_LOGW(TAG, "POCKETJS_GUEST_CONSOLE %s", line); + break; + case ESP_LOG_DEBUG: + ESP_LOGD(TAG, "POCKETJS_GUEST_CONSOLE %s", line); + break; + default: + ESP_LOGI(TAG, "POCKETJS_GUEST_CONSOLE %s", line); + break; + } + return JS_UNDEFINED; +} + +static bool set_console_method(JSContext *context, JSValue console, + const char *name, int level) { + JSValue function = JS_NewCFunctionMagic(context, guest_console, name, 1, + JS_CFUNC_generic_magic, level); + if (JS_IsException(function)) { + return false; + } + if (JS_SetPropertyStr(context, console, name, function) < 0) { + return false; + } + return true; +} + +static bool install_console(JSContext *context) { + JSValue console = JS_NewObject(context); + if (JS_IsException(console)) { + return false; + } + + const bool installed = + set_console_method(context, console, "log", ESP_LOG_INFO) && + set_console_method(context, console, "info", ESP_LOG_INFO) && + set_console_method(context, console, "warn", ESP_LOG_WARN) && + set_console_method(context, console, "error", ESP_LOG_ERROR) && + set_console_method(context, console, "debug", ESP_LOG_DEBUG); + if (!installed) { + JS_FreeValue(context, console); + return false; + } + + JSValue global = JS_GetGlobalObject(context); + if (JS_IsException(global)) { + JS_FreeValue(context, console); + return false; + } + const int result = JS_SetPropertyStr(context, global, "console", console); + JS_FreeValue(context, global); + return result >= 0; +} + +static void log_context_exception(JSContext *context, const char *phase) { + JSValue exception = JS_GetException(context); + const char *message = JS_ToCString(context, exception); + JSValue stack = JS_GetPropertyStr(context, exception, "stack"); + const char *stack_text = JS_IsException(stack) || JS_IsUndefined(stack) + ? NULL + : JS_ToCString(context, stack); + + ESP_LOGE(TAG, "POCKETJS_GUEST_EXCEPTION phase=%s message=%s", phase, + message != NULL ? message : ""); + if (stack_text != NULL && stack_text[0] != '\0') { + ESP_LOGE(TAG, "POCKETJS_GUEST_STACK %s", stack_text); + } + + if (stack_text != NULL) { + JS_FreeCString(context, stack_text); + } + JS_FreeValue(context, stack); + if (message != NULL) { + JS_FreeCString(context, message); + } + JS_FreeValue(context, exception); +} + +esp_err_t pocketjs_esp_guest_create(const pocketjs_esp_guest_config_t *config, + pocketjs_esp_guest_t **out_guest) { + if (out_guest == NULL) { + return ESP_ERR_INVALID_ARG; + } + *out_guest = NULL; + + pocketjs_esp_guest_t *guest = calloc(1, sizeof(*guest)); + if (guest == NULL) { + return ESP_ERR_NO_MEM; + } + + guest->owner_task = xTaskGetCurrentTaskHandle(); + guest->frame_function = JS_UNDEFINED; + guest->allocate_in_external_memory = + config != NULL && config->allocate_in_external_memory; + guest->execution_timeout_us = + config != NULL ? config->execution_timeout_us : 0; + guest->max_interrupt_checks = + config != NULL ? config->max_interrupt_checks : 0; + guest->runtime = JS_NewRuntime2(&GUEST_ALLOCATOR, guest); + if (guest->runtime == NULL) { + free(guest); + return ESP_ERR_NO_MEM; + } + JS_SetInterruptHandler(guest->runtime, guest_interrupt, guest); + JS_SetCanBlock(guest->runtime, false); + + if (config != NULL && config->memory_limit_bytes != 0) { + JS_SetMemoryLimit(guest->runtime, config->memory_limit_bytes); + } + if (config != NULL && config->stack_limit_bytes != 0) { + JS_SetMaxStackSize(guest->runtime, config->stack_limit_bytes); + } + + guest->context = JS_NewContext(guest->runtime); + if (guest->context == NULL) { + JS_FreeRuntime(guest->runtime); + free(guest); + return ESP_ERR_NO_MEM; + } + if (!install_console(guest->context)) { + log_context_exception(guest->context, "install_console"); + JS_FreeContext(guest->context); + JS_FreeRuntime(guest->runtime); + free(guest); + return ESP_FAIL; + } + + *out_guest = guest; + ESP_LOGI(TAG, + "POCKETJS_GUEST_READY engine=%s version=%s memory_limit=%u " + "stack_limit=%u external_required=%d timeout_us=%llu " + "interrupt_checks=%u", + POCKETJS_ESP_GUEST_ENGINE_ID, POCKETJS_ESP_GUEST_ENGINE_VERSION, + config != NULL ? (unsigned)config->memory_limit_bytes : 0, + config != NULL ? (unsigned)config->stack_limit_bytes : 0, + guest->allocate_in_external_memory, + (unsigned long long)guest->execution_timeout_us, + (unsigned)guest->max_interrupt_checks); + return ESP_OK; +} + +JSContext *pocketjs_esp_guest_context(pocketjs_esp_guest_t *guest) { + if (!guest_is_owner(guest)) { + return NULL; + } + return guest->context; +} + +esp_err_t pocketjs_esp_guest_eval(pocketjs_esp_guest_t *guest, + const char *label, const char *source, + size_t source_length) { + if (!guest_is_owner(guest) || label == NULL || source == NULL || + source_length == 0 || source[source_length] != '\0') { + return ESP_ERR_INVALID_ARG; + } + + esp_err_t error = guest_begin_execution(guest); + if (error != ESP_OK) { + return error; + } + + JSValue result = JS_Eval(guest->context, source, source_length, label, + JS_EVAL_TYPE_GLOBAL); + if (JS_IsException(result)) { + log_context_exception(guest->context, "eval"); + JS_FreeValue(guest->context, result); + return guest_end_execution(guest, true); + } + JS_FreeValue(guest->context, result); + return guest_end_execution(guest, false); +} + +esp_err_t +pocketjs_esp_guest_mount_factory(pocketjs_esp_guest_t *guest, const char *label, + const char *source, size_t source_length, + JSValueConst binding, JSValue *out_result) { + if (!guest_is_owner(guest) || label == NULL || source == NULL || + source_length == 0 || source[source_length] != '\0' || + !JS_IsObject(binding) || guest->mounted) { + return ESP_ERR_INVALID_ARG; + } + if (out_result != NULL) { + *out_result = JS_UNDEFINED; + } + + esp_err_t error = guest_begin_execution(guest); + if (error != ESP_OK) { + return error; + } + if (JS_FreezeObject(guest->context, binding) < 0) { + log_context_exception(guest->context, "freeze_binding"); + return guest_end_execution(guest, true); + } + + JSValue factory = JS_Eval(guest->context, source, source_length, label, + JS_EVAL_TYPE_GLOBAL); + if (JS_IsException(factory)) { + log_context_exception(guest->context, "eval_factory"); + JS_FreeValue(guest->context, factory); + return guest_end_execution(guest, true); + } + if (!JS_IsFunction(guest->context, factory)) { + JS_FreeValue(guest->context, factory); + JS_ThrowTypeError(guest->context, + "PocketJS Guest bundle must evaluate to a factory"); + log_context_exception(guest->context, "validate_factory"); + return guest_end_execution(guest, true); + } + + JSValueConst arguments[] = {binding}; + JSValue result = JS_Call(guest->context, factory, JS_UNDEFINED, 1, arguments); + JS_FreeValue(guest->context, factory); + if (JS_IsException(result)) { + log_context_exception(guest->context, "call_factory"); + JS_FreeValue(guest->context, result); + return guest_end_execution(guest, true); + } + + JSValue global = JS_GetGlobalObject(guest->context); + JSValue frame = JS_IsException(global) + ? JS_EXCEPTION + : JS_GetPropertyStr(guest->context, global, "frame"); + JS_FreeValue(guest->context, global); + if (JS_IsException(frame) || !JS_IsFunction(guest->context, frame)) { + JS_FreeValue(guest->context, frame); + JS_FreeValue(guest->context, result); + JS_ThrowTypeError(guest->context, + "PocketJS Guest factory must install globalThis.frame"); + log_context_exception(guest->context, "capture_frame"); + return guest_end_execution(guest, true); + } + guest->frame_function = frame; + guest->mounted = true; + if (out_result != NULL) { + *out_result = result; + } else { + JS_FreeValue(guest->context, result); + } + return guest_end_execution(guest, false); +} + +esp_err_t pocketjs_esp_guest_call_frame(pocketjs_esp_guest_t *guest, + size_t argument_count, + JSValueConst *arguments) { + if (!guest_is_owner(guest) || !guest->mounted || argument_count > INT32_MAX || + (argument_count != 0 && arguments == NULL)) { + return ESP_ERR_INVALID_ARG; + } + esp_err_t error = guest_begin_execution(guest); + if (error != ESP_OK) { + return error; + } + JSValue global = JS_GetGlobalObject(guest->context); + if (JS_IsException(global)) { + log_context_exception(guest->context, "get_frame_receiver"); + JS_FreeValue(guest->context, global); + return guest_end_execution(guest, true); + } + JSValue result = JS_Call(guest->context, guest->frame_function, global, + (int)argument_count, arguments); + JS_FreeValue(guest->context, global); + if (JS_IsException(result)) { + log_context_exception(guest->context, "call_frame"); + JS_FreeValue(guest->context, result); + return guest_end_execution(guest, true); + } + JS_FreeValue(guest->context, result); + return guest_end_execution(guest, false); +} + +esp_err_t pocketjs_esp_guest_call_function( + pocketjs_esp_guest_t *guest, const char *phase, JSValueConst function, + JSValueConst receiver, size_t argument_count, JSValueConst *arguments, + JSValue *out_result) { + if (out_result != NULL) { + *out_result = JS_UNDEFINED; + } + if (!guest_is_owner(guest) || phase == NULL || + !JS_IsFunction(guest->context, function) || argument_count > INT32_MAX || + (argument_count != 0 && arguments == NULL)) { + return ESP_ERR_INVALID_ARG; + } + + esp_err_t error = guest_begin_execution(guest); + if (error != ESP_OK) { + return error; + } + JSValue result = JS_Call(guest->context, function, receiver, + (int)argument_count, arguments); + if (JS_IsException(result)) { + log_context_exception(guest->context, phase); + JS_FreeValue(guest->context, result); + return guest_end_execution(guest, true); + } + if (out_result != NULL) { + *out_result = result; + } else { + JS_FreeValue(guest->context, result); + } + return guest_end_execution(guest, false); +} + +esp_err_t pocketjs_esp_guest_execute_jobs(pocketjs_esp_guest_t *guest, + size_t max_jobs, size_t *out_executed, + bool *out_pending) { + if (!guest_is_owner(guest) || max_jobs == 0) { + return ESP_ERR_INVALID_ARG; + } + if (out_executed != NULL) { + *out_executed = 0; + } + if (out_pending != NULL) { + *out_pending = false; + } + esp_err_t error = guest_begin_execution(guest); + if (error != ESP_OK) { + return error; + } + size_t executed = 0; + while (executed < max_jobs) { + JSContext *job_context = NULL; + const int result = JS_ExecutePendingJob(guest->runtime, &job_context); + if (result == 0) { + break; + } + if (result < 0) { + log_context_exception(job_context != NULL ? job_context : guest->context, + "pending_job"); + if (out_executed != NULL) { + *out_executed = executed; + } + if (out_pending != NULL) { + *out_pending = JS_IsJobPending(guest->runtime); + } + return guest_end_execution(guest, true); + } + ++executed; + } + if (out_executed != NULL) { + *out_executed = executed; + } + if (out_pending != NULL) { + *out_pending = JS_IsJobPending(guest->runtime); + } + return guest_end_execution(guest, false); +} + +esp_err_t pocketjs_esp_guest_get_stats(pocketjs_esp_guest_t *guest, + pocketjs_esp_guest_stats_t *out_stats) { + if (!guest_is_owner(guest) || out_stats == NULL) { + return ESP_ERR_INVALID_ARG; + } + *out_stats = guest->stats; + JSMemoryUsage usage = {0}; + JS_ComputeMemoryUsage(guest->runtime, &usage); + if (usage.memory_used_size > 0) { + out_stats->engine_memory_used_bytes = (size_t)usage.memory_used_size; + } + if (usage.obj_count > 0) { + out_stats->engine_object_count = (size_t)usage.obj_count; + } + return ESP_OK; +} + +void pocketjs_esp_guest_log_exception(pocketjs_esp_guest_t *guest, + const char *phase) { + if (!guest_is_owner(guest)) { + return; + } + log_context_exception(guest->context, phase != NULL ? phase : "unknown"); +} + +void pocketjs_esp_guest_destroy(pocketjs_esp_guest_t *guest) { + if (!guest_is_owner(guest)) { + return; + } + JS_FreeValue(guest->context, guest->frame_function); + JS_FreeContext(guest->context); + JS_FreeRuntime(guest->runtime); + free(guest); +} diff --git a/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/.gitignore b/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/.gitignore new file mode 100644 index 00000000..cfbd37f9 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/.gitignore @@ -0,0 +1,5 @@ +/build*/ +/dependencies.lock +/managed_components/ +/sdkconfig +/sdkconfig.old diff --git a/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/CMakeLists.txt new file mode 100644 index 00000000..0fc40366 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/CMakeLists.txt @@ -0,0 +1,7 @@ +cmake_minimum_required(VERSION 3.16) + +set(EXTRA_COMPONENT_DIRS "../..") +set(SDKCONFIG "${CMAKE_BINARY_DIR}/sdkconfig") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +idf_build_set_property(MINIMAL_BUILD ON) +project(pocketjs_esp_guest_build_smoke) diff --git a/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/README.md b/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/README.md new file mode 100644 index 00000000..7488a1e1 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/README.md @@ -0,0 +1,13 @@ +# Build smoke + +This app compiles the task-owned Guest component, mounts a frozen-binding +factory, calls its cached frame twice, invokes a registered Guest function +through the execution guard, and executes a bounded Promise job. Build it for +both ESP-IDF profiles with the pinned v6.0.2 toolchain: + +```sh +idf.py -B build-esp32s3 -DIDF_TARGET=esp32s3 build +idf.py -B build-esp32p4 -DIDF_TARGET=esp32p4 build +``` + +Build success is not a network capability admission. diff --git a/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/main/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/main/CMakeLists.txt new file mode 100644 index 00000000..a2c051a4 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "build_smoke.c" + INCLUDE_DIRS "." + REQUIRES pocketjs_esp_guest +) diff --git a/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/main/build_smoke.c b/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/main/build_smoke.c new file mode 100644 index 00000000..df737887 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/main/build_smoke.c @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT + +#include +#include + +#include "pocketjs/esp_guest.h" + +static const char PROBE_SCRIPT[] = + "((binding) => {" + " if (!Object.isFrozen(binding) || binding.version !== 1) throw new " + "Error('binding');" + " let frames = 0;" + " globalThis.pocketProbe = 0;" + " globalThis.frame = () => { frames += 1; globalThis.pocketFrames = " + "frames; };" + " globalThis.pocketCall = (value) => value + 1;" + " Promise.resolve(21).then((value) => { globalThis.pocketProbe = value * " + "2; });" + "})"; + +void app_main(void) { + const pocketjs_esp_guest_config_t config = { + .memory_limit_bytes = 256U * 1024U, + .stack_limit_bytes = 16U * 1024U, + .allocate_in_external_memory = false, + .execution_timeout_us = 100U * 1000U, + .max_interrupt_checks = 64, + }; + pocketjs_esp_guest_t *guest = NULL; + ESP_ERROR_CHECK(pocketjs_esp_guest_create(&config, &guest)); + assert(guest != NULL); + JSContext *context = pocketjs_esp_guest_context(guest); + assert(context != NULL); + JSValue binding = JS_NewObject(context); + assert(!JS_IsException(binding)); + assert(JS_SetPropertyStr(context, binding, "version", + JS_NewInt32(context, 1)) >= 0); + ESP_ERROR_CHECK(pocketjs_esp_guest_mount_factory( + guest, "pocketjs-esp-guest-build-smoke.js", PROBE_SCRIPT, + strlen(PROBE_SCRIPT), binding, NULL)); + JS_FreeValue(context, binding); + ESP_ERROR_CHECK(pocketjs_esp_guest_call_frame(guest, 0, NULL)); + ESP_ERROR_CHECK(pocketjs_esp_guest_call_frame(guest, 0, NULL)); + + size_t jobs = 0; + bool pending = true; + ESP_ERROR_CHECK(pocketjs_esp_guest_execute_jobs(guest, 8, &jobs, &pending)); + assert(jobs == 1); + assert(!pending); + + JSValue global = JS_GetGlobalObject(context); + JSValue value = JS_GetPropertyStr(context, global, "pocketProbe"); + int32_t probe = 0; + assert(JS_ToInt32(context, &probe, value) == 0); + assert(probe == 42); + JS_FreeValue(context, value); + value = JS_GetPropertyStr(context, global, "pocketFrames"); + assert(JS_ToInt32(context, &probe, value) == 0); + assert(probe == 2); + JS_FreeValue(context, value); + JSValue call_function = JS_GetPropertyStr(context, global, "pocketCall"); + assert(JS_IsFunction(context, call_function)); + JSValue call_argument = JS_NewInt32(context, 41); + JSValue call_result = JS_UNDEFINED; + ESP_ERROR_CHECK(pocketjs_esp_guest_call_function( + guest, "build_smoke_call", call_function, JS_UNDEFINED, 1, + &call_argument, &call_result)); + assert(JS_ToInt32(context, &probe, call_result) == 0); + assert(probe == 42); + JS_FreeValue(context, call_result); + JS_FreeValue(context, call_argument); + JS_FreeValue(context, call_function); + JS_FreeValue(context, global); + + pocketjs_esp_guest_stats_t stats = {0}; + ESP_ERROR_CHECK(pocketjs_esp_guest_get_stats(guest, &stats)); + assert(stats.allocation_bytes > 0); + assert(stats.allocation_bytes_high_water >= stats.allocation_bytes); + assert(stats.engine_memory_used_bytes > 0); + pocketjs_esp_guest_destroy(guest); +} diff --git a/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/main/idf_component.yml b/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/main/idf_component.yml new file mode 100644 index 00000000..700ce96e --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/main/idf_component.yml @@ -0,0 +1,3 @@ +dependencies: + idf: + version: "==6.0.2" diff --git a/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/sdkconfig.defaults b/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/sdkconfig.defaults new file mode 100644 index 00000000..4983b4df --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_esp_guest/test_apps/build_smoke/sdkconfig.defaults @@ -0,0 +1 @@ +CONFIG_COMPILER_OPTIMIZATION_SIZE=y diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_esp_runtime/CMakeLists.txt new file mode 100644 index 00000000..bd68e776 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/CMakeLists.txt @@ -0,0 +1,18 @@ +idf_component_register( + SRCS + "src/esp_runtime.c" + "src/network_v1_binding.c" + "src/runtime_contract.c" + INCLUDE_DIRS + "include" + "../../../../contracts/spec/network/generated" + PRIV_INCLUDE_DIRS "private" + REQUIRES + esp_timer + pocketjs_esp_guest + pocketjs_net_esp_transport + pocketjs_net_http_client_core + quickjs-ng +) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/README.md b/hosts/esp-idf/components/pocketjs_net_esp_runtime/README.md new file mode 100644 index 00000000..c4d1bece --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/README.md @@ -0,0 +1,82 @@ +# PocketJS ESP-IDF network runtime candidate + +This experimental component adapts the frozen private network ABI 1.1 table to +a bounded pool of HTTP Client Cores and dedicated ESP transports. **The table +is owner-task-only, frozen, accessor-free, and bound to the exact Build Plan +hash and either the `network.http.client` projection or the exact +`network.http.client` plus `network.http.client.tls` projection.** The +handshake and `getLimits()` return the same frozen feature list. lwIP callbacks +only call the configured scheduler wake hook; Guest code runs only through +`pocketjs_esp_guest_call_function()`. + +Each operation slot owns one Core and one transport because Core-local +transport tokens cannot safely share a completion consumer. The pool has a +compile-time ceiling of eight slots and a smaller admitted runtime size. +`pocketjs_net_esp_runtime_required_native_buffer_bytes()` computes the maximum +PocketJS-owned allocation payload for the runtime, binding tombstone, fixed +slots, dedicated transports, and CA-validation snapshot. Creation rejects a +`runtime.nativeBufferBytes` default or hard limit below that result. Header +Latin-1 conversion uses fixed runtime scratch instead of transient native heap, +and stats report both current PocketJS-owned bytes and the admitted default. +Allocator metadata and IDF-owned lwIP/Mbed TLS pools remain outside this number +and must be admitted separately before public capability advertisement. +Response body events retain the Core lease until the formal +`leaseTake`/`leaseReadInto`/`leaseRelease` sequence completes. Request upload +pull events are retired immediately while their single credit and Core body +and pull generations remain recorded for the later producer command. +`leaseReadInto` accepts only an exact borrowed `Uint8Array` window whose byte +length equals `maxBytes`, and rejects any range beyond the retained lease. + +Plaintext `http:` is available in both projections. HTTPS requires the exact +TLS feature projection and an immutable Host-selected certificate bundle or a +single Host-pinned CA. The runtime copies a pinned CA during `create()`; the +trusted-clock callback and its context remain borrowed until successful +destroy. **The native boundary accepts only TLS 1.2, full certificate and +hostname verification, no client certificate, no custom CA input, no ALPN, +and the canonical DNS A-label as `serverName`.** Numeric HTTPS hosts and every +other TLS policy are rejected before permission checks, DNS, or socket I/O. +The transport reads the failed handshake's live Mbed TLS verification result +through ESP-TLS's public SSL-context accessor before teardown. The runtime +therefore reports `distinct_tls_errors=true`: hostname mismatch maps to +`tls_hostname_mismatch`, while chain, validity, and usage failures map to +`tls_certificate_invalid`. +TLS close operations send a client `close_notify`. Nonblocking +`WANT_READ`/`WANT_WRITE` results are retried by later owner turns under the +close operation's monotonic deadline. The Host does not wait indefinitely for +the peer's reciprocal alert; failure, cancellation, and timeout hard-close the +dedicated connection. + +The runtime passes `manual`, `error`, and bounded `follow` modes into the Core. +The final response metadata carries the effective URL and whether any hop was +followed. A one-shot streaming producer can follow redirects that rewrite the +request to GET, but 307/308 and other preserve-body redirects fail with +`invalid_state`; the runtime never asks the Guest producer to replay consumed +input. The native redirect resolver is still an ASCII-only subset of the +framework URL implementation. Runtime creation now compares the Build Plan's +generated HTTP backend, network driver, and optional TLS provider selection +with the exact compiled provider IDs before allocating slots. It also checks +the compiled Core and transport descriptors against the lifecycle, redirect, +credit, lease, pool, and TLS properties consumed by the runtime. This check +applies to test-only plans, so a component replacement or descriptor drift +fails before Guest evaluation. A public admission request additionally +requires every composed descriptor to advertise the capability, exhaustive +DNS candidates with generation-safe cancellation, bounded native-step time, +and bounded lwIP allocation. TLS public admission also requires nonblocking +TLS steps, no ESP-TLS internal resolver allocation, bounded ESP-TLS/X.509 +allocation, and distinct certificate errors. Those properties are currently +false. Full product Host descriptor/resource aggregation is still incomplete. +**The descriptor therefore keeps public capability advertisement off.** + +Shutdown has three explicit stages: stop admission and request cancellation, +run guarded shutdown service turns while pumping native cleanup, then destroy +quiescent transports and deinitialize every Core before releasing the binding. +If a poisoned Core retains native ownership, phase 2 destroys only that Core's +dedicated transport and synchronously confirms the invalid transport context +before any other Core call. Persistent Host event retirement then uses the +Core's poison-only exact-sequence abandon path; healthy event retirement is +never discarded. A late stock lwIP DNS callback delays this recovery without +freeing its callback context. +Both zero-budget probes and nonzero-budget empty polls read Core status before +reporting `Drained`, so Core poison cannot be hidden by an empty Host queue. +Binding construction clears and logs any QuickJS exception on failure, including +the closure constructor's returned-value/pending-exception boundary. diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/idf_component.yml b/hosts/esp-idf/components/pocketjs_net_esp_runtime/idf_component.yml new file mode 100644 index 00000000..700ce96e --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/idf_component.yml @@ -0,0 +1,3 @@ +dependencies: + idf: + version: "==6.0.2" diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/include/pocketjs/net/esp_runtime.h b/hosts/esp-idf/components/pocketjs_net_esp_runtime/include/pocketjs/net/esp_runtime.h new file mode 100644 index 00000000..78c86315 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/include/pocketjs/net/esp_runtime.h @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +#include "esp_err.h" +#include "pocketjs/esp_guest.h" +#include "pocketjs/net/esp_transport.h" +#include "pocketjs/net/http_client_core.h" +#include "pocketjs_network_v1_abi.h" +#include "quickjs.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define POCKETJS_NET_ESP_RUNTIME_ID \ + "pocketjs.net.esp-idf.runtime.v1.experimental" +#define POCKETJS_NET_ESP_RUNTIME_IMPLEMENTATION_VERSION \ + "esp-idf-v6.0.2-reference-candidate" + +#define POCKETJS_NET_ESP_RUNTIME_MAX_OPERATIONS 8U +#define POCKETJS_NET_ESP_RUNTIME_MAX_FEATURES 2U +#define POCKETJS_NET_ESP_RUNTIME_MAX_REDIRECTS 5U +#define POCKETJS_NET_ESP_RUNTIME_MAX_LIMIT_ENTRIES 5U +#define POCKETJS_NET_ESP_RUNTIME_SEQUENCE_MAX UINT64_C(9007199254740991) + +typedef struct pocketjs_net_esp_runtime pocketjs_net_esp_runtime_t; + +typedef void (*pocketjs_net_esp_runtime_wake_fn)(void *context); +typedef bool (*pocketjs_net_esp_runtime_permission_fn)( + void *context, const pocketjs_net_http_client_endpoint_t *endpoint); + +typedef struct { + uint64_t minimum; + uint64_t default_value; + uint64_t hard; +} pocketjs_net_esp_runtime_limit_range_t; + +typedef struct { + pocketjs_net_esp_runtime_limit_range_t buffered_body_bytes; + pocketjs_net_esp_runtime_limit_range_t header_bytes; + pocketjs_net_esp_runtime_limit_range_t max_body_chunk_bytes; + pocketjs_net_esp_runtime_limit_range_t max_operations; + pocketjs_net_esp_runtime_limit_range_t native_buffer_bytes; +} pocketjs_net_esp_runtime_limits_t; + +typedef enum { + POCKETJS_NET_ESP_RUNTIME_ADMISSION_TEST_ONLY = 1, + POCKETJS_NET_ESP_RUNTIME_ADMISSION_PUBLIC, +} pocketjs_net_esp_runtime_admission_t; + +typedef enum { + POCKETJS_NET_ESP_RUNTIME_TLS_SELECTION_NONE = 0, + POCKETJS_NET_ESP_RUNTIME_TLS_SELECTION_PROVIDER, + POCKETJS_NET_ESP_RUNTIME_TLS_SELECTION_BACKEND, +} pocketjs_net_esp_runtime_tls_selection_source_t; + +typedef struct { + const char *http_client_backend_id; + const char *net_driver_id; + pocketjs_net_esp_runtime_tls_selection_source_t http_client_tls_source; + const char *http_client_tls_id; +} pocketjs_net_esp_runtime_provider_selection_t; + +typedef struct { + /** Guest borrowed until destroy; it supplies the owner context and guard. */ + pocketjs_esp_guest_t *guest; + /** Non-zero generation selected by the product Host; it never wraps. */ + uint32_t runtime_generation; + /** Exact digest portion of the verified sha256 Build Plan hash. */ + uint8_t plan_hash[POCKETJS_NETWORK_V1_PLAN_HASH_BYTES]; + /** Exact, strictly increasing network feature projection. */ + const pocketjs_network_v1_feature_id_t *feature_ids; + uint16_t feature_count; + /** Exact provider selection copied from the already verified Build Plan. */ + pocketjs_net_esp_runtime_provider_selection_t providers; + /** Test-only plans never cause public capability advertisement. */ + pocketjs_net_esp_runtime_admission_t admission; + /** One through POCKETJS_NET_ESP_RUNTIME_MAX_OPERATIONS. */ + uint16_t max_operations; + pocketjs_net_esp_runtime_limits_t limits; + uint64_t connect_timeout_us; + uint64_t headers_timeout_us; + uint64_t idle_timeout_us; + uint64_t total_timeout_us; + /** Host-selected immutable TLS profile; Guest code cannot replace it. */ + pocketjs_net_esp_tls_trust_source_t tls_trust_source; + /** Borrowed only for create(); a valid pinned CA is copied before return. */ + const uint8_t *host_pinned_ca_pem; + size_t host_pinned_ca_pem_bytes; + /** + * Borrowed through successful destroy(). Called only by the owner pump; it + * must read already-published trusted-clock state without blocking, + * re-entering the runtime, or entering QuickJS. + */ + pocketjs_net_esp_wall_clock_trusted_fn wall_clock_trusted; + void *wall_clock_context; + /** May run from lwIP's tcpip task; it may only signal the scheduler. */ + pocketjs_net_esp_runtime_wake_fn wake; + void *wake_context; + /** + * Runs synchronously on the owner task before hostname and numeric I/O. + * It must only inspect the endpoint and return; it must not reenter the + * Guest or this runtime. Reentrant service/shutdown calls fail closed. + */ + pocketjs_net_esp_runtime_permission_fn allow_endpoint; + void *permission_context; +} pocketjs_net_esp_runtime_config_t; + +typedef enum { + POCKETJS_NET_ESP_RUNTIME_PHASE_RUNNING = 1, + POCKETJS_NET_ESP_RUNTIME_PHASE_SHUTDOWN_REQUESTED, + POCKETJS_NET_ESP_RUNTIME_PHASE_QUIESCING, + POCKETJS_NET_ESP_RUNTIME_PHASE_READY_TO_DESTROY, +} pocketjs_net_esp_runtime_phase_t; + +typedef struct { + const char *id; + const char *implementation_version; + bool experimental; + bool advertises_public_capability; + uint16_t abi_major; + uint16_t abi_minor; + bool owner_only_quickjs; + bool worker_or_callback_calls_quickjs; + bool frozen_accessor_free_binding; + bool exact_plan_handshake; + bool endpoint_permission_rechecked; + bool fixed_operation_pool; + bool pocketjs_owned_native_buffer_floor_enforced; + bool connection_reuse; + bool bounded_connection_pool; + bool exact_lease_ownership; + bool explicit_three_phase_shutdown; + bool plaintext_http; + bool https_rejected_before_io; + bool https_explicit_opt_in; + bool exact_host_tls_profile; + bool distinct_tls_errors; + bool tls_close_notify; + bool tls_close_notify_uses_operation_deadline; + bool tls_close_notify_waits_for_peer; + const char *tls_provider_id; + bool redirect_manual; + bool redirect_error; + bool redirect_follow; + bool redirect_replayable_stream_body; + bool guest_execution_guarded_dispatch; + bool hidden_retry; + bool hidden_auth; + bool hidden_cookie_store; + bool proxy; + bool content_decoding; + size_t max_operations; + size_t max_cached_connections; + size_t max_redirects; + size_t operation_slot_bytes; + size_t validation_snapshot_bytes; +} pocketjs_net_esp_runtime_descriptor_t; + +typedef struct { + pocketjs_net_esp_runtime_phase_t phase; + uint32_t runtime_generation; + uint16_t configured_operation_slots; + uint16_t initialized_operation_slots; + uint16_t active_operations; + uint16_t pending_core_events; + uint16_t queued_leases; + uint16_t taken_leases; + uint64_t last_command_sequence; + uint64_t last_completion_sequence; + uint64_t last_service_turn; + uint64_t requests_started; + uint64_t completions_delivered; + uint64_t leases_taken; + uint64_t leases_released; + uint64_t leases_cleaned_up; + uint64_t permission_checks; + uint32_t poison_flags; + uint16_t poisoned_core_slots; + uint32_t core_poison_flags; + int32_t first_core_poison_cause_code; + size_t runtime_instance_bytes; + size_t core_storage_bytes; + size_t transport_instances; + size_t pocketjs_owned_native_bytes; + size_t admitted_native_buffer_bytes; +} pocketjs_net_esp_runtime_stats_t; + +typedef struct { + pocketjs_network_v1_service_turn_status_t status; + uint32_t events_delivered; + uint32_t payload_bytes_delivered; + uint64_t last_sequence; +} pocketjs_net_esp_runtime_service_result_t; + +const pocketjs_net_esp_runtime_descriptor_t * +pocketjs_net_esp_runtime_descriptor(void); + +/** + * Compute the maximum PocketJS-owned native allocation payload for the fixed + * runtime, binding tombstone, configured slots, transports, and CA-validation + * snapshot. Allocator metadata and IDF-owned lwIP/Mbed TLS pools are excluded + * and remain separate descriptor/admission requirements. + */ +bool pocketjs_net_esp_runtime_required_native_buffer_bytes( + uint16_t max_operations, size_t *out_bytes); + +/** Create on the same FreeRTOS task that owns quickjs_context. */ +esp_err_t +pocketjs_net_esp_runtime_create(const pocketjs_net_esp_runtime_config_t *config, + pocketjs_net_esp_runtime_t **out_runtime); + +/** Return an owned reference to the frozen private ABI table. */ +esp_err_t +pocketjs_net_esp_runtime_get_binding(pocketjs_net_esp_runtime_t *runtime, + JSValue *out_binding); + +/** + * Pump bounded native work, then invoke the registered formal dispatcher on + * the owner task. During shutdown the invocation kind is SHUTDOWN. + * + * The registered dispatcher runs through pocketjs_esp_guest_call_function(), + * under the Guest's interrupt-count and monotonic execution-time guard. + */ +esp_err_t pocketjs_net_esp_runtime_service( + pocketjs_net_esp_runtime_t *runtime, uint64_t now_us, + uint32_t max_native_steps, uint32_t max_transport_completions, + uint32_t max_events, uint32_t max_payload_bytes, + pocketjs_net_esp_runtime_service_result_t *out_result); + +/** Phase 1: permanently stop admission and request bounded cancellation. */ +esp_err_t +pocketjs_net_esp_runtime_begin_shutdown(pocketjs_net_esp_runtime_t *runtime, + uint64_t now_us); + +/** Phase 2 completes through repeated service calls until this becomes true. */ +bool pocketjs_net_esp_runtime_is_ready_to_destroy( + const pocketjs_net_esp_runtime_t *runtime); + +/** + * Phase 3: destroy quiescent transports, deinitialize Cores, and release + * QuickJS values. The pocketjs_esp_guest context must still be alive. + */ +esp_err_t pocketjs_net_esp_runtime_destroy(pocketjs_net_esp_runtime_t *runtime); + +esp_err_t +pocketjs_net_esp_runtime_get_stats(pocketjs_net_esp_runtime_t *runtime, + pocketjs_net_esp_runtime_stats_t *out_stats); + +#ifdef __cplusplus +} +#endif diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/private/runtime_contract.h b/hosts/esp-idf/components/pocketjs_net_esp_runtime/private/runtime_contract.h new file mode 100644 index 00000000..782f008e --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/private/runtime_contract.h @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +#include "pocketjs_network_v1_abi.h" + +typedef enum { + POCKETJS_NET_ESP_REDIRECT_NOT_REDIRECT = 0, + POCKETJS_NET_ESP_REDIRECT_REWRITE_GET, + POCKETJS_NET_ESP_REDIRECT_PRESERVE_METHOD_BODY, +} pocketjs_net_esp_redirect_action_t; + +bool pocketjs_net_esp_runtime_feature_projection_valid( + const pocketjs_network_v1_feature_id_t *feature_ids, + uint16_t feature_count); + +bool pocketjs_net_esp_runtime_feature_projection_has_tls( + const pocketjs_network_v1_feature_id_t *feature_ids, + uint16_t feature_count); + +bool pocketjs_net_esp_runtime_next_sequence(uint64_t *sequence); + +bool pocketjs_net_esp_runtime_live_handle(pocketjs_network_v1_handle_t handle); + +bool pocketjs_net_esp_runtime_same_handle(pocketjs_network_v1_handle_t left, + pocketjs_network_v1_handle_t right); + +pocketjs_net_esp_redirect_action_t +pocketjs_net_esp_runtime_redirect_action(unsigned status, const uint8_t *method, + size_t method_length); + +bool pocketjs_net_esp_runtime_sensitive_redirect_header(const uint8_t *name, + size_t name_length); diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/private/runtime_internal.h b/hosts/esp-idf/components/pocketjs_net_esp_runtime/private/runtime_internal.h new file mode 100644 index 00000000..1776310b --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/private/runtime_internal.h @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "pocketjs/net/esp_runtime.h" +#include "pocketjs/net/esp_transport.h" +#include "quickjs.h" + +#define POCKETJS_NET_ESP_RUNTIME_MAGIC UINT64_C(0x504a534e45545254) +#define POCKETJS_NET_ESP_RUNTIME_HTTP_RESOURCE_ID 1U +#define POCKETJS_NET_ESP_RUNTIME_HTTP_RESOURCE_GENERATION 1U + +#define POCKETJS_NET_ESP_RUNTIME_POISON_QUICKJS (UINT32_C(1) << 0) +#define POCKETJS_NET_ESP_RUNTIME_POISON_CORE (UINT32_C(1) << 1) +#define POCKETJS_NET_ESP_RUNTIME_POISON_LEASE (UINT32_C(1) << 2) +#define POCKETJS_NET_ESP_RUNTIME_POISON_SEQUENCE (UINT32_C(1) << 3) +#define POCKETJS_NET_ESP_RUNTIME_POISON_SHUTDOWN (UINT32_C(1) << 4) + +typedef enum { + POCKETJS_NET_ESP_RUNTIME_LEASE_NONE = 0, + POCKETJS_NET_ESP_RUNTIME_LEASE_QUEUED, + POCKETJS_NET_ESP_RUNTIME_LEASE_TAKEN, + POCKETJS_NET_ESP_RUNTIME_LEASE_RELEASED, +} pocketjs_net_esp_runtime_lease_state_t; + +typedef struct pocketjs_net_esp_runtime_binding_state { + pocketjs_net_esp_runtime_t *runtime; + uint32_t references; +} pocketjs_net_esp_runtime_binding_state_t; + +typedef struct { + bool initialized; + bool active; + bool headers_delivered; + bool response_body_published; + bool upload_credit_active; + pocketjs_network_v1_handle_t operation; + pocketjs_network_v1_handle_t request_body; + pocketjs_network_v1_handle_t response_body; + uint32_t last_operation_generation; + uint32_t last_response_body_generation; + uint64_t core_operation_token; + uint64_t upload_body_generation; + uint64_t upload_pull_generation; + size_t upload_maximum_bytes; + uint8_t request_method[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_METHOD_BYTES]; + size_t request_method_length; + + pocketjs_net_esp_transport_t *transport; + bool transport_detached_awaiting_confirm; + pocketjs_net_http_client_core_storage_t core_storage; + pocketjs_net_http_client_core_t *core; + + bool event_pending; + pocketjs_net_http_client_event_t event; + pocketjs_net_esp_runtime_lease_state_t lease_state; + bool lease_descriptor_delivered; + pocketjs_network_v1_handle_t lease; + uint32_t last_lease_generation; + uint32_t lease_byte_length; + pocketjs_net_http_client_body_lease_t core_lease; +} pocketjs_net_esp_runtime_slot_t; + +typedef struct { + uint8_t url[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_URL_BYTES]; + size_t url_length; + uint8_t method[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_METHOD_BYTES]; + size_t method_length; + uint8_t header_bytes[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_HEADER_BYTES]; + size_t header_bytes_used; + pocketjs_net_http_client_header_t + headers[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_HEADERS]; + size_t header_count; + bool has_body; + bool tls_present; + bool tls_requested; + uint8_t tls_server_name[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_HOST_BYTES]; + size_t tls_server_name_length; + uint8_t tls_credential[128U]; + size_t tls_credential_length; + pocketjs_net_http_client_tls_policy_t tls_policy; + bool borrowed_input_present; + bool has_timeout_overrides; + bool has_limit_overrides; + pocketjs_network_v1_http_redirect_mode_t redirect_mode; + uint16_t max_redirects; + bool ref; +} pocketjs_net_esp_runtime_http_command_t; + +typedef struct { + pocketjs_network_v1_error_category_t category; + pocketjs_network_v1_error_code_t code; + const char *operation; + bool temporary; + int32_t cause_code; + bool has_cause_code; +} pocketjs_net_esp_runtime_error_t; + +struct pocketjs_net_esp_runtime { + uint64_t magic; + TaskHandle_t owner_task; + pocketjs_esp_guest_t *guest; + JSContext *context; + pocketjs_net_esp_runtime_phase_t phase; + uint32_t runtime_generation; + uint8_t plan_hash[POCKETJS_NETWORK_V1_PLAN_HASH_BYTES]; + pocketjs_network_v1_feature_id_t + feature_ids[POCKETJS_NET_ESP_RUNTIME_MAX_FEATURES]; + uint16_t feature_count; + uint16_t max_operations; + pocketjs_net_esp_runtime_limits_t limits; + uint64_t connect_timeout_us; + uint64_t headers_timeout_us; + uint64_t idle_timeout_us; + uint64_t total_timeout_us; + bool tls_enabled; + pocketjs_net_esp_tls_trust_source_t tls_trust_source; + uint8_t pinned_ca[POCKETJS_NET_ESP_TRANSPORT_MAX_PINNED_CA_PEM_BYTES + 1U]; + size_t pinned_ca_bytes; + pocketjs_net_esp_wall_clock_trusted_fn wall_clock_trusted; + void *wall_clock_context; + pocketjs_net_esp_runtime_wake_fn wake; + void *wake_context; + pocketjs_net_esp_runtime_permission_fn allow_endpoint; + void *permission_context; + + JSValue binding; + JSValue dispatcher; + pocketjs_net_esp_runtime_binding_state_t *binding_state; + bool dispatcher_registered; + bool binding_call_active; + bool service_call_active; + bool dispatcher_call_active; + bool permission_call_active; + bool shutdown_dispatch_drained; + uint32_t turn_events_remaining; + uint32_t turn_payload_remaining; + uint32_t turn_events_observed; + uint32_t turn_payload_observed; + uint64_t turn_last_sequence_observed; + uint16_t turn_last_poll_status; + uint64_t command_sequence; + uint64_t completion_sequence; + uint64_t service_turn; + size_t pump_cursor; + size_t completion_cursor; + uint32_t poison_flags; + + uint64_t requests_started; + uint64_t completions_delivered; + uint64_t leases_taken; + uint64_t leases_released; + uint64_t leases_cleaned_up; + uint64_t permission_checks; + + pocketjs_net_esp_runtime_http_command_t command_scratch; + uint16_t + latin1_scratch[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_RESPONSE_HEADER_BYTES]; + /* Sized to max_operations by create(); capacity never changes afterward. */ + pocketjs_net_esp_runtime_slot_t slots[]; +}; + +bool pocketjs_net_esp_runtime_is_owner( + const pocketjs_net_esp_runtime_t *runtime); +void pocketjs_net_esp_runtime_poison(pocketjs_net_esp_runtime_t *runtime, + uint32_t flag); +void pocketjs_net_esp_runtime_signal(pocketjs_net_esp_runtime_t *runtime); + +bool pocketjs_net_esp_runtime_validate_identity( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + bool consume_sequence, pocketjs_net_esp_runtime_error_t *out_error); + +bool pocketjs_net_esp_runtime_start_http( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + const pocketjs_net_esp_runtime_http_command_t *command, + pocketjs_net_esp_runtime_error_t *out_error); + +bool pocketjs_net_esp_runtime_cancel( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + pocketjs_net_esp_runtime_error_t *out_error); + +bool pocketjs_net_esp_runtime_grant_body_credit( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + uint32_t maximum_bytes, pocketjs_net_esp_runtime_error_t *out_error); + +bool pocketjs_net_esp_runtime_submit_body_chunk( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + const uint8_t *bytes, size_t length, + pocketjs_net_esp_runtime_error_t *out_error); + +bool pocketjs_net_esp_runtime_submit_body_end( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + pocketjs_net_esp_runtime_error_t *out_error); + +bool pocketjs_net_esp_runtime_submit_body_error( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, int32_t cause_code, + pocketjs_net_esp_runtime_error_t *out_error); + +bool pocketjs_net_esp_runtime_cancel_body( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + pocketjs_net_esp_runtime_error_t *out_error); + +bool pocketjs_net_esp_runtime_peek_event( + pocketjs_net_esp_runtime_t *runtime, + pocketjs_net_esp_runtime_slot_t **out_slot); + +/* -1 is Core poison/status failure, 0 is drained, and 1 is ready. Never takes. + */ +int pocketjs_net_esp_runtime_completion_readiness( + pocketjs_net_esp_runtime_t *runtime); + +bool pocketjs_net_esp_runtime_retire_nonlease_event( + pocketjs_net_esp_runtime_t *runtime, pocketjs_net_esp_runtime_slot_t *slot); + +bool pocketjs_net_esp_runtime_lease_take( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + pocketjs_network_v1_handle_t lease, uint32_t byte_length, + uint32_t *out_byte_length, pocketjs_net_esp_runtime_error_t *out_error); + +bool pocketjs_net_esp_runtime_lease_read( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + pocketjs_network_v1_handle_t lease, uint32_t offset, uint32_t maximum_bytes, + uint8_t *destination, size_t destination_length, uint32_t *out_copied, + pocketjs_net_esp_runtime_error_t *out_error); + +bool pocketjs_net_esp_runtime_lease_release( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + pocketjs_network_v1_handle_t lease, + pocketjs_net_esp_runtime_error_t *out_error); + +esp_err_t +pocketjs_net_esp_runtime_create_binding(pocketjs_net_esp_runtime_t *runtime); +void pocketjs_net_esp_runtime_revoke_binding( + pocketjs_net_esp_runtime_t *runtime); + +esp_err_t pocketjs_net_esp_runtime_call_dispatcher( + pocketjs_net_esp_runtime_t *runtime, + pocketjs_network_v1_service_turn_kind_t kind, uint32_t max_events, + uint32_t max_payload_bytes, + pocketjs_net_esp_runtime_service_result_t *out_result); diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/src/esp_runtime.c b/hosts/esp-idf/components/pocketjs_net_esp_runtime/src/esp_runtime.c new file mode 100644 index 00000000..2f35cb61 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/src/esp_runtime.c @@ -0,0 +1,1650 @@ +// SPDX-License-Identifier: MIT + +#include "pocketjs/net/esp_runtime.h" + +#include +#include + +#include "esp_timer.h" +#include "pocketjs/net/http_client_core_esp.h" +#include "runtime_contract.h" +#include "runtime_internal.h" + +_Static_assert(POCKETJS_NET_ESP_RUNTIME_MAX_REDIRECTS == + POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REDIRECTS, + "runtime and HTTP Core redirect ceilings must match"); + +static const pocketjs_net_esp_runtime_descriptor_t RUNTIME_DESCRIPTOR = { + .id = POCKETJS_NET_ESP_RUNTIME_ID, + .implementation_version = POCKETJS_NET_ESP_RUNTIME_IMPLEMENTATION_VERSION, + .experimental = true, + .advertises_public_capability = false, + .abi_major = POCKETJS_NETWORK_V1_ABI_MAJOR, + .abi_minor = POCKETJS_NETWORK_V1_ABI_MINOR, + .owner_only_quickjs = true, + .worker_or_callback_calls_quickjs = false, + .frozen_accessor_free_binding = true, + .exact_plan_handshake = true, + .endpoint_permission_rechecked = true, + .fixed_operation_pool = true, + .pocketjs_owned_native_buffer_floor_enforced = true, + .connection_reuse = true, + .bounded_connection_pool = true, + .exact_lease_ownership = true, + .explicit_three_phase_shutdown = true, + .plaintext_http = true, + .https_rejected_before_io = true, + .https_explicit_opt_in = true, + .exact_host_tls_profile = true, + .distinct_tls_errors = true, + .tls_close_notify = true, + .tls_close_notify_uses_operation_deadline = true, + .tls_close_notify_waits_for_peer = false, + .tls_provider_id = POCKETJS_NET_ESP_TLS_PROVIDER_ID, + .redirect_manual = true, + .redirect_error = true, + .redirect_follow = true, + .redirect_replayable_stream_body = false, + .guest_execution_guarded_dispatch = true, + .hidden_retry = false, + .hidden_auth = false, + .hidden_cookie_store = false, + .proxy = false, + .content_decoding = false, + .max_operations = POCKETJS_NET_ESP_RUNTIME_MAX_OPERATIONS, + .max_cached_connections = POCKETJS_NET_ESP_RUNTIME_MAX_OPERATIONS, + .max_redirects = POCKETJS_NET_ESP_RUNTIME_MAX_REDIRECTS, + .operation_slot_bytes = sizeof(pocketjs_net_esp_runtime_slot_t), + .validation_snapshot_bytes = + POCKETJS_NET_ESP_TRANSPORT_MAX_PINNED_CA_PEM_BYTES + 1U, +}; + +static const pocketjs_network_v1_handle_t ABSENT_HANDLE = {0U, 0U}; + +static bool checked_size_add(size_t left, size_t right, size_t *out) { + if (out == NULL || left > SIZE_MAX - right) { + return false; + } + *out = left + right; + return true; +} + +static bool checked_size_multiply(size_t left, size_t right, size_t *out) { + if (out == NULL || (right != 0U && left > SIZE_MAX / right)) { + return false; + } + *out = left * right; + return true; +} + +bool pocketjs_net_esp_runtime_required_native_buffer_bytes( + uint16_t max_operations, size_t *out_bytes) { + if (out_bytes == NULL || max_operations == 0U || + max_operations > POCKETJS_NET_ESP_RUNTIME_MAX_OPERATIONS) { + return false; + } + const pocketjs_net_esp_transport_descriptor_t *transport = + pocketjs_net_esp_transport_descriptor(); + if (transport == NULL || transport->pocketjs_owned_instance_bytes == 0U) { + return false; + } + size_t slots = 0U; + size_t persistent_transports = 0U; + size_t prefinal_transports = 0U; + size_t base = 0U; + if (!checked_size_multiply(sizeof(pocketjs_net_esp_runtime_slot_t), + max_operations, &slots) || + !checked_size_multiply(transport->pocketjs_owned_instance_bytes, + max_operations, &persistent_transports) || + !checked_size_multiply(transport->pocketjs_owned_instance_bytes, + max_operations - 1U, &prefinal_transports) || + !checked_size_add(sizeof(pocketjs_net_esp_runtime_t), slots, &base) || + !checked_size_add(base, sizeof(pocketjs_net_esp_runtime_binding_state_t), + &base)) { + return false; + } + size_t validation_peak = 0U; + if (!checked_size_add(prefinal_transports, + POCKETJS_NET_ESP_TRANSPORT_MAX_PINNED_CA_PEM_BYTES + 1U, + &validation_peak)) { + return false; + } + const size_t dynamic_peak = persistent_transports > validation_peak + ? persistent_transports + : validation_peak; + return checked_size_add(base, dynamic_peak, out_bytes); +} + +static void set_error(pocketjs_net_esp_runtime_error_t *error, + pocketjs_network_v1_error_category_t category, + pocketjs_network_v1_error_code_t code, + const char *operation, bool temporary) { + if (error == NULL) { + return; + } + *error = (pocketjs_net_esp_runtime_error_t){ + .category = category, + .code = code, + .operation = operation, + .temporary = temporary, + }; +} + +static bool valid_limit(pocketjs_net_esp_runtime_limit_range_t limit, + bool allow_zero_minimum) { + return limit.default_value != 0U && limit.hard != 0U && + (allow_zero_minimum || limit.minimum != 0U) && + limit.minimum <= limit.default_value && + limit.default_value <= limit.hard && + limit.hard <= POCKETJS_NET_ESP_RUNTIME_SEQUENCE_MAX; +} + +static bool +valid_tls_config_shape(const pocketjs_net_esp_runtime_config_t *config, + bool tls_enabled) { + if (!tls_enabled) { + return config->tls_trust_source == POCKETJS_NET_ESP_TLS_TRUST_DISABLED && + config->host_pinned_ca_pem == NULL && + config->host_pinned_ca_pem_bytes == 0U && + config->wall_clock_trusted == NULL && + config->wall_clock_context == NULL; + } + if (config->wall_clock_trusted == NULL) { + return false; + } + switch (config->tls_trust_source) { + case POCKETJS_NET_ESP_TLS_TRUST_CERTIFICATE_BUNDLE: + return config->host_pinned_ca_pem == NULL && + config->host_pinned_ca_pem_bytes == 0U; + case POCKETJS_NET_ESP_TLS_TRUST_HOST_PINNED_CA: + return config->host_pinned_ca_pem != NULL && + config->host_pinned_ca_pem_bytes != 0U && + config->host_pinned_ca_pem_bytes <= + POCKETJS_NET_ESP_TRANSPORT_MAX_PINNED_CA_PEM_BYTES; + default: + return false; + } +} + +static bool +valid_provider_selection(const pocketjs_net_esp_runtime_config_t *config, + bool tls_enabled) { + const pocketjs_net_esp_runtime_provider_selection_t *selection = + &config->providers; + if (selection->http_client_backend_id == NULL || + selection->net_driver_id == NULL || + strcmp(selection->http_client_backend_id, + POCKETJS_NET_HTTP_CLIENT_CORE_ID) != 0 || + strcmp(selection->net_driver_id, POCKETJS_NET_ESP_TRANSPORT_ID) != 0) { + return false; + } + if (tls_enabled) { + return selection->http_client_tls_source == + POCKETJS_NET_ESP_RUNTIME_TLS_SELECTION_PROVIDER && + selection->http_client_tls_id != NULL && + strcmp(selection->http_client_tls_id, + POCKETJS_NET_ESP_TLS_PROVIDER_ID) == 0; + } + return selection->http_client_tls_source == + POCKETJS_NET_ESP_RUNTIME_TLS_SELECTION_NONE && + selection->http_client_tls_id == NULL; +} + +static bool core_descriptor_compatible( + const pocketjs_net_http_client_core_descriptor_t *core) { + return core != NULL && core->id != NULL && + strcmp(core->id, POCKETJS_NET_HTTP_CLIENT_CORE_ID) == 0 && + core->experimental && core->plaintext_http && + core->https_fail_closed_before_io && core->https_explicit_opt_in && + core->owner_pumped && core->one_operation && + core->fixed_core_storage && core->headers_first && + core->explicit_body_credit && core->explicit_body_lease && + core->connection_reuse && core->bounded_connection_pool && + core->redirects_followed && core->redirect_manual && + core->redirect_error && core->redirect_fixed_body_replay && + !core->redirect_streaming_body_replay && + core->connect_error_candidate_fallback && !core->hidden_retry && + !core->hidden_auth && !core->hidden_cookie_store && !core->proxy && + !core->content_decoding && + core->cleanup_faults_separate_from_terminal && + core->poison_is_machine_readable && + core->explicit_shutdown_lifecycle && core->fixed_request_body && + core->streaming_request_body && core->chunked_request_body && + core->known_length_streaming_request_body && + !core->streaming_request_body_buffered_in_full && + core->instance_bytes == POCKETJS_NET_HTTP_CLIENT_CORE_INSTANCE_BYTES && + core->max_fixed_request_body_bytes == + POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_BODY_BYTES && + core->max_request_body_chunk_bytes == + POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES && + core->body_lease_bytes == + POCKETJS_NET_HTTP_CLIENT_CORE_BODY_LEASE_BYTES && + core->max_cached_connections == 1U; +} + +static bool transport_descriptor_compatible( + const pocketjs_net_esp_transport_descriptor_t *transport, + const pocketjs_net_http_client_core_descriptor_t *core, bool tls_enabled) { + bool compatible = + transport != NULL && core != NULL && transport->id != NULL && + strcmp(transport->id, POCKETJS_NET_ESP_TRANSPORT_ID) == 0 && + transport->implementation_version != NULL && transport->experimental && + transport->ipv4 && transport->asynchronous_raw_dns && + transport->stock_lwip_dns_callbacks_only && + transport->rejects_saturated_dns_candidate_prefix && + transport->dns_cancel_generation_cleanup && + !transport->synchronous_getaddrinfo_for_hostname && + transport->nonblocking_plain_tcp_steps && + transport->monotonic_deadlines && + transport->cancel_between_native_steps && + !transport->worker_or_callback_calls_quickjs && + transport->exact_one_terminal && transport->aba_safe_tokens && + transport->fixed_operation_pool && transport->fixed_completion_pool && + transport->fixed_payload_pool && + transport->pocketjs_owned_instance_bytes != 0U && + transport->max_connections != 0U && transport->max_operations != 0U && + transport->completion_capacity != 0U && + transport->max_dns_candidates != 0U && + transport->max_write_bytes >= core->max_request_body_chunk_bytes && + transport->read_lease_bytes >= core->body_lease_bytes; + if (!compatible || !tls_enabled) { + return compatible; + } + return transport->tls_compiled && transport->tls_1_2_only && + transport->host_trust && transport->host_pinned_ca && + transport->hostname_verification && transport->distinct_tls_errors && + transport->sni && transport->trusted_wall_clock_required && + !transport->plaintext_fallback && !transport->renegotiation && + !transport->early_data && transport->tls_close_notify && + transport->tls_close_notify_uses_operation_deadline && + !transport->tls_close_notify_waits_for_peer; +} + +static bool compiled_descriptors_compatible(bool tls_enabled) { + const pocketjs_net_http_client_core_descriptor_t *core = + pocketjs_net_http_client_core_descriptor(); + const pocketjs_net_esp_transport_descriptor_t *transport = + pocketjs_net_esp_transport_descriptor(); + return core_descriptor_compatible(core) && + transport_descriptor_compatible(transport, core, tls_enabled); +} + +static bool public_descriptors_admitted(bool tls_enabled) { + const pocketjs_net_http_client_core_descriptor_t *core = + pocketjs_net_http_client_core_descriptor(); + const pocketjs_net_esp_transport_descriptor_t *transport = + pocketjs_net_esp_transport_descriptor(); + bool admitted = core_descriptor_compatible(core) && + transport_descriptor_compatible(transport, core, tls_enabled); + admitted &= RUNTIME_DESCRIPTOR.advertises_public_capability; + admitted &= core != NULL && core->advertises_public_capability; + admitted &= transport != NULL && transport->advertises_public_capability; + admitted &= transport != NULL && transport->complete_dns_candidate_set; + admitted &= transport != NULL && transport->dns_cancel_generation_cleanup; + admitted &= transport != NULL && transport->bounded_native_step_wall_time; + admitted &= + transport != NULL && transport->bounded_lwip_dns_callback_allocation; + admitted &= transport != NULL && transport->bounded_lwip_socket_allocation; + if (tls_enabled) { + admitted &= RUNTIME_DESCRIPTOR.distinct_tls_errors; + admitted &= transport != NULL && transport->nonblocking_tls_steps; + admitted &= + transport != NULL && !transport->esp_tls_numeric_getaddrinfo_internal; + admitted &= transport != NULL && transport->bounded_esp_tls_allocation; + admitted &= + transport != NULL && transport->bounded_mbedtls_x509_parse_allocation; + } + return admitted; +} + +static bool valid_config(const pocketjs_net_esp_runtime_config_t *config) { + if (config == NULL || config->guest == NULL || + config->runtime_generation == 0U || config->max_operations == 0U || + config->max_operations > POCKETJS_NET_ESP_RUNTIME_MAX_OPERATIONS || + config->max_operations != config->limits.max_operations.default_value || + config->limits.max_operations.hard > + POCKETJS_NET_ESP_RUNTIME_MAX_OPERATIONS || + config->connect_timeout_us == 0U || config->headers_timeout_us == 0U || + config->idle_timeout_us == 0U || config->total_timeout_us == 0U || + config->allow_endpoint == NULL || + !pocketjs_net_esp_runtime_feature_projection_valid( + config->feature_ids, config->feature_count)) { + return false; + } + const bool tls_enabled = pocketjs_net_esp_runtime_feature_projection_has_tls( + config->feature_ids, config->feature_count); + if ((config->admission != POCKETJS_NET_ESP_RUNTIME_ADMISSION_TEST_ONLY && + config->admission != POCKETJS_NET_ESP_RUNTIME_ADMISSION_PUBLIC) || + !valid_provider_selection(config, tls_enabled) || + !compiled_descriptors_compatible(tls_enabled) || + !valid_tls_config_shape(config, tls_enabled) || + (config->admission == POCKETJS_NET_ESP_RUNTIME_ADMISSION_PUBLIC && + !public_descriptors_admitted(tls_enabled))) { + return false; + } + size_t required_native_bytes = 0U; + if (config->limits.native_buffer_bytes.default_value > SIZE_MAX || + config->limits.native_buffer_bytes.hard > SIZE_MAX || + !pocketjs_net_esp_runtime_required_native_buffer_bytes( + config->max_operations, &required_native_bytes) || + config->limits.native_buffer_bytes.default_value < + required_native_bytes || + config->limits.native_buffer_bytes.hard < required_native_bytes) { + return false; + } + return valid_limit(config->limits.buffered_body_bytes, true) && + valid_limit(config->limits.header_bytes, true) && + valid_limit(config->limits.max_body_chunk_bytes, true) && + valid_limit(config->limits.max_operations, false) && + valid_limit(config->limits.native_buffer_bytes, true) && + config->limits.header_bytes.hard <= + POCKETJS_NET_HTTP_CLIENT_CORE_MAX_RESPONSE_HEADER_BYTES && + config->limits.max_body_chunk_bytes.hard <= + POCKETJS_NET_HTTP_CLIENT_CORE_BODY_LEASE_BYTES; +} + +const pocketjs_net_esp_runtime_descriptor_t * +pocketjs_net_esp_runtime_descriptor(void) { + return &RUNTIME_DESCRIPTOR; +} + +bool pocketjs_net_esp_runtime_is_owner( + const pocketjs_net_esp_runtime_t *runtime) { + return runtime != NULL && runtime->magic == POCKETJS_NET_ESP_RUNTIME_MAGIC && + runtime->owner_task == xTaskGetCurrentTaskHandle(); +} + +void pocketjs_net_esp_runtime_poison(pocketjs_net_esp_runtime_t *runtime, + uint32_t flag) { + if (runtime != NULL) { + runtime->poison_flags |= flag; + } +} + +void pocketjs_net_esp_runtime_signal(pocketjs_net_esp_runtime_t *runtime) { + if (runtime != NULL && runtime->wake != NULL) { + runtime->wake(runtime->wake_context); + } +} + +static void transport_wake(void *context) { + pocketjs_net_esp_runtime_signal(context); +} + +static bool +core_permission(void *context, + const pocketjs_net_http_client_endpoint_t *endpoint) { + pocketjs_net_esp_runtime_t *runtime = context; + if (runtime->permission_call_active) { + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_CORE); + return false; + } + ++runtime->permission_checks; + runtime->permission_call_active = true; + const bool allowed = + runtime->allow_endpoint(runtime->permission_context, endpoint); + runtime->permission_call_active = false; + return allowed; +} + +static bool initialize_slot(pocketjs_net_esp_runtime_t *runtime, + pocketjs_net_esp_runtime_slot_t *slot, + pocketjs_net_esp_runtime_error_t *out_error) { + if (slot->initialized) { + return true; + } + pocketjs_net_esp_transport_config_t transport_config = { + .wake = transport_wake, + .wake_context = runtime, + .tls_trust_source = runtime->tls_trust_source, + .host_pinned_ca_pem = + runtime->tls_trust_source == POCKETJS_NET_ESP_TLS_TRUST_HOST_PINNED_CA + ? runtime->pinned_ca + : NULL, + .host_pinned_ca_pem_bytes = runtime->pinned_ca_bytes, + .wall_clock_trusted = runtime->wall_clock_trusted, + .wall_clock_context = runtime->wall_clock_context, + }; + esp_err_t create_result = + pocketjs_net_esp_transport_create(&transport_config, &slot->transport); + if (create_result != ESP_OK) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_RESOURCE_LIMIT, "http.fetch", false); + return false; + } + pocketjs_net_http_client_core_config_t core_config = { + .transport_ops = pocketjs_net_http_client_core_esp_transport_ops(), + .transport_context = slot->transport, + .allow_endpoint = core_permission, + .permission_context = runtime, + .connect_timeout_us = runtime->connect_timeout_us, + .headers_timeout_us = runtime->headers_timeout_us, + .idle_timeout_us = runtime->idle_timeout_us, + .total_timeout_us = runtime->total_timeout_us, + .allow_https = runtime->tls_enabled, + .enable_connection_reuse = true, + .response_header_bytes_limit = + (size_t)runtime->limits.header_bytes.default_value, + }; + pocketjs_net_http_client_start_result_t init_result = + pocketjs_net_http_client_core_init(&slot->core_storage, &core_config, + &slot->core); + if (init_result != POCKETJS_NET_HTTP_CLIENT_START_OK) { + pocketjs_net_esp_transport_begin_shutdown(slot->transport); + if (pocketjs_net_esp_transport_is_quiescent(slot->transport)) { + (void)pocketjs_net_esp_transport_destroy(slot->transport); + } + slot->transport = NULL; + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_RESOURCE_LIMIT, "http.fetch", false); + return false; + } + slot->initialized = true; + return true; +} + +static void observe_core_status(pocketjs_net_esp_runtime_t *runtime, + pocketjs_net_esp_runtime_slot_t *slot) { + pocketjs_net_http_client_core_status_t status = {0}; + if (!pocketjs_net_http_client_core_get_status(slot->core, &status) || + status.poisoned) { + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_CORE); + } +} + +static pocketjs_net_esp_runtime_slot_t * +slot_for_operation(pocketjs_net_esp_runtime_t *runtime, + pocketjs_network_v1_handle_t operation) { + if (!pocketjs_net_esp_runtime_live_handle(operation) || operation.id == 0U || + operation.id > runtime->max_operations) { + return NULL; + } + pocketjs_net_esp_runtime_slot_t *slot = &runtime->slots[operation.id - 1U]; + return slot->active && pocketjs_net_esp_runtime_same_handle(slot->operation, + operation) + ? slot + : NULL; +} + +bool pocketjs_net_esp_runtime_validate_identity( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + bool consume_sequence, pocketjs_net_esp_runtime_error_t *out_error) { + if (!pocketjs_net_esp_runtime_is_owner(runtime) || identity == NULL || + identity->runtime_generation != runtime->runtime_generation || + identity->resource.id != POCKETJS_NET_ESP_RUNTIME_HTTP_RESOURCE_ID || + identity->resource.generation != + POCKETJS_NET_ESP_RUNTIME_HTTP_RESOURCE_GENERATION || + !pocketjs_net_esp_runtime_live_handle(identity->operation) || + (!pocketjs_network_v1_handle_is_absent(identity->body) && + !pocketjs_net_esp_runtime_live_handle(identity->body)) || + identity->command_sequence == 0U || + identity->command_sequence > POCKETJS_NETWORK_V1_SEQUENCE_MAX || + identity->command_sequence <= runtime->command_sequence) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_INVALID_STATE, "network.dispatch", + false); + return false; + } + if (consume_sequence) { + runtime->command_sequence = identity->command_sequence; + } + return true; +} + +static void clear_operation(pocketjs_net_esp_runtime_slot_t *slot) { + slot->active = false; + slot->headers_delivered = false; + slot->response_body_published = false; + slot->upload_credit_active = false; + slot->operation = ABSENT_HANDLE; + slot->request_body = ABSENT_HANDLE; + slot->response_body = ABSENT_HANDLE; + slot->core_operation_token = 0U; + slot->upload_body_generation = 0U; + slot->upload_pull_generation = 0U; + slot->upload_maximum_bytes = 0U; + slot->event_pending = false; + slot->lease_state = POCKETJS_NET_ESP_RUNTIME_LEASE_NONE; + slot->lease_descriptor_delivered = false; + slot->lease = ABSENT_HANDLE; + slot->lease_byte_length = 0U; +} + +static void map_start_error(pocketjs_net_http_client_start_result_t result, + pocketjs_net_esp_runtime_error_t *out_error) { + pocketjs_network_v1_error_code_t code = + POCKETJS_NETWORK_V1_ERROR_INVALID_STATE; + switch (result) { + case POCKETJS_NET_HTTP_CLIENT_START_BUSY: + code = POCKETJS_NETWORK_V1_ERROR_BUSY; + break; + case POCKETJS_NET_HTTP_CLIENT_START_UNSUPPORTED_TLS: + code = POCKETJS_NETWORK_V1_ERROR_UNSUPPORTED; + break; + case POCKETJS_NET_HTTP_CLIENT_START_LIMIT_EXCEEDED: + case POCKETJS_NET_HTTP_CLIENT_START_TOKEN_EXHAUSTED: + code = POCKETJS_NETWORK_V1_ERROR_RESOURCE_LIMIT; + break; + case POCKETJS_NET_HTTP_CLIENT_START_FORBIDDEN_REQUEST: + code = POCKETJS_NETWORK_V1_ERROR_PERMISSION_DENIED; + break; + case POCKETJS_NET_HTTP_CLIENT_START_INVALID_URL: + case POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT: + code = POCKETJS_NETWORK_V1_ERROR_HTTP_PROTOCOL_ERROR; + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_PROTOCOL, code, + "http.fetch", false); + return; + case POCKETJS_NET_HTTP_CLIENT_START_POISONED: + case POCKETJS_NET_HTTP_CLIENT_START_SHUTTING_DOWN: + case POCKETJS_NET_HTTP_CLIENT_START_REENTRANT: + case POCKETJS_NET_HTTP_CLIENT_START_OK: + default: + break; + } + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, code, + "http.fetch", false); +} + +bool pocketjs_net_esp_runtime_start_http( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + const pocketjs_net_esp_runtime_http_command_t *command, + pocketjs_net_esp_runtime_error_t *out_error) { + if (!pocketjs_net_esp_runtime_validate_identity(runtime, identity, true, + out_error)) { + return false; + } + if (runtime->phase != POCKETJS_NET_ESP_RUNTIME_PHASE_RUNNING || + runtime->poison_flags != 0U || command == NULL || + identity->operation.id > runtime->max_operations) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_INVALID_STATE, "http.fetch", false); + return false; + } + if (command->has_body != + pocketjs_net_esp_runtime_live_handle(identity->body)) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_PROTOCOL, + POCKETJS_NETWORK_V1_ERROR_HTTP_PROTOCOL_ERROR, "http.fetch", + false); + return false; + } + const uint64_t request_header_bytes = + (uint64_t)command->header_bytes_used + + (uint64_t)command->header_count * UINT64_C(4); + if (request_header_bytes > runtime->limits.header_bytes.default_value) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_RESOURCE_LIMIT, "http.fetch", false); + return false; + } + pocketjs_net_esp_runtime_slot_t *slot = + &runtime->slots[identity->operation.id - 1U]; + if (slot->active) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_BUSY, "http.fetch", false); + return false; + } + if (slot->last_operation_generation == UINT32_MAX) { + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_SEQUENCE); + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_RESOURCE_LIMIT, "http.fetch", false); + return false; + } + if (identity->operation.generation <= slot->last_operation_generation) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_INVALID_STATE, "http.fetch", false); + return false; + } + /* + * A valid formal slot generation is consumed even by pre-I/O refusal. + * Guest validation may consume generations without dispatching them, so the + * Host requires strict monotonicity but does not require contiguity. + */ + slot->last_operation_generation = identity->operation.generation; + if (slot->last_response_body_generation == UINT32_MAX || + slot->last_lease_generation == UINT32_MAX) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_RESOURCE_LIMIT, "http.fetch", false); + return false; + } + if ((command->tls_requested && !runtime->tls_enabled) || + command->borrowed_input_present || command->has_timeout_overrides || + command->has_limit_overrides) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_UNSUPPORTED, "http.fetch", false); + return false; + } + if (!initialize_slot(runtime, slot, out_error)) { + return false; + } + const uint64_t operation_token = + ((uint64_t)identity->operation.generation << 32U) | + (uint64_t)identity->operation.id; + if (operation_token == 0U) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_RESOURCE_LIMIT, "http.fetch", false); + return false; + } + + slot->active = true; + slot->operation = identity->operation; + slot->request_body = identity->body; + slot->response_body = ABSENT_HANDLE; + slot->core_operation_token = operation_token; + memcpy(slot->request_method, command->method, command->method_length); + slot->request_method_length = command->method_length; + + pocketjs_net_http_client_request_t request = { + .operation_token = operation_token, + .url = {.data = command->url, .length = command->url_length}, + .method = {.data = command->method, .length = command->method_length}, + .headers = command->headers, + .header_count = command->header_count, + .body_kind = command->has_body + ? POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_STREAMING + : POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_NONE, + .streaming_content_length_known = false, + .streaming_content_length = 0U, + .redirect_mode = + command->redirect_mode == POCKETJS_NETWORK_V1_HTTP_REDIRECT_FOLLOW + ? POCKETJS_NET_HTTP_CLIENT_REDIRECT_FOLLOW + : command->redirect_mode == POCKETJS_NETWORK_V1_HTTP_REDIRECT_ERROR + ? POCKETJS_NET_HTTP_CLIENT_REDIRECT_ERROR + : POCKETJS_NET_HTTP_CLIENT_REDIRECT_MANUAL, + .max_redirects = command->max_redirects, + .tls = command->tls_present ? &command->tls_policy : NULL, + }; + const uint64_t now_us = (uint64_t)esp_timer_get_time(); + pocketjs_net_http_client_start_result_t result = + pocketjs_net_http_client_core_start(slot->core, &request, now_us); + observe_core_status(runtime, slot); + if (result != POCKETJS_NET_HTTP_CLIENT_START_OK) { + clear_operation(slot); + map_start_error(result, out_error); + return false; + } + ++runtime->requests_started; + pocketjs_net_esp_runtime_signal(runtime); + return true; +} + +static bool current_body_matches(const pocketjs_net_esp_runtime_slot_t *slot, + pocketjs_network_v1_handle_t body) { + return pocketjs_net_esp_runtime_same_handle( + slot->headers_delivered ? slot->response_body : slot->request_body, body); +} + +bool pocketjs_net_esp_runtime_cancel( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + pocketjs_net_esp_runtime_error_t *out_error) { + if (!pocketjs_net_esp_runtime_validate_identity(runtime, identity, true, + out_error)) { + return false; + } + pocketjs_net_esp_runtime_slot_t *slot = + slot_for_operation(runtime, identity->operation); + if (slot == NULL || !current_body_matches(slot, identity->body)) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_INVALID_STATE, "http.fetch.cancel", + false); + return false; + } + if (!pocketjs_net_http_client_core_abort(slot->core, + slot->core_operation_token)) { + pocketjs_net_http_client_core_status_t status = {0}; + if (!pocketjs_net_http_client_core_get_status(slot->core, &status) || + !status.request_active || + status.operation_token != slot->core_operation_token) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_INVALID_STATE, "http.fetch.cancel", + false); + return false; + } + } + observe_core_status(runtime, slot); + pocketjs_net_esp_runtime_signal(runtime); + return true; +} + +bool pocketjs_net_esp_runtime_grant_body_credit( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + uint32_t maximum_bytes, pocketjs_net_esp_runtime_error_t *out_error) { + if (!pocketjs_net_esp_runtime_validate_identity(runtime, identity, true, + out_error)) { + return false; + } + pocketjs_net_esp_runtime_slot_t *slot = + slot_for_operation(runtime, identity->operation); + const uint64_t selected = runtime->limits.max_body_chunk_bytes.default_value; + if (slot == NULL || !slot->headers_delivered || + !pocketjs_net_esp_runtime_same_handle(slot->response_body, + identity->body) || + maximum_bytes == 0U || maximum_bytes > selected || + slot->last_lease_generation == UINT32_MAX || + !pocketjs_net_http_client_core_grant_body_credit( + slot->core, slot->core_operation_token, maximum_bytes)) { + set_error( + out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + maximum_bytes > selected || + (slot != NULL && slot->last_lease_generation == UINT32_MAX) + ? POCKETJS_NETWORK_V1_ERROR_RESOURCE_LIMIT + : POCKETJS_NETWORK_V1_ERROR_INVALID_STATE, + "http.body.pull", false); + return false; + } + pocketjs_net_esp_runtime_signal(runtime); + return true; +} + +static bool +upload_command_slot(pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + pocketjs_net_esp_runtime_slot_t **out_slot, + pocketjs_net_esp_runtime_error_t *out_error) { + if (!pocketjs_net_esp_runtime_validate_identity(runtime, identity, true, + out_error)) { + return false; + } + pocketjs_net_esp_runtime_slot_t *slot = + slot_for_operation(runtime, identity->operation); + if (slot == NULL || slot->headers_delivered || !slot->upload_credit_active || + !pocketjs_net_esp_runtime_same_handle(slot->request_body, + identity->body)) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_INVALID_STATE, "http.fetch.upload", + false); + return false; + } + *out_slot = slot; + return true; +} + +bool pocketjs_net_esp_runtime_submit_body_chunk( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + const uint8_t *bytes, size_t length, + pocketjs_net_esp_runtime_error_t *out_error) { + pocketjs_net_esp_runtime_slot_t *slot = NULL; + if (!upload_command_slot(runtime, identity, &slot, out_error)) { + return false; + } + if (bytes == NULL || length == 0U || length > slot->upload_maximum_bytes || + !pocketjs_net_http_client_core_submit_request_body_chunk( + slot->core, slot->core_operation_token, slot->upload_body_generation, + slot->upload_pull_generation, bytes, length)) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + length > slot->upload_maximum_bytes + ? POCKETJS_NETWORK_V1_ERROR_RESOURCE_LIMIT + : POCKETJS_NETWORK_V1_ERROR_INVALID_STATE, + "http.fetch.upload", false); + return false; + } + slot->upload_credit_active = false; + pocketjs_net_esp_runtime_signal(runtime); + return true; +} + +bool pocketjs_net_esp_runtime_submit_body_end( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + pocketjs_net_esp_runtime_error_t *out_error) { + pocketjs_net_esp_runtime_slot_t *slot = NULL; + if (!upload_command_slot(runtime, identity, &slot, out_error)) { + return false; + } + if (!pocketjs_net_http_client_core_submit_request_body_end( + slot->core, slot->core_operation_token, slot->upload_body_generation, + slot->upload_pull_generation)) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_INVALID_STATE, "http.fetch.upload", + false); + return false; + } + slot->upload_credit_active = false; + pocketjs_net_esp_runtime_signal(runtime); + return true; +} + +bool pocketjs_net_esp_runtime_submit_body_error( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, int32_t cause_code, + pocketjs_net_esp_runtime_error_t *out_error) { + pocketjs_net_esp_runtime_slot_t *slot = NULL; + if (!upload_command_slot(runtime, identity, &slot, out_error)) { + return false; + } + if (!pocketjs_net_http_client_core_submit_request_body_error( + slot->core, slot->core_operation_token, slot->upload_body_generation, + slot->upload_pull_generation, cause_code)) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_INVALID_STATE, "http.fetch.upload", + false); + return false; + } + slot->upload_credit_active = false; + pocketjs_net_esp_runtime_signal(runtime); + return true; +} + +bool pocketjs_net_esp_runtime_cancel_body( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + pocketjs_net_esp_runtime_error_t *out_error) { + return pocketjs_net_esp_runtime_cancel(runtime, identity, out_error); +} + +static bool response_has_body(const pocketjs_net_esp_runtime_slot_t *slot, + unsigned status) { + const bool head = + slot->request_method_length == 4U && + (slot->request_method[0] == 'H' || slot->request_method[0] == 'h') && + (slot->request_method[1] == 'E' || slot->request_method[1] == 'e') && + (slot->request_method[2] == 'A' || slot->request_method[2] == 'a') && + (slot->request_method[3] == 'D' || slot->request_method[3] == 'd'); + return !head && status != 204U && status != 205U && status != 304U; +} + +static bool prepare_event(pocketjs_net_esp_runtime_t *runtime, + pocketjs_net_esp_runtime_slot_t *slot) { + if (slot->event.type == POCKETJS_NET_HTTP_CLIENT_EVENT_REQUEST_BODY_PULL) { + const size_t selected = + (size_t)runtime->limits.max_body_chunk_bytes.default_value; + if (slot->event.detail.request_body_pull.maximum_bytes > selected) { + slot->event.detail.request_body_pull.maximum_bytes = selected; + } + } else if (slot->event.type == + POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS) { + if (response_has_body(slot, slot->event.detail.response.status_code)) { + if (slot->last_response_body_generation == UINT32_MAX) { + pocketjs_net_esp_runtime_poison( + runtime, POCKETJS_NET_ESP_RUNTIME_POISON_SEQUENCE); + return false; + } + ++slot->last_response_body_generation; + slot->response_body = (pocketjs_network_v1_handle_t){ + .id = slot->operation.id, + .generation = slot->last_response_body_generation, + }; + slot->response_body_published = true; + } else { + slot->response_body = ABSENT_HANDLE; + slot->response_body_published = false; + } + } else if (slot->event.type == POCKETJS_NET_HTTP_CLIENT_EVENT_BODY) { + if (slot->last_lease_generation == UINT32_MAX) { + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_SEQUENCE); + return false; + } + ++slot->last_lease_generation; + slot->lease = (pocketjs_network_v1_handle_t){ + .id = slot->operation.id, + .generation = slot->last_lease_generation, + }; + slot->lease_byte_length = (uint32_t)slot->event.detail.body.byte_count; + slot->core_lease = slot->event.detail.body.lease; + slot->lease_state = POCKETJS_NET_ESP_RUNTIME_LEASE_QUEUED; + slot->lease_descriptor_delivered = false; + } + return true; +} + +static bool +abandon_poisoned_pending_event(pocketjs_net_esp_runtime_t *runtime, + pocketjs_net_esp_runtime_slot_t *slot) { + if (runtime->phase == POCKETJS_NET_ESP_RUNTIME_PHASE_RUNNING || + !slot->event_pending || slot->transport != NULL || + slot->transport_detached_awaiting_confirm) { + return false; + } + pocketjs_net_http_client_core_status_t status = {0}; + if (!pocketjs_net_http_client_core_get_status(slot->core, &status) || + !status.shutdown_requested || !status.poisoned || + !pocketjs_net_http_client_core_abandon_event_after_transport_shutdown( + slot->core, slot->event.sequence)) { + return false; + } + + const pocketjs_net_http_client_event_type_t type = slot->event.type; + if (type == POCKETJS_NET_HTTP_CLIENT_EVENT_BODY && + slot->lease_state != POCKETJS_NET_ESP_RUNTIME_LEASE_NONE && + slot->lease_state != POCKETJS_NET_ESP_RUNTIME_LEASE_RELEASED) { + ++runtime->leases_cleaned_up; + } + slot->event_pending = false; + memset(&slot->event, 0, sizeof(slot->event)); + slot->lease_state = POCKETJS_NET_ESP_RUNTIME_LEASE_NONE; + slot->lease_descriptor_delivered = false; + slot->lease = ABSENT_HANDLE; + slot->lease_byte_length = 0U; + slot->core_lease = (pocketjs_net_http_client_body_lease_t){0}; + if (type == POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE || + type == POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR) { + clear_operation(slot); + } + return true; +} + +static bool cleanup_pending_event(pocketjs_net_esp_runtime_t *runtime, + pocketjs_net_esp_runtime_slot_t *slot) { + if (!slot->event_pending) { + return true; + } + const pocketjs_net_http_client_event_type_t type = slot->event.type; + if (type == POCKETJS_NET_HTTP_CLIENT_EVENT_BODY && + slot->lease_state != POCKETJS_NET_ESP_RUNTIME_LEASE_RELEASED) { + const pocketjs_net_http_client_body_lease_t lease = + slot->lease_state == POCKETJS_NET_ESP_RUNTIME_LEASE_NONE + ? slot->event.detail.body.lease + : slot->core_lease; + if (!pocketjs_net_http_client_core_release_body_lease(slot->core, lease)) { + (void)pocketjs_net_http_client_core_report_host_event_retire_failure( + slot->core, slot->event.sequence); + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_LEASE); + return abandon_poisoned_pending_event(runtime, slot); + } + slot->core_lease = lease; + slot->lease_state = POCKETJS_NET_ESP_RUNTIME_LEASE_RELEASED; + ++runtime->leases_cleaned_up; + } + if (!pocketjs_net_http_client_core_retire_event(slot->core, + slot->event.sequence)) { + (void)pocketjs_net_http_client_core_report_host_event_retire_failure( + slot->core, slot->event.sequence); + pocketjs_net_esp_runtime_poison(runtime, + type == POCKETJS_NET_HTTP_CLIENT_EVENT_BODY + ? POCKETJS_NET_ESP_RUNTIME_POISON_LEASE + : POCKETJS_NET_ESP_RUNTIME_POISON_CORE); + return abandon_poisoned_pending_event(runtime, slot); + } + slot->event_pending = false; + slot->lease_state = POCKETJS_NET_ESP_RUNTIME_LEASE_NONE; + slot->lease_descriptor_delivered = false; + slot->lease = ABSENT_HANDLE; + slot->lease_byte_length = 0U; + if (type == POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE || + type == POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR) { + clear_operation(slot); + } + return true; +} + +bool pocketjs_net_esp_runtime_peek_event( + pocketjs_net_esp_runtime_t *runtime, + pocketjs_net_esp_runtime_slot_t **out_slot) { + if (!pocketjs_net_esp_runtime_is_owner(runtime) || out_slot == NULL) { + return false; + } + *out_slot = NULL; + for (size_t offset = 0U; offset < runtime->max_operations; ++offset) { + const size_t index = + (runtime->completion_cursor + offset) % runtime->max_operations; + pocketjs_net_esp_runtime_slot_t *slot = &runtime->slots[index]; + if (!slot->initialized || !slot->active) { + continue; + } + if (!slot->event_pending) { + if (!pocketjs_net_http_client_core_take_event(slot->core, &slot->event)) { + continue; + } + slot->event_pending = true; + if (slot->event.operation_token != slot->core_operation_token || + !prepare_event(runtime, slot)) { + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_CORE); + (void)cleanup_pending_event(runtime, slot); + return false; + } + } + if (slot->event.type == POCKETJS_NET_HTTP_CLIENT_EVENT_BODY && + slot->lease_descriptor_delivered) { + continue; + } + runtime->completion_cursor = (index + 1U) % runtime->max_operations; + *out_slot = slot; + return true; + } + return false; +} + +int pocketjs_net_esp_runtime_completion_readiness( + pocketjs_net_esp_runtime_t *runtime) { + if (!pocketjs_net_esp_runtime_is_owner(runtime)) { + return -1; + } + for (size_t index = 0U; index < runtime->max_operations; ++index) { + pocketjs_net_esp_runtime_slot_t *slot = &runtime->slots[index]; + if (!slot->initialized) { + continue; + } + if (slot->transport_detached_awaiting_confirm) { + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_SHUTDOWN); + return -1; + } + pocketjs_net_http_client_core_status_t status = {0}; + if (!pocketjs_net_http_client_core_get_status(slot->core, &status) || + status.poisoned) { + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_CORE); + return -1; + } + if (slot->event_pending || status.event_outstanding) { + return 1; + } + } + return 0; +} + +bool pocketjs_net_esp_runtime_retire_nonlease_event( + pocketjs_net_esp_runtime_t *runtime, + pocketjs_net_esp_runtime_slot_t *slot) { + if (!pocketjs_net_esp_runtime_is_owner(runtime) || slot == NULL || + !slot->event_pending || + slot->event.type == POCKETJS_NET_HTTP_CLIENT_EVENT_BODY) { + return false; + } + const pocketjs_net_http_client_event_type_t type = slot->event.type; + if (type == POCKETJS_NET_HTTP_CLIENT_EVENT_REQUEST_BODY_PULL) { + slot->upload_body_generation = + slot->event.detail.request_body_pull.body_generation; + slot->upload_pull_generation = + slot->event.detail.request_body_pull.pull_generation; + slot->upload_maximum_bytes = + slot->event.detail.request_body_pull.maximum_bytes; + } + if (!pocketjs_net_http_client_core_retire_event(slot->core, + slot->event.sequence)) { + (void)pocketjs_net_http_client_core_report_host_event_retire_failure( + slot->core, slot->event.sequence); + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_CORE); + return false; + } + slot->event_pending = false; + if (type == POCKETJS_NET_HTTP_CLIENT_EVENT_REQUEST_BODY_PULL) { + slot->upload_credit_active = true; + } else if (type == POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS) { + slot->headers_delivered = true; + slot->upload_credit_active = false; + } else if (type == POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE || + type == POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR) { + clear_operation(slot); + } + return true; +} + +static bool lease_slot(pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + pocketjs_network_v1_handle_t lease, + pocketjs_net_esp_runtime_slot_t **out_slot, + pocketjs_net_esp_runtime_error_t *out_error) { + if (!pocketjs_net_esp_runtime_validate_identity(runtime, identity, true, + out_error)) { + return false; + } + pocketjs_net_esp_runtime_slot_t *slot = + slot_for_operation(runtime, identity->operation); + if (slot == NULL || !slot->event_pending || + slot->event.type != POCKETJS_NET_HTTP_CLIENT_EVENT_BODY || + !pocketjs_net_esp_runtime_same_handle(slot->response_body, + identity->body) || + !pocketjs_net_esp_runtime_same_handle(slot->lease, lease)) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_INVALID_STATE, "http.body.lease", + false); + return false; + } + *out_slot = slot; + return true; +} + +bool pocketjs_net_esp_runtime_lease_take( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + pocketjs_network_v1_handle_t lease, uint32_t byte_length, + uint32_t *out_byte_length, pocketjs_net_esp_runtime_error_t *out_error) { + pocketjs_net_esp_runtime_slot_t *slot = NULL; + if (out_byte_length == NULL || + !lease_slot(runtime, identity, lease, &slot, out_error) || + slot->lease_state != POCKETJS_NET_ESP_RUNTIME_LEASE_QUEUED || + !slot->lease_descriptor_delivered || + byte_length != slot->lease_byte_length) { + if (out_error != NULL && out_error->code == 0U) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_INVALID_STATE, "http.body.lease", + false); + } + return false; + } + slot->lease_state = POCKETJS_NET_ESP_RUNTIME_LEASE_TAKEN; + *out_byte_length = slot->lease_byte_length; + ++runtime->leases_taken; + return true; +} + +bool pocketjs_net_esp_runtime_lease_read( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + pocketjs_network_v1_handle_t lease, uint32_t offset, uint32_t maximum_bytes, + uint8_t *destination, size_t destination_length, uint32_t *out_copied, + pocketjs_net_esp_runtime_error_t *out_error) { + pocketjs_net_esp_runtime_slot_t *slot = NULL; + if (out_copied == NULL || destination == NULL || + !lease_slot(runtime, identity, lease, &slot, out_error) || + slot->lease_state != POCKETJS_NET_ESP_RUNTIME_LEASE_TAKEN || + maximum_bytes == 0U || (size_t)maximum_bytes != destination_length || + offset > slot->lease_byte_length || + maximum_bytes > slot->lease_byte_length - offset) { + if (out_error != NULL && out_error->code == 0U) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_INVALID_STATE, "http.body.lease", + false); + } + return false; + } + const uint8_t *bytes = NULL; + size_t byte_length = 0U; + if (!pocketjs_net_http_client_core_body_lease_view( + slot->core, slot->core_lease, &bytes, &byte_length) || + byte_length != slot->lease_byte_length) { + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_LEASE); + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_INVALID_STATE, "http.body.lease", + false); + return false; + } + memcpy(destination, bytes + offset, maximum_bytes); + *out_copied = maximum_bytes; + return true; +} + +bool pocketjs_net_esp_runtime_lease_release( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_network_v1_command_identity_t *identity, + pocketjs_network_v1_handle_t lease, + pocketjs_net_esp_runtime_error_t *out_error) { + pocketjs_net_esp_runtime_slot_t *slot = NULL; + if (!lease_slot(runtime, identity, lease, &slot, out_error) || + slot->lease_state != POCKETJS_NET_ESP_RUNTIME_LEASE_TAKEN) { + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_LEASE); + if (out_error != NULL && out_error->code == 0U) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_INVALID_STATE, "http.body.lease", + false); + } + return false; + } + if (!pocketjs_net_http_client_core_release_body_lease(slot->core, + slot->core_lease)) { + (void)pocketjs_net_http_client_core_report_host_event_retire_failure( + slot->core, slot->event.sequence); + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_LEASE); + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_INVALID_STATE, "http.body.lease", + false); + return false; + } + slot->lease_state = POCKETJS_NET_ESP_RUNTIME_LEASE_RELEASED; + ++runtime->leases_released; + if (!cleanup_pending_event(runtime, slot)) { + set_error(out_error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_INVALID_STATE, "http.body.lease", + false); + return false; + } + return true; +} + +esp_err_t +pocketjs_net_esp_runtime_create(const pocketjs_net_esp_runtime_config_t *config, + pocketjs_net_esp_runtime_t **out_runtime) { + if (out_runtime == NULL || *out_runtime != NULL || !valid_config(config)) { + return ESP_ERR_INVALID_ARG; + } + const size_t runtime_bytes = + sizeof(pocketjs_net_esp_runtime_t) + + sizeof(pocketjs_net_esp_runtime_slot_t) * config->max_operations; + pocketjs_net_esp_runtime_t *runtime = calloc(1U, runtime_bytes); + if (runtime == NULL) { + return ESP_ERR_NO_MEM; + } + runtime->tls_enabled = pocketjs_net_esp_runtime_feature_projection_has_tls( + config->feature_ids, config->feature_count); + runtime->tls_trust_source = config->tls_trust_source; + runtime->pinned_ca_bytes = config->host_pinned_ca_pem_bytes; + if (runtime->pinned_ca_bytes != 0U) { + memcpy(runtime->pinned_ca, config->host_pinned_ca_pem, + runtime->pinned_ca_bytes); + runtime->pinned_ca[runtime->pinned_ca_bytes] = '\0'; + } + runtime->wall_clock_trusted = config->wall_clock_trusted; + runtime->wall_clock_context = config->wall_clock_context; + const pocketjs_net_esp_transport_config_t transport_config = { + .tls_trust_source = runtime->tls_trust_source, + .host_pinned_ca_pem = + runtime->tls_trust_source == POCKETJS_NET_ESP_TLS_TRUST_HOST_PINNED_CA + ? runtime->pinned_ca + : NULL, + .host_pinned_ca_pem_bytes = runtime->pinned_ca_bytes, + .wall_clock_trusted = runtime->wall_clock_trusted, + .wall_clock_context = runtime->wall_clock_context, + }; + if (pocketjs_net_esp_transport_validate_config(&transport_config) != ESP_OK) { + memset(runtime->pinned_ca, 0, sizeof(runtime->pinned_ca)); + free(runtime); + return ESP_ERR_INVALID_ARG; + } + runtime->magic = POCKETJS_NET_ESP_RUNTIME_MAGIC; + runtime->owner_task = xTaskGetCurrentTaskHandle(); + runtime->guest = config->guest; + runtime->context = pocketjs_esp_guest_context(config->guest); + if (runtime->context == NULL) { + runtime->magic = 0U; + memset(runtime->pinned_ca, 0, sizeof(runtime->pinned_ca)); + free(runtime); + return ESP_ERR_INVALID_STATE; + } + runtime->phase = POCKETJS_NET_ESP_RUNTIME_PHASE_RUNNING; + runtime->runtime_generation = config->runtime_generation; + memcpy(runtime->plan_hash, config->plan_hash, sizeof(runtime->plan_hash)); + memcpy(runtime->feature_ids, config->feature_ids, + sizeof(runtime->feature_ids[0]) * config->feature_count); + runtime->feature_count = config->feature_count; + runtime->max_operations = config->max_operations; + runtime->limits = config->limits; + runtime->connect_timeout_us = config->connect_timeout_us; + runtime->headers_timeout_us = config->headers_timeout_us; + runtime->idle_timeout_us = config->idle_timeout_us; + runtime->total_timeout_us = config->total_timeout_us; + runtime->wake = config->wake; + runtime->wake_context = config->wake_context; + runtime->allow_endpoint = config->allow_endpoint; + runtime->permission_context = config->permission_context; + runtime->binding = JS_UNDEFINED; + runtime->dispatcher = JS_UNDEFINED; + esp_err_t result = pocketjs_net_esp_runtime_create_binding(runtime); + if (result != ESP_OK) { + runtime->magic = 0U; + memset(runtime->pinned_ca, 0, sizeof(runtime->pinned_ca)); + free(runtime); + return result; + } + *out_runtime = runtime; + return ESP_OK; +} + +esp_err_t +pocketjs_net_esp_runtime_get_binding(pocketjs_net_esp_runtime_t *runtime, + JSValue *out_binding) { + if (!pocketjs_net_esp_runtime_is_owner(runtime) || out_binding == NULL || + runtime->phase != POCKETJS_NET_ESP_RUNTIME_PHASE_RUNNING || + runtime->service_call_active || runtime->dispatcher_call_active || + runtime->binding_call_active || runtime->permission_call_active || + JS_IsUndefined(runtime->binding)) { + return ESP_ERR_INVALID_ARG; + } + *out_binding = JS_DupValue(runtime->context, runtime->binding); + return ESP_OK; +} + +static bool native_quiescent(pocketjs_net_esp_runtime_t *runtime) { + for (size_t index = 0U; index < runtime->max_operations; ++index) { + pocketjs_net_esp_runtime_slot_t *slot = &runtime->slots[index]; + if (!slot->initialized) { + continue; + } + if (slot->transport_detached_awaiting_confirm || slot->event_pending || + slot->lease_state != POCKETJS_NET_ESP_RUNTIME_LEASE_NONE || + !pocketjs_net_http_client_core_is_quiescent(slot->core) || + (slot->transport != NULL && + !pocketjs_net_esp_transport_is_quiescent(slot->transport))) { + return false; + } + } + return true; +} + +static void pump_slots(pocketjs_net_esp_runtime_t *runtime, uint64_t now_us, + uint32_t max_native_steps, + uint32_t max_transport_completions) { + uint32_t native_remaining = max_native_steps; + uint32_t completion_remaining = max_transport_completions; + while (native_remaining != 0U || completion_remaining != 0U) { + bool visited = false; + for (size_t offset = 0U; offset < runtime->max_operations; ++offset) { + const size_t index = + (runtime->pump_cursor + offset) % runtime->max_operations; + pocketjs_net_esp_runtime_slot_t *slot = &runtime->slots[index]; + if (!slot->initialized || slot->transport == NULL) { + continue; + } + const size_t native = native_remaining != 0U ? 1U : 0U; + const size_t completions = completion_remaining != 0U ? 1U : 0U; + const bool pumped = pocketjs_net_http_client_core_pump( + slot->core, now_us, native, completions); + if (!pumped) { + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_CORE); + } + observe_core_status(runtime, slot); + native_remaining -= (uint32_t)native; + completion_remaining -= (uint32_t)completions; + runtime->pump_cursor = (index + 1U) % runtime->max_operations; + visited = true; + break; + } + if (!visited) { + break; + } + } +} + +static uint32_t cleanup_native_events(pocketjs_net_esp_runtime_t *runtime, + uint32_t maximum_events) { + uint32_t cleaned = 0U; + while (cleaned < maximum_events) { + bool found = false; + for (size_t offset = 0U; offset < runtime->max_operations; ++offset) { + const size_t index = + (runtime->completion_cursor + offset) % runtime->max_operations; + pocketjs_net_esp_runtime_slot_t *slot = &runtime->slots[index]; + if (!slot->initialized || slot->transport_detached_awaiting_confirm) { + continue; + } + if (!slot->event_pending && + !pocketjs_net_http_client_core_take_event(slot->core, &slot->event)) { + continue; + } + slot->event_pending = true; + runtime->completion_cursor = (index + 1U) % runtime->max_operations; + found = true; + if (!cleanup_pending_event(runtime, slot)) { + return cleaned; + } + ++cleaned; + break; + } + if (!found) { + break; + } + } + return cleaned; +} + +/* + * A poisoned Core may retain transport ownership for audit until its + * dedicated transport has been synchronously destroyed and acknowledged. + * This is native phase-2 cleanup; it never calls the Guest. + */ +static void teardown_poisoned_transports(pocketjs_net_esp_runtime_t *runtime) { + for (size_t index = 0U; index < runtime->max_operations; ++index) { + pocketjs_net_esp_runtime_slot_t *slot = &runtime->slots[index]; + if (!slot->initialized) { + continue; + } + if (slot->transport_detached_awaiting_confirm) { + if (!pocketjs_net_http_client_core_confirm_transport_shutdown( + slot->core)) { + pocketjs_net_esp_runtime_poison( + runtime, POCKETJS_NET_ESP_RUNTIME_POISON_SHUTDOWN); + continue; + } + slot->transport_detached_awaiting_confirm = false; + continue; + } + pocketjs_net_http_client_core_status_t status = {0}; + if (!pocketjs_net_http_client_core_get_status(slot->core, &status)) { + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_SHUTDOWN); + continue; + } + if (!status.poisoned) { + continue; + } + if (slot->transport != NULL) { + const bool quiescent = + pocketjs_net_esp_transport_is_quiescent(slot->transport); + esp_err_t result = + quiescent + ? pocketjs_net_esp_transport_destroy(slot->transport) + : pocketjs_net_esp_transport_destroy_poisoned(slot->transport); + if (result != ESP_OK) { + if (result != ESP_ERR_NOT_FINISHED) { + pocketjs_net_esp_runtime_poison( + runtime, POCKETJS_NET_ESP_RUNTIME_POISON_SHUTDOWN); + } + continue; + } + slot->transport = NULL; + slot->transport_detached_awaiting_confirm = true; + } + if (!pocketjs_net_http_client_core_confirm_transport_shutdown(slot->core)) { + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_SHUTDOWN); + continue; + } + slot->transport_detached_awaiting_confirm = false; + } +} + +static void +advance_shutdown_phase(pocketjs_net_esp_runtime_t *runtime, uint32_t max_events, + pocketjs_net_esp_runtime_service_result_t *out_result) { + teardown_poisoned_transports(runtime); + (void)cleanup_native_events(runtime, max_events); + runtime->phase = POCKETJS_NET_ESP_RUNTIME_PHASE_QUIESCING; + if (runtime->shutdown_dispatch_drained && native_quiescent(runtime)) { + runtime->phase = POCKETJS_NET_ESP_RUNTIME_PHASE_READY_TO_DESTROY; + out_result->status = POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_DRAINED; + } +} + +esp_err_t pocketjs_net_esp_runtime_service( + pocketjs_net_esp_runtime_t *runtime, uint64_t now_us, + uint32_t max_native_steps, uint32_t max_transport_completions, + uint32_t max_events, uint32_t max_payload_bytes, + pocketjs_net_esp_runtime_service_result_t *out_result) { + if (!pocketjs_net_esp_runtime_is_owner(runtime) || now_us == 0U || + max_events == 0U || max_payload_bytes == 0U || out_result == NULL || + runtime->service_call_active || runtime->binding_call_active || + runtime->permission_call_active || + runtime->phase == POCKETJS_NET_ESP_RUNTIME_PHASE_READY_TO_DESTROY) { + return ESP_ERR_INVALID_ARG; + } + runtime->service_call_active = true; + pump_slots(runtime, now_us, max_native_steps, max_transport_completions); + const pocketjs_network_v1_service_turn_kind_t kind = + runtime->phase == POCKETJS_NET_ESP_RUNTIME_PHASE_RUNNING + ? POCKETJS_NETWORK_V1_SERVICE_TURN_KIND_NETWORK + : POCKETJS_NETWORK_V1_SERVICE_TURN_KIND_SHUTDOWN; + if (kind == POCKETJS_NETWORK_V1_SERVICE_TURN_KIND_SHUTDOWN && + (runtime->poison_flags != 0U || !runtime->dispatcher_registered)) { + (void)cleanup_native_events(runtime, max_events); + *out_result = (pocketjs_net_esp_runtime_service_result_t){ + .status = POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_MORE_READY, + }; + runtime->shutdown_dispatch_drained = true; + runtime->service_call_active = false; + advance_shutdown_phase(runtime, max_events, out_result); + return ESP_OK; + } + esp_err_t result = pocketjs_net_esp_runtime_call_dispatcher( + runtime, kind, max_events, max_payload_bytes, out_result); + runtime->service_call_active = false; + if (result != ESP_OK) { + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_QUICKJS); + if (kind == POCKETJS_NETWORK_V1_SERVICE_TURN_KIND_SHUTDOWN) { + (void)cleanup_native_events(runtime, max_events); + *out_result = (pocketjs_net_esp_runtime_service_result_t){ + .status = POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_MORE_READY, + }; + runtime->shutdown_dispatch_drained = true; + advance_shutdown_phase(runtime, max_events, out_result); + } + return result; + } + if (kind == POCKETJS_NETWORK_V1_SERVICE_TURN_KIND_SHUTDOWN) { + runtime->shutdown_dispatch_drained = + out_result->status == POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_DRAINED; + advance_shutdown_phase(runtime, max_events, out_result); + } + return ESP_OK; +} + +esp_err_t +pocketjs_net_esp_runtime_begin_shutdown(pocketjs_net_esp_runtime_t *runtime, + uint64_t now_us) { + if (!pocketjs_net_esp_runtime_is_owner(runtime) || now_us == 0U || + runtime->service_call_active || runtime->binding_call_active || + runtime->permission_call_active) { + return ESP_ERR_INVALID_ARG; + } + if (runtime->phase != POCKETJS_NET_ESP_RUNTIME_PHASE_RUNNING) { + return ESP_ERR_INVALID_STATE; + } + runtime->phase = POCKETJS_NET_ESP_RUNTIME_PHASE_SHUTDOWN_REQUESTED; + for (size_t index = 0U; index < runtime->max_operations; ++index) { + pocketjs_net_esp_runtime_slot_t *slot = &runtime->slots[index]; + if (!slot->initialized) { + continue; + } + if (slot->event_pending) { + (void)cleanup_pending_event(runtime, slot); + } + if (!pocketjs_net_http_client_core_begin_shutdown(slot->core, now_us)) { + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_SHUTDOWN); + } + observe_core_status(runtime, slot); + pocketjs_net_esp_transport_begin_shutdown(slot->transport); + } + pocketjs_net_esp_runtime_signal(runtime); + return ESP_OK; +} + +bool pocketjs_net_esp_runtime_is_ready_to_destroy( + const pocketjs_net_esp_runtime_t *runtime) { + return pocketjs_net_esp_runtime_is_owner(runtime) && + runtime->phase == POCKETJS_NET_ESP_RUNTIME_PHASE_READY_TO_DESTROY; +} + +esp_err_t +pocketjs_net_esp_runtime_destroy(pocketjs_net_esp_runtime_t *runtime) { + if (!pocketjs_net_esp_runtime_is_owner(runtime) || + runtime->phase != POCKETJS_NET_ESP_RUNTIME_PHASE_READY_TO_DESTROY || + runtime->service_call_active || runtime->dispatcher_call_active || + runtime->binding_call_active || runtime->permission_call_active) { + return ESP_ERR_INVALID_STATE; + } + for (size_t index = 0U; index < runtime->max_operations; ++index) { + pocketjs_net_esp_runtime_slot_t *slot = &runtime->slots[index]; + if (!slot->initialized) { + continue; + } + if (slot->transport != NULL) { + esp_err_t result = pocketjs_net_esp_transport_destroy(slot->transport); + if (result != ESP_OK) { + return result; + } + slot->transport = NULL; + } + if (slot->core != NULL) { + if (!pocketjs_net_http_client_core_confirm_transport_shutdown( + slot->core) || + !pocketjs_net_http_client_core_deinit(slot->core)) { + return ESP_ERR_INVALID_STATE; + } + slot->core = NULL; + } + slot->initialized = false; + } + pocketjs_net_esp_runtime_revoke_binding(runtime); + memset(runtime->pinned_ca, 0, sizeof(runtime->pinned_ca)); + runtime->magic = 0U; + free(runtime); + return ESP_OK; +} + +esp_err_t pocketjs_net_esp_runtime_get_stats( + pocketjs_net_esp_runtime_t *runtime, + pocketjs_net_esp_runtime_stats_t *out_stats) { + if (!pocketjs_net_esp_runtime_is_owner(runtime) || out_stats == NULL) { + return ESP_ERR_INVALID_ARG; + } + *out_stats = (pocketjs_net_esp_runtime_stats_t){ + .phase = runtime->phase, + .runtime_generation = runtime->runtime_generation, + .configured_operation_slots = runtime->max_operations, + .last_command_sequence = runtime->command_sequence, + .last_completion_sequence = runtime->completion_sequence, + .last_service_turn = runtime->service_turn, + .requests_started = runtime->requests_started, + .completions_delivered = runtime->completions_delivered, + .leases_taken = runtime->leases_taken, + .leases_released = runtime->leases_released, + .leases_cleaned_up = runtime->leases_cleaned_up, + .permission_checks = runtime->permission_checks, + .poison_flags = runtime->poison_flags, + .runtime_instance_bytes = sizeof(*runtime) + sizeof(runtime->slots[0]) * + runtime->max_operations, + .core_storage_bytes = sizeof(pocketjs_net_http_client_core_storage_t) * + runtime->max_operations, + .admitted_native_buffer_bytes = + (size_t)runtime->limits.native_buffer_bytes.default_value, + }; + for (size_t index = 0U; index < runtime->max_operations; ++index) { + const pocketjs_net_esp_runtime_slot_t *slot = &runtime->slots[index]; + if (slot->initialized) { + ++out_stats->initialized_operation_slots; + pocketjs_net_http_client_core_status_t core_status = {0}; + if (pocketjs_net_http_client_core_get_status(slot->core, &core_status) && + core_status.poisoned) { + if (out_stats->poisoned_core_slots == 0U) { + out_stats->first_core_poison_cause_code = + core_status.first_poison_cause_code; + } + ++out_stats->poisoned_core_slots; + out_stats->core_poison_flags |= core_status.poison_flags; + } + } + if (slot->transport != NULL) { + ++out_stats->transport_instances; + } + if (slot->active) { + ++out_stats->active_operations; + } + if (slot->event_pending) { + ++out_stats->pending_core_events; + } + if (slot->lease_state == POCKETJS_NET_ESP_RUNTIME_LEASE_QUEUED) { + ++out_stats->queued_leases; + } else if (slot->lease_state == POCKETJS_NET_ESP_RUNTIME_LEASE_TAKEN) { + ++out_stats->taken_leases; + } + } + const pocketjs_net_esp_transport_descriptor_t *transport = + pocketjs_net_esp_transport_descriptor(); + size_t transport_bytes = 0U; + size_t owned_bytes = 0U; + if (transport != NULL && + checked_size_multiply(transport->pocketjs_owned_instance_bytes, + out_stats->transport_instances, &transport_bytes) && + checked_size_add(out_stats->runtime_instance_bytes, + sizeof(pocketjs_net_esp_runtime_binding_state_t), + &owned_bytes) && + checked_size_add(owned_bytes, transport_bytes, &owned_bytes)) { + out_stats->pocketjs_owned_native_bytes = owned_bytes; + } + return ESP_OK; +} diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/src/network_v1_binding.c b/hosts/esp-idf/components/pocketjs_net_esp_runtime/src/network_v1_binding.c new file mode 100644 index 00000000..004222e7 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/src/network_v1_binding.c @@ -0,0 +1,1846 @@ +// SPDX-License-Identifier: MIT + +#include "runtime_internal.h" + +#include +#include +#include +#include +#include + +#include "runtime_contract.h" + +#define POCKETJS_NET_ESP_RUNTIME_MAX_LIMIT_OVERRIDES 32U +#define POCKETJS_NET_ESP_RUNTIME_MAX_LIMIT_NAME_BYTES 64U +#define POCKETJS_NET_ESP_RUNTIME_MAX_OPERATION_LABEL_BYTES 64U +#define POCKETJS_NET_ESP_RUNTIME_MAX_CAUSE_CODE_BYTES 64U + +static void binding_state_release(void *opaque) { + pocketjs_net_esp_runtime_binding_state_t *state = opaque; + if (state == NULL || state->references == 0U) { + return; + } + --state->references; + if (state->references == 0U) { + free(state); + } +} + +static void set_error(pocketjs_net_esp_runtime_error_t *error, + pocketjs_network_v1_error_category_t category, + pocketjs_network_v1_error_code_t code, + const char *operation, bool temporary) { + *error = (pocketjs_net_esp_runtime_error_t){ + .category = category, + .code = code, + .operation = operation, + .temporary = temporary, + }; +} + +static JSValue binding_type_error(JSContext *context, const char *detail) { + return JS_ThrowTypeError(context, "PocketJS ESP network ABI: %s", detail); +} + +static bool set_property(JSContext *context, JSValueConst object, + const char *name, JSValue value) { + if (JS_IsException(value)) { + return false; + } + return JS_SetPropertyStr(context, object, name, value) >= 0; +} + +static bool set_array_property(JSContext *context, JSValueConst array, + uint32_t index, JSValue value) { + if (JS_IsException(value)) { + return false; + } + return JS_SetPropertyUint32(context, array, index, value) >= 0; +} + +static bool freeze(JSContext *context, JSValueConst value) { + return JS_FreezeObject(context, value) >= 0; +} + +static void free_descriptor(JSContext *context, + JSPropertyDescriptor *descriptor) { + JS_FreeValue(context, descriptor->value); + JS_FreeValue(context, descriptor->getter); + JS_FreeValue(context, descriptor->setter); +} + +/* -1 is malformed/exception, 0 is absent, and 1 is an own data property. */ +static int own_data_property_status(JSContext *context, JSValueConst object, + const char *name, JSValue *out_value) { + *out_value = JS_UNDEFINED; + if (!JS_IsObject(object) || JS_IsProxy(object)) { + return -1; + } + JSAtom atom = JS_NewAtom(context, name); + if (atom == JS_ATOM_NULL) { + return -1; + } + JSPropertyDescriptor descriptor = {0}; + int result = JS_GetOwnProperty(context, &descriptor, object, atom); + JS_FreeAtom(context, atom); + if (result <= 0) { + return result; + } + if ((descriptor.flags & JS_PROP_GETSET) != 0) { + free_descriptor(context, &descriptor); + return -1; + } + *out_value = descriptor.value; + JS_FreeValue(context, descriptor.getter); + JS_FreeValue(context, descriptor.setter); + return 1; +} + +static bool own_data_property(JSContext *context, JSValueConst object, + const char *name, JSValue *out_value) { + return own_data_property_status(context, object, name, out_value) == 1; +} + +static bool read_u64(JSContext *context, JSValueConst value, uint64_t maximum, + uint64_t *out_value) { + if (!JS_IsNumber(value)) { + return false; + } + double number = 0.0; + if (JS_ToFloat64(context, &number, value) < 0 || !isfinite(number) || + number < 0.0 || number > (double)maximum) { + return false; + } + const uint64_t integer = (uint64_t)number; + if ((double)integer != number) { + return false; + } + *out_value = integer; + return true; +} + +static bool read_u32(JSContext *context, JSValueConst value, uint32_t maximum, + uint32_t *out_value) { + uint64_t result = 0U; + if (!read_u64(context, value, maximum, &result)) { + return false; + } + *out_value = (uint32_t)result; + return true; +} + +static bool read_u16(JSContext *context, JSValueConst value, uint16_t maximum, + uint16_t *out_value) { + uint32_t result = 0U; + if (!read_u32(context, value, maximum, &result)) { + return false; + } + *out_value = (uint16_t)result; + return true; +} + +static bool object_u32(JSContext *context, JSValueConst object, + const char *name, uint32_t maximum, + uint32_t *out_value) { + JSValue value = JS_UNDEFINED; + if (!own_data_property(context, object, name, &value)) { + return false; + } + const bool result = read_u32(context, value, maximum, out_value); + JS_FreeValue(context, value); + return result; +} + +static bool object_u16(JSContext *context, JSValueConst object, + const char *name, uint16_t maximum, + uint16_t *out_value) { + JSValue value = JS_UNDEFINED; + if (!own_data_property(context, object, name, &value)) { + return false; + } + const bool result = read_u16(context, value, maximum, out_value); + JS_FreeValue(context, value); + return result; +} + +static bool object_u64(JSContext *context, JSValueConst object, + const char *name, uint64_t maximum, + uint64_t *out_value) { + JSValue value = JS_UNDEFINED; + if (!own_data_property(context, object, name, &value)) { + return false; + } + const bool result = read_u64(context, value, maximum, out_value); + JS_FreeValue(context, value); + return result; +} + +static bool object_bool(JSContext *context, JSValueConst object, + const char *name, bool *out_value) { + JSValue value = JS_UNDEFINED; + if (!own_data_property(context, object, name, &value) || !JS_IsBool(value)) { + JS_FreeValue(context, value); + return false; + } + *out_value = JS_ToBool(context, value) != 0; + JS_FreeValue(context, value); + return true; +} + +static bool read_latin1(JSContext *context, JSValueConst value, + uint8_t *destination, size_t capacity, + size_t *out_length, bool ascii_only, bool allow_empty) { + if (!JS_IsString(value)) { + return false; + } + size_t length = 0U; + const uint16_t *characters = JS_ToCStringLenUTF16(context, &length, value); + if (characters == NULL) { + return false; + } + bool valid = length <= capacity && (allow_empty || length != 0U); + for (size_t index = 0U; valid && index < length; ++index) { + if (characters[index] > (ascii_only ? 0x7fU : 0xffU)) { + valid = false; + } else { + destination[index] = (uint8_t)characters[index]; + } + } + JS_FreeCStringUTF16(context, characters); + if (valid) { + *out_length = length; + } + return valid; +} + +static bool object_latin1(JSContext *context, JSValueConst object, + const char *name, uint8_t *destination, + size_t capacity, size_t *out_length, bool ascii_only, + bool allow_empty) { + JSValue value = JS_UNDEFINED; + if (!own_data_property(context, object, name, &value)) { + return false; + } + const bool result = read_latin1(context, value, destination, capacity, + out_length, ascii_only, allow_empty); + JS_FreeValue(context, value); + return result; +} + +static bool array_length(JSContext *context, JSValueConst array, + uint32_t maximum, uint32_t *out_length) { + if (!JS_IsArray(array) || JS_IsProxy(array)) { + return false; + } + JSValue value = JS_UNDEFINED; + if (!own_data_property(context, array, "length", &value)) { + return false; + } + const bool result = read_u32(context, value, maximum, out_length); + JS_FreeValue(context, value); + return result; +} + +static bool array_item(JSContext *context, JSValueConst array, uint32_t index, + JSValue *out_value) { + *out_value = JS_UNDEFINED; + JSAtom atom = JS_NewAtomUInt32(context, index); + if (atom == JS_ATOM_NULL) { + return false; + } + JSPropertyDescriptor descriptor = {0}; + int result = JS_GetOwnProperty(context, &descriptor, array, atom); + JS_FreeAtom(context, atom); + if (result != 1 || (descriptor.flags & JS_PROP_GETSET) != 0) { + if (result == 1) { + free_descriptor(context, &descriptor); + } + return false; + } + *out_value = descriptor.value; + JS_FreeValue(context, descriptor.getter); + JS_FreeValue(context, descriptor.setter); + return true; +} + +static bool parse_handle(JSContext *context, JSValueConst value, + pocketjs_network_v1_handle_t *out_handle) { + return object_u32(context, value, "id", UINT32_MAX, &out_handle->id) && + object_u32(context, value, "generation", UINT32_MAX, + &out_handle->generation) && + ((out_handle->id == 0U && out_handle->generation == 0U) || + (out_handle->id != 0U && out_handle->generation != 0U)); +} + +static bool +parse_identity(JSContext *context, JSValueConst command, + pocketjs_network_v1_command_identity_t *out_identity) { + JSValue identity = JS_UNDEFINED; + JSValue resource = JS_UNDEFINED; + JSValue operation = JS_UNDEFINED; + JSValue body = JS_UNDEFINED; + bool valid = own_data_property(context, command, "identity", &identity) && + object_u32(context, identity, "runtimeGeneration", UINT32_MAX, + &out_identity->runtime_generation) && + own_data_property(context, identity, "resource", &resource) && + parse_handle(context, resource, &out_identity->resource) && + own_data_property(context, identity, "operation", &operation) && + parse_handle(context, operation, &out_identity->operation) && + own_data_property(context, identity, "body", &body) && + parse_handle(context, body, &out_identity->body) && + object_u64(context, identity, "commandSequence", + POCKETJS_NETWORK_V1_SEQUENCE_MAX, + &out_identity->command_sequence); + JS_FreeValue(context, body); + JS_FreeValue(context, operation); + JS_FreeValue(context, resource); + JS_FreeValue(context, identity); + return valid; +} + +static bool typed_array_window(JSContext *context, JSValueConst value, + uint8_t **out_bytes, size_t *out_length) { + if (JS_IsProxy(value) || + JS_GetTypedArrayType(value) != JS_TYPED_ARRAY_UINT8) { + return false; + } + size_t offset = 0U; + size_t length = 0U; + size_t bytes_per_element = 0U; + JSValue buffer = JS_GetTypedArrayBuffer(context, value, &offset, &length, + &bytes_per_element); + if (JS_IsException(buffer)) { + return false; + } + size_t buffer_length = 0U; + uint8_t *base = JS_GetArrayBuffer(context, &buffer_length, buffer); + const bool valid = !JS_HasException(context) && bytes_per_element == 1U && + offset <= buffer_length && + length <= buffer_length - offset; + if (valid) { + *out_bytes = base == NULL ? NULL : base + offset; + *out_length = length; + } + JS_FreeValue(context, buffer); + return valid; +} + +static JSValue new_latin1_string(JSContext *context, + pocketjs_net_esp_runtime_t *runtime, + const uint8_t *bytes, size_t length) { + if (length == 0U) { + return JS_NewStringLen(context, "", 0U); + } + if (length > POCKETJS_NET_HTTP_CLIENT_CORE_MAX_RESPONSE_HEADER_BYTES || + runtime == NULL) { + return JS_ThrowRangeError(context, "ByteString exceeds native bound"); + } + for (size_t index = 0U; index < length; ++index) { + runtime->latin1_scratch[index] = bytes[index]; + } + return JS_NewStringUTF16(context, runtime->latin1_scratch, length); +} + +static JSValue new_handle(JSContext *context, + pocketjs_network_v1_handle_t handle) { + JSValue object = JS_NewObject(context); + if (JS_IsException(object) || + !set_property(context, object, "id", JS_NewUint32(context, handle.id)) || + !set_property(context, object, "generation", + JS_NewUint32(context, handle.generation)) || + !freeze(context, object)) { + JS_FreeValue(context, object); + return JS_EXCEPTION; + } + return object; +} + +static JSValue new_identity(JSContext *context, + pocketjs_net_esp_runtime_t *runtime, + pocketjs_net_esp_runtime_slot_t *slot, + pocketjs_network_v1_handle_t body, + uint64_t sequence) { + JSValue object = JS_NewObject(context); + if (JS_IsException(object) || + !set_property(context, object, "runtimeGeneration", + JS_NewUint32(context, runtime->runtime_generation)) || + !set_property( + context, object, "resource", + new_handle(context, (pocketjs_network_v1_handle_t){1U, 1U})) || + !set_property(context, object, "operation", + new_handle(context, slot->operation)) || + !set_property(context, object, "body", new_handle(context, body)) || + !set_property(context, object, "sequence", + JS_NewInt64(context, (int64_t)sequence)) || + !freeze(context, object)) { + JS_FreeValue(context, object); + return JS_EXCEPTION; + } + return object; +} + +static JSValue new_error(JSContext *context, + const pocketjs_net_esp_runtime_error_t *error) { + if (error == NULL || error->operation == NULL || error->code == 0U || + error->category == 0U) { + return binding_type_error(context, "native error metadata is incomplete"); + } + JSValue object = JS_NewObject(context); + if (JS_IsException(object) || + !set_property(context, object, "category", + JS_NewUint32(context, error->category)) || + !set_property(context, object, "code", + JS_NewUint32(context, error->code)) || + !set_property(context, object, "operation", + JS_NewString(context, error->operation)) || + !set_property(context, object, "temporary", + JS_NewBool(context, error->temporary))) { + JS_FreeValue(context, object); + return JS_EXCEPTION; + } + if (error->has_cause_code) { + char cause[32]; + int count = + snprintf(cause, sizeof(cause), "native-%ld", (long)error->cause_code); + if (count <= 0 || (size_t)count >= sizeof(cause) || + !set_property(context, object, "causeCode", + JS_NewStringLen(context, cause, (size_t)count))) { + JS_FreeValue(context, object); + return JS_EXCEPTION; + } + } + if (!freeze(context, object)) { + JS_FreeValue(context, object); + return JS_EXCEPTION; + } + return object; +} + +static JSValue +new_dispatch_result(JSContext *context, bool accepted, + const pocketjs_net_esp_runtime_error_t *error) { + JSValue object = JS_NewObject(context); + if (JS_IsException(object) || + !set_property( + context, object, "status", + JS_NewUint32(context, accepted + ? POCKETJS_NETWORK_V1_DISPATCH_ACCEPTED + : POCKETJS_NETWORK_V1_DISPATCH_REFUSED)) || + (!accepted && + !set_property(context, object, "error", new_error(context, error))) || + !freeze(context, object)) { + JS_FreeValue(context, object); + return JS_EXCEPTION; + } + return object; +} + +static JSValue new_sync_result(JSContext *context, bool completed, + const pocketjs_net_esp_runtime_error_t *error) { + JSValue object = JS_NewObject(context); + if (JS_IsException(object) || + !set_property( + context, object, "status", + JS_NewUint32(context, completed + ? POCKETJS_NETWORK_V1_DISPATCH_COMPLETED + : POCKETJS_NETWORK_V1_DISPATCH_REFUSED)) || + (!completed && + !set_property(context, object, "error", new_error(context, error))) || + !freeze(context, object)) { + JS_FreeValue(context, object); + return JS_EXCEPTION; + } + return object; +} + +static pocketjs_net_esp_runtime_t *binding_runtime(JSContext *context, + void *opaque) { + pocketjs_net_esp_runtime_binding_state_t *state = opaque; + pocketjs_net_esp_runtime_t *runtime = state == NULL ? NULL : state->runtime; + if (runtime == NULL || !pocketjs_net_esp_runtime_is_owner(runtime) || + runtime->context != context || runtime->binding_call_active) { + binding_type_error(context, "stale, foreign, or reentrant binding call"); + return NULL; + } + return runtime; +} + +static bool parse_headers(JSContext *context, JSValueConst metadata, + pocketjs_net_esp_runtime_http_command_t *command) { + JSValue headers = JS_UNDEFINED; + if (!own_data_property(context, metadata, "headers", &headers)) { + return false; + } + uint32_t count = 0U; + if (!array_length(context, headers, + POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_HEADERS, + &count)) { + JS_FreeValue(context, headers); + return false; + } + command->header_count = count; + command->header_bytes_used = 0U; + bool valid = true; + for (uint32_t index = 0U; valid && index < count; ++index) { + JSValue entry = JS_UNDEFINED; + JSValue name = JS_UNDEFINED; + JSValue value = JS_UNDEFINED; + size_t name_length = 0U; + size_t value_length = 0U; + valid = array_item(context, headers, index, &entry) && + own_data_property(context, entry, "name", &name) && + own_data_property(context, entry, "value", &value); + const size_t remaining = + sizeof(command->header_bytes) - command->header_bytes_used; + if (valid) { + valid = read_latin1(context, name, + command->header_bytes + command->header_bytes_used, + remaining, &name_length, true, false); + } + if (valid) { + const size_t value_remaining = remaining - name_length; + valid = read_latin1(context, value, + command->header_bytes + command->header_bytes_used + + name_length, + value_remaining, &value_length, false, true); + } + if (valid) { + command->headers[index] = (pocketjs_net_http_client_header_t){ + .name = {.data = command->header_bytes + command->header_bytes_used, + .length = name_length}, + .value = + { + .data = command->header_bytes + command->header_bytes_used + + name_length, + .length = value_length, + }, + }; + command->header_bytes_used += name_length + value_length; + } + JS_FreeValue(context, value); + JS_FreeValue(context, name); + JS_FreeValue(context, entry); + } + JS_FreeValue(context, headers); + return valid; +} + +static bool parse_timeouts(JSContext *context, JSValueConst metadata, + pocketjs_net_esp_runtime_http_command_t *command) { + JSValue timeouts = JS_UNDEFINED; + bool valid = own_data_property(context, metadata, "timeouts", &timeouts); + static const char *const names[] = {"connectMs", "headersMs", "idleMs", + "totalMs"}; + for (size_t index = 0U; valid && index < 4U; ++index) { + uint32_t value = 0U; + valid = object_u32(context, timeouts, names[index], UINT32_MAX, &value); + command->has_timeout_overrides |= valid && value != 0U; + } + JS_FreeValue(context, timeouts); + return valid; +} + +static bool limit_name_valid(const uint8_t *name, size_t length) { + if (name == NULL || length == 0U) { + return false; + } + const bool first_alpha = + (name[0] >= 'A' && name[0] <= 'Z') || (name[0] >= 'a' && name[0] <= 'z'); + if (!first_alpha) { + return false; + } + for (size_t index = 1U; index < length; ++index) { + const uint8_t byte = name[index]; + if (!((byte >= 'A' && byte <= 'Z') || (byte >= 'a' && byte <= 'z') || + (byte >= '0' && byte <= '9') || byte == '.' || byte == '_' || + byte == '-')) { + return false; + } + } + return true; +} + +static bool parse_limits(JSContext *context, JSValueConst metadata, + pocketjs_net_esp_runtime_http_command_t *command) { + JSValue limits = JS_UNDEFINED; + uint32_t length = 0U; + bool valid = + own_data_property(context, metadata, "limits", &limits) && + array_length(context, limits, + POCKETJS_NET_ESP_RUNTIME_MAX_LIMIT_OVERRIDES, &length); + uint8_t previous_name[POCKETJS_NET_ESP_RUNTIME_MAX_LIMIT_NAME_BYTES]; + size_t previous_length = 0U; + for (uint32_t index = 0U; valid && index < length; ++index) { + JSValue entry = JS_UNDEFINED; + uint8_t name[POCKETJS_NET_ESP_RUNTIME_MAX_LIMIT_NAME_BYTES]; + size_t name_length = 0U; + uint32_t value = 0U; + valid = array_item(context, limits, index, &entry) && + object_latin1(context, entry, "name", name, sizeof(name), + &name_length, true, false) && + limit_name_valid(name, name_length) && + object_u32(context, entry, "value", UINT32_MAX, &value) && + value != 0U && + (index == 0U || + memcmp(previous_name, name, + previous_length < name_length ? previous_length + : name_length) < 0 || + (memcmp(previous_name, name, + previous_length < name_length ? previous_length + : name_length) == 0 && + previous_length < name_length)); + if (valid) { + memcpy(previous_name, name, name_length); + previous_length = name_length; + } + JS_FreeValue(context, entry); + } + command->has_limit_overrides = valid && length != 0U; + JS_FreeValue(context, limits); + return valid; +} + +static bool +parse_base_tls_metadata(JSContext *context, JSValueConst tls, + pocketjs_net_esp_runtime_http_command_t *command) { + if (JS_IsNull(tls)) { + command->tls_present = false; + return true; + } + JSValue alpn = JS_UNDEFINED; + uint32_t alpn_count = 0U; + uint16_t minimum_version = 0U; + uint16_t maximum_version = 0U; + uint16_t client_certificate = 0U; + uint16_t verification = 0U; + uint16_t revocation = 0U; + uint32_t custom_ca_bytes = 0U; + bool valid = + object_latin1(context, tls, "serverName", command->tls_server_name, + sizeof(command->tls_server_name), + &command->tls_server_name_length, true, true) && + object_u16(context, tls, "minVersion", UINT16_MAX, &minimum_version) && + object_u16(context, tls, "maxVersion", UINT16_MAX, &maximum_version) && + own_data_property(context, tls, "alpn", &alpn) && + array_length(context, alpn, 8U, &alpn_count) && + object_latin1(context, tls, "credential", command->tls_credential, + sizeof(command->tls_credential), + &command->tls_credential_length, true, true) && + object_u16(context, tls, "clientCertificate", UINT16_MAX, + &client_certificate) && + object_u16(context, tls, "verification", UINT16_MAX, &verification) && + object_u16(context, tls, "revocation", UINT16_MAX, &revocation) && + object_u32(context, tls, "customCaBytes", UINT32_MAX, &custom_ca_bytes); + JS_FreeValue(context, alpn); + if (!valid) { + return false; + } + command->tls_present = true; + command->tls_policy = (pocketjs_net_http_client_tls_policy_t){ + .server_name = {.data = command->tls_server_name, + .length = command->tls_server_name_length}, + .minimum_version = + minimum_version == POCKETJS_NETWORK_V1_TLS_VERSION_V1_2 + ? POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_2 + : minimum_version == POCKETJS_NETWORK_V1_TLS_VERSION_V1_3 + ? POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_3 + : 0, + .maximum_version = + maximum_version == POCKETJS_NETWORK_V1_TLS_VERSION_V1_2 + ? POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_2 + : maximum_version == POCKETJS_NETWORK_V1_TLS_VERSION_V1_3 + ? POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_3 + : 0, + .alpn_count = alpn_count, + .credential = {.data = command->tls_credential, + .length = command->tls_credential_length}, + .client_certificate = + client_certificate == POCKETJS_NETWORK_V1_CLIENT_CERTIFICATE_NONE + ? POCKETJS_NET_HTTP_CLIENT_TLS_CLIENT_CERTIFICATE_NONE + : client_certificate == + POCKETJS_NETWORK_V1_CLIENT_CERTIFICATE_OPTIONAL + ? POCKETJS_NET_HTTP_CLIENT_TLS_CLIENT_CERTIFICATE_OPTIONAL + : client_certificate == + POCKETJS_NETWORK_V1_CLIENT_CERTIFICATE_REQUIRED + ? POCKETJS_NET_HTTP_CLIENT_TLS_CLIENT_CERTIFICATE_REQUIRED + : 0, + .verification = + verification == POCKETJS_NETWORK_V1_TLS_VERIFICATION_FULL + ? POCKETJS_NET_HTTP_CLIENT_TLS_VERIFICATION_FULL + : verification == + POCKETJS_NETWORK_V1_TLS_VERIFICATION_DEVELOPMENT_INSECURE + ? POCKETJS_NET_HTTP_CLIENT_TLS_VERIFICATION_DEVELOPMENT_INSECURE + : 0, + .revocation = + revocation == POCKETJS_NETWORK_V1_TLS_REVOCATION_HOST_DEFAULT + ? POCKETJS_NET_HTTP_CLIENT_TLS_REVOCATION_HOST_DEFAULT + : revocation == POCKETJS_NETWORK_V1_TLS_REVOCATION_REQUIRED + ? POCKETJS_NET_HTTP_CLIENT_TLS_REVOCATION_REQUIRED + : 0, + .custom_ca_bytes = custom_ca_bytes, + }; + return true; +} + +static bool url_has_https_scheme(const uint8_t *url, size_t length) { + static const char scheme[] = "https:"; + if (url == NULL || length < sizeof(scheme) - 1U) { + return false; + } + for (size_t index = 0U; index < sizeof(scheme) - 1U; ++index) { + uint8_t byte = url[index]; + if (byte >= 'A' && byte <= 'Z') { + byte = (uint8_t)(byte + ('a' - 'A')); + } + if (byte != (uint8_t)scheme[index]) { + return false; + } + } + return true; +} + +static bool +parse_http_command(JSContext *context, JSValueConst command_value, + pocketjs_net_esp_runtime_http_command_t *command) { + JSValue metadata = JS_UNDEFINED; + JSValue tls = JS_UNDEFINED; + memset(command, 0, sizeof(*command)); + bool valid = + own_data_property(context, command_value, "metadata", &metadata) && + object_latin1(context, metadata, "url", command->url, + sizeof(command->url), &command->url_length, true, false) && + object_latin1(context, metadata, "method", command->method, + sizeof(command->method), &command->method_length, true, + false) && + parse_headers(context, metadata, command) && + object_bool(context, metadata, "hasBody", &command->has_body) && + object_u16(context, metadata, "redirect", UINT16_MAX, + &command->redirect_mode) && + object_u16(context, metadata, "maxRedirects", + POCKETJS_NET_ESP_RUNTIME_MAX_REDIRECTS, + &command->max_redirects) && + parse_timeouts(context, metadata, command) && + parse_limits(context, metadata, command) && + object_bool(context, metadata, "ref", &command->ref) && + own_data_property(context, metadata, "tls", &tls) && + parse_base_tls_metadata(context, tls, command); + if (valid) { + command->tls_requested = + command->tls_present || + url_has_https_scheme(command->url, command->url_length); + valid = + command->redirect_mode == POCKETJS_NETWORK_V1_HTTP_REDIRECT_FOLLOW || + command->redirect_mode == POCKETJS_NETWORK_V1_HTTP_REDIRECT_MANUAL || + command->redirect_mode == POCKETJS_NETWORK_V1_HTTP_REDIRECT_ERROR; + } + JS_FreeValue(context, tls); + JS_FreeValue(context, metadata); + return valid; +} + +static bool parse_optional_input(JSContext *context, JSValueConst command, + bool *out_present) { + JSValue input = JS_UNDEFINED; + const int status = + own_data_property_status(context, command, "input", &input); + if (status < 0) { + return false; + } + if (status == 0) { + *out_present = false; + return true; + } + *out_present = !JS_IsUndefined(input); + JS_FreeValue(context, input); + return true; +} + +static bool known_error_code(uint16_t code) { + switch (code) { + case POCKETJS_NETWORK_V1_ERROR_ABORTED: + case POCKETJS_NETWORK_V1_ERROR_TIMED_OUT: + case POCKETJS_NETWORK_V1_ERROR_CLOSED: + case POCKETJS_NETWORK_V1_ERROR_INVALID_STATE: + case POCKETJS_NETWORK_V1_ERROR_BUSY: + case POCKETJS_NETWORK_V1_ERROR_RESOURCE_LIMIT: + case POCKETJS_NETWORK_V1_ERROR_UNSUPPORTED: + case POCKETJS_NETWORK_V1_ERROR_PERMISSION_DENIED: + case POCKETJS_NETWORK_V1_ERROR_DNS_NOT_FOUND: + case POCKETJS_NETWORK_V1_ERROR_DNS_TEMPORARY_FAILURE: + case POCKETJS_NETWORK_V1_ERROR_DNS_REFUSED: + case POCKETJS_NETWORK_V1_ERROR_CONNECTION_REFUSED: + case POCKETJS_NETWORK_V1_ERROR_CONNECTION_RESET: + case POCKETJS_NETWORK_V1_ERROR_NETWORK_UNREACHABLE: + case POCKETJS_NETWORK_V1_ERROR_ADDRESS_IN_USE: + case POCKETJS_NETWORK_V1_ERROR_BROKEN_PIPE: + case POCKETJS_NETWORK_V1_ERROR_TLS_CERTIFICATE_INVALID: + case POCKETJS_NETWORK_V1_ERROR_TLS_HOSTNAME_MISMATCH: + case POCKETJS_NETWORK_V1_ERROR_TLS_HANDSHAKE_FAILED: + case POCKETJS_NETWORK_V1_ERROR_TLS_VERSION_UNSUPPORTED: + case POCKETJS_NETWORK_V1_ERROR_TLS_ALERT: + case POCKETJS_NETWORK_V1_ERROR_HTTP_PROTOCOL_ERROR: + case POCKETJS_NETWORK_V1_ERROR_WEBSOCKET_PROTOCOL_ERROR: + case POCKETJS_NETWORK_V1_ERROR_MQTT_UNACCEPTABLE_PROTOCOL_VERSION: + case POCKETJS_NETWORK_V1_ERROR_MQTT_IDENTIFIER_REJECTED: + case POCKETJS_NETWORK_V1_ERROR_MQTT_SERVER_UNAVAILABLE: + case POCKETJS_NETWORK_V1_ERROR_MQTT_BAD_CREDENTIALS: + case POCKETJS_NETWORK_V1_ERROR_MQTT_NOT_AUTHORIZED: + case POCKETJS_NETWORK_V1_ERROR_MQTT_PROTOCOL_ERROR: + case POCKETJS_NETWORK_V1_ERROR_MESSAGE_TOO_LARGE: + case POCKETJS_NETWORK_V1_ERROR_SYSTEM_ERROR: + return true; + default: + return false; + } +} + +static bool optional_latin1_property(JSContext *context, JSValueConst object, + const char *name, uint8_t *buffer, + size_t capacity) { + JSValue value = JS_UNDEFINED; + const int status = own_data_property_status(context, object, name, &value); + if (status <= 0) { + return status == 0; + } + size_t length = 0U; + const bool valid = + read_latin1(context, value, buffer, capacity, &length, false, true); + JS_FreeValue(context, value); + return valid; +} + +static bool optional_u32_property(JSContext *context, JSValueConst object, + const char *name, uint32_t minimum, + uint32_t maximum) { + JSValue value = JS_UNDEFINED; + const int status = own_data_property_status(context, object, name, &value); + if (status <= 0) { + return status == 0; + } + uint32_t parsed = 0U; + const bool valid = + read_u32(context, value, maximum, &parsed) && parsed >= minimum; + JS_FreeValue(context, value); + return valid; +} + +static bool parse_body_error_cause(JSContext *context, JSValueConst command, + int32_t *out_cause_code) { + JSValue error = JS_UNDEFINED; + uint16_t category = 0U; + uint16_t code = 0U; + bool temporary = false; + uint8_t operation[POCKETJS_NET_ESP_RUNTIME_MAX_OPERATION_LABEL_BYTES]; + size_t operation_length = 0U; + uint8_t address[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_HOST_BYTES]; + uint8_t cause[POCKETJS_NET_ESP_RUNTIME_MAX_CAUSE_CODE_BYTES]; + const bool valid = + own_data_property(context, command, "error", &error) && + object_u16(context, error, "category", + POCKETJS_NETWORK_V1_ERROR_CATEGORY_PROTOCOL, &category) && + category >= POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME && + object_u16(context, error, "code", INT16_MAX, &code) && + known_error_code(code) && + object_latin1(context, error, "operation", operation, sizeof(operation), + &operation_length, false, false) && + object_bool(context, error, "temporary", &temporary) && + optional_latin1_property(context, error, "address", address, + sizeof(address)) && + optional_u32_property(context, error, "port", 1U, UINT16_MAX) && + optional_latin1_property(context, error, "causeCode", cause, + sizeof(cause)) && + optional_u32_property(context, error, "reasonCode", 0U, UINT32_MAX); + (void)temporary; + JS_FreeValue(context, error); + if (valid) { + *out_cause_code = (int32_t)code; + } + return valid; +} + +static JSValue binding_dispatch(JSContext *context, JSValueConst this_value, + int argument_count, JSValueConst *arguments, + int magic, void *opaque) { + (void)this_value; + (void)magic; + pocketjs_net_esp_runtime_t *runtime = binding_runtime(context, opaque); + if (runtime == NULL) { + return JS_EXCEPTION; + } + if (argument_count != 1 || !JS_IsObject(arguments[0])) { + return binding_type_error(context, "dispatch requires one command object"); + } + runtime->binding_call_active = true; + uint16_t opcode = 0U; + pocketjs_network_v1_command_identity_t identity = {0}; + pocketjs_net_esp_runtime_error_t error = {0}; + bool accepted = + object_u16(context, arguments[0], "opcode", UINT16_MAX, &opcode) && + parse_identity(context, arguments[0], &identity); + if (!accepted) { + runtime->binding_call_active = false; + return binding_type_error(context, + "command is not an accessor-free ABI value"); + } + + switch (opcode) { + case POCKETJS_NETWORK_V1_COMMAND_HTTP_REQUEST_START: { + if (!parse_http_command(context, arguments[0], &runtime->command_scratch) || + !parse_optional_input( + context, arguments[0], + &runtime->command_scratch.borrowed_input_present)) { + runtime->binding_call_active = false; + return binding_type_error( + context, "HTTP metadata is malformed or exceeds a bound"); + } + accepted = pocketjs_net_esp_runtime_start_http( + runtime, &identity, &runtime->command_scratch, &error); + break; + } + case POCKETJS_NETWORK_V1_COMMAND_OPERATION_CANCEL: + accepted = pocketjs_net_esp_runtime_cancel(runtime, &identity, &error); + break; + case POCKETJS_NETWORK_V1_COMMAND_BODY_PULL: { + uint32_t maximum = 0U; + accepted = + object_u32(context, arguments[0], "maxBytes", UINT32_MAX, &maximum) && + pocketjs_net_esp_runtime_grant_body_credit(runtime, &identity, maximum, + &error); + break; + } + case POCKETJS_NETWORK_V1_COMMAND_BODY_CHUNK: { + JSValue input = JS_UNDEFINED; + JSValue bytes_value = JS_UNDEFINED; + uint16_t kind = 0U; + uint8_t *bytes = NULL; + size_t length = 0U; + accepted = own_data_property(context, arguments[0], "input", &input) && + object_u16(context, input, "kind", UINT16_MAX, &kind) && + kind == POCKETJS_NETWORK_V1_BORROWED_INPUT_BODY_CHUNK && + own_data_property(context, input, "bytes", &bytes_value) && + typed_array_window(context, bytes_value, &bytes, &length) && + pocketjs_net_esp_runtime_submit_body_chunk( + runtime, &identity, bytes, length, &error); + JS_FreeValue(context, bytes_value); + JS_FreeValue(context, input); + break; + } + case POCKETJS_NETWORK_V1_COMMAND_BODY_END: + accepted = + pocketjs_net_esp_runtime_submit_body_end(runtime, &identity, &error); + break; + case POCKETJS_NETWORK_V1_COMMAND_BODY_ERROR: { + int32_t cause_code = 0; + if (!parse_body_error_cause(context, arguments[0], &cause_code)) { + runtime->binding_call_active = false; + return binding_type_error(context, "BODY_ERROR metadata is malformed"); + } + accepted = pocketjs_net_esp_runtime_submit_body_error(runtime, &identity, + cause_code, &error); + break; + } + case POCKETJS_NETWORK_V1_COMMAND_BODY_CANCEL: + accepted = pocketjs_net_esp_runtime_cancel_body(runtime, &identity, &error); + break; + default: + if (pocketjs_net_esp_runtime_validate_identity(runtime, &identity, true, + &error)) { + set_error(&error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_UNSUPPORTED, "network.dispatch", + false); + } + accepted = false; + break; + } + runtime->binding_call_active = false; + if (!accepted && error.code == 0U) { + set_error(&error, POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME, + POCKETJS_NETWORK_V1_ERROR_INVALID_STATE, "network.dispatch", + false); + } + return new_dispatch_result(context, accepted, &error); +} + +static pocketjs_net_esp_runtime_error_t +core_error(const pocketjs_net_esp_runtime_slot_t *slot) { + const pocketjs_net_http_client_error_t code = slot->event.detail.error.code; + pocketjs_net_esp_runtime_error_t error = { + .operation = slot->headers_delivered ? "http.body.read" : "http.fetch", + .temporary = false, + .cause_code = slot->event.detail.error.cause_code, + .has_cause_code = slot->event.detail.error.cause_code != 0, + }; + switch (code) { + case POCKETJS_NET_HTTP_CLIENT_ERROR_ABORTED: + error.category = POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME; + error.code = POCKETJS_NETWORK_V1_ERROR_ABORTED; + break; + case POCKETJS_NET_HTTP_CLIENT_ERROR_TIMED_OUT: + error.category = POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME; + error.code = POCKETJS_NETWORK_V1_ERROR_TIMED_OUT; + error.temporary = true; + break; + case POCKETJS_NET_HTTP_CLIENT_ERROR_PERMISSION_DENIED: + error.category = POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME; + error.code = POCKETJS_NETWORK_V1_ERROR_PERMISSION_DENIED; + break; + case POCKETJS_NET_HTTP_CLIENT_ERROR_DNS: + error.category = POCKETJS_NETWORK_V1_ERROR_CATEGORY_RESOLVER; + error.code = POCKETJS_NETWORK_V1_ERROR_DNS_TEMPORARY_FAILURE; + error.temporary = true; + break; + case POCKETJS_NET_HTTP_CLIENT_ERROR_CONNECT: + error.category = POCKETJS_NETWORK_V1_ERROR_CATEGORY_TRANSPORT; + error.code = POCKETJS_NETWORK_V1_ERROR_CONNECTION_REFUSED; + error.temporary = true; + break; + case POCKETJS_NET_HTTP_CLIENT_ERROR_IO: + case POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT: + error.category = POCKETJS_NETWORK_V1_ERROR_CATEGORY_TRANSPORT; + error.code = POCKETJS_NETWORK_V1_ERROR_CONNECTION_RESET; + error.temporary = true; + break; + case POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT: + case POCKETJS_NET_HTTP_CLIENT_ERROR_REDIRECT_LIMIT: + error.category = POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME; + error.code = POCKETJS_NETWORK_V1_ERROR_RESOURCE_LIMIT; + break; + case POCKETJS_NET_HTTP_CLIENT_ERROR_UNSUPPORTED: + error.category = POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME; + error.code = POCKETJS_NETWORK_V1_ERROR_UNSUPPORTED; + break; + case POCKETJS_NET_HTTP_CLIENT_ERROR_REDIRECT_BODY_NOT_REPLAYABLE: + error.category = POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME; + error.code = POCKETJS_NETWORK_V1_ERROR_INVALID_STATE; + break; + case POCKETJS_NET_HTTP_CLIENT_ERROR_PROTOCOL: + case POCKETJS_NET_HTTP_CLIENT_ERROR_REQUEST_BODY: + case POCKETJS_NET_HTTP_CLIENT_ERROR_REDIRECT: + error.category = POCKETJS_NETWORK_V1_ERROR_CATEGORY_PROTOCOL; + error.code = POCKETJS_NETWORK_V1_ERROR_HTTP_PROTOCOL_ERROR; + break; + case POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_CERTIFICATE_INVALID: + error.category = POCKETJS_NETWORK_V1_ERROR_CATEGORY_TLS; + error.code = POCKETJS_NETWORK_V1_ERROR_TLS_CERTIFICATE_INVALID; + break; + case POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_HOSTNAME_MISMATCH: + error.category = POCKETJS_NETWORK_V1_ERROR_CATEGORY_TLS; + error.code = POCKETJS_NETWORK_V1_ERROR_TLS_HOSTNAME_MISMATCH; + break; + case POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_HANDSHAKE_FAILED: + error.category = POCKETJS_NETWORK_V1_ERROR_CATEGORY_TLS; + error.code = POCKETJS_NETWORK_V1_ERROR_TLS_HANDSHAKE_FAILED; + break; + case POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_VERSION_UNSUPPORTED: + error.category = POCKETJS_NETWORK_V1_ERROR_CATEGORY_TLS; + error.code = POCKETJS_NETWORK_V1_ERROR_TLS_VERSION_UNSUPPORTED; + break; + case POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_ALERT: + error.category = POCKETJS_NETWORK_V1_ERROR_CATEGORY_TLS; + error.code = POCKETJS_NETWORK_V1_ERROR_TLS_ALERT; + break; + default: + error.category = POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME; + error.code = POCKETJS_NETWORK_V1_ERROR_SYSTEM_ERROR; + break; + } + return error; +} + +static JSValue new_headers(JSContext *context, + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_net_http_client_header_t *headers, + size_t count) { + JSValue array = JS_NewArray(context); + if (JS_IsException(array)) { + return array; + } + for (size_t index = 0U; index < count; ++index) { + JSValue entry = JS_NewObject(context); + if (JS_IsException(entry) || + !set_property(context, entry, "name", + new_latin1_string(context, runtime, + headers[index].name.data, + headers[index].name.length)) || + !set_property(context, entry, "value", + new_latin1_string(context, runtime, + headers[index].value.data, + headers[index].value.length)) || + !freeze(context, entry) || + !set_array_property(context, array, (uint32_t)index, + JS_DupValue(context, entry))) { + JS_FreeValue(context, entry); + JS_FreeValue(context, array); + return JS_EXCEPTION; + } + JS_FreeValue(context, entry); + } + if (!freeze(context, array)) { + JS_FreeValue(context, array); + return JS_EXCEPTION; + } + return array; +} + +static JSValue new_response_metadata(JSContext *context, + pocketjs_net_esp_runtime_t *runtime, + pocketjs_net_esp_runtime_slot_t *slot) { + const pocketjs_net_http_client_event_t *event = &slot->event; + JSValue object = JS_NewObject(context); + if (JS_IsException(object) || + !set_property( + context, object, "status", + JS_NewUint32(context, event->detail.response.status_code)) || + !set_property( + context, object, "statusText", + new_latin1_string(context, runtime, + event->detail.response.status_text.data, + event->detail.response.status_text.length)) || + !set_property(context, object, "headers", + new_headers(context, runtime, + event->detail.response.headers, + event->detail.response.header_count)) || + !set_property(context, object, "url", + JS_NewStringLen( + context, (const char *)event->detail.response.url.data, + event->detail.response.url.length)) || + !set_property(context, object, "redirected", + JS_NewBool(context, event->detail.response.redirected)) || + !set_property( + context, object, "bufferedBodyBytes", + JS_NewInt64( + context, + (int64_t)runtime->limits.buffered_body_bytes.default_value)) || + !freeze(context, object)) { + JS_FreeValue(context, object); + return JS_EXCEPTION; + } + return object; +} + +static JSValue new_completion(JSContext *context, + pocketjs_net_esp_runtime_t *runtime, + pocketjs_net_esp_runtime_slot_t *slot, + uint64_t sequence) { + uint16_t event_code = 0U; + pocketjs_network_v1_handle_t body = {0U, 0U}; + switch (slot->event.type) { + case POCKETJS_NET_HTTP_CLIENT_EVENT_REQUEST_BODY_PULL: + event_code = POCKETJS_NETWORK_V1_EVENT_BODY_PULL; + body = slot->request_body; + break; + case POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS: + event_code = POCKETJS_NETWORK_V1_EVENT_HTTP_RESPONSE_HEADERS; + body = slot->response_body; + break; + case POCKETJS_NET_HTTP_CLIENT_EVENT_BODY: + event_code = POCKETJS_NETWORK_V1_EVENT_BODY_CHUNK; + body = slot->response_body; + break; + case POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE: + event_code = POCKETJS_NETWORK_V1_EVENT_BODY_END; + body = slot->response_body; + break; + case POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR: + event_code = slot->headers_delivered + ? POCKETJS_NETWORK_V1_EVENT_BODY_ERROR + : POCKETJS_NETWORK_V1_EVENT_HTTP_REQUEST_ERROR; + body = slot->headers_delivered ? slot->response_body : slot->request_body; + break; + default: + return binding_type_error(context, "Core produced an unknown event"); + } + JSValue object = JS_NewObject(context); + if (JS_IsException(object) || + !set_property(context, object, "eventCode", + JS_NewUint32(context, event_code)) || + !set_property(context, object, "identity", + new_identity(context, runtime, slot, body, sequence))) { + JS_FreeValue(context, object); + return JS_EXCEPTION; + } + if (event_code == POCKETJS_NETWORK_V1_EVENT_BODY_PULL) { + if (!set_property( + context, object, "maxBytes", + JS_NewUint32(context, (uint32_t)slot->event.detail.request_body_pull + .maximum_bytes))) { + JS_FreeValue(context, object); + return JS_EXCEPTION; + } + } else if (event_code == POCKETJS_NETWORK_V1_EVENT_HTTP_RESPONSE_HEADERS) { + if (!set_property(context, object, "metadata", + new_response_metadata(context, runtime, slot))) { + JS_FreeValue(context, object); + return JS_EXCEPTION; + } + } else if (event_code == POCKETJS_NETWORK_V1_EVENT_BODY_CHUNK) { + JSValue payload = JS_NewObject(context); + if (JS_IsException(payload) || + !set_property(context, payload, "runtimeGeneration", + JS_NewUint32(context, runtime->runtime_generation)) || + !set_property(context, payload, "lease", + new_handle(context, slot->lease)) || + !set_property(context, payload, "byteLength", + JS_NewUint32(context, slot->lease_byte_length)) || + !freeze(context, payload) || + !set_property(context, object, "payload", + JS_DupValue(context, payload))) { + JS_FreeValue(context, payload); + JS_FreeValue(context, object); + return JS_EXCEPTION; + } + JS_FreeValue(context, payload); + } else if (event_code == POCKETJS_NETWORK_V1_EVENT_HTTP_REQUEST_ERROR || + event_code == POCKETJS_NETWORK_V1_EVENT_BODY_ERROR) { + const pocketjs_net_esp_runtime_error_t error = core_error(slot); + if (!set_property(context, object, "error", new_error(context, &error))) { + JS_FreeValue(context, object); + return JS_EXCEPTION; + } + } + if (!freeze(context, object)) { + JS_FreeValue(context, object); + return JS_EXCEPTION; + } + return object; +} + +static JSValue new_poll_empty(JSContext *context, uint16_t status) { + JSValue object = JS_NewObject(context); + if (JS_IsException(object) || + !set_property(context, object, "status", JS_NewUint32(context, status)) || + !set_property(context, object, "payloadBytesDelivered", + JS_NewUint32(context, 0U)) || + !freeze(context, object)) { + JS_FreeValue(context, object); + return JS_EXCEPTION; + } + return object; +} + +static JSValue binding_next_completion(JSContext *context, + JSValueConst this_value, + int argument_count, + JSValueConst *arguments, int magic, + void *opaque) { + (void)this_value; + (void)magic; + pocketjs_net_esp_runtime_t *runtime = binding_runtime(context, opaque); + if (runtime == NULL) { + return JS_EXCEPTION; + } + if (argument_count != 1) { + return binding_type_error(context, + "nextCompletion requires one poll request"); + } + uint32_t generation = 0U; + uint32_t budget = 0U; + if (!object_u32(context, arguments[0], "runtimeGeneration", UINT32_MAX, + &generation) || + !object_u32(context, arguments[0], "maxPayloadBytes", UINT32_MAX, + &budget) || + generation != runtime->runtime_generation) { + return binding_type_error(context, "completion poll is stale or malformed"); + } + if (!runtime->service_call_active || !runtime->dispatcher_call_active) { + return binding_type_error(context, + "completion poll is outside its service turn"); + } + if (runtime->turn_events_remaining == 0U || budget == 0U || + runtime->turn_payload_remaining == 0U) { + const int ready = pocketjs_net_esp_runtime_completion_readiness(runtime); + if (ready < 0) { + return binding_type_error(context, "native readiness probe failed"); + } + runtime->turn_last_poll_status = + ready != 0 ? POCKETJS_NETWORK_V1_COMPLETION_POLL_BUDGET_EXHAUSTED + : POCKETJS_NETWORK_V1_COMPLETION_POLL_DRAINED; + return new_poll_empty(context, runtime->turn_last_poll_status); + } + runtime->binding_call_active = true; + pocketjs_net_esp_runtime_slot_t *slot = NULL; + const bool ready = pocketjs_net_esp_runtime_peek_event(runtime, &slot); + if (!ready || slot == NULL) { + runtime->binding_call_active = false; + const int pending = pocketjs_net_esp_runtime_completion_readiness(runtime); + if (pending < 0 || runtime->poison_flags != 0U) { + return binding_type_error(context, "native event queue is poisoned"); + } + runtime->turn_last_poll_status = + pending != 0 ? POCKETJS_NETWORK_V1_COMPLETION_POLL_BUDGET_EXHAUSTED + : POCKETJS_NETWORK_V1_COMPLETION_POLL_DRAINED; + return new_poll_empty(context, runtime->turn_last_poll_status); + } + const uint32_t payload = + slot->event.type == POCKETJS_NET_HTTP_CLIENT_EVENT_BODY + ? slot->lease_byte_length + : 0U; + if (budget == 0U || payload > budget || + payload > runtime->turn_payload_remaining) { + runtime->binding_call_active = false; + runtime->turn_last_poll_status = + POCKETJS_NETWORK_V1_COMPLETION_POLL_BUDGET_EXHAUSTED; + return new_poll_empty(context, + POCKETJS_NETWORK_V1_COMPLETION_POLL_BUDGET_EXHAUSTED); + } + if (runtime->completion_sequence >= POCKETJS_NETWORK_V1_SEQUENCE_MAX) { + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_SEQUENCE); + runtime->binding_call_active = false; + return binding_type_error(context, "completion sequence is exhausted"); + } + const uint64_t completion_sequence = runtime->completion_sequence + 1U; + JSValue completion = + new_completion(context, runtime, slot, completion_sequence); + if (JS_IsException(completion)) { + runtime->binding_call_active = false; + return completion; + } + JSValue result = JS_NewObject(context); + if (JS_IsException(result) || + !set_property( + context, result, "status", + JS_NewUint32(context, POCKETJS_NETWORK_V1_COMPLETION_POLL_ITEM)) || + !set_property(context, result, "completion", + JS_DupValue(context, completion)) || + !set_property(context, result, "payloadBytesDelivered", + JS_NewUint32(context, payload)) || + !freeze(context, result)) { + JS_FreeValue(context, completion); + JS_FreeValue(context, result); + runtime->binding_call_active = false; + return JS_EXCEPTION; + } + JS_FreeValue(context, completion); + if (slot->event.type != POCKETJS_NET_HTTP_CLIENT_EVENT_BODY && + !pocketjs_net_esp_runtime_retire_nonlease_event(runtime, slot)) { + JS_FreeValue(context, result); + runtime->binding_call_active = false; + return binding_type_error(context, "Core event retirement failed"); + } + runtime->completion_sequence = completion_sequence; + --runtime->turn_events_remaining; + runtime->turn_payload_remaining -= payload; + ++runtime->turn_events_observed; + runtime->turn_payload_observed += payload; + runtime->turn_last_sequence_observed = completion_sequence; + runtime->turn_last_poll_status = POCKETJS_NETWORK_V1_COMPLETION_POLL_ITEM; + if (slot->event.type == POCKETJS_NET_HTTP_CLIENT_EVENT_BODY) { + slot->lease_descriptor_delivered = true; + } + ++runtime->completions_delivered; + runtime->binding_call_active = false; + return result; +} + +static bool +parse_lease_command(JSContext *context, JSValueConst command, + uint16_t expected_opcode, + pocketjs_network_v1_command_identity_t *out_identity, + pocketjs_network_v1_handle_t *out_lease) { + uint16_t opcode = 0U; + JSValue lease = JS_UNDEFINED; + const bool valid = + object_u16(context, command, "opcode", UINT16_MAX, &opcode) && + opcode == expected_opcode && + parse_identity(context, command, out_identity) && + own_data_property(context, command, "lease", &lease) && + parse_handle(context, lease, out_lease); + JS_FreeValue(context, lease); + return valid; +} + +static JSValue binding_lease_take(JSContext *context, JSValueConst this_value, + int argument_count, JSValueConst *arguments, + int magic, void *opaque) { + (void)this_value; + (void)magic; + pocketjs_net_esp_runtime_t *runtime = binding_runtime(context, opaque); + if (runtime == NULL) { + return JS_EXCEPTION; + } + if (!runtime->service_call_active || !runtime->dispatcher_call_active) { + return binding_type_error(context, "leaseTake is outside its service turn"); + } + pocketjs_network_v1_command_identity_t identity = {0}; + pocketjs_network_v1_handle_t lease = {0}; + uint32_t byte_length = 0U; + if (argument_count != 1 || + !parse_lease_command(context, arguments[0], + POCKETJS_NETWORK_V1_COMMAND_BUFFER_LEASE_TAKE, + &identity, &lease) || + !object_u32(context, arguments[0], "byteLength", UINT32_MAX, + &byte_length)) { + return binding_type_error(context, "leaseTake command is malformed"); + } + runtime->binding_call_active = true; + pocketjs_net_esp_runtime_error_t error = {0}; + uint32_t taken_length = 0U; + const bool completed = pocketjs_net_esp_runtime_lease_take( + runtime, &identity, lease, byte_length, &taken_length, &error); + runtime->binding_call_active = false; + if (!completed) { + return new_sync_result(context, false, &error); + } + JSValue result = JS_NewObject(context); + if (JS_IsException(result) || + !set_property( + context, result, "status", + JS_NewUint32(context, POCKETJS_NETWORK_V1_DISPATCH_COMPLETED)) || + !set_property(context, result, "byteLength", + JS_NewUint32(context, taken_length)) || + !freeze(context, result)) { + JS_FreeValue(context, result); + return JS_EXCEPTION; + } + return result; +} + +static JSValue binding_lease_read_into(JSContext *context, + JSValueConst this_value, + int argument_count, + JSValueConst *arguments, int magic, + void *opaque) { + (void)this_value; + (void)magic; + pocketjs_net_esp_runtime_t *runtime = binding_runtime(context, opaque); + if (runtime == NULL) { + return JS_EXCEPTION; + } + if (!runtime->service_call_active || !runtime->dispatcher_call_active) { + return binding_type_error(context, + "leaseReadInto is outside its service turn"); + } + pocketjs_network_v1_command_identity_t identity = {0}; + pocketjs_network_v1_handle_t lease = {0}; + uint32_t offset = 0U; + uint32_t maximum = 0U; + uint8_t *destination = NULL; + size_t destination_length = 0U; + if (argument_count != 2 || + !parse_lease_command(context, arguments[0], + POCKETJS_NETWORK_V1_COMMAND_BUFFER_LEASE_READ_INTO, + &identity, &lease) || + !object_u32(context, arguments[0], "offset", UINT32_MAX, &offset) || + !object_u32(context, arguments[0], "maxBytes", UINT32_MAX, &maximum) || + !typed_array_window(context, arguments[1], &destination, + &destination_length)) { + return binding_type_error(context, "leaseReadInto command is malformed"); + } + runtime->binding_call_active = true; + pocketjs_net_esp_runtime_error_t error = {0}; + uint32_t copied = 0U; + const bool completed = pocketjs_net_esp_runtime_lease_read( + runtime, &identity, lease, offset, maximum, destination, + destination_length, &copied, &error); + runtime->binding_call_active = false; + if (!completed) { + return new_sync_result(context, false, &error); + } + JSValue result = JS_NewObject(context); + if (JS_IsException(result) || + !set_property( + context, result, "status", + JS_NewUint32(context, POCKETJS_NETWORK_V1_DISPATCH_COMPLETED)) || + !set_property(context, result, "bytesCopied", + JS_NewUint32(context, copied)) || + !freeze(context, result)) { + JS_FreeValue(context, result); + return JS_EXCEPTION; + } + return result; +} + +static JSValue binding_lease_release(JSContext *context, + JSValueConst this_value, + int argument_count, + JSValueConst *arguments, int magic, + void *opaque) { + (void)this_value; + (void)magic; + pocketjs_net_esp_runtime_t *runtime = binding_runtime(context, opaque); + if (runtime == NULL) { + return JS_EXCEPTION; + } + if (!runtime->service_call_active || !runtime->dispatcher_call_active) { + return binding_type_error(context, + "leaseRelease is outside its service turn"); + } + pocketjs_network_v1_command_identity_t identity = {0}; + pocketjs_network_v1_handle_t lease = {0}; + if (argument_count != 1 || + !parse_lease_command(context, arguments[0], + POCKETJS_NETWORK_V1_COMMAND_BUFFER_LEASE_RELEASE, + &identity, &lease)) { + return binding_type_error(context, "leaseRelease command is malformed"); + } + runtime->binding_call_active = true; + pocketjs_net_esp_runtime_error_t error = {0}; + const bool completed = + pocketjs_net_esp_runtime_lease_release(runtime, &identity, lease, &error); + runtime->binding_call_active = false; + return new_sync_result(context, completed, &error); +} + +static JSValue new_limit_entry(JSContext *context, const char *name, + pocketjs_net_esp_runtime_limit_range_t value) { + JSValue entry = JS_NewObject(context); + if (JS_IsException(entry) || + !set_property(context, entry, "name", JS_NewString(context, name)) || + !set_property(context, entry, "default", + JS_NewInt64(context, (int64_t)value.default_value)) || + !set_property(context, entry, "hard", + JS_NewInt64(context, (int64_t)value.hard)) || + !set_property(context, entry, "minimum", + JS_NewInt64(context, (int64_t)value.minimum)) || + !freeze(context, entry)) { + JS_FreeValue(context, entry); + return JS_EXCEPTION; + } + return entry; +} + +static bool set_feature_projection(JSContext *context, JSValueConst array, + const pocketjs_net_esp_runtime_t *runtime) { + for (uint32_t index = 0U; index < runtime->feature_count; ++index) { + if (!set_array_property( + context, array, index, + JS_NewUint32(context, runtime->feature_ids[index]))) { + return false; + } + } + return true; +} + +static JSValue binding_get_limits(JSContext *context, JSValueConst this_value, + int argument_count, JSValueConst *arguments, + int magic, void *opaque) { + (void)this_value; + (void)magic; + pocketjs_net_esp_runtime_t *runtime = binding_runtime(context, opaque); + if (runtime == NULL) { + return JS_EXCEPTION; + } + uint32_t generation = 0U; + uint16_t protocol = 0U; + uint16_t role = 0U; + if (argument_count != 1 || + !object_u32(context, arguments[0], "runtimeGeneration", UINT32_MAX, + &generation) || + !object_u16(context, arguments[0], "protocol", UINT16_MAX, &protocol) || + !object_u16(context, arguments[0], "role", UINT16_MAX, &role) || + generation != runtime->runtime_generation || + (protocol != POCKETJS_NETWORK_V1_LIMIT_PROTOCOL_ANY && + protocol != POCKETJS_NETWORK_V1_LIMIT_PROTOCOL_HTTP && + protocol != POCKETJS_NETWORK_V1_LIMIT_PROTOCOL_WEBSOCKET && + protocol != POCKETJS_NETWORK_V1_LIMIT_PROTOCOL_MQTT && + protocol != POCKETJS_NETWORK_V1_LIMIT_PROTOCOL_TCP && + protocol != POCKETJS_NETWORK_V1_LIMIT_PROTOCOL_UDP) || + (role != POCKETJS_NETWORK_V1_LIMIT_ROLE_ANY && + role != POCKETJS_NETWORK_V1_LIMIT_ROLE_CLIENT && + role != POCKETJS_NETWORK_V1_LIMIT_ROLE_SERVER)) { + return binding_type_error(context, "limits query is stale or malformed"); + } + const bool included = (protocol == POCKETJS_NETWORK_V1_LIMIT_PROTOCOL_ANY || + protocol == POCKETJS_NETWORK_V1_LIMIT_PROTOCOL_HTTP) && + (role == POCKETJS_NETWORK_V1_LIMIT_ROLE_ANY || + role == POCKETJS_NETWORK_V1_LIMIT_ROLE_CLIENT); + JSValue values = JS_NewArray(context); + JSValue features = JS_NewArray(context); + if (JS_IsException(values) || JS_IsException(features)) { + JS_FreeValue(context, features); + JS_FreeValue(context, values); + return JS_EXCEPTION; + } + if (included) { + static const char *const names[] = { + "http.bufferedBodyBytes", "http.headerBytes", + "http.maxBodyChunkBytes", "http.maxOperations", + "runtime.nativeBufferBytes", + }; + const pocketjs_net_esp_runtime_limit_range_t limits[] = { + runtime->limits.buffered_body_bytes, runtime->limits.header_bytes, + runtime->limits.max_body_chunk_bytes, runtime->limits.max_operations, + runtime->limits.native_buffer_bytes, + }; + for (uint32_t index = 0U; index < 5U; ++index) { + JSValue entry = new_limit_entry(context, names[index], limits[index]); + if (JS_IsException(entry) || + !set_array_property(context, values, index, + JS_DupValue(context, entry))) { + JS_FreeValue(context, entry); + JS_FreeValue(context, features); + JS_FreeValue(context, values); + return JS_EXCEPTION; + } + JS_FreeValue(context, entry); + } + if (!set_feature_projection(context, features, runtime)) { + JS_FreeValue(context, features); + JS_FreeValue(context, values); + return JS_EXCEPTION; + } + } + if (!freeze(context, values) || !freeze(context, features)) { + JS_FreeValue(context, features); + JS_FreeValue(context, values); + return JS_EXCEPTION; + } + JSValue snapshot = JS_NewObject(context); + if (JS_IsException(snapshot) || + !set_property(context, snapshot, "runtimeGeneration", + JS_NewUint32(context, generation)) || + !set_property(context, snapshot, "protocol", + JS_NewUint32(context, protocol)) || + !set_property(context, snapshot, "role", JS_NewUint32(context, role)) || + !set_property(context, snapshot, "values", + JS_DupValue(context, values)) || + !set_property(context, snapshot, "featureIds", + JS_DupValue(context, features)) || + !freeze(context, snapshot)) { + JS_FreeValue(context, features); + JS_FreeValue(context, values); + JS_FreeValue(context, snapshot); + return JS_EXCEPTION; + } + JS_FreeValue(context, features); + JS_FreeValue(context, values); + return snapshot; +} + +static JSValue binding_register_dispatcher(JSContext *context, + JSValueConst this_value, + int argument_count, + JSValueConst *arguments, int magic, + void *opaque) { + (void)this_value; + (void)magic; + pocketjs_net_esp_runtime_t *runtime = binding_runtime(context, opaque); + if (runtime == NULL) { + return JS_EXCEPTION; + } + if (argument_count != 1 || !JS_IsFunction(context, arguments[0]) || + runtime->dispatcher_registered || + runtime->phase != POCKETJS_NET_ESP_RUNTIME_PHASE_RUNNING) { + return binding_type_error(context, + "service dispatcher registration is invalid"); + } + runtime->dispatcher = JS_DupValue(context, arguments[0]); + runtime->dispatcher_registered = true; + return JS_UNDEFINED; +} + +static bool add_closure(JSContext *context, JSValueConst table, + const char *name, int length, JSCClosure *function, + pocketjs_net_esp_runtime_binding_state_t *state) { + /* + * A NULL function name leaves no fallible atom allocation after QuickJS has + * attached the opaque finalizer. The table property still has the ABI name. + */ + JSValue closure = JS_NewCClosure(context, function, NULL, + binding_state_release, length, 0, state); + if (JS_IsException(closure)) { + return false; + } + /* A returned closure already owns the opaque finalizer, even if an internal + * property allocation left a pending exception. Balance that ownership before + * observing the constructor's pending-exception boundary. */ + ++state->references; + if (JS_HasException(context)) { + JS_FreeValue(context, closure); + return false; + } + const bool installed = + set_property(context, table, name, JS_DupValue(context, closure)); + JS_FreeValue(context, closure); + return installed; +} + +esp_err_t +pocketjs_net_esp_runtime_create_binding(pocketjs_net_esp_runtime_t *runtime) { + pocketjs_net_esp_runtime_binding_state_t *state = calloc(1U, sizeof(*state)); + if (state == NULL) { + return ESP_ERR_NO_MEM; + } + state->runtime = runtime; + state->references = 1U; + JSContext *context = runtime->context; + if (JS_HasException(context)) { + pocketjs_esp_guest_log_exception(runtime->guest, + "network_binding_create_entry"); + binding_state_release(state); + return ESP_FAIL; + } + JSValue feature_ids = JS_NewArray(context); + JSValue plan_hash = JS_NewUint8ArrayCopy(context, runtime->plan_hash, + sizeof(runtime->plan_hash)); + JSValue handshake = JS_NewObject(context); + JSValue table = JS_NewObject(context); + if (JS_IsException(feature_ids) || JS_IsException(plan_hash) || + JS_IsException(handshake) || JS_IsException(table) || + !set_feature_projection(context, feature_ids, runtime) || + !freeze(context, feature_ids) || + !set_property(context, handshake, "abiMajor", + JS_NewUint32(context, POCKETJS_NETWORK_V1_ABI_MAJOR)) || + !set_property(context, handshake, "abiMinor", + JS_NewUint32(context, POCKETJS_NETWORK_V1_ABI_MINOR)) || + !set_property(context, handshake, "runtimeGeneration", + JS_NewUint32(context, runtime->runtime_generation)) || + !set_property(context, handshake, "planHash", + JS_DupValue(context, plan_hash)) || + !set_property(context, handshake, "featureIds", + JS_DupValue(context, feature_ids)) || + !freeze(context, handshake) || + !set_property(context, table, "handshake", + JS_DupValue(context, handshake)) || + !add_closure(context, table, "getLimits", 1, binding_get_limits, state) || + !add_closure(context, table, "dispatch", 1, binding_dispatch, state) || + !add_closure(context, table, "nextCompletion", 1, binding_next_completion, + state) || + !add_closure(context, table, "leaseTake", 1, binding_lease_take, state) || + !add_closure(context, table, "leaseReadInto", 2, binding_lease_read_into, + state) || + !add_closure(context, table, "leaseRelease", 1, binding_lease_release, + state) || + !add_closure(context, table, "registerServiceDispatcher", 1, + binding_register_dispatcher, state) || + !freeze(context, table) || JS_HasException(context)) { + JS_FreeValue(context, table); + JS_FreeValue(context, handshake); + JS_FreeValue(context, plan_hash); + JS_FreeValue(context, feature_ids); + binding_state_release(state); + if (JS_HasException(context)) { + pocketjs_esp_guest_log_exception(runtime->guest, + "network_binding_create"); + } + return ESP_FAIL; + } + JS_FreeValue(context, handshake); + JS_FreeValue(context, plan_hash); + JS_FreeValue(context, feature_ids); + runtime->binding_state = state; + runtime->binding = table; + return ESP_OK; +} + +void pocketjs_net_esp_runtime_revoke_binding( + pocketjs_net_esp_runtime_t *runtime) { + pocketjs_net_esp_runtime_binding_state_t *state = runtime->binding_state; + if (state != NULL) { + state->runtime = NULL; + } + JS_FreeValue(runtime->context, runtime->dispatcher); + runtime->dispatcher = JS_UNDEFINED; + runtime->dispatcher_registered = false; + JS_FreeValue(runtime->context, runtime->binding); + runtime->binding = JS_UNDEFINED; + runtime->binding_state = NULL; + binding_state_release(state); +} + +static bool +turn_has_outstanding_lease(const pocketjs_net_esp_runtime_t *runtime) { + for (size_t index = 0U; index < runtime->max_operations; ++index) { + const pocketjs_net_esp_runtime_slot_t *slot = &runtime->slots[index]; + if (slot->event_pending && + slot->event.type == POCKETJS_NET_HTTP_CLIENT_EVENT_BODY && + slot->lease_descriptor_delivered) { + return true; + } + } + return false; +} + +esp_err_t pocketjs_net_esp_runtime_call_dispatcher( + pocketjs_net_esp_runtime_t *runtime, + pocketjs_network_v1_service_turn_kind_t kind, uint32_t max_events, + uint32_t max_payload_bytes, + pocketjs_net_esp_runtime_service_result_t *out_result) { + if (!runtime->dispatcher_registered) { + *out_result = (pocketjs_net_esp_runtime_service_result_t){ + .status = POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_DRAINED, + }; + return ESP_OK; + } + if (!pocketjs_net_esp_runtime_next_sequence(&runtime->service_turn)) { + pocketjs_net_esp_runtime_poison(runtime, + POCKETJS_NET_ESP_RUNTIME_POISON_SEQUENCE); + return ESP_ERR_INVALID_STATE; + } + runtime->turn_events_remaining = max_events; + runtime->turn_payload_remaining = max_payload_bytes; + runtime->turn_events_observed = 0U; + runtime->turn_payload_observed = 0U; + runtime->turn_last_sequence_observed = 0U; + runtime->turn_last_poll_status = 0U; + JSContext *context = runtime->context; + JSValue request = JS_NewObject(context); + if (JS_IsException(request) || + !set_property(context, request, "runtimeGeneration", + JS_NewUint32(context, runtime->runtime_generation)) || + !set_property(context, request, "turnId", + JS_NewInt64(context, (int64_t)runtime->service_turn)) || + !set_property(context, request, "kind", JS_NewUint32(context, kind)) || + !set_property(context, request, "maxEvents", + JS_NewUint32(context, max_events)) || + !set_property(context, request, "maxPayloadBytes", + JS_NewUint32(context, max_payload_bytes)) || + !freeze(context, request)) { + JS_FreeValue(context, request); + return ESP_ERR_NO_MEM; + } + JSValue result = JS_UNDEFINED; + runtime->dispatcher_call_active = true; + esp_err_t call_result = pocketjs_esp_guest_call_function( + runtime->guest, "network_service_dispatcher", runtime->dispatcher, + runtime->binding, 1U, &request, &result); + runtime->dispatcher_call_active = false; + JS_FreeValue(context, request); + if (call_result != ESP_OK) { + /* The Guest contract returns undefined on failure; freeing is also safe if + * a hostile implementation transferred an owned value before failing. */ + JS_FreeValue(context, result); + return call_result; + } + if (turn_has_outstanding_lease(runtime)) { + JS_FreeValue(context, result); + return ESP_ERR_INVALID_RESPONSE; + } + uint16_t status = 0U; + uint32_t events = 0U; + uint32_t payload = 0U; + uint64_t last_sequence = 0U; + const bool valid = + object_u16(context, result, "status", UINT16_MAX, &status) && + object_u32(context, result, "eventsDelivered", UINT32_MAX, &events) && + object_u32(context, result, "payloadBytesDelivered", UINT32_MAX, + &payload) && + object_u64(context, result, "lastSequence", + POCKETJS_NETWORK_V1_SEQUENCE_MAX, &last_sequence) && + (status == POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_DRAINED || + status == POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_MORE_READY) && + events == runtime->turn_events_observed && + payload == runtime->turn_payload_observed && + last_sequence == runtime->turn_last_sequence_observed && + ((status == POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_DRAINED && + runtime->turn_last_poll_status == + POCKETJS_NETWORK_V1_COMPLETION_POLL_DRAINED) || + (status == POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_MORE_READY && + (runtime->turn_last_poll_status == + POCKETJS_NETWORK_V1_COMPLETION_POLL_BUDGET_EXHAUSTED || + (runtime->turn_last_poll_status == + POCKETJS_NETWORK_V1_COMPLETION_POLL_ITEM && + runtime->turn_events_remaining == 0U)))); + JS_FreeValue(context, result); + if (!valid) { + return ESP_ERR_INVALID_RESPONSE; + } + *out_result = (pocketjs_net_esp_runtime_service_result_t){ + .status = status, + .events_delivered = events, + .payload_bytes_delivered = payload, + .last_sequence = last_sequence, + }; + return ESP_OK; +} diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/src/runtime_contract.c b/hosts/esp-idf/components/pocketjs_net_esp_runtime/src/runtime_contract.c new file mode 100644 index 00000000..dcbcfc25 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/src/runtime_contract.c @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT + +#include "runtime_contract.h" + +#include + +bool pocketjs_net_esp_runtime_feature_projection_valid( + const pocketjs_network_v1_feature_id_t *feature_ids, + uint16_t feature_count) { + return feature_ids != NULL && + (feature_count == 1U || + (feature_count == 2U && + feature_ids[1] == POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_TLS)) && + feature_ids[0] == POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT; +} + +bool pocketjs_net_esp_runtime_feature_projection_has_tls( + const pocketjs_network_v1_feature_id_t *feature_ids, + uint16_t feature_count) { + return pocketjs_net_esp_runtime_feature_projection_valid(feature_ids, + feature_count) && + feature_count == 2U; +} + +bool pocketjs_net_esp_runtime_next_sequence(uint64_t *sequence) { + if (sequence == NULL || *sequence >= POCKETJS_NETWORK_V1_SEQUENCE_MAX) { + return false; + } + ++*sequence; + return true; +} + +bool pocketjs_net_esp_runtime_live_handle(pocketjs_network_v1_handle_t handle) { + return pocketjs_network_v1_handle_is_live(handle) != 0; +} + +bool pocketjs_net_esp_runtime_same_handle(pocketjs_network_v1_handle_t left, + pocketjs_network_v1_handle_t right) { + return left.id == right.id && left.generation == right.generation; +} + +static bool ascii_equal_case(const uint8_t *left, size_t left_length, + const char *right) { + const size_t right_length = strlen(right); + if (left == NULL || left_length != right_length) { + return false; + } + for (size_t index = 0U; index < left_length; ++index) { + uint8_t value = left[index]; + if (value >= 'A' && value <= 'Z') { + value = (uint8_t)(value + ('a' - 'A')); + } + if (value != (uint8_t)right[index]) { + return false; + } + } + return true; +} + +pocketjs_net_esp_redirect_action_t +pocketjs_net_esp_runtime_redirect_action(unsigned status, const uint8_t *method, + size_t method_length) { + if (status != 301U && status != 302U && status != 303U && status != 307U && + status != 308U) { + return POCKETJS_NET_ESP_REDIRECT_NOT_REDIRECT; + } + if (status == 303U || ((status == 301U || status == 302U) && + ascii_equal_case(method, method_length, "post"))) { + return POCKETJS_NET_ESP_REDIRECT_REWRITE_GET; + } + return POCKETJS_NET_ESP_REDIRECT_PRESERVE_METHOD_BODY; +} + +bool pocketjs_net_esp_runtime_sensitive_redirect_header(const uint8_t *name, + size_t name_length) { + return ascii_equal_case(name, name_length, "authorization") || + ascii_equal_case(name, name_length, "proxy-authorization") || + ascii_equal_case(name, name_length, "cookie"); +} diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/.gitignore b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/.gitignore new file mode 100644 index 00000000..d6bd44b7 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/.gitignore @@ -0,0 +1,6 @@ +/build/ +/build-*/ +/managed_components/ +/dependencies.lock +/sdkconfig +/sdkconfig.old diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/CMakeLists.txt new file mode 100644 index 00000000..eb679a46 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/CMakeLists.txt @@ -0,0 +1,12 @@ +cmake_minimum_required(VERSION 3.16) + +set(EXTRA_COMPONENT_DIRS + "../.." + "../../../pocketjs_esp_guest" + "../../../pocketjs_net_http1" + "../../../pocketjs_net_esp_transport" + "../../../pocketjs_net_http_client_core") +set(SDKCONFIG_DEFAULTS "sdkconfig.defaults") +set(SDKCONFIG "${CMAKE_BINARY_DIR}/sdkconfig") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(pocketjs_net_esp_runtime_build_smoke) diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/README.md b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/README.md new file mode 100644 index 00000000..e6a20b18 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/README.md @@ -0,0 +1,27 @@ +# ESP runtime build smoke + +This app compiles the formal ABI 1.1 binding with the experimental ESP-IDF +HTTP runtime, mounts it through `pocketjs_esp_guest`, exercises `getLimits` +and a guarded `nextCompletion` service turn, then completes the three-phase +shutdown contract. A frame retained by the Guest also verifies that binding +closures fail with `TypeError` after runtime destruction instead of accessing +freed runtime memory. + +Build both targets with the exact PocketJS ESP-IDF baseline: + +```sh +source /Users/halfsweet/Documents/GitHub/esp-idf-v6-pocketjs-network/export.sh +idf.py -B build-current-esp32s3 set-target esp32s3 +idf.py -B build-current-esp32s3 build +idf.py -B build-current-esp32p4 set-target esp32p4 +idf.py -B build-current-esp32p4 build +``` + +The P4 defaults select **ESP32-P4 revision 1.0 or newer** and the +`SELECTS_REV_LESS_V3` compatibility path required by the Tab5 revision 1.3 +silicon. They also retain the 1000 Hz FreeRTOS tick. The smoke app does not +write eFuses or modify the Tab5 ESP32-C6 companion. + +The component descriptor deliberately keeps public capability advertisement +disabled while redirect URL differential conformance, descriptor aggregation, +resource admission, and the complete hardware gate remain incomplete. diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/main/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/main/CMakeLists.txt new file mode 100644 index 00000000..17aef747 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "build_smoke.c" + INCLUDE_DIRS "." + REQUIRES pocketjs_net_esp_runtime +) diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/main/build_smoke.c b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/main/build_smoke.c new file mode 100644 index 00000000..b7dea4a9 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/main/build_smoke.c @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: MIT + +#include +#include + +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "pocketjs/net/esp_runtime.h" + +static bool permission_called; +static bool wake_called; +static unsigned tls_clock_context; +static const char *const TAG = "pocketjs_net_runtime"; + +static bool +allow_endpoint(void *context, + const pocketjs_net_http_client_endpoint_t *endpoint) { + (void)context; + assert(endpoint != NULL); + permission_called = true; + return true; +} + +static void wake_runtime(void *context) { + (void)context; + wake_called = true; +} + +static bool trusted_wall_clock(void *context) { + assert(context == &tls_clock_context); + ++tls_clock_context; + return true; +} + +static const char FACTORY[] = + "(binding => {" + " if (!Object.isFrozen(binding) || !Object.isFrozen(binding.handshake))" + " throw new Error('binding is mutable');" + " if (binding.handshake.abiMajor !== 1 ||" + " binding.handshake.abiMinor !== 1 ||" + " binding.handshake.runtimeGeneration !== 7 ||" + " binding.handshake.featureIds.length !== 1 ||" + " binding.handshake.featureIds[0] !== 256 ||" + " binding.handshake.planHash.byteLength !== 32)" + " throw new Error('bad handshake');" + " const limits = binding.getLimits(Object.freeze({" + " runtimeGeneration: 7, protocol: 1, role: 1" + " }));" + " if (!Object.isFrozen(limits) || limits.featureIds[0] !== 256 ||" + " limits.values.length !== 5) throw new Error('bad limits');" + " binding.registerServiceDispatcher(request => {" + " const poll = binding.nextCompletion(Object.freeze({" + " runtimeGeneration: 7, maxPayloadBytes: request.maxPayloadBytes" + " }));" + " if (!Object.isFrozen(poll) || poll.status !== 2 ||" + " poll.payloadBytesDelivered !== 0)" + " throw new Error('completion poll did not drain');" + " return Object.freeze({" + " status: 1, eventsDelivered: 0, payloadBytesDelivered: 0," + " lastSequence: 0" + " });" + " });" + " let frameCount = 0;" + " globalThis.frame = () => {" + " frameCount += 1;" + " if (frameCount !== 1) {" + " let revoked = false;" + " try {" + " binding.getLimits(Object.freeze({" + " runtimeGeneration: 7, protocol: 1, role: 1" + " }));" + " } catch (error) {" + " revoked = error instanceof TypeError;" + " }" + " if (!revoked) throw new Error('stale binding was callable');" + " return;" + " }" + " const handle = (id, generation) => Object.freeze({id, generation});" + " const result = binding.dispatch(Object.freeze({" + " opcode: 256," + " identity: Object.freeze({" + " runtimeGeneration: 7, resource: handle(1, 1)," + " operation: handle(1, 1), body: handle(0, 0), commandSequence: 1" + " })," + " metadata: Object.freeze({" + " url: 'https://example.com/', method: 'GET'," + " headers: Object.freeze([]), hasBody: false, redirect: 2," + " timeouts: Object.freeze({connectMs: 0, headersMs: 0, idleMs: 0, " + "totalMs: 0})," + " maxRedirects: 5, tls: Object.freeze({" + " serverName: 'example.com', minVersion: 258, maxVersion: 258," + " alpn: Object.freeze([]), credential: ''," + " clientCertificate: 1, verification: 1, revocation: 1," + " customCaBytes: 0" + " })," + " limits: Object.freeze([]), ref: true" + " })" + " }));" + " if (result.status !== 3 || result.error.code !== 262)" + " throw new Error('HTTPS did not fail closed');" + " };" + "})"; + +static const char TLS_FACTORY[] = + "(binding => {" + " const featureIds = binding.handshake.featureIds;" + " if (featureIds.length !== 2 || featureIds[0] !== 256 ||" + " featureIds[1] !== 257) throw new Error('bad TLS handshake');" + " const limits = binding.getLimits(Object.freeze({" + " runtimeGeneration: 7, protocol: 1, role: 1" + " }));" + " if (limits.featureIds.length !== 2 ||" + " limits.featureIds[0] !== 256 || limits.featureIds[1] !== 257)" + " throw new Error('bad TLS limits');" + " binding.registerServiceDispatcher(request => Object.freeze({" + " status: 1, eventsDelivered: 0, payloadBytesDelivered: 0," + " lastSequence: 0" + " }));" + " globalThis.frame = () => {" + " const handle = (id, generation) => Object.freeze({id, generation});" + " const result = binding.dispatch(Object.freeze({" + " opcode: 256," + " identity: Object.freeze({" + " runtimeGeneration: 7, resource: handle(1, 1)," + " operation: handle(1, 1), body: handle(0, 0), commandSequence: 1" + " })," + " metadata: Object.freeze({" + " url: 'https://example.com/', method: 'GET'," + " headers: Object.freeze([]), hasBody: false, redirect: 2," + " timeouts: Object.freeze({connectMs: 0, headersMs: 0, idleMs: 0," + " totalMs: 0}), maxRedirects: 5," + " tls: Object.freeze({" + " serverName: 'wrong.example', minVersion: 258, maxVersion: 258," + " alpn: Object.freeze([]), credential: ''," + " clientCertificate: 1, verification: 1, revocation: 1," + " customCaBytes: 0" + " }), limits: Object.freeze([]), ref: true" + " })" + " }));" + " if (result.status !== 3 || result.error.code !== 262)" + " throw new Error('bad TLS metadata did not fail closed');" + " };" + "})"; + +void app_main(void) { + const pocketjs_net_esp_runtime_descriptor_t *descriptor = + pocketjs_net_esp_runtime_descriptor(); + assert(descriptor != NULL); + assert(strcmp(descriptor->id, POCKETJS_NET_ESP_RUNTIME_ID) == 0); + assert(descriptor->experimental); + assert(!descriptor->advertises_public_capability); + assert(descriptor->abi_major == 1U && descriptor->abi_minor == 1U); + assert(descriptor->owner_only_quickjs); + assert(!descriptor->worker_or_callback_calls_quickjs); + assert(descriptor->frozen_accessor_free_binding); + assert(descriptor->exact_plan_handshake); + assert(descriptor->fixed_operation_pool); + assert(descriptor->pocketjs_owned_native_buffer_floor_enforced); + assert(descriptor->validation_snapshot_bytes == + POCKETJS_NET_ESP_TRANSPORT_MAX_PINNED_CA_PEM_BYTES + 1U); + assert(descriptor->connection_reuse); + assert(descriptor->bounded_connection_pool); + assert(descriptor->max_cached_connections == descriptor->max_operations); + assert(descriptor->exact_lease_ownership); + assert(descriptor->explicit_three_phase_shutdown); + assert(descriptor->plaintext_http); + assert(descriptor->https_rejected_before_io); + assert(descriptor->https_explicit_opt_in); + assert(descriptor->exact_host_tls_profile); + assert(descriptor->distinct_tls_errors); + assert(descriptor->tls_close_notify); + assert(descriptor->tls_close_notify_uses_operation_deadline); + assert(!descriptor->tls_close_notify_waits_for_peer); + assert(strcmp(descriptor->tls_provider_id, + POCKETJS_NET_ESP_TLS_PROVIDER_ID) == 0); + assert(descriptor->redirect_manual); + assert(descriptor->redirect_error); + assert(descriptor->redirect_follow); + assert(!descriptor->redirect_replayable_stream_body); + assert(descriptor->guest_execution_guarded_dispatch); + + pocketjs_esp_guest_t *guest = NULL; + const pocketjs_esp_guest_config_t guest_config = { + .memory_limit_bytes = 1024U * 1024U, + .stack_limit_bytes = 24U * 1024U, + .execution_timeout_us = 1000000U, + .max_interrupt_checks = 100U, + }; + assert(pocketjs_esp_guest_create(&guest_config, &guest) == ESP_OK); + + static const pocketjs_network_v1_feature_id_t features[] = { + POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT, + }; + pocketjs_net_esp_runtime_t *runtime = NULL; + pocketjs_net_esp_runtime_config_t config = { + .guest = guest, + .runtime_generation = 7U, + .feature_ids = features, + .feature_count = 1U, + .providers = + { + .http_client_backend_id = POCKETJS_NET_HTTP_CLIENT_CORE_ID, + .net_driver_id = POCKETJS_NET_ESP_TRANSPORT_ID, + .http_client_tls_source = + POCKETJS_NET_ESP_RUNTIME_TLS_SELECTION_NONE, + }, + .admission = POCKETJS_NET_ESP_RUNTIME_ADMISSION_TEST_ONLY, + .max_operations = 1U, + .limits = + { + .buffered_body_bytes = {512U, 4096U, 16384U}, + .header_bytes = {1024U, 4096U, 8192U}, + .max_body_chunk_bytes = {512U, 2048U, 2048U}, + .max_operations = {1U, 1U, 1U}, + .native_buffer_bytes = {32768U, 524288U, 524288U}, + }, + .connect_timeout_us = 1000000U, + .headers_timeout_us = 1000000U, + .idle_timeout_us = 1000000U, + .total_timeout_us = 4000000U, + .wake = wake_runtime, + .allow_endpoint = allow_endpoint, + }; + memset(config.plan_hash, 0x5a, sizeof(config.plan_hash)); + size_t required_native_bytes = 0U; + assert(pocketjs_net_esp_runtime_required_native_buffer_bytes( + config.max_operations, &required_native_bytes)); + assert(required_native_bytes <= + config.limits.native_buffer_bytes.default_value); + pocketjs_net_esp_runtime_config_t underbudgeted = config; + underbudgeted.limits.native_buffer_bytes.default_value = + required_native_bytes - 1U; + assert(pocketjs_net_esp_runtime_create(&underbudgeted, &runtime) == + ESP_ERR_INVALID_ARG); + assert(runtime == NULL); + ESP_LOGI(TAG, "network runtime owner task=%p", + (void *)xTaskGetCurrentTaskHandle()); + assert(pocketjs_net_esp_runtime_create(&config, &runtime) == ESP_OK); + + JSValue binding = JS_UNDEFINED; + assert(pocketjs_net_esp_runtime_get_binding(runtime, &binding) == ESP_OK); + assert(pocketjs_esp_guest_mount_factory(guest, "esp-runtime-smoke.js", + FACTORY, sizeof(FACTORY) - 1U, + binding, NULL) == ESP_OK); + JS_FreeValue(pocketjs_esp_guest_context(guest), binding); + assert(pocketjs_esp_guest_call_frame(guest, 0U, NULL) == ESP_OK); + + pocketjs_net_esp_runtime_service_result_t service = {0}; + assert(pocketjs_net_esp_runtime_service(runtime, + (uint64_t)esp_timer_get_time(), 1U, + 1U, 8U, 4096U, &service) == ESP_OK); + assert(service.status == POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_DRAINED); + assert(service.events_delivered == 0U); + assert(!permission_called); + + assert(pocketjs_net_esp_runtime_begin_shutdown( + runtime, (uint64_t)esp_timer_get_time()) == ESP_OK); + assert(wake_called); + assert(pocketjs_net_esp_runtime_service(runtime, + (uint64_t)esp_timer_get_time(), 1U, + 1U, 8U, 4096U, &service) == ESP_OK); + assert(pocketjs_net_esp_runtime_is_ready_to_destroy(runtime)); + + pocketjs_net_esp_runtime_stats_t stats; + assert(pocketjs_net_esp_runtime_get_stats(runtime, &stats) == ESP_OK); + assert(stats.runtime_generation == 7U); + assert(stats.configured_operation_slots == 1U); + assert(stats.active_operations == 0U); + assert(stats.initialized_operation_slots == 0U); + assert(stats.last_command_sequence == 1U); + assert(stats.poison_flags == 0U); + + assert(pocketjs_net_esp_runtime_destroy(runtime) == ESP_OK); + assert(pocketjs_esp_guest_call_frame(guest, 0U, NULL) == ESP_OK); + pocketjs_esp_guest_destroy(guest); + + permission_called = false; + wake_called = false; + guest = NULL; + assert(pocketjs_esp_guest_create(&guest_config, &guest) == ESP_OK); + static const pocketjs_network_v1_feature_id_t tls_features[] = { + POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT, + POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_TLS, + }; + pocketjs_net_esp_runtime_config_t tls_config = config; + tls_config.guest = guest; + tls_config.feature_ids = tls_features; + tls_config.feature_count = 2U; + tls_config.providers.http_client_tls_source = + POCKETJS_NET_ESP_RUNTIME_TLS_SELECTION_PROVIDER; + tls_config.providers.http_client_tls_id = POCKETJS_NET_ESP_TLS_PROVIDER_ID; + tls_config.tls_trust_source = POCKETJS_NET_ESP_TLS_TRUST_CERTIFICATE_BUNDLE; + tls_config.wall_clock_trusted = trusted_wall_clock; + tls_config.wall_clock_context = &tls_clock_context; + runtime = NULL; + assert(pocketjs_net_esp_runtime_create(&tls_config, &runtime) == ESP_OK); + binding = JS_UNDEFINED; + assert(pocketjs_net_esp_runtime_get_binding(runtime, &binding) == ESP_OK); + assert(pocketjs_esp_guest_mount_factory(guest, "esp-runtime-tls-smoke.js", + TLS_FACTORY, sizeof(TLS_FACTORY) - 1U, + binding, NULL) == ESP_OK); + JS_FreeValue(pocketjs_esp_guest_context(guest), binding); + assert(pocketjs_esp_guest_call_frame(guest, 0U, NULL) == ESP_OK); + assert(!permission_called && tls_clock_context == 0U); + + assert(pocketjs_net_esp_runtime_begin_shutdown( + runtime, (uint64_t)esp_timer_get_time()) == ESP_OK); + for (size_t turn = 0U; + turn < 4U && !pocketjs_net_esp_runtime_is_ready_to_destroy(runtime); + ++turn) { + assert(pocketjs_net_esp_runtime_service(runtime, + (uint64_t)esp_timer_get_time(), 1U, + 1U, 8U, 4096U, &service) == ESP_OK); + } + assert(wake_called && pocketjs_net_esp_runtime_is_ready_to_destroy(runtime)); + assert(pocketjs_net_esp_runtime_get_stats(runtime, &stats) == ESP_OK); + assert(stats.configured_operation_slots == 1U && + stats.initialized_operation_slots == 1U && + stats.active_operations == 0U && stats.requests_started == 0U && + stats.last_command_sequence == 1U && stats.poison_flags == 0U); + assert(pocketjs_net_esp_runtime_destroy(runtime) == ESP_OK); + pocketjs_esp_guest_destroy(guest); + ESP_LOGI(TAG, "POCKET_NET_ESP_RUNTIME_SMOKE PASS"); +} diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/main/idf_component.yml b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/main/idf_component.yml new file mode 100644 index 00000000..a6289a63 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/main/idf_component.yml @@ -0,0 +1,3 @@ +dependencies: + pocketjs_net_esp_runtime: + override_path: ../../.. diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/partitions.csv b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/partitions.csv new file mode 100644 index 00000000..8b6aed9c --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/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, 0x1f0000, diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/sdkconfig.defaults b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/sdkconfig.defaults new file mode 100644 index 00000000..776d87d9 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/sdkconfig.defaults @@ -0,0 +1,16 @@ +CONFIG_COMPILER_OPTIMIZATION_SIZE=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" +CONFIG_LWIP_DNS_MAX_HOST_IP=4 +CONFIG_LWIP_HOOK_DNS_EXT_RESOLVE_NONE=y +# CONFIG_LWIP_HOOK_DNS_EXT_RESOLVE_CUSTOM is not set +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=y +CONFIG_MBEDTLS_SSL_PROTO_TLS1_2=y +# CONFIG_MBEDTLS_SSL_PROTO_TLS1_3 is not set +# CONFIG_MBEDTLS_SSL_RENEGOTIATION is not set +# CONFIG_MBEDTLS_ALLOW_WEAK_CERTIFICATE_VERIFICATION is not set +# CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEPRECATED_LIST is not set +# CONFIG_MBEDTLS_DES_C is not set +# CONFIG_ESP_TLS_INSECURE is not set diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/sdkconfig.defaults.esp32p4 b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/sdkconfig.defaults.esp32p4 new file mode 100644 index 00000000..95e3af82 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_apps/build_smoke/sdkconfig.defaults.esp32p4 @@ -0,0 +1,4 @@ +# Tab5 production silicon is ESP32-P4 revision 1.3. +CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y +CONFIG_ESP32P4_REV_MIN_100=y +CONFIG_FREERTOS_HZ=1000 diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/README.md b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/README.md new file mode 100644 index 00000000..c0a4246d --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/README.md @@ -0,0 +1,47 @@ +# Host tests + +`runtime_contract_test.c` checks the pure handle, sequence, feature projection, +and reserved redirect rules. `runtime_fake_test.c` links the production runtime +against bounded fake Core and transport implementations and checks pre-I/O +refusal, slot generations, upload credit, response leases, permission phases, +stats, and three-stage shutdown. Hostile cases cover dispatcher failure poison, +Guest-independent native shutdown cleanup, taken and released lease retirement +failures, delayed transport quiescence, and rejection of new work after +shutdown starts. It also checks that native-work and transport-completion pump +budgets can be supplied independently without poisoning the runtime. A +readiness probe covers healthy drained, pending, and poisoned-Core states +without taking an event. Lease reads reject a larger destination window and a +range past the lease end before accepting the exact formal window. A +close-completion poison case retains Core ownership until phase 2 destroys the +dedicated transport. It injects one failed confirmation and rejects all other +Core calls while the transport context is detached, then confirms on the next +turn. A persistent Host terminal-retirement failure is abandoned only through +the poisoned exact-sequence path, after which shutdown reaches the +ready-to-destroy phase without destroying the transport again in phase 3. + +Run both with Clang sanitizers from the repository root: + +```sh +clang -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined \ + -fno-omit-frame-pointer \ + -Ihosts/esp-idf/components/pocketjs_net_esp_runtime/private \ + -Icontracts/spec/network/generated \ + hosts/esp-idf/components/pocketjs_net_esp_runtime/src/runtime_contract.c \ + hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/runtime_contract_test.c \ + -o /tmp/pocketjs-net-esp-runtime-contract-test +/tmp/pocketjs-net-esp-runtime-contract-test + +clang -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined \ + -fno-omit-frame-pointer \ + -Ihosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include \ + -Ihosts/esp-idf/components/pocketjs_net_esp_runtime/include \ + -Ihosts/esp-idf/components/pocketjs_net_esp_runtime/private \ + -Ihosts/esp-idf/components/pocketjs_net_http_client_core/include \ + -Ihosts/esp-idf/components/pocketjs_net_esp_transport/include \ + -Icontracts/spec/network/generated \ + hosts/esp-idf/components/pocketjs_net_esp_runtime/src/esp_runtime.c \ + hosts/esp-idf/components/pocketjs_net_esp_runtime/src/runtime_contract.c \ + hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/runtime_fake_test.c \ + -o /tmp/pocketjs-net-esp-runtime-fake-test +/tmp/pocketjs-net-esp-runtime-fake-test +``` diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include/esp_err.h b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include/esp_err.h new file mode 100644 index 00000000..8df2c41f --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include/esp_err.h @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +typedef int esp_err_t; + +#define ESP_OK 0 +#define ESP_FAIL 0x101 +#define ESP_ERR_NO_MEM 0x102 +#define ESP_ERR_INVALID_ARG 0x103 +#define ESP_ERR_INVALID_STATE 0x104 +#define ESP_ERR_INVALID_RESPONSE 0x105 +#define ESP_ERR_NOT_FINISHED 0x10c diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include/esp_timer.h b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include/esp_timer.h new file mode 100644 index 00000000..9fe19ea3 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include/esp_timer.h @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include + +int64_t esp_timer_get_time(void); diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include/freertos/FreeRTOS.h b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include/freertos/FreeRTOS.h new file mode 100644 index 00000000..05e767e3 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include/freertos/FreeRTOS.h @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: MIT + +#pragma once diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include/freertos/task.h b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include/freertos/task.h new file mode 100644 index 00000000..0196fa1c --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include/freertos/task.h @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +typedef void *TaskHandle_t; + +TaskHandle_t xTaskGetCurrentTaskHandle(void); diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include/pocketjs/esp_guest.h b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include/pocketjs/esp_guest.h new file mode 100644 index 00000000..b4f240b3 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include/pocketjs/esp_guest.h @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include "quickjs.h" + +typedef struct pocketjs_esp_guest pocketjs_esp_guest_t; + +JSContext *pocketjs_esp_guest_context(pocketjs_esp_guest_t *guest); diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include/quickjs.h b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include/quickjs.h new file mode 100644 index 00000000..3d548bb5 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/fake_include/quickjs.h @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include + +typedef struct JSContext JSContext; +typedef uint64_t JSValue; +typedef JSValue JSValueConst; + +#define JS_UNDEFINED UINT64_C(0) + +static inline int JS_IsUndefined(JSValueConst value) { + return value == JS_UNDEFINED; +} + +static inline JSValue JS_DupValue(JSContext *context, JSValueConst value) { + (void)context; + return value; +} + +static inline void JS_FreeValue(JSContext *context, JSValue value) { + (void)context; + (void)value; +} diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/runtime_contract_test.c b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/runtime_contract_test.c new file mode 100644 index 00000000..07c77a0e --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/runtime_contract_test.c @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT + +#include +#include +#include + +#include "runtime_contract.h" + +int main(void) { + const pocketjs_network_v1_feature_id_t http[] = { + POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT, + }; + const pocketjs_network_v1_feature_id_t http_tls[] = { + POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT, + POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_TLS, + }; + assert(pocketjs_net_esp_runtime_feature_projection_valid(http, 1U)); + assert(pocketjs_net_esp_runtime_feature_projection_valid(http_tls, 2U)); + assert(!pocketjs_net_esp_runtime_feature_projection_has_tls(http, 1U)); + assert(pocketjs_net_esp_runtime_feature_projection_has_tls(http_tls, 2U)); + assert(!pocketjs_net_esp_runtime_feature_projection_valid(NULL, 0U)); + + uint64_t sequence = POCKETJS_NETWORK_V1_SEQUENCE_MAX - 1U; + assert(pocketjs_net_esp_runtime_next_sequence(&sequence)); + assert(sequence == POCKETJS_NETWORK_V1_SEQUENCE_MAX); + assert(!pocketjs_net_esp_runtime_next_sequence(&sequence)); + + const pocketjs_network_v1_handle_t live = {.id = 2U, .generation = 9U}; + const pocketjs_network_v1_handle_t stale = {.id = 2U, .generation = 8U}; + const pocketjs_network_v1_handle_t partial = {.id = 2U, .generation = 0U}; + assert(pocketjs_net_esp_runtime_live_handle(live)); + assert(!pocketjs_net_esp_runtime_live_handle(partial)); + assert(pocketjs_net_esp_runtime_same_handle(live, live)); + assert(!pocketjs_net_esp_runtime_same_handle(live, stale)); + + static const uint8_t post[] = "POST"; + static const uint8_t get[] = "GET"; + assert(pocketjs_net_esp_runtime_redirect_action(200U, get, 3U) == + POCKETJS_NET_ESP_REDIRECT_NOT_REDIRECT); + assert(pocketjs_net_esp_runtime_redirect_action(301U, post, 4U) == + POCKETJS_NET_ESP_REDIRECT_REWRITE_GET); + assert(pocketjs_net_esp_runtime_redirect_action(303U, get, 3U) == + POCKETJS_NET_ESP_REDIRECT_REWRITE_GET); + assert(pocketjs_net_esp_runtime_redirect_action(307U, post, 4U) == + POCKETJS_NET_ESP_REDIRECT_PRESERVE_METHOD_BODY); + assert(pocketjs_net_esp_runtime_redirect_action(308U, get, 3U) == + POCKETJS_NET_ESP_REDIRECT_PRESERVE_METHOD_BODY); + + assert(pocketjs_net_esp_runtime_sensitive_redirect_header( + (const uint8_t *)"Authorization", strlen("Authorization"))); + assert(pocketjs_net_esp_runtime_sensitive_redirect_header( + (const uint8_t *)"proxy-authorization", strlen("proxy-authorization"))); + assert(pocketjs_net_esp_runtime_sensitive_redirect_header( + (const uint8_t *)"COOKIE", strlen("COOKIE"))); + assert(!pocketjs_net_esp_runtime_sensitive_redirect_header( + (const uint8_t *)"content-type", strlen("content-type"))); + return 0; +} diff --git a/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/runtime_fake_test.c b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/runtime_fake_test.c new file mode 100644 index 00000000..7d6ba4ad --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_runtime/test_host/runtime_fake_test.c @@ -0,0 +1,1458 @@ +// SPDX-License-Identifier: MIT + +#include +#include +#include + +#include "runtime_internal.h" + +struct JSContext { + unsigned marker; +}; + +struct pocketjs_esp_guest { + JSContext *context; +}; + +struct pocketjs_net_esp_transport { + bool shutdown; + bool quiescent; + bool completion_retire_stuck; + size_t shutdown_pumps_remaining; +}; + +struct pocketjs_net_http_client_core { + pocketjs_net_http_client_core_config_t config; + pocketjs_net_http_client_operation_token_t operation_token; + bool active; + bool shutdown; + bool event_pending; + bool event_delivering; + bool upload_credit; + bool response_credit; + bool lease_released; + bool transport_confirmed; + bool poison_on_shutdown; + bool poisoned; + size_t transport_destroys_at_init; + uint64_t transport_confirm_order; + uint64_t terminal_retire_order; + uint64_t terminal_abandon_order; + uint64_t upload_body_generation; + uint64_t upload_pull_generation; + size_t upload_maximum; + pocketjs_net_http_client_event_t event; + uint8_t lease_bytes[8]; + size_t lease_length; +}; + +static JSContext FAKE_CONTEXT = {.marker = 1U}; +static TaskHandle_t const OWNER_TASK = (TaskHandle_t)(uintptr_t)0x1234U; +static size_t transport_creates; +static size_t transport_destroys; +static size_t core_starts; +static pocketjs_net_http_client_redirect_mode_t last_core_redirect_mode; +static uint16_t last_core_max_redirects; +static size_t wake_count; +static size_t permission_count; +static bool fake_core_descriptor_drift; +static bool fake_transport_tls_descriptor_drift; + +const pocketjs_net_esp_transport_descriptor_t * +pocketjs_net_esp_transport_descriptor(void) { + static pocketjs_net_esp_transport_descriptor_t descriptor = { + .id = POCKETJS_NET_ESP_TRANSPORT_ID, + .implementation_version = "fake-1", + .experimental = true, + .advertises_public_capability = false, + .ipv4 = true, + .asynchronous_raw_dns = true, + .stock_lwip_dns_callbacks_only = true, + .rejects_saturated_dns_candidate_prefix = true, + .dns_cancel_generation_cleanup = true, + .synchronous_getaddrinfo_for_hostname = false, + .nonblocking_plain_tcp_steps = true, + .monotonic_deadlines = true, + .cancel_between_native_steps = true, + .worker_or_callback_calls_quickjs = false, + .exact_one_terminal = true, + .aba_safe_tokens = true, + .fixed_operation_pool = true, + .fixed_completion_pool = true, + .fixed_payload_pool = true, + .tls_compiled = true, + .tls_1_2_only = true, + .host_trust = true, + .host_pinned_ca = true, + .hostname_verification = true, + .distinct_tls_errors = true, + .sni = true, + .trusted_wall_clock_required = true, + .plaintext_fallback = false, + .renegotiation = false, + .early_data = false, + .tls_close_notify = true, + .tls_close_notify_uses_operation_deadline = true, + .tls_close_notify_waits_for_peer = false, + .pocketjs_owned_instance_bytes = + sizeof(struct pocketjs_net_esp_transport), + .max_connections = 1U, + .max_operations = 1U, + .completion_capacity = 1U, + .max_dns_candidates = 1U, + .max_write_bytes = POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES, + .read_lease_bytes = POCKETJS_NET_HTTP_CLIENT_CORE_BODY_LEASE_BYTES, + }; + descriptor.tls_close_notify = !fake_transport_tls_descriptor_drift; + return &descriptor; +} + +const pocketjs_net_http_client_core_descriptor_t * +pocketjs_net_http_client_core_descriptor(void) { + static pocketjs_net_http_client_core_descriptor_t descriptor = { + .id = POCKETJS_NET_HTTP_CLIENT_CORE_ID, + .experimental = true, + .advertises_public_capability = false, + .plaintext_http = true, + .https_fail_closed_before_io = true, + .https_explicit_opt_in = true, + .owner_pumped = true, + .one_operation = true, + .fixed_core_storage = true, + .headers_first = true, + .explicit_body_credit = true, + .explicit_body_lease = true, + .connection_reuse = true, + .bounded_connection_pool = true, + .redirects_followed = true, + .redirect_manual = true, + .redirect_error = true, + .redirect_fixed_body_replay = true, + .redirect_streaming_body_replay = false, + .connect_error_candidate_fallback = true, + .hidden_retry = false, + .hidden_auth = false, + .hidden_cookie_store = false, + .proxy = false, + .content_decoding = false, + .cleanup_faults_separate_from_terminal = true, + .poison_is_machine_readable = true, + .explicit_shutdown_lifecycle = true, + .fixed_request_body = true, + .streaming_request_body = true, + .chunked_request_body = true, + .known_length_streaming_request_body = true, + .streaming_request_body_buffered_in_full = false, + .instance_bytes = POCKETJS_NET_HTTP_CLIENT_CORE_INSTANCE_BYTES, + .max_request_body_bytes = + POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_BODY_BYTES, + .max_fixed_request_body_bytes = + POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_BODY_BYTES, + .max_request_body_chunk_bytes = + POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES, + .body_lease_bytes = POCKETJS_NET_HTTP_CLIENT_CORE_BODY_LEASE_BYTES, + .max_cached_connections = 1U, + }; + descriptor.connection_reuse = !fake_core_descriptor_drift; + return &descriptor; +} +static size_t dispatcher_calls; +static int64_t fake_now_us = 1000; +static size_t next_transport_shutdown_pumps; +static bool fail_next_event_retirement; +static bool fail_next_dispatcher_call; +static bool saw_native_only_pump; +static bool saw_completion_only_pump; +static bool saw_combined_pump; +static bool next_core_poison_on_shutdown; +static bool next_transport_completion_retire_stuck; +static bool fail_next_transport_confirmation; +static bool poisoned_transport_detached; +static bool fail_event_retirement_persistently; +static uint64_t teardown_order; +static uint64_t last_transport_destroy_order; +static size_t poisoned_transport_destroys; +static size_t poisoned_events_abandoned; +static bool expect_tls_profile; +static bool expect_tls_request; +static const uint8_t *tls_ca_source; +static pocketjs_net_http_client_scheme_t expected_permission_scheme = + POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTP; +static unsigned tls_clock_context; +static const uint8_t EXPECTED_TLS_CA[] = {'t', 'e', 's', 't', '-', 'c', 'a'}; + +TaskHandle_t xTaskGetCurrentTaskHandle(void) { return OWNER_TASK; } + +int64_t esp_timer_get_time(void) { return ++fake_now_us; } + +JSContext *pocketjs_esp_guest_context(pocketjs_esp_guest_t *guest) { + return guest == NULL ? NULL : guest->context; +} + +static void fake_wake(void *context) { + assert(context == &wake_count); + ++wake_count; +} + +static bool +fake_permission(void *context, + const pocketjs_net_http_client_endpoint_t *endpoint) { + assert(context == &permission_count); + assert(endpoint != NULL); + assert(endpoint->scheme == expected_permission_scheme); + ++permission_count; + return true; +} + +static bool fake_wall_clock_trusted(void *context) { + assert(context == &tls_clock_context); + return true; +} + +esp_err_t pocketjs_net_esp_transport_validate_config( + const pocketjs_net_esp_transport_config_t *config) { + if (config == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (!expect_tls_profile) { + return config->tls_trust_source == POCKETJS_NET_ESP_TLS_TRUST_DISABLED && + config->host_pinned_ca_pem == NULL && + config->host_pinned_ca_pem_bytes == 0U && + config->wall_clock_trusted == NULL + ? ESP_OK + : ESP_ERR_INVALID_ARG; + } + return config->tls_trust_source == + POCKETJS_NET_ESP_TLS_TRUST_HOST_PINNED_CA && + config->host_pinned_ca_pem != NULL && + config->host_pinned_ca_pem != tls_ca_source && + config->host_pinned_ca_pem_bytes == sizeof(EXPECTED_TLS_CA) && + memcmp(config->host_pinned_ca_pem, EXPECTED_TLS_CA, + sizeof(EXPECTED_TLS_CA)) == 0 && + config->wall_clock_trusted == fake_wall_clock_trusted && + config->wall_clock_context == &tls_clock_context + ? ESP_OK + : ESP_ERR_INVALID_ARG; +} + +esp_err_t pocketjs_net_esp_transport_create( + const pocketjs_net_esp_transport_config_t *config, + pocketjs_net_esp_transport_t **out_transport) { + assert(config != NULL); + assert(pocketjs_net_esp_transport_validate_config(config) == ESP_OK); + assert(out_transport != NULL && *out_transport == NULL); + *out_transport = calloc(1U, sizeof(**out_transport)); + if (*out_transport == NULL) { + return ESP_ERR_NO_MEM; + } + (*out_transport)->shutdown_pumps_remaining = next_transport_shutdown_pumps; + (*out_transport)->completion_retire_stuck = + next_transport_completion_retire_stuck; + next_transport_completion_retire_stuck = false; + ++transport_creates; + return ESP_OK; +} + +void pocketjs_net_esp_transport_begin_shutdown( + pocketjs_net_esp_transport_t *transport) { + assert(transport != NULL); + transport->shutdown = true; + transport->quiescent = transport->shutdown_pumps_remaining == 0U && + !transport->completion_retire_stuck; +} + +bool pocketjs_net_esp_transport_is_quiescent( + const pocketjs_net_esp_transport_t *transport) { + return transport != NULL && transport->shutdown && transport->quiescent; +} + +esp_err_t +pocketjs_net_esp_transport_destroy(pocketjs_net_esp_transport_t *transport) { + if (!pocketjs_net_esp_transport_is_quiescent(transport)) { + return ESP_ERR_INVALID_STATE; + } + last_transport_destroy_order = ++teardown_order; + ++transport_destroys; + free(transport); + return ESP_OK; +} + +esp_err_t pocketjs_net_esp_transport_destroy_poisoned( + pocketjs_net_esp_transport_t *transport) { + if (transport == NULL || !transport->shutdown) { + return ESP_ERR_INVALID_STATE; + } + last_transport_destroy_order = ++teardown_order; + ++transport_destroys; + ++poisoned_transport_destroys; + assert(!poisoned_transport_detached); + poisoned_transport_detached = true; + free(transport); + return ESP_OK; +} + +static const pocketjs_net_http_client_transport_ops_t FAKE_TRANSPORT_OPS = {0}; + +const pocketjs_net_http_client_transport_ops_t * +pocketjs_net_http_client_core_esp_transport_ops(void) { + return &FAKE_TRANSPORT_OPS; +} + +pocketjs_net_http_client_start_result_t pocketjs_net_http_client_core_init( + pocketjs_net_http_client_core_storage_t *storage, + const pocketjs_net_http_client_core_config_t *config, + pocketjs_net_http_client_core_t **out_core) { + assert(storage != NULL && config != NULL && out_core != NULL); + assert(sizeof(struct pocketjs_net_http_client_core) <= sizeof(*storage)); + struct pocketjs_net_http_client_core *core = (void *)storage; + memset(core, 0, sizeof(*core)); + core->config = *config; + assert(core->config.allow_https == expect_tls_profile); + core->poison_on_shutdown = next_core_poison_on_shutdown; + core->transport_destroys_at_init = transport_destroys; + next_core_poison_on_shutdown = false; + *out_core = core; + return POCKETJS_NET_HTTP_CLIENT_START_OK; +} + +pocketjs_net_http_client_start_result_t pocketjs_net_http_client_core_start( + pocketjs_net_http_client_core_t *core, + const pocketjs_net_http_client_request_t *request, uint64_t now_us) { + assert(core != NULL && request != NULL && now_us != 0U); + assert(!core->active && !core->shutdown); + assert(request->operation_token != 0U); + assert(request->body_kind == POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_NONE || + request->body_kind == POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_STREAMING); + const bool tls = request->tls != NULL; + assert(tls == expect_tls_request); + if (tls) { + assert(request->tls->minimum_version == + POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_2 && + request->tls->maximum_version == + POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_2 && + request->tls->server_name.length == sizeof("example.test") - 1U && + memcmp(request->tls->server_name.data, "example.test", + sizeof("example.test") - 1U) == 0); + } + const pocketjs_net_http_client_endpoint_t hostname = { + .phase = POCKETJS_NET_HTTP_CLIENT_PERMISSION_HOSTNAME, + .scheme = tls ? POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTPS + : POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTP, + .hostname = "example.test", + .port = tls ? 443U : 80U, + }; + const pocketjs_net_http_client_endpoint_t numeric = { + .phase = POCKETJS_NET_HTTP_CLIENT_PERMISSION_NUMERIC_CANDIDATE, + .scheme = hostname.scheme, + .hostname = "example.test", + .port = hostname.port, + .ipv4_be = UINT32_C(0x7f000001), + }; + if (!core->config.allow_endpoint(core->config.permission_context, + &hostname) || + !core->config.allow_endpoint(core->config.permission_context, &numeric)) { + return POCKETJS_NET_HTTP_CLIENT_START_FORBIDDEN_REQUEST; + } + core->operation_token = request->operation_token; + core->active = true; + last_core_redirect_mode = request->redirect_mode; + last_core_max_redirects = request->max_redirects; + ++core_starts; + return POCKETJS_NET_HTTP_CLIENT_START_OK; +} + +bool pocketjs_net_http_client_core_abort( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_operation_token_t operation_token) { + return core != NULL && core->active && + core->operation_token == operation_token; +} + +bool pocketjs_net_http_client_core_get_status( + const pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_core_status_t *out_status) { + assert(!poisoned_transport_detached); + if (core == NULL || out_status == NULL) { + return false; + } + *out_status = (pocketjs_net_http_client_core_status_t){ + .initialized = true, + .shutdown_requested = core->shutdown, + .poisoned = core->poisoned, + .quiescent = pocketjs_net_http_client_core_is_quiescent(core), + .request_active = core->active, + .connection_owned = core->poisoned && !core->transport_confirmed, + .completion_retire_pending = core->poisoned && !core->transport_confirmed, + .event_outstanding = core->event_pending || core->event_delivering, + .poison_flags = core->poisoned + ? POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_COMPLETION + : POCKETJS_NET_HTTP_CLIENT_POISON_NONE, + .first_poison_cause_code = core->poisoned ? -123 : 0, + .operation_token = core->operation_token, + }; + return true; +} + +bool pocketjs_net_http_client_core_pump(pocketjs_net_http_client_core_t *core, + uint64_t now_us, + size_t max_native_steps, + size_t max_transport_completions) { + assert(!poisoned_transport_detached); + if (core == NULL || now_us == 0U) { + return false; + } + if (max_native_steps == 0U && max_transport_completions == 0U) { + return false; + } + saw_native_only_pump |= + max_native_steps != 0U && max_transport_completions == 0U; + saw_completion_only_pump |= + max_native_steps == 0U && max_transport_completions != 0U; + saw_combined_pump |= + max_native_steps != 0U && max_transport_completions != 0U; + pocketjs_net_esp_transport_t *transport = core->config.transport_context; + if (transport != NULL && transport->shutdown && !transport->quiescent && + (max_native_steps != 0U || max_transport_completions != 0U)) { + if (!transport->completion_retire_stuck) { + assert(transport->shutdown_pumps_remaining != 0U); + --transport->shutdown_pumps_remaining; + transport->quiescent = transport->shutdown_pumps_remaining == 0U; + } + } + return true; +} + +bool pocketjs_net_http_client_core_grant_body_credit( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_operation_token_t operation_token, + size_t maximum_bytes) { + if (core == NULL || !core->active || core->response_credit || + core->operation_token != operation_token || maximum_bytes == 0U) { + return false; + } + core->response_credit = true; + return true; +} + +bool pocketjs_net_http_client_core_submit_request_body_chunk( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_operation_token_t operation_token, + uint64_t body_generation, uint64_t pull_generation, const uint8_t *bytes, + size_t length) { + if (core == NULL || !core->upload_credit || bytes == NULL || length == 0U || + length > core->upload_maximum || + core->operation_token != operation_token || + core->upload_body_generation != body_generation || + core->upload_pull_generation != pull_generation) { + return false; + } + core->upload_credit = false; + return true; +} + +bool pocketjs_net_http_client_core_submit_request_body_end( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_operation_token_t operation_token, + uint64_t body_generation, uint64_t pull_generation) { + static const uint8_t ignored = 0U; + return pocketjs_net_http_client_core_submit_request_body_chunk( + core, operation_token, body_generation, pull_generation, &ignored, 1U); +} + +bool pocketjs_net_http_client_core_submit_request_body_error( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_operation_token_t operation_token, + uint64_t body_generation, uint64_t pull_generation, int32_t cause_code) { + return cause_code != 0 && + pocketjs_net_http_client_core_submit_request_body_end( + core, operation_token, body_generation, pull_generation); +} + +bool pocketjs_net_http_client_core_take_event( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_event_t *out_event) { + if (core == NULL || out_event == NULL || !core->event_pending || + core->event_delivering) { + return false; + } + *out_event = core->event; + core->event_delivering = true; + return true; +} + +bool pocketjs_net_http_client_core_retire_event( + pocketjs_net_http_client_core_t *core, uint64_t sequence) { + if (core == NULL || !core->event_delivering || + core->event.sequence != sequence || + (core->event.type == POCKETJS_NET_HTTP_CLIENT_EVENT_BODY && + !core->lease_released)) { + return false; + } + if (fail_event_retirement_persistently || fail_next_event_retirement) { + fail_next_event_retirement = false; + return false; + } + if (core->event.type == POCKETJS_NET_HTTP_CLIENT_EVENT_REQUEST_BODY_PULL) { + core->upload_body_generation = + core->event.detail.request_body_pull.body_generation; + core->upload_pull_generation = + core->event.detail.request_body_pull.pull_generation; + core->upload_maximum = core->event.detail.request_body_pull.maximum_bytes; + core->upload_credit = true; + } else if (core->event.type == POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE || + core->event.type == POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR) { + core->active = false; + if (core->poisoned && core->transport_confirmed) { + core->terminal_retire_order = ++teardown_order; + } + } + core->event_pending = false; + core->event_delivering = false; + return true; +} + +bool pocketjs_net_http_client_core_body_lease_view( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_body_lease_t lease, const uint8_t **out_bytes, + size_t *out_length) { + if (core == NULL || out_bytes == NULL || out_length == NULL || + !core->event_delivering || core->lease_released || + core->event.detail.body.lease.slot != lease.slot || + core->event.detail.body.lease.generation != lease.generation) { + return false; + } + *out_bytes = core->lease_bytes; + *out_length = core->lease_length; + return true; +} + +bool pocketjs_net_http_client_core_release_body_lease( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_body_lease_t lease) { + if (core == NULL || !core->event_delivering || core->lease_released || + core->event.detail.body.lease.slot != lease.slot || + core->event.detail.body.lease.generation != lease.generation) { + return false; + } + core->lease_released = true; + return true; +} + +bool pocketjs_net_http_client_core_begin_shutdown( + pocketjs_net_http_client_core_t *core, uint64_t now_us) { + if (core == NULL || core->shutdown || now_us == 0U) { + return false; + } + core->shutdown = true; + core->poisoned = core->poison_on_shutdown; + return true; +} + +bool pocketjs_net_http_client_core_is_quiescent( + const pocketjs_net_http_client_core_t *core) { + assert(!poisoned_transport_detached); + return core != NULL && core->shutdown && + (!core->poisoned || core->transport_confirmed) && !core->active && + !core->event_pending && !core->event_delivering; +} + +bool pocketjs_net_http_client_core_confirm_transport_shutdown( + pocketjs_net_http_client_core_t *core) { + if (core == NULL || !core->shutdown) { + return false; + } + if (core->transport_confirmed) { + return true; + } + if (core->poisoned) { + if (transport_destroys != core->transport_destroys_at_init + 1U || + last_transport_destroy_order == 0U) { + return false; + } + if (fail_next_transport_confirmation) { + fail_next_transport_confirmation = false; + return false; + } + assert(poisoned_transport_detached); + poisoned_transport_detached = false; + core->transport_confirmed = true; + core->transport_confirm_order = ++teardown_order; + assert(last_transport_destroy_order < core->transport_confirm_order); + assert(core->active && !core->event_pending && !core->event_delivering); + core->event = (pocketjs_net_http_client_event_t){ + .type = POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR, + .sequence = 501U, + .operation_token = core->operation_token, + .detail.error = + { + .code = POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, + }, + }; + core->event_pending = true; + return true; + } + if (!pocketjs_net_http_client_core_is_quiescent(core)) { + return false; + } + core->transport_confirmed = true; + return true; +} + +bool pocketjs_net_http_client_core_report_host_event_retire_failure( + pocketjs_net_http_client_core_t *core, uint64_t sequence) { + if (core == NULL || !core->event_delivering || + core->event.sequence != sequence) { + return false; + } + core->poisoned = true; + return true; +} + +bool pocketjs_net_http_client_core_abandon_event_after_transport_shutdown( + pocketjs_net_http_client_core_t *core, uint64_t sequence) { + if (core == NULL || !core->shutdown || !core->poisoned || + !core->transport_confirmed || !core->event_delivering || + core->event.sequence != sequence) { + return false; + } + const pocketjs_net_http_client_event_type_t type = core->event.type; + core->event_pending = false; + core->event_delivering = false; + core->lease_released = false; + if (type == POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE || + type == POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR) { + core->active = false; + core->terminal_abandon_order = ++teardown_order; + } + ++poisoned_events_abandoned; + return true; +} + +bool pocketjs_net_http_client_core_deinit( + pocketjs_net_http_client_core_t *core) { + return core != NULL && core->transport_confirmed; +} + +esp_err_t +pocketjs_net_esp_runtime_create_binding(pocketjs_net_esp_runtime_t *runtime) { + assert(runtime != NULL); + runtime->binding = UINT64_C(42); + return ESP_OK; +} + +void pocketjs_net_esp_runtime_revoke_binding( + pocketjs_net_esp_runtime_t *runtime) { + assert(runtime != NULL); + runtime->binding = JS_UNDEFINED; + runtime->dispatcher = JS_UNDEFINED; + runtime->dispatcher_registered = false; +} + +esp_err_t pocketjs_net_esp_runtime_call_dispatcher( + pocketjs_net_esp_runtime_t *runtime, + pocketjs_network_v1_service_turn_kind_t kind, uint32_t max_events, + uint32_t max_payload_bytes, + pocketjs_net_esp_runtime_service_result_t *out_result) { + assert(runtime != NULL); + (void)kind; + (void)max_events; + (void)max_payload_bytes; + assert(out_result != NULL); + ++dispatcher_calls; + if (fail_next_dispatcher_call) { + fail_next_dispatcher_call = false; + return ESP_FAIL; + } + *out_result = (pocketjs_net_esp_runtime_service_result_t){ + .status = POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_DRAINED, + }; + return ESP_OK; +} + +static void queue_event(pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_event_t event) { + assert(core != NULL && core->active && !core->event_pending && + !core->event_delivering); + event.operation_token = core->operation_token; + core->event = event; + core->event_pending = true; + core->lease_released = false; +} + +static pocketjs_network_v1_command_identity_t +identity(uint32_t operation_generation, pocketjs_network_v1_handle_t body, + uint64_t command_sequence) { + return (pocketjs_network_v1_command_identity_t){ + .runtime_generation = 7U, + .resource = {POCKETJS_NET_ESP_RUNTIME_HTTP_RESOURCE_ID, + POCKETJS_NET_ESP_RUNTIME_HTTP_RESOURCE_GENERATION}, + .operation = {1U, operation_generation}, + .body = body, + .command_sequence = command_sequence, + }; +} + +typedef struct { + pocketjs_net_esp_runtime_t *runtime; + pocketjs_net_esp_runtime_slot_t *slot; + pocketjs_net_http_client_core_t *core; + pocketjs_network_v1_command_identity_t response_identity; + pocketjs_network_v1_handle_t lease; +} taken_lease_fixture_t; + +static taken_lease_fixture_t +start_taken_lease(const pocketjs_net_esp_runtime_config_t *config, + const pocketjs_net_esp_runtime_http_command_t *command) { + pocketjs_net_esp_runtime_t *runtime = NULL; + assert(pocketjs_net_esp_runtime_create(config, &runtime) == ESP_OK); + assert(pocketjs_net_esp_runtime_completion_readiness(runtime) == 0); + pocketjs_net_esp_runtime_error_t error = {0}; + pocketjs_network_v1_command_identity_t request_identity = + identity(1U, (pocketjs_network_v1_handle_t){0U, 0U}, 1U); + assert(pocketjs_net_esp_runtime_start_http(runtime, &request_identity, + command, &error)); + assert(pocketjs_net_esp_runtime_completion_readiness(runtime) == 0); + pocketjs_net_esp_runtime_slot_t *slot = &runtime->slots[0]; + pocketjs_net_http_client_core_t *core = slot->core; + + static const uint8_t status_text[] = "OK"; + queue_event( + core, (pocketjs_net_http_client_event_t){ + .type = POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS, + .sequence = 101U, + .detail.response = + { + .status_code = 200U, + .status_text = {status_text, sizeof(status_text) - 1U}, + }, + }); + assert(pocketjs_net_esp_runtime_completion_readiness(runtime) == 1); + pocketjs_net_esp_runtime_slot_t *selected = NULL; + assert(pocketjs_net_esp_runtime_peek_event(runtime, &selected) && + selected == slot); + assert(pocketjs_net_esp_runtime_retire_nonlease_event(runtime, slot)); + assert(slot->response_body.id != 0U && slot->response_body.generation != 0U); + + pocketjs_network_v1_command_identity_t response_identity = + identity(1U, slot->response_body, 2U); + assert(pocketjs_net_esp_runtime_grant_body_credit(runtime, &response_identity, + 3U, &error)); + memcpy(core->lease_bytes, "abc", 3U); + core->lease_length = 3U; + queue_event(core, (pocketjs_net_http_client_event_t){ + .type = POCKETJS_NET_HTTP_CLIENT_EVENT_BODY, + .sequence = 102U, + .detail.body = + { + .lease = {7U, 8U}, + .byte_count = 3U, + }, + }); + assert(pocketjs_net_esp_runtime_peek_event(runtime, &selected) && + selected == slot && + slot->lease_state == POCKETJS_NET_ESP_RUNTIME_LEASE_QUEUED); + slot->lease_descriptor_delivered = true; + const pocketjs_network_v1_handle_t lease = slot->lease; + uint32_t taken = 0U; + response_identity.command_sequence = 3U; + assert(pocketjs_net_esp_runtime_lease_take(runtime, &response_identity, lease, + 3U, &taken, &error)); + assert(taken == 3U && + slot->lease_state == POCKETJS_NET_ESP_RUNTIME_LEASE_TAKEN); + return (taken_lease_fixture_t){ + .runtime = runtime, + .slot = slot, + .core = core, + .response_identity = response_identity, + .lease = lease, + }; +} + +static void queue_shutdown_error(taken_lease_fixture_t *fixture, + uint64_t event_sequence) { + queue_event(fixture->core, + (pocketjs_net_http_client_event_t){ + .type = POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR, + .sequence = event_sequence, + .detail.error = + { + .code = POCKETJS_NET_HTTP_CLIENT_ERROR_ABORTED, + }, + }); +} + +static void assert_shutdown_rejects_new_work( + pocketjs_net_esp_runtime_t *runtime, + const pocketjs_net_esp_runtime_http_command_t *command, + uint64_t command_sequence) { + pocketjs_net_esp_runtime_error_t error = {0}; + pocketjs_network_v1_command_identity_t request_identity = + identity(2U, (pocketjs_network_v1_handle_t){0U, 0U}, command_sequence); + assert(!pocketjs_net_esp_runtime_start_http(runtime, &request_identity, + command, &error)); + assert(error.code == POCKETJS_NETWORK_V1_ERROR_INVALID_STATE); + assert(pocketjs_net_esp_runtime_begin_shutdown(runtime, 9000U) == + ESP_ERR_INVALID_STATE); +} + +static void test_dispatcher_poison_native_fallback( + const pocketjs_net_esp_runtime_config_t *config, + const pocketjs_net_esp_runtime_http_command_t *command) { + next_transport_shutdown_pumps = 2U; + pocketjs_net_esp_runtime_t *runtime = NULL; + assert(pocketjs_net_esp_runtime_create(config, &runtime) == ESP_OK); + pocketjs_net_esp_runtime_error_t error = {0}; + pocketjs_network_v1_command_identity_t request_identity = + identity(1U, (pocketjs_network_v1_handle_t){0U, 0U}, 1U); + assert(pocketjs_net_esp_runtime_start_http(runtime, &request_identity, + command, &error)); + pocketjs_net_esp_runtime_slot_t *slot = &runtime->slots[0]; + pocketjs_net_http_client_core_t *core = slot->core; + + saw_native_only_pump = false; + saw_completion_only_pump = false; + saw_combined_pump = false; + pocketjs_net_esp_runtime_service_result_t service_result = {0}; + assert(pocketjs_net_esp_runtime_service(runtime, 2000U, 10U, 1U, 1U, 16U, + &service_result) == ESP_OK); + assert(saw_combined_pump && saw_native_only_pump && + runtime->poison_flags == 0U); + assert(pocketjs_net_esp_runtime_service(runtime, 3000U, 0U, 1U, 1U, 16U, + &service_result) == ESP_OK); + assert(saw_completion_only_pump && runtime->poison_flags == 0U); + saw_native_only_pump = false; + assert(pocketjs_net_esp_runtime_service(runtime, 4000U, 1U, 0U, 1U, 16U, + &service_result) == ESP_OK); + assert(saw_native_only_pump && runtime->poison_flags == 0U); + + fail_next_dispatcher_call = true; + const size_t dispatcher_calls_before_failure = dispatcher_calls; + assert(pocketjs_net_esp_runtime_service(runtime, 5000U, 1U, 1U, 1U, 16U, + &service_result) == ESP_FAIL); + assert(dispatcher_calls == dispatcher_calls_before_failure + 1U); + assert(runtime->poison_flags == POCKETJS_NET_ESP_RUNTIME_POISON_QUICKJS); + + assert(pocketjs_net_esp_runtime_begin_shutdown(runtime, 6000U) == ESP_OK); + assert_shutdown_rejects_new_work(runtime, command, 2U); + queue_event(core, (pocketjs_net_http_client_event_t){ + .type = POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR, + .sequence = 100U, + .detail.error = + { + .code = POCKETJS_NET_HTTP_CLIENT_ERROR_ABORTED, + }, + }); + + assert(pocketjs_net_esp_runtime_service(runtime, 7000U, 1U, 1U, 1U, 16U, + &service_result) == ESP_OK); + assert(service_result.status == + POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_MORE_READY); + assert(pocketjs_net_http_client_core_is_quiescent(core)); + assert(!pocketjs_net_esp_transport_is_quiescent(slot->transport)); + assert(!pocketjs_net_esp_runtime_is_ready_to_destroy(runtime)); + + assert(pocketjs_net_esp_runtime_service(runtime, 8000U, 1U, 1U, 1U, 16U, + &service_result) == ESP_OK); + assert(service_result.status == + POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_DRAINED); + assert(dispatcher_calls == dispatcher_calls_before_failure + 1U); + assert(pocketjs_net_esp_runtime_is_ready_to_destroy(runtime)); + assert(pocketjs_net_esp_runtime_service(runtime, 9000U, 1U, 1U, 1U, 16U, + &service_result) == + ESP_ERR_INVALID_ARG); + + pocketjs_net_esp_runtime_stats_t stats = {0}; + assert(pocketjs_net_esp_runtime_get_stats(runtime, &stats) == ESP_OK); + assert(stats.active_operations == 0U && stats.requests_started == 1U && + stats.poison_flags == POCKETJS_NET_ESP_RUNTIME_POISON_QUICKJS); + assert(pocketjs_net_esp_runtime_destroy(runtime) == ESP_OK); + next_transport_shutdown_pumps = 0U; +} + +static void test_released_lease_retire_failure( + const pocketjs_net_esp_runtime_config_t *config, + const pocketjs_net_esp_runtime_http_command_t *command) { + next_transport_shutdown_pumps = 0U; + taken_lease_fixture_t fixture = start_taken_lease(config, command); + pocketjs_net_esp_runtime_error_t error = {0}; + fail_next_event_retirement = true; + fixture.response_identity.command_sequence = 4U; + assert(!pocketjs_net_esp_runtime_lease_release( + fixture.runtime, &fixture.response_identity, fixture.lease, &error)); + assert(error.category == POCKETJS_NETWORK_V1_ERROR_CATEGORY_RUNTIME && + error.code == POCKETJS_NETWORK_V1_ERROR_INVALID_STATE && + strcmp(error.operation, "http.body.lease") == 0); + assert(fixture.slot->event_pending && fixture.core->lease_released && + fixture.slot->lease_state == POCKETJS_NET_ESP_RUNTIME_LEASE_RELEASED); + assert((fixture.runtime->poison_flags & + POCKETJS_NET_ESP_RUNTIME_POISON_LEASE) != 0U); + + pocketjs_net_esp_runtime_service_result_t service_result = {0}; + assert(pocketjs_net_esp_runtime_begin_shutdown(fixture.runtime, 6000U) == + ESP_OK); + assert(!fixture.slot->event_pending && + fixture.slot->lease_state == POCKETJS_NET_ESP_RUNTIME_LEASE_NONE); + assert_shutdown_rejects_new_work(fixture.runtime, command, 5U); + queue_shutdown_error(&fixture, 103U); + + assert(pocketjs_net_esp_runtime_service(fixture.runtime, 7000U, 1U, 1U, 1U, + 16U, &service_result) == ESP_OK); + assert(service_result.status == + POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_DRAINED); + assert(pocketjs_net_http_client_core_is_quiescent(fixture.core)); + assert(pocketjs_net_esp_transport_is_quiescent(fixture.slot->transport)); + assert(pocketjs_net_esp_runtime_is_ready_to_destroy(fixture.runtime)); + assert(pocketjs_net_esp_runtime_service(fixture.runtime, 9000U, 1U, 1U, 1U, + 16U, &service_result) == + ESP_ERR_INVALID_ARG); + + pocketjs_net_esp_runtime_stats_t stats = {0}; + assert(pocketjs_net_esp_runtime_get_stats(fixture.runtime, &stats) == ESP_OK); + assert(stats.active_operations == 0U && stats.leases_taken == 1U && + stats.leases_released == 1U && stats.leases_cleaned_up == 0U && + stats.poison_flags == POCKETJS_NET_ESP_RUNTIME_POISON_LEASE); + assert(pocketjs_net_esp_runtime_destroy(fixture.runtime) == ESP_OK); + next_transport_shutdown_pumps = 0U; +} + +static void test_held_lease_shutdown_cleanup( + const pocketjs_net_esp_runtime_config_t *config, + const pocketjs_net_esp_runtime_http_command_t *command) { + next_transport_shutdown_pumps = 1U; + taken_lease_fixture_t fixture = start_taken_lease(config, command); + fail_next_event_retirement = true; + assert(pocketjs_net_esp_runtime_begin_shutdown(fixture.runtime, 10000U) == + ESP_OK); + assert(fixture.core->lease_released && fixture.slot->event_pending && + fixture.slot->lease_state == POCKETJS_NET_ESP_RUNTIME_LEASE_RELEASED && + (fixture.runtime->poison_flags & + POCKETJS_NET_ESP_RUNTIME_POISON_LEASE) != 0U); + assert_shutdown_rejects_new_work(fixture.runtime, command, 4U); + + pocketjs_net_esp_runtime_service_result_t service_result = {0}; + assert(pocketjs_net_esp_runtime_service(fixture.runtime, 11000U, 1U, 1U, 1U, + 16U, &service_result) == ESP_OK); + assert(service_result.status == + POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_MORE_READY); + assert(!fixture.slot->event_pending && fixture.slot->active && + !pocketjs_net_esp_runtime_is_ready_to_destroy(fixture.runtime)); + + queue_shutdown_error(&fixture, 104U); + assert(pocketjs_net_esp_runtime_service(fixture.runtime, 12000U, 1U, 1U, 1U, + 16U, &service_result) == ESP_OK); + assert(service_result.status == + POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_DRAINED); + assert(pocketjs_net_esp_runtime_is_ready_to_destroy(fixture.runtime)); + + pocketjs_net_esp_runtime_stats_t stats = {0}; + assert(pocketjs_net_esp_runtime_get_stats(fixture.runtime, &stats) == ESP_OK); + assert(stats.active_operations == 0U && stats.leases_taken == 1U && + stats.leases_released == 0U && stats.leases_cleaned_up == 1U && + stats.poison_flags == POCKETJS_NET_ESP_RUNTIME_POISON_LEASE); + assert(pocketjs_net_esp_runtime_destroy(fixture.runtime) == ESP_OK); + next_transport_shutdown_pumps = 0U; +} + +static void test_poisoned_core_retained_transport_teardown( + const pocketjs_net_esp_runtime_config_t *config, + const pocketjs_net_esp_runtime_http_command_t *command) { + next_transport_shutdown_pumps = 1U; + next_transport_completion_retire_stuck = true; + next_core_poison_on_shutdown = true; + pocketjs_net_esp_runtime_t *runtime = NULL; + assert(pocketjs_net_esp_runtime_create(config, &runtime) == ESP_OK); + pocketjs_net_esp_runtime_error_t error = {0}; + pocketjs_network_v1_command_identity_t request_identity = + identity(1U, (pocketjs_network_v1_handle_t){0U, 0U}, 1U); + assert(pocketjs_net_esp_runtime_start_http(runtime, &request_identity, + command, &error)); + pocketjs_net_esp_runtime_slot_t *slot = &runtime->slots[0]; + pocketjs_net_http_client_core_t *core = slot->core; + const size_t destroys_before_shutdown = transport_destroys; + const size_t poisoned_destroys_before_shutdown = poisoned_transport_destroys; + const size_t abandoned_before_shutdown = poisoned_events_abandoned; + fail_next_transport_confirmation = true; + fail_event_retirement_persistently = true; + + assert(pocketjs_net_esp_runtime_begin_shutdown(runtime, 13000U) == ESP_OK); + pocketjs_net_http_client_core_status_t core_status = {0}; + assert(pocketjs_net_http_client_core_get_status(core, &core_status)); + assert(core_status.poisoned && core_status.connection_owned && + core_status.completion_retire_pending && !core_status.quiescent && + !core->transport_confirmed && + !pocketjs_net_esp_transport_is_quiescent(slot->transport)); + assert(runtime->poison_flags == POCKETJS_NET_ESP_RUNTIME_POISON_CORE); + assert(pocketjs_net_esp_runtime_completion_readiness(runtime) == -1); + + pocketjs_net_esp_runtime_service_result_t service_result = {0}; + assert(pocketjs_net_esp_runtime_service(runtime, 14000U, 1U, 1U, 1U, 16U, + &service_result) == ESP_OK); + assert(service_result.status == + POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_MORE_READY); + assert(!pocketjs_net_esp_runtime_is_ready_to_destroy(runtime)); + assert(slot->transport == NULL && slot->transport_detached_awaiting_confirm && + !core->transport_confirmed && poisoned_transport_detached); + + assert(pocketjs_net_esp_runtime_service(runtime, 15000U, 1U, 1U, 1U, 16U, + &service_result) == ESP_OK); + assert(service_result.status == + POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_DRAINED); + assert(pocketjs_net_esp_runtime_is_ready_to_destroy(runtime)); + assert(slot->transport == NULL && core->transport_confirmed && + !slot->event_pending && !slot->active && + pocketjs_net_http_client_core_is_quiescent(core)); + assert(transport_destroys == destroys_before_shutdown + 1U); + assert(poisoned_transport_destroys == poisoned_destroys_before_shutdown + 1U); + assert(poisoned_events_abandoned == abandoned_before_shutdown + 1U); + assert(last_transport_destroy_order < core->transport_confirm_order && + core->transport_confirm_order < core->terminal_abandon_order && + core->terminal_retire_order == 0U); + + pocketjs_net_esp_runtime_stats_t stats = {0}; + assert(pocketjs_net_esp_runtime_get_stats(runtime, &stats) == ESP_OK); + assert(stats.active_operations == 0U && stats.pending_core_events == 0U && + stats.poison_flags == (POCKETJS_NET_ESP_RUNTIME_POISON_CORE | + POCKETJS_NET_ESP_RUNTIME_POISON_SHUTDOWN)); + assert(stats.poisoned_core_slots == 1U && + stats.core_poison_flags == + POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_COMPLETION && + stats.first_core_poison_cause_code == -123); + const size_t destroys_before_runtime_destroy = transport_destroys; + const size_t poisoned_destroys_before_runtime_destroy = + poisoned_transport_destroys; + assert(pocketjs_net_esp_runtime_destroy(runtime) == ESP_OK); + assert(transport_destroys == destroys_before_runtime_destroy); + assert(poisoned_transport_destroys == + poisoned_destroys_before_runtime_destroy); + next_transport_shutdown_pumps = 0U; + assert(!next_core_poison_on_shutdown); + assert(!next_transport_completion_retire_stuck); + assert(!fail_next_transport_confirmation); + assert(!poisoned_transport_detached); + fail_event_retirement_persistently = false; +} + +static void test_tls_profile_snapshot( + const pocketjs_net_esp_runtime_config_t *plain_config) { + const pocketjs_network_v1_feature_id_t tls_features[] = { + POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT, + POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_TLS, + }; + pocketjs_net_esp_runtime_config_t tls_config = *plain_config; + tls_config.feature_ids = tls_features; + tls_config.feature_count = 2U; + tls_config.providers.http_client_tls_source = + POCKETJS_NET_ESP_RUNTIME_TLS_SELECTION_PROVIDER; + tls_config.providers.http_client_tls_id = POCKETJS_NET_ESP_TLS_PROVIDER_ID; + + pocketjs_net_esp_runtime_t *runtime = NULL; + assert(pocketjs_net_esp_runtime_create(&tls_config, &runtime) == + ESP_ERR_INVALID_ARG); + assert(runtime == NULL); + + uint8_t mutable_ca[sizeof(EXPECTED_TLS_CA)]; + memcpy(mutable_ca, EXPECTED_TLS_CA, sizeof(mutable_ca)); + tls_config.tls_trust_source = POCKETJS_NET_ESP_TLS_TRUST_HOST_PINNED_CA; + tls_config.host_pinned_ca_pem = mutable_ca; + tls_config.host_pinned_ca_pem_bytes = sizeof(mutable_ca); + tls_config.wall_clock_trusted = fake_wall_clock_trusted; + tls_config.wall_clock_context = &tls_clock_context; + expect_tls_profile = true; + expect_tls_request = true; + tls_ca_source = mutable_ca; + expected_permission_scheme = POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTPS; + + fake_transport_tls_descriptor_drift = true; + assert(pocketjs_net_esp_runtime_create(&tls_config, &runtime) == + ESP_ERR_INVALID_ARG); + assert(runtime == NULL); + fake_transport_tls_descriptor_drift = false; + + const size_t creates_before = transport_creates; + const size_t destroys_before = transport_destroys; + const size_t starts_before = core_starts; + assert(pocketjs_net_esp_runtime_create(&tls_config, &runtime) == ESP_OK); + assert(runtime != NULL && runtime->tls_enabled && + runtime->feature_count == 2U && + runtime->pinned_ca_bytes == sizeof(EXPECTED_TLS_CA)); + mutable_ca[0] = 'X'; + + pocketjs_net_esp_runtime_http_command_t command = { + .url = "https://example.test/", + .url_length = sizeof("https://example.test/") - 1U, + .method = "GET", + .method_length = 3U, + .tls_present = true, + .tls_requested = true, + .tls_server_name = "example.test", + .tls_server_name_length = sizeof("example.test") - 1U, + .redirect_mode = POCKETJS_NETWORK_V1_HTTP_REDIRECT_MANUAL, + .max_redirects = 5U, + .ref = true, + }; + command.tls_policy = (pocketjs_net_http_client_tls_policy_t){ + .server_name = {command.tls_server_name, command.tls_server_name_length}, + .minimum_version = POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_2, + .maximum_version = POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_2, + .client_certificate = + POCKETJS_NET_HTTP_CLIENT_TLS_CLIENT_CERTIFICATE_NONE, + .verification = POCKETJS_NET_HTTP_CLIENT_TLS_VERIFICATION_FULL, + .revocation = POCKETJS_NET_HTTP_CLIENT_TLS_REVOCATION_HOST_DEFAULT, + }; + pocketjs_net_esp_runtime_error_t error = {0}; + pocketjs_network_v1_command_identity_t request_identity = + identity(1U, (pocketjs_network_v1_handle_t){0U, 0U}, 1U); + assert(pocketjs_net_esp_runtime_start_http(runtime, &request_identity, + &command, &error)); + assert(transport_creates == creates_before + 1U && + core_starts == starts_before + 1U); + pocketjs_net_esp_runtime_slot_t *slot = &runtime->slots[0]; + queue_event(slot->core, (pocketjs_net_http_client_event_t){ + .type = POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE, + .sequence = 601U, + }); + pocketjs_net_esp_runtime_slot_t *selected = NULL; + assert(pocketjs_net_esp_runtime_peek_event(runtime, &selected) && + selected == slot); + assert(pocketjs_net_esp_runtime_retire_nonlease_event(runtime, slot)); + + expect_tls_request = false; + expected_permission_scheme = POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTP; + command.tls_present = false; + command.tls_requested = false; + command.redirect_mode = POCKETJS_NETWORK_V1_HTTP_REDIRECT_FOLLOW; + command.max_redirects = 2U; + memcpy(command.url, "http://example.test/", sizeof("http://example.test/")); + command.url_length = sizeof("http://example.test/") - 1U; + request_identity = identity(2U, (pocketjs_network_v1_handle_t){0U, 0U}, 2U); + assert(pocketjs_net_esp_runtime_start_http(runtime, &request_identity, + &command, &error)); + assert(last_core_redirect_mode == POCKETJS_NET_HTTP_CLIENT_REDIRECT_FOLLOW && + last_core_max_redirects == 2U); + assert(transport_creates == creates_before + 1U && + core_starts == starts_before + 2U); + queue_event(slot->core, (pocketjs_net_http_client_event_t){ + .type = POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE, + .sequence = 602U, + }); + assert(pocketjs_net_esp_runtime_peek_event(runtime, &selected) && + selected == slot); + assert(pocketjs_net_esp_runtime_retire_nonlease_event(runtime, slot)); + assert(pocketjs_net_esp_runtime_begin_shutdown(runtime, 16000U) == ESP_OK); + pocketjs_net_esp_runtime_service_result_t service_result = {0}; + assert(pocketjs_net_esp_runtime_service(runtime, 17000U, 1U, 1U, 1U, 16U, + &service_result) == ESP_OK); + assert(pocketjs_net_esp_runtime_is_ready_to_destroy(runtime)); + assert(pocketjs_net_esp_runtime_destroy(runtime) == ESP_OK); + assert(transport_destroys == destroys_before + 1U); + + expect_tls_profile = false; + expect_tls_request = false; + tls_ca_source = NULL; + expected_permission_scheme = POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTP; +} + +int main(void) { + const pocketjs_network_v1_feature_id_t features[] = { + POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT, + }; + struct pocketjs_esp_guest guest = {.context = &FAKE_CONTEXT}; + pocketjs_net_esp_runtime_config_t config = { + .guest = &guest, + .runtime_generation = 7U, + .feature_ids = features, + .feature_count = 1U, + .providers = + { + .http_client_backend_id = POCKETJS_NET_HTTP_CLIENT_CORE_ID, + .net_driver_id = POCKETJS_NET_ESP_TRANSPORT_ID, + .http_client_tls_source = + POCKETJS_NET_ESP_RUNTIME_TLS_SELECTION_NONE, + }, + .admission = POCKETJS_NET_ESP_RUNTIME_ADMISSION_TEST_ONLY, + .max_operations = 1U, + .limits = + { + .buffered_body_bytes = {0U, 1024U, 4096U}, + .header_bytes = {0U, 2048U, 8192U}, + .max_body_chunk_bytes = {1U, 256U, 2048U}, + .max_operations = {1U, 1U, 1U}, + .native_buffer_bytes = {0U, 524288U, 524288U}, + }, + .connect_timeout_us = 1000U, + .headers_timeout_us = 2000U, + .idle_timeout_us = 3000U, + .total_timeout_us = 4000U, + .wake = fake_wake, + .wake_context = &wake_count, + .allow_endpoint = fake_permission, + .permission_context = &permission_count, + }; + size_t required_native_bytes = 0U; + assert(pocketjs_net_esp_runtime_required_native_buffer_bytes( + config.max_operations, &required_native_bytes)); + assert(required_native_bytes <= + config.limits.native_buffer_bytes.default_value); + assert(!pocketjs_net_esp_runtime_required_native_buffer_bytes( + 0U, &required_native_bytes)); + assert(!pocketjs_net_esp_runtime_required_native_buffer_bytes( + POCKETJS_NET_ESP_RUNTIME_MAX_OPERATIONS + 1U, &required_native_bytes)); + assert(!pocketjs_net_esp_runtime_required_native_buffer_bytes(1U, NULL)); + pocketjs_net_esp_runtime_t *runtime = NULL; + pocketjs_net_esp_runtime_config_t refused = config; + fake_core_descriptor_drift = true; + assert(pocketjs_net_esp_runtime_create(&refused, &runtime) == + ESP_ERR_INVALID_ARG); + assert(runtime == NULL); + fake_core_descriptor_drift = false; + refused = config; + refused.limits.native_buffer_bytes.default_value = required_native_bytes - 1U; + assert(pocketjs_net_esp_runtime_create(&refused, &runtime) == + ESP_ERR_INVALID_ARG); + assert(runtime == NULL); + refused = config; + refused.providers.http_client_backend_id = "wrong.backend"; + assert(pocketjs_net_esp_runtime_create(&refused, &runtime) == + ESP_ERR_INVALID_ARG); + assert(runtime == NULL); + refused = config; + refused.providers.net_driver_id = "wrong.driver"; + assert(pocketjs_net_esp_runtime_create(&refused, &runtime) == + ESP_ERR_INVALID_ARG); + assert(runtime == NULL); + refused = config; + refused.providers.http_client_tls_source = + POCKETJS_NET_ESP_RUNTIME_TLS_SELECTION_PROVIDER; + refused.providers.http_client_tls_id = POCKETJS_NET_ESP_TLS_PROVIDER_ID; + assert(pocketjs_net_esp_runtime_create(&refused, &runtime) == + ESP_ERR_INVALID_ARG); + assert(runtime == NULL); + refused = config; + refused.admission = POCKETJS_NET_ESP_RUNTIME_ADMISSION_PUBLIC; + assert(pocketjs_net_esp_runtime_create(&refused, &runtime) == + ESP_ERR_INVALID_ARG); + assert(runtime == NULL); + assert(pocketjs_net_esp_runtime_create(&config, &runtime) == ESP_OK); + assert(runtime != NULL && runtime->max_operations == 1U); + + pocketjs_net_esp_runtime_http_command_t command = { + .url = "https://example.test/", + .url_length = sizeof("https://example.test/") - 1U, + .method = "GET", + .method_length = 3U, + .tls_requested = true, + .redirect_mode = POCKETJS_NETWORK_V1_HTTP_REDIRECT_MANUAL, + .max_redirects = 5U, + .ref = true, + }; + pocketjs_net_esp_runtime_error_t error = {0}; + pocketjs_network_v1_command_identity_t request_identity = + identity(1U, (pocketjs_network_v1_handle_t){0U, 0U}, 1U); + assert(!pocketjs_net_esp_runtime_start_http(runtime, &request_identity, + &command, &error)); + assert(error.code == POCKETJS_NETWORK_V1_ERROR_UNSUPPORTED); + assert(transport_creates == 0U && + runtime->slots[0].last_operation_generation == 1U); + + command.tls_requested = false; + memcpy(command.url, "http://example.test/", sizeof("http://example.test/")); + command.url_length = sizeof("http://example.test/") - 1U; + request_identity = identity(1U, (pocketjs_network_v1_handle_t){0U, 0U}, 2U); + memset(&error, 0, sizeof(error)); + assert(!pocketjs_net_esp_runtime_start_http(runtime, &request_identity, + &command, &error)); + assert(error.code == POCKETJS_NETWORK_V1_ERROR_INVALID_STATE); + assert(transport_creates == 0U); + + command.has_body = true; + request_identity = identity(2U, (pocketjs_network_v1_handle_t){1U, 2U}, 3U); + memset(&error, 0, sizeof(error)); + assert(pocketjs_net_esp_runtime_start_http(runtime, &request_identity, + &command, &error)); + assert(transport_creates == 1U && core_starts == 1U && + permission_count == 2U); + pocketjs_net_esp_runtime_slot_t *slot = &runtime->slots[0]; + pocketjs_net_http_client_core_t *core = slot->core; + + queue_event(core, + (pocketjs_net_http_client_event_t){ + .type = POCKETJS_NET_HTTP_CLIENT_EVENT_REQUEST_BODY_PULL, + .sequence = 11U, + .detail.request_body_pull = + { + .body_generation = 4U, + .pull_generation = 5U, + .maximum_bytes = 16U, + }, + }); + pocketjs_net_esp_runtime_slot_t *selected = NULL; + assert(pocketjs_net_esp_runtime_peek_event(runtime, &selected) && + selected == slot); + assert(pocketjs_net_esp_runtime_retire_nonlease_event(runtime, slot)); + const uint8_t upload[] = {1U, 2U, 3U}; + pocketjs_network_v1_command_identity_t upload_identity = + identity(2U, (pocketjs_network_v1_handle_t){1U, 2U}, 4U); + assert(pocketjs_net_esp_runtime_submit_body_chunk( + runtime, &upload_identity, upload, sizeof(upload), &error)); + + static const uint8_t status_text[] = "OK"; + static const uint8_t header_name[] = "content-type"; + static const uint8_t header_value[] = "text/plain"; + static const pocketjs_net_http_client_header_t headers[] = {{ + .name = {header_name, sizeof(header_name) - 1U}, + .value = {header_value, sizeof(header_value) - 1U}, + }}; + queue_event( + core, (pocketjs_net_http_client_event_t){ + .type = POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS, + .sequence = 12U, + .detail.response = + { + .status_code = 200U, + .status_text = {status_text, sizeof(status_text) - 1U}, + .headers = headers, + .header_count = 1U, + }, + }); + assert(pocketjs_net_esp_runtime_peek_event(runtime, &selected) && + selected == slot && slot->response_body.id == 1U && + slot->response_body.generation == 1U); + assert(pocketjs_net_esp_runtime_retire_nonlease_event(runtime, slot)); + pocketjs_network_v1_command_identity_t response_identity = + identity(2U, slot->response_body, 5U); + assert(pocketjs_net_esp_runtime_grant_body_credit(runtime, &response_identity, + 3U, &error)); + + memcpy(core->lease_bytes, "abc", 3U); + core->lease_length = 3U; + queue_event(core, (pocketjs_net_http_client_event_t){ + .type = POCKETJS_NET_HTTP_CLIENT_EVENT_BODY, + .sequence = 13U, + .detail.body = + { + .lease = {3U, 4U}, + .byte_count = 3U, + }, + }); + assert(pocketjs_net_esp_runtime_peek_event(runtime, &selected) && + selected == slot && + slot->lease_state == POCKETJS_NET_ESP_RUNTIME_LEASE_QUEUED); + /* binding_next_completion marks the descriptor selected after JS creation. */ + slot->lease_descriptor_delivered = true; + const pocketjs_network_v1_handle_t lease = slot->lease; + uint32_t taken = 0U; + response_identity.command_sequence = 6U; + assert(pocketjs_net_esp_runtime_lease_take(runtime, &response_identity, lease, + 3U, &taken, &error)); + assert(taken == 3U); + uint8_t oversized_destination[3] = {0}; + uint8_t destination[2] = {0}; + uint32_t copied = 0U; + response_identity.command_sequence = 7U; + assert(!pocketjs_net_esp_runtime_lease_read( + runtime, &response_identity, lease, 1U, 2U, oversized_destination, + sizeof(oversized_destination), &copied, &error)); + response_identity.command_sequence = 8U; + assert(!pocketjs_net_esp_runtime_lease_read( + runtime, &response_identity, lease, 2U, 2U, destination, + sizeof(destination), &copied, &error)); + response_identity.command_sequence = 9U; + assert(pocketjs_net_esp_runtime_lease_read( + runtime, &response_identity, lease, 1U, 2U, destination, + sizeof(destination), &copied, &error)); + assert(copied == 2U && destination[0] == 'b' && destination[1] == 'c'); + response_identity.command_sequence = 10U; + assert(pocketjs_net_esp_runtime_lease_release(runtime, &response_identity, + lease, &error)); + assert(!slot->event_pending && + slot->lease_state == POCKETJS_NET_ESP_RUNTIME_LEASE_NONE); + + queue_event(core, (pocketjs_net_http_client_event_t){ + .type = POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE, + .sequence = 14U, + }); + assert(pocketjs_net_esp_runtime_peek_event(runtime, &selected) && + selected == slot); + assert(pocketjs_net_esp_runtime_retire_nonlease_event(runtime, slot)); + assert(!slot->active); + + command.has_body = false; + request_identity = identity(3U, (pocketjs_network_v1_handle_t){0U, 0U}, 11U); + assert(pocketjs_net_esp_runtime_start_http(runtime, &request_identity, + &command, &error)); + assert(core_starts == 2U && permission_count == 4U); + queue_event( + core, (pocketjs_net_http_client_event_t){ + .type = POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS, + .sequence = 19U, + .detail.response = + { + .status_code = 204U, + .status_text = {status_text, sizeof(status_text) - 1U}, + .headers = headers, + .header_count = 1U, + }, + }); + assert(pocketjs_net_esp_runtime_peek_event(runtime, &selected) && + selected == slot && + pocketjs_network_v1_handle_is_absent(slot->response_body)); + assert(pocketjs_net_esp_runtime_retire_nonlease_event(runtime, slot)); + assert(slot->active); + queue_event(core, (pocketjs_net_http_client_event_t){ + .type = POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE, + .sequence = 20U, + }); + assert(pocketjs_net_esp_runtime_peek_event(runtime, &selected) && + selected == slot); + assert(pocketjs_net_esp_runtime_retire_nonlease_event(runtime, slot)); + assert(!slot->active); + + request_identity = identity(4U, (pocketjs_network_v1_handle_t){0U, 0U}, 12U); + assert(pocketjs_net_esp_runtime_start_http(runtime, &request_identity, + &command, &error)); + assert(core_starts == 3U && permission_count == 6U); + assert(pocketjs_net_esp_runtime_begin_shutdown(runtime, 5000U) == ESP_OK); + queue_event(core, (pocketjs_net_http_client_event_t){ + .type = POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR, + .sequence = 21U, + .detail.error = + { + .code = POCKETJS_NET_HTTP_CLIENT_ERROR_ABORTED, + }, + }); + assert(pocketjs_net_esp_runtime_peek_event(runtime, &selected) && + selected == slot); + assert(pocketjs_net_esp_runtime_retire_nonlease_event(runtime, slot)); + pocketjs_net_esp_runtime_service_result_t service_result = {0}; + assert(pocketjs_net_esp_runtime_service(runtime, 6000U, 1U, 1U, 1U, 16U, + &service_result) == ESP_OK); + assert(service_result.status == + POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_DRAINED); + assert(pocketjs_net_esp_runtime_is_ready_to_destroy(runtime)); + + pocketjs_net_esp_runtime_stats_t stats = {0}; + assert(pocketjs_net_esp_runtime_get_stats(runtime, &stats) == ESP_OK); + assert(stats.configured_operation_slots == 1U && + stats.initialized_operation_slots == 1U && + stats.active_operations == 0U && stats.requests_started == 3U && + stats.leases_taken == 1U && stats.leases_released == 1U && + stats.permission_checks == 6U && stats.poison_flags == 0U && + stats.runtime_instance_bytes == + sizeof(*runtime) + sizeof(runtime->slots[0]) && + stats.pocketjs_owned_native_bytes == + stats.runtime_instance_bytes + + sizeof(pocketjs_net_esp_runtime_binding_state_t) + + sizeof(struct pocketjs_net_esp_transport) && + stats.admitted_native_buffer_bytes == 524288U); + assert(pocketjs_net_esp_runtime_destroy(runtime) == ESP_OK); + assert(transport_destroys == 1U && wake_count >= 4U); + test_dispatcher_poison_native_fallback(&config, &command); + test_released_lease_retire_failure(&config, &command); + test_held_lease_shutdown_cleanup(&config, &command); + test_poisoned_core_retained_transport_teardown(&config, &command); + assert(transport_creates == 5U && transport_destroys == 5U); + test_tls_profile_snapshot(&config); + assert(transport_creates == 6U && transport_destroys == 6U); + assert(!fail_next_event_retirement && !fail_next_dispatcher_call); + return 0; +} diff --git a/hosts/esp-idf/components/pocketjs_net_esp_transport/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_esp_transport/CMakeLists.txt new file mode 100644 index 00000000..cf13623c --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_transport/CMakeLists.txt @@ -0,0 +1,11 @@ +idf_component_register( + SRCS + "src/esp_transport.c" + "src/transport_state.c" + INCLUDE_DIRS "include" + PRIV_INCLUDE_DIRS "private" + REQUIRES esp_common freertos + PRIV_REQUIRES esp-tls esp_timer lwip mbedtls +) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/hosts/esp-idf/components/pocketjs_net_esp_transport/README.md b/hosts/esp-idf/components/pocketjs_net_esp_transport/README.md new file mode 100644 index 00000000..d538b883 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_transport/README.md @@ -0,0 +1,143 @@ +# PocketJS ESP-IDF transport candidate + +This component is a private, experimental substrate for the reference protocol +cores. **It does not register or advertise a PocketJS public capability.** It +is pinned to ESP-IDF v6.0.2 commit +`7101770dc6db2667b3c477cc31365dd1acd6db4e` and supports the `esp32s3` and +`esp32p4` build targets. + +## Ownership and scheduling + +The API separates `resolve` from `connect`. A resolve completion contains a +bounded, de-duplicated IPv4 candidate array. The protocol Core is responsible +for applying endpoint permission to every candidate before starting a connect +with one numeric address. TLS connect additionally receives the original DNS +hostname, which ESP-TLS uses as `common_name`; Mbed TLS therefore performs both +certificate identity verification and SNI with the original hostname. + +Create, start, pump, completion, lease, and destroy calls belong to one Host +owner task. `cancel` and `begin_shutdown` are thread-safe and wake the owner +after releasing the transport lock. Before destroy, the Host must prohibit new +cross-task calls and join all in-flight callers. The lwIP DNS callback only +copies bounded numeric results, updates native state, and calls the Host wake +hook. **No worker, lwIP callback, or ESP-TLS call enters QuickJS.** + +Every accepted operation reserves one terminal credit. Success, error, abort, +and monotonic timeout compete for one operation lifecycle transition. Dequeue +moves that credit to `delivering`; the Core returns it explicitly only after +Guest delivery. Operation tokens are strictly increasing 64-bit values and +never wrap. Connection and read-lease generations also never wrap, so a stale +handle cannot become valid through slot reuse. + +Cancel or timeout of connect, read, write, or close closes that connection. In +particular, a partially completed write cannot leave a reusable stream with an +unreported prefix, and a cancelled read cannot reuse an HTTP/1 connection at an +unknown response boundary. Resolve cancellation retains only its quarantined +DNS context. A normal TLS close sends `close_notify`; `WANT_READ`/`WANT_WRITE` +retries consume bounded owner-pump steps under the close operation's monotonic +deadline. The client does not wait indefinitely for a reciprocal alert. A +close-alert failure, cancellation, or timeout hard-closes the native stream and +is reported through the existing close terminal path. Transport-wide shutdown +cancels active resolve/connect/read/write operations, but it does not cancel an +already accepted close; that close completes or reaches its own deadline. + +Poison recovery is separate from healthy shutdown. An owner may call +`pocketjs_net_esp_transport_destroy_poisoned` only after the sole Core using the +dedicated transport has reported poison and shutdown has stopped admission. +The call abandons stuck completion/lease accounting and closes every native +connection after a tcpip-thread barrier. It does not free a raw DNS lookup's +callback context early: until lwIP delivers that lookup's late callback, poison +teardown returns `ESP_ERR_NOT_FINISHED` and the owner retries. A successful +poison teardown invalidates that Core's transport context, so its owner confirms +transport shutdown before calling any other Core entry point. + +The transport owner is a product task, never lwIP's tcpip task, because both +destroy paths use a synchronous tcpip callback barrier. The component accepts +only stock tcpip-thread DNS callbacks. A build with +`CONFIG_LWIP_HOOK_DNS_EXT_RESOLVE_CUSTOM` fails at compile time because an +external resolver can deliver a late callback outside that barrier. + +DNS submission uses one pre-acquired lwIP static callback message per DNS +context and `dns_gethostbyname_addrtype(..., LWIP_DNS_ADDRTYPE_IPV4)`. The +component does not call synchronous `getaddrinfo` for a hostname. Cancellation +marks the operation immediately; a pending raw DNS context remains quarantined +and cannot be reused until its late callback performs cleanup. Both the static +tcpip submission and raw found callback carry an immutable, nonzero snapshot of +the context generation. They compare it with the live generation under the +transport lock before every state transition, so a stale callback cannot act on +a reused slot. `.local` and non-canonical hostnames are rejected before native +I/O. If all `DNS_MAX_HOST_IP` cache slots are populated, the result is rejected +as `resource_limit` instead of exposing a possibly truncated prefix. + +The BSP must initialize its concrete network interface, ESP-NETIF, and lwIP +before creating this transport. This component never configures Wi-Fi or owns +interface lifecycle. + +## TLS profile + +TLS is exact TLS 1.2 with hostname verification and SNI. The product Host +selects either the compiled ESP certificate bundle or one copied, bounded, +validated CA PEM. Guest input cannot select trust. The Host must publish a +trusted wall-clock state; every handshake step fails closed with +`tls_certificate_invalid` while that state is absent. Insecure certificate +verification, weak certificate verification, renegotiation, DES/3DES, early +data, and plaintext fallback are disabled or rejected at compile time. +ESP-TLS reports `MBEDTLS_ERR_X509_CERT_VERIFY_FAILED` when certificate +verification aborts without retained verification flags. The transport maps +that code to `tls_certificate_invalid`; it does not relabel it as a generic +handshake failure. + +## Admission blockers + +The descriptor deliberately reports the following gaps: + +- **Native step wall time is not proven.** The adapter now creates and connects + the numeric IPv4 socket itself, polls it with zero timeout, and transfers the + connected `O_NONBLOCK` descriptor through ESP-TLS's public socket/state APIs. + This bypasses ESP-TLS's private `getaddrinfo` and internal TCP-connect + `select`; each Mbed TLS handshake call returns on `WANT_READ`/`WANT_WRITE`. + Socket allocation, cryptographic work, and scheduler contention still have no + proven per-step wall-time bound. +- **lwIP DNS tables, socket/netconn objects, TCP buffers, callback MEMP entries, + and ESP-TLS/Mbed TLS handshake objects are native allocations whose byte peak + is not proven by the fixed PocketJS pools.** The descriptor reports each as + unbounded. +- **Validation of the one Host-pinned CA uses Mbed TLS X.509 parsing.** Its + transient native allocation is not caller-owned or byte-bounded even though + the input snapshot is fixed at 4096 bytes. +- **Certificate verification reasons are recovered from the live negotiation + state.** ESP-IDF's error handle may preserve only the generic X.509 failure, + so the adapter also reads `mbedtls_ssl_get_verify_result()` from the public + ESP-TLS SSL-context accessor before destroying the connection. Hostname + mismatch maps to `tls_hostname_mismatch`; chain, validity, and usage failures + map to `tls_certificate_invalid`. S3 and P4 wire tests require the expected + SNI, a failed handshake, and zero HTTP requests. +- **The lwIP resolver retains at most `DNS_MAX_HOST_IP` records.** A full prefix + is rejected, but the stock callback does not expose the DNS `TC` bit. A + truncated response containing fewer records therefore remains + indistinguishable from a complete response, so the descriptor does not claim + a complete candidate set. +- **lwIP raw DNS has no request cancellation primitive.** A cancelled query can + retain one fixed DNS context until success, failure, or lwIP timeout. +- **The Host owns cross-task API lifetime.** It must disable and join + `cancel`/`begin_shutdown` callers before destroy; internal pool quiescence + cannot prove that an external caller does not still hold the pointer. + +Until these blockers have measured target-specific resource envelopes and +bounded native step wall time, this provider must not enter a production or +admitted-target Build Plan and must not advertise +`network.http.client.tls`. **An exact test-only TLS smoke plan may select this +provider solely to gather pre-admission hardware evidence.** That plan does not +add a stock target, open the compiler's public network gate, or change the +descriptor's `advertises_public_capability=false` result. + +## Verification + +The host-state test covers token monotonicity, no-wrap generations, shutdown +admission, terminal credit conservation, cancel/deadline terminal arbitration, +round-robin progress, saturated DNS-prefix rejection, stale DNS callback-ticket +rejection, and pure TLS error classification without ESP-IDF. The nested +ESP-IDF build-smoke app repeats the TLS classification assertions with the +pinned Mbed TLS constants and asserts the public descriptor and stable numeric +and name mapping. Its `sdkconfig.defaults` fixes the candidate count and TLS +policy. diff --git a/hosts/esp-idf/components/pocketjs_net_esp_transport/idf_component.yml b/hosts/esp-idf/components/pocketjs_net_esp_transport/idf_component.yml new file mode 100644 index 00000000..173a31ab --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_transport/idf_component.yml @@ -0,0 +1,10 @@ +version: "0.1.0" +description: Experimental fixed-pool PocketJS ESP-IDF TCP and TLS transport candidate +license: MIT +repository: https://github.com/pocket-stack/pocketjs +documentation: https://github.com/pocket-stack/pocketjs/tree/main/hosts/esp-idf +targets: + - esp32p4 + - esp32s3 +dependencies: + idf: "6.0.2" diff --git a/hosts/esp-idf/components/pocketjs_net_esp_transport/include/pocketjs/net/esp_transport.h b/hosts/esp-idf/components/pocketjs_net_esp_transport/include/pocketjs/net/esp_transport.h new file mode 100644 index 00000000..16f41f48 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_transport/include/pocketjs/net/esp_transport.h @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define POCKETJS_NET_ESP_TRANSPORT_ID \ + "pocketjs.net.esp-idf.transport.v1.experimental" +#define POCKETJS_NET_ESP_TLS_PROVIDER_ID \ + "pocketjs.net.esp-idf.esp-tls.v1.experimental" + +#define POCKETJS_NET_ESP_TRANSPORT_MAX_CONNECTIONS 4U +#define POCKETJS_NET_ESP_TRANSPORT_MAX_OPERATIONS 8U +#define POCKETJS_NET_ESP_TRANSPORT_COMPLETION_CAPACITY 8U +#define POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CONTEXTS 8U +#define POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CANDIDATES 4U +#define POCKETJS_NET_ESP_TRANSPORT_MAX_HOSTNAME_BYTES 253U +#define POCKETJS_NET_ESP_TRANSPORT_MAX_WRITE_BYTES 4096U +#define POCKETJS_NET_ESP_TRANSPORT_READ_LEASES 8U +#define POCKETJS_NET_ESP_TRANSPORT_READ_LEASE_BYTES 2048U +#define POCKETJS_NET_ESP_TRANSPORT_MAX_PINNED_CA_PEM_BYTES 4096U +#define POCKETJS_NET_ESP_TRANSPORT_TLS_STEP_TIMEOUT_MS 0U + +typedef struct pocketjs_net_esp_transport pocketjs_net_esp_transport_t; + +/** + * Operation tokens are selected by the owner. A transport accepts non-zero + * values in strictly increasing order and never wraps. Accepting UINT64_MAX + * exhausts that transport's token space permanently. This makes a stale + * completion distinguishable even after an operation slot is reused. + */ +typedef uint64_t pocketjs_net_esp_operation_token_t; + +typedef struct { + uint32_t slot; + uint64_t generation; +} pocketjs_net_esp_connection_t; + +typedef struct { + uint32_t slot; + uint64_t generation; +} pocketjs_net_esp_read_lease_t; + +/* These values are wire/log stable. Additions must use new numeric values. */ +typedef enum { + POCKETJS_NET_ESP_TERMINAL_RESOLVED = 0x1001, + POCKETJS_NET_ESP_TERMINAL_CONNECTED = 0x1002, + POCKETJS_NET_ESP_TERMINAL_READ = 0x1003, + POCKETJS_NET_ESP_TERMINAL_WRITTEN = 0x1004, + POCKETJS_NET_ESP_TERMINAL_CLOSED = 0x1005, + POCKETJS_NET_ESP_TERMINAL_ERROR = 0x10ff, +} pocketjs_net_esp_terminal_type_t; + +typedef enum { + POCKETJS_NET_ESP_ERROR_NONE = 0, + POCKETJS_NET_ESP_ERROR_ABORTED = 1, + POCKETJS_NET_ESP_ERROR_TIMED_OUT = 2, + POCKETJS_NET_ESP_ERROR_BUSY = 3, + POCKETJS_NET_ESP_ERROR_RESOURCE_LIMIT = 4, + POCKETJS_NET_ESP_ERROR_INVALID_ARGUMENT = 5, + POCKETJS_NET_ESP_ERROR_CLOSED = 6, + POCKETJS_NET_ESP_ERROR_UNSUPPORTED = 7, + + POCKETJS_NET_ESP_ERROR_DNS_NOT_FOUND = 100, + POCKETJS_NET_ESP_ERROR_DNS_FAILED = 101, + + POCKETJS_NET_ESP_ERROR_CONNECTION_REFUSED = 200, + POCKETJS_NET_ESP_ERROR_CONNECTION_RESET = 201, + POCKETJS_NET_ESP_ERROR_NETWORK_UNREACHABLE = 202, + POCKETJS_NET_ESP_ERROR_TRANSPORT_FAILED = 203, + + POCKETJS_NET_ESP_ERROR_TLS_CERTIFICATE_INVALID = 300, + POCKETJS_NET_ESP_ERROR_TLS_HOSTNAME_MISMATCH = 301, + POCKETJS_NET_ESP_ERROR_TLS_HANDSHAKE_FAILED = 302, + POCKETJS_NET_ESP_ERROR_TLS_VERSION_UNSUPPORTED = 303, + POCKETJS_NET_ESP_ERROR_TLS_ALERT = 304, +} pocketjs_net_esp_error_t; + +typedef enum { + POCKETJS_NET_ESP_ERROR_CATEGORY_RUNTIME = 1, + POCKETJS_NET_ESP_ERROR_CATEGORY_RESOLVER = 2, + POCKETJS_NET_ESP_ERROR_CATEGORY_TRANSPORT = 3, + POCKETJS_NET_ESP_ERROR_CATEGORY_TLS = 4, +} pocketjs_net_esp_error_category_t; + +typedef enum { + POCKETJS_NET_ESP_TLS_TRUST_DISABLED = 0, + POCKETJS_NET_ESP_TLS_TRUST_CERTIFICATE_BUNDLE = 1, + POCKETJS_NET_ESP_TLS_TRUST_HOST_PINNED_CA = 2, +} pocketjs_net_esp_tls_trust_source_t; + +typedef void (*pocketjs_net_esp_wake_fn)(void *context); +typedef bool (*pocketjs_net_esp_wall_clock_trusted_fn)(void *context); + +typedef struct { + /** + * May run on lwIP's tcpip thread or a cancel/shutdown caller task. It must + * only signal the product scheduler; it must not block, enter QuickJS, + * re-enter this transport, or destroy it. + */ + pocketjs_net_esp_wake_fn wake; + void *wake_context; + pocketjs_net_esp_tls_trust_source_t tls_trust_source; + const uint8_t *host_pinned_ca_pem; + size_t host_pinned_ca_pem_bytes; + /** Required for either TLS trust source and called only by the owner pump. */ + pocketjs_net_esp_wall_clock_trusted_fn wall_clock_trusted; + void *wall_clock_context; +} pocketjs_net_esp_transport_config_t; + +typedef struct { + const char *id; + const char *implementation_version; + bool experimental; + bool advertises_public_capability; + bool ipv4; + bool asynchronous_raw_dns; + bool stock_lwip_dns_callbacks_only; + bool complete_dns_candidate_set; + bool rejects_saturated_dns_candidate_prefix; + /** Late callbacks are gated by an immutable, non-wrapping generation. */ + bool dns_cancel_generation_cleanup; + bool synchronous_getaddrinfo_for_hostname; + bool esp_tls_numeric_getaddrinfo_internal; + bool nonblocking_plain_tcp_steps; + bool nonblocking_tls_steps; + bool bounded_native_step_wall_time; + uint32_t esp_tls_internal_select_timeout_ms; + bool monotonic_deadlines; + bool cancel_between_native_steps; + bool worker_or_callback_calls_quickjs; + bool exact_one_terminal; + bool aba_safe_tokens; + bool fixed_operation_pool; + bool fixed_completion_pool; + bool fixed_payload_pool; + bool tls_compiled; + bool tls_1_2_only; + bool host_trust; + bool host_pinned_ca; + bool hostname_verification; + /** Hostname mismatch is distinct from other certificate failures. */ + bool distinct_tls_errors; + bool sni; + bool trusted_wall_clock_required; + bool plaintext_fallback; + bool renegotiation; + bool early_data; + bool tls_close_notify; + bool tls_close_notify_uses_operation_deadline; + bool tls_close_notify_waits_for_peer; + bool bounded_lwip_dns_callback_allocation; + bool bounded_lwip_socket_allocation; + bool bounded_esp_tls_allocation; + bool bounded_mbedtls_x509_parse_allocation; + size_t pocketjs_owned_instance_bytes; + size_t lwip_static_callback_messages; + size_t max_connections; + size_t max_operations; + size_t completion_capacity; + size_t max_dns_candidates; + size_t max_write_bytes; + size_t read_lease_bytes; + size_t max_pinned_ca_bytes; +} pocketjs_net_esp_transport_descriptor_t; + +typedef struct { + const char *hostname; + uint64_t deadline_us; +} pocketjs_net_esp_resolve_request_t; + +typedef struct { + /** IPv4 address in network byte order, already selected by the Core. */ + uint32_t ipv4_be; + uint16_t port; + bool tls; + /** Required for TLS and used for certificate verification and SNI. */ + const char *original_hostname; + uint64_t deadline_us; +} pocketjs_net_esp_connect_request_t; + +typedef struct { + pocketjs_net_esp_connection_t connection; + size_t maximum_bytes; + uint64_t deadline_us; +} pocketjs_net_esp_read_request_t; + +typedef struct { + pocketjs_net_esp_connection_t connection; + const uint8_t *bytes; + size_t length; + uint64_t deadline_us; +} pocketjs_net_esp_write_request_t; + +typedef struct { + pocketjs_net_esp_connection_t connection; + uint64_t deadline_us; +} pocketjs_net_esp_close_request_t; + +typedef struct { + pocketjs_net_esp_error_t code; + pocketjs_net_esp_error_category_t category; + int32_t cause_code; + int32_t tls_code; + uint32_t tls_certificate_flags; + bool temporary; +} pocketjs_net_esp_error_detail_t; + +typedef struct { + pocketjs_net_esp_terminal_type_t type; + pocketjs_net_esp_operation_token_t operation_token; + uint64_t sequence; + union { + struct { + uint32_t ipv4_be[POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CANDIDATES]; + size_t candidate_count; + } resolved; + struct { + pocketjs_net_esp_connection_t connection; + uint32_t ipv4_be; + bool tls; + } connected; + struct { + pocketjs_net_esp_connection_t connection; + pocketjs_net_esp_read_lease_t lease; + size_t byte_count; + bool eof; + } read; + struct { + pocketjs_net_esp_connection_t connection; + size_t byte_count; + } written; + struct { + pocketjs_net_esp_connection_t connection; + } closed; + pocketjs_net_esp_error_detail_t error; + } detail; +} pocketjs_net_esp_completion_t; + +const pocketjs_net_esp_transport_descriptor_t * +pocketjs_net_esp_transport_descriptor(void); + +/** Validate and parse a Host TLS profile without creating transport state. */ +esp_err_t pocketjs_net_esp_transport_validate_config( + const pocketjs_net_esp_transport_config_t *config); + +/** + * create fixes the calling task as owner. The owner must be a product task, + * never lwIP's tcpip task, because destroy uses a synchronous tcpip callback + * barrier. Except for cancel and begin_shutdown, every API below must be called + * by that same task. + */ +esp_err_t pocketjs_net_esp_transport_create( + const pocketjs_net_esp_transport_config_t *config, + pocketjs_net_esp_transport_t **out_transport); + +/** Thread-safe. + * Starts quiescing and requests cancellation for every active operation except + * an already accepted close, which continues under its monotonic deadline. + * The owner must continue pump/take/retire until is_quiescent returns true. + * The wake hook is called after releasing the transport lock. + */ +void pocketjs_net_esp_transport_begin_shutdown( + pocketjs_net_esp_transport_t *transport); + +bool pocketjs_net_esp_transport_is_quiescent( + const pocketjs_net_esp_transport_t *transport); + +/** + * Returns ESP_ERR_INVALID_STATE until the transport is quiescent. Before this + * call, the Host must prevent new cancel/begin_shutdown calls and join every + * in-flight caller; internal quiescence does not own external caller lifetime. + */ +esp_err_t +pocketjs_net_esp_transport_destroy(pocketjs_net_esp_transport_t *transport); + +/** + * Owner-only poison teardown for a dedicated transport whose sole protocol + * Core has entered shutdown and reported poisoned native ownership. The caller + * must already have called begin_shutdown, prohibited new external calls, and + * joined every in-flight caller. This abandons queued/delivering completions, + * closes native connections, and releases transport-owned leases only after a + * tcpip-thread barrier proves that no DNS callback is executing. A raw DNS + * lookup that has not reached its late callback keeps returning + * ESP_ERR_NOT_FINISHED; the caller must retry without freeing the Core. Once + * this succeeds, transport_context in that Core is invalid: the caller must + * synchronously call core_confirm_transport_shutdown before any other Core + * entry point. + * + * Healthy shutdown must use pocketjs_net_esp_transport_destroy(). + */ +esp_err_t pocketjs_net_esp_transport_destroy_poisoned( + pocketjs_net_esp_transport_t *transport); + +esp_err_t pocketjs_net_esp_transport_start_resolve( + pocketjs_net_esp_transport_t *transport, + pocketjs_net_esp_operation_token_t token, + const pocketjs_net_esp_resolve_request_t *request); + +esp_err_t pocketjs_net_esp_transport_start_connect( + pocketjs_net_esp_transport_t *transport, + pocketjs_net_esp_operation_token_t token, + const pocketjs_net_esp_connect_request_t *request); + +esp_err_t pocketjs_net_esp_transport_start_read( + pocketjs_net_esp_transport_t *transport, + pocketjs_net_esp_operation_token_t token, + const pocketjs_net_esp_read_request_t *request); + +esp_err_t pocketjs_net_esp_transport_start_write( + pocketjs_net_esp_transport_t *transport, + pocketjs_net_esp_operation_token_t token, + const pocketjs_net_esp_write_request_t *request); + +esp_err_t pocketjs_net_esp_transport_start_close( + pocketjs_net_esp_transport_t *transport, + pocketjs_net_esp_operation_token_t token, + const pocketjs_net_esp_close_request_t *request); + +/** + * Thread-safe. Native cleanup is performed later by the owner pump. A + * successful request calls the wake hook after releasing the transport lock. + */ +esp_err_t +pocketjs_net_esp_transport_cancel(pocketjs_net_esp_transport_t *transport, + pocketjs_net_esp_operation_token_t token); + +/** + * Advances at most max_native_steps operations with persistent round-robin + * fairness. now_us must use esp_timer_get_time()'s monotonic timebase. The + * transport re-samples that clock after native steps before terminal claim. + * This function never calls the wake hook or QuickJS. + */ +esp_err_t +pocketjs_net_esp_transport_pump(pocketjs_net_esp_transport_t *transport, + uint64_t now_us, size_t max_native_steps); + +/** Non-blocking single-consumer terminal dequeue. */ +esp_err_t pocketjs_net_esp_transport_take_completion( + pocketjs_net_esp_transport_t *transport, + pocketjs_net_esp_completion_t *out_completion); + +/** + * Ends delivery and returns the terminal admission credit. It must be called + * exactly once after take_completion, after the Core has finished delivery. + */ +esp_err_t pocketjs_net_esp_transport_retire_completion( + pocketjs_net_esp_transport_t *transport, + pocketjs_net_esp_operation_token_t token); + +esp_err_t pocketjs_net_esp_transport_read_lease_view( + pocketjs_net_esp_transport_t *transport, + pocketjs_net_esp_read_lease_t lease, const uint8_t **out_bytes, + size_t *out_capacity); + +esp_err_t pocketjs_net_esp_transport_release_read_lease( + pocketjs_net_esp_transport_t *transport, + pocketjs_net_esp_read_lease_t lease); + +const char *pocketjs_net_esp_error_name(pocketjs_net_esp_error_t error); + +#if defined(POCKETJS_NET_ESP_TRANSPORT_TEST_INTERNALS) +/** Build-smoke seam for deterministic native TLS error mapping checks. */ +pocketjs_net_esp_error_t +pocketjs_net_esp_transport_map_tls_error_for_test(int tls_code, + uint32_t certificate_flags); +#endif + +#ifdef __cplusplus +} +#endif diff --git a/hosts/esp-idf/components/pocketjs_net_esp_transport/private/transport_state.h b/hosts/esp-idf/components/pocketjs_net_esp_transport/private/transport_state.h new file mode 100644 index 00000000..2d653978 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_transport/private/transport_state.h @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +typedef struct { + uint64_t last; + bool exhausted; +} pocketjs_net_token_gate_t; + +typedef struct { + size_t capacity; + size_t reserved; + size_t queued; + size_t delivering; +} pocketjs_net_terminal_credits_t; + +typedef enum { + POCKETJS_NET_OPERATION_FREE = 0, + POCKETJS_NET_OPERATION_ACTIVE = 1, + POCKETJS_NET_OPERATION_TERMINAL_QUEUED = 2, + POCKETJS_NET_OPERATION_DELIVERING = 3, +} pocketjs_net_operation_lifecycle_t; + +typedef enum { + POCKETJS_NET_TRANSPORT_OPERATION_NONE = 0, + POCKETJS_NET_TRANSPORT_OPERATION_RESOLVE = 1, + POCKETJS_NET_TRANSPORT_OPERATION_CONNECT = 2, + POCKETJS_NET_TRANSPORT_OPERATION_READ = 3, + POCKETJS_NET_TRANSPORT_OPERATION_WRITE = 4, + POCKETJS_NET_TRANSPORT_OPERATION_CLOSE = 5, +} pocketjs_net_transport_operation_kind_t; + +typedef enum { + POCKETJS_NET_OPERATION_ADMISSION_ACCEPTED = 0, + POCKETJS_NET_OPERATION_ADMISSION_CLOSING, + POCKETJS_NET_OPERATION_ADMISSION_INVALID_TOKEN, + POCKETJS_NET_OPERATION_ADMISSION_NO_CAPACITY, +} pocketjs_net_operation_admission_t; + +typedef enum { + POCKETJS_NET_OPERATION_TERMINAL_NONE = 0, + POCKETJS_NET_OPERATION_TERMINAL_ABORTED, + POCKETJS_NET_OPERATION_TERMINAL_TIMED_OUT, +} pocketjs_net_operation_terminal_reason_t; + +typedef enum { + POCKETJS_NET_TLS_ERROR_CLASS_HANDSHAKE_FAILED = 0, + POCKETJS_NET_TLS_ERROR_CLASS_CERTIFICATE_INVALID, + POCKETJS_NET_TLS_ERROR_CLASS_HOSTNAME_MISMATCH, + POCKETJS_NET_TLS_ERROR_CLASS_VERSION_UNSUPPORTED, + POCKETJS_NET_TLS_ERROR_CLASS_ALERT, + POCKETJS_NET_TLS_ERROR_CLASS_RESOURCE_LIMIT, +} pocketjs_net_tls_error_class_t; + +typedef enum { + POCKETJS_NET_TLS_CLOSE_NOTIFY_COMPLETE = 0, + POCKETJS_NET_TLS_CLOSE_NOTIFY_RETRY, + POCKETJS_NET_TLS_CLOSE_NOTIFY_FAILED, +} pocketjs_net_tls_close_notify_outcome_t; + +/** + * Native TLS constants are supplied by the pinned platform build. Keeping the + * classifier independent of those headers makes the mapping host-testable. + */ +typedef struct { + int certificate_verify_failed; + int bad_certificate; + int ca_chain_required; + int bad_protocol_version; + int fatal_alert_message; + int allocation_failed; + uint32_t hostname_mismatch_flag; +} pocketjs_net_tls_error_symbols_t; + +bool pocketjs_net_token_gate_can_accept(const pocketjs_net_token_gate_t *gate, + uint64_t token); +void pocketjs_net_token_gate_consume(pocketjs_net_token_gate_t *gate, + uint64_t token); + +bool pocketjs_net_terminal_credit_reserve( + pocketjs_net_terminal_credits_t *credits); +bool pocketjs_net_terminal_credit_enqueue( + pocketjs_net_terminal_credits_t *credits); +bool pocketjs_net_terminal_credit_take( + pocketjs_net_terminal_credits_t *credits); +bool pocketjs_net_terminal_credit_retire( + pocketjs_net_terminal_credits_t *credits); +bool pocketjs_net_terminal_credit_invariant( + const pocketjs_net_terminal_credits_t *credits); + +bool pocketjs_net_operation_claim_terminal( + pocketjs_net_operation_lifecycle_t *lifecycle); +/** Caller must serialize this with shutdown and cancellation. */ +pocketjs_net_operation_admission_t +pocketjs_net_operation_admit(bool closing, pocketjs_net_token_gate_t *gate, + pocketjs_net_terminal_credits_t *credits, + pocketjs_net_operation_lifecycle_t *lifecycle, + uint64_t *current_token, uint64_t requested_token); +/** Caller must serialize this with cancellation. */ +bool pocketjs_net_operation_claim_native_terminal( + pocketjs_net_operation_lifecycle_t *lifecycle, bool cancel_requested, + bool deadline_expired); +/** Cancellation wins over a simultaneous deadline. Caller must serialize. */ +pocketjs_net_operation_terminal_reason_t +pocketjs_net_operation_claim_cancel_or_timeout( + pocketjs_net_operation_lifecycle_t *lifecycle, bool cancel_requested, + bool deadline_expired); +bool pocketjs_net_operation_begin_delivery( + pocketjs_net_operation_lifecycle_t *lifecycle); +bool pocketjs_net_operation_retire( + pocketjs_net_operation_lifecycle_t *lifecycle); +bool pocketjs_net_operation_ticket_matches( + pocketjs_net_operation_lifecycle_t lifecycle, uint64_t current_token, + uint64_t requested_token); +bool pocketjs_net_operation_cancel_closes_connection( + pocketjs_net_transport_operation_kind_t kind); +bool pocketjs_net_operation_shutdown_requests_cancel( + pocketjs_net_transport_operation_kind_t kind); + +pocketjs_net_tls_error_class_t pocketjs_net_classify_tls_error( + int tls_code, uint32_t certificate_flags, + const pocketjs_net_tls_error_symbols_t *symbols); + +uint32_t pocketjs_net_select_tls_certificate_flags(uint32_t captured_flags, + uint32_t live_verify_result); + +pocketjs_net_tls_close_notify_outcome_t +pocketjs_net_classify_tls_close_notify(int result, int want_read, + int want_write); + +size_t pocketjs_net_round_robin_next(size_t current, size_t capacity); + +/** A full fixed resolver prefix is ambiguous and must be rejected. */ +bool pocketjs_net_dns_candidate_prefix_saturated(size_t populated_slots, + size_t slot_capacity); + +/** Callback tickets are nonzero immutable snapshots of a context generation. */ +bool pocketjs_net_dns_callback_ticket_matches(bool ticket_active, + uint64_t context_generation, + uint64_t ticket_generation); + +/** Never wraps. False permanently retires a slot at UINT64_MAX. */ +bool pocketjs_net_generation_advance(uint64_t *generation); diff --git a/hosts/esp-idf/components/pocketjs_net_esp_transport/src/esp_transport.c b/hosts/esp-idf/components/pocketjs_net_esp_transport/src/esp_transport.c new file mode 100644 index 00000000..cb8334d2 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_transport/src/esp_transport.c @@ -0,0 +1,2072 @@ +// SPDX-License-Identifier: MIT + +#include "pocketjs/net/esp_transport.h" + +#include +#include +#include +#include +#include +#include + +#include "esp_crt_bundle.h" +#include "esp_idf_version.h" +#include "esp_timer.h" +#include "esp_tls.h" +#include "esp_tls_errors.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "lwip/dns.h" +#include "lwip/inet.h" +#include "lwip/sockets.h" +#include "lwip/tcpip.h" +#include "mbedtls/ssl.h" +#include "mbedtls/x509.h" +#include "mbedtls/x509_crt.h" +#include "sdkconfig.h" +#include "transport_state.h" + +#if ESP_IDF_VERSION != ESP_IDF_VERSION_VAL(6, 0, 2) +#error "PocketJS ESP transport is pinned to exact ESP-IDF v6.0.2" +#endif +#if !defined(CONFIG_ESP_TLS_USING_MBEDTLS) || !CONFIG_ESP_TLS_USING_MBEDTLS +#error "PocketJS ESP transport requires the ESP-TLS Mbed TLS backend" +#endif +#if !defined(CONFIG_MBEDTLS_SSL_PROTO_TLS1_2) || \ + !CONFIG_MBEDTLS_SSL_PROTO_TLS1_2 +#error "PocketJS ESP transport requires TLS 1.2" +#endif +#if defined(CONFIG_MBEDTLS_SSL_RENEGOTIATION) && \ + CONFIG_MBEDTLS_SSL_RENEGOTIATION +#error "PocketJS ESP transport rejects TLS 1.2 renegotiation" +#endif +#if defined(CONFIG_ESP_TLS_INSECURE) && CONFIG_ESP_TLS_INSECURE +#error "PocketJS ESP transport rejects insecure ESP-TLS builds" +#endif +#if defined(CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY) && \ + CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY +#error "PocketJS ESP transport requires certificate verification" +#endif +#if defined(CONFIG_MBEDTLS_ALLOW_WEAK_CERTIFICATE_VERIFICATION) && \ + CONFIG_MBEDTLS_ALLOW_WEAK_CERTIFICATE_VERIFICATION +#error "PocketJS ESP transport rejects weak certificate verification" +#endif +#if defined(CONFIG_MBEDTLS_DES_C) && CONFIG_MBEDTLS_DES_C +#error "PocketJS ESP transport rejects DES and 3DES" +#endif +#if defined(MBEDTLS_SSL_NULL_CIPHERSUITES) || defined(MBEDTLS_ARC4_C) || \ + defined(MBEDTLS_KEY_EXCHANGE_DH_ANON_ENABLED) || \ + defined(MBEDTLS_KEY_EXCHANGE_ECDH_ANON_ENABLED) +#error "PocketJS ESP transport rejects NULL, RC4, and anonymous ciphersuites" +#endif +#if DNS_MAX_HOST_IP != POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CANDIDATES +#error "CONFIG_LWIP_DNS_MAX_HOST_IP must equal PocketJS candidate capacity" +#endif +#if defined(CONFIG_LWIP_HOOK_DNS_EXT_RESOLVE_CUSTOM) && \ + CONFIG_LWIP_HOOK_DNS_EXT_RESOLVE_CUSTOM +#error "PocketJS ESP transport requires stock tcpip-thread DNS callbacks" +#endif + +#if defined(CONFIG_MBEDTLS_CERTIFICATE_BUNDLE) && \ + CONFIG_MBEDTLS_CERTIFICATE_BUNDLE +#define POCKETJS_NET_ESP_TRANSPORT_HAS_BUNDLE 1 +#else +#define POCKETJS_NET_ESP_TRANSPORT_HAS_BUNDLE 0 +#endif + +#if POCKETJS_NET_ESP_TRANSPORT_HAS_BUNDLE && \ + defined(CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEPRECATED_LIST) && \ + CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEPRECATED_LIST +#error "PocketJS ESP transport rejects deprecated Host roots" +#endif + +#define STRINGIFY_INNER(value) #value +#define STRINGIFY(value) STRINGIFY_INNER(value) +#define IMPLEMENTATION_VERSION \ + "esp-idf-v" STRINGIFY(ESP_IDF_VERSION_MAJOR) "." STRINGIFY( \ + ESP_IDF_VERSION_MINOR) "." STRINGIFY(ESP_IDF_VERSION_PATCH) "-reference" \ + "-candidate" + +typedef enum { + OP_PHASE_INITIAL = 0, + OP_PHASE_DNS_WAIT, + OP_PHASE_TCP_CONNECT_WAIT, + OP_PHASE_TLS_HANDSHAKE, + OP_PHASE_IO_WAIT, +} operation_phase_t; + +typedef enum { + CONNECTION_FREE = 0, + CONNECTION_CONNECTING, + CONNECTION_OPEN, + CONNECTION_RETIRED, +} connection_state_t; + +typedef enum { + DNS_CONTEXT_FREE = 0, + DNS_CONTEXT_SUBMIT_QUEUED, + DNS_CONTEXT_LOOKUP_PENDING, + DNS_CONTEXT_RESULT_READY, +} dns_context_state_t; + +typedef struct { + bool in_use; + uint64_t generation; + size_t byte_count; + uint8_t bytes[POCKETJS_NET_ESP_TRANSPORT_READ_LEASE_BYTES]; +} read_lease_slot_t; + +typedef struct { + connection_state_t state; + uint64_t generation; + int fd; + esp_tls_t *tls; + bool secure; + uint32_t ipv4_be; + pocketjs_net_esp_operation_token_t busy_token; +} connection_slot_t; + +typedef struct { + pocketjs_net_operation_lifecycle_t lifecycle; + pocketjs_net_transport_operation_kind_t kind; + operation_phase_t phase; + pocketjs_net_esp_operation_token_t token; + uint64_t deadline_us; + atomic_bool cancel_requested; + uint32_t connection_slot; + uint32_t dns_context_slot; + uint32_t read_lease_slot; + uint32_t ipv4_be; + uint16_t port; + bool secure; + char hostname[POCKETJS_NET_ESP_TRANSPORT_MAX_HOSTNAME_BYTES + 1U]; + uint8_t write_bytes[POCKETJS_NET_ESP_TRANSPORT_MAX_WRITE_BYTES]; + size_t write_length; + size_t transferred; +} operation_slot_t; + +typedef struct dns_context dns_context_t; + +typedef struct { + dns_context_t *context; + uint64_t generation; + bool active; +} dns_callback_ticket_t; + +struct dns_context { + struct pocketjs_net_esp_transport *owner; + struct tcpip_callback_msg *submit_message; + dns_callback_ticket_t callback_ticket; + dns_context_state_t state; + uint64_t generation; + pocketjs_net_esp_operation_token_t token; + atomic_bool cancelled; + char hostname[POCKETJS_NET_ESP_TRANSPORT_MAX_HOSTNAME_BYTES + 1U]; + uint32_t candidates[POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CANDIDATES]; + size_t candidate_count; + err_t result; +}; + +struct pocketjs_net_esp_transport { + TaskHandle_t owner_task; + portMUX_TYPE lock; + atomic_bool closing; + pocketjs_net_token_gate_t token_gate; + pocketjs_net_terminal_credits_t terminal_credits; + uint64_t next_sequence; + size_t pump_cursor; + + pocketjs_net_esp_wake_fn wake; + void *wake_context; + pocketjs_net_esp_tls_trust_source_t tls_trust_source; + pocketjs_net_esp_wall_clock_trusted_fn wall_clock_trusted; + void *wall_clock_context; + uint8_t pinned_ca[POCKETJS_NET_ESP_TRANSPORT_MAX_PINNED_CA_PEM_BYTES + 1U]; + size_t pinned_ca_bytes; + + operation_slot_t operations[POCKETJS_NET_ESP_TRANSPORT_MAX_OPERATIONS]; + connection_slot_t connections[POCKETJS_NET_ESP_TRANSPORT_MAX_CONNECTIONS]; + dns_context_t dns_contexts[POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CONTEXTS]; + read_lease_slot_t read_leases[POCKETJS_NET_ESP_TRANSPORT_READ_LEASES]; + + pocketjs_net_esp_completion_t + completion_ring[POCKETJS_NET_ESP_TRANSPORT_COMPLETION_CAPACITY]; + size_t completion_head; + size_t completion_tail; + size_t completion_count; +}; + +static const pocketjs_net_esp_transport_descriptor_t s_descriptor = { + .id = POCKETJS_NET_ESP_TRANSPORT_ID, + .implementation_version = IMPLEMENTATION_VERSION, + .experimental = true, + .advertises_public_capability = false, + .ipv4 = true, + .asynchronous_raw_dns = true, + .stock_lwip_dns_callbacks_only = true, + /* A full cache prefix is rejected, but stock lwIP does not expose the DNS + * TC bit when a shorter prefix came from a truncated response. */ + .complete_dns_candidate_set = false, + .rejects_saturated_dns_candidate_prefix = true, + /* Both the tcpip submit and raw found callbacks carry an immutable + * generation ticket and revalidate it under the transport lock. */ + .dns_cancel_generation_cleanup = true, + .synchronous_getaddrinfo_for_hostname = false, + /* The adapter creates and connects the numeric IPv4 socket, then transfers + * it through ESP-TLS's public socket/state setters. ESP-TLS never resolves + * the candidate or performs its internal TCP-connect select. */ + .esp_tls_numeric_getaddrinfo_internal = false, + .nonblocking_plain_tcp_steps = true, + /* TCP readiness is polled with a zero timeout. ESP-TLS is entered at its + * CONNECTING state over the already-connected O_NONBLOCK socket, so each + * Mbed TLS handshake call returns on WANT_READ/WANT_WRITE. */ + .nonblocking_tls_steps = true, + /* Socket allocation, crypto work, and scheduler contention still have no + * proven per-step wall-time bound. */ + .bounded_native_step_wall_time = false, + .esp_tls_internal_select_timeout_ms = + POCKETJS_NET_ESP_TRANSPORT_TLS_STEP_TIMEOUT_MS, + .monotonic_deadlines = true, + .cancel_between_native_steps = true, + .worker_or_callback_calls_quickjs = false, + .exact_one_terminal = true, + .aba_safe_tokens = true, + .fixed_operation_pool = true, + .fixed_completion_pool = true, + .fixed_payload_pool = true, + .tls_compiled = true, + .tls_1_2_only = true, + .host_trust = true, + .host_pinned_ca = true, + .hostname_verification = true, + /* ESP-TLS exposes its live Mbed TLS context through a public accessor. + * Reading the negotiation verify result after a failed handshake preserves + * CN_MISMATCH even when ESP-IDF's error handle retained only -0x2700. */ + .distinct_tls_errors = true, + .sni = true, + .trusted_wall_clock_required = true, + .plaintext_fallback = false, + .renegotiation = false, + .early_data = false, + .tls_close_notify = true, + .tls_close_notify_uses_operation_deadline = true, + /* The client sends an orderly alert but does not wait indefinitely for a + * reciprocal alert before releasing the native connection. */ + .tls_close_notify_waits_for_peer = false, + /* Static callback messages are pre-acquired at create time, but their + * backing MEMP pool and DNS table are IDF-owned rather than byte-bounded. + */ + .bounded_lwip_dns_callback_allocation = false, + .bounded_lwip_socket_allocation = false, + .bounded_esp_tls_allocation = false, + .bounded_mbedtls_x509_parse_allocation = false, + .pocketjs_owned_instance_bytes = sizeof(pocketjs_net_esp_transport_t), + .lwip_static_callback_messages = + POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CONTEXTS, + .max_connections = POCKETJS_NET_ESP_TRANSPORT_MAX_CONNECTIONS, + .max_operations = POCKETJS_NET_ESP_TRANSPORT_MAX_OPERATIONS, + .completion_capacity = POCKETJS_NET_ESP_TRANSPORT_COMPLETION_CAPACITY, + .max_dns_candidates = POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CANDIDATES, + .max_write_bytes = POCKETJS_NET_ESP_TRANSPORT_MAX_WRITE_BYTES, + .read_lease_bytes = POCKETJS_NET_ESP_TRANSPORT_READ_LEASE_BYTES, + .max_pinned_ca_bytes = POCKETJS_NET_ESP_TRANSPORT_MAX_PINNED_CA_PEM_BYTES, +}; + +static bool owner_task(const pocketjs_net_esp_transport_t *transport) { + return transport != NULL && + xTaskGetCurrentTaskHandle() == transport->owner_task; +} + +static bool hostname_is_local(const char *hostname) { + size_t length = strlen(hostname); + static const char suffix[] = ".local"; + return strcmp(hostname, "local") == 0 || + (length > sizeof(suffix) - 1U && + strcmp(hostname + length - (sizeof(suffix) - 1U), suffix) == 0); +} + +static bool hostname_is_canonical(const char *hostname) { + if (hostname == NULL) { + return false; + } + size_t length = strlen(hostname); + if (length == 0U || length > POCKETJS_NET_ESP_TRANSPORT_MAX_HOSTNAME_BYTES || + hostname[length - 1U] == '.' || hostname_is_local(hostname)) { + return false; + } + + size_t label_length = 0U; + for (size_t index = 0; index < length; ++index) { + unsigned char byte = (unsigned char)hostname[index]; + if (byte == '.') { + if (label_length == 0U || label_length > 63U || + hostname[index - 1U] == '-') { + return false; + } + label_length = 0U; + continue; + } + if (!((byte >= 'a' && byte <= 'z') || (byte >= '0' && byte <= '9') || + byte == '-')) { + return false; + } + if (label_length == 0U && byte == '-') { + return false; + } + ++label_length; + } + return label_length > 0U && label_length <= 63U && + hostname[length - 1U] != '-'; +} + +static bool connection_matches(const connection_slot_t *slot, + pocketjs_net_esp_connection_t handle) { + return slot != NULL && slot->state == CONNECTION_OPEN && + handle.generation != 0U && slot->generation == handle.generation; +} + +static pocketjs_net_esp_connection_t connection_handle(size_t slot, + uint64_t generation) { + pocketjs_net_esp_connection_t result = { + .slot = (uint32_t)slot, + .generation = generation, + }; + return result; +} + +static void connection_native_close(connection_slot_t *connection) { + if (connection->tls != NULL) { + esp_tls_conn_destroy(connection->tls); + connection->tls = NULL; + connection->fd = -1; + } else if (connection->fd >= 0) { + close(connection->fd); + connection->fd = -1; + } + connection->busy_token = 0U; + connection->secure = false; + connection->ipv4_be = 0U; + connection->state = CONNECTION_FREE; +} + +static operation_slot_t *find_operation(pocketjs_net_esp_transport_t *transport, + uint64_t token) { + for (size_t index = 0; index < POCKETJS_NET_ESP_TRANSPORT_MAX_OPERATIONS; + ++index) { + operation_slot_t *operation = &transport->operations[index]; + if (pocketjs_net_operation_ticket_matches(operation->lifecycle, + operation->token, token)) { + return operation; + } + } + return NULL; +} + +static operation_slot_t * +find_free_operation(pocketjs_net_esp_transport_t *transport) { + for (size_t index = 0; index < POCKETJS_NET_ESP_TRANSPORT_MAX_OPERATIONS; + ++index) { + if (transport->operations[index].lifecycle == POCKETJS_NET_OPERATION_FREE) { + return &transport->operations[index]; + } + } + return NULL; +} + +static dns_context_t * +reserve_dns_context(pocketjs_net_esp_transport_t *transport, + size_t *out_index) { + dns_context_t *result = NULL; + portENTER_CRITICAL(&transport->lock); + for (size_t index = 0; index < POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CONTEXTS; + ++index) { + dns_context_t *context = &transport->dns_contexts[index]; + if (context->state == DNS_CONTEXT_FREE && + !context->callback_ticket.active && + pocketjs_net_generation_advance(&context->generation)) { + context->callback_ticket.generation = context->generation; + context->callback_ticket.active = true; + context->state = DNS_CONTEXT_SUBMIT_QUEUED; + *out_index = index; + result = context; + break; + } + } + portEXIT_CRITICAL(&transport->lock); + return result; +} + +static connection_slot_t * +reserve_connection(pocketjs_net_esp_transport_t *transport, size_t *out_index) { + for (size_t index = 0; index < POCKETJS_NET_ESP_TRANSPORT_MAX_CONNECTIONS; + ++index) { + connection_slot_t *connection = &transport->connections[index]; + if (connection->state == CONNECTION_FREE && + pocketjs_net_generation_advance(&connection->generation)) { + connection->state = CONNECTION_CONNECTING; + connection->fd = -1; + *out_index = index; + return connection; + } + } + return NULL; +} + +static read_lease_slot_t * +reserve_read_lease(pocketjs_net_esp_transport_t *transport, size_t *out_index) { + for (size_t index = 0; index < POCKETJS_NET_ESP_TRANSPORT_READ_LEASES; + ++index) { + read_lease_slot_t *lease = &transport->read_leases[index]; + if (!lease->in_use && pocketjs_net_generation_advance(&lease->generation)) { + lease->in_use = true; + lease->byte_count = 0U; + *out_index = index; + return lease; + } + } + return NULL; +} + +static void release_read_lease_slot(read_lease_slot_t *lease) { + lease->byte_count = 0U; + lease->in_use = false; +} + +static esp_err_t accept_operation(pocketjs_net_esp_transport_t *transport, + uint64_t token, + operation_slot_t **out_operation) { + operation_slot_t *operation = NULL; + pocketjs_net_operation_admission_t admission; + portENTER_CRITICAL(&transport->lock); + bool closing = + atomic_load_explicit(&transport->closing, memory_order_acquire); + if (closing) { + admission = POCKETJS_NET_OPERATION_ADMISSION_CLOSING; + } else if (!pocketjs_net_token_gate_can_accept(&transport->token_gate, + token)) { + admission = POCKETJS_NET_OPERATION_ADMISSION_INVALID_TOKEN; + } else if ((operation = find_free_operation(transport)) == NULL) { + admission = POCKETJS_NET_OPERATION_ADMISSION_NO_CAPACITY; + } else { + memset(operation, 0, sizeof(*operation)); + admission = pocketjs_net_operation_admit( + false, &transport->token_gate, &transport->terminal_credits, + &operation->lifecycle, &operation->token, token); + if (admission == POCKETJS_NET_OPERATION_ADMISSION_ACCEPTED) { + operation->connection_slot = UINT32_MAX; + operation->dns_context_slot = UINT32_MAX; + operation->read_lease_slot = UINT32_MAX; + atomic_init(&operation->cancel_requested, false); + } + } + portEXIT_CRITICAL(&transport->lock); + + if (admission == POCKETJS_NET_OPERATION_ADMISSION_CLOSING) { + return ESP_ERR_INVALID_STATE; + } + if (admission == POCKETJS_NET_OPERATION_ADMISSION_INVALID_TOKEN) { + return ESP_ERR_INVALID_ARG; + } + if (admission != POCKETJS_NET_OPERATION_ADMISSION_ACCEPTED) { + return ESP_ERR_NO_MEM; + } + *out_operation = operation; + return ESP_OK; +} + +static void queue_claimed_completion(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation, + pocketjs_net_esp_completion_t completion) { + if (!pocketjs_net_terminal_credit_enqueue(&transport->terminal_credits) || + transport->completion_count >= + POCKETJS_NET_ESP_TRANSPORT_COMPLETION_CAPACITY) { + abort(); + } + completion.operation_token = operation->token; + completion.sequence = ++transport->next_sequence; + transport->completion_ring[transport->completion_tail] = completion; + transport->completion_tail = (transport->completion_tail + 1U) % + POCKETJS_NET_ESP_TRANSPORT_COMPLETION_CAPACITY; + ++transport->completion_count; +} + +static bool enqueue_completion(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation, + pocketjs_net_esp_completion_t completion) { + portENTER_CRITICAL(&transport->lock); + uint64_t now_us = (uint64_t)esp_timer_get_time(); + bool cancel_requested = + atomic_load_explicit(&operation->cancel_requested, memory_order_acquire); + bool claimed = pocketjs_net_operation_claim_native_terminal( + &operation->lifecycle, cancel_requested, + now_us >= operation->deadline_us); + portEXIT_CRITICAL(&transport->lock); + if (!claimed) { + return false; + } + queue_claimed_completion(transport, operation, completion); + return true; +} + +static pocketjs_net_esp_error_category_t +error_category(pocketjs_net_esp_error_t error) { + if (error >= POCKETJS_NET_ESP_ERROR_TLS_CERTIFICATE_INVALID) { + return POCKETJS_NET_ESP_ERROR_CATEGORY_TLS; + } + if (error >= POCKETJS_NET_ESP_ERROR_CONNECTION_REFUSED) { + return POCKETJS_NET_ESP_ERROR_CATEGORY_TRANSPORT; + } + if (error >= POCKETJS_NET_ESP_ERROR_DNS_NOT_FOUND) { + return POCKETJS_NET_ESP_ERROR_CATEGORY_RESOLVER; + } + return POCKETJS_NET_ESP_ERROR_CATEGORY_RUNTIME; +} + +static pocketjs_net_esp_completion_t +make_error_completion(pocketjs_net_esp_error_t code, int cause_code, + int tls_code, uint32_t certificate_flags, + bool temporary) { + return (pocketjs_net_esp_completion_t){ + .type = POCKETJS_NET_ESP_TERMINAL_ERROR, + .detail.error = + { + .code = code, + .category = error_category(code), + .cause_code = cause_code, + .tls_code = tls_code, + .tls_certificate_flags = certificate_flags, + .temporary = temporary, + }, + }; +} + +static bool enqueue_error(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation, + pocketjs_net_esp_error_t code, int cause_code, + int tls_code, uint32_t certificate_flags, + bool temporary) { + return enqueue_completion(transport, operation, + make_error_completion(code, cause_code, tls_code, + certificate_flags, + temporary)); +} + +static pocketjs_net_esp_error_t map_errno(int code) { + switch (code) { + case ECONNREFUSED: + return POCKETJS_NET_ESP_ERROR_CONNECTION_REFUSED; + case ECONNRESET: + case EPIPE: + return POCKETJS_NET_ESP_ERROR_CONNECTION_RESET; + case ENETUNREACH: + case EHOSTUNREACH: + return POCKETJS_NET_ESP_ERROR_NETWORK_UNREACHABLE; + case ENOMEM: + case ENOBUFS: + return POCKETJS_NET_ESP_ERROR_RESOURCE_LIMIT; + default: + return POCKETJS_NET_ESP_ERROR_TRANSPORT_FAILED; + } +} + +static pocketjs_net_esp_error_t map_tls_error(int tls_code, + uint32_t certificate_flags) { + static const pocketjs_net_tls_error_symbols_t symbols = { + .certificate_verify_failed = MBEDTLS_ERR_X509_CERT_VERIFY_FAILED, + .bad_certificate = MBEDTLS_ERR_SSL_BAD_CERTIFICATE, + .ca_chain_required = MBEDTLS_ERR_SSL_CA_CHAIN_REQUIRED, + .bad_protocol_version = MBEDTLS_ERR_SSL_BAD_PROTOCOL_VERSION, + .fatal_alert_message = MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE, + .allocation_failed = MBEDTLS_ERR_SSL_ALLOC_FAILED, + .hostname_mismatch_flag = MBEDTLS_X509_BADCERT_CN_MISMATCH, + }; + switch ( + pocketjs_net_classify_tls_error(tls_code, certificate_flags, &symbols)) { + case POCKETJS_NET_TLS_ERROR_CLASS_HOSTNAME_MISMATCH: + return POCKETJS_NET_ESP_ERROR_TLS_HOSTNAME_MISMATCH; + case POCKETJS_NET_TLS_ERROR_CLASS_CERTIFICATE_INVALID: + return POCKETJS_NET_ESP_ERROR_TLS_CERTIFICATE_INVALID; + case POCKETJS_NET_TLS_ERROR_CLASS_VERSION_UNSUPPORTED: + return POCKETJS_NET_ESP_ERROR_TLS_VERSION_UNSUPPORTED; + case POCKETJS_NET_TLS_ERROR_CLASS_ALERT: + return POCKETJS_NET_ESP_ERROR_TLS_ALERT; + case POCKETJS_NET_TLS_ERROR_CLASS_RESOURCE_LIMIT: + return POCKETJS_NET_ESP_ERROR_RESOURCE_LIMIT; + case POCKETJS_NET_TLS_ERROR_CLASS_HANDSHAKE_FAILED: + default: + return POCKETJS_NET_ESP_ERROR_TLS_HANDSHAKE_FAILED; + } +} + +pocketjs_net_esp_error_t +pocketjs_net_esp_transport_map_tls_error_for_test(int tls_code, + uint32_t certificate_flags) { + return map_tls_error(tls_code, certificate_flags); +} + +static void read_tls_error(esp_tls_t *tls, int *cause, int *system_error, + int *tls_code, uint32_t *certificate_flags) { + esp_tls_error_handle_t error_handle = NULL; + *cause = 0; + *system_error = 0; + *tls_code = 0; + *certificate_flags = 0U; + if (esp_tls_get_error_handle(tls, &error_handle) == ESP_OK && + error_handle != NULL) { + int flags = 0; + (void)esp_tls_get_and_clear_error_type( + error_handle, ESP_TLS_ERR_TYPE_SYSTEM, system_error); + (void)esp_tls_get_and_clear_error_type(error_handle, ESP_TLS_ERR_TYPE_ESP, + cause); + (void)esp_tls_get_and_clear_error_type(error_handle, + ESP_TLS_ERR_TYPE_MBEDTLS, tls_code); + (void)esp_tls_get_and_clear_error_type( + error_handle, ESP_TLS_ERR_TYPE_MBEDTLS_CERT_FLAGS, &flags); + *certificate_flags = (uint32_t)flags; + if (*system_error != 0) { + *cause = *system_error; + } + } + mbedtls_ssl_context *ssl = + (mbedtls_ssl_context *)esp_tls_get_ssl_context(tls); + if (ssl != NULL) { + *certificate_flags = pocketjs_net_select_tls_certificate_flags( + *certificate_flags, mbedtls_ssl_get_verify_result(ssl)); + } +} + +static size_t +copy_dns_candidates(const ip_addr_t *addresses, + uint32_t out[POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CANDIDATES], + bool *out_saturated) { + size_t count = 0U; + size_t populated_slots = 0U; + *out_saturated = false; + if (addresses == NULL) { + return 0U; + } + for (size_t index = 0; index < DNS_MAX_HOST_IP && + count < POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CANDIDATES; + ++index) { + if (ip_addr_isany(&addresses[index])) { + continue; + } + ++populated_slots; +#if LWIP_IPV4 && LWIP_IPV6 + if (!IP_IS_V4(&addresses[index])) { + continue; + } +#endif + uint32_t address = ip_2_ip4(&addresses[index])->addr; + bool duplicate = false; + for (size_t previous = 0; previous < count; ++previous) { + duplicate = duplicate || out[previous] == address; + } + if (!duplicate) { + out[count++] = address; + } + } + *out_saturated = pocketjs_net_dns_candidate_prefix_saturated(populated_slots, + DNS_MAX_HOST_IP); + return count; +} + +static void pocketjs_dns_found(const char *name, const ip_addr_t *addresses, + void *callback_arg) { + dns_callback_ticket_t *ticket = callback_arg; + if (ticket == NULL || ticket->context == NULL) { + return; + } + dns_context_t *context = ticket->context; + const uint64_t callback_generation = ticket->generation; + pocketjs_net_esp_transport_t *transport = context->owner; + + portENTER_CRITICAL(&transport->lock); + const bool current = + pocketjs_net_dns_callback_ticket_matches( + ticket->active, context->generation, callback_generation) && + context->state == DNS_CONTEXT_LOOKUP_PENDING; + portEXIT_CRITICAL(&transport->lock); + if (!current) { + return; + } + + ip_addr_t cached_addresses[DNS_MAX_HOST_IP]; + memset(cached_addresses, 0, sizeof(cached_addresses)); + /* IDF 6.0.2 passes the DNS table's whole fixed address array, but entry + * reuse resets only ipaddr_cnt and can leave stale addresses in its tail. + * During the success callback the completed entry is already cacheable, so + * copy it through dns_lookup into a zeroed caller array. dns_lookup copies + * only the current entry's ipaddr_cnt prefix. */ + err_t cache_result = + addresses != NULL && name != NULL && strcmp(name, context->hostname) == 0 + ? dns_gethostbyname_addrtype(name, cached_addresses, NULL, NULL, + LWIP_DNS_ADDRTYPE_IPV4) + : ERR_VAL; + uint32_t candidates[POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CANDIDATES] = {0}; + bool saturated = false; + size_t count = + cache_result == ERR_OK + ? copy_dns_candidates(cached_addresses, candidates, &saturated) + : 0U; + + portENTER_CRITICAL(&transport->lock); + if (pocketjs_net_dns_callback_ticket_matches( + ticket->active, context->generation, callback_generation) && + context->state == DNS_CONTEXT_LOOKUP_PENDING) { + if (atomic_load_explicit(&context->cancelled, memory_order_acquire)) { + context->state = DNS_CONTEXT_FREE; + } else { + memcpy(context->candidates, candidates, sizeof(candidates)); + context->candidate_count = saturated ? 0U : count; + context->result = + saturated ? ERR_BUF : (count == 0U ? cache_result : ERR_OK); + if (context->result == ERR_OK && count == 0U) { + context->result = ERR_VAL; + } + context->state = DNS_CONTEXT_RESULT_READY; + } + ticket->active = false; + } + portEXIT_CRITICAL(&transport->lock); + + if (transport->wake != NULL) { + transport->wake(transport->wake_context); + } +} + +static void submit_dns_in_tcpip_thread(void *callback_arg) { + dns_callback_ticket_t *ticket = callback_arg; + if (ticket == NULL || ticket->context == NULL) { + return; + } + dns_context_t *context = ticket->context; + const uint64_t callback_generation = ticket->generation; + pocketjs_net_esp_transport_t *transport = context->owner; + + portENTER_CRITICAL(&transport->lock); + bool cancelled = + atomic_load_explicit(&context->cancelled, memory_order_acquire); + if (!pocketjs_net_dns_callback_ticket_matches( + ticket->active, context->generation, callback_generation) || + context->state != DNS_CONTEXT_SUBMIT_QUEUED) { + portEXIT_CRITICAL(&transport->lock); + return; + } + if (cancelled) { + context->state = DNS_CONTEXT_FREE; + ticket->active = false; + portEXIT_CRITICAL(&transport->lock); + if (transport->wake != NULL) { + transport->wake(transport->wake_context); + } + return; + } + context->state = DNS_CONTEXT_LOOKUP_PENDING; + portEXIT_CRITICAL(&transport->lock); + + ip_addr_t addresses[DNS_MAX_HOST_IP]; + memset(addresses, 0, sizeof(addresses)); + err_t result = dns_gethostbyname_addrtype(context->hostname, addresses, + pocketjs_dns_found, ticket, + LWIP_DNS_ADDRTYPE_IPV4); + if (result == ERR_INPROGRESS) { + return; + } + + uint32_t candidates[POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CANDIDATES] = {0}; + bool saturated = false; + size_t count = result == ERR_OK + ? copy_dns_candidates(addresses, candidates, &saturated) + : 0U; + portENTER_CRITICAL(&transport->lock); + if (!pocketjs_net_dns_callback_ticket_matches( + ticket->active, context->generation, callback_generation) || + context->state != DNS_CONTEXT_LOOKUP_PENDING) { + portEXIT_CRITICAL(&transport->lock); + return; + } + if (atomic_load_explicit(&context->cancelled, memory_order_acquire)) { + context->state = DNS_CONTEXT_FREE; + } else { + memcpy(context->candidates, candidates, sizeof(candidates)); + context->candidate_count = saturated ? 0U : count; + context->result = + saturated ? ERR_BUF + : (result == ERR_OK && count == 0U ? ERR_VAL : result); + context->state = DNS_CONTEXT_RESULT_READY; + } + ticket->active = false; + portEXIT_CRITICAL(&transport->lock); + if (transport->wake != NULL) { + transport->wake(transport->wake_context); + } +} + +static bool validate_pinned_ca(const uint8_t *pem, size_t bytes) { + if (pem == NULL || bytes == 0U || + bytes > POCKETJS_NET_ESP_TRANSPORT_MAX_PINNED_CA_PEM_BYTES || + memchr(pem, '\0', bytes) != NULL) { + return false; + } + uint8_t *snapshot = malloc(bytes + 1U); + if (snapshot == NULL) { + return false; + } + memcpy(snapshot, pem, bytes); + snapshot[bytes] = '\0'; + mbedtls_x509_crt certificate; + mbedtls_x509_crt_init(&certificate); + int result = mbedtls_x509_crt_parse(&certificate, snapshot, bytes + 1U); + bool valid = result == 0 && certificate.next == NULL && + mbedtls_x509_crt_get_ca_istrue(&certificate) == 1; + mbedtls_x509_crt_free(&certificate); + free(snapshot); + return valid; +} + +static bool +tls_config_shape_valid(const pocketjs_net_esp_transport_config_t *config) { + switch (config->tls_trust_source) { + case POCKETJS_NET_ESP_TLS_TRUST_DISABLED: + return config->host_pinned_ca_pem == NULL && + config->host_pinned_ca_pem_bytes == 0U && + config->wall_clock_trusted == NULL; + case POCKETJS_NET_ESP_TLS_TRUST_CERTIFICATE_BUNDLE: + return POCKETJS_NET_ESP_TRANSPORT_HAS_BUNDLE && + config->host_pinned_ca_pem == NULL && + config->host_pinned_ca_pem_bytes == 0U && + config->wall_clock_trusted != NULL; + case POCKETJS_NET_ESP_TLS_TRUST_HOST_PINNED_CA: + return config->wall_clock_trusted != NULL && + config->host_pinned_ca_pem != NULL && + config->host_pinned_ca_pem_bytes > 0U && + config->host_pinned_ca_pem_bytes <= + POCKETJS_NET_ESP_TRANSPORT_MAX_PINNED_CA_PEM_BYTES && + memchr(config->host_pinned_ca_pem, '\0', + config->host_pinned_ca_pem_bytes) == NULL; + default: + return false; + } +} + +static void tcpip_destroy_barrier(void *context) { (void)context; } + +static esp_err_t +prepare_poisoned_dns_teardown(pocketjs_net_esp_transport_t *transport) { + portENTER_CRITICAL(&transport->lock); + for (size_t index = 0; index < POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CONTEXTS; + ++index) { + dns_context_t *context = &transport->dns_contexts[index]; + atomic_store_explicit(&context->cancelled, true, memory_order_release); + if (context->state == DNS_CONTEXT_RESULT_READY) { + context->state = DNS_CONTEXT_FREE; + } + } + portEXIT_CRITICAL(&transport->lock); + + /* A queued submission observes cancelled and frees its context. A lookup + * already owned by lwIP must run its late callback before the backing context + * can be released; the barrier makes both observations stable. */ + if (tcpip_callback_wait(tcpip_destroy_barrier, NULL) != ERR_OK) { + return ESP_FAIL; + } + + bool callbacks_drained = true; + portENTER_CRITICAL(&transport->lock); + for (size_t index = 0; + callbacks_drained && index < POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CONTEXTS; + ++index) { + callbacks_drained = + transport->dns_contexts[index].state == DNS_CONTEXT_FREE && + !transport->dns_contexts[index].callback_ticket.active; + } + portEXIT_CRITICAL(&transport->lock); + return callbacks_drained ? ESP_OK : ESP_ERR_NOT_FINISHED; +} + +static void release_transport_storage(pocketjs_net_esp_transport_t *transport) { + for (size_t index = 0; index < POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CONTEXTS; + ++index) { + tcpip_callbackmsg_delete(transport->dns_contexts[index].submit_message); + } + memset(transport->pinned_ca, 0, sizeof(transport->pinned_ca)); + free(transport); +} + +const pocketjs_net_esp_transport_descriptor_t * +pocketjs_net_esp_transport_descriptor(void) { + return &s_descriptor; +} + +esp_err_t pocketjs_net_esp_transport_validate_config( + const pocketjs_net_esp_transport_config_t *config) { + if (config == NULL || !tls_config_shape_valid(config)) { + return ESP_ERR_INVALID_ARG; + } + if (config->tls_trust_source == POCKETJS_NET_ESP_TLS_TRUST_HOST_PINNED_CA && + !validate_pinned_ca(config->host_pinned_ca_pem, + config->host_pinned_ca_pem_bytes)) { + return ESP_ERR_INVALID_ARG; + } + return ESP_OK; +} + +esp_err_t pocketjs_net_esp_transport_create( + const pocketjs_net_esp_transport_config_t *config, + pocketjs_net_esp_transport_t **out_transport) { + if (out_transport == NULL || *out_transport != NULL || + pocketjs_net_esp_transport_validate_config(config) != ESP_OK) { + return ESP_ERR_INVALID_ARG; + } + + pocketjs_net_esp_transport_t *transport = calloc(1U, sizeof(*transport)); + if (transport == NULL) { + return ESP_ERR_NO_MEM; + } + portMUX_INITIALIZE(&transport->lock); + atomic_init(&transport->closing, false); + transport->owner_task = xTaskGetCurrentTaskHandle(); + transport->terminal_credits.capacity = + POCKETJS_NET_ESP_TRANSPORT_COMPLETION_CAPACITY; + transport->wake = config->wake; + transport->wake_context = config->wake_context; + transport->tls_trust_source = config->tls_trust_source; + transport->wall_clock_trusted = config->wall_clock_trusted; + transport->wall_clock_context = config->wall_clock_context; + if (config->tls_trust_source == POCKETJS_NET_ESP_TLS_TRUST_HOST_PINNED_CA) { + memcpy(transport->pinned_ca, config->host_pinned_ca_pem, + config->host_pinned_ca_pem_bytes); + transport->pinned_ca[config->host_pinned_ca_pem_bytes] = '\0'; + transport->pinned_ca_bytes = config->host_pinned_ca_pem_bytes; + } + for (size_t index = 0; index < POCKETJS_NET_ESP_TRANSPORT_MAX_CONNECTIONS; + ++index) { + transport->connections[index].fd = -1; + } + for (size_t index = 0; index < POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CONTEXTS; + ++index) { + dns_context_t *context = &transport->dns_contexts[index]; + context->owner = transport; + context->callback_ticket.context = context; + atomic_init(&context->cancelled, false); + context->submit_message = tcpip_callbackmsg_new(submit_dns_in_tcpip_thread, + &context->callback_ticket); + if (context->submit_message == NULL) { + for (size_t previous = 0; previous < index; ++previous) { + tcpip_callbackmsg_delete( + transport->dns_contexts[previous].submit_message); + } + free(transport); + return ESP_ERR_NO_MEM; + } + } + + *out_transport = transport; + return ESP_OK; +} + +void pocketjs_net_esp_transport_begin_shutdown( + pocketjs_net_esp_transport_t *transport) { + if (transport == NULL) { + return; + } + portENTER_CRITICAL(&transport->lock); + atomic_store_explicit(&transport->closing, true, memory_order_release); + for (size_t index = 0; index < POCKETJS_NET_ESP_TRANSPORT_MAX_OPERATIONS; + ++index) { + operation_slot_t *operation = &transport->operations[index]; + if (operation->lifecycle == POCKETJS_NET_OPERATION_ACTIVE && + pocketjs_net_operation_shutdown_requests_cancel(operation->kind)) { + atomic_store_explicit(&operation->cancel_requested, true, + memory_order_release); + } + } + portEXIT_CRITICAL(&transport->lock); + if (transport->wake != NULL) { + transport->wake(transport->wake_context); + } +} + +bool pocketjs_net_esp_transport_is_quiescent( + const pocketjs_net_esp_transport_t *transport) { + if (transport == NULL) { + return true; + } + if (!owner_task(transport)) { + return false; + } + pocketjs_net_esp_transport_t *mutable_transport = + (pocketjs_net_esp_transport_t *)transport; + bool quiescent = true; + portENTER_CRITICAL(&mutable_transport->lock); + quiescent = transport->completion_count == 0U && + transport->terminal_credits.reserved == 0U && + transport->terminal_credits.queued == 0U && + transport->terminal_credits.delivering == 0U; + for (size_t index = 0; + quiescent && index < POCKETJS_NET_ESP_TRANSPORT_MAX_OPERATIONS; + ++index) { + quiescent = + transport->operations[index].lifecycle == POCKETJS_NET_OPERATION_FREE; + } + for (size_t index = 0; + quiescent && index < POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CONTEXTS; + ++index) { + quiescent = transport->dns_contexts[index].state == DNS_CONTEXT_FREE && + !transport->dns_contexts[index].callback_ticket.active; + } + for (size_t index = 0; + quiescent && index < POCKETJS_NET_ESP_TRANSPORT_MAX_CONNECTIONS; + ++index) { + quiescent = transport->connections[index].state == CONNECTION_FREE; + } + for (size_t index = 0; + quiescent && index < POCKETJS_NET_ESP_TRANSPORT_READ_LEASES; ++index) { + quiescent = !transport->read_leases[index].in_use; + } + portEXIT_CRITICAL(&mutable_transport->lock); + return quiescent; +} + +esp_err_t +pocketjs_net_esp_transport_destroy(pocketjs_net_esp_transport_t *transport) { + if (transport == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (!owner_task(transport) || + !pocketjs_net_esp_transport_is_quiescent(transport)) { + return ESP_ERR_INVALID_STATE; + } + /* A raw DNS callback can make its context quiescent immediately before it + * returns. This barrier proves that callback and every prior submission has + * left the tcpip thread before static callback messages are released. */ + if (tcpip_callback_wait(tcpip_destroy_barrier, NULL) != ERR_OK) { + return ESP_FAIL; + } + release_transport_storage(transport); + return ESP_OK; +} + +esp_err_t pocketjs_net_esp_transport_destroy_poisoned( + pocketjs_net_esp_transport_t *transport) { + if (!owner_task(transport) || + !atomic_load_explicit(&transport->closing, memory_order_acquire)) { + return ESP_ERR_INVALID_STATE; + } + esp_err_t dns_result = prepare_poisoned_dns_teardown(transport); + if (dns_result != ESP_OK) { + return dns_result; + } + + /* The protocol Core has abandoned this dedicated transport. With callbacks + * drained and external callers joined, only owner-task native storage + * remains. No completion or lease is observable after this call succeeds. */ + for (size_t index = 0; index < POCKETJS_NET_ESP_TRANSPORT_MAX_CONNECTIONS; + ++index) { + connection_native_close(&transport->connections[index]); + } + for (size_t index = 0; index < POCKETJS_NET_ESP_TRANSPORT_READ_LEASES; + ++index) { + release_read_lease_slot(&transport->read_leases[index]); + } + memset(transport->operations, 0, sizeof(transport->operations)); + memset(transport->completion_ring, 0, sizeof(transport->completion_ring)); + transport->completion_head = 0U; + transport->completion_tail = 0U; + transport->completion_count = 0U; + transport->terminal_credits = (pocketjs_net_terminal_credits_t){ + .capacity = POCKETJS_NET_ESP_TRANSPORT_COMPLETION_CAPACITY, + }; + release_transport_storage(transport); + return ESP_OK; +} + +esp_err_t pocketjs_net_esp_transport_start_resolve( + pocketjs_net_esp_transport_t *transport, uint64_t token, + const pocketjs_net_esp_resolve_request_t *request) { + if (!owner_task(transport) || request == NULL || request->deadline_us == 0U || + !hostname_is_canonical(request->hostname)) { + return ESP_ERR_INVALID_ARG; + } + + struct in_addr literal = {0}; + bool is_literal = inet_pton(AF_INET, request->hostname, &literal) == 1; + size_t context_index = 0U; + dns_context_t *context = NULL; + if (!is_literal) { + context = reserve_dns_context(transport, &context_index); + if (context == NULL) { + return ESP_ERR_NO_MEM; + } + } + + operation_slot_t *operation = NULL; + esp_err_t accept_result = accept_operation(transport, token, &operation); + if (accept_result != ESP_OK) { + if (context != NULL) { + portENTER_CRITICAL(&transport->lock); + context->state = DNS_CONTEXT_FREE; + context->callback_ticket.active = false; + portEXIT_CRITICAL(&transport->lock); + } + return accept_result; + } + operation->kind = POCKETJS_NET_TRANSPORT_OPERATION_RESOLVE; + operation->deadline_us = request->deadline_us; + memcpy(operation->hostname, request->hostname, + strlen(request->hostname) + 1U); + + if (is_literal) { + operation->ipv4_be = literal.s_addr; + operation->phase = OP_PHASE_INITIAL; + return ESP_OK; + } + + context->token = token; + atomic_store_explicit(&context->cancelled, false, memory_order_release); + memcpy(context->hostname, request->hostname, strlen(request->hostname) + 1U); + memset(context->candidates, 0, sizeof(context->candidates)); + context->candidate_count = 0U; + context->result = ERR_INPROGRESS; + operation->dns_context_slot = (uint32_t)context_index; + operation->phase = OP_PHASE_DNS_WAIT; + + err_t post_result = tcpip_callbackmsg_trycallback(context->submit_message); + if (post_result != ERR_OK) { + portENTER_CRITICAL(&transport->lock); + if (pocketjs_net_dns_callback_ticket_matches( + context->callback_ticket.active, context->generation, + context->callback_ticket.generation) && + context->state == DNS_CONTEXT_SUBMIT_QUEUED) { + context->result = post_result; + context->state = DNS_CONTEXT_RESULT_READY; + context->callback_ticket.active = false; + } + portEXIT_CRITICAL(&transport->lock); + } + return ESP_OK; +} + +esp_err_t pocketjs_net_esp_transport_start_connect( + pocketjs_net_esp_transport_t *transport, uint64_t token, + const pocketjs_net_esp_connect_request_t *request) { + if (!owner_task(transport) || request == NULL || request->ipv4_be == 0U || + request->port == 0U || request->deadline_us == 0U || + (request->tls && !hostname_is_canonical(request->original_hostname))) { + return ESP_ERR_INVALID_ARG; + } + if (request->tls && + transport->tls_trust_source == POCKETJS_NET_ESP_TLS_TRUST_DISABLED) { + return ESP_ERR_NOT_SUPPORTED; + } + + size_t connection_index = 0U; + connection_slot_t *connection = + reserve_connection(transport, &connection_index); + if (connection == NULL) { + return ESP_ERR_NO_MEM; + } + operation_slot_t *operation = NULL; + esp_err_t accept_result = accept_operation(transport, token, &operation); + if (accept_result != ESP_OK) { + connection->state = CONNECTION_FREE; + return accept_result; + } + + operation->kind = POCKETJS_NET_TRANSPORT_OPERATION_CONNECT; + operation->phase = OP_PHASE_INITIAL; + operation->deadline_us = request->deadline_us; + operation->connection_slot = (uint32_t)connection_index; + operation->ipv4_be = request->ipv4_be; + operation->port = request->port; + operation->secure = request->tls; + if (request->tls) { + memcpy(operation->hostname, request->original_hostname, + strlen(request->original_hostname) + 1U); + } + connection->busy_token = token; + connection->secure = request->tls; + connection->ipv4_be = request->ipv4_be; + return ESP_OK; +} + +static esp_err_t +validate_open_connection(pocketjs_net_esp_transport_t *transport, + pocketjs_net_esp_connection_t handle, + connection_slot_t **out_connection, + size_t *out_index) { + if (handle.slot >= POCKETJS_NET_ESP_TRANSPORT_MAX_CONNECTIONS) { + return ESP_ERR_INVALID_ARG; + } + connection_slot_t *connection = &transport->connections[handle.slot]; + if (!connection_matches(connection, handle)) { + return ESP_ERR_NOT_FOUND; + } + if (connection->busy_token != 0U) { + return ESP_ERR_INVALID_STATE; + } + *out_connection = connection; + *out_index = handle.slot; + return ESP_OK; +} + +esp_err_t pocketjs_net_esp_transport_start_read( + pocketjs_net_esp_transport_t *transport, uint64_t token, + const pocketjs_net_esp_read_request_t *request) { + if (!owner_task(transport) || request == NULL || request->deadline_us == 0U || + request->maximum_bytes == 0U || + request->maximum_bytes > POCKETJS_NET_ESP_TRANSPORT_READ_LEASE_BYTES) { + return ESP_ERR_INVALID_ARG; + } + connection_slot_t *connection = NULL; + size_t connection_index = 0U; + esp_err_t result = validate_open_connection(transport, request->connection, + &connection, &connection_index); + if (result != ESP_OK) { + return result; + } + size_t lease_index = 0U; + read_lease_slot_t *lease = reserve_read_lease(transport, &lease_index); + if (lease == NULL) { + return ESP_ERR_NO_MEM; + } + operation_slot_t *operation = NULL; + esp_err_t accept_result = accept_operation(transport, token, &operation); + if (accept_result != ESP_OK) { + release_read_lease_slot(lease); + return accept_result; + } + operation->kind = POCKETJS_NET_TRANSPORT_OPERATION_READ; + operation->phase = OP_PHASE_IO_WAIT; + operation->deadline_us = request->deadline_us; + operation->connection_slot = (uint32_t)connection_index; + operation->read_lease_slot = (uint32_t)lease_index; + operation->write_length = request->maximum_bytes; + connection->busy_token = token; + return ESP_OK; +} + +esp_err_t pocketjs_net_esp_transport_start_write( + pocketjs_net_esp_transport_t *transport, uint64_t token, + const pocketjs_net_esp_write_request_t *request) { + if (!owner_task(transport) || request == NULL || request->deadline_us == 0U || + request->bytes == NULL || request->length == 0U || + request->length > POCKETJS_NET_ESP_TRANSPORT_MAX_WRITE_BYTES) { + return ESP_ERR_INVALID_ARG; + } + connection_slot_t *connection = NULL; + size_t connection_index = 0U; + esp_err_t result = validate_open_connection(transport, request->connection, + &connection, &connection_index); + if (result != ESP_OK) { + return result; + } + operation_slot_t *operation = NULL; + esp_err_t accept_result = accept_operation(transport, token, &operation); + if (accept_result != ESP_OK) { + return accept_result; + } + operation->kind = POCKETJS_NET_TRANSPORT_OPERATION_WRITE; + operation->phase = OP_PHASE_IO_WAIT; + operation->deadline_us = request->deadline_us; + operation->connection_slot = (uint32_t)connection_index; + operation->write_length = request->length; + memcpy(operation->write_bytes, request->bytes, request->length); + connection->busy_token = token; + return ESP_OK; +} + +esp_err_t pocketjs_net_esp_transport_start_close( + pocketjs_net_esp_transport_t *transport, uint64_t token, + const pocketjs_net_esp_close_request_t *request) { + if (!owner_task(transport) || request == NULL || request->deadline_us == 0U) { + return ESP_ERR_INVALID_ARG; + } + connection_slot_t *connection = NULL; + size_t connection_index = 0U; + esp_err_t result = validate_open_connection(transport, request->connection, + &connection, &connection_index); + if (result != ESP_OK) { + return result; + } + operation_slot_t *operation = NULL; + esp_err_t accept_result = accept_operation(transport, token, &operation); + if (accept_result != ESP_OK) { + return accept_result; + } + operation->kind = POCKETJS_NET_TRANSPORT_OPERATION_CLOSE; + operation->phase = OP_PHASE_INITIAL; + operation->deadline_us = request->deadline_us; + operation->connection_slot = (uint32_t)connection_index; + connection->busy_token = token; + return ESP_OK; +} + +esp_err_t +pocketjs_net_esp_transport_cancel(pocketjs_net_esp_transport_t *transport, + uint64_t token) { + if (transport == NULL || token == 0U) { + return ESP_ERR_INVALID_ARG; + } + portENTER_CRITICAL(&transport->lock); + operation_slot_t *operation = find_operation(transport, token); + if (operation == NULL || + operation->lifecycle != POCKETJS_NET_OPERATION_ACTIVE) { + portEXIT_CRITICAL(&transport->lock); + return ESP_ERR_NOT_FOUND; + } + atomic_store_explicit(&operation->cancel_requested, true, + memory_order_release); + portEXIT_CRITICAL(&transport->lock); + if (transport->wake != NULL) { + transport->wake(transport->wake_context); + } + return ESP_OK; +} + +static void cancel_dns_context(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation) { + if (operation->dns_context_slot >= + POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CONTEXTS) { + return; + } + dns_context_t *context = + &transport->dns_contexts[operation->dns_context_slot]; + portENTER_CRITICAL(&transport->lock); + if (context->token == operation->token && + context->state != DNS_CONTEXT_FREE) { + atomic_store_explicit(&context->cancelled, true, memory_order_release); + if (context->state == DNS_CONTEXT_RESULT_READY) { + context->state = DNS_CONTEXT_FREE; + } + } + portEXIT_CRITICAL(&transport->lock); +} + +static void +operation_release_busy_connection(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation) { + if (operation->connection_slot >= + POCKETJS_NET_ESP_TRANSPORT_MAX_CONNECTIONS) { + return; + } + connection_slot_t *connection = + &transport->connections[operation->connection_slot]; + if (connection->busy_token == operation->token) { + connection->busy_token = 0U; + } +} + +static void operation_cleanup_for_error(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation, + bool close_connection) { + cancel_dns_context(transport, operation); + if (operation->read_lease_slot < POCKETJS_NET_ESP_TRANSPORT_READ_LEASES) { + release_read_lease_slot( + &transport->read_leases[operation->read_lease_slot]); + operation->read_lease_slot = UINT32_MAX; + } + if (operation->connection_slot < POCKETJS_NET_ESP_TRANSPORT_MAX_CONNECTIONS) { + connection_slot_t *connection = + &transport->connections[operation->connection_slot]; + if (close_connection) { + connection_native_close(connection); + } else { + operation_release_busy_connection(transport, operation); + } + } +} + +static void +finish_cancelled_or_timed_out(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation, + pocketjs_net_esp_error_t error) { + portENTER_CRITICAL(&transport->lock); + bool cancel_requested = + atomic_load_explicit(&operation->cancel_requested, memory_order_acquire); + pocketjs_net_operation_terminal_reason_t reason = + pocketjs_net_operation_claim_cancel_or_timeout( + &operation->lifecycle, cancel_requested, + error == POCKETJS_NET_ESP_ERROR_TIMED_OUT); + portEXIT_CRITICAL(&transport->lock); + if (reason == POCKETJS_NET_OPERATION_TERMINAL_NONE) { + abort(); + } + + pocketjs_net_esp_error_t terminal_error = + reason == POCKETJS_NET_OPERATION_TERMINAL_ABORTED + ? POCKETJS_NET_ESP_ERROR_ABORTED + : POCKETJS_NET_ESP_ERROR_TIMED_OUT; + bool close_connection = + pocketjs_net_operation_cancel_closes_connection(operation->kind); + operation_cleanup_for_error(transport, operation, close_connection); + queue_claimed_completion( + transport, operation, + make_error_completion(terminal_error, 0, 0, 0U, false)); +} + +static void pump_resolve(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation) { + pocketjs_net_esp_completion_t completion = { + .type = POCKETJS_NET_ESP_TERMINAL_RESOLVED, + }; + if (operation->dns_context_slot == UINT32_MAX) { + completion.detail.resolved.ipv4_be[0] = operation->ipv4_be; + completion.detail.resolved.candidate_count = 1U; + enqueue_completion(transport, operation, completion); + return; + } + + dns_context_t *context = + &transport->dns_contexts[operation->dns_context_slot]; + portENTER_CRITICAL(&transport->lock); + if (context->state != DNS_CONTEXT_RESULT_READY || + context->token != operation->token) { + portEXIT_CRITICAL(&transport->lock); + return; + } + err_t result = context->result; + size_t count = context->candidate_count; + memcpy(completion.detail.resolved.ipv4_be, context->candidates, + sizeof(context->candidates)); + context->state = DNS_CONTEXT_FREE; + portEXIT_CRITICAL(&transport->lock); + + if (result == ERR_OK && count > 0U) { + completion.detail.resolved.candidate_count = count; + enqueue_completion(transport, operation, completion); + } else { + pocketjs_net_esp_error_t error = result == ERR_MEM || result == ERR_BUF + ? POCKETJS_NET_ESP_ERROR_RESOURCE_LIMIT + : POCKETJS_NET_ESP_ERROR_DNS_NOT_FOUND; + enqueue_error(transport, operation, error, (int)result, 0, 0U, + result == ERR_TIMEOUT); + } +} + +static bool make_tls_config(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation, + esp_tls_cfg_t *configuration) { + if (transport->wall_clock_trusted == NULL || + !transport->wall_clock_trusted(transport->wall_clock_context)) { + return false; + } + memset(configuration, 0, sizeof(*configuration)); + /* The adapter has already connected an O_NONBLOCK socket and enters + * ESP-TLS at ESP_TLS_CONNECTING. false skips ESP-TLS's internal select; + * it does not change the already-configured socket flags. */ + configuration->non_block = false; + configuration->timeout_ms = POCKETJS_NET_ESP_TRANSPORT_TLS_STEP_TIMEOUT_MS; + configuration->common_name = operation->hostname; + configuration->skip_common_name = false; + configuration->addr_family = ESP_TLS_AF_INET; + configuration->tls_version = ESP_TLS_VER_TLS_1_2; + if (transport->tls_trust_source == + POCKETJS_NET_ESP_TLS_TRUST_CERTIFICATE_BUNDLE) { +#if POCKETJS_NET_ESP_TRANSPORT_HAS_BUNDLE + configuration->crt_bundle_attach = esp_crt_bundle_attach; +#else + return false; +#endif + } else if (transport->tls_trust_source == + POCKETJS_NET_ESP_TLS_TRUST_HOST_PINNED_CA) { + configuration->cacert_buf = transport->pinned_ca; + configuration->cacert_bytes = (unsigned int)transport->pinned_ca_bytes + 1U; + } else { + return false; + } + return true; +} + +static void finish_connected(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation, + connection_slot_t *connection) { + connection->state = CONNECTION_OPEN; + pocketjs_net_esp_completion_t completion = { + .type = POCKETJS_NET_ESP_TERMINAL_CONNECTED, + .detail.connected = + { + .connection = connection_handle(operation->connection_slot, + connection->generation), + .ipv4_be = connection->ipv4_be, + .tls = connection->secure, + }, + }; + if (enqueue_completion(transport, operation, completion)) { + connection->busy_token = 0U; + } +} + +typedef enum socket_connect_progress { + SOCKET_CONNECT_FAILED = 0, + SOCKET_CONNECT_PENDING, + SOCKET_CONNECT_READY, +} socket_connect_progress_t; + +static socket_connect_progress_t +start_socket_connect(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation, + connection_slot_t *connection) { + int fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (fd < 0) { + int cause = errno; + connection_native_close(connection); + enqueue_error(transport, operation, map_errno(cause), cause, 0, 0U, + cause == ENOBUFS || cause == ENOMEM); + return SOCKET_CONNECT_FAILED; + } + int flags = fcntl(fd, F_GETFL, 0); + if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) { + int cause = errno; + close(fd); + connection_native_close(connection); + enqueue_error(transport, operation, POCKETJS_NET_ESP_ERROR_TRANSPORT_FAILED, + cause, 0, 0U, false); + return SOCKET_CONNECT_FAILED; + } + connection->fd = fd; + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_port = htons(operation->port), + .sin_addr.s_addr = operation->ipv4_be, + }; + if (connect(fd, (struct sockaddr *)&address, sizeof(address)) == 0) { + return SOCKET_CONNECT_READY; + } + if (errno != EINPROGRESS) { + int cause = errno; + connection_native_close(connection); + enqueue_error(transport, operation, map_errno(cause), cause, 0, 0U, + cause == ENETUNREACH || cause == EHOSTUNREACH); + return SOCKET_CONNECT_FAILED; + } + operation->phase = OP_PHASE_TCP_CONNECT_WAIT; + return SOCKET_CONNECT_PENDING; +} + +static socket_connect_progress_t +poll_socket_connect(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation, + connection_slot_t *connection) { + struct pollfd descriptor = { + .fd = connection->fd, + .events = POLLOUT, + }; + int poll_result = poll(&descriptor, 1U, 0); + if (poll_result == 0) { + return SOCKET_CONNECT_PENDING; + } + if (poll_result < 0) { + int cause = errno; + connection_native_close(connection); + enqueue_error(transport, operation, map_errno(cause), cause, 0, 0U, false); + return SOCKET_CONNECT_FAILED; + } + int socket_error = 0; + if ((descriptor.revents & POLLNVAL) != 0) { + socket_error = EBADF; + } else { + socklen_t error_length = sizeof(socket_error); + if (getsockopt(connection->fd, SOL_SOCKET, SO_ERROR, &socket_error, + &error_length) < 0) { + socket_error = errno; + } + } + if (socket_error != 0) { + connection_native_close(connection); + enqueue_error(transport, operation, map_errno(socket_error), socket_error, + 0, 0U, + socket_error == ENETUNREACH || socket_error == EHOSTUNREACH); + return SOCKET_CONNECT_FAILED; + } + return SOCKET_CONNECT_READY; +} + +static void pump_plain_connect(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation, + connection_slot_t *connection) { + socket_connect_progress_t progress = + operation->phase == OP_PHASE_INITIAL + ? start_socket_connect(transport, operation, connection) + : poll_socket_connect(transport, operation, connection); + if (progress == SOCKET_CONNECT_READY) { + finish_connected(transport, operation, connection); + } +} + +static bool +attach_tls_to_connected_socket(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation, + connection_slot_t *connection) { + esp_tls_t *tls = esp_tls_init(); + if (tls == NULL) { + connection_native_close(connection); + enqueue_error(transport, operation, POCKETJS_NET_ESP_ERROR_RESOURCE_LIMIT, + ENOMEM, 0, 0U, true); + return false; + } + if (esp_tls_set_conn_sockfd(tls, connection->fd) != ESP_OK) { + esp_tls_conn_destroy(tls); + connection_native_close(connection); + enqueue_error(transport, operation, POCKETJS_NET_ESP_ERROR_TRANSPORT_FAILED, + EBADF, 0, 0U, false); + return false; + } + + /* Ownership transfers to ESP-TLS as soon as the socket setter succeeds. */ + connection->tls = tls; + connection->fd = -1; + if (esp_tls_set_conn_state(tls, ESP_TLS_CONNECTING) != ESP_OK) { + connection_native_close(connection); + enqueue_error(transport, operation, POCKETJS_NET_ESP_ERROR_TRANSPORT_FAILED, + EINVAL, 0, 0U, false); + return false; + } + operation->phase = OP_PHASE_TLS_HANDSHAKE; + return true; +} + +static void pump_tls_connect(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation, + connection_slot_t *connection) { + esp_tls_cfg_t configuration; + if (!make_tls_config(transport, operation, &configuration)) { + connection_native_close(connection); + enqueue_error(transport, operation, + POCKETJS_NET_ESP_ERROR_TLS_CERTIFICATE_INVALID, 0, 0, 0U, + false); + return; + } + if (connection->tls == NULL) { + socket_connect_progress_t progress = + operation->phase == OP_PHASE_INITIAL + ? start_socket_connect(transport, operation, connection) + : poll_socket_connect(transport, operation, connection); + if (progress != SOCKET_CONNECT_READY) { + return; + } + if (!attach_tls_to_connected_socket(transport, operation, connection)) { + return; + } + } + + int result = esp_tls_conn_new_async( + operation->hostname, (int)strlen(operation->hostname), operation->port, + &configuration, connection->tls); + if (result == 0) { + esp_tls_conn_state_t state = ESP_TLS_INIT; + if (esp_tls_get_conn_state(connection->tls, &state) != ESP_OK || + state != ESP_TLS_HANDSHAKE) { + connection_native_close(connection); + enqueue_error(transport, operation, + POCKETJS_NET_ESP_ERROR_TLS_HANDSHAKE_FAILED, 0, 0, 0U, + false); + return; + } + return; + } + if (result == 1) { + finish_connected(transport, operation, connection); + return; + } + + int cause = 0; + int system_error = 0; + int tls_code = 0; + uint32_t certificate_flags = 0U; + read_tls_error(connection->tls, &cause, &system_error, &tls_code, + &certificate_flags); + pocketjs_net_esp_error_t error = + system_error != 0 ? map_errno(system_error) + : map_tls_error(tls_code, certificate_flags); + if (cause == ESP_ERR_ESP_TLS_CONNECTION_TIMEOUT) { + error = POCKETJS_NET_ESP_ERROR_TIMED_OUT; + } + connection_native_close(connection); + enqueue_error(transport, operation, error, cause, tls_code, certificate_flags, + false); +} + +static void pump_connect(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation) { + connection_slot_t *connection = + &transport->connections[operation->connection_slot]; + if (operation->secure) { + pump_tls_connect(transport, operation, connection); + } else { + pump_plain_connect(transport, operation, connection); + } +} + +static void fail_io(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation, int cause, int tls_code, + uint32_t certificate_flags) { + connection_slot_t *connection = + &transport->connections[operation->connection_slot]; + pocketjs_net_esp_error_t error = map_errno(cause); + if (connection->secure && + (tls_code != 0 || certificate_flags != 0U || cause == 0)) { + error = map_tls_error(tls_code, certificate_flags); + } + operation_cleanup_for_error(transport, operation, true); + enqueue_error(transport, operation, error, cause, tls_code, certificate_flags, + false); +} + +static void pump_read(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation) { + connection_slot_t *connection = + &transport->connections[operation->connection_slot]; + read_lease_slot_t *lease = + &transport->read_leases[operation->read_lease_slot]; + ssize_t result = + connection->secure + ? esp_tls_conn_read(connection->tls, lease->bytes, + operation->write_length) + : recv(connection->fd, lease->bytes, operation->write_length, 0); + if (result < 0) { + if ((!connection->secure && (errno == EAGAIN || errno == EWOULDBLOCK)) || + (connection->secure && (result == ESP_TLS_ERR_SSL_WANT_READ || + result == ESP_TLS_ERR_SSL_WANT_WRITE))) { + return; + } + int cause = connection->secure ? 0 : errno; + int system_error = 0; + int tls_code = connection->secure ? (int)result : 0; + uint32_t flags = 0U; + if (connection->secure) { + read_tls_error(connection->tls, &cause, &system_error, &tls_code, &flags); + } + fail_io(transport, operation, cause, tls_code, flags); + return; + } + + pocketjs_net_esp_completion_t completion = { + .type = POCKETJS_NET_ESP_TERMINAL_READ, + .detail.read = + { + .connection = connection_handle(operation->connection_slot, + connection->generation), + .byte_count = (size_t)result, + .eof = result == 0, + }, + }; + if (result > 0) { + lease->byte_count = (size_t)result; + completion.detail.read.lease.slot = operation->read_lease_slot; + completion.detail.read.lease.generation = lease->generation; + } else { + release_read_lease_slot(lease); + operation->read_lease_slot = UINT32_MAX; + } + if (enqueue_completion(transport, operation, completion)) { + operation->read_lease_slot = UINT32_MAX; + connection->busy_token = 0U; + } +} + +static void pump_write(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation) { + connection_slot_t *connection = + &transport->connections[operation->connection_slot]; + const uint8_t *bytes = operation->write_bytes + operation->transferred; + size_t remaining = operation->write_length - operation->transferred; + ssize_t result = connection->secure + ? esp_tls_conn_write(connection->tls, bytes, remaining) + : send(connection->fd, bytes, remaining, MSG_DONTWAIT); + if (result < 0) { + if ((!connection->secure && (errno == EAGAIN || errno == EWOULDBLOCK)) || + (connection->secure && (result == ESP_TLS_ERR_SSL_WANT_READ || + result == ESP_TLS_ERR_SSL_WANT_WRITE))) { + return; + } + int cause = connection->secure ? 0 : errno; + int system_error = 0; + int tls_code = connection->secure ? (int)result : 0; + uint32_t flags = 0U; + if (connection->secure) { + read_tls_error(connection->tls, &cause, &system_error, &tls_code, &flags); + } + fail_io(transport, operation, cause, tls_code, flags); + return; + } + if (result == 0) { + return; + } + operation->transferred += (size_t)result; + if (operation->transferred < operation->write_length) { + return; + } + + pocketjs_net_esp_completion_t completion = { + .type = POCKETJS_NET_ESP_TERMINAL_WRITTEN, + .detail.written = + { + .connection = connection_handle(operation->connection_slot, + connection->generation), + .byte_count = operation->transferred, + }, + }; + if (enqueue_completion(transport, operation, completion)) { + connection->busy_token = 0U; + } +} + +static void pump_close(pocketjs_net_esp_transport_t *transport, + operation_slot_t *operation) { + connection_slot_t *connection = + &transport->connections[operation->connection_slot]; + pocketjs_net_esp_connection_t handle = + connection_handle(operation->connection_slot, connection->generation); + if (connection->secure) { + mbedtls_ssl_context *ssl = esp_tls_get_ssl_context(connection->tls); + if (ssl == NULL) { + operation_cleanup_for_error(transport, operation, true); + enqueue_error(transport, operation, + POCKETJS_NET_ESP_ERROR_TRANSPORT_FAILED, + ESP_ERR_INVALID_STATE, 0, 0U, false); + return; + } + int result = mbedtls_ssl_close_notify(ssl); + pocketjs_net_tls_close_notify_outcome_t outcome = + pocketjs_net_classify_tls_close_notify( + result, MBEDTLS_ERR_SSL_WANT_READ, MBEDTLS_ERR_SSL_WANT_WRITE); + if (outcome == POCKETJS_NET_TLS_CLOSE_NOTIFY_RETRY) { + return; + } + if (outcome == POCKETJS_NET_TLS_CLOSE_NOTIFY_FAILED) { + int cause = 0; + int system_error = 0; + int tls_code = 0; + uint32_t flags = 0U; + read_tls_error(connection->tls, &cause, &system_error, &tls_code, &flags); + if (tls_code == 0) { + tls_code = result; + } + if (system_error != 0) { + cause = system_error; + } else if (cause == 0) { + /* Preserve the direct close_notify result for poison diagnostics. */ + cause = tls_code; + } + operation_cleanup_for_error(transport, operation, true); + enqueue_error(transport, operation, + system_error != 0 ? map_errno(system_error) + : map_tls_error(tls_code, flags), + cause, tls_code, flags, false); + return; + } + } + connection_native_close(connection); + pocketjs_net_esp_completion_t completion = { + .type = POCKETJS_NET_ESP_TERMINAL_CLOSED, + .detail.closed = {.connection = handle}, + }; + enqueue_completion(transport, operation, completion); +} + +esp_err_t +pocketjs_net_esp_transport_pump(pocketjs_net_esp_transport_t *transport, + uint64_t now_us, size_t max_native_steps) { + if (!owner_task(transport) || now_us == 0U || max_native_steps == 0U) { + return ESP_ERR_INVALID_ARG; + } + size_t steps = 0U; + size_t examined = 0U; + while (examined < POCKETJS_NET_ESP_TRANSPORT_MAX_OPERATIONS && + steps < max_native_steps) { + size_t index = transport->pump_cursor; + transport->pump_cursor = pocketjs_net_round_robin_next( + transport->pump_cursor, POCKETJS_NET_ESP_TRANSPORT_MAX_OPERATIONS); + ++examined; + operation_slot_t *operation = &transport->operations[index]; + if (operation->lifecycle != POCKETJS_NET_OPERATION_ACTIVE) { + continue; + } + ++steps; + uint64_t sampled_now_us = (uint64_t)esp_timer_get_time(); + if (sampled_now_us < now_us) { + sampled_now_us = now_us; + } + if (atomic_load_explicit(&operation->cancel_requested, + memory_order_acquire)) { + finish_cancelled_or_timed_out(transport, operation, + POCKETJS_NET_ESP_ERROR_ABORTED); + continue; + } + if (sampled_now_us >= operation->deadline_us) { + finish_cancelled_or_timed_out(transport, operation, + POCKETJS_NET_ESP_ERROR_TIMED_OUT); + continue; + } + switch (operation->kind) { + case POCKETJS_NET_TRANSPORT_OPERATION_RESOLVE: + pump_resolve(transport, operation); + break; + case POCKETJS_NET_TRANSPORT_OPERATION_CONNECT: + pump_connect(transport, operation); + break; + case POCKETJS_NET_TRANSPORT_OPERATION_READ: + pump_read(transport, operation); + break; + case POCKETJS_NET_TRANSPORT_OPERATION_WRITE: + pump_write(transport, operation); + break; + case POCKETJS_NET_TRANSPORT_OPERATION_CLOSE: + pump_close(transport, operation); + break; + default: + abort(); + } + if (operation->lifecycle == POCKETJS_NET_OPERATION_ACTIVE) { + sampled_now_us = (uint64_t)esp_timer_get_time(); + bool cancel_requested = atomic_load_explicit(&operation->cancel_requested, + memory_order_acquire); + if (cancel_requested || sampled_now_us >= operation->deadline_us) { + finish_cancelled_or_timed_out(transport, operation, + cancel_requested + ? POCKETJS_NET_ESP_ERROR_ABORTED + : POCKETJS_NET_ESP_ERROR_TIMED_OUT); + } + } + } + + if (atomic_load_explicit(&transport->closing, memory_order_acquire)) { + for (size_t index = 0; index < POCKETJS_NET_ESP_TRANSPORT_MAX_CONNECTIONS; + ++index) { + connection_slot_t *connection = &transport->connections[index]; + if (connection->state == CONNECTION_OPEN && + connection->busy_token == 0U) { + connection_native_close(connection); + } + } + } + return ESP_OK; +} + +esp_err_t pocketjs_net_esp_transport_take_completion( + pocketjs_net_esp_transport_t *transport, + pocketjs_net_esp_completion_t *out_completion) { + if (!owner_task(transport) || out_completion == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (transport->completion_count == 0U) { + return ESP_ERR_NOT_FOUND; + } + pocketjs_net_esp_completion_t completion = + transport->completion_ring[transport->completion_head]; + operation_slot_t *operation = + find_operation(transport, completion.operation_token); + portENTER_CRITICAL(&transport->lock); + bool began_delivery = + operation != NULL && + pocketjs_net_operation_begin_delivery(&operation->lifecycle); + portEXIT_CRITICAL(&transport->lock); + if (!began_delivery || + !pocketjs_net_terminal_credit_take(&transport->terminal_credits)) { + abort(); + } + transport->completion_head = (transport->completion_head + 1U) % + POCKETJS_NET_ESP_TRANSPORT_COMPLETION_CAPACITY; + --transport->completion_count; + *out_completion = completion; + return ESP_OK; +} + +esp_err_t pocketjs_net_esp_transport_retire_completion( + pocketjs_net_esp_transport_t *transport, uint64_t token) { + if (!owner_task(transport) || token == 0U) { + return ESP_ERR_INVALID_ARG; + } + operation_slot_t *operation = find_operation(transport, token); + portENTER_CRITICAL(&transport->lock); + bool retired = + operation != NULL && pocketjs_net_operation_retire(&operation->lifecycle); + if (retired) { + memset(operation, 0, sizeof(*operation)); + } + portEXIT_CRITICAL(&transport->lock); + if (!retired || + !pocketjs_net_terminal_credit_retire(&transport->terminal_credits)) { + return ESP_ERR_INVALID_STATE; + } + return ESP_OK; +} + +esp_err_t pocketjs_net_esp_transport_read_lease_view( + pocketjs_net_esp_transport_t *transport, + pocketjs_net_esp_read_lease_t handle, const uint8_t **out_bytes, + size_t *out_capacity) { + if (!owner_task(transport) || out_bytes == NULL || out_capacity == NULL || + handle.slot >= POCKETJS_NET_ESP_TRANSPORT_READ_LEASES) { + return ESP_ERR_INVALID_ARG; + } + read_lease_slot_t *lease = &transport->read_leases[handle.slot]; + if (!lease->in_use || lease->generation != handle.generation) { + return ESP_ERR_NOT_FOUND; + } + *out_bytes = lease->bytes; + *out_capacity = lease->byte_count; + return ESP_OK; +} + +esp_err_t pocketjs_net_esp_transport_release_read_lease( + pocketjs_net_esp_transport_t *transport, + pocketjs_net_esp_read_lease_t handle) { + if (!owner_task(transport) || + handle.slot >= POCKETJS_NET_ESP_TRANSPORT_READ_LEASES) { + return ESP_ERR_INVALID_ARG; + } + read_lease_slot_t *lease = &transport->read_leases[handle.slot]; + if (!lease->in_use || lease->generation != handle.generation) { + return ESP_ERR_NOT_FOUND; + } + release_read_lease_slot(lease); + return ESP_OK; +} + +const char *pocketjs_net_esp_error_name(pocketjs_net_esp_error_t error) { + switch (error) { + case POCKETJS_NET_ESP_ERROR_NONE: + return "none"; + case POCKETJS_NET_ESP_ERROR_ABORTED: + return "aborted"; + case POCKETJS_NET_ESP_ERROR_TIMED_OUT: + return "timed_out"; + case POCKETJS_NET_ESP_ERROR_BUSY: + return "busy"; + case POCKETJS_NET_ESP_ERROR_RESOURCE_LIMIT: + return "resource_limit"; + case POCKETJS_NET_ESP_ERROR_INVALID_ARGUMENT: + return "invalid_argument"; + case POCKETJS_NET_ESP_ERROR_CLOSED: + return "closed"; + case POCKETJS_NET_ESP_ERROR_UNSUPPORTED: + return "unsupported"; + case POCKETJS_NET_ESP_ERROR_DNS_NOT_FOUND: + return "dns_not_found"; + case POCKETJS_NET_ESP_ERROR_DNS_FAILED: + return "dns_failed"; + case POCKETJS_NET_ESP_ERROR_CONNECTION_REFUSED: + return "connection_refused"; + case POCKETJS_NET_ESP_ERROR_CONNECTION_RESET: + return "connection_reset"; + case POCKETJS_NET_ESP_ERROR_NETWORK_UNREACHABLE: + return "network_unreachable"; + case POCKETJS_NET_ESP_ERROR_TRANSPORT_FAILED: + return "transport_failed"; + case POCKETJS_NET_ESP_ERROR_TLS_CERTIFICATE_INVALID: + return "tls_certificate_invalid"; + case POCKETJS_NET_ESP_ERROR_TLS_HOSTNAME_MISMATCH: + return "tls_hostname_mismatch"; + case POCKETJS_NET_ESP_ERROR_TLS_HANDSHAKE_FAILED: + return "tls_handshake_failed"; + case POCKETJS_NET_ESP_ERROR_TLS_VERSION_UNSUPPORTED: + return "tls_version_unsupported"; + case POCKETJS_NET_ESP_ERROR_TLS_ALERT: + return "tls_alert"; + default: + return NULL; + } +} diff --git a/hosts/esp-idf/components/pocketjs_net_esp_transport/src/transport_state.c b/hosts/esp-idf/components/pocketjs_net_esp_transport/src/transport_state.c new file mode 100644 index 00000000..64a52ad3 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_transport/src/transport_state.c @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: MIT + +#include "transport_state.h" + +#include + +bool pocketjs_net_token_gate_can_accept(const pocketjs_net_token_gate_t *gate, + uint64_t token) { + return gate != NULL && !gate->exhausted && token != 0U && token > gate->last; +} + +void pocketjs_net_token_gate_consume(pocketjs_net_token_gate_t *gate, + uint64_t token) { + gate->last = token; + if (token == UINT64_MAX) { + gate->exhausted = true; + } +} + +bool pocketjs_net_terminal_credit_invariant( + const pocketjs_net_terminal_credits_t *credits) { + if (credits == NULL) { + return false; + } + return credits->reserved <= credits->capacity && + credits->queued <= credits->capacity && + credits->delivering <= credits->capacity && + credits->reserved + credits->queued + credits->delivering <= + credits->capacity; +} + +bool pocketjs_net_terminal_credit_reserve( + pocketjs_net_terminal_credits_t *credits) { + if (!pocketjs_net_terminal_credit_invariant(credits) || + credits->reserved + credits->queued + credits->delivering == + credits->capacity) { + return false; + } + ++credits->reserved; + return true; +} + +bool pocketjs_net_terminal_credit_enqueue( + pocketjs_net_terminal_credits_t *credits) { + if (!pocketjs_net_terminal_credit_invariant(credits) || + credits->reserved == 0U) { + return false; + } + --credits->reserved; + ++credits->queued; + return pocketjs_net_terminal_credit_invariant(credits); +} + +bool pocketjs_net_terminal_credit_take( + pocketjs_net_terminal_credits_t *credits) { + if (!pocketjs_net_terminal_credit_invariant(credits) || + credits->queued == 0U) { + return false; + } + --credits->queued; + ++credits->delivering; + return pocketjs_net_terminal_credit_invariant(credits); +} + +bool pocketjs_net_terminal_credit_retire( + pocketjs_net_terminal_credits_t *credits) { + if (!pocketjs_net_terminal_credit_invariant(credits) || + credits->delivering == 0U) { + return false; + } + --credits->delivering; + return pocketjs_net_terminal_credit_invariant(credits); +} + +bool pocketjs_net_operation_claim_terminal( + pocketjs_net_operation_lifecycle_t *lifecycle) { + if (lifecycle == NULL || *lifecycle != POCKETJS_NET_OPERATION_ACTIVE) { + return false; + } + *lifecycle = POCKETJS_NET_OPERATION_TERMINAL_QUEUED; + return true; +} + +pocketjs_net_operation_admission_t +pocketjs_net_operation_admit(bool closing, pocketjs_net_token_gate_t *gate, + pocketjs_net_terminal_credits_t *credits, + pocketjs_net_operation_lifecycle_t *lifecycle, + uint64_t *current_token, + uint64_t requested_token) { + if (closing) { + return POCKETJS_NET_OPERATION_ADMISSION_CLOSING; + } + if (!pocketjs_net_token_gate_can_accept(gate, requested_token)) { + return POCKETJS_NET_OPERATION_ADMISSION_INVALID_TOKEN; + } + if (lifecycle == NULL || current_token == NULL || + *lifecycle != POCKETJS_NET_OPERATION_FREE || + !pocketjs_net_terminal_credit_reserve(credits)) { + return POCKETJS_NET_OPERATION_ADMISSION_NO_CAPACITY; + } + *current_token = requested_token; + *lifecycle = POCKETJS_NET_OPERATION_ACTIVE; + pocketjs_net_token_gate_consume(gate, requested_token); + return POCKETJS_NET_OPERATION_ADMISSION_ACCEPTED; +} + +bool pocketjs_net_operation_claim_native_terminal( + pocketjs_net_operation_lifecycle_t *lifecycle, bool cancel_requested, + bool deadline_expired) { + return !cancel_requested && !deadline_expired && + pocketjs_net_operation_claim_terminal(lifecycle); +} + +pocketjs_net_operation_terminal_reason_t +pocketjs_net_operation_claim_cancel_or_timeout( + pocketjs_net_operation_lifecycle_t *lifecycle, bool cancel_requested, + bool deadline_expired) { + if ((!cancel_requested && !deadline_expired) || + !pocketjs_net_operation_claim_terminal(lifecycle)) { + return POCKETJS_NET_OPERATION_TERMINAL_NONE; + } + return cancel_requested ? POCKETJS_NET_OPERATION_TERMINAL_ABORTED + : POCKETJS_NET_OPERATION_TERMINAL_TIMED_OUT; +} + +bool pocketjs_net_operation_begin_delivery( + pocketjs_net_operation_lifecycle_t *lifecycle) { + if (lifecycle == NULL || + *lifecycle != POCKETJS_NET_OPERATION_TERMINAL_QUEUED) { + return false; + } + *lifecycle = POCKETJS_NET_OPERATION_DELIVERING; + return true; +} + +bool pocketjs_net_operation_retire( + pocketjs_net_operation_lifecycle_t *lifecycle) { + if (lifecycle == NULL || *lifecycle != POCKETJS_NET_OPERATION_DELIVERING) { + return false; + } + *lifecycle = POCKETJS_NET_OPERATION_FREE; + return true; +} + +bool pocketjs_net_operation_ticket_matches( + pocketjs_net_operation_lifecycle_t lifecycle, uint64_t current_token, + uint64_t requested_token) { + return lifecycle != POCKETJS_NET_OPERATION_FREE && requested_token != 0U && + current_token == requested_token; +} + +bool pocketjs_net_operation_cancel_closes_connection( + pocketjs_net_transport_operation_kind_t kind) { + return kind == POCKETJS_NET_TRANSPORT_OPERATION_CONNECT || + kind == POCKETJS_NET_TRANSPORT_OPERATION_READ || + kind == POCKETJS_NET_TRANSPORT_OPERATION_WRITE || + kind == POCKETJS_NET_TRANSPORT_OPERATION_CLOSE; +} + +bool pocketjs_net_operation_shutdown_requests_cancel( + pocketjs_net_transport_operation_kind_t kind) { + return kind != POCKETJS_NET_TRANSPORT_OPERATION_CLOSE; +} + +static bool tls_code_matches(int actual, int expected) { + return expected != 0 && + (actual == expected || (expected != INT_MIN && actual == -expected)); +} + +uint32_t +pocketjs_net_select_tls_certificate_flags(uint32_t captured_flags, + uint32_t live_verify_result) { + if (live_verify_result != 0U && live_verify_result != UINT32_MAX) { + return live_verify_result; + } + return captured_flags; +} + +pocketjs_net_tls_error_class_t pocketjs_net_classify_tls_error( + int tls_code, uint32_t certificate_flags, + const pocketjs_net_tls_error_symbols_t *symbols) { + if (symbols == NULL) { + return POCKETJS_NET_TLS_ERROR_CLASS_HANDSHAKE_FAILED; + } + + /* Mbed TLS uses UINT32_MAX when a verification result is unavailable. It is + * not a set of certificate-reason flags and must not imply CN mismatch. */ + bool certificate_flags_available = certificate_flags != UINT32_MAX; + if (certificate_flags_available && + (certificate_flags & symbols->hostname_mismatch_flag) != 0U) { + return POCKETJS_NET_TLS_ERROR_CLASS_HOSTNAME_MISMATCH; + } + if ((certificate_flags_available && certificate_flags != 0U) || + tls_code_matches(tls_code, symbols->certificate_verify_failed) || + tls_code_matches(tls_code, symbols->bad_certificate) || + tls_code_matches(tls_code, symbols->ca_chain_required)) { + return POCKETJS_NET_TLS_ERROR_CLASS_CERTIFICATE_INVALID; + } + if (tls_code_matches(tls_code, symbols->bad_protocol_version)) { + return POCKETJS_NET_TLS_ERROR_CLASS_VERSION_UNSUPPORTED; + } + if (tls_code_matches(tls_code, symbols->fatal_alert_message)) { + return POCKETJS_NET_TLS_ERROR_CLASS_ALERT; + } + if (tls_code_matches(tls_code, symbols->allocation_failed)) { + return POCKETJS_NET_TLS_ERROR_CLASS_RESOURCE_LIMIT; + } + return POCKETJS_NET_TLS_ERROR_CLASS_HANDSHAKE_FAILED; +} + +pocketjs_net_tls_close_notify_outcome_t +pocketjs_net_classify_tls_close_notify(int result, int want_read, + int want_write) { + if (result == 0) { + return POCKETJS_NET_TLS_CLOSE_NOTIFY_COMPLETE; + } + if (result == want_read || result == want_write) { + return POCKETJS_NET_TLS_CLOSE_NOTIFY_RETRY; + } + return POCKETJS_NET_TLS_CLOSE_NOTIFY_FAILED; +} + +size_t pocketjs_net_round_robin_next(size_t current, size_t capacity) { + return capacity == 0U || current >= capacity - 1U ? 0U : current + 1U; +} + +bool pocketjs_net_dns_candidate_prefix_saturated(size_t populated_slots, + size_t slot_capacity) { + return slot_capacity == 0U || populated_slots >= slot_capacity; +} + +bool pocketjs_net_dns_callback_ticket_matches(bool ticket_active, + uint64_t context_generation, + uint64_t ticket_generation) { + return ticket_active && ticket_generation != 0U && + context_generation == ticket_generation; +} + +bool pocketjs_net_generation_advance(uint64_t *generation) { + if (generation == NULL || *generation == UINT64_MAX) { + return false; + } + ++*generation; + if (*generation == 0U) { + return false; + } + return true; +} diff --git a/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/.gitignore b/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/.gitignore new file mode 100644 index 00000000..d166be1a --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/.gitignore @@ -0,0 +1,3 @@ +/build-*/ +/sdkconfig +/dependencies.lock diff --git a/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/CMakeLists.txt new file mode 100644 index 00000000..d49597a7 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.16) + +set(EXTRA_COMPONENT_DIRS "../..") +set(SDKCONFIG "${CMAKE_BINARY_DIR}/sdkconfig") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(pocketjs_net_esp_transport_build_smoke) diff --git a/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/README.md b/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/README.md new file mode 100644 index 00000000..f1ade3cc --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/README.md @@ -0,0 +1,10 @@ +# ESP transport build smoke + +Build this app with exact ESP-IDF v6.0.2 for both supported targets. It checks +that the public descriptor remains conservative. It does not configure Wi-Fi, +perform network I/O, or advertise a PocketJS capability. + +```sh +idf.py -B build-esp32s3 -DIDF_TARGET=esp32s3 build +idf.py -B build-esp32p4 -DIDF_TARGET=esp32p4 build +``` diff --git a/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/main/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/main/CMakeLists.txt new file mode 100644 index 00000000..b9f2c313 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/main/CMakeLists.txt @@ -0,0 +1,10 @@ +idf_component_register( + SRCS "build_smoke.c" + INCLUDE_DIRS "." + REQUIRES pocketjs_net_esp_transport + PRIV_REQUIRES mbedtls +) + +target_compile_definitions(${COMPONENT_LIB} + PRIVATE POCKETJS_NET_ESP_TRANSPORT_TEST_INTERNALS=1 +) diff --git a/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/main/build_smoke.c b/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/main/build_smoke.c new file mode 100644 index 00000000..45f2216d --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/main/build_smoke.c @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT + +#include +#include +#include + +#include "mbedtls/ssl.h" +#include "mbedtls/x509.h" +#include "pocketjs/net/esp_transport.h" + +void app_main(void) { + assert(pocketjs_net_esp_transport_map_tls_error_for_test( + MBEDTLS_ERR_X509_CERT_VERIFY_FAILED, 0U) == + POCKETJS_NET_ESP_ERROR_TLS_CERTIFICATE_INVALID); + assert(pocketjs_net_esp_transport_map_tls_error_for_test( + -MBEDTLS_ERR_X509_CERT_VERIFY_FAILED, 0U) == + POCKETJS_NET_ESP_ERROR_TLS_CERTIFICATE_INVALID); + assert(pocketjs_net_esp_transport_map_tls_error_for_test( + 0, MBEDTLS_X509_BADCERT_CN_MISMATCH) == + POCKETJS_NET_ESP_ERROR_TLS_HOSTNAME_MISMATCH); + assert(pocketjs_net_esp_transport_map_tls_error_for_test( + 0, MBEDTLS_X509_BADCERT_NOT_TRUSTED) == + POCKETJS_NET_ESP_ERROR_TLS_CERTIFICATE_INVALID); + assert(pocketjs_net_esp_transport_map_tls_error_for_test(-0x1234, 0U) == + POCKETJS_NET_ESP_ERROR_TLS_HANDSHAKE_FAILED); + + const pocketjs_net_esp_transport_descriptor_t *descriptor = + pocketjs_net_esp_transport_descriptor(); + assert(descriptor != NULL); + assert(strcmp(descriptor->id, POCKETJS_NET_ESP_TRANSPORT_ID) == 0); + assert(strlen(POCKETJS_NET_ESP_TLS_PROVIDER_ID) != 0U); + assert(descriptor->experimental); + assert(!descriptor->advertises_public_capability); + assert(descriptor->ipv4); + assert(descriptor->asynchronous_raw_dns); + assert(descriptor->stock_lwip_dns_callbacks_only); + assert(!descriptor->complete_dns_candidate_set); + assert(descriptor->rejects_saturated_dns_candidate_prefix); + assert(descriptor->dns_cancel_generation_cleanup); + assert(!descriptor->synchronous_getaddrinfo_for_hostname); + assert(!descriptor->esp_tls_numeric_getaddrinfo_internal); + assert(descriptor->nonblocking_plain_tcp_steps); + assert(descriptor->nonblocking_tls_steps); + assert(!descriptor->bounded_native_step_wall_time); + assert(descriptor->esp_tls_internal_select_timeout_ms == 0U); + assert(descriptor->monotonic_deadlines); + assert(descriptor->cancel_between_native_steps); + assert(!descriptor->worker_or_callback_calls_quickjs); + assert(descriptor->exact_one_terminal); + assert(descriptor->aba_safe_tokens); + assert(descriptor->fixed_operation_pool); + assert(descriptor->fixed_completion_pool); + assert(descriptor->fixed_payload_pool); + assert(descriptor->tls_compiled); + assert(descriptor->tls_1_2_only); + assert(descriptor->host_trust); + assert(descriptor->host_pinned_ca); + assert(descriptor->hostname_verification); + assert(descriptor->distinct_tls_errors); + assert(descriptor->sni); + assert(descriptor->trusted_wall_clock_required); + assert(!descriptor->plaintext_fallback); + assert(!descriptor->renegotiation); + assert(!descriptor->early_data); + assert(descriptor->tls_close_notify); + assert(descriptor->tls_close_notify_uses_operation_deadline); + assert(!descriptor->tls_close_notify_waits_for_peer); + assert(!descriptor->bounded_lwip_dns_callback_allocation); + assert(!descriptor->bounded_lwip_socket_allocation); + assert(!descriptor->bounded_esp_tls_allocation); + assert(!descriptor->bounded_mbedtls_x509_parse_allocation); + assert(descriptor->pocketjs_owned_instance_bytes > 0U); + assert(descriptor->lwip_static_callback_messages == 8U); + assert(descriptor->max_dns_candidates == 4U); + + const pocketjs_net_esp_transport_config_t disabled = { + .tls_trust_source = POCKETJS_NET_ESP_TLS_TRUST_DISABLED, + }; + assert(pocketjs_net_esp_transport_validate_config(&disabled) == ESP_OK); + pocketjs_net_esp_transport_config_t invalid = disabled; + invalid.tls_trust_source = POCKETJS_NET_ESP_TLS_TRUST_HOST_PINNED_CA; + assert(pocketjs_net_esp_transport_validate_config(&invalid) == + ESP_ERR_INVALID_ARG); + + assert(strcmp(pocketjs_net_esp_error_name( + POCKETJS_NET_ESP_ERROR_TLS_CERTIFICATE_INVALID), + "tls_certificate_invalid") == 0); + assert(strcmp(pocketjs_net_esp_error_name( + POCKETJS_NET_ESP_ERROR_TLS_HOSTNAME_MISMATCH), + "tls_hostname_mismatch") == 0); + assert(pocketjs_net_esp_error_name((pocketjs_net_esp_error_t)UINT16_MAX) == + NULL); +} diff --git a/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/main/idf_component.yml b/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/main/idf_component.yml new file mode 100644 index 00000000..75552434 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/main/idf_component.yml @@ -0,0 +1,3 @@ +dependencies: + pocketjs_net_esp_transport: + override_path: ../../.. diff --git a/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/sdkconfig.defaults b/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/sdkconfig.defaults new file mode 100644 index 00000000..271d0a42 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_transport/test_apps/build_smoke/sdkconfig.defaults @@ -0,0 +1,12 @@ +CONFIG_LWIP_DNS_MAX_HOST_IP=4 +CONFIG_LWIP_HOOK_DNS_EXT_RESOLVE_NONE=y +# CONFIG_LWIP_HOOK_DNS_EXT_RESOLVE_CUSTOM is not set +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=y +CONFIG_MBEDTLS_SSL_PROTO_TLS1_2=y +# CONFIG_MBEDTLS_SSL_PROTO_TLS1_3 is not set +# CONFIG_MBEDTLS_SSL_RENEGOTIATION is not set +# CONFIG_MBEDTLS_ALLOW_WEAK_CERTIFICATE_VERIFICATION is not set +# CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEPRECATED_LIST is not set +# CONFIG_MBEDTLS_DES_C is not set +# CONFIG_ESP_TLS_INSECURE is not set diff --git a/hosts/esp-idf/components/pocketjs_net_esp_transport/test_host/state_test.c b/hosts/esp-idf/components/pocketjs_net_esp_transport/test_host/state_test.c new file mode 100644 index 00000000..c613e062 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_esp_transport/test_host/state_test.c @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: MIT + +#include +#include + +#include "transport_state.h" + +int main(void) { + assert(pocketjs_net_select_tls_certificate_flags(0U, 0U) == 0U); + assert(pocketjs_net_select_tls_certificate_flags(0x08U, 0U) == 0x08U); + assert(pocketjs_net_select_tls_certificate_flags(0U, 0x04U) == 0x04U); + assert(pocketjs_net_select_tls_certificate_flags(0x08U, 0x04U) == 0x04U); + assert(pocketjs_net_select_tls_certificate_flags(0x08U, UINT32_MAX) == 0x08U); + static const pocketjs_net_tls_error_symbols_t tls_symbols = { + .certificate_verify_failed = -0x2700, + .bad_certificate = -0x7a00, + .ca_chain_required = -0x7680, + .bad_protocol_version = -0x6e80, + .fatal_alert_message = -0x7780, + .allocation_failed = -0x007f, + .hostname_mismatch_flag = 0x04U, + }; + assert(pocketjs_net_classify_tls_error(-0x2700, 0U, &tls_symbols) == + POCKETJS_NET_TLS_ERROR_CLASS_CERTIFICATE_INVALID); + assert(pocketjs_net_classify_tls_error(0x2700, 0U, &tls_symbols) == + POCKETJS_NET_TLS_ERROR_CLASS_CERTIFICATE_INVALID); + assert(pocketjs_net_classify_tls_error(-0x1234, 0x04U, &tls_symbols) == + POCKETJS_NET_TLS_ERROR_CLASS_HOSTNAME_MISMATCH); + assert(pocketjs_net_classify_tls_error(-0x1234, 0x08U, &tls_symbols) == + POCKETJS_NET_TLS_ERROR_CLASS_CERTIFICATE_INVALID); + assert(pocketjs_net_classify_tls_error(-0x1234, UINT32_MAX, &tls_symbols) == + POCKETJS_NET_TLS_ERROR_CLASS_HANDSHAKE_FAILED); + assert(pocketjs_net_classify_tls_error(0x7a00, 0U, &tls_symbols) == + POCKETJS_NET_TLS_ERROR_CLASS_CERTIFICATE_INVALID); + assert(pocketjs_net_classify_tls_error(-0x7680, 0U, &tls_symbols) == + POCKETJS_NET_TLS_ERROR_CLASS_CERTIFICATE_INVALID); + assert(pocketjs_net_classify_tls_error(0x6e80, 0U, &tls_symbols) == + POCKETJS_NET_TLS_ERROR_CLASS_VERSION_UNSUPPORTED); + assert(pocketjs_net_classify_tls_error(-0x7780, 0U, &tls_symbols) == + POCKETJS_NET_TLS_ERROR_CLASS_ALERT); + assert(pocketjs_net_classify_tls_error(0x007f, 0U, &tls_symbols) == + POCKETJS_NET_TLS_ERROR_CLASS_RESOURCE_LIMIT); + assert(pocketjs_net_classify_tls_error(-0x1234, 0U, &tls_symbols) == + POCKETJS_NET_TLS_ERROR_CLASS_HANDSHAKE_FAILED); + assert(pocketjs_net_classify_tls_error(-0x2700, 0U, NULL) == + POCKETJS_NET_TLS_ERROR_CLASS_HANDSHAKE_FAILED); + assert(pocketjs_net_classify_tls_close_notify(0, -0x6900, -0x6880) == + POCKETJS_NET_TLS_CLOSE_NOTIFY_COMPLETE); + assert(pocketjs_net_classify_tls_close_notify(-0x6900, -0x6900, -0x6880) == + POCKETJS_NET_TLS_CLOSE_NOTIFY_RETRY); + assert(pocketjs_net_classify_tls_close_notify(-0x6880, -0x6900, -0x6880) == + POCKETJS_NET_TLS_CLOSE_NOTIFY_RETRY); + assert(pocketjs_net_classify_tls_close_notify(-0x1234, -0x6900, -0x6880) == + POCKETJS_NET_TLS_CLOSE_NOTIFY_FAILED); + + pocketjs_net_token_gate_t gate = {0}; + assert(!pocketjs_net_token_gate_can_accept(&gate, 0)); + assert(pocketjs_net_token_gate_can_accept(&gate, 1)); + pocketjs_net_token_gate_consume(&gate, 1); + assert(!pocketjs_net_token_gate_can_accept(&gate, 1)); + assert(pocketjs_net_token_gate_can_accept(&gate, UINT64_MAX)); + pocketjs_net_token_gate_consume(&gate, UINT64_MAX); + assert(!pocketjs_net_token_gate_can_accept(&gate, UINT64_MAX)); + + assert(!pocketjs_net_dns_candidate_prefix_saturated(0U, 4U)); + assert(!pocketjs_net_dns_candidate_prefix_saturated(3U, 4U)); + assert(pocketjs_net_dns_candidate_prefix_saturated(4U, 4U)); + assert(pocketjs_net_dns_candidate_prefix_saturated(5U, 4U)); + assert(pocketjs_net_dns_candidate_prefix_saturated(0U, 0U)); + assert(!pocketjs_net_dns_callback_ticket_matches(false, 7U, 7U)); + assert(!pocketjs_net_dns_callback_ticket_matches(true, 0U, 0U)); + assert(pocketjs_net_dns_callback_ticket_matches(true, 7U, 7U)); + assert(!pocketjs_net_dns_callback_ticket_matches(true, 8U, 7U)); + + uint64_t generation = 0; + assert(pocketjs_net_generation_advance(&generation)); + assert(generation == 1); + generation = UINT64_MAX; + assert(!pocketjs_net_generation_advance(&generation)); + assert(generation == UINT64_MAX); + + pocketjs_net_terminal_credits_t credits = {.capacity = 2}; + assert(pocketjs_net_terminal_credit_invariant(&credits)); + assert(pocketjs_net_terminal_credit_reserve(&credits)); + assert(pocketjs_net_terminal_credit_reserve(&credits)); + assert(!pocketjs_net_terminal_credit_reserve(&credits)); + assert(pocketjs_net_terminal_credit_enqueue(&credits)); + assert(pocketjs_net_terminal_credit_take(&credits)); + assert(!pocketjs_net_terminal_credit_reserve(&credits)); + assert(pocketjs_net_terminal_credit_retire(&credits)); + assert(pocketjs_net_terminal_credit_reserve(&credits)); + + /* Admission is serialized with shutdown. A shutdown-first interleaving + * consumes neither a token nor a terminal credit. */ + pocketjs_net_token_gate_t admission_gate = {0}; + pocketjs_net_terminal_credits_t admission_credits = {.capacity = 1}; + pocketjs_net_operation_lifecycle_t admission_lifecycle = + POCKETJS_NET_OPERATION_FREE; + uint64_t admitted_token = 0U; + assert(pocketjs_net_operation_admit( + true, &admission_gate, &admission_credits, &admission_lifecycle, + &admitted_token, 41U) == POCKETJS_NET_OPERATION_ADMISSION_CLOSING); + assert(admission_lifecycle == POCKETJS_NET_OPERATION_FREE); + assert(admission_gate.last == 0U); + assert(admission_credits.reserved == 0U); + assert(pocketjs_net_operation_admit(false, &admission_gate, + &admission_credits, &admission_lifecycle, + &admitted_token, 41U) == + POCKETJS_NET_OPERATION_ADMISSION_ACCEPTED); + assert(admission_lifecycle == POCKETJS_NET_OPERATION_ACTIVE); + assert(admitted_token == 41U); + assert(admission_gate.last == 41U); + assert(admission_credits.reserved == 1U); + + pocketjs_net_operation_lifecycle_t lifecycle = POCKETJS_NET_OPERATION_ACTIVE; + assert(pocketjs_net_operation_claim_terminal(&lifecycle)); + /* A late native success loses after abort/timeout has claimed terminal. */ + assert(!pocketjs_net_operation_claim_terminal(&lifecycle)); + assert(pocketjs_net_operation_begin_delivery(&lifecycle)); + assert(!pocketjs_net_operation_begin_delivery(&lifecycle)); + assert(pocketjs_net_operation_retire(&lifecycle)); + assert(lifecycle == POCKETJS_NET_OPERATION_FREE); + + /* A stale cancellation ticket cannot match a reused operation slot. */ + assert(!pocketjs_net_operation_ticket_matches(lifecycle, 10, 10)); + lifecycle = POCKETJS_NET_OPERATION_ACTIVE; + assert(pocketjs_net_operation_ticket_matches(lifecycle, 11, 11)); + assert(!pocketjs_net_operation_ticket_matches(lifecycle, 11, 10)); + + /* cancel, deadline, and native completion use one serialized terminal + * transition. Cancellation wins when it was published first. */ + lifecycle = POCKETJS_NET_OPERATION_ACTIVE; + assert( + !pocketjs_net_operation_claim_native_terminal(&lifecycle, true, false)); + assert( + pocketjs_net_operation_claim_cancel_or_timeout(&lifecycle, true, true) == + POCKETJS_NET_OPERATION_TERMINAL_ABORTED); + assert( + !pocketjs_net_operation_claim_native_terminal(&lifecycle, false, false)); + + lifecycle = POCKETJS_NET_OPERATION_ACTIVE; + assert( + !pocketjs_net_operation_claim_native_terminal(&lifecycle, false, true)); + assert( + pocketjs_net_operation_claim_cancel_or_timeout(&lifecycle, false, true) == + POCKETJS_NET_OPERATION_TERMINAL_TIMED_OUT); + + lifecycle = POCKETJS_NET_OPERATION_ACTIVE; + assert( + pocketjs_net_operation_claim_native_terminal(&lifecycle, false, false)); + assert(pocketjs_net_operation_claim_cancel_or_timeout( + &lifecycle, true, true) == POCKETJS_NET_OPERATION_TERMINAL_NONE); + + size_t cursor = 0U; + bool visited[8] = {false}; + for (size_t index = 0; index < 8U; ++index) { + visited[cursor] = true; + cursor = pocketjs_net_round_robin_next(cursor, 8U); + } + assert(cursor == 0U); + for (size_t index = 0; index < 8U; ++index) { + assert(visited[index]); + } + + assert(!pocketjs_net_operation_cancel_closes_connection( + POCKETJS_NET_TRANSPORT_OPERATION_RESOLVE)); + assert(pocketjs_net_operation_cancel_closes_connection( + POCKETJS_NET_TRANSPORT_OPERATION_CONNECT)); + assert(pocketjs_net_operation_cancel_closes_connection( + POCKETJS_NET_TRANSPORT_OPERATION_READ)); + assert(pocketjs_net_operation_cancel_closes_connection( + POCKETJS_NET_TRANSPORT_OPERATION_WRITE)); + assert(pocketjs_net_operation_cancel_closes_connection( + POCKETJS_NET_TRANSPORT_OPERATION_CLOSE)); + assert(pocketjs_net_operation_shutdown_requests_cancel( + POCKETJS_NET_TRANSPORT_OPERATION_RESOLVE)); + assert(pocketjs_net_operation_shutdown_requests_cancel( + POCKETJS_NET_TRANSPORT_OPERATION_CONNECT)); + assert(pocketjs_net_operation_shutdown_requests_cancel( + POCKETJS_NET_TRANSPORT_OPERATION_READ)); + assert(pocketjs_net_operation_shutdown_requests_cancel( + POCKETJS_NET_TRANSPORT_OPERATION_WRITE)); + assert(!pocketjs_net_operation_shutdown_requests_cancel( + POCKETJS_NET_TRANSPORT_OPERATION_CLOSE)); + return 0; +} diff --git a/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/CMakeLists.txt new file mode 100644 index 00000000..4a0e9202 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/CMakeLists.txt @@ -0,0 +1,29 @@ +include("${CMAKE_CURRENT_LIST_DIR}/../../cmake/pocketjs_generate_test_artifact.cmake") +pocketjs_generate_test_artifact( + _pocketjs_generated + "${CMAKE_CURRENT_LIST_DIR}/generate.ts" +) + +idf_component_register( + SRCS + "${_pocketjs_generated}/formal_smoke_metadata.c" + "src/formal_smoke_report.c" + "src/formal_smoke_runner.c" + INCLUDE_DIRS + "include" + "../../../../contracts/spec/network/generated" + REQUIRES + pocketjs_esp_guest + pocketjs_net_esp_runtime + esp_timer + lwip + quickjs-ng +) + +target_add_binary_data( + ${COMPONENT_LIB} + "${_pocketjs_generated}/factory.js.bin" + BINARY +) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/README.md b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/README.md new file mode 100644 index 00000000..ee89e558 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/README.md @@ -0,0 +1,59 @@ +# Formal ESP network hardware-smoke artifact + +This test-only component embeds one PocketJS network factory for ESP32-S3 and +ESP32-P4 hardware checks. **Both boards link the same NUL-terminated factory, +32-byte Build Plan hash, sorted feature list, endpoint tuple, and factory +SHA-256.** The component does not advertise a public capability and does not +change the stock target registry. + +`app.ts` imports `fetch` from `@pocketjs/framework/net/http`. The generator +resolves a format-3 manifest and invokes the normal private network-factory +pipeline with a narrow test-only compiler permit. The permit requires the +exact plan hash, app entry, target, host ABI, feature set, HTTP backend, and ESP +transport. Normal builds, changed plans, `Headers`, `serve`, and namespace +imports remain staged. + +The Guest runs 20 rounds against `http://172.16.10.126:8088`. Each round checks +`GET /health` and a binary, unknown-length streamed `POST /echo`. The requests +use the SDK's default `redirect: "follow"`, which verifies that an ordinary +`fetch(url)` reaches the native runtime without a smoke-only option override. +The factory installs the legacy `frame` slot required by the Guest host, but +**the headless runner never calls it and requires `frameCalls === 0`.** + +ESP-IDF generates the artifact into the component's build directory during +configuration. To inspect the deterministic outputs without changing the +source tree, generate them into a temporary directory: + +```sh +bun hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/generate.ts \ + --output-dir=/tmp/pocketjs-formal-http-artifact +bun test tests/esp-network-artifacts.test.ts +``` + +**Generated plans, metadata C, and factory bundles are build outputs and are +not committed.** The consolidated artifact test regenerates all HTTP and TLS +profiles in an isolated directory and locks their hashes and admission rules. + +`pocketjs_net_formal_smoke_run()` is called from a dedicated non-lwIP owner +task. It creates a PSRAM-only QuickJS Guest, performs the exact formal ABI 1.1 +handshake, services native work and Promise jobs with fixed budgets, snapshots +the report without accessors or eval, and completes the runtime's three-stage +shutdown. **The minimum Guest heap is 2 MiB, the recommended board setting is +4 MiB, and the minimum QuickJS stack limit is 24 KiB.** The runner exclusively +uses FreeRTOS task notification index zero while active. + +Once native runtime creation succeeds, the runner does not return until the +runtime and Guest can be destroyed safely. `shutdown_warning_ms` emits a +fail-stop diagnostic but does not abandon callback-owned memory. A broken +native subsystem therefore keeps the dedicated owner task in cleanup instead +of returning with a dangling wake or permission context. + +This artifact validates the formal plaintext request path, default redirect +mode admission, one-entry-per-slot connection reuse, and board scheduler +integration. Its generated metadata carries the exact backend and network +driver selection from the verified Build Plan, and the runner passes those +values into fail-closed runtime admission. It does not satisfy public HTTP +admission: redirect URL differential conformance, executable full descriptor +aggregation, complete resource accounting, DNS candidate completeness, and the +full hardware/conformance matrix remain required before the compiler or stock +target registry can expose `network.http.client`. diff --git a/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/app.ts b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/app.ts new file mode 100644 index 00000000..2f51e6dd --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/app.ts @@ -0,0 +1,149 @@ +import { fetch } from "@pocketjs/framework/net/http"; + +const REPORT_GLOBAL = "__pocketjsFormalNetworkSmokeReportV1"; +const ORIGIN = "http://172.16.10.126:8088"; +const ROUNDS_TOTAL = 20; +const HEALTH_BODY = + '{"peer":"mac","protocol":"pocketjs-independent-peer-v1","status":"ok"}'; + +type SmokePhase = "starting" | "health" | "echo" | "passed" | "failed"; + +interface SmokeState { + version: 1; + checkpoint: number; + phase: SmokePhase; + roundsTotal: number; + roundsStarted: number; + roundsPassed: number; + requestsPassed: number; + frameCalls: number; + done: boolean; + ok: boolean; + errorName: string; + errorCode: string; + errorOperation: string; +} + +const state: SmokeState = { + version: 1, + checkpoint: 0, + phase: "starting", + roundsTotal: ROUNDS_TOTAL, + roundsStarted: 0, + roundsPassed: 0, + requestsPassed: 0, + frameCalls: 0, + done: false, + ok: false, + errorName: "", + errorCode: "", + errorOperation: "", +}; + +function checkpoint(phase: SmokePhase): void { + state.phase = phase; + state.checkpoint += 1; +} + +function report(): Readonly { + return Object.freeze({ ...state }); +} + +function boundedDiagnostic(value: unknown): string { + return typeof value === "string" ? value.slice(0, 64) : ""; +} + +function recordFailure(error: unknown): void { + const candidate = typeof error === "object" && error !== null + ? error as Record + : {}; + state.errorName = boundedDiagnostic(candidate.name); + state.errorCode = boundedDiagnostic(candidate.code); + state.errorOperation = boundedDiagnostic(candidate.operation); + state.done = true; + state.ok = false; + checkpoint("failed"); +} + +function echoPayload(round: number): Uint8Array { + return new Uint8Array([ + 0x00, + 0xff, + 0x50, + 0x6f, + 0x63, + 0x6b, + 0x65, + 0x74, + 0x4a, + 0x53, + round, + 0x7f, + ]); +} + +async function* streamedPayload(payload: Uint8Array): AsyncIterable { + yield payload.slice(0, 3); + yield payload.slice(3, 8); + yield payload.slice(8); +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + for (let index = 0; index < left.byteLength; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + +async function run(): Promise { + try { + for (let round = 1; round <= ROUNDS_TOTAL; round += 1) { + state.roundsStarted = round; + checkpoint("health"); + const health = await fetch(`${ORIGIN}/health`); + if (health.status !== 200 || await health.text() !== HEALTH_BODY) { + throw new Error("formal smoke health response mismatch"); + } + state.requestsPassed += 1; + + checkpoint("echo"); + const payload = echoPayload(round); + const echo = await fetch(`${ORIGIN}/echo`, { + method: "POST", + body: streamedPayload(payload), + }); + const echoed = new Uint8Array(await echo.arrayBuffer()); + if (echo.status !== 200 || !equalBytes(echoed, payload)) { + throw new Error("formal smoke echo response mismatch"); + } + state.requestsPassed += 1; + state.roundsPassed = round; + } + state.done = true; + state.ok = true; + checkpoint("passed"); + } catch (error) { + recordFailure(error); + } +} + +Object.defineProperty(globalThis, REPORT_GLOBAL, { + value: report, + configurable: false, + enumerable: false, + writable: false, +}); + +// pocketjs_esp_guest_mount_factory() requires this legacy lifecycle slot. +// The formal smoke Host polls REPORT_GLOBAL and must not call frame(). +Object.defineProperty(globalThis, "frame", { + value: () => { + state.frameCalls += 1; + }, + configurable: false, + enumerable: false, + writable: false, +}); + +void run(); diff --git a/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/generate.ts b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/generate.ts new file mode 100644 index 00000000..b695c984 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/generate.ts @@ -0,0 +1,392 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + defineCapabilityRegistry, + definePlatformContractRegistry, + defineTargetRegistry, + type CapabilityId, + type TargetProfile, +} from "../../../../contracts/spec/platforms.ts"; +import { + networkV1FeatureIdsFromBuildPlan, + networkV1PlanHashBytes, +} from "../../../../contracts/spec/network/network-v1.ts"; +import type { HostNetworkResolutionProfile } from + "../../../../framework/src/manifest/network.ts"; +import { + verifyPlanHash, + type ResolvedBuildPlan, +} from "../../../../framework/src/manifest/plan.ts"; +import { validateAndResolveBuildPlan } from + "../../../../framework/src/manifest/resolve.ts"; +import { + testArtifactOutputDirectory, + writeTestArtifactOutputs, + type GeneratedTestArtifactOutput, +} from "../../../../tools/test-artifact-output.ts"; + +const COMPONENT = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(COMPONENT, "../../../.."); +const TARGET_ID = "esp-formal-network-smoke-test"; +const ORIGIN = "http://172.16.10.126:8088"; +const SCHEME = "http"; +const HOST = "172.16.10.126"; +const PORT = 8088; +const REPORT_GLOBAL = "__pocketjsFormalNetworkSmokeReportV1"; +const PUBLIC_HTTP_SPECIFIER = "@pocketjs/framework/net/http"; + +const TEST_CAPABILITIES = defineCapabilityRegistry([ + "network.http.client", +] as const); + +type TestCapability = CapabilityId; + +const TEST_TARGET_DEFINITIONS = { + [TARGET_ID]: { + hostAbi: 1, + platform: "esp-idf-formal-network-test", + form: "takeover", + display: { + physicalViewport: [1, 1], + logicalViewports: [[1, 1]], + presentations: ["native"], + rasterDensity: 1, + }, + capabilities: ["network.http.client"], + }, +} as const satisfies Readonly>>; + +const TEST_TARGETS = defineTargetRegistry< + TestCapability, + typeof TEST_TARGET_DEFINITIONS +>(TEST_TARGET_DEFINITIONS); + +const TEST_CONTRACTS = definePlatformContractRegistry( + TEST_CAPABILITIES, + TEST_TARGETS, +); + +const TEST_NETWORK_PROFILE: HostNetworkResolutionProfile = { + providers: { + backendByRole: { + "http.client": "pocketjs.net.http-client-core.v1.experimental", + }, + tlsByRole: {}, + netDriverId: "pocketjs.net.esp-idf.transport.v1.experimental", + }, + hardLimits: { + runtime: { + connections: 4, + pendingOperations: 8, + completionDescriptors: 8, + nativeBufferBytes: 524288, + }, + http: { + connections: 4, + inflightRequests: 8, + headerBytes: 8192, + headerCount: 60, + bufferedBodyBytes: 16384, + }, + }, + developmentBuild: false, +}; + +interface GeneratedMetadata { + readonly schemaVersion: 1; + readonly testOnly: true; + readonly target: { + readonly id: string; + readonly hostAbi: number; + }; + readonly planHash: string; + readonly planHashBytes: readonly number[]; + readonly featureIds: readonly number[]; + readonly providers: { + readonly httpClientBackendId: string; + readonly netDriverId: string; + }; + readonly factory: { + readonly sourceBytes: number; + readonly storageBytes: number; + readonly sha256: string; + }; + readonly endpoint: { + readonly origin: string; + readonly scheme: string; + readonly host: string; + readonly port: number; + readonly healthUrl: string; + readonly echoUrl: string; + }; + readonly reportGlobal: string; + readonly stagedSurfaceBuild: { + readonly publicSpecifier: string; + readonly permit: "test-only-staged-http-client-fetch"; + readonly exactAppId: string; + readonly exactTargetId: string; + readonly productionGateChanged: false; + }; +} + +const encoder = new TextEncoder(); + +function textBytes(value: string): Uint8Array { + return encoder.encode(value); +} + +function cBytes(bytes: readonly number[], columns = 12): string { + const lines: string[] = []; + for (let offset = 0; offset < bytes.length; offset += columns) { + lines.push( + ` ${bytes.slice(offset, offset + columns) + .map((byte) => `0x${byte.toString(16).padStart(2, "0")}`) + .join(", ")},`, + ); + } + return lines.join("\n"); +} + +function cFeatureIds(ids: readonly number[]): string { + return ids + .map((id) => ` (pocketjs_network_v1_feature_id_t)0x${id.toString(16).padStart(4, "0")},`) + .join("\n"); +} + +function cString(value: string): string { + return JSON.stringify(value); +} + +function metadataSource( + plan: ResolvedBuildPlan, + metadata: GeneratedMetadata, +): string { + const digest = metadata.factory.sha256.slice("sha256:".length); + const planDigest = plan.planHash.slice("sha256:".length); + const artifactHash = Array.from(Buffer.from(digest, "hex")); + return `// GENERATED by generate.ts; do not edit. +// SPDX-License-Identifier: MIT + +#include "pocketjs/net/formal_smoke_artifact.h" + +extern const uint8_t embedded_factory_start[] + asm("_binary_factory_js_bin_start"); + +const uint8_t *const pocketjs_net_formal_smoke_factory_bytes = + embedded_factory_start; +const size_t pocketjs_net_formal_smoke_factory_length = + ${metadata.factory.sourceBytes}U; +const size_t pocketjs_net_formal_smoke_factory_storage_length = + ${metadata.factory.storageBytes}U; + +const char pocketjs_net_formal_smoke_plan_hash[] = ${cString(plan.planHash)}; +const uint8_t pocketjs_net_formal_smoke_plan_hash_bytes + [POCKETJS_NETWORK_V1_PLAN_HASH_BYTES] = { +${cBytes(metadata.planHashBytes)} +}; + +const pocketjs_network_v1_feature_id_t + pocketjs_net_formal_smoke_feature_ids[] = { +${cFeatureIds(metadata.featureIds)} +}; +const uint16_t pocketjs_net_formal_smoke_feature_count = + ${metadata.featureIds.length}U; + +const char pocketjs_net_formal_smoke_http_client_backend_id[] = + ${cString(metadata.providers.httpClientBackendId)}; +const char pocketjs_net_formal_smoke_net_driver_id[] = + ${cString(metadata.providers.netDriverId)}; + +const char pocketjs_net_formal_smoke_factory_sha256[] = + ${cString(`sha256:${digest}`)}; +const uint8_t pocketjs_net_formal_smoke_factory_sha256_bytes[32] = { +${cBytes(artifactHash)} +}; + +const pocketjs_net_formal_smoke_endpoint_t + pocketjs_net_formal_smoke_endpoint = { + .origin = ${cString(metadata.endpoint.origin)}, + .scheme = ${cString(metadata.endpoint.scheme)}, + .host = ${cString(metadata.endpoint.host)}, + .port = ${metadata.endpoint.port}U, + .health_url = ${cString(metadata.endpoint.healthUrl)}, + .echo_url = ${cString(metadata.endpoint.echoUrl)}, +}; + +const char pocketjs_net_formal_smoke_report_global[] = + ${cString(metadata.reportGlobal)}; + +_Static_assert(sizeof(pocketjs_net_formal_smoke_plan_hash) == + sizeof("sha256:${planDigest}"), + "plan hash text length drifted"); +_Static_assert(sizeof(pocketjs_net_formal_smoke_factory_sha256) == + sizeof("sha256:${digest}"), + "artifact hash text length drifted"); +`; +} + +async function resolvePlan(): Promise { + const manifest = await Bun.file(join(COMPONENT, "manifest.json")).json(); + const result = validateAndResolveBuildPlan( + manifest, + { target: TARGET_ID, network: TEST_NETWORK_PROFILE }, + TEST_CONTRACTS, + ); + if (!result.ok) { + const detail = result.diagnostics + .map((item) => `${item.code} ${item.path}: ${item.message}`) + .join("\n"); + throw new Error(`formal smoke manifest did not resolve:\n${detail}`); + } + if (!verifyPlanHash(result.plan) || result.plan.network === undefined) { + throw new Error("formal smoke resolver did not produce a verified network plan"); + } + if ( + result.plan.app.id !== "dev.pocketjs.esp-formal-network-smoke" || + result.plan.app.entry !== "app.ts" || + result.plan.target.id !== TARGET_ID + ) { + throw new Error("formal smoke plan identity escaped its exact test-only allowlist"); + } + const connect = result.plan.network.policy.connect; + const backend = result.plan.network.providers.backendByRole["http.client"]; + const driver = result.plan.network.providers.netDriverId; + if ( + connect.length !== 1 || + connect[0]?.protocol !== SCHEME || + connect[0]?.host !== HOST || + connect[0]?.port.min !== PORT || + connect[0]?.port.max !== PORT || + backend !== "pocketjs.net.http-client-core.v1.experimental" || + driver !== "pocketjs.net.esp-idf.transport.v1.experimental" || + Object.keys(result.plan.network.providers.backendByRole).length !== 1 || + Object.keys(result.plan.network.providers.tlsByRole).length !== 0 + ) { + throw new Error("formal smoke plan endpoint or provider selection drifted"); + } + return result.plan; +} + +async function assertPublicBuildInput(): Promise { + const source = await Bun.file(join(COMPONENT, "app.ts")).text(); + const marker = `"${PUBLIC_HTTP_SPECIFIER}"`; + if (source.split(marker).length !== 2) { + throw new Error("formal smoke app must contain exactly one public HTTP import"); + } +} + +async function buildFactory(plan: ResolvedBuildPlan): Promise { + await assertPublicBuildInput(); + const temporary = await mkdtemp(join(tmpdir(), "pocketjs-formal-smoke-")); + try { + const planPath = join(temporary, "resolved-plan.json"); + const output = join(temporary, "dist"); + await Bun.write(planPath, `${JSON.stringify(plan, null, 2)}\n`); + const buildProcess = Bun.spawn([ + process.execPath, + join(ROOT, "tools/build.ts"), + `--plan=${planPath}`, + `--project-root=${COMPONENT}`, + `--outdir=${output}`, + "--no-config", + "--network-factory", + "--test-only-staged-http-client-fetch", + ], { + cwd: ROOT, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + buildProcess.exited, + new Response(buildProcess.stdout).text(), + new Response(buildProcess.stderr).text(), + ]); + if (exitCode !== 0) { + throw new Error(`formal smoke factory build failed:\n${stdout}\n${stderr}`); + } + const builtSource = await Bun.file( + join(output, "esp-formal-network-smoke.js"), + ).text(); + const source = textBytes(builtSource.replace(/[\t ]+$/gm, "")); + if (source.length === 0 || source.includes(0)) { + throw new Error("formal smoke factory source is empty or contains an interior NUL"); + } + const terminated = new Uint8Array(source.length + 1); + terminated.set(source); + return terminated; + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} + +async function expectedOutputs(): Promise { + const plan = await resolvePlan(); + const factory = await buildFactory(plan); + const source = factory.subarray(0, factory.length - 1); + const sourceHash = createHash("sha256").update(source).digest("hex"); + const planHashBytes = Array.from(networkV1PlanHashBytes(plan.planHash)); + const featureIds = Array.from(networkV1FeatureIdsFromBuildPlan(plan.features)); + const metadata: GeneratedMetadata = { + schemaVersion: 1, + testOnly: true, + target: { id: plan.target.id, hostAbi: plan.target.hostAbi }, + planHash: plan.planHash, + planHashBytes, + featureIds, + providers: { + httpClientBackendId: + plan.network!.providers.backendByRole["http.client"]!, + netDriverId: plan.network!.providers.netDriverId, + }, + factory: { + sourceBytes: source.length, + storageBytes: factory.length, + sha256: `sha256:${sourceHash}`, + }, + endpoint: { + origin: ORIGIN, + scheme: SCHEME, + host: HOST, + port: PORT, + healthUrl: `${ORIGIN}/health`, + echoUrl: `${ORIGIN}/echo`, + }, + reportGlobal: REPORT_GLOBAL, + stagedSurfaceBuild: { + publicSpecifier: PUBLIC_HTTP_SPECIFIER, + permit: "test-only-staged-http-client-fetch", + exactAppId: plan.app.id, + exactTargetId: plan.target.id, + productionGateChanged: false, + }, + }; + return [ + { + name: "resolved-plan.json", + bytes: textBytes(`${JSON.stringify(plan, null, 2)}\n`), + }, + { + name: "metadata.json", + bytes: textBytes(`${JSON.stringify(metadata, null, 2)}\n`), + }, + { name: "factory.js.bin", bytes: factory }, + { + name: "formal_smoke_metadata.c", + bytes: textBytes(metadataSource(plan, metadata)), + }, + ]; +} + +export async function generate(outputDirectory: string): Promise { + const outputs = await expectedOutputs(); + await writeTestArtifactOutputs(outputDirectory, outputs); +} + +if (import.meta.main) { + const outputDirectory = testArtifactOutputDirectory(process.argv.slice(2)); + await generate(outputDirectory); + console.log(`PocketJS formal smoke artifact generated in ${outputDirectory}`); +} diff --git a/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/idf_component.yml b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/idf_component.yml new file mode 100644 index 00000000..9af1e485 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/idf_component.yml @@ -0,0 +1,10 @@ +version: "0.1.0" +description: Test-only shared PocketJS formal ESP network hardware-smoke artifact +license: MIT +repository: https://github.com/pocket-stack/pocketjs +documentation: https://github.com/pocket-stack/pocketjs/tree/main/hosts/esp-idf +targets: + - esp32p4 + - esp32s3 +dependencies: + idf: "==6.0.2" diff --git a/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/include/pocketjs/net/formal_smoke_artifact.h b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/include/pocketjs/net/formal_smoke_artifact.h new file mode 100644 index 00000000..0920f364 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/include/pocketjs/net/formal_smoke_artifact.h @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +#include "esp_err.h" +#include "pocketjs/esp_guest.h" +#include "pocketjs/net/esp_runtime.h" +#include "pocketjs_network_v1_abi.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Test-only canonical permission tuple compiled into the verified Build Plan. + * A board Host must reject startup unless its configured peer origin matches + * this tuple exactly. + */ +typedef struct { + const char *origin; + const char *scheme; + const char *host; + uint16_t port; + const char *health_url; + const char *echo_url; +} pocketjs_net_formal_smoke_endpoint_t; + +typedef enum { + POCKETJS_NET_FORMAL_SMOKE_PHASE_STARTING = 1, + POCKETJS_NET_FORMAL_SMOKE_PHASE_HEALTH, + POCKETJS_NET_FORMAL_SMOKE_PHASE_ECHO, + POCKETJS_NET_FORMAL_SMOKE_PHASE_PASSED, + POCKETJS_NET_FORMAL_SMOKE_PHASE_FAILED, +} pocketjs_net_formal_smoke_phase_t; + +#define POCKETJS_NET_FORMAL_SMOKE_ROUNDS 20U +#define POCKETJS_NET_FORMAL_SMOKE_REQUESTS 40U +#define POCKETJS_NET_FORMAL_SMOKE_DIAGNOSTIC_BYTES 65U +#define POCKETJS_NET_FORMAL_SMOKE_MIN_GUEST_MEMORY_BYTES (2U * 1024U * 1024U) +#define POCKETJS_NET_FORMAL_SMOKE_RECOMMENDED_GUEST_MEMORY_BYTES \ + (4U * 1024U * 1024U) +#define POCKETJS_NET_FORMAL_SMOKE_MIN_GUEST_STACK_BYTES (24U * 1024U) + +typedef struct { + uint32_t checkpoint; + pocketjs_net_formal_smoke_phase_t phase; + uint32_t rounds_started; + uint32_t rounds_passed; + uint32_t requests_passed; + uint32_t frame_calls; + bool done; + bool ok; + char error_name[POCKETJS_NET_FORMAL_SMOKE_DIAGNOSTIC_BYTES]; + char error_code[POCKETJS_NET_FORMAL_SMOKE_DIAGNOSTIC_BYTES]; + char error_operation[POCKETJS_NET_FORMAL_SMOKE_DIAGNOSTIC_BYTES]; +} pocketjs_net_formal_smoke_report_t; + +/** NUL-terminated factory storage; factory_length excludes the terminator. */ +extern const uint8_t *const pocketjs_net_formal_smoke_factory_bytes; +extern const size_t pocketjs_net_formal_smoke_factory_length; +extern const size_t pocketjs_net_formal_smoke_factory_storage_length; + +/** Full textual Build Plan hash plus the exact 32-byte handshake digest. */ +extern const char pocketjs_net_formal_smoke_plan_hash[]; +extern const uint8_t pocketjs_net_formal_smoke_plan_hash_bytes + [POCKETJS_NETWORK_V1_PLAN_HASH_BYTES]; + +/** Strictly increasing exact network feature projection for the handshake. */ +extern const pocketjs_network_v1_feature_id_t + pocketjs_net_formal_smoke_feature_ids[]; +extern const uint16_t pocketjs_net_formal_smoke_feature_count; + +/** Exact provider selection copied from the verified Build Plan. */ +extern const char pocketjs_net_formal_smoke_http_client_backend_id[]; +extern const char pocketjs_net_formal_smoke_net_driver_id[]; + +/** SHA-256 of factory_length source bytes, excluding the trailing NUL. */ +extern const char pocketjs_net_formal_smoke_factory_sha256[]; +extern const uint8_t pocketjs_net_formal_smoke_factory_sha256_bytes[32]; + +extern const pocketjs_net_formal_smoke_endpoint_t + pocketjs_net_formal_smoke_endpoint; + +/** Global function installed by the Guest; each call returns a frozen report. + */ +extern const char pocketjs_net_formal_smoke_report_global[]; + +/** + * Call and strictly snapshot the test-only report on the Guest owner task. + * No eval string is used. The helper rejects proxies, accessors, wrong field + * types, out-of-range counters, and inconsistent terminal states. + */ +esp_err_t pocketjs_net_formal_smoke_read_report( + pocketjs_esp_guest_t *guest, + pocketjs_net_formal_smoke_report_t *out_report); + +/** + * Owner-task configuration for the shared two-board hardware smoke. + * configured_origin must exactly equal + * pocketjs_net_formal_smoke_endpoint.origin. This function exclusively uses + * FreeRTOS task notification index zero while it runs. All QuickJS allocations + * are required to come from external RAM. + */ +typedef struct { + const char *configured_origin; + size_t guest_memory_limit_bytes; + size_t guest_stack_limit_bytes; + uint64_t guest_execution_timeout_us; + uint32_t guest_max_interrupt_checks; + uint32_t overall_timeout_ms; + /** Emit a fail-stop diagnostic after this interval; cleanup still runs. */ + uint32_t shutdown_warning_ms; +} pocketjs_net_formal_smoke_run_config_t; + +typedef struct { + pocketjs_net_formal_smoke_report_t report; + pocketjs_esp_guest_stats_t guest; + pocketjs_net_esp_runtime_stats_t runtime; + uint64_t service_turns; + uint64_t guest_jobs_executed; + uint64_t elapsed_ms; + size_t owner_stack_low_water_bytes; + bool shutdown_complete; +} pocketjs_net_formal_smoke_run_result_t; + +/** + * Create the Guest and formal runtime, mount the shared factory, run its + * headless service loop, then always attempt the three-phase shutdown. + * The caller must be a dedicated non-tcpip owner task with external RAM. + * This function never calls globalThis.frame. Once a runtime exists it does + * not return until native shutdown and Guest destruction are safe; a broken + * native subsystem therefore leaves the owner task in fail-stop cleanup. + */ +esp_err_t pocketjs_net_formal_smoke_run( + const pocketjs_net_formal_smoke_run_config_t *config, + pocketjs_net_formal_smoke_run_result_t *out_result); + +#ifdef __cplusplus +} +#endif diff --git a/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/manifest.json b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/manifest.json new file mode 100644 index 00000000..fd853ff6 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/manifest.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://pocketjs.dev/schema/pocket-3.json", + "pocket": 3, + "id": "dev.pocketjs.esp-formal-network-smoke", + "name": "esp-formal-network-smoke", + "title": "ESP formal network smoke", + "version": "1.0.0", + "engine": { + "capabilities": { + "requires": [ + "network.http.client" + ] + } + }, + "permissions": { + "network": { + "connect": [ + { + "protocol": "http", + "host": "172.16.10.126", + "port": 8088 + } + ], + "listen": [], + "localNetwork": true, + "insecureTransport": true, + "broadcast": false, + "multicast": false, + "allowInvalidTlsForDevelopment": false, + "browserAmbientCredentials": false, + "browserOpaqueWebSocketRedirects": false, + "credentials": [] + } + }, + "resources": { + "network": { + "minimum": { + "runtime": { + "connections": 1, + "pendingOperations": 1, + "completionDescriptors": 8, + "nativeBufferBytes": 65536 + }, + "http": { + "connections": 1, + "inflightRequests": 1, + "headerBytes": 4096, + "headerCount": 16, + "bufferedBodyBytes": 4096 + } + } + } + }, + "app": { + "entry": "app.ts", + "output": "esp-formal-network-smoke", + "framework": "solid", + "viewport": { + "logical": [ + 1, + 1 + ], + "presentation": "native" + } + } +} diff --git a/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/runtime_probe.ts b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/runtime_probe.ts new file mode 100644 index 00000000..222c29dd --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/runtime_probe.ts @@ -0,0 +1,164 @@ +import { join } from "node:path"; +import { + NETWORK_V1_ABI_MAJOR, + NETWORK_V1_ABI_MINOR, + NETWORK_V1_LIMIT_PROTOCOL_ANY, + NETWORK_V1_LIMIT_ROLE_ANY, + NetworkV1CompletionPollStatus, + NetworkV1DispatchStatus, + NetworkV1ErrorCategory, + NetworkV1ErrorCode, + NetworkV1LimitProtocol, + NetworkV1LimitRole, + type NetworkV1BindingTable, + type NetworkV1FeatureId, +} from "../../../../contracts/spec/network/network-v1.ts"; + +const generated = process.env.POCKETJS_TEST_ARTIFACT_DIR; +if (generated === undefined || generated.length === 0) { + throw new Error("POCKETJS_TEST_ARTIFACT_DIR is required"); +} +const metadata = await Bun.file(join(generated, "metadata.json")).json() as { + planHashBytes: number[]; + featureIds: number[]; + reportGlobal: string; +}; +const binary = new Uint8Array(await Bun.file( + join(generated, "factory.js.bin"), +).arrayBuffer()); +if (binary.length < 2 || binary[binary.length - 1] !== 0) { + throw new Error("factory storage is not NUL-terminated"); +} + +const featureIds = Object.freeze( + metadata.featureIds.slice() as NetworkV1FeatureId[], +); +const handshake = Object.freeze({ + abiMajor: NETWORK_V1_ABI_MAJOR, + abiMinor: NETWORK_V1_ABI_MINOR, + runtimeGeneration: 1, + planHash: Uint8Array.from(metadata.planHashBytes), + featureIds, +}); +const completed = () => Object.freeze({ status: NetworkV1DispatchStatus.Completed }); +const binding = Object.freeze({ + handshake, + getLimits(query: { runtimeGeneration: number; protocol: number; role: number }) { + const admitted = ( + query.protocol === NETWORK_V1_LIMIT_PROTOCOL_ANY || + query.protocol === NetworkV1LimitProtocol.Http + ) && ( + query.role === NETWORK_V1_LIMIT_ROLE_ANY || + query.role === NetworkV1LimitRole.Client + ); + return Object.freeze({ + runtimeGeneration: 1, + protocol: query.protocol, + role: query.role, + values: Object.freeze(admitted ? [ + Object.freeze({ + name: "http.bufferedBodyBytes", + default: 4096, + hard: 16384, + minimum: 4096, + }), + Object.freeze({ + name: "http.headerBytes", + default: 4096, + hard: 8192, + minimum: 4096, + }), + Object.freeze({ + name: "http.maxBodyChunkBytes", + default: 2048, + hard: 2048, + minimum: 512, + }), + Object.freeze({ + name: "http.maxOperations", + default: 1, + hard: 1, + minimum: 1, + }), + Object.freeze({ + name: "runtime.nativeBufferBytes", + default: 65536, + hard: 524288, + minimum: 65536, + }), + ] : []), + featureIds: Object.freeze(admitted ? featureIds.slice() : []), + }); + }, + dispatch() { + return Object.freeze({ + status: NetworkV1DispatchStatus.Refused, + error: Object.freeze({ + category: NetworkV1ErrorCategory.Runtime, + code: NetworkV1ErrorCode.PermissionDenied, + operation: "http.request.start", + temporary: false, + }), + }); + }, + nextCompletion() { + return Object.freeze({ + status: NetworkV1CompletionPollStatus.Drained, + payloadBytesDelivered: 0, + }); + }, + leaseTake(command: { byteLength: number }) { + return Object.freeze({ + status: NetworkV1DispatchStatus.Completed, + byteLength: command.byteLength, + }); + }, + leaseReadInto(command: { maxBytes: number }) { + return Object.freeze({ + status: NetworkV1DispatchStatus.Completed, + bytesCopied: command.maxBytes, + }); + }, + leaseRelease: completed, + registerServiceDispatcher(dispatcher: unknown) { + if (typeof dispatcher !== "function") { + throw new TypeError("service dispatcher was not installed"); + } + }, +}) as unknown as NetworkV1BindingTable; + +const source = new TextDecoder().decode(binary.subarray(0, binary.length - 1)); +const factory = (0, eval)(source) as (binding: NetworkV1BindingTable) => unknown; +if (typeof factory !== "function" || factory.length !== 0) { + throw new Error("generated artifact did not evaluate to the factory ABI"); +} +if (Object.hasOwn(globalThis, metadata.reportGlobal) || Object.hasOwn(globalThis, "frame")) { + throw new Error("application initialized before the factory call"); +} +factory(binding); +for (let checkpoint = 0; checkpoint < 20; checkpoint += 1) { + await Promise.resolve(); +} + +const reportFunction = (globalThis as Record)[metadata.reportGlobal]; +if (typeof reportFunction !== "function" || typeof globalThis.frame !== "function") { + throw new Error("factory did not install its report and legacy frame slots"); +} +const report = (reportFunction as () => Record)(); +if ( + !Object.isFrozen(report) || + report.version !== 1 || + report.phase !== "failed" || + report.done !== true || + report.ok !== false || + report.roundsTotal !== 20 || + report.roundsStarted !== 1 || + report.roundsPassed !== 0 || + report.requestsPassed !== 0 || + report.frameCalls !== 0 || + report.errorCode !== "permission_denied" +) { + throw new Error(`unexpected refusal report: ${JSON.stringify(report)}`); +} + +console.log(JSON.stringify(report)); diff --git a/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/src/formal_smoke_report.c b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/src/formal_smoke_report.c new file mode 100644 index 00000000..7044ec9f --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/src/formal_smoke_report.c @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: MIT + +#include "pocketjs/net/formal_smoke_artifact.h" + +#include +#include + +#include "quickjs.h" + +#define REPORT_ENUMERABLE_FIELDS 13U + +static void free_descriptor(JSContext *context, + JSPropertyDescriptor *descriptor) { + JS_FreeValue(context, descriptor->value); + JS_FreeValue(context, descriptor->getter); + JS_FreeValue(context, descriptor->setter); +} + +static bool own_data_property(JSContext *context, JSValueConst object, + const char *name, JSValue *out_value) { + *out_value = JS_UNDEFINED; + if (!JS_IsObject(object) || JS_IsProxy(object)) { + return false; + } + JSAtom atom = JS_NewAtom(context, name); + if (atom == JS_ATOM_NULL) { + return false; + } + JSPropertyDescriptor descriptor = {0}; + const int result = JS_GetOwnProperty(context, &descriptor, object, atom); + JS_FreeAtom(context, atom); + if (result != 1 || (descriptor.flags & JS_PROP_GETSET) != 0) { + if (result == 1) { + free_descriptor(context, &descriptor); + } + return false; + } + *out_value = descriptor.value; + JS_FreeValue(context, descriptor.getter); + JS_FreeValue(context, descriptor.setter); + return true; +} + +static bool read_u32(JSContext *context, JSValueConst object, const char *name, + uint32_t maximum, uint32_t *out_value) { + JSValue value = JS_UNDEFINED; + if (!own_data_property(context, object, name, &value) || + !JS_IsNumber(value)) { + JS_FreeValue(context, value); + return false; + } + double number = 0.0; + const bool valid = JS_ToFloat64(context, &number, value) == 0 && + isfinite(number) && number >= 0.0 && + number <= (double)maximum && + number == floor(number); + JS_FreeValue(context, value); + if (!valid) { + return false; + } + *out_value = (uint32_t)number; + return true; +} + +static bool read_bool(JSContext *context, JSValueConst object, const char *name, + bool *out_value) { + JSValue value = JS_UNDEFINED; + if (!own_data_property(context, object, name, &value) || !JS_IsBool(value)) { + JS_FreeValue(context, value); + return false; + } + const int result = JS_ToBool(context, value); + JS_FreeValue(context, value); + if (result < 0) { + return false; + } + *out_value = result != 0; + return true; +} + +static bool read_ascii(JSContext *context, JSValueConst object, + const char *name, char *out_value, + size_t output_capacity) { + JSValue value = JS_UNDEFINED; + if (!own_data_property(context, object, name, &value) || + !JS_IsString(value)) { + JS_FreeValue(context, value); + return false; + } + size_t length = 0U; + const char *text = JS_ToCStringLen2(context, &length, value, false); + JS_FreeValue(context, value); + if (text == NULL) { + return false; + } + bool valid = length < output_capacity; + for (size_t index = 0U; valid && index < length; ++index) { + const unsigned char byte = (unsigned char)text[index]; + valid = byte >= 0x20U && byte <= 0x7eU; + } + if (valid) { + memcpy(out_value, text, length); + out_value[length] = '\0'; + } + JS_FreeCString(context, text); + return valid; +} + +static bool read_phase(JSContext *context, JSValueConst report, + pocketjs_net_formal_smoke_phase_t *out_phase) { + char phase[16] = {0}; + if (!read_ascii(context, report, "phase", phase, sizeof(phase))) { + return false; + } + if (strcmp(phase, "starting") == 0) { + *out_phase = POCKETJS_NET_FORMAL_SMOKE_PHASE_STARTING; + } else if (strcmp(phase, "health") == 0) { + *out_phase = POCKETJS_NET_FORMAL_SMOKE_PHASE_HEALTH; + } else if (strcmp(phase, "echo") == 0) { + *out_phase = POCKETJS_NET_FORMAL_SMOKE_PHASE_ECHO; + } else if (strcmp(phase, "passed") == 0) { + *out_phase = POCKETJS_NET_FORMAL_SMOKE_PHASE_PASSED; + } else if (strcmp(phase, "failed") == 0) { + *out_phase = POCKETJS_NET_FORMAL_SMOKE_PHASE_FAILED; + } else { + return false; + } + return true; +} + +static bool exact_enumerable_field_count(JSContext *context, + JSValueConst report) { + JSPropertyEnum *properties = NULL; + uint32_t count = 0U; + if (JS_GetOwnPropertyNames(context, &properties, &count, report, + JS_GPN_STRING_MASK | JS_GPN_ENUM_ONLY) < 0) { + return false; + } + for (uint32_t index = 0U; index < count; ++index) { + JS_FreeAtom(context, properties[index].atom); + } + js_free(context, properties); + return count == REPORT_ENUMERABLE_FIELDS; +} + +static bool parse_report(JSContext *context, JSValueConst value, + pocketjs_net_formal_smoke_report_t *out_report) { + uint32_t version = 0U; + uint32_t rounds_total = 0U; + if (!JS_IsObject(value) || JS_IsProxy(value) || + !exact_enumerable_field_count(context, value) || + !read_u32(context, value, "version", 1U, &version) || version != 1U || + !read_u32(context, value, "checkpoint", UINT32_MAX, + &out_report->checkpoint) || + !read_phase(context, value, &out_report->phase) || + !read_u32(context, value, "roundsTotal", + POCKETJS_NET_FORMAL_SMOKE_ROUNDS, &rounds_total) || + rounds_total != POCKETJS_NET_FORMAL_SMOKE_ROUNDS || + !read_u32(context, value, "roundsStarted", + POCKETJS_NET_FORMAL_SMOKE_ROUNDS, + &out_report->rounds_started) || + !read_u32(context, value, "roundsPassed", + POCKETJS_NET_FORMAL_SMOKE_ROUNDS, + &out_report->rounds_passed) || + !read_u32(context, value, "requestsPassed", + POCKETJS_NET_FORMAL_SMOKE_REQUESTS, + &out_report->requests_passed) || + !read_u32(context, value, "frameCalls", UINT32_MAX, + &out_report->frame_calls) || + !read_bool(context, value, "done", &out_report->done) || + !read_bool(context, value, "ok", &out_report->ok) || + !read_ascii(context, value, "errorName", out_report->error_name, + sizeof(out_report->error_name)) || + !read_ascii(context, value, "errorCode", out_report->error_code, + sizeof(out_report->error_code)) || + !read_ascii(context, value, "errorOperation", + out_report->error_operation, + sizeof(out_report->error_operation))) { + return false; + } + + if (out_report->rounds_passed > out_report->rounds_started || + out_report->requests_passed < out_report->rounds_passed * 2U || + out_report->requests_passed > out_report->rounds_started * 2U) { + return false; + } + if (out_report->done) { + if (out_report->ok) { + return out_report->phase == POCKETJS_NET_FORMAL_SMOKE_PHASE_PASSED && + out_report->rounds_started == + POCKETJS_NET_FORMAL_SMOKE_ROUNDS && + out_report->rounds_passed == POCKETJS_NET_FORMAL_SMOKE_ROUNDS && + out_report->requests_passed == + POCKETJS_NET_FORMAL_SMOKE_REQUESTS && + out_report->error_name[0] == '\0' && + out_report->error_code[0] == '\0' && + out_report->error_operation[0] == '\0'; + } + return out_report->phase == POCKETJS_NET_FORMAL_SMOKE_PHASE_FAILED; + } + return !out_report->ok && + out_report->phase != POCKETJS_NET_FORMAL_SMOKE_PHASE_PASSED && + out_report->phase != POCKETJS_NET_FORMAL_SMOKE_PHASE_FAILED; +} + +esp_err_t pocketjs_net_formal_smoke_read_report( + pocketjs_esp_guest_t *guest, + pocketjs_net_formal_smoke_report_t *out_report) { + if (guest == NULL || out_report == NULL) { + return ESP_ERR_INVALID_ARG; + } + memset(out_report, 0, sizeof(*out_report)); + JSContext *context = pocketjs_esp_guest_context(guest); + if (context == NULL) { + return ESP_ERR_INVALID_STATE; + } + + JSValue global = JS_GetGlobalObject(context); + JSValue function = JS_UNDEFINED; + JSValue result = JS_UNDEFINED; + if (JS_IsException(global) || + !own_data_property(context, global, + pocketjs_net_formal_smoke_report_global, &function) || + !JS_IsFunction(context, function)) { + JS_FreeValue(context, function); + JS_FreeValue(context, global); + if (JS_HasException(context)) { + pocketjs_esp_guest_log_exception(guest, "formal_smoke_report_lookup"); + } + return ESP_ERR_INVALID_STATE; + } + + const esp_err_t call_result = pocketjs_esp_guest_call_function( + guest, "formal_smoke_report", function, global, 0U, NULL, &result); + JS_FreeValue(context, function); + JS_FreeValue(context, global); + if (call_result != ESP_OK) { + JS_FreeValue(context, result); + return call_result; + } + + const bool valid = parse_report(context, result, out_report); + JS_FreeValue(context, result); + if (!valid) { + memset(out_report, 0, sizeof(*out_report)); + if (JS_HasException(context)) { + pocketjs_esp_guest_log_exception(guest, "formal_smoke_report_parse"); + } + return ESP_ERR_INVALID_RESPONSE; + } + return ESP_OK; +} diff --git a/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/src/formal_smoke_runner.c b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/src/formal_smoke_runner.c new file mode 100644 index 00000000..2431858e --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/src/formal_smoke_runner.c @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: MIT + +#include "pocketjs/net/formal_smoke_artifact.h" + +#include +#include + +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "lwip/ip4_addr.h" +#include "quickjs.h" + +#define FORMAL_SMOKE_RUNTIME_GENERATION 1U +#define FORMAL_SMOKE_MAX_OPERATIONS 1U +#define FORMAL_SMOKE_SERVICE_NATIVE_STEPS 16U +#define FORMAL_SMOKE_SERVICE_COMPLETIONS 16U +#define FORMAL_SMOKE_SERVICE_EVENTS 16U +#define FORMAL_SMOKE_SERVICE_PAYLOAD_BYTES 4096U +#define FORMAL_SMOKE_GUEST_JOBS 32U +#define FORMAL_SMOKE_WAIT_MS 10U +#define FORMAL_SMOKE_CONNECT_TIMEOUT_US UINT64_C(10000000) +#define FORMAL_SMOKE_HEADERS_TIMEOUT_US UINT64_C(10000000) +#define FORMAL_SMOKE_IDLE_TIMEOUT_US UINT64_C(10000000) +#define FORMAL_SMOKE_TOTAL_TIMEOUT_US UINT64_C(30000000) + +static const char *const TAG = "pocketjs_formal_smoke"; + +typedef struct { + TaskHandle_t owner_task; + uint32_t expected_ipv4_be; +} formal_smoke_host_context_t; + +static uint64_t monotonic_us(void) { + const int64_t now = esp_timer_get_time(); + return now > 0 ? (uint64_t)now : UINT64_C(1); +} + +static uint64_t deadline_after_ms(uint64_t now_us, uint32_t milliseconds) { + const uint64_t delta = (uint64_t)milliseconds * UINT64_C(1000); + return delta > UINT64_MAX - now_us ? UINT64_MAX : now_us + delta; +} + +static void wake_owner(void *context) { + formal_smoke_host_context_t *host = context; + if (host != NULL && host->owner_task != NULL) { + xTaskNotifyGive(host->owner_task); + } +} + +static bool +allow_endpoint(void *context, + const pocketjs_net_http_client_endpoint_t *endpoint) { + const formal_smoke_host_context_t *host = context; + if (host == NULL || endpoint == NULL || + endpoint->scheme != POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTP || + endpoint->hostname == NULL || + strcmp(endpoint->hostname, pocketjs_net_formal_smoke_endpoint.host) != + 0 || + endpoint->port != pocketjs_net_formal_smoke_endpoint.port) { + return false; + } + if (endpoint->phase == POCKETJS_NET_HTTP_CLIENT_PERMISSION_HOSTNAME) { + return endpoint->ipv4_be == 0U; + } + return endpoint->phase == + POCKETJS_NET_HTTP_CLIENT_PERMISSION_NUMERIC_CANDIDATE && + endpoint->ipv4_be == host->expected_ipv4_be; +} + +static bool valid_config(const pocketjs_net_formal_smoke_run_config_t *config, + pocketjs_net_formal_smoke_run_result_t *out_result) { + const pocketjs_net_esp_runtime_descriptor_t *descriptor = + pocketjs_net_esp_runtime_descriptor(); + size_t required_native_bytes = 0U; + return config != NULL && out_result != NULL && descriptor != NULL && + descriptor->pocketjs_owned_native_buffer_floor_enforced && + pocketjs_net_esp_runtime_required_native_buffer_bytes( + FORMAL_SMOKE_MAX_OPERATIONS, &required_native_bytes) && + required_native_bytes <= 524288U && + config->configured_origin != NULL && + strcmp(config->configured_origin, + pocketjs_net_formal_smoke_endpoint.origin) == 0 && + config->guest_memory_limit_bytes >= + POCKETJS_NET_FORMAL_SMOKE_MIN_GUEST_MEMORY_BYTES && + config->guest_stack_limit_bytes >= + POCKETJS_NET_FORMAL_SMOKE_MIN_GUEST_STACK_BYTES && + config->guest_execution_timeout_us != 0U && + config->guest_max_interrupt_checks != 0U && + config->overall_timeout_ms != 0U && + config->shutdown_warning_ms != 0U && + pocketjs_net_formal_smoke_factory_bytes != NULL && + pocketjs_net_formal_smoke_factory_storage_length == + pocketjs_net_formal_smoke_factory_length + 1U && + pocketjs_net_formal_smoke_factory_bytes + [pocketjs_net_formal_smoke_factory_length] == 0U && + pocketjs_net_formal_smoke_feature_count == 1U && + pocketjs_net_formal_smoke_feature_ids[0] == + POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT; +} + +static bool runtime_idle(const pocketjs_net_esp_runtime_stats_t *stats) { + return stats->active_operations == 0U && stats->pending_core_events == 0U && + stats->queued_leases == 0U && stats->taken_leases == 0U; +} + +static esp_err_t service_once(pocketjs_net_esp_runtime_t *runtime, + pocketjs_esp_guest_t *guest, + pocketjs_net_formal_smoke_run_result_t *result, + bool *out_more_ready, bool *out_jobs_pending) { + pocketjs_net_esp_runtime_service_result_t service = {0}; + esp_err_t status = pocketjs_net_esp_runtime_service( + runtime, monotonic_us(), FORMAL_SMOKE_SERVICE_NATIVE_STEPS, + FORMAL_SMOKE_SERVICE_COMPLETIONS, FORMAL_SMOKE_SERVICE_EVENTS, + FORMAL_SMOKE_SERVICE_PAYLOAD_BYTES, &service); + ++result->service_turns; + if (status != ESP_OK) { + *out_more_ready = true; + *out_jobs_pending = false; + return status; + } + + size_t executed = 0U; + bool pending = false; + status = pocketjs_esp_guest_execute_jobs(guest, FORMAL_SMOKE_GUEST_JOBS, + &executed, &pending); + result->guest_jobs_executed += executed; + *out_more_ready = + service.status != POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_DRAINED; + *out_jobs_pending = pending; + return status; +} + +static esp_err_t +shutdown_runtime(pocketjs_net_esp_runtime_t *runtime, + pocketjs_esp_guest_t *guest, uint32_t warning_ms, + pocketjs_net_formal_smoke_run_result_t *result) { + esp_err_t first_error = ESP_OK; + esp_err_t status = + pocketjs_net_esp_runtime_begin_shutdown(runtime, monotonic_us()); + if (status != ESP_OK) { + first_error = status; + } + const uint64_t warning_deadline = + deadline_after_ms(monotonic_us(), warning_ms); + bool warning_logged = false; + while (!pocketjs_net_esp_runtime_is_ready_to_destroy(runtime)) { + if (!warning_logged && monotonic_us() >= warning_deadline) { + ESP_LOGE(TAG, "POCKET_NET_FORMAL_SHUTDOWN_FAIL_STOP first_error=%s", + esp_err_to_name(first_error)); + warning_logged = true; + } + bool more_ready = false; + bool jobs_pending = false; + status = service_once(runtime, guest, result, &more_ready, &jobs_pending); + if (status != ESP_OK && first_error == ESP_OK) { + first_error = status; + } + if (!more_ready && !jobs_pending) { + (void)ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(FORMAL_SMOKE_WAIT_MS)); + } + } + status = pocketjs_net_esp_runtime_get_stats(runtime, &result->runtime); + if (status != ESP_OK && first_error == ESP_OK) { + first_error = status; + } + while ((status = pocketjs_net_esp_runtime_destroy(runtime)) != ESP_OK) { + if (first_error == ESP_OK) { + first_error = status; + } + ESP_LOGE(TAG, "POCKET_NET_FORMAL_DESTROY_FAIL_STOP esp_err=%s", + esp_err_to_name(status)); + vTaskDelay(pdMS_TO_TICKS(FORMAL_SMOKE_WAIT_MS)); + } + result->shutdown_complete = true; + return first_error; +} + +esp_err_t pocketjs_net_formal_smoke_run( + const pocketjs_net_formal_smoke_run_config_t *config, + pocketjs_net_formal_smoke_run_result_t *out_result) { + if (out_result == NULL) { + return ESP_ERR_INVALID_ARG; + } + memset(out_result, 0, sizeof(*out_result)); + if (!valid_config(config, out_result) || + heap_caps_get_total_size(MALLOC_CAP_SPIRAM) == 0U) { + return ESP_ERR_INVALID_ARG; + } + const uint64_t started_us = monotonic_us(); + esp_err_t run_error = ESP_OK; + pocketjs_esp_guest_t *guest = NULL; + pocketjs_net_esp_runtime_t *runtime = NULL; + JSValue binding = JS_UNDEFINED; + bool binding_owned = false; + + formal_smoke_host_context_t host = { + .owner_task = xTaskGetCurrentTaskHandle(), + }; + ip4_addr_t expected_address = {0}; + if (host.owner_task == NULL || + !ip4addr_aton(pocketjs_net_formal_smoke_endpoint.host, + &expected_address)) { + return ESP_ERR_INVALID_ARG; + } + host.expected_ipv4_be = expected_address.addr; + (void)ulTaskNotifyTake(pdTRUE, 0U); + + const pocketjs_esp_guest_config_t guest_config = { + .memory_limit_bytes = config->guest_memory_limit_bytes, + .stack_limit_bytes = config->guest_stack_limit_bytes, + .allocate_in_external_memory = true, + .execution_timeout_us = config->guest_execution_timeout_us, + .max_interrupt_checks = config->guest_max_interrupt_checks, + }; + run_error = pocketjs_esp_guest_create(&guest_config, &guest); + if (run_error != ESP_OK) { + goto finish; + } + + pocketjs_net_esp_runtime_config_t runtime_config = { + .guest = guest, + .runtime_generation = FORMAL_SMOKE_RUNTIME_GENERATION, + .feature_ids = pocketjs_net_formal_smoke_feature_ids, + .feature_count = pocketjs_net_formal_smoke_feature_count, + .providers = + { + .http_client_backend_id = + pocketjs_net_formal_smoke_http_client_backend_id, + .net_driver_id = pocketjs_net_formal_smoke_net_driver_id, + .http_client_tls_source = + POCKETJS_NET_ESP_RUNTIME_TLS_SELECTION_NONE, + }, + .admission = POCKETJS_NET_ESP_RUNTIME_ADMISSION_TEST_ONLY, + .max_operations = FORMAL_SMOKE_MAX_OPERATIONS, + .limits = + { + .buffered_body_bytes = {4096U, 4096U, 16384U}, + .header_bytes = {4096U, 4096U, 8192U}, + .max_body_chunk_bytes = {512U, 2048U, 2048U}, + .max_operations = {1U, 1U, 1U}, + .native_buffer_bytes = {65536U, 524288U, 524288U}, + }, + .connect_timeout_us = FORMAL_SMOKE_CONNECT_TIMEOUT_US, + .headers_timeout_us = FORMAL_SMOKE_HEADERS_TIMEOUT_US, + .idle_timeout_us = FORMAL_SMOKE_IDLE_TIMEOUT_US, + .total_timeout_us = FORMAL_SMOKE_TOTAL_TIMEOUT_US, + .wake = wake_owner, + .wake_context = &host, + .allow_endpoint = allow_endpoint, + .permission_context = &host, + }; + memcpy(runtime_config.plan_hash, pocketjs_net_formal_smoke_plan_hash_bytes, + sizeof(runtime_config.plan_hash)); + run_error = pocketjs_net_esp_runtime_create(&runtime_config, &runtime); + if (run_error != ESP_OK) { + goto finish; + } + run_error = pocketjs_net_esp_runtime_get_binding(runtime, &binding); + if (run_error != ESP_OK) { + goto shutdown; + } + binding_owned = true; + run_error = pocketjs_esp_guest_mount_factory( + guest, "esp-formal-network-smoke.js", + (const char *)pocketjs_net_formal_smoke_factory_bytes, + pocketjs_net_formal_smoke_factory_length, binding, NULL); + JS_FreeValue(pocketjs_esp_guest_context(guest), binding); + binding_owned = false; + if (run_error != ESP_OK) { + goto shutdown; + } + + const uint64_t deadline = + deadline_after_ms(started_us, config->overall_timeout_ms); + bool application_complete = false; + while (monotonic_us() < deadline) { + bool more_ready = false; + bool jobs_pending = false; + run_error = + service_once(runtime, guest, out_result, &more_ready, &jobs_pending); + if (run_error != ESP_OK) { + break; + } + run_error = + pocketjs_net_formal_smoke_read_report(guest, &out_result->report); + if (run_error != ESP_OK) { + break; + } + run_error = + pocketjs_net_esp_runtime_get_stats(runtime, &out_result->runtime); + if (run_error != ESP_OK) { + break; + } + if (out_result->report.done && !jobs_pending && + runtime_idle(&out_result->runtime)) { + application_complete = true; + break; + } + if (!more_ready && !jobs_pending) { + (void)ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(FORMAL_SMOKE_WAIT_MS)); + } + } + if (!application_complete && run_error == ESP_OK) { + run_error = ESP_ERR_TIMEOUT; + } + +shutdown: { + const esp_err_t shutdown_error = + shutdown_runtime(runtime, guest, config->shutdown_warning_ms, out_result); + runtime = NULL; + if (run_error == ESP_OK && shutdown_error != ESP_OK) { + run_error = shutdown_error; + } +} + +finish: + if (binding_owned && guest != NULL) { + JS_FreeValue(pocketjs_esp_guest_context(guest), binding); + } + if (runtime == NULL && guest != NULL) { + const esp_err_t stats_error = + pocketjs_esp_guest_get_stats(guest, &out_result->guest); + if (run_error == ESP_OK && stats_error != ESP_OK) { + run_error = stats_error; + } + pocketjs_esp_guest_destroy(guest); + } + out_result->owner_stack_low_water_bytes = + uxTaskGetStackHighWaterMark(NULL) * sizeof(StackType_t); + out_result->elapsed_ms = (monotonic_us() - started_us) / UINT64_C(1000); + + const bool successful = + run_error == ESP_OK && out_result->shutdown_complete && + out_result->report.done && out_result->report.ok && + out_result->report.frame_calls == 0U && + out_result->report.rounds_passed == POCKETJS_NET_FORMAL_SMOKE_ROUNDS && + out_result->report.requests_passed == + POCKETJS_NET_FORMAL_SMOKE_REQUESTS && + out_result->runtime.active_operations == 0U && + out_result->runtime.pending_core_events == 0U && + out_result->runtime.queued_leases == 0U && + out_result->runtime.taken_leases == 0U && + out_result->runtime.leases_taken == out_result->runtime.leases_released && + out_result->runtime.poison_flags == 0U && + out_result->runtime.requests_started == + POCKETJS_NET_FORMAL_SMOKE_REQUESTS; + if (!successful && run_error == ESP_OK) { + run_error = ESP_ERR_INVALID_RESPONSE; + } + ESP_LOGI(TAG, + "POCKET_NET_FORMAL_RUN status=%s rounds=%u/%u requests=%u/%u " + "frame_calls=%u service_turns=%llu jobs=%llu shutdown=%d " + "poison=0x%08x elapsed_ms=%llu", + esp_err_to_name(run_error), + (unsigned)out_result->report.rounds_passed, + (unsigned)POCKETJS_NET_FORMAL_SMOKE_ROUNDS, + (unsigned)out_result->report.requests_passed, + (unsigned)POCKETJS_NET_FORMAL_SMOKE_REQUESTS, + (unsigned)out_result->report.frame_calls, + (unsigned long long)out_result->service_turns, + (unsigned long long)out_result->guest_jobs_executed, + out_result->shutdown_complete, out_result->runtime.poison_flags, + (unsigned long long)out_result->elapsed_ms); + return run_error; +} diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/CMakeLists.txt new file mode 100644 index 00000000..76ec8f84 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/CMakeLists.txt @@ -0,0 +1,35 @@ +set(_pocketjs_tls_smoke + "${CMAKE_CURRENT_LIST_DIR}/../pocketjs_net_formal_tls_smoke_artifact") + +include("${CMAKE_CURRENT_LIST_DIR}/../../cmake/pocketjs_generate_test_artifact.cmake") +pocketjs_generate_test_artifact( + _pocketjs_generated + "${CMAKE_CURRENT_LIST_DIR}/generate.ts" +) + +idf_component_register( + SRCS + "${_pocketjs_generated}/formal_tls_smoke_metadata.c" + "${_pocketjs_tls_smoke}/src/formal_tls_smoke_ipv4.c" + "${_pocketjs_tls_smoke}/src/formal_tls_smoke_report.c" + "${_pocketjs_tls_smoke}/src/formal_tls_smoke_runner.c" + INCLUDE_DIRS + "${_pocketjs_tls_smoke}/include" + "../../../../contracts/spec/network/generated" + PRIV_INCLUDE_DIRS + "${_pocketjs_tls_smoke}/private" + REQUIRES + pocketjs_esp_guest + pocketjs_net_esp_runtime + esp_timer + lwip + quickjs-ng +) + +target_add_binary_data( + ${COMPONENT_LIB} + "${_pocketjs_generated}/factory.js.bin" + BINARY +) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/README.md b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/README.md new file mode 100644 index 00000000..c175a00c --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/README.md @@ -0,0 +1,32 @@ +# PocketJS formal ESP TLS conformance artifact + +This test-only component runs a second immutable Guest artifact against the +independent Mac HTTPS peer. It keeps the validated repeated-handshake soak +factory unchanged and exercises **redirect modes, chunked responses and +trailers, 4xx/5xx responses, strict malformed framing, HEAD, fixed and streamed +uploads, connection reuse, and no hidden retry** over TLS 1.2. + +The component deliberately reuses the audited owner-only C runner and report +parser from `pocketjs_net_formal_tls_smoke_artifact`, but supplies a distinct +verified Build Plan and factory. The two components export the same runner API +and therefore are mutually exclusive in one firmware image. A board harness +selects exactly one in `EXTRA_COMPONENT_DIRS` and `REQUIRES`. + +This is not a public capability target. The compiler permit remains restricted +to the exact test entry and verified plan, all native descriptors continue to +advertise `public_capability=false`, and the production target registry remains +unchanged. + +ESP-IDF generates the artifact into the component's build directory during +configuration. To inspect the deterministic outputs without changing the +source tree, generate them into a temporary directory: + +```sh +bun hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/generate.ts \ + --output-dir=/tmp/pocketjs-formal-tls-conformance-artifact +bun test tests/esp-network-artifacts.test.ts +``` + +**Generated plans, metadata C, and factory bundles are build outputs and are +not committed.** The consolidated artifact test regenerates all profiles in an +isolated directory and locks their hashes and admission rules. diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/app.ts b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/app.ts new file mode 100644 index 00000000..75918291 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/app.ts @@ -0,0 +1,309 @@ +import { fetch } from "@pocketjs/framework/net/http"; + +const REPORT_GLOBAL = "__pocketjsFormalNetworkTlsSmokeReportV1"; +const CANCEL_GLOBAL = "__pocketjsFormalNetworkTlsSmokeCancelV1"; +const ORIGIN = "https://pocketjs.test:8443"; +const ROUNDS_TOTAL = 20; +const HEALTH_BODY = + '{"peer":"mac","protocol":"pocketjs-independent-peer-v1","status":"ok"}'; +const CHUNKED_BODY = "PocketJS-independent-peer"; +const TLS_POLICY = Object.freeze({ + serverName: "pocketjs.test", + minVersion: "1.2" as const, + maxVersion: "1.2" as const, + verification: "full" as const, + revocation: "host-default" as const, +}); + +type SmokePhase = "starting" | "health" | "echo" | "passed" | "failed"; + +interface SmokeState { + version: 1; + checkpoint: number; + phase: SmokePhase; + roundsTotal: number; + roundsStarted: number; + roundsPassed: number; + requestsPassed: number; + frameCalls: number; + done: boolean; + ok: boolean; + errorName: string; + errorCode: string; + errorOperation: string; +} + +const state: SmokeState = { + version: 1, + checkpoint: 0, + phase: "starting", + roundsTotal: ROUNDS_TOTAL, + roundsStarted: 0, + roundsPassed: 0, + requestsPassed: 0, + frameCalls: 0, + done: false, + ok: false, + errorName: "", + errorCode: "", + errorOperation: "", +}; + +function checkpoint(phase: SmokePhase): void { + state.phase = phase; + state.checkpoint += 1; +} + +function report(): Readonly { + return Object.freeze({ ...state }); +} + +function boundedDiagnostic(value: unknown): string { + return typeof value === "string" ? value.slice(0, 64) : ""; +} + +function recordFailure(error: unknown): void { + const candidate = typeof error === "object" && error !== null + ? error as Record + : {}; + state.errorName = boundedDiagnostic(candidate.name); + state.errorCode = boundedDiagnostic(candidate.code); + state.errorOperation = boundedDiagnostic(candidate.operation); + state.done = true; + state.ok = false; + checkpoint("failed"); +} + +function url(path: string): string { + return `${ORIGIN}${path}`; +} + +function assert(condition: boolean, message: string): void { + if (!condition) throw new Error(message); +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + for (let index = 0; index < left.byteLength; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + +async function expectText( + path: string, + status: number, + body: string, + redirect: "follow" | "manual" | "error" = "manual", + redirected = false, + finalPath = path, +): Promise { + const response = await fetch(url(path), { redirect, tls: TLS_POLICY }); + const text = await response.text(); + assert(response.status === status, `status mismatch for ${path}`); + assert(text === body, `body mismatch for ${path}`); + assert(response.redirected === redirected, `redirect flag mismatch for ${path}`); + assert(response.url === url(finalPath), `response URL mismatch for ${path}`); +} + +async function expectNetworkError( + path: string, + expectedCodes: readonly string[], + redirect: "follow" | "manual" | "error" = "manual", +): Promise { + try { + const response = await fetch(url(path), { redirect, tls: TLS_POLICY }); + await response.arrayBuffer(); + } catch (error) { + const candidate = typeof error === "object" && error !== null + ? error as Record + : {}; + if (candidate.name !== "NetworkError") throw error; + for (let index = 0; index < expectedCodes.length; index += 1) { + if (candidate.code === expectedCodes[index]) return; + } + throw error; + } + throw new Error(`expected network error for ${path}`); +} + +async function fixedEcho(round: number): Promise { + const payload = new Uint8Array([0x00, 0xff, 0x46, 0x49, 0x58, round, 0x7f]); + const response = await fetch(url("/echo"), { + method: "POST", + body: payload, + redirect: "manual", + tls: TLS_POLICY, + }); + const echoed = new Uint8Array(await response.arrayBuffer()); + assert(response.status === 200 && equalBytes(echoed, payload), "fixed echo mismatch"); +} + +async function* streamedPayload(payload: Uint8Array): AsyncIterable { + yield payload.slice(0, 2); + yield payload.slice(2, 6); + yield payload.slice(6); +} + +async function streamedEcho(round: number): Promise { + const payload = new Uint8Array([ + 0x00, 0xff, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, round, 0x7f, + ]); + const response = await fetch(url("/echo"), { + method: "POST", + body: streamedPayload(payload), + redirect: "manual", + tls: TLS_POLICY, + }); + const echoed = new Uint8Array(await response.arrayBuffer()); + assert(response.status === 200 && equalBytes(echoed, payload), "streamed echo mismatch"); +} + +async function manualRedirect(): Promise { + const path = "/redirect?status=302&to=/health"; + const response = await fetch(url(path), { redirect: "manual", tls: TLS_POLICY }); + assert(response.status === 302, "manual redirect status mismatch"); + assert(response.redirected === false, "manual redirect followed unexpectedly"); + assert(response.url === url(path), "manual redirect URL changed"); + assert(response.headers.get("location") === "/health", "manual Location mismatch"); + assert(await response.text() === "redirect", "manual redirect body mismatch"); +} + +async function headHealth(): Promise { + const response = await fetch(url("/health"), { + method: "HEAD", + redirect: "manual", + tls: TLS_POLICY, + }); + const body = await response.arrayBuffer(); + assert(response.status === 200 && body.byteLength === 0, "HEAD body mismatch"); +} + +function retryToken(round: number): string { + return `tls-conformance-${round}`; +} + +async function retryOnce(round: number): Promise { + await expectNetworkError( + `/retry-once?token=${retryToken(round)}`, + Object.freeze(["connection_reset", "http_protocol_error"]), + ); +} + +async function attemptCount(round: number): Promise { + const response = await fetch(url(`/attempts?token=${retryToken(round)}`), { + redirect: "manual", + tls: TLS_POLICY, + }); + const body = await response.json() as { attempts?: unknown }; + assert(response.status === 200 && body.attempts === 1, "hidden retry detected"); +} + +async function operation(phase: "health" | "echo", run: () => Promise): Promise { + checkpoint(phase); + await run(); + state.requestsPassed += 1; +} + +async function runRound(round: number): Promise { + const scenario = (round - 1) % 10; + if (scenario === 0) { + await operation("health", headHealth); + await operation("echo", () => expectText( + "/chunked?fragment_ms=1", 200, CHUNKED_BODY, + )); + } else if (scenario === 1) { + await operation("health", () => expectText("/status/404", 404, "status-404")); + await operation("echo", () => expectText("/status/503", 503, "status-503")); + } else if (scenario === 2) { + await operation("health", () => expectText( + "/redirect?status=301&to=/health", 200, HEALTH_BODY, "follow", true, + "/health", + )); + await operation("echo", () => expectText( + "/redirect?status=308&to=/chunked?fragment_ms=1", 200, CHUNKED_BODY, + "follow", true, "/chunked?fragment_ms=1", + )); + } else if (scenario === 3) { + await operation("health", manualRedirect); + await operation("echo", () => expectNetworkError( + "/redirect?status=302&to=/health", Object.freeze(["http_protocol_error"]), + "error", + )); + } else if (scenario === 4) { + await operation("health", () => expectNetworkError( + "/malformed/te-cl", Object.freeze(["http_protocol_error"]), + )); + await operation("echo", () => expectNetworkError( + "/malformed/duplicate-content-length", + Object.freeze(["http_protocol_error"]), + )); + } else if (scenario === 5) { + await operation("health", () => expectNetworkError( + "/malformed/obs-fold", Object.freeze(["http_protocol_error"]), + )); + await operation("echo", () => expectNetworkError( + "/malformed/te-duplicate", Object.freeze(["http_protocol_error"]), + )); + } else if (scenario === 6) { + await operation("health", () => expectNetworkError( + "/malformed/te-combined", Object.freeze(["http_protocol_error"]), + )); + await operation("echo", () => expectNetworkError( + "/malformed/te-unknown", Object.freeze(["http_protocol_error"]), + )); + } else if (scenario === 7) { + await operation("health", () => expectNetworkError( + "/malformed/trailer-forbidden", Object.freeze(["http_protocol_error"]), + )); + await operation("echo", () => expectNetworkError( + "/malformed/chunk-size", Object.freeze(["http_protocol_error"]), + )); + } else if (scenario === 8) { + await operation("health", () => fixedEcho(round)); + await operation("echo", () => streamedEcho(round)); + } else { + await operation("health", () => retryOnce(round)); + await operation("echo", () => attemptCount(round)); + } +} + +async function run(): Promise { + try { + for (let round = 1; round <= ROUNDS_TOTAL; round += 1) { + state.roundsStarted = round; + await runRound(round); + state.roundsPassed = round; + } + state.done = true; + state.ok = true; + checkpoint("passed"); + } catch (error) { + recordFailure(error); + } +} + +Object.defineProperty(globalThis, REPORT_GLOBAL, { + value: report, + configurable: false, + enumerable: false, + writable: false, +}); + +Object.defineProperty(globalThis, CANCEL_GLOBAL, { + value: () => false, + configurable: false, + enumerable: false, + writable: false, +}); + +Object.defineProperty(globalThis, "frame", { + value: () => { + state.frameCalls += 1; + }, + configurable: false, + enumerable: false, + writable: false, +}); + +void run(); diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/generate.ts b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/generate.ts new file mode 100644 index 00000000..18e84bc7 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/generate.ts @@ -0,0 +1,245 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + definePlatformContractRegistry, + defineTargetRegistry, + type TargetProfile, +} from "../../../../contracts/spec/platforms.ts"; +import { + networkV1FeatureIdsFromBuildPlan, + networkV1PlanHashBytes, +} from "../../../../contracts/spec/network/network-v1.ts"; +import { + verifyPlanHash, + type ResolvedBuildPlan, +} from "../../../../framework/src/manifest/plan.ts"; +import { validateAndResolveBuildPlan } from + "../../../../framework/src/manifest/resolve.ts"; +import { + testArtifactOutputDirectory, + writeTestArtifactOutputs, + type GeneratedTestArtifactOutput, +} from "../../../../tools/test-artifact-output.ts"; +import { + metadataSource, + readCa, + TEST_CAPABILITIES, + TEST_NETWORK_PROFILE, + type GeneratedMetadata, + type TestCapability, +} from "../pocketjs_net_formal_tls_smoke_artifact/generate.ts"; + +const COMPONENT = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(COMPONENT, "../../../.."); +const TARGET_ID = "esp-formal-network-tls-conformance-test"; +const APP_ID = "dev.pocketjs.esp-formal-network-tls-conformance"; +const OUTPUT = "esp-formal-network-tls-conformance"; +const ORIGIN = "https://pocketjs.test:8443"; +const HOST = "pocketjs.test"; +const PORT = 8443; +const REPORT_GLOBAL = "__pocketjsFormalNetworkTlsSmokeReportV1"; +const CANCEL_GLOBAL = "__pocketjsFormalNetworkTlsSmokeCancelV1"; +const PUBLIC_HTTP_SPECIFIER = "@pocketjs/framework/net/http"; +const HTTP_BACKEND_ID = "pocketjs.net.http-client-core.v1.experimental"; +const TLS_PROVIDER_ID = "pocketjs.net.esp-idf.esp-tls.v1.experimental"; +const NET_DRIVER_ID = "pocketjs.net.esp-idf.transport.v1.experimental"; + +const TEST_TARGET_DEFINITIONS = { + [TARGET_ID]: { + hostAbi: 1, + platform: "esp-idf-formal-network-tls-test", + form: "takeover", + display: { + physicalViewport: [1, 1], + logicalViewports: [[1, 1]], + presentations: ["native"], + rasterDensity: 1, + }, + capabilities: ["network.http.client", "network.http.client.tls"], + }, +} as const satisfies Readonly>>; + +const TEST_TARGETS = defineTargetRegistry< + TestCapability, + typeof TEST_TARGET_DEFINITIONS +>(TEST_TARGET_DEFINITIONS); + +const TEST_CONTRACTS = definePlatformContractRegistry( + TEST_CAPABILITIES, + TEST_TARGETS, +); + +const encoder = new TextEncoder(); + +function textBytes(value: string): Uint8Array { + return encoder.encode(value); +} + +export async function resolvePlan(): Promise { + const manifest = await Bun.file(join(COMPONENT, "manifest.json")).json(); + const result = validateAndResolveBuildPlan( + manifest, + { target: TARGET_ID, network: TEST_NETWORK_PROFILE }, + TEST_CONTRACTS, + ); + if (!result.ok) { + const detail = result.diagnostics + .map((item) => `${item.code} ${item.path}: ${item.message}`) + .join("\n"); + throw new Error(`formal TLS conformance manifest did not resolve:\n${detail}`); + } + const plan = result.plan; + const network = plan.network; + const connect = network?.policy.connect ?? []; + const providers = network?.providers; + const tlsProvider = providers?.tlsByRole["http.client"]; + const featureIds = Array.from(networkV1FeatureIdsFromBuildPlan(plan.features)); + if ( + !verifyPlanHash(plan) || + network === undefined || + plan.app.id !== APP_ID || + plan.app.entry !== "app.ts" || + plan.app.output !== OUTPUT || + plan.target.id !== TARGET_ID || + plan.target.hostAbi !== 1 || + featureIds.length !== 2 || + featureIds[0] !== 0x0100 || + featureIds[1] !== 0x0101 || + connect.length !== 1 || + connect[0]?.protocol !== "https" || + connect[0]?.host !== HOST || + connect[0]?.port.min !== PORT || + connect[0]?.port.max !== PORT || + Object.keys(providers?.backendByRole ?? {}).length !== 1 || + providers?.backendByRole["http.client"] !== HTTP_BACKEND_ID || + Object.keys(providers?.tlsByRole ?? {}).length !== 1 || + tlsProvider?.source !== "provider" || + tlsProvider.id !== TLS_PROVIDER_ID || + providers?.netDriverId !== NET_DRIVER_ID + ) { + throw new Error("formal TLS conformance plan escaped its exact test identity"); + } + return plan; +} + +async function assertPublicBuildInput(): Promise { + const source = await Bun.file(join(COMPONENT, "app.ts")).text(); + if (source.split(`"${PUBLIC_HTTP_SPECIFIER}"`).length !== 2) { + throw new Error("formal TLS conformance app must have one public HTTP import"); + } +} + +async function buildFactory(plan: ResolvedBuildPlan): Promise { + await assertPublicBuildInput(); + const temporary = await mkdtemp(join(tmpdir(), "pocketjs-formal-tls-conformance-")); + try { + const planPath = join(temporary, "resolved-plan.json"); + const output = join(temporary, "dist"); + await Bun.write(planPath, `${JSON.stringify(plan, null, 2)}\n`); + const child = Bun.spawn([ + process.execPath, + join(ROOT, "tools/build.ts"), + `--plan=${planPath}`, + `--project-root=${COMPONENT}`, + `--outdir=${output}`, + "--no-config", + "--network-factory", + "--test-only-staged-https-client-fetch", + ], { cwd: ROOT, stdout: "pipe", stderr: "pipe" }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + if (exitCode !== 0) { + throw new Error(`formal TLS conformance build failed:\n${stdout}\n${stderr}`); + } + const built = await Bun.file(join(output, `${OUTPUT}.js`)).text(); + const source = textBytes(built.replace(/[\t ]+$/gm, "")); + if (source.length === 0 || source.includes(0)) { + throw new Error("formal TLS conformance factory is empty or contains NUL"); + } + const terminated = new Uint8Array(source.length + 1); + terminated.set(source); + return terminated; + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} + +async function expectedOutputs(): Promise { + const [plan, ca] = await Promise.all([resolvePlan(), readCa()]); + const factory = await buildFactory(plan); + const source = factory.subarray(0, factory.length - 1); + const featureIds = Array.from(networkV1FeatureIdsFromBuildPlan(plan.features)); + const metadata: GeneratedMetadata = { + schemaVersion: 1, + testOnly: true, + target: { id: plan.target.id, hostAbi: plan.target.hostAbi }, + planHash: plan.planHash, + planHashBytes: Array.from(networkV1PlanHashBytes(plan.planHash)), + featureIds, + providers: { + httpClientBackendId: HTTP_BACKEND_ID, + netDriverId: NET_DRIVER_ID, + tlsProviderId: TLS_PROVIDER_ID, + }, + factory: { + sourceBytes: source.length, + storageBytes: factory.length, + sha256: `sha256:${createHash("sha256").update(source).digest("hex")}`, + }, + endpoint: { + origin: ORIGIN, + scheme: "https", + host: HOST, + port: PORT, + healthUrl: `${ORIGIN}/health`, + echoUrl: `${ORIGIN}/echo`, + }, + tls: { + providerId: TLS_PROVIDER_ID, + trustSource: "host-pinned-ca", + minVersion: "1.2", + maxVersion: "1.2", + verification: "full", + revocation: "host-default", + caPemBytes: ca.pem.length, + caDerSha256: ca.derSha256, + }, + reportGlobal: REPORT_GLOBAL, + cancelGlobal: CANCEL_GLOBAL, + stagedSurfaceBuild: { + publicSpecifier: PUBLIC_HTTP_SPECIFIER, + permit: "test-only-staged-https-client-fetch", + exactAppId: APP_ID, + exactTargetId: TARGET_ID, + productionGateChanged: false, + }, + }; + return [ + { name: "resolved-plan.json", bytes: textBytes(`${JSON.stringify(plan, null, 2)}\n`) }, + { name: "metadata.json", bytes: textBytes(`${JSON.stringify(metadata, null, 2)}\n`) }, + { name: "factory.js.bin", bytes: factory }, + { + name: "formal_tls_smoke_metadata.c", + bytes: textBytes(metadataSource(plan, metadata, ca.pem)), + }, + ]; +} + +export async function generate(outputDirectory: string): Promise { + const outputs = await expectedOutputs(); + await writeTestArtifactOutputs(outputDirectory, outputs); +} + +if (import.meta.main) { + const outputDirectory = testArtifactOutputDirectory(process.argv.slice(2)); + await generate(outputDirectory); + console.log( + `PocketJS formal TLS conformance artifact generated in ${outputDirectory}`, + ); +} diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/idf_component.yml b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/idf_component.yml new file mode 100644 index 00000000..fd700821 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/idf_component.yml @@ -0,0 +1,10 @@ +version: "0.1.0" +description: Test-only PocketJS formal ESP TLS HTTPS conformance artifact +license: MIT +repository: https://github.com/pocket-stack/pocketjs +documentation: https://github.com/pocket-stack/pocketjs/tree/main/hosts/esp-idf +targets: + - esp32p4 + - esp32s3 +dependencies: + idf: "==6.0.2" diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/manifest.json b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/manifest.json new file mode 100644 index 00000000..00d9f649 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/manifest.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://pocketjs.dev/schema/pocket-3.json", + "pocket": 3, + "id": "dev.pocketjs.esp-formal-network-tls-conformance", + "name": "esp-formal-network-tls-conformance", + "title": "ESP formal TLS network conformance", + "version": "1.0.0", + "engine": { + "capabilities": { + "requires": [ + "network.http.client", + "network.http.client.tls" + ] + } + }, + "permissions": { + "network": { + "connect": [ + { + "protocol": "https", + "host": "pocketjs.test", + "port": 8443 + } + ], + "listen": [], + "localNetwork": true, + "insecureTransport": false, + "broadcast": false, + "multicast": false, + "allowInvalidTlsForDevelopment": false, + "browserAmbientCredentials": false, + "browserOpaqueWebSocketRedirects": false, + "credentials": [] + } + }, + "resources": { + "network": { + "minimum": { + "runtime": { + "connections": 1, + "pendingOperations": 1, + "completionDescriptors": 8, + "nativeBufferBytes": 65536 + }, + "http": { + "connections": 1, + "inflightRequests": 1, + "headerBytes": 4096, + "headerCount": 16, + "bufferedBodyBytes": 4096 + } + } + } + }, + "app": { + "entry": "app.ts", + "output": "esp-formal-network-tls-conformance", + "framework": "solid", + "viewport": { + "logical": [1, 1], + "presentation": "native" + } + } +} diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/test_apps/build_smoke/.gitignore b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/test_apps/build_smoke/.gitignore new file mode 100644 index 00000000..046474f5 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/test_apps/build_smoke/.gitignore @@ -0,0 +1,3 @@ +/build-*/ +/dependencies.lock +/managed_components/ diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/test_apps/build_smoke/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/test_apps/build_smoke/CMakeLists.txt new file mode 100644 index 00000000..f3ce7052 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/test_apps/build_smoke/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.16) + +set(EXTRA_COMPONENT_DIRS + "../.." + "../../../pocketjs_esp_guest" + "../../../pocketjs_net_http1" + "../../../pocketjs_net_esp_transport" + "../../../pocketjs_net_http_client_core" + "../../../pocketjs_net_esp_runtime") +set(SDKCONFIG_DEFAULTS "sdkconfig.defaults") +set(SDKCONFIG "${CMAKE_BINARY_DIR}/sdkconfig") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(pocketjs_net_formal_tls_conformance_build_smoke) diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/test_apps/build_smoke/main/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/test_apps/build_smoke/main/CMakeLists.txt new file mode 100644 index 00000000..decf180a --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/test_apps/build_smoke/main/CMakeLists.txt @@ -0,0 +1,8 @@ +idf_component_register( + SRCS + "../../../../pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/main/build_smoke.c" + INCLUDE_DIRS "." + REQUIRES pocketjs_net_formal_tls_conformance_artifact +) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/test_apps/build_smoke/main/idf_component.yml b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/test_apps/build_smoke/main/idf_component.yml new file mode 100644 index 00000000..fcf76119 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/test_apps/build_smoke/main/idf_component.yml @@ -0,0 +1,3 @@ +dependencies: + pocketjs_net_formal_tls_conformance_artifact: + override_path: ../../.. diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/test_apps/build_smoke/partitions.csv b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/test_apps/build_smoke/partitions.csv new file mode 100644 index 00000000..0a309d77 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/test_apps/build_smoke/partitions.csv @@ -0,0 +1,4 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 24K, +phy_init, data, phy, 0xf000, 4K, +factory, app, factory, 0x10000, 3M, diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/test_apps/build_smoke/sdkconfig.defaults b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/test_apps/build_smoke/sdkconfig.defaults new file mode 100644 index 00000000..6dabaccc --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/test_apps/build_smoke/sdkconfig.defaults @@ -0,0 +1,15 @@ +CONFIG_COMPILER_OPTIMIZATION_SIZE=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_LWIP_DNS_MAX_HOST_IP=4 +CONFIG_LWIP_HOOK_DNS_EXT_RESOLVE_NONE=y +# CONFIG_LWIP_HOOK_DNS_EXT_RESOLVE_CUSTOM is not set +CONFIG_MBEDTLS_SSL_PROTO_TLS1_2=y +# CONFIG_MBEDTLS_SSL_PROTO_TLS1_3 is not set +CONFIG_MBEDTLS_HAVE_TIME_DATE=y +# CONFIG_MBEDTLS_SSL_RENEGOTIATION is not set +# CONFIG_MBEDTLS_ALLOW_WEAK_CERTIFICATE_VERIFICATION is not set +# CONFIG_MBEDTLS_DES_C is not set +# CONFIG_ESP_TLS_INSECURE is not set diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/CMakeLists.txt new file mode 100644 index 00000000..dcc199b7 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/CMakeLists.txt @@ -0,0 +1,32 @@ +include("${CMAKE_CURRENT_LIST_DIR}/../../cmake/pocketjs_generate_test_artifact.cmake") +pocketjs_generate_test_artifact( + _pocketjs_generated + "${CMAKE_CURRENT_LIST_DIR}/generate.ts" +) + +idf_component_register( + SRCS + "${_pocketjs_generated}/formal_tls_smoke_metadata.c" + "src/formal_tls_smoke_ipv4.c" + "src/formal_tls_smoke_report.c" + "src/formal_tls_smoke_runner.c" + INCLUDE_DIRS + "include" + "../../../../contracts/spec/network/generated" + PRIV_INCLUDE_DIRS + "private" + REQUIRES + pocketjs_esp_guest + pocketjs_net_esp_runtime + esp_timer + lwip + quickjs-ng +) + +target_add_binary_data( + ${COMPONENT_LIB} + "${_pocketjs_generated}/factory.js.bin" + BINARY +) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/README.md b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/README.md new file mode 100644 index 00000000..0b5b7c08 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/README.md @@ -0,0 +1,67 @@ +# ESP formal TLS network smoke artifact + +This component is an isolated **test-only** PocketJS network factory for +ESP32-S3 and ESP32-P4 hardware validation. It does not change the public +network capability gate. + +The verified Build Plan admits exactly `network.http.client` and +`network.http.client.tls`, the ESP HTTP Core, the ESP transport, and the ESP-TLS +provider. The only endpoint is **`https://pocketjs.test:8443`**. Each of 20 +rounds performs one health request and one streamed binary echo request with +the SDK's default redirect-follow mode and this exact Guest TLS policy: + +- DNS A-label and SNI: `pocketjs.test` +- TLS minimum and maximum: 1.2 +- verification: full +- revocation: host-default +- ALPN, Guest custom CA, client credentials, and client certificates: absent + +`fixtures/ca.cert.pem` is the public test CA only. Its DER SHA-256 is recorded +in generated metadata. **No CA or server private key belongs in this +component.** + +ESP-IDF generates the artifact into the component's build directory during +configuration. To inspect the deterministic outputs without changing the +source tree, generate them into a temporary directory: + +```sh +bun hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/generate.ts \ + --output-dir=/tmp/pocketjs-formal-tls-artifact +bun test tests/esp-network-artifacts.test.ts +``` + +**Generated plans, metadata C, and factory bundles are build outputs and are +not committed.** The consolidated artifact test regenerates all HTTP and TLS +profiles in an isolated directory and locks their hashes and admission rules. + +The board harness must configure stock lwIP DNS so `pocketjs.test` resolves to +the selected Mac peer, provision a trusted wall clock, and pass both facts to +`pocketjs_net_formal_tls_smoke_run()`. The runner snapshots the public CA into +the runtime as `POCKETJS_NET_ESP_TLS_TRUST_HOST_PINNED_CA` and rejects every +other Host profile. + +The runner also exposes test-only deadline and cancellation controls. A zero +stage-timeout field selects the fixed artifact default; a nonzero value is +passed unchanged to the formal runtime. `cancel_after_ms` installs no timer in +native networking: the owner task waits until the first health request is +active, then invokes the artifact's private AbortController function through +the guarded Guest call API. These controls are for deterministic delayed- +handshake tests and do not change the Build Plan or public SDK surface. +With a five-second peer handshake delay, a two-second connect deadline must +produce `timed_out`; cancelling after 500 ms must produce `aborted`. Both runs +must complete three-phase shutdown with zero poison and no live leases. Peer +evidence must contain the board's DNS answer, delayed TLS connection, and SNI +`pocketjs.test`, but no successful connection or HTTP request. + +The generated artifact metadata also carries the exact HTTP backend, network +driver, and TLS provider selection from its verified Build Plan. The runner +passes those generated values into fail-closed test-only runtime admission; +they are not reconstructed from product defaults. + +The exact runtime descriptor must report `distinct_tls_errors=true`. The +transport reads the failed handshake's live Mbed TLS verification result before +teardown: hostname mismatch maps to `tls_hostname_mismatch`, while trust-chain, +validity, and usage failures map to `tls_certificate_invalid`. Negative wire +evidence must show the expected SNI, a failed TLS handshake, zero HTTP requests, +and no plaintext fallback. Native allocation and DNS completeness blockers keep +the public TLS capability gate closed. diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/app.ts b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/app.ts new file mode 100644 index 00000000..16c44751 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/app.ts @@ -0,0 +1,180 @@ +import { AbortController, fetch } from "@pocketjs/framework/net/http"; + +const REPORT_GLOBAL = "__pocketjsFormalNetworkTlsSmokeReportV1"; +const CANCEL_GLOBAL = "__pocketjsFormalNetworkTlsSmokeCancelV1"; +const ORIGIN = "https://pocketjs.test:8443"; +const ROUNDS_TOTAL = 20; +const HEALTH_BODY = + '{"peer":"mac","protocol":"pocketjs-independent-peer-v1","status":"ok"}'; +const TLS_POLICY = Object.freeze({ + serverName: "pocketjs.test", + minVersion: "1.2" as const, + maxVersion: "1.2" as const, + verification: "full" as const, + revocation: "host-default" as const, +}); + +type SmokePhase = "starting" | "health" | "echo" | "passed" | "failed"; + +interface SmokeState { + version: 1; + checkpoint: number; + phase: SmokePhase; + roundsTotal: number; + roundsStarted: number; + roundsPassed: number; + requestsPassed: number; + frameCalls: number; + done: boolean; + ok: boolean; + errorName: string; + errorCode: string; + errorOperation: string; +} + +const state: SmokeState = { + version: 1, + checkpoint: 0, + phase: "starting", + roundsTotal: ROUNDS_TOTAL, + roundsStarted: 0, + roundsPassed: 0, + requestsPassed: 0, + frameCalls: 0, + done: false, + ok: false, + errorName: "", + errorCode: "", + errorOperation: "", +}; + +let activeController: AbortController | null = null; + +function cancelActiveRequest(): boolean { + const controller = activeController; + if (controller === null) return false; + controller.abort(); + return true; +} + +function checkpoint(phase: SmokePhase): void { + state.phase = phase; + state.checkpoint += 1; +} + +function report(): Readonly { + return Object.freeze({ ...state }); +} + +function boundedDiagnostic(value: unknown): string { + return typeof value === "string" ? value.slice(0, 64) : ""; +} + +function recordFailure(error: unknown): void { + const candidate = typeof error === "object" && error !== null + ? error as Record + : {}; + state.errorName = boundedDiagnostic(candidate.name); + state.errorCode = boundedDiagnostic(candidate.code); + state.errorOperation = boundedDiagnostic(candidate.operation); + state.done = true; + state.ok = false; + checkpoint("failed"); +} + +function echoPayload(round: number): Uint8Array { + return new Uint8Array([ + 0x00, 0xff, 0x54, 0x4c, 0x53, 0x2d, 0x50, 0x6f, 0x63, 0x6b, round, 0x7f, + ]); +} + +async function* streamedPayload(payload: Uint8Array): AsyncIterable { + yield payload.slice(0, 3); + yield payload.slice(3, 8); + yield payload.slice(8); +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + for (let index = 0; index < left.byteLength; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + +async function run(): Promise { + try { + for (let round = 1; round <= ROUNDS_TOTAL; round += 1) { + state.roundsStarted = round; + checkpoint("health"); + const healthController = new AbortController(); + activeController = healthController; + let health; + try { + health = await fetch(`${ORIGIN}/health`, { + signal: healthController.signal, + tls: TLS_POLICY, + }); + } finally { + activeController = null; + } + if (health.status !== 200 || await health.text() !== HEALTH_BODY) { + throw new Error("formal TLS smoke health response mismatch"); + } + state.requestsPassed += 1; + + checkpoint("echo"); + const payload = echoPayload(round); + const echoController = new AbortController(); + activeController = echoController; + let echo; + try { + echo = await fetch(`${ORIGIN}/echo`, { + method: "POST", + body: streamedPayload(payload), + signal: echoController.signal, + tls: TLS_POLICY, + }); + } finally { + activeController = null; + } + const echoed = new Uint8Array(await echo.arrayBuffer()); + if (echo.status !== 200 || !equalBytes(echoed, payload)) { + throw new Error("formal TLS smoke echo response mismatch"); + } + state.requestsPassed += 1; + state.roundsPassed = round; + } + state.done = true; + state.ok = true; + checkpoint("passed"); + } catch (error) { + recordFailure(error); + } +} + +Object.defineProperty(globalThis, REPORT_GLOBAL, { + value: report, + configurable: false, + enumerable: false, + writable: false, +}); + +Object.defineProperty(globalThis, CANCEL_GLOBAL, { + value: cancelActiveRequest, + configurable: false, + enumerable: false, + writable: false, +}); + +// The Host polls REPORT_GLOBAL; hardware conformance never calls frame(). +Object.defineProperty(globalThis, "frame", { + value: () => { + state.frameCalls += 1; + }, + configurable: false, + enumerable: false, + writable: false, +}); + +void run(); diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/fixtures/ca.cert.pem b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/fixtures/ca.cert.pem new file mode 100644 index 00000000..8a7deb9f --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/fixtures/ca.cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDNjCCAh6gAwIBAgIUf03HAEUjfT5dR/UjmDseJIWoiyYwDQYJKoZIhvcNAQEL +BQAwITEfMB0GA1UEAwwWUG9ja2V0SlMgTG9jYWwgVGVzdCBDQTAeFw0yNjA4MTUx +MzAyNDJaFw0zNjA4MTIxMzAyNDJaMCExHzAdBgNVBAMMFlBvY2tldEpTIExvY2Fs +IFRlc3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCK2O+kCjbB +pULw//fumyWgJoovpl4hGsj8PZFKMgHnSn3gzSKokZ7QFMk6BMEbPy+7ocWjw7L0 +hksetJ5on6pVrnJXg99p20z6W0JeX7IFbyXloJaEIw5Og682BicwCWsEVDWmH3gO +vrEcTNhohtRgcqP7oIdjk+ZhJNgZzqNChxeu1FXGVYS8+I9PGPsQ1AZKIf34sMxS +1XrhXh0S0K/UwtFa8fJ9nuh1NwwbrENoxJXAm8E8cFevbJTRkaAoK5zay14fVrrd +tRSQE/r9JpPDA7Ylvtr4k43JnjrRB/SHEqQEM17Im4nqWCRV6Ox7g1hylc+rbTlm +jJMkgiwpOm1rAgMBAAGjZjBkMB8GA1UdIwQYMBaAFNFio1m6Wl7/tZ1QKvBtuebO +/2RkMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQW +BBTRYqNZulpe/7WdUCrwbbnmzv9kZDANBgkqhkiG9w0BAQsFAAOCAQEAFcpmXq/A +3F+R6KYxrzSFa6dFirCs6SXTvqKi+ZxQhYbP4Sd1IYm/6r9g6wosyJt7DT9rYtp3 +iGy8mZm4c8r2x7tOQAJjwh3P+LFDUjPWt8/Jlm+gdQr+GtN11bf2TeK+CSHJ1v4s +rpODjfZ5KoxxzlxClMR1c3H14HDfutieUGdg3Y4yTHTwD643X+ZlE3Y0nKVK/3wT +WFmjXH0Sj5J5yamRs6qpKlasQ4GehowSSXRuHLIcHB52I+EunY5tJlgkOTEpewYI +9r7D/ISoKjOfFdKsL0WacvclxezijWVC94jfKN6B4fPB84e0FqG2x7ozacsPHv4V +V0V2v3P8ploBgQ== +-----END CERTIFICATE----- diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/generate.ts b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/generate.ts new file mode 100644 index 00000000..dcd95161 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/generate.ts @@ -0,0 +1,449 @@ +import { createHash, X509Certificate } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + defineCapabilityRegistry, + definePlatformContractRegistry, + defineTargetRegistry, + type CapabilityId, + type TargetProfile, +} from "../../../../contracts/spec/platforms.ts"; +import { + networkV1FeatureIdsFromBuildPlan, + networkV1PlanHashBytes, +} from "../../../../contracts/spec/network/network-v1.ts"; +import type { HostNetworkResolutionProfile } from + "../../../../framework/src/manifest/network.ts"; +import { + verifyPlanHash, + type ResolvedBuildPlan, +} from "../../../../framework/src/manifest/plan.ts"; +import { validateAndResolveBuildPlan } from + "../../../../framework/src/manifest/resolve.ts"; +import { + testArtifactOutputDirectory, + writeTestArtifactOutputs, + type GeneratedTestArtifactOutput, +} from "../../../../tools/test-artifact-output.ts"; + +const COMPONENT = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(COMPONENT, "../../../.."); +const TARGET_ID = "esp-formal-network-tls-smoke-test"; +const ORIGIN = "https://pocketjs.test:8443"; +const SCHEME = "https"; +const HOST = "pocketjs.test"; +const PORT = 8443; +const REPORT_GLOBAL = "__pocketjsFormalNetworkTlsSmokeReportV1"; +const CANCEL_GLOBAL = "__pocketjsFormalNetworkTlsSmokeCancelV1"; +const PUBLIC_HTTP_SPECIFIER = "@pocketjs/framework/net/http"; +const TLS_PROVIDER_ID = "pocketjs.net.esp-idf.esp-tls.v1.experimental"; +export const EXPECTED_CA_DER_SHA256 = + "sha256:318ae57f0fb82d12cf86431571fb6ec3556ecb74f530a5be6f741a482b5447af"; + +export const TEST_CAPABILITIES = defineCapabilityRegistry([ + "network.http.client", + "network.http.client.tls", +] as const); + +export type TestCapability = CapabilityId; + +const TEST_TARGET_DEFINITIONS = { + [TARGET_ID]: { + hostAbi: 1, + platform: "esp-idf-formal-network-tls-test", + form: "takeover", + display: { + physicalViewport: [1, 1], + logicalViewports: [[1, 1]], + presentations: ["native"], + rasterDensity: 1, + }, + capabilities: ["network.http.client", "network.http.client.tls"], + }, +} as const satisfies Readonly>>; + +const TEST_TARGETS = defineTargetRegistry< + TestCapability, + typeof TEST_TARGET_DEFINITIONS +>(TEST_TARGET_DEFINITIONS); + +const TEST_CONTRACTS = definePlatformContractRegistry( + TEST_CAPABILITIES, + TEST_TARGETS, +); + +export const TEST_NETWORK_PROFILE: HostNetworkResolutionProfile = { + providers: { + backendByRole: { + "http.client": "pocketjs.net.http-client-core.v1.experimental", + }, + tlsByRole: { + "http.client": { + source: "provider", + id: TLS_PROVIDER_ID, + }, + }, + netDriverId: "pocketjs.net.esp-idf.transport.v1.experimental", + }, + hardLimits: { + runtime: { + connections: 4, + pendingOperations: 8, + completionDescriptors: 8, + nativeBufferBytes: 524288, + }, + http: { + connections: 4, + inflightRequests: 8, + headerBytes: 8192, + headerCount: 60, + bufferedBodyBytes: 16384, + }, + }, + developmentBuild: false, +}; + +export interface GeneratedMetadata { + readonly schemaVersion: 1; + readonly testOnly: true; + readonly target: { readonly id: string; readonly hostAbi: number }; + readonly planHash: string; + readonly planHashBytes: readonly number[]; + readonly featureIds: readonly number[]; + readonly providers: { + readonly httpClientBackendId: string; + readonly netDriverId: string; + readonly tlsProviderId: string; + }; + readonly factory: { + readonly sourceBytes: number; + readonly storageBytes: number; + readonly sha256: string; + }; + readonly endpoint: { + readonly origin: string; + readonly scheme: string; + readonly host: string; + readonly port: number; + readonly healthUrl: string; + readonly echoUrl: string; + }; + readonly tls: { + readonly providerId: string; + readonly trustSource: "host-pinned-ca"; + readonly minVersion: "1.2"; + readonly maxVersion: "1.2"; + readonly verification: "full"; + readonly revocation: "host-default"; + readonly caPemBytes: number; + readonly caDerSha256: string; + }; + readonly reportGlobal: string; + readonly cancelGlobal: string; + readonly stagedSurfaceBuild: { + readonly publicSpecifier: string; + readonly permit: "test-only-staged-https-client-fetch"; + readonly exactAppId: string; + readonly exactTargetId: string; + readonly productionGateChanged: false; + }; +} + +const encoder = new TextEncoder(); + +function textBytes(value: string): Uint8Array { + return encoder.encode(value); +} + +function cBytes(bytes: readonly number[] | Uint8Array, columns = 12): string { + const values = Array.from(bytes); + const lines: string[] = []; + for (let offset = 0; offset < values.length; offset += columns) { + lines.push( + ` ${values.slice(offset, offset + columns) + .map((byte) => `0x${byte.toString(16).padStart(2, "0")}`) + .join(", ")},`, + ); + } + return lines.join("\n"); +} + +function cFeatureIds(ids: readonly number[]): string { + return ids + .map((id) => + ` (pocketjs_network_v1_feature_id_t)0x${id.toString(16).padStart(4, "0")},` + ) + .join("\n"); +} + +function cString(value: string): string { + return JSON.stringify(value); +} + +export function metadataSource( + plan: ResolvedBuildPlan, + metadata: GeneratedMetadata, + caPem: Uint8Array, +): string { + const factoryDigest = metadata.factory.sha256.slice("sha256:".length); + const factoryHash = Array.from(Buffer.from(factoryDigest, "hex")); + const caStorage = new Uint8Array(caPem.length + 1); + caStorage.set(caPem); + return `// GENERATED by generate.ts; do not edit. +// SPDX-License-Identifier: MIT + +#include "pocketjs/net/formal_tls_smoke_artifact.h" + +extern const uint8_t embedded_factory_start[] + asm("_binary_factory_js_bin_start"); + +const uint8_t *const pocketjs_net_formal_tls_smoke_factory_bytes = + embedded_factory_start; +const size_t pocketjs_net_formal_tls_smoke_factory_length = + ${metadata.factory.sourceBytes}U; +const size_t pocketjs_net_formal_tls_smoke_factory_storage_length = + ${metadata.factory.storageBytes}U; + +const char pocketjs_net_formal_tls_smoke_plan_hash[] = ${cString(plan.planHash)}; +const uint8_t pocketjs_net_formal_tls_smoke_plan_hash_bytes + [POCKETJS_NETWORK_V1_PLAN_HASH_BYTES] = { +${cBytes(metadata.planHashBytes)} +}; + +const pocketjs_network_v1_feature_id_t + pocketjs_net_formal_tls_smoke_feature_ids[] = { +${cFeatureIds(metadata.featureIds)} +}; +const uint16_t pocketjs_net_formal_tls_smoke_feature_count = + ${metadata.featureIds.length}U; + +const char pocketjs_net_formal_tls_smoke_http_client_backend_id[] = + ${cString(metadata.providers.httpClientBackendId)}; +const char pocketjs_net_formal_tls_smoke_net_driver_id[] = + ${cString(metadata.providers.netDriverId)}; + +const char pocketjs_net_formal_tls_smoke_factory_sha256[] = + ${cString(metadata.factory.sha256)}; +const uint8_t pocketjs_net_formal_tls_smoke_factory_sha256_bytes[32] = { +${cBytes(factoryHash)} +}; + +const pocketjs_net_formal_tls_smoke_endpoint_t + pocketjs_net_formal_tls_smoke_endpoint = { + .origin = ${cString(metadata.endpoint.origin)}, + .scheme = ${cString(metadata.endpoint.scheme)}, + .host = ${cString(metadata.endpoint.host)}, + .port = ${metadata.endpoint.port}U, + .health_url = ${cString(metadata.endpoint.healthUrl)}, + .echo_url = ${cString(metadata.endpoint.echoUrl)}, +}; + +const uint8_t pocketjs_net_formal_tls_smoke_ca_pem[] = { +${cBytes(caStorage)} +}; +const size_t pocketjs_net_formal_tls_smoke_ca_pem_length = ${caPem.length}U; +const size_t pocketjs_net_formal_tls_smoke_ca_pem_storage_length = + ${caStorage.length}U; +const char pocketjs_net_formal_tls_smoke_ca_der_sha256[] = + ${cString(metadata.tls.caDerSha256)}; +const char pocketjs_net_formal_tls_smoke_tls_provider_id[] = + ${cString(metadata.providers.tlsProviderId)}; + +const char pocketjs_net_formal_tls_smoke_report_global[] = + ${cString(metadata.reportGlobal)}; +const char pocketjs_net_formal_tls_smoke_cancel_global[] = + ${cString(metadata.cancelGlobal)}; + +_Static_assert(sizeof(pocketjs_net_formal_tls_smoke_plan_hash) == 72U, + "plan hash text length drifted"); +_Static_assert(sizeof(pocketjs_net_formal_tls_smoke_factory_sha256) == 72U, + "artifact hash text length drifted"); +_Static_assert(sizeof(pocketjs_net_formal_tls_smoke_ca_pem) == + ${caStorage.length}U, + "CA snapshot storage length drifted"); +`; +} + +export async function resolvePlan(): Promise { + const manifest = await Bun.file(join(COMPONENT, "manifest.json")).json(); + const result = validateAndResolveBuildPlan( + manifest, + { target: TARGET_ID, network: TEST_NETWORK_PROFILE }, + TEST_CONTRACTS, + ); + if (!result.ok) { + const detail = result.diagnostics + .map((item) => `${item.code} ${item.path}: ${item.message}`) + .join("\n"); + throw new Error(`formal TLS smoke manifest did not resolve:\n${detail}`); + } + if (!verifyPlanHash(result.plan) || result.plan.network === undefined) { + throw new Error("formal TLS smoke resolver did not produce a verified network plan"); + } + if ( + result.plan.app.id !== "dev.pocketjs.esp-formal-network-tls-smoke" || + result.plan.app.entry !== "app.ts" || + result.plan.target.id !== TARGET_ID + ) { + throw new Error("formal TLS smoke plan escaped its exact test-only identity"); + } + const connect = result.plan.network.policy.connect; + const backend = result.plan.network.providers.backendByRole["http.client"]; + const driver = result.plan.network.providers.netDriverId; + const tlsProvider = result.plan.network.providers.tlsByRole["http.client"]; + if ( + connect.length !== 1 || + connect[0]?.protocol !== SCHEME || + connect[0]?.host !== HOST || + connect[0]?.port.min !== PORT || + connect[0]?.port.max !== PORT || + backend !== "pocketjs.net.http-client-core.v1.experimental" || + driver !== "pocketjs.net.esp-idf.transport.v1.experimental" || + Object.keys(result.plan.network.providers.backendByRole).length !== 1 || + Object.keys(result.plan.network.providers.tlsByRole).length !== 1 || + tlsProvider?.source !== "provider" || + tlsProvider.id !== TLS_PROVIDER_ID + ) { + throw new Error("formal TLS smoke plan endpoint or TLS provider drifted"); + } + return result.plan; +} + +export async function readCa(): Promise<{ pem: Uint8Array; derSha256: string }> { + const pem = new Uint8Array(await Bun.file( + join(COMPONENT, "fixtures/ca.cert.pem"), + ).arrayBuffer()); + if (pem.length === 0 || pem.includes(0)) { + throw new Error("formal TLS smoke CA PEM is empty or contains NUL"); + } + const certificate = new X509Certificate(pem); + const derSha256 = `sha256:${createHash("sha256").update(certificate.raw).digest("hex")}`; + if (derSha256 !== EXPECTED_CA_DER_SHA256 || !certificate.ca) { + throw new Error("formal TLS smoke CA identity or CA basic constraint drifted"); + } + return { pem, derSha256 }; +} + +async function assertPublicBuildInput(): Promise { + const source = await Bun.file(join(COMPONENT, "app.ts")).text(); + if (source.split(`"${PUBLIC_HTTP_SPECIFIER}"`).length !== 2) { + throw new Error("formal TLS smoke app must contain exactly one public HTTP import"); + } +} + +async function buildFactory(plan: ResolvedBuildPlan): Promise { + await assertPublicBuildInput(); + const temporary = await mkdtemp(join(tmpdir(), "pocketjs-formal-tls-smoke-")); + try { + const planPath = join(temporary, "resolved-plan.json"); + const output = join(temporary, "dist"); + await Bun.write(planPath, `${JSON.stringify(plan, null, 2)}\n`); + const buildProcess = Bun.spawn([ + process.execPath, + join(ROOT, "tools/build.ts"), + `--plan=${planPath}`, + `--project-root=${COMPONENT}`, + `--outdir=${output}`, + "--no-config", + "--network-factory", + "--test-only-staged-https-client-fetch", + ], { cwd: ROOT, stdout: "pipe", stderr: "pipe" }); + const [exitCode, stdout, stderr] = await Promise.all([ + buildProcess.exited, + new Response(buildProcess.stdout).text(), + new Response(buildProcess.stderr).text(), + ]); + if (exitCode !== 0) { + throw new Error(`formal TLS smoke factory build failed:\n${stdout}\n${stderr}`); + } + const builtSource = await Bun.file( + join(output, "esp-formal-network-tls-smoke.js"), + ).text(); + const source = textBytes(builtSource.replace(/[\t ]+$/gm, "")); + if (source.length === 0 || source.includes(0)) { + throw new Error("formal TLS smoke factory source is empty or contains NUL"); + } + const terminated = new Uint8Array(source.length + 1); + terminated.set(source); + return terminated; + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} + +async function expectedOutputs(): Promise { + const [plan, ca] = await Promise.all([resolvePlan(), readCa()]); + const factory = await buildFactory(plan); + const source = factory.subarray(0, factory.length - 1); + const featureIds = Array.from(networkV1FeatureIdsFromBuildPlan(plan.features)); + const metadata: GeneratedMetadata = { + schemaVersion: 1, + testOnly: true, + target: { id: plan.target.id, hostAbi: plan.target.hostAbi }, + planHash: plan.planHash, + planHashBytes: Array.from(networkV1PlanHashBytes(plan.planHash)), + featureIds, + providers: { + httpClientBackendId: + plan.network!.providers.backendByRole["http.client"]!, + netDriverId: plan.network!.providers.netDriverId, + tlsProviderId: + plan.network!.providers.tlsByRole["http.client"]!.id, + }, + factory: { + sourceBytes: source.length, + storageBytes: factory.length, + sha256: `sha256:${createHash("sha256").update(source).digest("hex")}`, + }, + endpoint: { + origin: ORIGIN, + scheme: SCHEME, + host: HOST, + port: PORT, + healthUrl: `${ORIGIN}/health`, + echoUrl: `${ORIGIN}/echo`, + }, + tls: { + providerId: + plan.network!.providers.tlsByRole["http.client"]!.id, + trustSource: "host-pinned-ca", + minVersion: "1.2", + maxVersion: "1.2", + verification: "full", + revocation: "host-default", + caPemBytes: ca.pem.length, + caDerSha256: ca.derSha256, + }, + reportGlobal: REPORT_GLOBAL, + cancelGlobal: CANCEL_GLOBAL, + stagedSurfaceBuild: { + publicSpecifier: PUBLIC_HTTP_SPECIFIER, + permit: "test-only-staged-https-client-fetch", + exactAppId: plan.app.id, + exactTargetId: plan.target.id, + productionGateChanged: false, + }, + }; + return [ + { name: "resolved-plan.json", bytes: textBytes(`${JSON.stringify(plan, null, 2)}\n`) }, + { name: "metadata.json", bytes: textBytes(`${JSON.stringify(metadata, null, 2)}\n`) }, + { name: "factory.js.bin", bytes: factory }, + { + name: "formal_tls_smoke_metadata.c", + bytes: textBytes(metadataSource(plan, metadata, ca.pem)), + }, + ]; +} + +export async function generate(outputDirectory: string): Promise { + const outputs = await expectedOutputs(); + await writeTestArtifactOutputs(outputDirectory, outputs); +} + +if (import.meta.main) { + const outputDirectory = testArtifactOutputDirectory(process.argv.slice(2)); + await generate(outputDirectory); + console.log(`PocketJS formal TLS smoke artifact generated in ${outputDirectory}`); +} diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/idf_component.yml b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/idf_component.yml new file mode 100644 index 00000000..4a28a849 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/idf_component.yml @@ -0,0 +1,10 @@ +version: "0.1.0" +description: Test-only PocketJS formal ESP TLS network hardware-smoke artifact +license: MIT +repository: https://github.com/pocket-stack/pocketjs +documentation: https://github.com/pocket-stack/pocketjs/tree/main/hosts/esp-idf +targets: + - esp32p4 + - esp32s3 +dependencies: + idf: "==6.0.2" diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/include/pocketjs/net/formal_tls_smoke_artifact.h b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/include/pocketjs/net/formal_tls_smoke_artifact.h new file mode 100644 index 00000000..1c9ed7a5 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/include/pocketjs/net/formal_tls_smoke_artifact.h @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +#include "esp_err.h" +#include "pocketjs/esp_guest.h" +#include "pocketjs/net/esp_runtime.h" +#include "pocketjs_network_v1_abi.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + const char *origin; + const char *scheme; + const char *host; + uint16_t port; + const char *health_url; + const char *echo_url; +} pocketjs_net_formal_tls_smoke_endpoint_t; + +typedef enum { + POCKETJS_NET_FORMAL_TLS_SMOKE_PHASE_STARTING = 1, + POCKETJS_NET_FORMAL_TLS_SMOKE_PHASE_HEALTH, + POCKETJS_NET_FORMAL_TLS_SMOKE_PHASE_ECHO, + POCKETJS_NET_FORMAL_TLS_SMOKE_PHASE_PASSED, + POCKETJS_NET_FORMAL_TLS_SMOKE_PHASE_FAILED, +} pocketjs_net_formal_tls_smoke_phase_t; + +#define POCKETJS_NET_FORMAL_TLS_SMOKE_ROUNDS 20U +#define POCKETJS_NET_FORMAL_TLS_SMOKE_REQUESTS 40U +#define POCKETJS_NET_FORMAL_TLS_SMOKE_DIAGNOSTIC_BYTES 65U +#define POCKETJS_NET_FORMAL_TLS_SMOKE_MIN_GUEST_MEMORY_BYTES \ + (2U * 1024U * 1024U) +#define POCKETJS_NET_FORMAL_TLS_SMOKE_RECOMMENDED_GUEST_MEMORY_BYTES \ + (4U * 1024U * 1024U) +#define POCKETJS_NET_FORMAL_TLS_SMOKE_MIN_GUEST_STACK_BYTES (24U * 1024U) + +typedef struct { + uint32_t checkpoint; + pocketjs_net_formal_tls_smoke_phase_t phase; + uint32_t rounds_started; + uint32_t rounds_passed; + uint32_t requests_passed; + uint32_t frame_calls; + bool done; + bool ok; + char error_name[POCKETJS_NET_FORMAL_TLS_SMOKE_DIAGNOSTIC_BYTES]; + char error_code[POCKETJS_NET_FORMAL_TLS_SMOKE_DIAGNOSTIC_BYTES]; + char error_operation[POCKETJS_NET_FORMAL_TLS_SMOKE_DIAGNOSTIC_BYTES]; +} pocketjs_net_formal_tls_smoke_report_t; + +/** NUL-terminated factory storage; factory_length excludes the terminator. */ +extern const uint8_t *const pocketjs_net_formal_tls_smoke_factory_bytes; +extern const size_t pocketjs_net_formal_tls_smoke_factory_length; +extern const size_t pocketjs_net_formal_tls_smoke_factory_storage_length; + +extern const char pocketjs_net_formal_tls_smoke_plan_hash[]; +extern const uint8_t pocketjs_net_formal_tls_smoke_plan_hash_bytes + [POCKETJS_NETWORK_V1_PLAN_HASH_BYTES]; +extern const pocketjs_network_v1_feature_id_t + pocketjs_net_formal_tls_smoke_feature_ids[]; +extern const uint16_t pocketjs_net_formal_tls_smoke_feature_count; + +/** Exact provider selection copied from the verified Build Plan. */ +extern const char pocketjs_net_formal_tls_smoke_http_client_backend_id[]; +extern const char pocketjs_net_formal_tls_smoke_net_driver_id[]; + +extern const char pocketjs_net_formal_tls_smoke_factory_sha256[]; +extern const uint8_t pocketjs_net_formal_tls_smoke_factory_sha256_bytes[32]; +extern const pocketjs_net_formal_tls_smoke_endpoint_t + pocketjs_net_formal_tls_smoke_endpoint; + +/** Public test CA snapshot. The byte length excludes its trailing NUL. */ +extern const uint8_t pocketjs_net_formal_tls_smoke_ca_pem[]; +extern const size_t pocketjs_net_formal_tls_smoke_ca_pem_length; +extern const size_t pocketjs_net_formal_tls_smoke_ca_pem_storage_length; +extern const char pocketjs_net_formal_tls_smoke_ca_der_sha256[]; +extern const char pocketjs_net_formal_tls_smoke_tls_provider_id[]; + +extern const char pocketjs_net_formal_tls_smoke_report_global[]; +extern const char pocketjs_net_formal_tls_smoke_cancel_global[]; + +esp_err_t pocketjs_net_formal_tls_smoke_read_report( + pocketjs_esp_guest_t *guest, + pocketjs_net_formal_tls_smoke_report_t *out_report); + +/** Invoke the test-only AbortController for the currently active request. */ +esp_err_t +pocketjs_net_formal_tls_smoke_cancel_active_request(pocketjs_esp_guest_t *guest, + bool *out_cancelled); + +typedef struct { + /** Must equal the generated HTTPS origin exactly. */ + const char *configured_origin; + /** Numeric IPv4 expected from stock lwIP DNS for pocketjs.test. */ + const char *configured_peer_ipv4; + /** Host-owned, immutable trusted-clock decision used by ESP-TLS. */ + pocketjs_net_esp_wall_clock_trusted_fn wall_clock_trusted; + void *wall_clock_context; + size_t guest_memory_limit_bytes; + size_t guest_stack_limit_bytes; + uint64_t guest_execution_timeout_us; + uint32_t guest_max_interrupt_checks; + /** Zero selects the artifact's fixed default for each runtime deadline. */ + uint64_t connect_timeout_us; + uint64_t headers_timeout_us; + uint64_t idle_timeout_us; + uint64_t total_timeout_us; + /** Zero disables cancellation; otherwise abort the first active request. */ + uint32_t cancel_after_ms; + uint32_t overall_timeout_ms; + uint32_t shutdown_warning_ms; +} pocketjs_net_formal_tls_smoke_run_config_t; + +typedef struct { + pocketjs_net_formal_tls_smoke_report_t report; + pocketjs_esp_guest_stats_t guest; + pocketjs_net_esp_runtime_stats_t runtime; + uint64_t service_turns; + uint64_t guest_jobs_executed; + uint64_t elapsed_ms; + size_t owner_stack_low_water_bytes; + bool shutdown_complete; +} pocketjs_net_formal_tls_smoke_run_result_t; + +/** + * Run the test-only TLS factory headlessly on a dedicated non-tcpip owner + * task. DNS remains stock lwIP; the product test harness owns DNS server and + * trusted wall-clock provisioning before this call. Public admission remains + * closed, and this runner never calls globalThis.frame. + */ +esp_err_t pocketjs_net_formal_tls_smoke_run( + const pocketjs_net_formal_tls_smoke_run_config_t *config, + pocketjs_net_formal_tls_smoke_run_result_t *out_result); + +#ifdef __cplusplus +} +#endif diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/manifest.json b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/manifest.json new file mode 100644 index 00000000..956ed143 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/manifest.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://pocketjs.dev/schema/pocket-3.json", + "pocket": 3, + "id": "dev.pocketjs.esp-formal-network-tls-smoke", + "name": "esp-formal-network-tls-smoke", + "title": "ESP formal TLS network smoke", + "version": "1.0.0", + "engine": { + "capabilities": { + "requires": [ + "network.http.client", + "network.http.client.tls" + ] + } + }, + "permissions": { + "network": { + "connect": [ + { + "protocol": "https", + "host": "pocketjs.test", + "port": 8443 + } + ], + "listen": [], + "localNetwork": true, + "insecureTransport": false, + "broadcast": false, + "multicast": false, + "allowInvalidTlsForDevelopment": false, + "browserAmbientCredentials": false, + "browserOpaqueWebSocketRedirects": false, + "credentials": [] + } + }, + "resources": { + "network": { + "minimum": { + "runtime": { + "connections": 1, + "pendingOperations": 1, + "completionDescriptors": 8, + "nativeBufferBytes": 65536 + }, + "http": { + "connections": 1, + "inflightRequests": 1, + "headerBytes": 4096, + "headerCount": 16, + "bufferedBodyBytes": 4096 + } + } + } + }, + "app": { + "entry": "app.ts", + "output": "esp-formal-network-tls-smoke", + "framework": "solid", + "viewport": { + "logical": [1, 1], + "presentation": "native" + } + } +} diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/private/formal_tls_smoke_ipv4.h b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/private/formal_tls_smoke_ipv4.h new file mode 100644 index 00000000..9bfac36b --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/private/formal_tls_smoke_ipv4.h @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include + +#include "lwip/ip4_addr.h" + +bool pocketjs_net_formal_tls_smoke_canonical_peer_ipv4(const char *text, + ip4_addr_t *out_address); diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/runtime_probe.ts b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/runtime_probe.ts new file mode 100644 index 00000000..3fb14ed8 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/runtime_probe.ts @@ -0,0 +1,195 @@ +import { join } from "node:path"; +import { + NETWORK_V1_ABI_MAJOR, + NETWORK_V1_ABI_MINOR, + NETWORK_V1_LIMIT_PROTOCOL_ANY, + NETWORK_V1_LIMIT_ROLE_ANY, + NetworkV1CompletionPollStatus, + NetworkV1DispatchStatus, + NetworkV1ErrorCategory, + NetworkV1ErrorCode, + NetworkV1LimitProtocol, + NetworkV1LimitRole, + type NetworkV1BindingTable, + type NetworkV1FeatureId, +} from "../../../../contracts/spec/network/network-v1.ts"; + +const generated = process.env.POCKETJS_TEST_ARTIFACT_DIR; +if (generated === undefined || generated.length === 0) { + throw new Error("POCKETJS_TEST_ARTIFACT_DIR is required"); +} +const metadata = await Bun.file(join(generated, "metadata.json")).json() as { + planHashBytes: number[]; + featureIds: number[]; + reportGlobal: string; + cancelGlobal: string; +}; +const binary = new Uint8Array(await Bun.file( + join(generated, "factory.js.bin"), +).arrayBuffer()); +if (binary.length < 2 || binary[binary.length - 1] !== 0) { + throw new Error("factory storage is not NUL-terminated"); +} + +const featureIds = Object.freeze( + metadata.featureIds.slice() as NetworkV1FeatureId[], +); +const handshake = Object.freeze({ + abiMajor: NETWORK_V1_ABI_MAJOR, + abiMinor: NETWORK_V1_ABI_MINOR, + runtimeGeneration: 1, + planHash: Uint8Array.from(metadata.planHashBytes), + featureIds, +}); +const completed = () => Object.freeze({ status: NetworkV1DispatchStatus.Completed }); +const binding = Object.freeze({ + handshake, + getLimits(query: { runtimeGeneration: number; protocol: number; role: number }) { + const admitted = ( + query.protocol === NETWORK_V1_LIMIT_PROTOCOL_ANY || + query.protocol === NetworkV1LimitProtocol.Http + ) && ( + query.role === NETWORK_V1_LIMIT_ROLE_ANY || + query.role === NetworkV1LimitRole.Client + ); + return Object.freeze({ + runtimeGeneration: 1, + protocol: query.protocol, + role: query.role, + values: Object.freeze(admitted ? [ + Object.freeze({ + name: "http.bufferedBodyBytes", + default: 4096, + hard: 16384, + minimum: 4096, + }), + Object.freeze({ + name: "http.headerBytes", + default: 4096, + hard: 8192, + minimum: 4096, + }), + Object.freeze({ + name: "http.maxBodyChunkBytes", + default: 2048, + hard: 2048, + minimum: 512, + }), + Object.freeze({ + name: "http.maxOperations", + default: 1, + hard: 1, + minimum: 1, + }), + Object.freeze({ + name: "runtime.nativeBufferBytes", + default: 65536, + hard: 524288, + minimum: 65536, + }), + ] : []), + featureIds: Object.freeze(admitted ? featureIds.slice() : []), + }); + }, + dispatch(command: any) { + const tls = command?.metadata?.tls; + if ( + command?.metadata?.url !== "https://pocketjs.test:8443/health" || + command?.metadata?.method !== "GET" || + command?.metadata?.redirect !== 1 || + tls?.serverName !== "pocketjs.test" || + tls?.minVersion !== 258 || + tls?.maxVersion !== 258 || + !Array.isArray(tls?.alpn) || + tls.alpn.length !== 0 || + tls?.credential !== "" || + tls?.clientCertificate !== 1 || + tls?.verification !== 1 || + tls?.revocation !== 1 || + tls?.customCaBytes !== 0 + ) { + throw new Error(`unexpected TLS metadata: ${JSON.stringify(command?.metadata)}`); + } + return Object.freeze({ + status: NetworkV1DispatchStatus.Refused, + error: Object.freeze({ + category: NetworkV1ErrorCategory.Runtime, + code: NetworkV1ErrorCode.PermissionDenied, + operation: "http.request.start", + temporary: false, + }), + }); + }, + nextCompletion() { + return Object.freeze({ + status: NetworkV1CompletionPollStatus.Drained, + payloadBytesDelivered: 0, + }); + }, + leaseTake(command: { byteLength: number }) { + return Object.freeze({ + status: NetworkV1DispatchStatus.Completed, + byteLength: command.byteLength, + }); + }, + leaseReadInto(command: { maxBytes: number }) { + return Object.freeze({ + status: NetworkV1DispatchStatus.Completed, + bytesCopied: command.maxBytes, + }); + }, + leaseRelease: completed, + registerServiceDispatcher(dispatcher: unknown) { + if (typeof dispatcher !== "function") { + throw new TypeError("service dispatcher was not installed"); + } + }, +}) as unknown as NetworkV1BindingTable; + +const source = new TextDecoder().decode(binary.subarray(0, binary.length - 1)); +const factory = (0, eval)(source) as (binding: NetworkV1BindingTable) => unknown; +if (typeof factory !== "function" || factory.length !== 0) { + throw new Error("generated artifact did not evaluate to the factory ABI"); +} +if ( + Object.hasOwn(globalThis, metadata.reportGlobal) || + Object.hasOwn(globalThis, metadata.cancelGlobal) || + Object.hasOwn(globalThis, "frame") +) { + throw new Error("application initialized before the factory call"); +} +factory(binding); +for (let checkpoint = 0; checkpoint < 20; checkpoint += 1) { + await Promise.resolve(); +} + +const reportFunction = (globalThis as Record)[metadata.reportGlobal]; +const cancelFunction = (globalThis as Record)[metadata.cancelGlobal]; +if ( + typeof reportFunction !== "function" || + typeof cancelFunction !== "function" || + typeof globalThis.frame !== "function" +) { + throw new Error("factory did not install its report, cancel, and frame slots"); +} +const report = (reportFunction as () => Record)(); +if ( + !Object.isFrozen(report) || + report.version !== 1 || + report.phase !== "failed" || + report.done !== true || + report.ok !== false || + report.roundsTotal !== 20 || + report.roundsStarted !== 1 || + report.roundsPassed !== 0 || + report.requestsPassed !== 0 || + report.frameCalls !== 0 || + report.errorCode !== "permission_denied" +) { + throw new Error(`unexpected refusal report: ${JSON.stringify(report)}`); +} +if ((cancelFunction as () => boolean)() !== false) { + throw new Error("completed request unexpectedly remained cancellable"); +} + +console.log(JSON.stringify(report)); diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/src/formal_tls_smoke_ipv4.c b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/src/formal_tls_smoke_ipv4.c new file mode 100644 index 00000000..66685b95 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/src/formal_tls_smoke_ipv4.c @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT + +#include "formal_tls_smoke_ipv4.h" + +#include + +bool pocketjs_net_formal_tls_smoke_canonical_peer_ipv4( + const char *text, ip4_addr_t *out_address) { + ip4_addr_t address = {0}; + char canonical[16] = {0}; + if (text == NULL || !ip4addr_aton(text, &address) || + address.addr == IPADDR_ANY || + ip4addr_ntoa_r(&address, canonical, sizeof(canonical)) == NULL || + strcmp(text, canonical) != 0) { + return false; + } + if (out_address != NULL) { + *out_address = address; + } + return true; +} diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/src/formal_tls_smoke_report.c b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/src/formal_tls_smoke_report.c new file mode 100644 index 00000000..16d0c631 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/src/formal_tls_smoke_report.c @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: MIT + +#include "pocketjs/net/formal_tls_smoke_artifact.h" + +#include +#include + +#include "quickjs.h" + +#define REPORT_ENUMERABLE_FIELDS 13U + +static void free_descriptor(JSContext *context, + JSPropertyDescriptor *descriptor) { + JS_FreeValue(context, descriptor->value); + JS_FreeValue(context, descriptor->getter); + JS_FreeValue(context, descriptor->setter); +} + +static bool own_data_property(JSContext *context, JSValueConst object, + const char *name, JSValue *out_value) { + *out_value = JS_UNDEFINED; + if (!JS_IsObject(object) || JS_IsProxy(object)) { + return false; + } + JSAtom atom = JS_NewAtom(context, name); + if (atom == JS_ATOM_NULL) { + return false; + } + JSPropertyDescriptor descriptor = {0}; + const int result = JS_GetOwnProperty(context, &descriptor, object, atom); + JS_FreeAtom(context, atom); + if (result != 1 || (descriptor.flags & JS_PROP_GETSET) != 0) { + if (result == 1) { + free_descriptor(context, &descriptor); + } + return false; + } + *out_value = descriptor.value; + JS_FreeValue(context, descriptor.getter); + JS_FreeValue(context, descriptor.setter); + return true; +} + +static bool read_u32(JSContext *context, JSValueConst object, const char *name, + uint32_t maximum, uint32_t *out_value) { + JSValue value = JS_UNDEFINED; + if (!own_data_property(context, object, name, &value) || + !JS_IsNumber(value)) { + JS_FreeValue(context, value); + return false; + } + double number = 0.0; + const bool valid = JS_ToFloat64(context, &number, value) == 0 && + isfinite(number) && number >= 0.0 && + number <= (double)maximum && number == floor(number); + JS_FreeValue(context, value); + if (!valid) { + return false; + } + *out_value = (uint32_t)number; + return true; +} + +static bool read_bool(JSContext *context, JSValueConst object, const char *name, + bool *out_value) { + JSValue value = JS_UNDEFINED; + if (!own_data_property(context, object, name, &value) || !JS_IsBool(value)) { + JS_FreeValue(context, value); + return false; + } + const int result = JS_ToBool(context, value); + JS_FreeValue(context, value); + if (result < 0) { + return false; + } + *out_value = result != 0; + return true; +} + +static bool read_ascii(JSContext *context, JSValueConst object, + const char *name, char *out_value, + size_t output_capacity) { + JSValue value = JS_UNDEFINED; + if (!own_data_property(context, object, name, &value) || + !JS_IsString(value)) { + JS_FreeValue(context, value); + return false; + } + size_t length = 0U; + const char *text = JS_ToCStringLen2(context, &length, value, false); + JS_FreeValue(context, value); + if (text == NULL) { + return false; + } + bool valid = length < output_capacity; + for (size_t index = 0U; valid && index < length; ++index) { + const unsigned char byte = (unsigned char)text[index]; + valid = byte >= 0x20U && byte <= 0x7eU; + } + if (valid) { + memcpy(out_value, text, length); + out_value[length] = '\0'; + } + JS_FreeCString(context, text); + return valid; +} + +static bool read_phase(JSContext *context, JSValueConst report, + pocketjs_net_formal_tls_smoke_phase_t *out_phase) { + char phase[16] = {0}; + if (!read_ascii(context, report, "phase", phase, sizeof(phase))) { + return false; + } + if (strcmp(phase, "starting") == 0) { + *out_phase = POCKETJS_NET_FORMAL_TLS_SMOKE_PHASE_STARTING; + } else if (strcmp(phase, "health") == 0) { + *out_phase = POCKETJS_NET_FORMAL_TLS_SMOKE_PHASE_HEALTH; + } else if (strcmp(phase, "echo") == 0) { + *out_phase = POCKETJS_NET_FORMAL_TLS_SMOKE_PHASE_ECHO; + } else if (strcmp(phase, "passed") == 0) { + *out_phase = POCKETJS_NET_FORMAL_TLS_SMOKE_PHASE_PASSED; + } else if (strcmp(phase, "failed") == 0) { + *out_phase = POCKETJS_NET_FORMAL_TLS_SMOKE_PHASE_FAILED; + } else { + return false; + } + return true; +} + +static bool exact_enumerable_field_count(JSContext *context, + JSValueConst report) { + JSPropertyEnum *properties = NULL; + uint32_t count = 0U; + if (JS_GetOwnPropertyNames(context, &properties, &count, report, + JS_GPN_STRING_MASK | JS_GPN_ENUM_ONLY) < 0) { + return false; + } + for (uint32_t index = 0U; index < count; ++index) { + JS_FreeAtom(context, properties[index].atom); + } + js_free(context, properties); + return count == REPORT_ENUMERABLE_FIELDS; +} + +static bool parse_report(JSContext *context, JSValueConst value, + pocketjs_net_formal_tls_smoke_report_t *out_report) { + uint32_t version = 0U; + uint32_t rounds_total = 0U; + if (!JS_IsObject(value) || JS_IsProxy(value) || + !exact_enumerable_field_count(context, value) || + !read_u32(context, value, "version", 1U, &version) || version != 1U || + !read_u32(context, value, "checkpoint", UINT32_MAX, + &out_report->checkpoint) || + !read_phase(context, value, &out_report->phase) || + !read_u32(context, value, "roundsTotal", + POCKETJS_NET_FORMAL_TLS_SMOKE_ROUNDS, &rounds_total) || + rounds_total != POCKETJS_NET_FORMAL_TLS_SMOKE_ROUNDS || + !read_u32(context, value, "roundsStarted", + POCKETJS_NET_FORMAL_TLS_SMOKE_ROUNDS, + &out_report->rounds_started) || + !read_u32(context, value, "roundsPassed", + POCKETJS_NET_FORMAL_TLS_SMOKE_ROUNDS, + &out_report->rounds_passed) || + !read_u32(context, value, "requestsPassed", + POCKETJS_NET_FORMAL_TLS_SMOKE_REQUESTS, + &out_report->requests_passed) || + !read_u32(context, value, "frameCalls", UINT32_MAX, + &out_report->frame_calls) || + !read_bool(context, value, "done", &out_report->done) || + !read_bool(context, value, "ok", &out_report->ok) || + !read_ascii(context, value, "errorName", out_report->error_name, + sizeof(out_report->error_name)) || + !read_ascii(context, value, "errorCode", out_report->error_code, + sizeof(out_report->error_code)) || + !read_ascii(context, value, "errorOperation", out_report->error_operation, + sizeof(out_report->error_operation))) { + return false; + } + + if (out_report->rounds_passed > out_report->rounds_started || + out_report->requests_passed < out_report->rounds_passed * 2U || + out_report->requests_passed > out_report->rounds_started * 2U) { + return false; + } + if (out_report->done) { + if (out_report->ok) { + return out_report->phase == POCKETJS_NET_FORMAL_TLS_SMOKE_PHASE_PASSED && + out_report->rounds_started == + POCKETJS_NET_FORMAL_TLS_SMOKE_ROUNDS && + out_report->rounds_passed == + POCKETJS_NET_FORMAL_TLS_SMOKE_ROUNDS && + out_report->requests_passed == + POCKETJS_NET_FORMAL_TLS_SMOKE_REQUESTS && + out_report->error_name[0] == '\0' && + out_report->error_code[0] == '\0' && + out_report->error_operation[0] == '\0'; + } + return out_report->phase == POCKETJS_NET_FORMAL_TLS_SMOKE_PHASE_FAILED; + } + return !out_report->ok && + out_report->phase != POCKETJS_NET_FORMAL_TLS_SMOKE_PHASE_PASSED && + out_report->phase != POCKETJS_NET_FORMAL_TLS_SMOKE_PHASE_FAILED; +} + +esp_err_t pocketjs_net_formal_tls_smoke_read_report( + pocketjs_esp_guest_t *guest, + pocketjs_net_formal_tls_smoke_report_t *out_report) { + if (guest == NULL || out_report == NULL) { + return ESP_ERR_INVALID_ARG; + } + memset(out_report, 0, sizeof(*out_report)); + JSContext *context = pocketjs_esp_guest_context(guest); + if (context == NULL) { + return ESP_ERR_INVALID_STATE; + } + + JSValue global = JS_GetGlobalObject(context); + JSValue function = JS_UNDEFINED; + JSValue result = JS_UNDEFINED; + if (JS_IsException(global) || + !own_data_property(context, global, + pocketjs_net_formal_tls_smoke_report_global, + &function) || + !JS_IsFunction(context, function)) { + JS_FreeValue(context, function); + JS_FreeValue(context, global); + if (JS_HasException(context)) { + pocketjs_esp_guest_log_exception(guest, "formal_tls_smoke_report_lookup"); + } + return ESP_ERR_INVALID_STATE; + } + + const esp_err_t call_result = pocketjs_esp_guest_call_function( + guest, "formal_tls_smoke_report", function, global, 0U, NULL, &result); + JS_FreeValue(context, function); + JS_FreeValue(context, global); + if (call_result != ESP_OK) { + JS_FreeValue(context, result); + return call_result; + } + + const bool valid = parse_report(context, result, out_report); + JS_FreeValue(context, result); + if (!valid) { + memset(out_report, 0, sizeof(*out_report)); + if (JS_HasException(context)) { + pocketjs_esp_guest_log_exception(guest, "formal_tls_smoke_report_parse"); + } + return ESP_ERR_INVALID_RESPONSE; + } + return ESP_OK; +} + +esp_err_t +pocketjs_net_formal_tls_smoke_cancel_active_request(pocketjs_esp_guest_t *guest, + bool *out_cancelled) { + if (guest == NULL || out_cancelled == NULL) { + return ESP_ERR_INVALID_ARG; + } + *out_cancelled = false; + JSContext *context = pocketjs_esp_guest_context(guest); + if (context == NULL) { + return ESP_ERR_INVALID_STATE; + } + + JSValue global = JS_GetGlobalObject(context); + JSValue function = JS_UNDEFINED; + JSValue result = JS_UNDEFINED; + if (JS_IsException(global) || + !own_data_property(context, global, + pocketjs_net_formal_tls_smoke_cancel_global, + &function) || + !JS_IsFunction(context, function)) { + JS_FreeValue(context, function); + JS_FreeValue(context, global); + if (JS_HasException(context)) { + pocketjs_esp_guest_log_exception(guest, "formal_tls_smoke_cancel_lookup"); + } + return ESP_ERR_INVALID_STATE; + } + + const esp_err_t call_result = pocketjs_esp_guest_call_function( + guest, "formal_tls_smoke_cancel", function, global, 0U, NULL, &result); + JS_FreeValue(context, function); + JS_FreeValue(context, global); + if (call_result != ESP_OK) { + JS_FreeValue(context, result); + return call_result; + } + + const int cancelled = JS_ToBool(context, result); + JS_FreeValue(context, result); + if (cancelled < 0) { + if (JS_HasException(context)) { + pocketjs_esp_guest_log_exception(guest, "formal_tls_smoke_cancel_result"); + } + return ESP_ERR_INVALID_RESPONSE; + } + *out_cancelled = cancelled != 0; + return ESP_OK; +} diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/src/formal_tls_smoke_runner.c b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/src/formal_tls_smoke_runner.c new file mode 100644 index 00000000..f43e18f8 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/src/formal_tls_smoke_runner.c @@ -0,0 +1,442 @@ +// SPDX-License-Identifier: MIT + +#include "pocketjs/net/formal_tls_smoke_artifact.h" + +#include "formal_tls_smoke_ipv4.h" + +#include +#include + +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "lwip/ip4_addr.h" +#include "quickjs.h" + +#define FORMAL_TLS_SMOKE_RUNTIME_GENERATION 1U +#define FORMAL_TLS_SMOKE_MAX_OPERATIONS 1U +#define FORMAL_TLS_SMOKE_SERVICE_NATIVE_STEPS 16U +#define FORMAL_TLS_SMOKE_SERVICE_COMPLETIONS 16U +#define FORMAL_TLS_SMOKE_SERVICE_EVENTS 16U +#define FORMAL_TLS_SMOKE_SERVICE_PAYLOAD_BYTES 4096U +#define FORMAL_TLS_SMOKE_GUEST_JOBS 32U +#define FORMAL_TLS_SMOKE_WAIT_MS 10U +#define FORMAL_TLS_SMOKE_CONNECT_TIMEOUT_US UINT64_C(10000000) +#define FORMAL_TLS_SMOKE_HEADERS_TIMEOUT_US UINT64_C(10000000) +#define FORMAL_TLS_SMOKE_IDLE_TIMEOUT_US UINT64_C(10000000) +#define FORMAL_TLS_SMOKE_TOTAL_TIMEOUT_US UINT64_C(30000000) + +static const char *const TAG = "pocketjs_formal_tls_smoke"; + +typedef struct { + TaskHandle_t owner_task; + uint32_t expected_ipv4_be; +} formal_tls_smoke_host_context_t; + +static uint64_t monotonic_us(void) { + const int64_t now = esp_timer_get_time(); + return now > 0 ? (uint64_t)now : UINT64_C(1); +} + +static uint64_t deadline_after_ms(uint64_t now_us, uint32_t milliseconds) { + const uint64_t delta = (uint64_t)milliseconds * UINT64_C(1000); + return delta > UINT64_MAX - now_us ? UINT64_MAX : now_us + delta; +} + +static uint64_t selected_timeout(uint64_t configured, uint64_t fallback) { + return configured == 0U ? fallback : configured; +} + +static void wake_owner(void *context) { + formal_tls_smoke_host_context_t *host = context; + if (host != NULL && host->owner_task != NULL) { + xTaskNotifyGive(host->owner_task); + } +} + +static bool +allow_endpoint(void *context, + const pocketjs_net_http_client_endpoint_t *endpoint) { + const formal_tls_smoke_host_context_t *host = context; + if (host == NULL || endpoint == NULL || + endpoint->scheme != POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTPS || + endpoint->hostname == NULL || + strcmp(endpoint->hostname, pocketjs_net_formal_tls_smoke_endpoint.host) != + 0 || + endpoint->port != pocketjs_net_formal_tls_smoke_endpoint.port) { + return false; + } + if (endpoint->phase == POCKETJS_NET_HTTP_CLIENT_PERMISSION_HOSTNAME) { + return endpoint->ipv4_be == 0U; + } + return endpoint->phase == + POCKETJS_NET_HTTP_CLIENT_PERMISSION_NUMERIC_CANDIDATE && + endpoint->ipv4_be == host->expected_ipv4_be; +} + +static bool +valid_config(const pocketjs_net_formal_tls_smoke_run_config_t *config, + pocketjs_net_formal_tls_smoke_run_result_t *out_result) { + const pocketjs_net_esp_runtime_descriptor_t *descriptor = + pocketjs_net_esp_runtime_descriptor(); + size_t required_native_bytes = 0U; + return config != NULL && out_result != NULL && + config->configured_origin != NULL && + strcmp(config->configured_origin, + pocketjs_net_formal_tls_smoke_endpoint.origin) == 0 && + pocketjs_net_formal_tls_smoke_canonical_peer_ipv4( + config->configured_peer_ipv4, NULL) && + config->wall_clock_trusted != NULL && descriptor != NULL && + !descriptor->advertises_public_capability && + descriptor->https_explicit_opt_in && + descriptor->exact_host_tls_profile && descriptor->connection_reuse && + descriptor->bounded_connection_pool && + descriptor->max_cached_connections == descriptor->max_operations && + descriptor->pocketjs_owned_native_buffer_floor_enforced && + pocketjs_net_esp_runtime_required_native_buffer_bytes( + FORMAL_TLS_SMOKE_MAX_OPERATIONS, &required_native_bytes) && + required_native_bytes <= 524288U && descriptor->distinct_tls_errors && + descriptor->tls_close_notify && + descriptor->tls_close_notify_uses_operation_deadline && + !descriptor->tls_close_notify_waits_for_peer && + descriptor->tls_provider_id != NULL && + strcmp(descriptor->tls_provider_id, + pocketjs_net_formal_tls_smoke_tls_provider_id) == 0 && + config->guest_memory_limit_bytes >= + POCKETJS_NET_FORMAL_TLS_SMOKE_MIN_GUEST_MEMORY_BYTES && + config->guest_stack_limit_bytes >= + POCKETJS_NET_FORMAL_TLS_SMOKE_MIN_GUEST_STACK_BYTES && + config->guest_execution_timeout_us != 0U && + config->guest_max_interrupt_checks != 0U && + (config->cancel_after_ms == 0U || + config->cancel_after_ms < config->overall_timeout_ms) && + config->overall_timeout_ms != 0U && + config->shutdown_warning_ms != 0U && + pocketjs_net_formal_tls_smoke_factory_bytes != NULL && + pocketjs_net_formal_tls_smoke_factory_storage_length == + pocketjs_net_formal_tls_smoke_factory_length + 1U && + pocketjs_net_formal_tls_smoke_factory_bytes + [pocketjs_net_formal_tls_smoke_factory_length] == 0U && + pocketjs_net_formal_tls_smoke_ca_pem_length > 0U && + pocketjs_net_formal_tls_smoke_ca_pem_storage_length == + pocketjs_net_formal_tls_smoke_ca_pem_length + 1U && + pocketjs_net_formal_tls_smoke_ca_pem + [pocketjs_net_formal_tls_smoke_ca_pem_length] == 0U && + memchr(pocketjs_net_formal_tls_smoke_ca_pem, '\0', + pocketjs_net_formal_tls_smoke_ca_pem_length) == NULL && + pocketjs_net_formal_tls_smoke_feature_count == 2U && + pocketjs_net_formal_tls_smoke_feature_ids[0] == + POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT && + pocketjs_net_formal_tls_smoke_feature_ids[1] == + POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_TLS; +} + +static bool runtime_idle(const pocketjs_net_esp_runtime_stats_t *stats) { + return stats->active_operations == 0U && stats->pending_core_events == 0U && + stats->queued_leases == 0U && stats->taken_leases == 0U; +} + +static esp_err_t +service_once(pocketjs_net_esp_runtime_t *runtime, pocketjs_esp_guest_t *guest, + pocketjs_net_formal_tls_smoke_run_result_t *result, + bool *out_more_ready, bool *out_jobs_pending) { + pocketjs_net_esp_runtime_service_result_t service = {0}; + esp_err_t status = pocketjs_net_esp_runtime_service( + runtime, monotonic_us(), FORMAL_TLS_SMOKE_SERVICE_NATIVE_STEPS, + FORMAL_TLS_SMOKE_SERVICE_COMPLETIONS, FORMAL_TLS_SMOKE_SERVICE_EVENTS, + FORMAL_TLS_SMOKE_SERVICE_PAYLOAD_BYTES, &service); + ++result->service_turns; + if (status != ESP_OK) { + *out_more_ready = true; + *out_jobs_pending = false; + return status; + } + + size_t executed = 0U; + bool pending = false; + status = pocketjs_esp_guest_execute_jobs(guest, FORMAL_TLS_SMOKE_GUEST_JOBS, + &executed, &pending); + result->guest_jobs_executed += executed; + *out_more_ready = + service.status != POCKETJS_NETWORK_V1_SERVICE_TURN_STATUS_DRAINED; + *out_jobs_pending = pending; + return status; +} + +static esp_err_t +shutdown_runtime(pocketjs_net_esp_runtime_t *runtime, + pocketjs_esp_guest_t *guest, uint32_t warning_ms, + pocketjs_net_formal_tls_smoke_run_result_t *result) { + esp_err_t first_error = ESP_OK; + esp_err_t status = + pocketjs_net_esp_runtime_begin_shutdown(runtime, monotonic_us()); + if (status != ESP_OK) { + first_error = status; + } + const uint64_t warning_deadline = + deadline_after_ms(monotonic_us(), warning_ms); + bool warning_logged = false; + while (!pocketjs_net_esp_runtime_is_ready_to_destroy(runtime)) { + if (!warning_logged && monotonic_us() >= warning_deadline) { + ESP_LOGE(TAG, "POCKET_NET_FORMAL_SHUTDOWN_FAIL_STOP first_error=%s", + esp_err_to_name(first_error)); + warning_logged = true; + } + bool more_ready = false; + bool jobs_pending = false; + status = service_once(runtime, guest, result, &more_ready, &jobs_pending); + if (status != ESP_OK && first_error == ESP_OK) { + first_error = status; + } + if (!more_ready && !jobs_pending) { + (void)ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(FORMAL_TLS_SMOKE_WAIT_MS)); + } + } + status = pocketjs_net_esp_runtime_get_stats(runtime, &result->runtime); + if (status != ESP_OK && first_error == ESP_OK) { + first_error = status; + } + while ((status = pocketjs_net_esp_runtime_destroy(runtime)) != ESP_OK) { + if (first_error == ESP_OK) { + first_error = status; + } + ESP_LOGE(TAG, "POCKET_NET_FORMAL_DESTROY_FAIL_STOP esp_err=%s", + esp_err_to_name(status)); + vTaskDelay(pdMS_TO_TICKS(FORMAL_TLS_SMOKE_WAIT_MS)); + } + result->shutdown_complete = true; + return first_error; +} + +esp_err_t pocketjs_net_formal_tls_smoke_run( + const pocketjs_net_formal_tls_smoke_run_config_t *config, + pocketjs_net_formal_tls_smoke_run_result_t *out_result) { + if (out_result == NULL) { + return ESP_ERR_INVALID_ARG; + } + memset(out_result, 0, sizeof(*out_result)); + if (!valid_config(config, out_result) || + heap_caps_get_total_size(MALLOC_CAP_SPIRAM) == 0U) { + return ESP_ERR_INVALID_ARG; + } + const uint64_t started_us = monotonic_us(); + esp_err_t run_error = ESP_OK; + pocketjs_esp_guest_t *guest = NULL; + pocketjs_net_esp_runtime_t *runtime = NULL; + JSValue binding = JS_UNDEFINED; + bool binding_owned = false; + + formal_tls_smoke_host_context_t host = { + .owner_task = xTaskGetCurrentTaskHandle(), + }; + ip4_addr_t expected_address = {0}; + if (host.owner_task == NULL || + !pocketjs_net_formal_tls_smoke_canonical_peer_ipv4( + config->configured_peer_ipv4, &expected_address)) { + return ESP_ERR_INVALID_ARG; + } + host.expected_ipv4_be = expected_address.addr; + (void)ulTaskNotifyTake(pdTRUE, 0U); + + const pocketjs_esp_guest_config_t guest_config = { + .memory_limit_bytes = config->guest_memory_limit_bytes, + .stack_limit_bytes = config->guest_stack_limit_bytes, + .allocate_in_external_memory = true, + .execution_timeout_us = config->guest_execution_timeout_us, + .max_interrupt_checks = config->guest_max_interrupt_checks, + }; + run_error = pocketjs_esp_guest_create(&guest_config, &guest); + if (run_error != ESP_OK) { + goto finish; + } + + pocketjs_net_esp_runtime_config_t runtime_config = { + .guest = guest, + .runtime_generation = FORMAL_TLS_SMOKE_RUNTIME_GENERATION, + .feature_ids = pocketjs_net_formal_tls_smoke_feature_ids, + .feature_count = pocketjs_net_formal_tls_smoke_feature_count, + .providers = + { + .http_client_backend_id = + pocketjs_net_formal_tls_smoke_http_client_backend_id, + .net_driver_id = pocketjs_net_formal_tls_smoke_net_driver_id, + .http_client_tls_source = + POCKETJS_NET_ESP_RUNTIME_TLS_SELECTION_PROVIDER, + .http_client_tls_id = + pocketjs_net_formal_tls_smoke_tls_provider_id, + }, + .admission = POCKETJS_NET_ESP_RUNTIME_ADMISSION_TEST_ONLY, + .max_operations = FORMAL_TLS_SMOKE_MAX_OPERATIONS, + .limits = + { + .buffered_body_bytes = {4096U, 4096U, 16384U}, + .header_bytes = {4096U, 4096U, 8192U}, + .max_body_chunk_bytes = {512U, 2048U, 2048U}, + .max_operations = {1U, 1U, 1U}, + .native_buffer_bytes = {65536U, 524288U, 524288U}, + }, + .connect_timeout_us = selected_timeout( + config->connect_timeout_us, FORMAL_TLS_SMOKE_CONNECT_TIMEOUT_US), + .headers_timeout_us = selected_timeout( + config->headers_timeout_us, FORMAL_TLS_SMOKE_HEADERS_TIMEOUT_US), + .idle_timeout_us = selected_timeout(config->idle_timeout_us, + FORMAL_TLS_SMOKE_IDLE_TIMEOUT_US), + .total_timeout_us = selected_timeout(config->total_timeout_us, + FORMAL_TLS_SMOKE_TOTAL_TIMEOUT_US), + .tls_trust_source = POCKETJS_NET_ESP_TLS_TRUST_HOST_PINNED_CA, + .host_pinned_ca_pem = pocketjs_net_formal_tls_smoke_ca_pem, + .host_pinned_ca_pem_bytes = pocketjs_net_formal_tls_smoke_ca_pem_length, + .wall_clock_trusted = config->wall_clock_trusted, + .wall_clock_context = config->wall_clock_context, + .wake = wake_owner, + .wake_context = &host, + .allow_endpoint = allow_endpoint, + .permission_context = &host, + }; + memcpy(runtime_config.plan_hash, + pocketjs_net_formal_tls_smoke_plan_hash_bytes, + sizeof(runtime_config.plan_hash)); + run_error = pocketjs_net_esp_runtime_create(&runtime_config, &runtime); + if (run_error != ESP_OK) { + goto finish; + } + run_error = pocketjs_net_esp_runtime_get_binding(runtime, &binding); + if (run_error != ESP_OK) { + goto shutdown; + } + binding_owned = true; + run_error = pocketjs_esp_guest_mount_factory( + guest, "esp-formal-network-tls-smoke.js", + (const char *)pocketjs_net_formal_tls_smoke_factory_bytes, + pocketjs_net_formal_tls_smoke_factory_length, binding, NULL); + JS_FreeValue(pocketjs_esp_guest_context(guest), binding); + binding_owned = false; + if (run_error != ESP_OK) { + goto shutdown; + } + + const uint64_t deadline = + deadline_after_ms(started_us, config->overall_timeout_ms); + bool application_complete = false; + bool cancel_invoked = false; + uint64_t cancel_deadline = 0U; + while (monotonic_us() < deadline) { + bool more_ready = false; + bool jobs_pending = false; + run_error = + service_once(runtime, guest, out_result, &more_ready, &jobs_pending); + if (run_error != ESP_OK) { + break; + } + run_error = + pocketjs_net_formal_tls_smoke_read_report(guest, &out_result->report); + if (run_error != ESP_OK) { + break; + } + if (config->cancel_after_ms != 0U && !cancel_invoked && + !out_result->report.done && + out_result->report.phase == + POCKETJS_NET_FORMAL_TLS_SMOKE_PHASE_HEALTH) { + const uint64_t now = monotonic_us(); + if (cancel_deadline == 0U) { + cancel_deadline = deadline_after_ms(now, config->cancel_after_ms); + } + if (now >= cancel_deadline) { + bool cancelled = false; + run_error = pocketjs_net_formal_tls_smoke_cancel_active_request( + guest, &cancelled); + if (run_error != ESP_OK || !cancelled) { + if (run_error == ESP_OK) { + run_error = ESP_ERR_INVALID_STATE; + } + break; + } + cancel_invoked = true; + } + } + run_error = + pocketjs_net_esp_runtime_get_stats(runtime, &out_result->runtime); + if (run_error != ESP_OK) { + break; + } + if (out_result->report.done && !jobs_pending && + runtime_idle(&out_result->runtime)) { + application_complete = true; + break; + } + if (!more_ready && !jobs_pending) { + (void)ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(FORMAL_TLS_SMOKE_WAIT_MS)); + } + } + if (!application_complete && run_error == ESP_OK) { + run_error = ESP_ERR_TIMEOUT; + } + +shutdown: { + const esp_err_t shutdown_error = + shutdown_runtime(runtime, guest, config->shutdown_warning_ms, out_result); + runtime = NULL; + if (run_error == ESP_OK && shutdown_error != ESP_OK) { + run_error = shutdown_error; + } +} + +finish: + if (binding_owned && guest != NULL) { + JS_FreeValue(pocketjs_esp_guest_context(guest), binding); + } + if (runtime == NULL && guest != NULL) { + const esp_err_t stats_error = + pocketjs_esp_guest_get_stats(guest, &out_result->guest); + if (run_error == ESP_OK && stats_error != ESP_OK) { + run_error = stats_error; + } + pocketjs_esp_guest_destroy(guest); + } + out_result->owner_stack_low_water_bytes = + uxTaskGetStackHighWaterMark(NULL) * sizeof(StackType_t); + out_result->elapsed_ms = (monotonic_us() - started_us) / UINT64_C(1000); + + const bool successful = + run_error == ESP_OK && out_result->shutdown_complete && + out_result->report.done && out_result->report.ok && + out_result->report.frame_calls == 0U && + out_result->report.rounds_passed == + POCKETJS_NET_FORMAL_TLS_SMOKE_ROUNDS && + out_result->report.requests_passed == + POCKETJS_NET_FORMAL_TLS_SMOKE_REQUESTS && + out_result->runtime.active_operations == 0U && + out_result->runtime.pending_core_events == 0U && + out_result->runtime.queued_leases == 0U && + out_result->runtime.taken_leases == 0U && + out_result->runtime.leases_taken == out_result->runtime.leases_released && + out_result->runtime.poison_flags == 0U && + out_result->guest.cooperative_yields > 0U && + out_result->runtime.requests_started == + POCKETJS_NET_FORMAL_TLS_SMOKE_REQUESTS; + if (!successful && run_error == ESP_OK) { + run_error = ESP_ERR_INVALID_RESPONSE; + } + ESP_LOGI(TAG, + "POCKET_NET_FORMAL_TLS_RUN status=%s rounds=%u/%u requests=%u/%u " + "frame_calls=%u service_turns=%llu jobs=%llu shutdown=%d " + "poison=0x%08x core_poison=0x%08x poisoned_cores=%u " + "core_cause=%ld guest_yields=%u elapsed_ms=%llu", + esp_err_to_name(run_error), + (unsigned)out_result->report.rounds_passed, + (unsigned)POCKETJS_NET_FORMAL_TLS_SMOKE_ROUNDS, + (unsigned)out_result->report.requests_passed, + (unsigned)POCKETJS_NET_FORMAL_TLS_SMOKE_REQUESTS, + (unsigned)out_result->report.frame_calls, + (unsigned long long)out_result->service_turns, + (unsigned long long)out_result->guest_jobs_executed, + out_result->shutdown_complete, out_result->runtime.poison_flags, + out_result->runtime.core_poison_flags, + (unsigned)out_result->runtime.poisoned_core_slots, + (long)out_result->runtime.first_core_poison_cause_code, + (unsigned)out_result->guest.cooperative_yields, + (unsigned long long)out_result->elapsed_ms); + return run_error; +} diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/CMakeLists.txt new file mode 100644 index 00000000..30e62319 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.16) + +set(EXTRA_COMPONENT_DIRS + "../.." + "../../../pocketjs_esp_guest" + "../../../pocketjs_net_http1" + "../../../pocketjs_net_esp_transport" + "../../../pocketjs_net_http_client_core" + "../../../pocketjs_net_esp_runtime") +set(SDKCONFIG_DEFAULTS "sdkconfig.defaults") +set(SDKCONFIG "${CMAKE_BINARY_DIR}/sdkconfig") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(pocketjs_net_formal_tls_smoke_build_smoke) diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/main/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/main/CMakeLists.txt new file mode 100644 index 00000000..220874be --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/main/CMakeLists.txt @@ -0,0 +1,7 @@ +idf_component_register( + SRCS "build_smoke.c" + INCLUDE_DIRS "." + REQUIRES pocketjs_net_formal_tls_smoke_artifact +) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/main/build_smoke.c b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/main/build_smoke.c new file mode 100644 index 00000000..307f393f --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/main/build_smoke.c @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT + +#include +#include + +#include "pocketjs/net/formal_tls_smoke_artifact.h" + +static bool trusted_wall_clock(void *context) { return context != NULL; } + +void app_main(void) { + assert(pocketjs_net_formal_tls_smoke_factory_bytes != NULL); + assert(pocketjs_net_formal_tls_smoke_factory_storage_length == + pocketjs_net_formal_tls_smoke_factory_length + 1U); + assert(pocketjs_net_formal_tls_smoke_factory_bytes + [pocketjs_net_formal_tls_smoke_factory_length] == 0U); + assert(pocketjs_net_formal_tls_smoke_feature_count == 2U); + assert(pocketjs_net_formal_tls_smoke_feature_ids[0] == + POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT); + assert(pocketjs_net_formal_tls_smoke_feature_ids[1] == + POCKETJS_NETWORK_V1_FEATURE_HTTP_CLIENT_TLS); + assert(strcmp(pocketjs_net_formal_tls_smoke_endpoint.origin, + "https://pocketjs.test:8443") == 0); + assert(pocketjs_net_formal_tls_smoke_ca_pem_length == 1176U); + assert(pocketjs_net_formal_tls_smoke_ca_pem_storage_length == 1177U); + assert(pocketjs_net_formal_tls_smoke_ca_pem[1176] == 0U); + assert(strcmp(pocketjs_net_formal_tls_smoke_ca_der_sha256, + "sha256:318ae57f0fb82d12cf86431571fb6ec3556ecb74f530a5be" + "6f741a482b5447af") == 0); + assert(strcmp(pocketjs_net_formal_tls_smoke_report_global, + "__pocketjsFormalNetworkTlsSmokeReportV1") == 0); + assert(strcmp(pocketjs_net_formal_tls_smoke_cancel_global, + "__pocketjsFormalNetworkTlsSmokeCancelV1") == 0); + + int clock_context = 1; + const pocketjs_net_formal_tls_smoke_run_config_t config = { + .configured_origin = pocketjs_net_formal_tls_smoke_endpoint.origin, + .configured_peer_ipv4 = "192.0.2.1", + .wall_clock_trusted = trusted_wall_clock, + .wall_clock_context = &clock_context, + .guest_memory_limit_bytes = + POCKETJS_NET_FORMAL_TLS_SMOKE_RECOMMENDED_GUEST_MEMORY_BYTES, + .guest_stack_limit_bytes = + POCKETJS_NET_FORMAL_TLS_SMOKE_MIN_GUEST_STACK_BYTES, + .guest_execution_timeout_us = 100000U, + .guest_max_interrupt_checks = 100000U, + .connect_timeout_us = 500000U, + .headers_timeout_us = 1000000U, + .idle_timeout_us = 1000000U, + .total_timeout_us = 2000000U, + .overall_timeout_ms = 120000U, + .shutdown_warning_ms = 5000U, + }; + assert(config.wall_clock_trusted(config.wall_clock_context)); + + static const char *const ambiguous_ipv4[] = { + "0", "0.0.0.0", "0177.0.0.1", "0x7f000001", "192.000.2.1", + }; + for (size_t index = 0U; + index < sizeof(ambiguous_ipv4) / sizeof(ambiguous_ipv4[0]); ++index) { + pocketjs_net_formal_tls_smoke_run_config_t invalid = config; + invalid.configured_peer_ipv4 = ambiguous_ipv4[index]; + pocketjs_net_formal_tls_smoke_run_result_t result = {0}; + assert(pocketjs_net_formal_tls_smoke_run(&invalid, &result) == + ESP_ERR_INVALID_ARG); + } + + pocketjs_net_formal_tls_smoke_run_config_t invalid_cancel = config; + invalid_cancel.cancel_after_ms = invalid_cancel.overall_timeout_ms; + pocketjs_net_formal_tls_smoke_run_result_t invalid_cancel_result = {0}; + assert(pocketjs_net_formal_tls_smoke_run( + &invalid_cancel, &invalid_cancel_result) == ESP_ERR_INVALID_ARG); +} diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/main/idf_component.yml b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/main/idf_component.yml new file mode 100644 index 00000000..945b86b7 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/main/idf_component.yml @@ -0,0 +1,3 @@ +dependencies: + pocketjs_net_formal_tls_smoke_artifact: + override_path: ../../.. diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/partitions.csv b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/partitions.csv new file mode 100644 index 00000000..0a309d77 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/partitions.csv @@ -0,0 +1,4 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 24K, +phy_init, data, phy, 0xf000, 4K, +factory, app, factory, 0x10000, 3M, diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/sdkconfig.defaults b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/sdkconfig.defaults new file mode 100644 index 00000000..6dabaccc --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_apps/build_smoke/sdkconfig.defaults @@ -0,0 +1,15 @@ +CONFIG_COMPILER_OPTIMIZATION_SIZE=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_LWIP_DNS_MAX_HOST_IP=4 +CONFIG_LWIP_HOOK_DNS_EXT_RESOLVE_NONE=y +# CONFIG_LWIP_HOOK_DNS_EXT_RESOLVE_CUSTOM is not set +CONFIG_MBEDTLS_SSL_PROTO_TLS1_2=y +# CONFIG_MBEDTLS_SSL_PROTO_TLS1_3 is not set +CONFIG_MBEDTLS_HAVE_TIME_DATE=y +# CONFIG_MBEDTLS_SSL_RENEGOTIATION is not set +# CONFIG_MBEDTLS_ALLOW_WEAK_CERTIFICATE_VERIFICATION is not set +# CONFIG_MBEDTLS_DES_C is not set +# CONFIG_ESP_TLS_INSECURE is not set diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_host/fake_include/lwip/ip4_addr.h b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_host/fake_include/lwip/ip4_addr.h new file mode 100644 index 00000000..567ad127 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_host/fake_include/lwip/ip4_addr.h @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include + +typedef struct { + uint32_t addr; +} ip4_addr_t; + +#define IPADDR_ANY UINT32_C(0) + +int ip4addr_aton(const char *text, ip4_addr_t *address); +char *ip4addr_ntoa_r(const ip4_addr_t *address, char *buffer, int length); diff --git a/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_host/ipv4_validation_test.c b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_host/ipv4_validation_test.c new file mode 100644 index 00000000..28ac8e70 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/test_host/ipv4_validation_test.c @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT + +#include +#include +#include +#include +#include + +#include "formal_tls_smoke_ipv4.h" + +int ip4addr_aton(const char *text, ip4_addr_t *address) { + struct in_addr parsed = {0}; + if (text == NULL || address == NULL || inet_aton(text, &parsed) == 0) { + return 0; + } + address->addr = parsed.s_addr; + return 1; +} + +char *ip4addr_ntoa_r(const ip4_addr_t *address, char *buffer, int length) { + if (address == NULL || buffer == NULL || length <= 0) { + return NULL; + } + const struct in_addr source = {.s_addr = address->addr}; + return (char *)inet_ntop(AF_INET, &source, buffer, (socklen_t)length); +} + +int main(void) { + static const char *const valid[] = { + "127.0.0.1", + "192.0.2.1", + "255.255.255.255", + }; + for (size_t index = 0U; index < sizeof(valid) / sizeof(valid[0]); ++index) { + ip4_addr_t address = {0}; + assert(pocketjs_net_formal_tls_smoke_canonical_peer_ipv4(valid[index], + &address)); + assert(address.addr != IPADDR_ANY); + } + + static const char *const invalid[] = { + "", "0", "0.0.0.0", "0177.0.0.1", + "0x7f000001", "192.000.2.1", "192.0.2.1.", "192.0.2", + }; + for (size_t index = 0U; index < sizeof(invalid) / sizeof(invalid[0]); + ++index) { + ip4_addr_t address = {.addr = UINT32_C(0x12345678)}; + assert(!pocketjs_net_formal_tls_smoke_canonical_peer_ipv4(invalid[index], + &address)); + assert(address.addr == UINT32_C(0x12345678)); + } + assert(!pocketjs_net_formal_tls_smoke_canonical_peer_ipv4(NULL, NULL)); + return 0; +} diff --git a/hosts/esp-idf/components/pocketjs_net_http1/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_http1/CMakeLists.txt new file mode 100644 index 00000000..872ec3db --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http1/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "src/http1_wire.c" + INCLUDE_DIRS "include" +) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/hosts/esp-idf/components/pocketjs_net_http1/README.md b/hosts/esp-idf/components/pocketjs_net_http1/README.md new file mode 100644 index 00000000..8a544f03 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http1/README.md @@ -0,0 +1,33 @@ +# PocketJS bounded HTTP/1.1 wire substrate + +This ESP-IDF component provides a fixed-memory HTTP/1.1 request-head encoder +and incremental response parser. It does not use `esp_http_client`, allocate +while parsing, open sockets, resolve DNS, perform TLS, follow redirects, or +expose the PocketJS `fetch()` capability. + +The parser preserves the wire reason phrase for the synchronous status +callback and reports every initial field line separately, including duplicate +non-framing fields. It rejects ambiguous framing, obsolete folding, malformed +chunk coding, and forbidden trailer fields. `Content-Encoding` is forbidden in +trailers so a late field cannot reinterpret already delivered identity body +bytes. Valid trailers are parsed, validated, counted, and discarded. + +Response body bytes point directly into the caller's input buffer. The parser +will not consume body bytes without explicit credit. A `PAUSED` result leaves +`input[consumed..]` untouched, so a transport can stop reading and resume with +the same bytes after downstream capacity returns. + +The request encoder validates the entire request head before emitting bytes. +It rejects the Fetch-forbidden `CONNECT`, `TRACE`, and `TRACK` methods without +regard to ASCII case. +It owns no request data: all method, target, host, and header slices must remain +valid until the encoder reports `DONE`. The caller remains responsible for +writing exactly the declared fixed-length body or valid chunked coding. +Fetch guards and the future Protocol Core remain responsible for filtering +semantic and hop-by-hop request fields such as credentials, `Connection`, and +`Upgrade`; this wire layer controls only `Host`, `Content-Length`, +`Transfer-Encoding`, and the unsupported outgoing `Trailer` field. + +This is parser/encoder substrate only. It must not appear in a Host descriptor +or resolved Build Plan until the socket/TLS backend, cancellation, bounded +resource ownership, Guest Binding, and hardware admission suites are complete. diff --git a/hosts/esp-idf/components/pocketjs_net_http1/idf_component.yml b/hosts/esp-idf/components/pocketjs_net_http1/idf_component.yml new file mode 100644 index 00000000..918a2441 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http1/idf_component.yml @@ -0,0 +1,10 @@ +version: "0.1.0" +description: Bounded PocketJS HTTP/1.1 request-head encoder and response parser substrate +license: MIT +repository: https://github.com/pocket-stack/pocketjs +documentation: https://github.com/pocket-stack/pocketjs/tree/main/hosts/esp-idf +targets: + - esp32p4 + - esp32s3 +dependencies: + idf: "6.0.2" diff --git a/hosts/esp-idf/components/pocketjs_net_http1/include/pocketjs/net/http1_wire.h b/hosts/esp-idf/components/pocketjs_net_http1/include/pocketjs/net/http1_wire.h new file mode 100644 index 00000000..1210c54f --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http1/include/pocketjs/net/http1_wire.h @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: MIT + +#ifndef POCKETJS_NET_HTTP1_WIRE_H +#define POCKETJS_NET_HTTP1_WIRE_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* The parser never allocates. This is its only wire-line storage. */ +#define POCKETJS_NET_HTTP1_MAX_LINE_BYTES 2048U +#define POCKETJS_NET_HTTP1_MAX_HEADER_BYTES 65536U +#define POCKETJS_NET_HTTP1_MAX_HEADER_FIELDS 128U +#define POCKETJS_NET_HTTP1_MAX_INFORMATIONAL_RESPONSES 16U + +#define POCKETJS_NET_HTTP1_DEFAULT_HEADER_BYTES 8192U +#define POCKETJS_NET_HTTP1_DEFAULT_HEADER_FIELDS 64U +#define POCKETJS_NET_HTTP1_DEFAULT_LINE_BYTES 2048U +#define POCKETJS_NET_HTTP1_DEFAULT_INFORMATIONAL_RESPONSES 8U + +typedef struct { + size_t max_header_bytes; + size_t max_header_fields; + size_t max_line_bytes; + size_t max_informational_responses; +} pocketjs_net_http1_limits_t; + +extern const pocketjs_net_http1_limits_t pocketjs_net_http1_default_limits; + +typedef enum { + POCKETJS_NET_HTTP1_WIRE_ERROR_NONE = 0, + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_ARGUMENT, + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_METHOD, + POCKETJS_NET_HTTP1_WIRE_ERROR_FORBIDDEN_METHOD, + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_TARGET, + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_HOST, + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_HEADER, + POCKETJS_NET_HTTP1_WIRE_ERROR_FORBIDDEN_REQUEST_HEADER, + POCKETJS_NET_HTTP1_WIRE_ERROR_LINE_TOO_LONG, + POCKETJS_NET_HTTP1_WIRE_ERROR_HEADER_BYTES_EXCEEDED, + POCKETJS_NET_HTTP1_WIRE_ERROR_HEADER_FIELDS_EXCEEDED, + POCKETJS_NET_HTTP1_WIRE_ERROR_TOO_MANY_INFORMATIONAL_RESPONSES, + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_STATUS_LINE, + POCKETJS_NET_HTTP1_WIRE_ERROR_SWITCHING_PROTOCOLS_UNSUPPORTED, + POCKETJS_NET_HTTP1_WIRE_ERROR_OBS_FOLD, + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_CONTENT_LENGTH, + POCKETJS_NET_HTTP1_WIRE_ERROR_DUPLICATE_CONTENT_LENGTH, + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_TRANSFER_ENCODING, + POCKETJS_NET_HTTP1_WIRE_ERROR_DUPLICATE_TRANSFER_ENCODING, + POCKETJS_NET_HTTP1_WIRE_ERROR_AMBIGUOUS_FRAMING, + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_TRAILER_DECLARATION, + POCKETJS_NET_HTTP1_WIRE_ERROR_FORBIDDEN_TRAILER, + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_CHUNK_SIZE, + POCKETJS_NET_HTTP1_WIRE_ERROR_CALLBACK_REJECTED, + POCKETJS_NET_HTTP1_WIRE_ERROR_UNEXPECTED_EOF, +} pocketjs_net_http1_wire_error_t; + +const char *pocketjs_net_http1_wire_error_name( + pocketjs_net_http1_wire_error_t error); + +bool pocketjs_net_http1_wire_error_is_limit( + pocketjs_net_http1_wire_error_t error); + +typedef struct { + const uint8_t *data; + size_t length; +} pocketjs_net_http1_slice_t; + +typedef struct { + pocketjs_net_http1_slice_t name; + pocketjs_net_http1_slice_t value; +} pocketjs_net_http1_header_t; + +typedef enum { + POCKETJS_NET_HTTP1_REQUEST_BODY_NONE = 0, + POCKETJS_NET_HTTP1_REQUEST_BODY_FIXED, + POCKETJS_NET_HTTP1_REQUEST_BODY_CHUNKED, +} pocketjs_net_http1_request_body_kind_t; + +typedef struct { + pocketjs_net_http1_slice_t method; + pocketjs_net_http1_slice_t target; + pocketjs_net_http1_slice_t host; + const pocketjs_net_http1_header_t *headers; + size_t header_count; + pocketjs_net_http1_request_body_kind_t body_kind; + uint64_t content_length; +} pocketjs_net_http1_request_t; + +typedef enum { + POCKETJS_NET_HTTP1_ENCODER_MORE = 0, + POCKETJS_NET_HTTP1_ENCODER_DONE, + POCKETJS_NET_HTTP1_ENCODER_ERROR, +} pocketjs_net_http1_encoder_result_t; + +/* + * This encoder emits the request line and field section. The caller retains + * all request slices until DONE, then writes either exactly content_length raw + * bytes or valid chunked coding according to body_kind. + */ +typedef struct { + pocketjs_net_http1_request_t request; + uint8_t phase; + size_t header_index; + size_t segment_offset; + uint8_t content_length_decimal[20]; + size_t content_length_decimal_length; + bool initialized; +} pocketjs_net_http1_request_encoder_t; + +pocketjs_net_http1_wire_error_t pocketjs_net_http1_request_encoder_init( + pocketjs_net_http1_request_encoder_t *encoder, + const pocketjs_net_http1_request_t *request, + const pocketjs_net_http1_limits_t *limits); + +pocketjs_net_http1_encoder_result_t pocketjs_net_http1_request_encoder_write( + pocketjs_net_http1_request_encoder_t *encoder, uint8_t *output, + size_t output_capacity, size_t *output_length); + +typedef enum { + POCKETJS_NET_HTTP1_RESPONSE_BODY_NONE = 0, + POCKETJS_NET_HTTP1_RESPONSE_BODY_FIXED, + POCKETJS_NET_HTTP1_RESPONSE_BODY_CHUNKED, + POCKETJS_NET_HTTP1_RESPONSE_BODY_UNTIL_EOF, +} pocketjs_net_http1_response_body_kind_t; + +typedef struct { + /* All slices are valid only for the duration of their callback. */ + bool (*on_status)(void *context, unsigned http_minor, unsigned status_code, + const uint8_t *status_text, size_t status_text_length, + bool informational); + bool (*on_header)(void *context, const uint8_t *name, size_t name_length, + const uint8_t *value, size_t value_length, + bool informational); + bool (*on_headers_complete)( + void *context, unsigned status_code, + pocketjs_net_http1_response_body_kind_t body_kind, + uint64_t content_length, bool informational); + /* Body points directly into the caller's input and never into hidden storage. */ + bool (*on_body)(void *context, const uint8_t *body, size_t body_length); + void (*on_complete)(void *context); +} pocketjs_net_http1_response_callbacks_t; + +typedef enum { + POCKETJS_NET_HTTP1_PARSE_NEED_MORE = 0, + POCKETJS_NET_HTTP1_PARSE_PAUSED, + POCKETJS_NET_HTTP1_PARSE_COMPLETE, + POCKETJS_NET_HTTP1_PARSE_ERROR, +} pocketjs_net_http1_parse_result_t; + +typedef enum { + POCKETJS_NET_HTTP1_PARSER_STATUS_LINE = 0, + POCKETJS_NET_HTTP1_PARSER_HEADER_LINE, + POCKETJS_NET_HTTP1_PARSER_FIXED_BODY, + POCKETJS_NET_HTTP1_PARSER_EOF_BODY, + POCKETJS_NET_HTTP1_PARSER_CHUNK_SIZE_LINE, + POCKETJS_NET_HTTP1_PARSER_CHUNK_BODY, + POCKETJS_NET_HTTP1_PARSER_CHUNK_BODY_CR, + POCKETJS_NET_HTTP1_PARSER_CHUNK_BODY_LF, + POCKETJS_NET_HTTP1_PARSER_TRAILER_LINE, + POCKETJS_NET_HTTP1_PARSER_COMPLETE, + POCKETJS_NET_HTTP1_PARSER_ERROR, +} pocketjs_net_http1_parser_state_t; + +typedef struct { + pocketjs_net_http1_limits_t limits; + pocketjs_net_http1_response_callbacks_t callbacks; + void *callback_context; + pocketjs_net_http1_parser_state_t state; + pocketjs_net_http1_wire_error_t error; + uint8_t line[POCKETJS_NET_HTTP1_MAX_LINE_BYTES]; + size_t line_length; + bool line_cr_pending; + bool response_to_head; + bool saw_content_length; + bool saw_transfer_encoding; + bool saw_trailer_declaration; + bool complete_notified; + unsigned http_minor; + unsigned status_code; + uint64_t content_length; + uint64_t body_remaining; + uint64_t chunk_remaining; + size_t header_bytes_used; + size_t header_fields_used; + size_t informational_responses; + size_t validated_trailer_fields; +} pocketjs_net_http1_response_parser_t; + +pocketjs_net_http1_wire_error_t pocketjs_net_http1_response_parser_init( + pocketjs_net_http1_response_parser_t *parser, + const pocketjs_net_http1_limits_t *limits, + const pocketjs_net_http1_response_callbacks_t *callbacks, + void *callback_context, bool response_to_head); + +/* + * feed consumes no more body bytes than body_credit. When PAUSED is returned, + * the caller must retain input[consumed..] and provide it again after granting + * more credit. Header/chunk syntax may be consumed without body credit. + */ +pocketjs_net_http1_parse_result_t pocketjs_net_http1_response_parser_feed( + pocketjs_net_http1_response_parser_t *parser, const uint8_t *input, + size_t input_length, size_t body_credit, size_t *consumed); + +/* Signal transport EOF. Only an EOF-delimited response can complete here. */ +pocketjs_net_http1_parse_result_t pocketjs_net_http1_response_parser_finish( + pocketjs_net_http1_response_parser_t *parser); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/hosts/esp-idf/components/pocketjs_net_http1/src/http1_wire.c b/hosts/esp-idf/components/pocketjs_net_http1/src/http1_wire.c new file mode 100644 index 00000000..43356b59 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http1/src/http1_wire.c @@ -0,0 +1,1361 @@ +// SPDX-License-Identifier: MIT + +#include "pocketjs/net/http1_wire.h" + +#include +#include + +const pocketjs_net_http1_limits_t pocketjs_net_http1_default_limits = { + .max_header_bytes = POCKETJS_NET_HTTP1_DEFAULT_HEADER_BYTES, + .max_header_fields = POCKETJS_NET_HTTP1_DEFAULT_HEADER_FIELDS, + .max_line_bytes = POCKETJS_NET_HTTP1_DEFAULT_LINE_BYTES, + .max_informational_responses = + POCKETJS_NET_HTTP1_DEFAULT_INFORMATIONAL_RESPONSES, +}; + +static bool ascii_is_alpha(uint8_t byte) { + return (byte >= 'A' && byte <= 'Z') || (byte >= 'a' && byte <= 'z'); +} + +static bool ascii_is_digit(uint8_t byte) { + return byte >= '0' && byte <= '9'; +} + +static uint8_t ascii_lower(uint8_t byte) { + return byte >= 'A' && byte <= 'Z' ? (uint8_t)(byte + ('a' - 'A')) : byte; +} + +static bool ascii_equal_case(const uint8_t *left, size_t left_length, + const char *right) { + size_t right_length = strlen(right); + if (left_length != right_length) { + return false; + } + for (size_t index = 0; index < left_length; ++index) { + if (ascii_lower(left[index]) != ascii_lower((uint8_t)right[index])) { + return false; + } + } + return true; +} + +static bool ascii_is_hex(uint8_t byte) { + return ascii_is_digit(byte) || (byte >= 'A' && byte <= 'F') || + (byte >= 'a' && byte <= 'f'); +} + +static unsigned ascii_hex_value(uint8_t byte) { + if (ascii_is_digit(byte)) { + return (unsigned)(byte - '0'); + } + return (unsigned)(ascii_lower(byte) - 'a' + 10); +} + +static bool is_tchar(uint8_t byte) { + return ascii_is_alpha(byte) || ascii_is_digit(byte) || byte == '!' || + byte == '#' || byte == '$' || byte == '%' || byte == '&' || + byte == '\'' || byte == '*' || byte == '+' || byte == '-' || + byte == '.' || byte == '^' || byte == '_' || byte == '`' || + byte == '|' || byte == '~'; +} + +static bool is_unreserved(uint8_t byte) { + return ascii_is_alpha(byte) || ascii_is_digit(byte) || byte == '-' || + byte == '.' || byte == '_' || byte == '~'; +} + +static bool is_sub_delim(uint8_t byte) { + return byte == '!' || byte == '$' || byte == '&' || byte == '\'' || + byte == '(' || byte == ')' || byte == '*' || byte == '+' || + byte == ',' || byte == ';' || byte == '='; +} + +static bool valid_percent_encoding(const uint8_t *data, size_t length, + size_t *index) { + if (*index + 2U >= length || !ascii_is_hex(data[*index + 1U]) || + !ascii_is_hex(data[*index + 2U])) { + return false; + } + *index += 2U; + return true; +} + +static bool valid_method(pocketjs_net_http1_slice_t method) { + if (method.data == NULL || method.length == 0) { + return false; + } + for (size_t index = 0; index < method.length; ++index) { + if (!is_tchar(method.data[index])) { + return false; + } + } + return true; +} + +static bool valid_target(pocketjs_net_http1_slice_t target) { + if (target.data == NULL || target.length == 0 || target.data[0] != '/') { + return false; + } + bool in_query = false; + for (size_t index = 0; index < target.length; ++index) { + uint8_t byte = target.data[index]; + if (byte == '?') { + if (!in_query) { + in_query = true; + } + continue; + } + if (byte == '%') { + if (!valid_percent_encoding(target.data, target.length, &index)) { + return false; + } + continue; + } + if (byte == '/' || is_unreserved(byte) || is_sub_delim(byte) || + byte == ':' || byte == '@' || (in_query && byte == '?')) { + continue; + } + return false; + } + return true; +} + +static bool valid_port(const uint8_t *data, size_t length) { + if (length == 0) { + return false; + } + unsigned port = 0; + for (size_t index = 0; index < length; ++index) { + if (!ascii_is_digit(data[index])) { + return false; + } + unsigned digit = (unsigned)(data[index] - '0'); + if (port > (65535U - digit) / 10U) { + return false; + } + port = port * 10U + digit; + } + return port != 0U; +} + +static bool valid_reg_name(const uint8_t *data, size_t length) { + if (length == 0) { + return false; + } + for (size_t index = 0; index < length; ++index) { + uint8_t byte = data[index]; + if (byte == '%') { + if (!valid_percent_encoding(data, length, &index)) { + return false; + } + } else if (!is_unreserved(byte) && !is_sub_delim(byte)) { + return false; + } + } + return true; +} + +static bool valid_ipv4_address(const uint8_t *data, size_t length) { + size_t index = 0; + for (unsigned part = 0; part < 4U; ++part) { + size_t start = index; + unsigned value = 0; + while (index < length && ascii_is_digit(data[index])) { + unsigned digit = (unsigned)(data[index] - '0'); + if (value > (255U - digit) / 10U) { + return false; + } + value = value * 10U + digit; + ++index; + } + if (start == index || (index - start > 1U && data[start] == '0')) { + return false; + } + if (part == 3U) { + return index == length; + } + if (index == length || data[index] != '.') { + return false; + } + ++index; + } + return false; +} + +static bool valid_ipv6_address(const uint8_t *data, size_t length) { + if (length == 0) { + return false; + } + size_t index = 0; + unsigned groups = 0; + bool compressed = false; + if (data[0] == ':') { + if (length < 2U || data[1] != ':') { + return false; + } + compressed = true; + index = 2U; + if (index == length) { + return true; + } + } + + while (index < length) { + size_t start = index; + bool has_dot = false; + while (index < length && data[index] != ':') { + has_dot = has_dot || data[index] == '.'; + ++index; + } + size_t group_length = index - start; + if (group_length == 0) { + return false; + } + if (has_dot) { + if (index != length || groups > 6U || + !valid_ipv4_address(data + start, group_length)) { + return false; + } + groups += 2U; + break; + } + if (group_length > 4U) { + return false; + } + for (size_t digit = start; digit < index; ++digit) { + if (!ascii_is_hex(data[digit])) { + return false; + } + } + if (++groups > 8U) { + return false; + } + if (index == length) { + break; + } + + ++index; + if (index == length) { + return false; + } + if (data[index] == ':') { + if (compressed) { + return false; + } + compressed = true; + ++index; + if (index == length) { + break; + } + } + } + return compressed ? groups < 8U : groups == 8U; +} + +static bool valid_host(pocketjs_net_http1_slice_t host) { + if (host.data == NULL || host.length == 0) { + return false; + } + if (host.data[0] == '[') { + size_t close = 1; + for (; close < host.length && host.data[close] != ']'; ++close) { + /* Locate the closing bracket before validating the complete address. */ + } + if (close == 1 || close >= host.length || + !valid_ipv6_address(host.data + 1U, close - 1U)) { + return false; + } + if (close + 1U == host.length) { + return true; + } + return host.data[close + 1U] == ':' && + valid_port(host.data + close + 2U, host.length - close - 2U); + } + + size_t colon = host.length; + for (size_t index = 0; index < host.length; ++index) { + if (host.data[index] == ':') { + if (colon != host.length) { + return false; + } + colon = index; + } + } + size_t name_length = colon == host.length ? host.length : colon; + if (!valid_reg_name(host.data, name_length)) { + return false; + } + return colon == host.length || + valid_port(host.data + colon + 1U, host.length - colon - 1U); +} + +static bool valid_field_name(pocketjs_net_http1_slice_t name) { + if (name.data == NULL || name.length == 0) { + return false; + } + for (size_t index = 0; index < name.length; ++index) { + if (!is_tchar(name.data[index])) { + return false; + } + } + return true; +} + +static bool valid_field_value(pocketjs_net_http1_slice_t value) { + if (value.length != 0 && value.data == NULL) { + return false; + } + for (size_t index = 0; index < value.length; ++index) { + uint8_t byte = value.data[index]; + if (byte != '\t' && (byte < 0x20U || byte == 0x7fU)) { + return false; + } + } + return true; +} + +static bool add_size(size_t *value, size_t increment) { + if (*value > SIZE_MAX - increment) { + return false; + } + *value += increment; + return true; +} + +static bool valid_limits(const pocketjs_net_http1_limits_t *limits) { + return limits != NULL && limits->max_header_bytes >= 2U && + limits->max_header_bytes <= POCKETJS_NET_HTTP1_MAX_HEADER_BYTES && + limits->max_header_fields != 0 && limits->max_line_bytes != 0 && + limits->max_header_fields <= POCKETJS_NET_HTTP1_MAX_HEADER_FIELDS && + limits->max_line_bytes <= POCKETJS_NET_HTTP1_MAX_LINE_BYTES && + limits->max_informational_responses != 0 && + limits->max_informational_responses <= + POCKETJS_NET_HTTP1_MAX_INFORMATIONAL_RESPONSES; +} + +const char *pocketjs_net_http1_wire_error_name( + pocketjs_net_http1_wire_error_t error) { + static const char *const names[] = { + "none", + "invalid_argument", + "invalid_method", + "forbidden_method", + "invalid_target", + "invalid_host", + "invalid_header", + "forbidden_request_header", + "line_too_long", + "header_bytes_exceeded", + "header_fields_exceeded", + "too_many_informational_responses", + "invalid_status_line", + "switching_protocols_unsupported", + "obs_fold", + "invalid_content_length", + "duplicate_content_length", + "invalid_transfer_encoding", + "duplicate_transfer_encoding", + "ambiguous_framing", + "invalid_trailer_declaration", + "forbidden_trailer", + "invalid_chunk_size", + "callback_rejected", + "unexpected_eof", + }; + size_t index = (size_t)error; + return index < sizeof(names) / sizeof(names[0]) ? names[index] + : "unknown"; +} + +bool pocketjs_net_http1_wire_error_is_limit( + pocketjs_net_http1_wire_error_t error) { + return error == POCKETJS_NET_HTTP1_WIRE_ERROR_LINE_TOO_LONG || + error == POCKETJS_NET_HTTP1_WIRE_ERROR_HEADER_BYTES_EXCEEDED || + error == POCKETJS_NET_HTTP1_WIRE_ERROR_HEADER_FIELDS_EXCEEDED || + error == + POCKETJS_NET_HTTP1_WIRE_ERROR_TOO_MANY_INFORMATIONAL_RESPONSES; +} + +static pocketjs_net_http1_wire_error_t validate_request( + const pocketjs_net_http1_request_t *request, + const pocketjs_net_http1_limits_t *limits, size_t decimal_length) { + if (request == NULL || !valid_limits(limits) || + (request->header_count != 0 && request->headers == NULL) || + request->body_kind > POCKETJS_NET_HTTP1_REQUEST_BODY_CHUNKED || + (request->body_kind != POCKETJS_NET_HTTP1_REQUEST_BODY_FIXED && + request->content_length != 0)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_ARGUMENT; + } + if (!valid_method(request->method)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_METHOD; + } + if (ascii_equal_case(request->method.data, request->method.length, "CONNECT") || + ascii_equal_case(request->method.data, request->method.length, "TRACE") || + ascii_equal_case(request->method.data, request->method.length, "TRACK")) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_FORBIDDEN_METHOD; + } + if (!valid_target(request->target)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_TARGET; + } + if (!valid_host(request->host)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_HOST; + } + + size_t framing_fields = + request->body_kind == POCKETJS_NET_HTTP1_REQUEST_BODY_NONE ? 0U : 1U; + if (limits->max_header_fields < 1U + framing_fields || + request->header_count > + limits->max_header_fields - 1U - framing_fields) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_HEADER_FIELDS_EXCEEDED; + } + + size_t wire_bytes = 0; + size_t request_line = request->method.length; + if (!add_size(&request_line, request->target.length) || + !add_size(&request_line, sizeof(" HTTP/1.1\r\n") - 1U)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_HEADER_BYTES_EXCEEDED; + } + if (request_line - 2U > limits->max_line_bytes || + !add_size(&wire_bytes, request_line)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_LINE_TOO_LONG; + } + + size_t fields = 1U; + size_t host_line = sizeof("Host: \r\n") - 1U; + if (!add_size(&host_line, request->host.length) || + host_line - 2U > limits->max_line_bytes || + !add_size(&wire_bytes, host_line)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_LINE_TOO_LONG; + } + + for (size_t index = 0; index < request->header_count; ++index) { + const pocketjs_net_http1_header_t *header = &request->headers[index]; + if (!valid_field_name(header->name) || !valid_field_value(header->value)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_HEADER; + } + if (ascii_equal_case(header->name.data, header->name.length, "Host") || + ascii_equal_case(header->name.data, header->name.length, + "Content-Length") || + ascii_equal_case(header->name.data, header->name.length, + "Transfer-Encoding") || + ascii_equal_case(header->name.data, header->name.length, "Trailer")) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_FORBIDDEN_REQUEST_HEADER; + } + size_t line = header->name.length; + if (!add_size(&line, header->value.length) || !add_size(&line, 4U) || + line - 2U > limits->max_line_bytes || !add_size(&wire_bytes, line)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_LINE_TOO_LONG; + } + ++fields; + } + + if (request->body_kind != POCKETJS_NET_HTTP1_REQUEST_BODY_NONE) { + size_t framing_line = + request->body_kind == POCKETJS_NET_HTTP1_REQUEST_BODY_FIXED + ? sizeof("Content-Length: \r\n") - 1U + decimal_length + : sizeof("Transfer-Encoding: chunked\r\n") - 1U; + if (framing_line - 2U > limits->max_line_bytes || + !add_size(&wire_bytes, framing_line)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_LINE_TOO_LONG; + } + ++fields; + } + if (!add_size(&wire_bytes, 2U)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_HEADER_BYTES_EXCEEDED; + } + if (fields > limits->max_header_fields) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_HEADER_FIELDS_EXCEEDED; + } + if (wire_bytes > limits->max_header_bytes) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_HEADER_BYTES_EXCEEDED; + } + return POCKETJS_NET_HTTP1_WIRE_ERROR_NONE; +} + +enum request_encoder_phase { + REQUEST_PHASE_METHOD = 0, + REQUEST_PHASE_METHOD_SPACE, + REQUEST_PHASE_TARGET, + REQUEST_PHASE_VERSION, + REQUEST_PHASE_HOST_PREFIX, + REQUEST_PHASE_HOST, + REQUEST_PHASE_HOST_END, + REQUEST_PHASE_HEADER_NAME, + REQUEST_PHASE_HEADER_SEPARATOR, + REQUEST_PHASE_HEADER_VALUE, + REQUEST_PHASE_HEADER_END, + REQUEST_PHASE_FRAMING_PREFIX, + REQUEST_PHASE_FRAMING_VALUE, + REQUEST_PHASE_FRAMING_END, + REQUEST_PHASE_FINAL_END, + REQUEST_PHASE_DONE, +}; + +static size_t write_decimal(uint64_t value, uint8_t output[20]) { + uint8_t reversed[20]; + size_t length = 0; + do { + reversed[length++] = (uint8_t)('0' + value % 10U); + value /= 10U; + } while (value != 0); + for (size_t index = 0; index < length; ++index) { + output[index] = reversed[length - index - 1U]; + } + return length; +} + +pocketjs_net_http1_wire_error_t pocketjs_net_http1_request_encoder_init( + pocketjs_net_http1_request_encoder_t *encoder, + const pocketjs_net_http1_request_t *request, + const pocketjs_net_http1_limits_t *limits) { + if (encoder == NULL) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_ARGUMENT; + } + memset(encoder, 0, sizeof(*encoder)); + if (request == NULL) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_ARGUMENT; + } + size_t decimal_length = + write_decimal(request->content_length, encoder->content_length_decimal); + pocketjs_net_http1_wire_error_t error = + validate_request(request, limits, decimal_length); + if (error != POCKETJS_NET_HTTP1_WIRE_ERROR_NONE) { + return error; + } + encoder->request = *request; + encoder->content_length_decimal_length = decimal_length; + encoder->phase = REQUEST_PHASE_METHOD; + encoder->initialized = true; + return POCKETJS_NET_HTTP1_WIRE_ERROR_NONE; +} + +static void request_encoder_segment( + const pocketjs_net_http1_request_encoder_t *encoder, const uint8_t **data, + size_t *length) { + static const uint8_t space[] = " "; + static const uint8_t version[] = " HTTP/1.1\r\n"; + static const uint8_t host_prefix[] = "Host: "; + static const uint8_t separator[] = ": "; + static const uint8_t line_end[] = "\r\n"; + static const uint8_t content_length_prefix[] = "Content-Length: "; + static const uint8_t transfer_encoding[] = "Transfer-Encoding: chunked"; + + *data = NULL; + *length = 0; + switch ((enum request_encoder_phase)encoder->phase) { + case REQUEST_PHASE_METHOD: + *data = encoder->request.method.data; + *length = encoder->request.method.length; + break; + case REQUEST_PHASE_METHOD_SPACE: + *data = space; + *length = sizeof(space) - 1U; + break; + case REQUEST_PHASE_TARGET: + *data = encoder->request.target.data; + *length = encoder->request.target.length; + break; + case REQUEST_PHASE_VERSION: + *data = version; + *length = sizeof(version) - 1U; + break; + case REQUEST_PHASE_HOST_PREFIX: + *data = host_prefix; + *length = sizeof(host_prefix) - 1U; + break; + case REQUEST_PHASE_HOST: + *data = encoder->request.host.data; + *length = encoder->request.host.length; + break; + case REQUEST_PHASE_HOST_END: + case REQUEST_PHASE_HEADER_END: + case REQUEST_PHASE_FRAMING_END: + case REQUEST_PHASE_FINAL_END: + *data = line_end; + *length = sizeof(line_end) - 1U; + break; + case REQUEST_PHASE_HEADER_NAME: + *data = encoder->request.headers[encoder->header_index].name.data; + *length = encoder->request.headers[encoder->header_index].name.length; + break; + case REQUEST_PHASE_HEADER_SEPARATOR: + *data = separator; + *length = sizeof(separator) - 1U; + break; + case REQUEST_PHASE_HEADER_VALUE: + *data = encoder->request.headers[encoder->header_index].value.data; + *length = encoder->request.headers[encoder->header_index].value.length; + break; + case REQUEST_PHASE_FRAMING_PREFIX: + if (encoder->request.body_kind == + POCKETJS_NET_HTTP1_REQUEST_BODY_FIXED) { + *data = content_length_prefix; + *length = sizeof(content_length_prefix) - 1U; + } else { + *data = transfer_encoding; + *length = sizeof(transfer_encoding) - 1U; + } + break; + case REQUEST_PHASE_FRAMING_VALUE: + *data = encoder->content_length_decimal; + *length = encoder->content_length_decimal_length; + break; + case REQUEST_PHASE_DONE: + break; + } +} + +static void request_encoder_advance(pocketjs_net_http1_request_encoder_t *encoder) { + switch ((enum request_encoder_phase)encoder->phase) { + case REQUEST_PHASE_METHOD: + encoder->phase = REQUEST_PHASE_METHOD_SPACE; + break; + case REQUEST_PHASE_METHOD_SPACE: + encoder->phase = REQUEST_PHASE_TARGET; + break; + case REQUEST_PHASE_TARGET: + encoder->phase = REQUEST_PHASE_VERSION; + break; + case REQUEST_PHASE_VERSION: + encoder->phase = REQUEST_PHASE_HOST_PREFIX; + break; + case REQUEST_PHASE_HOST_PREFIX: + encoder->phase = REQUEST_PHASE_HOST; + break; + case REQUEST_PHASE_HOST: + encoder->phase = REQUEST_PHASE_HOST_END; + break; + case REQUEST_PHASE_HOST_END: + encoder->phase = encoder->request.header_count == 0 + ? REQUEST_PHASE_FRAMING_PREFIX + : REQUEST_PHASE_HEADER_NAME; + if (encoder->request.header_count == 0 && + encoder->request.body_kind == POCKETJS_NET_HTTP1_REQUEST_BODY_NONE) { + encoder->phase = REQUEST_PHASE_FINAL_END; + } + break; + case REQUEST_PHASE_HEADER_NAME: + encoder->phase = REQUEST_PHASE_HEADER_SEPARATOR; + break; + case REQUEST_PHASE_HEADER_SEPARATOR: + encoder->phase = REQUEST_PHASE_HEADER_VALUE; + break; + case REQUEST_PHASE_HEADER_VALUE: + encoder->phase = REQUEST_PHASE_HEADER_END; + break; + case REQUEST_PHASE_HEADER_END: + ++encoder->header_index; + if (encoder->header_index < encoder->request.header_count) { + encoder->phase = REQUEST_PHASE_HEADER_NAME; + } else if (encoder->request.body_kind == + POCKETJS_NET_HTTP1_REQUEST_BODY_NONE) { + encoder->phase = REQUEST_PHASE_FINAL_END; + } else { + encoder->phase = REQUEST_PHASE_FRAMING_PREFIX; + } + break; + case REQUEST_PHASE_FRAMING_PREFIX: + encoder->phase = + encoder->request.body_kind == POCKETJS_NET_HTTP1_REQUEST_BODY_FIXED + ? REQUEST_PHASE_FRAMING_VALUE + : REQUEST_PHASE_FRAMING_END; + break; + case REQUEST_PHASE_FRAMING_VALUE: + encoder->phase = REQUEST_PHASE_FRAMING_END; + break; + case REQUEST_PHASE_FRAMING_END: + encoder->phase = REQUEST_PHASE_FINAL_END; + break; + case REQUEST_PHASE_FINAL_END: + encoder->phase = REQUEST_PHASE_DONE; + break; + case REQUEST_PHASE_DONE: + break; + } +} + +pocketjs_net_http1_encoder_result_t pocketjs_net_http1_request_encoder_write( + pocketjs_net_http1_request_encoder_t *encoder, uint8_t *output, + size_t output_capacity, size_t *output_length) { + if (output_length != NULL) { + *output_length = 0; + } + if (encoder == NULL || !encoder->initialized || output_length == NULL || + (output_capacity != 0 && output == NULL)) { + return POCKETJS_NET_HTTP1_ENCODER_ERROR; + } + size_t produced = 0; + while (produced < output_capacity && + encoder->phase != REQUEST_PHASE_DONE) { + const uint8_t *segment = NULL; + size_t segment_length = 0; + request_encoder_segment(encoder, &segment, &segment_length); + if (encoder->segment_offset == segment_length) { + encoder->segment_offset = 0; + request_encoder_advance(encoder); + continue; + } + size_t available = segment_length - encoder->segment_offset; + size_t capacity = output_capacity - produced; + size_t amount = available < capacity ? available : capacity; + memcpy(output + produced, segment + encoder->segment_offset, amount); + encoder->segment_offset += amount; + produced += amount; + } + while (encoder->phase != REQUEST_PHASE_DONE) { + const uint8_t *segment = NULL; + size_t segment_length = 0; + request_encoder_segment(encoder, &segment, &segment_length); + (void)segment; + if (encoder->segment_offset != segment_length) { + break; + } + encoder->segment_offset = 0; + request_encoder_advance(encoder); + } + *output_length = produced; + return encoder->phase == REQUEST_PHASE_DONE + ? POCKETJS_NET_HTTP1_ENCODER_DONE + : POCKETJS_NET_HTTP1_ENCODER_MORE; +} + +static pocketjs_net_http1_parse_result_t parser_fail( + pocketjs_net_http1_response_parser_t *parser, + pocketjs_net_http1_wire_error_t error) { + parser->error = error; + parser->state = POCKETJS_NET_HTTP1_PARSER_ERROR; + return POCKETJS_NET_HTTP1_PARSE_ERROR; +} + +static bool parser_complete(pocketjs_net_http1_response_parser_t *parser) { + parser->state = POCKETJS_NET_HTTP1_PARSER_COMPLETE; + if (!parser->complete_notified) { + parser->complete_notified = true; + if (parser->callbacks.on_complete != NULL) { + parser->callbacks.on_complete(parser->callback_context); + } + } + return true; +} + +static void trim_ows(const uint8_t *data, size_t length, size_t *start, + size_t *end) { + *start = 0; + *end = length; + while (*start < *end && + (data[*start] == ' ' || data[*start] == '\t')) { + ++*start; + } + while (*end > *start && + (data[*end - 1U] == ' ' || data[*end - 1U] == '\t')) { + --*end; + } +} + +static bool parse_content_length(const uint8_t *data, size_t length, + uint64_t *result) { + if (length == 0) { + return false; + } + uint64_t value = 0; + for (size_t index = 0; index < length; ++index) { + if (!ascii_is_digit(data[index])) { + return false; + } + unsigned digit = (unsigned)(data[index] - '0'); + if (value > (UINT64_MAX - digit) / 10U) { + return false; + } + value = value * 10U + digit; + } + *result = value; + return true; +} + +static bool trailer_name_forbidden(const uint8_t *name, size_t length) { + static const char *const forbidden[] = { + "content-length", "transfer-encoding", "host", + "content-encoding", + "connection", "trailer", "upgrade", + "authorization", "proxy-authorization", + "proxy-authenticate", "www-authenticate", "authentication-info", + "proxy-authentication-info", "te", "keep-alive", + "proxy-connection", + }; + for (size_t index = 0; index < sizeof(forbidden) / sizeof(forbidden[0]); + ++index) { + if (ascii_equal_case(name, length, forbidden[index])) { + return true; + } + } + return false; +} + +static bool validate_trailer_declaration(const uint8_t *value, size_t length) { + size_t index = 0; + bool saw_name = false; + for (;;) { + while (index < length && (value[index] == ' ' || value[index] == '\t')) { + ++index; + } + if (index == length) { + return false; + } + size_t start = index; + while (index < length && is_tchar(value[index])) { + ++index; + } + if (start == index || trailer_name_forbidden(value + start, index - start)) { + return false; + } + saw_name = true; + while (index < length && (value[index] == ' ' || value[index] == '\t')) { + ++index; + } + if (index == length) { + return saw_name; + } + if (value[index] != ',') { + return false; + } + ++index; + } +} + +static pocketjs_net_http1_wire_error_t parse_status_line( + pocketjs_net_http1_response_parser_t *parser) { + const uint8_t *line = parser->line; + size_t length = parser->line_length; + if (length < 13U || memcmp(line, "HTTP/1.", 7U) != 0 || + (line[7] != '0' && line[7] != '1') || line[8] != ' ' || + !ascii_is_digit(line[9]) || !ascii_is_digit(line[10]) || + !ascii_is_digit(line[11]) || line[12] != ' ') { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_STATUS_LINE; + } + unsigned status = (unsigned)(line[9] - '0') * 100U + + (unsigned)(line[10] - '0') * 10U + + (unsigned)(line[11] - '0'); + if (status < 100U || status > 599U) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_STATUS_LINE; + } + if (status == 101U) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_SWITCHING_PROTOCOLS_UNSUPPORTED; + } + for (size_t index = 13U; index < length; ++index) { + uint8_t byte = line[index]; + if (byte != '\t' && (byte < 0x20U || byte == 0x7fU)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_STATUS_LINE; + } + } + parser->http_minor = (unsigned)(line[7] - '0'); + parser->status_code = status; + parser->saw_content_length = false; + parser->saw_transfer_encoding = false; + parser->saw_trailer_declaration = false; + parser->content_length = 0; + bool informational = status < 200U; + if (parser->callbacks.on_status != NULL && + !parser->callbacks.on_status(parser->callback_context, + parser->http_minor, status, line + 13U, + length - 13U, informational)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_CALLBACK_REJECTED; + } + return POCKETJS_NET_HTTP1_WIRE_ERROR_NONE; +} + +static pocketjs_net_http1_wire_error_t parse_header_line( + pocketjs_net_http1_response_parser_t *parser, bool trailer) { + const uint8_t *line = parser->line; + size_t length = parser->line_length; + if (line[0] == ' ' || line[0] == '\t') { + return POCKETJS_NET_HTTP1_WIRE_ERROR_OBS_FOLD; + } + size_t colon = 0; + while (colon < length && line[colon] != ':') { + ++colon; + } + if (colon == 0 || colon == length) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_HEADER; + } + pocketjs_net_http1_slice_t name = {.data = line, .length = colon}; + if (!valid_field_name(name)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_HEADER; + } + pocketjs_net_http1_slice_t raw_value = { + .data = line + colon + 1U, + .length = length - colon - 1U, + }; + if (!valid_field_value(raw_value)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_HEADER; + } + size_t value_start = 0; + size_t value_end = 0; + trim_ows(raw_value.data, raw_value.length, &value_start, &value_end); + const uint8_t *value = raw_value.data + value_start; + size_t value_length = value_end - value_start; + + ++parser->header_fields_used; + if (parser->header_fields_used > parser->limits.max_header_fields) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_HEADER_FIELDS_EXCEEDED; + } + if (trailer) { + if (trailer_name_forbidden(name.data, name.length)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_FORBIDDEN_TRAILER; + } + ++parser->validated_trailer_fields; + return POCKETJS_NET_HTTP1_WIRE_ERROR_NONE; + } + + if (ascii_equal_case(name.data, name.length, "Content-Length")) { + if (parser->saw_content_length) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_DUPLICATE_CONTENT_LENGTH; + } + uint64_t parsed = 0; + if (!parse_content_length(value, value_length, &parsed)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_CONTENT_LENGTH; + } + parser->saw_content_length = true; + parser->content_length = parsed; + } else if (ascii_equal_case(name.data, name.length, "Transfer-Encoding")) { + if (parser->saw_transfer_encoding) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_DUPLICATE_TRANSFER_ENCODING; + } + if (!ascii_equal_case(value, value_length, "chunked")) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_TRANSFER_ENCODING; + } + parser->saw_transfer_encoding = true; + } else if (ascii_equal_case(name.data, name.length, "Trailer")) { + if (!validate_trailer_declaration(value, value_length)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_TRAILER_DECLARATION; + } + parser->saw_trailer_declaration = true; + } + + bool informational = parser->status_code < 200U; + if (parser->callbacks.on_header != NULL && + !parser->callbacks.on_header(parser->callback_context, name.data, + name.length, value, value_length, + informational)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_CALLBACK_REJECTED; + } + return POCKETJS_NET_HTTP1_WIRE_ERROR_NONE; +} + +static pocketjs_net_http1_wire_error_t finish_headers( + pocketjs_net_http1_response_parser_t *parser) { + if (parser->saw_content_length && parser->saw_transfer_encoding) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_AMBIGUOUS_FRAMING; + } + if (parser->http_minor == 0U && parser->saw_transfer_encoding) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_TRANSFER_ENCODING; + } + bool informational = parser->status_code < 200U; + if (informational) { + if (parser->saw_content_length || parser->saw_transfer_encoding || + parser->saw_trailer_declaration) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_AMBIGUOUS_FRAMING; + } + ++parser->informational_responses; + if (parser->informational_responses > + parser->limits.max_informational_responses) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_TOO_MANY_INFORMATIONAL_RESPONSES; + } + if (parser->callbacks.on_headers_complete != NULL && + !parser->callbacks.on_headers_complete( + parser->callback_context, parser->status_code, + POCKETJS_NET_HTTP1_RESPONSE_BODY_NONE, 0, true)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_CALLBACK_REJECTED; + } + parser->state = POCKETJS_NET_HTTP1_PARSER_STATUS_LINE; + return POCKETJS_NET_HTTP1_WIRE_ERROR_NONE; + } + + bool no_body = parser->response_to_head || parser->status_code == 204U || + parser->status_code == 304U; + if (parser->status_code == 204U && + (parser->saw_content_length || parser->saw_transfer_encoding)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_AMBIGUOUS_FRAMING; + } + if (no_body && parser->saw_trailer_declaration) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_TRAILER_DECLARATION; + } + if (parser->saw_trailer_declaration && + !parser->saw_transfer_encoding) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_TRAILER_DECLARATION; + } + + pocketjs_net_http1_response_body_kind_t body_kind; + if (no_body) { + body_kind = POCKETJS_NET_HTTP1_RESPONSE_BODY_NONE; + } else if (parser->saw_transfer_encoding) { + body_kind = POCKETJS_NET_HTTP1_RESPONSE_BODY_CHUNKED; + } else if (parser->saw_content_length) { + body_kind = POCKETJS_NET_HTTP1_RESPONSE_BODY_FIXED; + } else { + body_kind = POCKETJS_NET_HTTP1_RESPONSE_BODY_UNTIL_EOF; + } + if (parser->callbacks.on_headers_complete != NULL && + !parser->callbacks.on_headers_complete( + parser->callback_context, parser->status_code, body_kind, + parser->saw_content_length ? parser->content_length : 0, false)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_CALLBACK_REJECTED; + } + + if (no_body || + (body_kind == POCKETJS_NET_HTTP1_RESPONSE_BODY_FIXED && + parser->content_length == 0)) { + parser_complete(parser); + } else if (body_kind == POCKETJS_NET_HTTP1_RESPONSE_BODY_FIXED) { + parser->body_remaining = parser->content_length; + parser->state = POCKETJS_NET_HTTP1_PARSER_FIXED_BODY; + } else if (body_kind == POCKETJS_NET_HTTP1_RESPONSE_BODY_CHUNKED) { + parser->state = POCKETJS_NET_HTTP1_PARSER_CHUNK_SIZE_LINE; + } else { + parser->state = POCKETJS_NET_HTTP1_PARSER_EOF_BODY; + } + return POCKETJS_NET_HTTP1_WIRE_ERROR_NONE; +} + +static bool valid_chunk_extension(const uint8_t *data, size_t length, + size_t index) { + while (index < length && (data[index] == ' ' || data[index] == '\t')) { + ++index; + } + while (index < length) { + if (data[index] != ';') { + return false; + } + ++index; + while (index < length && (data[index] == ' ' || data[index] == '\t')) { + ++index; + } + size_t name_start = index; + while (index < length && is_tchar(data[index])) { + ++index; + } + if (name_start == index) { + return false; + } + while (index < length && (data[index] == ' ' || data[index] == '\t')) { + ++index; + } + if (index < length && data[index] == '=') { + ++index; + while (index < length && (data[index] == ' ' || data[index] == '\t')) { + ++index; + } + if (index == length) { + return false; + } + if (data[index] == '"') { + ++index; + bool closed = false; + while (index < length) { + uint8_t byte = data[index++]; + if (byte == '"') { + closed = true; + break; + } + if (byte == '\\') { + if (index == length || data[index] == '\r' || data[index] == '\n' || + data[index] == 0x7fU || + (data[index] < 0x20U && data[index] != '\t')) { + return false; + } + ++index; + } else if (byte == '\r' || byte == '\n' || byte == 0x7fU || + (byte < 0x20U && byte != '\t')) { + return false; + } + } + if (!closed) { + return false; + } + } else { + size_t value_start = index; + while (index < length && is_tchar(data[index])) { + ++index; + } + if (value_start == index) { + return false; + } + } + while (index < length && (data[index] == ' ' || data[index] == '\t')) { + ++index; + } + } + } + return true; +} + +static pocketjs_net_http1_wire_error_t parse_chunk_size_line( + pocketjs_net_http1_response_parser_t *parser) { + const uint8_t *line = parser->line; + size_t length = parser->line_length; + size_t index = 0; + uint64_t value = 0; + while (index < length && ascii_is_hex(line[index])) { + unsigned digit = ascii_hex_value(line[index]); + if (value > (UINT64_MAX - digit) / 16U) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_CHUNK_SIZE; + } + value = value * 16U + digit; + ++index; + } + if (index == 0 || !valid_chunk_extension(line, length, index)) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_CHUNK_SIZE; + } + parser->chunk_remaining = value; + parser->state = value == 0 ? POCKETJS_NET_HTTP1_PARSER_TRAILER_LINE + : POCKETJS_NET_HTTP1_PARSER_CHUNK_BODY; + return POCKETJS_NET_HTTP1_WIRE_ERROR_NONE; +} + +static pocketjs_net_http1_wire_error_t account_line_byte( + pocketjs_net_http1_response_parser_t *parser) { + if (parser->state != POCKETJS_NET_HTTP1_PARSER_HEADER_LINE && + parser->state != POCKETJS_NET_HTTP1_PARSER_TRAILER_LINE) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_NONE; + } + if (parser->header_bytes_used == parser->limits.max_header_bytes) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_HEADER_BYTES_EXCEEDED; + } + ++parser->header_bytes_used; + return POCKETJS_NET_HTTP1_WIRE_ERROR_NONE; +} + +static pocketjs_net_http1_wire_error_t finish_line( + pocketjs_net_http1_response_parser_t *parser) { + pocketjs_net_http1_wire_error_t error = + POCKETJS_NET_HTTP1_WIRE_ERROR_NONE; + switch (parser->state) { + case POCKETJS_NET_HTTP1_PARSER_STATUS_LINE: + error = parse_status_line(parser); + if (error == POCKETJS_NET_HTTP1_WIRE_ERROR_NONE) { + parser->state = POCKETJS_NET_HTTP1_PARSER_HEADER_LINE; + } + break; + case POCKETJS_NET_HTTP1_PARSER_HEADER_LINE: + error = parser->line_length == 0 ? finish_headers(parser) + : parse_header_line(parser, false); + break; + case POCKETJS_NET_HTTP1_PARSER_CHUNK_SIZE_LINE: + error = parse_chunk_size_line(parser); + break; + case POCKETJS_NET_HTTP1_PARSER_TRAILER_LINE: + if (parser->line_length == 0) { + parser_complete(parser); + } else { + error = parse_header_line(parser, true); + } + break; + default: + error = POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_ARGUMENT; + break; + } + parser->line_length = 0; + parser->line_cr_pending = false; + return error; +} + +static pocketjs_net_http1_wire_error_t consume_line_byte( + pocketjs_net_http1_response_parser_t *parser, uint8_t byte, + bool *line_complete) { + *line_complete = false; + pocketjs_net_http1_wire_error_t account = account_line_byte(parser); + if (account != POCKETJS_NET_HTTP1_WIRE_ERROR_NONE) { + return account; + } + if (parser->line_cr_pending) { + if (byte != '\n') { + return parser->state == POCKETJS_NET_HTTP1_PARSER_CHUNK_SIZE_LINE + ? POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_CHUNK_SIZE + : POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_HEADER; + } + *line_complete = true; + return finish_line(parser); + } + if (byte == '\r') { + parser->line_cr_pending = true; + return POCKETJS_NET_HTTP1_WIRE_ERROR_NONE; + } + if (byte == '\n') { + return parser->state == POCKETJS_NET_HTTP1_PARSER_CHUNK_SIZE_LINE + ? POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_CHUNK_SIZE + : POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_HEADER; + } + if (parser->line_length == parser->limits.max_line_bytes) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_LINE_TOO_LONG; + } + parser->line[parser->line_length++] = byte; + return POCKETJS_NET_HTTP1_WIRE_ERROR_NONE; +} + +pocketjs_net_http1_wire_error_t pocketjs_net_http1_response_parser_init( + pocketjs_net_http1_response_parser_t *parser, + const pocketjs_net_http1_limits_t *limits, + const pocketjs_net_http1_response_callbacks_t *callbacks, + void *callback_context, bool response_to_head) { + if (parser == NULL) { + return POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_ARGUMENT; + } + memset(parser, 0, sizeof(*parser)); + if (!valid_limits(limits) || callbacks == NULL) { + parser->state = POCKETJS_NET_HTTP1_PARSER_ERROR; + parser->error = POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_ARGUMENT; + return parser->error; + } + parser->limits = *limits; + parser->callbacks = *callbacks; + parser->callback_context = callback_context; + parser->response_to_head = response_to_head; + parser->state = POCKETJS_NET_HTTP1_PARSER_STATUS_LINE; + return POCKETJS_NET_HTTP1_WIRE_ERROR_NONE; +} + +static bool parser_is_line_state(pocketjs_net_http1_parser_state_t state) { + return state == POCKETJS_NET_HTTP1_PARSER_STATUS_LINE || + state == POCKETJS_NET_HTTP1_PARSER_HEADER_LINE || + state == POCKETJS_NET_HTTP1_PARSER_CHUNK_SIZE_LINE || + state == POCKETJS_NET_HTTP1_PARSER_TRAILER_LINE; +} + +static pocketjs_net_http1_parse_result_t deliver_body( + pocketjs_net_http1_response_parser_t *parser, const uint8_t *input, + size_t amount) { + if (amount != 0 && + (parser->callbacks.on_body == NULL || + !parser->callbacks.on_body(parser->callback_context, input, amount))) { + return parser_fail(parser, + POCKETJS_NET_HTTP1_WIRE_ERROR_CALLBACK_REJECTED); + } + return POCKETJS_NET_HTTP1_PARSE_NEED_MORE; +} + +pocketjs_net_http1_parse_result_t pocketjs_net_http1_response_parser_feed( + pocketjs_net_http1_response_parser_t *parser, const uint8_t *input, + size_t input_length, size_t body_credit, size_t *consumed) { + if (consumed != NULL) { + *consumed = 0; + } + if (parser == NULL || consumed == NULL || + (input_length != 0 && input == NULL)) { + if (parser != NULL) { + return parser_fail(parser, + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_ARGUMENT); + } + return POCKETJS_NET_HTTP1_PARSE_ERROR; + } + if (parser->state == POCKETJS_NET_HTTP1_PARSER_ERROR) { + return POCKETJS_NET_HTTP1_PARSE_ERROR; + } + if (parser->state == POCKETJS_NET_HTTP1_PARSER_COMPLETE) { + return POCKETJS_NET_HTTP1_PARSE_COMPLETE; + } + + size_t position = 0; + size_t credit = body_credit; + while (position < input_length) { + if (parser_is_line_state(parser->state)) { + bool line_complete = false; + pocketjs_net_http1_wire_error_t error = + consume_line_byte(parser, input[position], &line_complete); + ++position; + if (error != POCKETJS_NET_HTTP1_WIRE_ERROR_NONE) { + *consumed = position; + return parser_fail(parser, error); + } + if (parser->state == POCKETJS_NET_HTTP1_PARSER_COMPLETE) { + *consumed = position; + return POCKETJS_NET_HTTP1_PARSE_COMPLETE; + } + continue; + } + + if (parser->state == POCKETJS_NET_HTTP1_PARSER_FIXED_BODY || + parser->state == POCKETJS_NET_HTTP1_PARSER_EOF_BODY || + parser->state == POCKETJS_NET_HTTP1_PARSER_CHUNK_BODY) { + if (credit == 0) { + *consumed = position; + return POCKETJS_NET_HTTP1_PARSE_PAUSED; + } + size_t amount = input_length - position; + if (amount > credit) { + amount = credit; + } + if (parser->state == POCKETJS_NET_HTTP1_PARSER_FIXED_BODY && + (uint64_t)amount > parser->body_remaining) { + amount = (size_t)parser->body_remaining; + } + if (parser->state == POCKETJS_NET_HTTP1_PARSER_CHUNK_BODY && + (uint64_t)amount > parser->chunk_remaining) { + amount = (size_t)parser->chunk_remaining; + } + pocketjs_net_http1_parse_result_t delivered = + deliver_body(parser, input + position, amount); + if (delivered == POCKETJS_NET_HTTP1_PARSE_ERROR) { + *consumed = position; + return delivered; + } + position += amount; + credit -= amount; + if (parser->state == POCKETJS_NET_HTTP1_PARSER_FIXED_BODY) { + parser->body_remaining -= amount; + if (parser->body_remaining == 0) { + parser_complete(parser); + *consumed = position; + return POCKETJS_NET_HTTP1_PARSE_COMPLETE; + } + } else if (parser->state == POCKETJS_NET_HTTP1_PARSER_CHUNK_BODY) { + parser->chunk_remaining -= amount; + if (parser->chunk_remaining == 0) { + parser->state = POCKETJS_NET_HTTP1_PARSER_CHUNK_BODY_CR; + } + } + continue; + } + + if (parser->state == POCKETJS_NET_HTTP1_PARSER_CHUNK_BODY_CR) { + ++position; + if (input[position - 1U] != '\r') { + *consumed = position; + return parser_fail(parser, + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_CHUNK_SIZE); + } + parser->state = POCKETJS_NET_HTTP1_PARSER_CHUNK_BODY_LF; + continue; + } + if (parser->state == POCKETJS_NET_HTTP1_PARSER_CHUNK_BODY_LF) { + ++position; + if (input[position - 1U] != '\n') { + *consumed = position; + return parser_fail(parser, + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_CHUNK_SIZE); + } + parser->state = POCKETJS_NET_HTTP1_PARSER_CHUNK_SIZE_LINE; + continue; + } + } + *consumed = position; + return parser->state == POCKETJS_NET_HTTP1_PARSER_COMPLETE + ? POCKETJS_NET_HTTP1_PARSE_COMPLETE + : POCKETJS_NET_HTTP1_PARSE_NEED_MORE; +} + +pocketjs_net_http1_parse_result_t pocketjs_net_http1_response_parser_finish( + pocketjs_net_http1_response_parser_t *parser) { + if (parser == NULL) { + return POCKETJS_NET_HTTP1_PARSE_ERROR; + } + if (parser->state == POCKETJS_NET_HTTP1_PARSER_COMPLETE) { + return POCKETJS_NET_HTTP1_PARSE_COMPLETE; + } + if (parser->state == POCKETJS_NET_HTTP1_PARSER_ERROR) { + return POCKETJS_NET_HTTP1_PARSE_ERROR; + } + if (parser->state == POCKETJS_NET_HTTP1_PARSER_EOF_BODY) { + parser_complete(parser); + return POCKETJS_NET_HTTP1_PARSE_COMPLETE; + } + return parser_fail(parser, POCKETJS_NET_HTTP1_WIRE_ERROR_UNEXPECTED_EOF); +} diff --git a/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/.gitignore b/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/.gitignore new file mode 100644 index 00000000..3349a3f7 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/.gitignore @@ -0,0 +1,4 @@ +/build-*/ +/dependencies.lock +/sdkconfig +/sdkconfig.old diff --git a/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/CMakeLists.txt new file mode 100644 index 00000000..bd2cb75b --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.16) + +set(EXTRA_COMPONENT_DIRS "../..") +set(SDKCONFIG "${CMAKE_BINARY_DIR}/sdkconfig") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(pocketjs_net_http1_build_smoke) diff --git a/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/README.md b/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/README.md new file mode 100644 index 00000000..740d8a62 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/README.md @@ -0,0 +1,30 @@ +# Bounded HTTP/1.1 wire corpus app + +This app runs request encoder (including case-insensitive rejection of the +Fetch-forbidden `CONNECT`, `TRACE`, and `TRACK` methods), response +fragmentation, framing, body-credit, chunk, and trailer assertions in +`app_main`. A successful boot prints: + +```text +pocketjs_net_http1: corpus passed +``` + +Use the pinned ESP-IDF v6.0.2 worktree for both targets: + +```sh +idf.py -B build-esp32s3 -DIDF_TARGET=esp32s3 build +idf.py -B build-esp32p4 -DIDF_TARGET=esp32p4 build +``` + +The same corpus can run on macOS without ESP-IDF: + +```sh +clang -std=c11 -Wall -Wextra -Werror \ + -DPOCKETJS_NET_HTTP1_HOST_TEST \ + -I../../include ../../src/http1_wire.c main/build_smoke.c \ + -o /tmp/pocketjs-net-http1-corpus +/tmp/pocketjs-net-http1-corpus +``` + +Build or corpus success validates the wire codec only. It does not admit the +PocketJS HTTP Client capability. diff --git a/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/main/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/main/CMakeLists.txt new file mode 100644 index 00000000..e9291b03 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/main/CMakeLists.txt @@ -0,0 +1,7 @@ +idf_component_register( + SRCS "build_smoke.c" + INCLUDE_DIRS "." + REQUIRES pocketjs_net_http1 +) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/main/build_smoke.c b/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/main/build_smoke.c new file mode 100644 index 00000000..c166527b --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/main/build_smoke.c @@ -0,0 +1,573 @@ +// SPDX-License-Identifier: MIT + +#include +#include +#include + +#include "pocketjs/net/http1_wire.h" + +#define SLICE(literal) \ + ((pocketjs_net_http1_slice_t){ \ + .data = (const uint8_t *)(literal), .length = sizeof(literal) - 1U}) + +typedef struct { + unsigned status_calls; + unsigned informational_status_calls; + unsigned header_calls; + unsigned headers_complete_calls; + unsigned completion_calls; + unsigned status; + pocketjs_net_http1_response_body_kind_t body_kind; + uint64_t content_length; + uint8_t status_text[64]; + size_t status_text_length; + uint8_t body[256]; + size_t body_length; + unsigned repeated_header_calls; +} observation_t; + +static bool observe_status(void *opaque, unsigned http_minor, + unsigned status_code, const uint8_t *status_text, + size_t status_text_length, bool informational) { + observation_t *observation = opaque; + assert(http_minor == 0U || http_minor == 1U); + assert(status_text_length <= sizeof(observation->status_text)); + observation->status_calls++; + if (informational) { + observation->informational_status_calls++; + } else { + observation->status = status_code; + observation->status_text_length = status_text_length; + memcpy(observation->status_text, status_text, status_text_length); + } + return true; +} + +static bool observe_header(void *opaque, const uint8_t *name, + size_t name_length, const uint8_t *value, + size_t value_length, bool informational) { + observation_t *observation = opaque; + (void)value; + (void)value_length; + (void)informational; + observation->header_calls++; + if (name_length == sizeof("X-Repeat") - 1U && + memcmp(name, "X-Repeat", name_length) == 0) { + observation->repeated_header_calls++; + } + /* Valid trailers are deliberately not delivered through this callback. */ + assert(name_length != sizeof("X-PocketJS-Trailer") - 1U || + memcmp(name, "X-PocketJS-Trailer", name_length) != 0); + return true; +} + +static bool observe_headers_complete( + void *opaque, unsigned status_code, + pocketjs_net_http1_response_body_kind_t body_kind, + uint64_t content_length, bool informational) { + observation_t *observation = opaque; + observation->headers_complete_calls++; + if (!informational) { + observation->status = status_code; + observation->body_kind = body_kind; + observation->content_length = content_length; + } + return true; +} + +static bool observe_body(void *opaque, const uint8_t *body, + size_t body_length) { + observation_t *observation = opaque; + assert(body_length <= sizeof(observation->body) - observation->body_length); + memcpy(observation->body + observation->body_length, body, body_length); + observation->body_length += body_length; + return true; +} + +static void observe_complete(void *opaque) { + observation_t *observation = opaque; + observation->completion_calls++; +} + +static const pocketjs_net_http1_response_callbacks_t callbacks = { + .on_status = observe_status, + .on_header = observe_header, + .on_headers_complete = observe_headers_complete, + .on_body = observe_body, + .on_complete = observe_complete, +}; + +static void init_parser(pocketjs_net_http1_response_parser_t *parser, + observation_t *observation, bool response_to_head) { + memset(observation, 0, sizeof(*observation)); + assert(pocketjs_net_http1_response_parser_init( + parser, &pocketjs_net_http1_default_limits, &callbacks, + observation, response_to_head) == + POCKETJS_NET_HTTP1_WIRE_ERROR_NONE); +} + +static pocketjs_net_http1_parse_result_t feed_one_byte_at_a_time( + pocketjs_net_http1_response_parser_t *parser, const uint8_t *wire, + size_t wire_length) { + pocketjs_net_http1_parse_result_t result = + POCKETJS_NET_HTTP1_PARSE_NEED_MORE; + size_t offset = 0; + while (offset < wire_length) { + size_t consumed = 0; + result = pocketjs_net_http1_response_parser_feed( + parser, wire + offset, 1U, SIZE_MAX, &consumed); + assert(consumed == 1U); + offset += consumed; + if (result == POCKETJS_NET_HTTP1_PARSE_COMPLETE || + result == POCKETJS_NET_HTTP1_PARSE_ERROR) { + assert(offset == wire_length); + break; + } + assert(result == POCKETJS_NET_HTTP1_PARSE_NEED_MORE); + } + return result; +} + +static pocketjs_net_http1_wire_error_t parse_malformed(const char *wire) { + pocketjs_net_http1_response_parser_t parser; + observation_t observation; + init_parser(&parser, &observation, false); + size_t length = strlen(wire); + size_t consumed = 0; + pocketjs_net_http1_parse_result_t result = + pocketjs_net_http1_response_parser_feed( + &parser, (const uint8_t *)wire, length, SIZE_MAX, &consumed); + if (result != POCKETJS_NET_HTTP1_PARSE_ERROR) { + result = pocketjs_net_http1_response_parser_finish(&parser); + } + assert(result == POCKETJS_NET_HTTP1_PARSE_ERROR); + assert(parser.error != POCKETJS_NET_HTTP1_WIRE_ERROR_NONE); + return parser.error; +} + +static void test_request_encoder(void) { + const pocketjs_net_http1_header_t headers[] = { + {.name = SLICE("X-Repeat"), .value = SLICE("one")}, + {.name = SLICE("X-Repeat"), .value = SLICE("two")}, + }; + pocketjs_net_http1_request_t request = { + .method = SLICE("PATCH"), + .target = SLICE("/v1/items/a%20b?mode=wire&n=2"), + .host = SLICE("[2001:db8::1]:8443"), + .headers = headers, + .header_count = sizeof(headers) / sizeof(headers[0]), + .body_kind = POCKETJS_NET_HTTP1_REQUEST_BODY_FIXED, + .content_length = 12, + }; + pocketjs_net_http1_request_encoder_t encoder; + assert(pocketjs_net_http1_request_encoder_init( + &encoder, &request, &pocketjs_net_http1_default_limits) == + POCKETJS_NET_HTTP1_WIRE_ERROR_NONE); + uint8_t wire[512]; + size_t wire_length = 0; + pocketjs_net_http1_encoder_result_t result; + do { + size_t produced = 0; + result = pocketjs_net_http1_request_encoder_write( + &encoder, wire + wire_length, 1U, &produced); + assert(produced == 1U || result == POCKETJS_NET_HTTP1_ENCODER_DONE); + wire_length += produced; + } while (result != POCKETJS_NET_HTTP1_ENCODER_DONE); + static const char expected[] = + "PATCH /v1/items/a%20b?mode=wire&n=2 HTTP/1.1\r\n" + "Host: [2001:db8::1]:8443\r\n" + "X-Repeat: one\r\n" + "X-Repeat: two\r\n" + "Content-Length: 12\r\n" + "\r\n"; + assert(wire_length == sizeof(expected) - 1U); + assert(memcmp(wire, expected, wire_length) == 0); + + request.method = SLICE("X-CUSTOM!METHOD"); + request.body_kind = POCKETJS_NET_HTTP1_REQUEST_BODY_CHUNKED; + request.content_length = 0; + assert(pocketjs_net_http1_request_encoder_init( + &encoder, &request, &pocketjs_net_http1_default_limits) == + POCKETJS_NET_HTTP1_WIRE_ERROR_NONE); + + request.method = SLICE("CONNECT"); + assert(pocketjs_net_http1_request_encoder_init( + &encoder, &request, &pocketjs_net_http1_default_limits) == + POCKETJS_NET_HTTP1_WIRE_ERROR_FORBIDDEN_METHOD); + request.method = SLICE("trace"); + assert(pocketjs_net_http1_request_encoder_init( + &encoder, &request, &pocketjs_net_http1_default_limits) == + POCKETJS_NET_HTTP1_WIRE_ERROR_FORBIDDEN_METHOD); + request.method = SLICE("TrAcK"); + assert(pocketjs_net_http1_request_encoder_init( + &encoder, &request, &pocketjs_net_http1_default_limits) == + POCKETJS_NET_HTTP1_WIRE_ERROR_FORBIDDEN_METHOD); + request.method = SLICE("BAD METHOD"); + assert(pocketjs_net_http1_request_encoder_init( + &encoder, &request, &pocketjs_net_http1_default_limits) == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_METHOD); + + request.method = SLICE("GET"); + request.target = SLICE("https://peer.test/"); + assert(pocketjs_net_http1_request_encoder_init( + &encoder, &request, &pocketjs_net_http1_default_limits) == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_TARGET); + request.target = SLICE("/bad#fragment"); + assert(pocketjs_net_http1_request_encoder_init( + &encoder, &request, &pocketjs_net_http1_default_limits) == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_TARGET); + request.target = SLICE("/bad%xx"); + assert(pocketjs_net_http1_request_encoder_init( + &encoder, &request, &pocketjs_net_http1_default_limits) == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_TARGET); + + request.target = SLICE("/"); + request.host = SLICE("peer.test:99999"); + assert(pocketjs_net_http1_request_encoder_init( + &encoder, &request, &pocketjs_net_http1_default_limits) == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_HOST); + request.host = SLICE("peer.test:0"); + assert(pocketjs_net_http1_request_encoder_init( + &encoder, &request, &pocketjs_net_http1_default_limits) == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_HOST); + request.host = SLICE("[::::]:443"); + assert(pocketjs_net_http1_request_encoder_init( + &encoder, &request, &pocketjs_net_http1_default_limits) == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_HOST); + request.host = SLICE("peer.test\r\nInjected: yes"); + assert(pocketjs_net_http1_request_encoder_init( + &encoder, &request, &pocketjs_net_http1_default_limits) == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_HOST); + + pocketjs_net_http1_header_t bad_header = { + .name = SLICE("X-Test"), .value = SLICE("safe\r\nInjected: yes")}; + request.host = SLICE("peer.test"); + request.headers = &bad_header; + request.header_count = 1; + assert(pocketjs_net_http1_request_encoder_init( + &encoder, &request, &pocketjs_net_http1_default_limits) == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_HEADER); + bad_header.name = SLICE("Host"); + bad_header.value = SLICE("other.test"); + assert(pocketjs_net_http1_request_encoder_init( + &encoder, &request, &pocketjs_net_http1_default_limits) == + POCKETJS_NET_HTTP1_WIRE_ERROR_FORBIDDEN_REQUEST_HEADER); + bad_header.name = SLICE("Trailer"); + assert(pocketjs_net_http1_request_encoder_init( + &encoder, &request, &pocketjs_net_http1_default_limits) == + POCKETJS_NET_HTTP1_WIRE_ERROR_FORBIDDEN_REQUEST_HEADER); + + request.headers = NULL; + request.header_count = 0; + request.host = SLICE("peer.test"); + request.body_kind = POCKETJS_NET_HTTP1_REQUEST_BODY_CHUNKED; + request.content_length = 1U; + assert(pocketjs_net_http1_request_encoder_init( + &encoder, &request, &pocketjs_net_http1_default_limits) == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_ARGUMENT); + request.body_kind = POCKETJS_NET_HTTP1_REQUEST_BODY_NONE; + assert(pocketjs_net_http1_request_encoder_init( + &encoder, &request, &pocketjs_net_http1_default_limits) == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_ARGUMENT); + request.content_length = 0; + + pocketjs_net_http1_limits_t tiny = pocketjs_net_http1_default_limits; + tiny.max_header_bytes = 16U; + request.headers = NULL; + request.header_count = 0; + assert(pocketjs_net_http1_request_encoder_init(&encoder, &request, &tiny) == + POCKETJS_NET_HTTP1_WIRE_ERROR_HEADER_BYTES_EXCEEDED); +} + +static void test_fragmented_fixed_and_informational(void) { + static const uint8_t wire[] = + "HTTP/1.1 103 Early Hints\r\n" + "Link: ; rel=preload\r\n" + "\r\n" + "HTTP/1.1 299 Odd Wire Text\r\n" + "X-Repeat: one\r\n" + "X-Repeat: two\r\n" + "Content-Length: 5\r\n" + "\r\n" + "hello"; + pocketjs_net_http1_response_parser_t parser; + observation_t observation; + init_parser(&parser, &observation, false); + assert(feed_one_byte_at_a_time(&parser, wire, sizeof(wire) - 1U) == + POCKETJS_NET_HTTP1_PARSE_COMPLETE); + assert(observation.status_calls == 2U); + assert(observation.informational_status_calls == 1U); + assert(observation.headers_complete_calls == 2U); + assert(observation.status == 299U); + assert(observation.body_kind == POCKETJS_NET_HTTP1_RESPONSE_BODY_FIXED); + assert(observation.content_length == 5U); + assert(observation.status_text_length == sizeof("Odd Wire Text") - 1U); + assert(memcmp(observation.status_text, "Odd Wire Text", + observation.status_text_length) == 0); + assert(observation.repeated_header_calls == 2U); + assert(observation.body_length == 5U); + assert(memcmp(observation.body, "hello", 5U) == 0); + assert(observation.completion_calls == 1U); +} + +static void test_body_credit_pause(void) { + static const uint8_t wire[] = + "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"; + const size_t head_length = sizeof(wire) - 1U - 5U; + pocketjs_net_http1_response_parser_t parser; + observation_t observation; + init_parser(&parser, &observation, false); + + size_t consumed = 0; + assert(pocketjs_net_http1_response_parser_feed( + &parser, wire, sizeof(wire) - 1U, 0, &consumed) == + POCKETJS_NET_HTTP1_PARSE_PAUSED); + assert(consumed == head_length); + assert(observation.body_length == 0); + + size_t second = 0; + assert(pocketjs_net_http1_response_parser_feed( + &parser, wire + consumed, sizeof(wire) - 1U - consumed, 2U, + &second) == POCKETJS_NET_HTTP1_PARSE_PAUSED); + assert(second == 2U); + assert(observation.body_length == 2U); + consumed += second; + + size_t third = 0; + assert(pocketjs_net_http1_response_parser_feed( + &parser, wire + consumed, sizeof(wire) - 1U - consumed, 3U, + &third) == POCKETJS_NET_HTTP1_PARSE_COMPLETE); + assert(third == 3U); + assert(observation.body_length == 5U); + assert(memcmp(observation.body, "hello", 5U) == 0); +} + +static void test_chunked_and_trailer(void) { + static const uint8_t wire[] = + "HTTP/1.1 200 Chunked\r\n" + "Transfer-Encoding: chunked\r\n" + "Trailer: X-PocketJS-Trailer\r\n" + "\r\n" + "4; source=peer\r\nPock\r\n" + "4; quoted=\"yes\\!\"\r\netJS\r\n" + "0\r\n" + "X-PocketJS-Trailer: complete\r\n" + "\r\n"; + pocketjs_net_http1_response_parser_t parser; + observation_t observation; + init_parser(&parser, &observation, false); + assert(feed_one_byte_at_a_time(&parser, wire, sizeof(wire) - 1U) == + POCKETJS_NET_HTTP1_PARSE_COMPLETE); + assert(observation.body_kind == POCKETJS_NET_HTTP1_RESPONSE_BODY_CHUNKED); + assert(observation.body_length == 8U); + assert(memcmp(observation.body, "PocketJS", 8U) == 0); + assert(parser.validated_trailer_fields == 1U); + assert(observation.header_calls == 2U); + assert(observation.completion_calls == 1U); + + assert(parse_malformed( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" + "1\r\na\r\n0\r\nAuthorization: secret\r\n\r\n") == + POCKETJS_NET_HTTP1_WIRE_ERROR_FORBIDDEN_TRAILER); + assert(parse_malformed( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" + "4\r\ngzip\r\n0\r\nContent-Encoding: gzip\r\n\r\n") == + POCKETJS_NET_HTTP1_WIRE_ERROR_FORBIDDEN_TRAILER); + assert(parse_malformed( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n" + "Trailer: Content-Encoding\r\n\r\n0\r\n\r\n") == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_TRAILER_DECLARATION); +} + +static void test_no_body_and_eof(void) { + static const uint8_t head_wire[] = + "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"; + pocketjs_net_http1_response_parser_t parser; + observation_t observation; + init_parser(&parser, &observation, true); + size_t consumed = 0; + assert(pocketjs_net_http1_response_parser_feed( + &parser, head_wire, sizeof(head_wire) - 1U, SIZE_MAX, + &consumed) == POCKETJS_NET_HTTP1_PARSE_COMPLETE); + assert(consumed == sizeof(head_wire) - 1U - 5U); + assert(observation.body_kind == POCKETJS_NET_HTTP1_RESPONSE_BODY_NONE); + assert(observation.body_length == 0); + + static const uint8_t no_content[] = + "HTTP/1.1 204 No Content\r\nDate: now\r\n\r\n"; + init_parser(&parser, &observation, false); + assert(feed_one_byte_at_a_time(&parser, no_content, + sizeof(no_content) - 1U) == + POCKETJS_NET_HTTP1_PARSE_COMPLETE); + assert(observation.status == 204U); + assert(observation.body_kind == POCKETJS_NET_HTTP1_RESPONSE_BODY_NONE); + + static const uint8_t not_modified[] = + "HTTP/1.1 304 Not Modified\r\nContent-Length: 123\r\n\r\n"; + init_parser(&parser, &observation, false); + assert(feed_one_byte_at_a_time(&parser, not_modified, + sizeof(not_modified) - 1U) == + POCKETJS_NET_HTTP1_PARSE_COMPLETE); + assert(observation.body_kind == POCKETJS_NET_HTTP1_RESPONSE_BODY_NONE); + + static const uint8_t zero[] = + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"; + init_parser(&parser, &observation, false); + assert(feed_one_byte_at_a_time(&parser, zero, sizeof(zero) - 1U) == + POCKETJS_NET_HTTP1_PARSE_COMPLETE); + assert(observation.body_kind == POCKETJS_NET_HTTP1_RESPONSE_BODY_FIXED); + + static const uint8_t eof_wire[] = + "HTTP/1.0 200 Legacy\r\nConnection: close\r\n\r\nlegacy-body"; + init_parser(&parser, &observation, false); + assert(feed_one_byte_at_a_time(&parser, eof_wire, sizeof(eof_wire) - 1U) == + POCKETJS_NET_HTTP1_PARSE_NEED_MORE); + assert(observation.body_kind == + POCKETJS_NET_HTTP1_RESPONSE_BODY_UNTIL_EOF); + assert(observation.body_length == sizeof("legacy-body") - 1U); + assert(pocketjs_net_http1_response_parser_finish(&parser) == + POCKETJS_NET_HTTP1_PARSE_COMPLETE); + + assert(parse_malformed( + "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n") == + POCKETJS_NET_HTTP1_WIRE_ERROR_AMBIGUOUS_FRAMING); + assert(parse_malformed( + "HTTP/1.0 200 Legacy\r\nTransfer-Encoding: chunked\r\n\r\n" + "0\r\n\r\n") == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_TRANSFER_ENCODING); +} + +static void test_peer_malformed_corpus(void) { + assert(parse_malformed( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n" + "Content-Length: 5\r\n\r\n5\r\nhello\r\n0\r\n\r\n") == + POCKETJS_NET_HTTP1_WIRE_ERROR_AMBIGUOUS_FRAMING); + assert(parse_malformed( + "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n" + "Content-Length: 5\r\n\r\nhello") == + POCKETJS_NET_HTTP1_WIRE_ERROR_DUPLICATE_CONTENT_LENGTH); + assert(parse_malformed( + "HTTP/1.1 200 OK\r\nContent-Length: 5, 5\r\n\r\nhello") == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_CONTENT_LENGTH); + assert(parse_malformed( + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nX-Test: first\r\n" + " second\r\n\r\nok") == + POCKETJS_NET_HTTP1_WIRE_ERROR_OBS_FOLD); + assert(parse_malformed( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n" + "Transfer-Encoding: chunked\r\n\r\n0\r\n\r\n") == + POCKETJS_NET_HTTP1_WIRE_ERROR_DUPLICATE_TRANSFER_ENCODING); + assert(parse_malformed( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip, chunked\r\n" + "\r\n0\r\n\r\n") == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_TRANSFER_ENCODING); + assert(parse_malformed( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip\r\n\r\nopaque") == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_TRANSFER_ENCODING); + assert(parse_malformed( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n" + "Trailer: Content-Length\r\n\r\n2\r\nok\r\n0\r\n" + "Content-Length: 2\r\n\r\n") == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_TRAILER_DECLARATION); + assert(parse_malformed( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n" + "Trailer: X-Valid, \r\n\r\n0\r\n\r\n") == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_TRAILER_DECLARATION); + assert(parse_malformed( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" + "Z\r\ninvalid\r\n0\r\n\r\n") == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_CHUNK_SIZE); + assert(parse_malformed( + "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhe") == + POCKETJS_NET_HTTP1_WIRE_ERROR_UNEXPECTED_EOF); +} + +static void test_limits(void) { + pocketjs_net_http1_limits_t limits = pocketjs_net_http1_default_limits; + limits.max_header_fields = 1U; + pocketjs_net_http1_response_parser_t parser; + observation_t observation; + memset(&observation, 0, sizeof(observation)); + assert(pocketjs_net_http1_response_parser_init( + &parser, &limits, &callbacks, &observation, false) == + POCKETJS_NET_HTTP1_WIRE_ERROR_NONE); + static const uint8_t fields[] = + "HTTP/1.1 200 OK\r\nX-One: 1\r\nX-Two: 2\r\n\r\n"; + size_t consumed = 0; + assert(pocketjs_net_http1_response_parser_feed( + &parser, fields, sizeof(fields) - 1U, SIZE_MAX, &consumed) == + POCKETJS_NET_HTTP1_PARSE_ERROR); + assert(parser.error == + POCKETJS_NET_HTTP1_WIRE_ERROR_HEADER_FIELDS_EXCEEDED); + assert(pocketjs_net_http1_wire_error_is_limit(parser.error)); + + limits = pocketjs_net_http1_default_limits; + limits.max_informational_responses = 1U; + memset(&observation, 0, sizeof(observation)); + assert(pocketjs_net_http1_response_parser_init( + &parser, &limits, &callbacks, &observation, false) == + POCKETJS_NET_HTTP1_WIRE_ERROR_NONE); + static const uint8_t infos[] = + "HTTP/1.1 100 Continue\r\n\r\n" + "HTTP/1.1 103 Early Hints\r\n\r\n" + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"; + assert(pocketjs_net_http1_response_parser_feed( + &parser, infos, sizeof(infos) - 1U, SIZE_MAX, &consumed) == + POCKETJS_NET_HTTP1_PARSE_ERROR); + assert(parser.error == + POCKETJS_NET_HTTP1_WIRE_ERROR_TOO_MANY_INFORMATIONAL_RESPONSES); + assert(strcmp(pocketjs_net_http1_wire_error_name(parser.error), + "too_many_informational_responses") == 0); + + limits = pocketjs_net_http1_default_limits; + limits.max_header_bytes = 20U; + memset(&observation, 0, sizeof(observation)); + assert(pocketjs_net_http1_response_parser_init( + &parser, &limits, &callbacks, &observation, false) == + POCKETJS_NET_HTTP1_WIRE_ERROR_NONE); + static const uint8_t header_bytes[] = + "HTTP/1.1 200 OK\r\nX-Long: 1234567890\r\n\r\n"; + assert(pocketjs_net_http1_response_parser_feed( + &parser, header_bytes, sizeof(header_bytes) - 1U, SIZE_MAX, + &consumed) == POCKETJS_NET_HTTP1_PARSE_ERROR); + assert(parser.error == POCKETJS_NET_HTTP1_WIRE_ERROR_HEADER_BYTES_EXCEEDED); + + limits = pocketjs_net_http1_default_limits; + limits.max_line_bytes = 16U; + memset(&observation, 0, sizeof(observation)); + assert(pocketjs_net_http1_response_parser_init( + &parser, &limits, &callbacks, &observation, false) == + POCKETJS_NET_HTTP1_WIRE_ERROR_NONE); + static const uint8_t long_line[] = + "HTTP/1.1 200 This reason phrase is too long\r\n\r\n"; + assert(pocketjs_net_http1_response_parser_feed( + &parser, long_line, sizeof(long_line) - 1U, SIZE_MAX, + &consumed) == POCKETJS_NET_HTTP1_PARSE_ERROR); + assert(parser.error == POCKETJS_NET_HTTP1_WIRE_ERROR_LINE_TOO_LONG); + + limits = pocketjs_net_http1_default_limits; + limits.max_header_fields = POCKETJS_NET_HTTP1_MAX_HEADER_FIELDS + 1U; + assert(pocketjs_net_http1_response_parser_init( + &parser, &limits, &callbacks, &observation, false) == + POCKETJS_NET_HTTP1_WIRE_ERROR_INVALID_ARGUMENT); +} + +static void run_corpus(void) { + test_request_encoder(); + test_fragmented_fixed_and_informational(); + test_body_credit_pause(); + test_chunked_and_trailer(); + test_no_body_and_eof(); + test_peer_malformed_corpus(); + test_limits(); + puts("pocketjs_net_http1: corpus passed"); +} + +#ifdef POCKETJS_NET_HTTP1_HOST_TEST +int main(void) { + run_corpus(); + return 0; +} +#else +void app_main(void) { run_corpus(); } +#endif diff --git a/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/main/idf_component.yml b/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/main/idf_component.yml new file mode 100644 index 00000000..700ce96e --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/main/idf_component.yml @@ -0,0 +1,3 @@ +dependencies: + idf: + version: "==6.0.2" diff --git a/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/sdkconfig.defaults b/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/sdkconfig.defaults new file mode 100644 index 00000000..4983b4df --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http1/test_apps/build_smoke/sdkconfig.defaults @@ -0,0 +1 @@ +CONFIG_COMPILER_OPTIMIZATION_SIZE=y diff --git a/hosts/esp-idf/components/pocketjs_net_http_client_core/.gitignore b/hosts/esp-idf/components/pocketjs_net_http_client_core/.gitignore new file mode 100644 index 00000000..86042ab4 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http_client_core/.gitignore @@ -0,0 +1 @@ +*.plist diff --git a/hosts/esp-idf/components/pocketjs_net_http_client_core/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_http_client_core/CMakeLists.txt new file mode 100644 index 00000000..13abb2eb --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http_client_core/CMakeLists.txt @@ -0,0 +1,9 @@ +idf_component_register( + SRCS + "src/http_client_core.c" + "src/http_client_core_esp.c" + INCLUDE_DIRS "include" + REQUIRES pocketjs_net_http1 pocketjs_net_esp_transport +) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/hosts/esp-idf/components/pocketjs_net_http_client_core/README.md b/hosts/esp-idf/components/pocketjs_net_http_client_core/README.md new file mode 100644 index 00000000..5f67a8a1 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http_client_core/README.md @@ -0,0 +1,215 @@ +# PocketJS ESP-IDF HTTP Client Core candidate + +This component is an experimental, private plaintext HTTP/1.1 protocol Core. +It combines `pocketjs_net_http1` with the public operation/completion API of +`pocketjs_net_esp_transport`; it does not use `esp_http_client` and does not +register or advertise a PocketJS capability. + +## Implemented boundary + +One statically supplied 36 KiB instance storage admits one request at a time. +The Core snapshots the method, URL, fields, and an optional fixed request body +before native I/O. A request selects exactly one body mode: none, a fixed +snapshot, or a credit-driven stream. The fixed snapshot is at most 4096 bytes. +A streaming producer may send more than that total, but each submitted chunk +is at most 2048 bytes and is copied into fixed Core storage before the owner API +returns. Request and response field storage are each 8192 bytes, and each +downstream response-body lease is at most 2048 bytes. The Core and wire parser +do not allocate. + +Only canonical ASCII DNS names and canonical dotted-decimal IPv4 literals are +accepted. User information, fragments, IPv6, non-canonical numeric hosts, +invalid percent escapes, and non-canonical ports are rejected before I/O. +Requests use origin-form, add `Accept-Encoding: identity`, and reject +caller-controlled connection, framing, proxy, upgrade, and content-coding +fields. GET and HEAD request bodies are rejected. With +`enable_connection_reuse=false`, every request also adds `Connection: close` +and closes after the response. With reuse enabled, one Core retains at most one +HTTP/1.1 connection after a completely parsed, self-delimited response that did +not carry a `Connection: close` token. There is no pipelining. A later request +reuses that connection only for the same canonical scheme, DNS name, port, and +selected numeric address, after repeating both permission checks. HTTP/1.0, +EOF-delimited responses, an explicit close token, a changed origin, redirect, +error, cancellation, timeout, or shutdown closes the connection first. The +same configured idle timeout also bounds how long an unused cached connection +is retained. + +Unknown-length request streams use strict HTTP/1.1 chunked coding: one +lowercase hexadecimal size, CRLF, the credited non-empty payload, and CRLF per +chunk, followed by exactly `0\r\n\r\n`. Known-length request streams emit +`Content-Length` and must submit exactly that many raw bytes. Their final byte +ends the upload without another producer pull; an early producer end selects a +request-body error. The fixed and known-length paths never use chunk markers. + +For each streaming chunk, the Core publishes one `REQUEST_BODY_PULL` carrying +the operation token, a non-reusable body generation, a non-reusable pull +generation, and `maximum_bytes`. The native adapter takes and immediately +retires that event. Retirement frees the event slot but leaves exactly one +credit active, so an asynchronous Guest producer can submit a later +`BODY_CHUNK`, `BODY_END`, or `BODY_ERROR` command that echoes the token and both +generations. A command before retirement, without credit, with stale identity, +with an empty chunk, or above the advertised bound is rejected without +consuming credit. Abort, timeout, producer failure, and shutdown revoke the +credit before connection cleanup. Event, body, and pull generations never +wrap or reuse during a live Core instance. + +The permission callback runs on the owner task. For a DNS name it receives the +canonical `(scheme, hostname, port)` tuple before resolve starts. After resolve, +the callback is invoked for every returned numeric candidate before any connect +starts. Allowed candidates are retained in resolver order. A transport-class +connect refusal or unreachable result advances to the next retained candidate +under the original connect deadline; no HTTP bytes have been emitted and no +request is replayed. TLS, timeout, I/O, resource, abort, and local admission +failures are terminal and never advance the address list. A literal IPv4 +address receives the numeric permission check directly. **The callback is +non-reentrant:** every Core entry point rejects a +call made from the callback, and the Core verifies its lifecycle generation, +operation token, state, and transport-idle state after each callback returns. + +Response headers are copied into the fixed response store and emitted before +body bytes. **`response_header_bytes_limit` bounds the final response fields as +`name + value + ": " + CRLF`; zero selects the 8192-byte compile-time maximum, +and initialization rejects a larger value.** Crossing the selected limit is a +`resource_limit` terminal selected by the parser callback before any response +headers event is published. The Host must retire that event, grant one bounded +body-credit window, view and release the resulting body lease, and retire the +body event before more input is consumed. The HTTP/1.1 substrate validates +framing, chunking, chunk extensions, trailer declarations, and trailer fields. +Statuses from 200 through 599, including 4xx and 5xx, are protocol success. +Content coding other than `identity` is rejected because this Core does no +decoding. When one read completion contains bytes and EOF, **EOF is applied +only after every byte in that transport lease has been consumed under body +credit**. Because a non-empty body lease is not itself an EOF marker, the Core +accepts one final downstream credit after the parser has completed and while +the successful terminal is closing or queued. That pull receives no bytes and +is completed by the terminal event; a duplicate final credit is rejected. + +Connect, response-header, idle-read, and total deadlines use Host-supplied +monotonic microseconds. Abort and all deadline paths select one terminal result. +When a response boundary is unknown, the Core cancels active read/write work or +starts an explicit close. When the transport admits cleanup, the Core waits for +its fail-close or close terminal. If cleanup cannot be admitted or times out, +the Core emits the already selected terminal with poison and retained ownership +so the product can tear down the dedicated transport. The ESP transport instance +must be dedicated to this Core because completion dequeue is single-consumer. + +**The first selected HTTP terminal result is immutable.** A later close +admission failure, close error, close timeout, read-lease release failure, or +completion-retirement failure cannot replace it. Those native cleanup failures +set machine-readable poison flags exposed by +`pocketjs_net_http_client_core_get_status`; a poisoned Core rejects every new +request. Failed lease release retains the exact handle as owned state, and a +failed completion retirement retains its token for bounded retries. An exact +Host event-retirement invariant failure is reported separately and retains the +delivering event and any body lease until shutdown cleanup. + +The lifecycle is explicit. `begin_shutdown` permanently stops admission and +aborts a current request. The owner continues pumping and retiring events until +`is_quiescent` succeeds, then calls `deinit`. If poisoned native ownership cannot +be discharged through the transport API, the product must synchronously destroy +the dedicated transport and call `confirm_transport_shutdown` before the Core +can become quiescent. A poisoned Core whose exact Host event still cannot be +retired after that confirmation exposes a separate abandon call; it is rejected +before shutdown, on a healthy Core, before transport confirmation, for a pending +event, or for a mismatched sequence. `init` detects and rejects reuse of live +storage. + +The Core implements `manual`, `error`, and bounded `follow` redirect modes in +the original operation. Intermediate redirect headers and bodies are not +published to the Guest. The Core closes the old connection, resolves the new +target, and repeats hostname and every numeric-candidate permission check +before the next write. The original total deadline spans all hops. It rewrites +POST for 301/302 and every method except HEAD for 303, removes the body fields, +and strips `Authorization`, `Proxy-Authorization`, and `Cookie` across origins. +A fixed body can be replayed for 307/308. **A consumed streaming body is never +buffered or replayed; a redirect that must preserve it fails with +`invalid_state` before the next hop.** Redirect response bodies and trailers +are abandoned by explicitly closing the current connection before the next +hop; they are never placed into the one-entry reuse cache. + +Redirect resolution currently accepts bounded ASCII HTTP(S) references, +including absolute, scheme-relative, root-relative, path-relative, +query-relative, fragment-only, and percent-encoded dot segments. Control +bytes, raw non-ASCII bytes, backslashes, invalid escapes, unsupported schemes, +IPv6, and non-canonical host syntax fail closed. This is sufficient for the +formal ESP smoke target but remains narrower than the framework's pinned +WHATWG/UTS #46 parser. Retry, authentication, cookie storage, proxy, +compression, and decompression remain absent. + +## HTTPS and admission blockers + +HTTPS is an explicit Host-selected experimental path. The default +`allow_https=false` still returns +`POCKETJS_NET_HTTP_CLIENT_START_UNSUPPORTED_TLS` before permission callbacks, +DNS, or socket I/O. An opted-in request must also carry the exact base TLS +policy: TLS 1.2 only, full verification, Host-default revocation, no ALPN, +credential, client certificate, or custom CA, and a server name equal to the +Core's canonical DNS hostname. HTTPS IP literals remain pre-I/O unsupported: +ESP-TLS/Mbed TLS currently couples IP-ID verification to sending that IP as an +invalid SNI value. The Core passes the canonical DNS name and selected numeric +candidate separately to the transport. Any policy mismatch fails before I/O. + +This path does not change admission status. The current ESP transport reports +a bounded one millisecond internal TLS wait, native DNS/socket/TLS allocations +without caller-owned byte bounds, and incomplete DNS candidate sets. Those +transport blockers also apply to this plaintext candidate's eventual public +admission where relevant. + +The current Core additionally has these gaps: + +- it supports one in-flight operation and one connection, not the architecture + concurrency target; +- it supports IPv4 only and does not implement IDNA or IPv6; +- its redirect resolver remains an ASCII-only native subset and therefore + still needs differential conformance against the pinned framework URL + implementation before public admission; +- a native failure to start or complete the final close has no lower-level + force-close primitive, so the Core preserves the HTTP terminal result, + exposes the cleanup poison and retained ownership, and requires the product + to tear down the dedicated transport; +- fixed PocketJS-owned storage is proven, but the composed lwIP pools and socket + allocations still need target-specific Kconfig bounds, peak measurements, + and soak evidence; +- the composed product scheduler, resource ledger, and full AtomS3R/Tab5 + admission matrix remain incomplete even though the formal runtime smoke + artifacts now exercise this Core on both targets. + +Until those gaps are resolved, this component must not enter a production or +publicly admitted Build Plan and must not advertise `network.http.client` or +`network.http.client.tls`. + +## Verification + +`test_host/core_test.c` uses a deterministic fake transport to cover permission +ordering, ordered fallback across authorized DNS candidates, candidate +exhaustion and non-retryable error classes, headers-first delivery, bounded +body credit and leases, 4xx success, strict trailer failure, +close-before-error ordering, default pre-I/O HTTPS +rejection, exact base-policy validation and explicit TLS connect selection, +redirect follow/error/manual decisions, relative URL resolution, per-hop +permission checks, method/body rewrite, cross-origin sensitive-field removal, +fixed-body replay, streaming-body replay refusal, abort, total timeout, +selected response-header configuration and parse +boundaries, and exact-one terminal delivery. Hostile cases cover all +public API calls attempted from a permission callback, bytes-plus-EOF reads, +stale completions while idle, malformed read cleanup, lease and completion +retirement failures, close admission/error/timeout, terminal immutability, +explicit teardown, poison-only Host event/lease abandonment, HEAD, 1xx followed +by 304, numeric-host denial, and the exact +4096-byte fixed request-body boundary. Streaming hostile cases cover strict +chunk coding over more than 64 KiB, one-byte chunks, asynchronous credit after +event retirement, empty/oversized/no-credit/stale submissions, known-length +underflow and overflow, abort, timeout, producer error, shutdown, and +generation non-reuse. It is compiled with `-Wall -Wextra -Werror` plus +AddressSanitizer and UndefinedBehaviorSanitizer and is also checked with Clang +Static Analyzer. + +Reuse cases cover same-origin plaintext and TLS requests without another DNS +or connect operation, permission rechecks on every request, close-token +handling, changed-origin replacement, and idle cached-connection shutdown. +The descriptor exposes a bounded one-entry cache per Core. + +`test_apps/build_smoke` links the Core, wire codec, and real ESP transport under +the pinned ESP-IDF v6.0.2 tree. It builds for both `esp32s3` and `esp32p4` with +the transport TLS policy Kconfig, while asserting that HTTPS remains +fail-closed unless the selected Host opts in. diff --git a/hosts/esp-idf/components/pocketjs_net_http_client_core/idf_component.yml b/hosts/esp-idf/components/pocketjs_net_http_client_core/idf_component.yml new file mode 100644 index 00000000..700ce96e --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http_client_core/idf_component.yml @@ -0,0 +1,3 @@ +dependencies: + idf: + version: "==6.0.2" diff --git a/hosts/esp-idf/components/pocketjs_net_http_client_core/include/pocketjs/net/http_client_core.h b/hosts/esp-idf/components/pocketjs_net_http_client_core/include/pocketjs/net/http_client_core.h new file mode 100644 index 00000000..76ec4dbf --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http_client_core/include/pocketjs/net/http_client_core.h @@ -0,0 +1,568 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define POCKETJS_NET_HTTP_CLIENT_CORE_ID \ + "pocketjs.net.http-client-core.v1.experimental" + +#define POCKETJS_NET_HTTP_CLIENT_CORE_INSTANCE_BYTES 36864U +#define POCKETJS_NET_HTTP_CLIENT_CORE_MAX_URL_BYTES 2048U +#define POCKETJS_NET_HTTP_CLIENT_CORE_MAX_METHOD_BYTES 32U +#define POCKETJS_NET_HTTP_CLIENT_CORE_MAX_HOST_BYTES 253U +#define POCKETJS_NET_HTTP_CLIENT_CORE_MAX_TARGET_BYTES 2048U +#define POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_HEADER_BYTES 8192U +#define POCKETJS_NET_HTTP_CLIENT_CORE_MAX_RESPONSE_HEADER_BYTES 8192U +#define POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_HEADERS 60U +#define POCKETJS_NET_HTTP_CLIENT_CORE_MAX_RESPONSE_HEADERS 64U +#define POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_BODY_BYTES 4096U +#define POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES 2048U +#define POCKETJS_NET_HTTP_CLIENT_CORE_WRITE_BYTES 4096U +#define POCKETJS_NET_HTTP_CLIENT_CORE_BODY_LEASE_BYTES 2048U +#define POCKETJS_NET_HTTP_CLIENT_CORE_MAX_DNS_CANDIDATES 4U +#define POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REDIRECTS 5U + +typedef struct pocketjs_net_http_client_core pocketjs_net_http_client_core_t; + +typedef union { + max_align_t alignment; + uint8_t bytes[POCKETJS_NET_HTTP_CLIENT_CORE_INSTANCE_BYTES]; +} pocketjs_net_http_client_core_storage_t; + +typedef uint64_t pocketjs_net_http_client_operation_token_t; + +typedef struct { + uint32_t slot; + uint64_t generation; +} pocketjs_net_http_client_transport_connection_t; + +typedef struct { + uint32_t slot; + uint64_t generation; +} pocketjs_net_http_client_transport_read_lease_t; + +typedef enum { + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK = 0, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_EMPTY, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_INVALID, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_BUSY, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_RESOURCE_LIMIT, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_FAILED, +} pocketjs_net_http_client_transport_result_t; + +typedef enum { + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_RESOLVED = 1, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_CONNECTED, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_READ, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_WRITTEN, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_CLOSED, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR, +} pocketjs_net_http_client_transport_terminal_t; + +typedef enum { + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_NONE = 0, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_ABORTED, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TIMED_OUT, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_DNS, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_CONNECT, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_IO, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_CERTIFICATE_INVALID, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_HOSTNAME_MISMATCH, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_HANDSHAKE_FAILED, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_VERSION_UNSUPPORTED, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_ALERT, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_RESOURCE_LIMIT, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_INVALID, +} pocketjs_net_http_client_transport_error_t; + +typedef struct { + pocketjs_net_http_client_transport_terminal_t type; + uint64_t operation_token; + union { + struct { + uint32_t ipv4_be[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_DNS_CANDIDATES]; + size_t candidate_count; + } resolved; + struct { + pocketjs_net_http_client_transport_connection_t connection; + uint32_t ipv4_be; + bool tls; + } connected; + struct { + pocketjs_net_http_client_transport_connection_t connection; + pocketjs_net_http_client_transport_read_lease_t lease; + size_t byte_count; + bool eof; + } read; + struct { + pocketjs_net_http_client_transport_connection_t connection; + size_t byte_count; + } written; + struct { + pocketjs_net_http_client_transport_connection_t connection; + } closed; + struct { + pocketjs_net_http_client_transport_error_t code; + int32_t cause_code; + } error; + } detail; +} pocketjs_net_http_client_transport_completion_t; + +typedef struct { + pocketjs_net_http_client_transport_result_t (*start_resolve)( + void *context, uint64_t token, const char *hostname, + uint64_t deadline_us); + pocketjs_net_http_client_transport_result_t (*start_connect)( + void *context, uint64_t token, uint32_t ipv4_be, uint16_t port, bool tls, + const char *original_hostname, uint64_t deadline_us); + pocketjs_net_http_client_transport_result_t (*start_read)( + void *context, uint64_t token, + pocketjs_net_http_client_transport_connection_t connection, + size_t maximum_bytes, uint64_t deadline_us); + pocketjs_net_http_client_transport_result_t (*start_write)( + void *context, uint64_t token, + pocketjs_net_http_client_transport_connection_t connection, + const uint8_t *bytes, size_t length, uint64_t deadline_us); + pocketjs_net_http_client_transport_result_t (*start_close)( + void *context, uint64_t token, + pocketjs_net_http_client_transport_connection_t connection, + uint64_t deadline_us); + pocketjs_net_http_client_transport_result_t (*cancel)(void *context, + uint64_t token); + pocketjs_net_http_client_transport_result_t (*pump)(void *context, + uint64_t now_us, + size_t max_native_steps); + pocketjs_net_http_client_transport_result_t (*take_completion)( + void *context, + pocketjs_net_http_client_transport_completion_t *out_completion); + pocketjs_net_http_client_transport_result_t (*retire_completion)( + void *context, uint64_t token); + pocketjs_net_http_client_transport_result_t (*read_lease_view)( + void *context, pocketjs_net_http_client_transport_read_lease_t lease, + const uint8_t **out_bytes, size_t *out_capacity); + pocketjs_net_http_client_transport_result_t (*release_read_lease)( + void *context, pocketjs_net_http_client_transport_read_lease_t lease); +} pocketjs_net_http_client_transport_ops_t; + +typedef enum { + POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTP = 1, + POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTPS = 2, +} pocketjs_net_http_client_scheme_t; + +typedef enum { + POCKETJS_NET_HTTP_CLIENT_PERMISSION_HOSTNAME = 1, + POCKETJS_NET_HTTP_CLIENT_PERMISSION_NUMERIC_CANDIDATE = 2, +} pocketjs_net_http_client_permission_phase_t; + +typedef struct { + pocketjs_net_http_client_permission_phase_t phase; + pocketjs_net_http_client_scheme_t scheme; + const char *hostname; + uint16_t port; + uint32_t ipv4_be; +} pocketjs_net_http_client_endpoint_t; + +typedef bool (*pocketjs_net_http_client_permission_fn)( + void *context, const pocketjs_net_http_client_endpoint_t *endpoint); + +typedef struct { + const pocketjs_net_http_client_transport_ops_t *transport_ops; + void *transport_context; + pocketjs_net_http_client_permission_fn allow_endpoint; + void *permission_context; + uint64_t connect_timeout_us; + uint64_t headers_timeout_us; + uint64_t idle_timeout_us; + uint64_t total_timeout_us; + /** HTTPS stays pre-I/O fail-closed unless the selected Host opts in. */ + bool allow_https; + /** Retain one fully delimited same-origin HTTP/1.1 connection per Core. */ + bool enable_connection_reuse; + /** Final response fields as name + value + ": " + CRLF; zero uses max. */ + size_t response_header_bytes_limit; +} pocketjs_net_http_client_core_config_t; + +typedef struct { + const uint8_t *data; + size_t length; +} pocketjs_net_http_client_slice_t; + +typedef enum { + POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_2 = 1, + POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_3, +} pocketjs_net_http_client_tls_version_t; + +typedef enum { + POCKETJS_NET_HTTP_CLIENT_TLS_CLIENT_CERTIFICATE_NONE = 1, + POCKETJS_NET_HTTP_CLIENT_TLS_CLIENT_CERTIFICATE_OPTIONAL, + POCKETJS_NET_HTTP_CLIENT_TLS_CLIENT_CERTIFICATE_REQUIRED, +} pocketjs_net_http_client_tls_client_certificate_t; + +typedef enum { + POCKETJS_NET_HTTP_CLIENT_TLS_VERIFICATION_FULL = 1, + POCKETJS_NET_HTTP_CLIENT_TLS_VERIFICATION_DEVELOPMENT_INSECURE, +} pocketjs_net_http_client_tls_verification_t; + +typedef enum { + POCKETJS_NET_HTTP_CLIENT_TLS_REVOCATION_HOST_DEFAULT = 1, + POCKETJS_NET_HTTP_CLIENT_TLS_REVOCATION_REQUIRED, +} pocketjs_net_http_client_tls_revocation_t; + +typedef struct { + /** Required canonical DNS hostname. IP literals stay unsupported. */ + pocketjs_net_http_client_slice_t server_name; + pocketjs_net_http_client_tls_version_t minimum_version; + pocketjs_net_http_client_tls_version_t maximum_version; + size_t alpn_count; + pocketjs_net_http_client_slice_t credential; + pocketjs_net_http_client_tls_client_certificate_t client_certificate; + pocketjs_net_http_client_tls_verification_t verification; + pocketjs_net_http_client_tls_revocation_t revocation; + size_t custom_ca_bytes; +} pocketjs_net_http_client_tls_policy_t; + +typedef struct { + pocketjs_net_http_client_slice_t name; + pocketjs_net_http_client_slice_t value; +} pocketjs_net_http_client_header_t; + +typedef enum { + POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_NONE = 0, + POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_FIXED, + POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_STREAMING, +} pocketjs_net_http_client_request_body_kind_t; + +typedef enum { + /* Zero intentionally preserves the historical manual behavior. */ + POCKETJS_NET_HTTP_CLIENT_REDIRECT_MANUAL = 0, + POCKETJS_NET_HTTP_CLIENT_REDIRECT_FOLLOW, + POCKETJS_NET_HTTP_CLIENT_REDIRECT_ERROR, +} pocketjs_net_http_client_redirect_mode_t; + +typedef struct { + pocketjs_net_http_client_operation_token_t operation_token; + pocketjs_net_http_client_slice_t url; + pocketjs_net_http_client_slice_t method; + const pocketjs_net_http_client_header_t *headers; + size_t header_count; + pocketjs_net_http_client_request_body_kind_t body_kind; + /* Used only by FIXED. The Core snapshots these bytes before native I/O. */ + pocketjs_net_http_client_slice_t body; + /* Used only by STREAMING. Unknown-length streams use strict chunked coding. + */ + bool streaming_content_length_known; + uint64_t streaming_content_length; + pocketjs_net_http_client_redirect_mode_t redirect_mode; + uint16_t max_redirects; + /** Required for HTTPS and forbidden for plaintext HTTP. Snapshotted by start. + */ + const pocketjs_net_http_client_tls_policy_t *tls; +} pocketjs_net_http_client_request_t; + +typedef enum { + POCKETJS_NET_HTTP_CLIENT_START_OK = 0, + POCKETJS_NET_HTTP_CLIENT_START_BUSY, + POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT, + POCKETJS_NET_HTTP_CLIENT_START_INVALID_URL, + POCKETJS_NET_HTTP_CLIENT_START_UNSUPPORTED_TLS, + POCKETJS_NET_HTTP_CLIENT_START_LIMIT_EXCEEDED, + POCKETJS_NET_HTTP_CLIENT_START_FORBIDDEN_REQUEST, + POCKETJS_NET_HTTP_CLIENT_START_TOKEN_EXHAUSTED, + POCKETJS_NET_HTTP_CLIENT_START_POISONED, + POCKETJS_NET_HTTP_CLIENT_START_SHUTTING_DOWN, + POCKETJS_NET_HTTP_CLIENT_START_REENTRANT, +} pocketjs_net_http_client_start_result_t; + +/** + * Cleanup faults are independent from the first selected HTTP terminal result. + * They make the instance reject new requests until the dedicated transport is + * shut down and the Core is deinitialized. + */ +typedef enum { + POCKETJS_NET_HTTP_CLIENT_POISON_NONE = 0U, + POCKETJS_NET_HTTP_CLIENT_POISON_PERMISSION_REENTRANCY = 1U << 0, + POCKETJS_NET_HTTP_CLIENT_POISON_TRANSPORT_PUMP = 1U << 1, + POCKETJS_NET_HTTP_CLIENT_POISON_COMPLETION_TAKE = 1U << 2, + POCKETJS_NET_HTTP_CLIENT_POISON_COMPLETION_RETIRE = 1U << 3, + POCKETJS_NET_HTTP_CLIENT_POISON_READ_LEASE_RELEASE = 1U << 4, + POCKETJS_NET_HTTP_CLIENT_POISON_CANCEL = 1U << 5, + POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_ADMISSION = 1U << 6, + POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_COMPLETION = 1U << 7, + POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_TIMEOUT = 1U << 8, + POCKETJS_NET_HTTP_CLIENT_POISON_STALE_COMPLETION = 1U << 9, + POCKETJS_NET_HTTP_CLIENT_POISON_HOST_EVENT_RETIRE = 1U << 10, +} pocketjs_net_http_client_poison_flag_t; + +typedef struct { + bool initialized; + bool shutdown_requested; + bool poisoned; + bool quiescent; + bool request_active; + bool transport_operation_active; + bool connection_owned; + bool completion_retire_pending; + bool event_outstanding; + bool request_body_credit_outstanding; + bool connection_reusable; + size_t transport_read_leases_owned; + uint32_t poison_flags; + int32_t first_poison_cause_code; + uint64_t lifecycle_generation; + pocketjs_net_http_client_operation_token_t operation_token; + uint64_t request_body_generation; + uint64_t request_body_pull_generation; +} pocketjs_net_http_client_core_status_t; + +typedef enum { + POCKETJS_NET_HTTP_CLIENT_ERROR_ABORTED = 1, + POCKETJS_NET_HTTP_CLIENT_ERROR_TIMED_OUT, + POCKETJS_NET_HTTP_CLIENT_ERROR_PERMISSION_DENIED, + POCKETJS_NET_HTTP_CLIENT_ERROR_DNS, + POCKETJS_NET_HTTP_CLIENT_ERROR_CONNECT, + POCKETJS_NET_HTTP_CLIENT_ERROR_IO, + POCKETJS_NET_HTTP_CLIENT_ERROR_PROTOCOL, + POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT, + POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, + POCKETJS_NET_HTTP_CLIENT_ERROR_REQUEST_BODY, + POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_CERTIFICATE_INVALID, + POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_HOSTNAME_MISMATCH, + POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_HANDSHAKE_FAILED, + POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_VERSION_UNSUPPORTED, + POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_ALERT, + POCKETJS_NET_HTTP_CLIENT_ERROR_UNSUPPORTED, + POCKETJS_NET_HTTP_CLIENT_ERROR_REDIRECT, + POCKETJS_NET_HTTP_CLIENT_ERROR_REDIRECT_LIMIT, + POCKETJS_NET_HTTP_CLIENT_ERROR_REDIRECT_BODY_NOT_REPLAYABLE, +} pocketjs_net_http_client_error_t; + +typedef enum { + POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_CAUSE_LENGTH_UNDERFLOW = 1, +} pocketjs_net_http_client_request_body_cause_t; + +typedef struct { + uint32_t slot; + uint64_t generation; +} pocketjs_net_http_client_body_lease_t; + +typedef enum { + POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS = 1, + POCKETJS_NET_HTTP_CLIENT_EVENT_BODY, + POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE, + POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR, + POCKETJS_NET_HTTP_CLIENT_EVENT_REQUEST_BODY_PULL, +} pocketjs_net_http_client_event_type_t; + +typedef struct { + pocketjs_net_http_client_event_type_t type; + uint64_t sequence; + pocketjs_net_http_client_operation_token_t operation_token; + union { + struct { + /* Slices remain valid only until this event is retired. */ + unsigned status_code; + pocketjs_net_http_client_slice_t status_text; + const pocketjs_net_http_client_header_t *headers; + size_t header_count; + pocketjs_net_http_client_slice_t url; + bool redirected; + } response; + struct { + /* The lease must be released before this event can be retired. */ + pocketjs_net_http_client_body_lease_t lease; + size_t byte_count; + } body; + struct { + /* + * Retiring this event does not consume its credit. A later producer + * command must echo both generations and the operation token. + */ + uint64_t body_generation; + uint64_t pull_generation; + size_t maximum_bytes; + } request_body_pull; + struct { + pocketjs_net_http_client_error_t code; + int32_t cause_code; + } error; + } detail; +} pocketjs_net_http_client_event_t; + +typedef struct { + const char *id; + bool experimental; + bool advertises_public_capability; + bool plaintext_http; + bool https_fail_closed_before_io; + bool https_explicit_opt_in; + bool owner_pumped; + bool one_operation; + bool fixed_core_storage; + bool headers_first; + bool explicit_body_credit; + bool explicit_body_lease; + bool connection_reuse; + bool bounded_connection_pool; + bool redirects_followed; + bool redirect_manual; + bool redirect_error; + bool redirect_fixed_body_replay; + bool redirect_streaming_body_replay; + /** Retry only another already-authorized DNS address before request I/O. */ + bool connect_error_candidate_fallback; + bool hidden_retry; + bool hidden_auth; + bool hidden_cookie_store; + bool proxy; + bool content_decoding; + bool cleanup_faults_separate_from_terminal; + bool poison_is_machine_readable; + bool explicit_shutdown_lifecycle; + bool fixed_request_body; + bool streaming_request_body; + bool chunked_request_body; + bool known_length_streaming_request_body; + bool streaming_request_body_buffered_in_full; + size_t instance_bytes; + /* Compatibility name for the fixed snapshot ceiling. */ + size_t max_request_body_bytes; + size_t max_fixed_request_body_bytes; + size_t max_request_body_chunk_bytes; + size_t body_lease_bytes; + size_t max_cached_connections; +} pocketjs_net_http_client_core_descriptor_t; + +const pocketjs_net_http_client_core_descriptor_t * +pocketjs_net_http_client_core_descriptor(void); + +pocketjs_net_http_client_start_result_t pocketjs_net_http_client_core_init( + pocketjs_net_http_client_core_storage_t *storage, + const pocketjs_net_http_client_core_config_t *config, + pocketjs_net_http_client_core_t **out_core); + +/* + * All instance entry points are owner-only and non-reentrant. During a + * permission callback, start returns START_REENTRANT and bool APIs return + * false. Init returns START_BUSY when storage still contains a live instance. + */ +pocketjs_net_http_client_start_result_t pocketjs_net_http_client_core_start( + pocketjs_net_http_client_core_t *core, + const pocketjs_net_http_client_request_t *request, uint64_t now_us); + +/* Owner-only. Abort is observed between transport steps. */ +bool pocketjs_net_http_client_core_abort( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_operation_token_t operation_token); + +/* + * At most max_transport_completions are delivered into the state machine. + * Either independent budget may be zero; both zero is an invalid no-op. + */ +bool pocketjs_net_http_client_core_pump(pocketjs_net_http_client_core_t *core, + uint64_t now_us, + size_t max_native_steps, + size_t max_transport_completions); + +/* + * Grants one bounded downstream window. It is consumed before another read. + * After the final non-empty lease, one additional credit may be accepted while + * a successful terminal is closing or queued; that pull is completed by the + * terminal event and makes end-of-stream explicit to the binding. + */ +bool pocketjs_net_http_client_core_grant_body_credit( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_operation_token_t operation_token, + size_t maximum_bytes); + +/* + * Owner-only request producer commands. They are accepted only after the + * matching REQUEST_BODY_PULL event has been taken and retired. Each matching + * command consumes exactly one credit; rejected commands consume nothing. + */ +bool pocketjs_net_http_client_core_submit_request_body_chunk( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_operation_token_t operation_token, + uint64_t body_generation, uint64_t pull_generation, const uint8_t *bytes, + size_t length); + +bool pocketjs_net_http_client_core_submit_request_body_end( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_operation_token_t operation_token, + uint64_t body_generation, uint64_t pull_generation); + +bool pocketjs_net_http_client_core_submit_request_body_error( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_operation_token_t operation_token, + uint64_t body_generation, uint64_t pull_generation, int32_t cause_code); + +bool pocketjs_net_http_client_core_take_event( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_event_t *out_event); + +bool pocketjs_net_http_client_core_retire_event( + pocketjs_net_http_client_core_t *core, uint64_t sequence); + +bool pocketjs_net_http_client_core_body_lease_view( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_body_lease_t lease, const uint8_t **out_bytes, + size_t *out_length); + +bool pocketjs_net_http_client_core_release_body_lease( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_body_lease_t lease); + +/** Snapshot lifecycle, poison, and native-resource ownership state. */ +bool pocketjs_net_http_client_core_get_status( + const pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_core_status_t *out_status); + +/** + * Permanently stops request admission and aborts any current request. Continue + * pumping and retiring events until is_quiescent returns true. + */ +bool pocketjs_net_http_client_core_begin_shutdown( + pocketjs_net_http_client_core_t *core, uint64_t now_us); + +bool pocketjs_net_http_client_core_is_quiescent( + const pocketjs_net_http_client_core_t *core); + +/** + * After the product has synchronously destroyed the dedicated transport, this + * acknowledges that any poisoned native handles retained for audit no longer + * exist. It never releases or invalidates a delivered Core body lease/event. + */ +bool pocketjs_net_http_client_core_confirm_transport_shutdown( + pocketjs_net_http_client_core_t *core); + +/** + * Marks an exact currently delivering event as a Host ownership failure. This + * does not retire or release the event; shutdown must preserve it until normal + * retirement succeeds or the poison-only abandon API is admitted. + */ +bool pocketjs_net_http_client_core_report_host_event_retire_failure( + pocketjs_net_http_client_core_t *core, uint64_t sequence); + +/** + * Poison-only Host ownership escape hatch. After begin_shutdown and synchronous + * destruction plus confirm_transport_shutdown of the dedicated transport, this + * abandons the exact delivering event and any Core body lease it owns. Healthy + * delivery must use retire_event; pending events must first be taken by the + * sole Host consumer. + */ +bool pocketjs_net_http_client_core_abandon_event_after_transport_shutdown( + pocketjs_net_http_client_core_t *core, uint64_t sequence); + +/** Clears initialized storage only after begin_shutdown and quiescence. */ +bool pocketjs_net_http_client_core_deinit( + pocketjs_net_http_client_core_t *core); + +#ifdef __cplusplus +} +#endif diff --git a/hosts/esp-idf/components/pocketjs_net_http_client_core/include/pocketjs/net/http_client_core_esp.h b/hosts/esp-idf/components/pocketjs_net_http_client_core/include/pocketjs/net/http_client_core_esp.h new file mode 100644 index 00000000..729befe2 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http_client_core/include/pocketjs/net/http_client_core_esp.h @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include "pocketjs/net/esp_transport.h" +#include "pocketjs/net/http_client_core.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* Narrow adapter over the experimental ESP-IDF transport public API. */ +const pocketjs_net_http_client_transport_ops_t * +pocketjs_net_http_client_core_esp_transport_ops(void); + +#ifdef __cplusplus +} +#endif diff --git a/hosts/esp-idf/components/pocketjs_net_http_client_core/src/http_client_core.c b/hosts/esp-idf/components/pocketjs_net_http_client_core/src/http_client_core.c new file mode 100644 index 00000000..e4b144e8 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http_client_core/src/http_client_core.c @@ -0,0 +1,3365 @@ +// SPDX-License-Identifier: MIT + +#include "pocketjs/net/http_client_core.h" + +#include "pocketjs/net/http1_wire.h" + +#include +#include + +#define CORE_MAGIC UINT64_C(0x504a534854545031) +#define CORE_CLOSE_TIMEOUT_US UINT64_C(1000000) + +typedef enum { + CORE_IDLE = 0, + CORE_RESOLVING, + CORE_CONNECTING, + CORE_WRITING_HEAD, + CORE_WRITING_BODY, + CORE_WAITING_REQUEST_BODY, + CORE_WRITING_REQUEST_BODY_CHUNK, + CORE_WRITING_REQUEST_BODY_END, + CORE_READING, + CORE_CLOSING, + CORE_REPLACING_CONNECTION, + CORE_IDLE_CONNECTION_CLOSING, + CORE_ENDPOINT_READY, + CORE_REDIRECT_READY, + CORE_WAITING_TERMINAL_RETIRE, +} core_state_t; + +typedef enum { + TRANSPORT_OPERATION_NONE = 0, + TRANSPORT_OPERATION_RESOLVE, + TRANSPORT_OPERATION_CONNECT, + TRANSPORT_OPERATION_WRITE, + TRANSPORT_OPERATION_READ, + TRANSPORT_OPERATION_CLOSE, +} transport_operation_kind_t; + +typedef enum { + EVENT_EMPTY = 0, + EVENT_PENDING, + EVENT_DELIVERING, +} event_state_t; + +struct pocketjs_net_http_client_core { + uint64_t magic; + pocketjs_net_http_client_core_config_t config; + core_state_t state; + event_state_t event_state; + pocketjs_net_http_client_event_t event; + uint64_t last_operation_token; + uint64_t last_transport_token; + uint64_t event_sequence; + uint64_t body_lease_generation; + uint64_t last_request_body_generation; + uint64_t last_request_body_pull_generation; + uint64_t lifecycle_generation; + uint64_t now_us; + uint64_t connect_deadline_us; + uint64_t headers_deadline_us; + uint64_t idle_deadline_us; + uint64_t total_deadline_us; + uint64_t close_deadline_us; + pocketjs_net_http_client_operation_token_t operation_token; + + bool permission_callback_active; + bool shutdown_requested; + bool transport_shutdown_confirmed; + bool transport_cancel_requested; + bool close_cancel_requested; + uint32_t poison_flags; + int32_t first_poison_cause_code; + + pocketjs_net_http_client_scheme_t scheme; + char hostname[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_HOST_BYTES + 1U]; + char host_field[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_HOST_BYTES + 7U]; + uint8_t target[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_TARGET_BYTES]; + size_t target_length; + uint16_t port; + bool numeric_host; + uint32_t numeric_ipv4_be; + uint32_t selected_ipv4_be; + uint32_t connect_candidates[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_DNS_CANDIDATES]; + size_t connect_candidate_count; + size_t next_connect_candidate; + + uint8_t method[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_METHOD_BYTES]; + size_t method_length; + uint8_t request_header_storage + [POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_HEADER_BYTES]; + size_t request_header_storage_used; + pocketjs_net_http1_header_t + request_headers[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_HEADERS + 2U]; + size_t request_header_count; + uint8_t request_body[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_BODY_BYTES]; + size_t request_body_length; + pocketjs_net_http_client_request_body_kind_t request_body_kind; + bool request_body_length_known; + uint64_t request_body_expected_length; + uint64_t request_body_submitted_length; + uint64_t request_body_generation; + uint64_t request_body_pull_generation; + size_t request_body_pull_maximum; + size_t request_body_pending_payload_length; + bool request_body_pull_active; + bool request_body_pull_event_retired; + pocketjs_net_http1_request_encoder_t encoder; + bool encoder_done; + uint8_t write_bytes[POCKETJS_NET_HTTP_CLIENT_CORE_WRITE_BYTES]; + size_t write_length; + + pocketjs_net_http1_response_parser_t parser; + bool final_headers_seen; + bool headers_delivered; + bool parser_complete; + bool force_no_body; + pocketjs_net_http_client_error_t callback_error; + unsigned response_status; + unsigned response_http_minor; + pocketjs_net_http1_response_body_kind_t response_body_kind; + bool response_connection_close; + bool response_connection_reusable; + bool redirect_location_seen; + pocketjs_net_http_client_slice_t redirect_location; + bool redirect_pending; + pocketjs_net_http_client_redirect_mode_t redirect_mode; + uint16_t redirect_count; + uint16_t max_redirects; + uint8_t response_header_storage + [POCKETJS_NET_HTTP_CLIENT_CORE_MAX_RESPONSE_HEADER_BYTES]; + size_t response_header_storage_used; + size_t response_header_field_bytes; + pocketjs_net_http_client_header_t + response_headers[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_RESPONSE_HEADERS]; + size_t response_header_count; + pocketjs_net_http_client_slice_t response_status_text; + + bool transport_active; + transport_operation_kind_t transport_operation_kind; + uint64_t transport_operation_token; + pocketjs_net_http_client_transport_connection_t connection; + bool connection_valid; + bool connection_reusable; + pocketjs_net_http_client_scheme_t connection_scheme; + char connection_hostname[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_HOST_BYTES + 1U]; + uint16_t connection_port; + uint32_t connection_ipv4_be; + uint64_t connection_idle_deadline_us; + pocketjs_net_http_client_transport_read_lease_t transport_read_lease; + bool transport_read_lease_valid; + const uint8_t *transport_read_bytes; + size_t transport_read_maximum; + size_t transport_read_length; + size_t transport_read_offset; + bool transport_read_eof_pending; + pocketjs_net_http_client_transport_read_lease_t orphan_read_lease; + bool orphan_read_lease_valid; + uint64_t completion_retire_token; + bool completion_retire_pending; + + size_t body_credit; + bool terminal_body_pull_active; + uint8_t body_bytes[POCKETJS_NET_HTTP_CLIENT_CORE_BODY_LEASE_BYTES]; + size_t body_byte_count; + bool body_lease_active; + bool body_lease_released; + pocketjs_net_http_client_body_lease_t body_lease; + + bool terminal_selected; + bool terminal_success; + pocketjs_net_http_client_error_t terminal_error; + int32_t terminal_cause; +}; + +_Static_assert(sizeof(struct pocketjs_net_http_client_core) <= + POCKETJS_NET_HTTP_CLIENT_CORE_INSTANCE_BYTES, + "HTTP Client Core storage constant is too small"); +_Static_assert(POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES + 32U <= + POCKETJS_NET_HTTP_CLIENT_CORE_WRITE_BYTES, + "request chunk plus chunked framing must fit write storage"); + +static const pocketjs_net_http_client_core_descriptor_t descriptor = { + .id = POCKETJS_NET_HTTP_CLIENT_CORE_ID, + .experimental = true, + .advertises_public_capability = false, + .plaintext_http = true, + .https_fail_closed_before_io = true, + .https_explicit_opt_in = true, + .owner_pumped = true, + .one_operation = true, + .fixed_core_storage = true, + .headers_first = true, + .explicit_body_credit = true, + .explicit_body_lease = true, + .connection_reuse = true, + .bounded_connection_pool = true, + .redirects_followed = true, + .redirect_manual = true, + .redirect_error = true, + .redirect_fixed_body_replay = true, + .redirect_streaming_body_replay = false, + .connect_error_candidate_fallback = true, + .hidden_retry = false, + .hidden_auth = false, + .hidden_cookie_store = false, + .proxy = false, + .content_decoding = false, + .cleanup_faults_separate_from_terminal = true, + .poison_is_machine_readable = true, + .explicit_shutdown_lifecycle = true, + .fixed_request_body = true, + .streaming_request_body = true, + .chunked_request_body = true, + .known_length_streaming_request_body = true, + .streaming_request_body_buffered_in_full = false, + .instance_bytes = POCKETJS_NET_HTTP_CLIENT_CORE_INSTANCE_BYTES, + .max_request_body_bytes = + POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_BODY_BYTES, + .max_fixed_request_body_bytes = + POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_BODY_BYTES, + .max_request_body_chunk_bytes = + POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES, + .body_lease_bytes = POCKETJS_NET_HTTP_CLIENT_CORE_BODY_LEASE_BYTES, + .max_cached_connections = 1U, +}; + +const pocketjs_net_http_client_core_descriptor_t * +pocketjs_net_http_client_core_descriptor(void) { + return &descriptor; +} + +static bool core_is_live(const pocketjs_net_http_client_core_t *core) { + return core != NULL && core->magic == CORE_MAGIC; +} + +static bool +core_public_entry_allowed(const pocketjs_net_http_client_core_t *core) { + return core_is_live(core) && !core->permission_callback_active; +} + +static void poison_core(pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_poison_flag_t flag, + int32_t cause_code) { + if (core->poison_flags == 0U) { + core->first_poison_cause_code = cause_code; + } + core->poison_flags |= (uint32_t)flag; +} + +static size_t +owned_transport_read_lease_count(const pocketjs_net_http_client_core_t *core) { + return (core->transport_read_lease_valid ? 1U : 0U) + + (core->orphan_read_lease_valid ? 1U : 0U); +} + +static bool +core_is_quiescent_internal(const pocketjs_net_http_client_core_t *core) { + return core->state == CORE_IDLE && core->event_state == EVENT_EMPTY && + !core->transport_active && !core->connection_valid && + !core->transport_read_lease_valid && !core->orphan_read_lease_valid && + !core->completion_retire_pending && !core->body_lease_active && + !core->request_body_pull_active && !core->permission_callback_active; +} + +static bool ascii_is_alpha(uint8_t byte) { + return (byte >= 'A' && byte <= 'Z') || (byte >= 'a' && byte <= 'z'); +} + +static bool ascii_is_digit(uint8_t byte) { return byte >= '0' && byte <= '9'; } + +static uint8_t ascii_lower(uint8_t byte) { + return byte >= 'A' && byte <= 'Z' ? (uint8_t)(byte + ('a' - 'A')) : byte; +} + +static bool ascii_equal_case(const uint8_t *left, size_t left_length, + const char *right) { + size_t right_length = strlen(right); + if (left_length != right_length) { + return false; + } + for (size_t index = 0; index < left_length; ++index) { + if (ascii_lower(left[index]) != ascii_lower((uint8_t)right[index])) { + return false; + } + } + return true; +} + +static bool is_tchar(uint8_t byte) { + return ascii_is_alpha(byte) || ascii_is_digit(byte) || byte == '!' || + byte == '#' || byte == '$' || byte == '%' || byte == '&' || + byte == '\'' || byte == '*' || byte == '+' || byte == '-' || + byte == '.' || byte == '^' || byte == '_' || byte == '`' || + byte == '|' || byte == '~'; +} + +static bool connection_value_is_valid(const uint8_t *value, size_t length, + bool *out_close) { + size_t offset = 0U; + bool saw_token = false; + while (offset < length) { + while (offset < length && (value[offset] == ' ' || value[offset] == '\t')) { + ++offset; + } + const size_t start = offset; + while (offset < length && is_tchar(value[offset])) { + ++offset; + } + if (offset == start) { + return false; + } + saw_token = true; + if (ascii_equal_case(value + start, offset - start, "close")) { + *out_close = true; + } + while (offset < length && (value[offset] == ' ' || value[offset] == '\t')) { + ++offset; + } + if (offset == length) { + break; + } + if (value[offset++] != ',') { + return false; + } + if (offset == length) { + return false; + } + } + return saw_token; +} + +static bool valid_method(const uint8_t *method, size_t length) { + if (method == NULL || length == 0U || + length > POCKETJS_NET_HTTP_CLIENT_CORE_MAX_METHOD_BYTES) { + return false; + } + for (size_t index = 0; index < length; ++index) { + if (!is_tchar(method[index])) { + return false; + } + } + return !ascii_equal_case(method, length, "CONNECT") && + !ascii_equal_case(method, length, "TRACE") && + !ascii_equal_case(method, length, "TRACK"); +} + +static bool valid_config(const pocketjs_net_http_client_core_config_t *config) { + if (config == NULL || config->transport_ops == NULL || + config->allow_endpoint == NULL || config->connect_timeout_us == 0U || + config->headers_timeout_us == 0U || config->idle_timeout_us == 0U || + config->total_timeout_us == 0U || + config->response_header_bytes_limit > + POCKETJS_NET_HTTP_CLIENT_CORE_MAX_RESPONSE_HEADER_BYTES) { + return false; + } + const pocketjs_net_http_client_transport_ops_t *ops = config->transport_ops; + return ops->start_resolve != NULL && ops->start_connect != NULL && + ops->start_read != NULL && ops->start_write != NULL && + ops->start_close != NULL && ops->cancel != NULL && ops->pump != NULL && + ops->take_completion != NULL && ops->retire_completion != NULL && + ops->read_lease_view != NULL && ops->release_read_lease != NULL; +} + +static uint64_t deadline_after(uint64_t now_us, uint64_t duration_us) { + return duration_us > UINT64_MAX - now_us ? UINT64_MAX : now_us + duration_us; +} + +static uint64_t earlier_deadline(uint64_t left, uint64_t right) { + return left < right ? left : right; +} + +static bool next_transport_token(pocketjs_net_http_client_core_t *core, + uint64_t *out_token) { + if (core->last_transport_token == UINT64_MAX) { + return false; + } + ++core->last_transport_token; + *out_token = core->last_transport_token; + return true; +} + +static bool next_event_sequence(pocketjs_net_http_client_core_t *core, + uint64_t *out_sequence) { + if (core->event_sequence == UINT64_MAX) { + return false; + } + ++core->event_sequence; + *out_sequence = core->event_sequence; + return true; +} + +static bool parse_ipv4(const uint8_t *data, size_t length, + uint32_t *out_ipv4_be) { + uint8_t octets[4]; + size_t index = 0U; + for (size_t part = 0U; part < 4U; ++part) { + size_t start = index; + unsigned value = 0U; + while (index < length && ascii_is_digit(data[index])) { + unsigned digit = (unsigned)(data[index] - '0'); + if (value > (255U - digit) / 10U) { + return false; + } + value = value * 10U + digit; + ++index; + } + if (index == start || (index - start > 1U && data[start] == '0')) { + return false; + } + octets[part] = (uint8_t)value; + if (part == 3U) { + if (index != length) { + return false; + } + } else if (index == length || data[index++] != '.') { + return false; + } + } + memcpy(out_ipv4_be, octets, sizeof(octets)); + return true; +} + +static bool looks_numeric_host(const uint8_t *data, size_t length) { + if (length == 0U) { + return false; + } + for (size_t index = 0U; index < length; ++index) { + if (!ascii_is_digit(data[index]) && data[index] != '.') { + return false; + } + } + return true; +} + +static bool canonicalize_dns_name(const uint8_t *data, size_t length, + char *output) { + if (length == 0U || length > POCKETJS_NET_HTTP_CLIENT_CORE_MAX_HOST_BYTES || + data[length - 1U] == '.') { + return false; + } + size_t label_start = 0U; + for (size_t index = 0U; index <= length; ++index) { + if (index != length && data[index] != '.') { + uint8_t byte = data[index]; + if (!ascii_is_alpha(byte) && !ascii_is_digit(byte) && byte != '-') { + return false; + } + output[index] = (char)ascii_lower(byte); + continue; + } + size_t label_length = index - label_start; + if (label_length == 0U || label_length > 63U || + output[label_start] == '-' || output[index - 1U] == '-') { + return false; + } + if (index != length) { + output[index] = '.'; + label_start = index + 1U; + } + } + output[length] = '\0'; + return true; +} + +static bool parse_port(const uint8_t *data, size_t length, uint16_t *out_port) { + if (length == 0U || (length > 1U && data[0] == '0')) { + return false; + } + unsigned value = 0U; + for (size_t index = 0U; index < length; ++index) { + if (!ascii_is_digit(data[index])) { + return false; + } + unsigned digit = (unsigned)(data[index] - '0'); + if (value > (65535U - digit) / 10U) { + return false; + } + value = value * 10U + digit; + } + if (value == 0U) { + return false; + } + *out_port = (uint16_t)value; + return true; +} + +static size_t write_port(uint16_t port, char output[5]) { + char reversed[5]; + size_t length = 0U; + do { + reversed[length++] = (char)('0' + port % 10U); + port = (uint16_t)(port / 10U); + } while (port != 0U); + for (size_t index = 0U; index < length; ++index) { + output[index] = reversed[length - index - 1U]; + } + return length; +} + +static bool parse_url(pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_slice_t url) { + if (url.data == NULL || url.length == 0U || + url.length > POCKETJS_NET_HTTP_CLIENT_CORE_MAX_URL_BYTES) { + return false; + } + size_t scheme_bytes = 0U; + uint16_t default_port = 0U; + if (url.length >= 7U && ascii_equal_case(url.data, 7U, "http://")) { + core->scheme = POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTP; + scheme_bytes = 7U; + default_port = 80U; + } else if (url.length >= 8U && ascii_equal_case(url.data, 8U, "https://")) { + core->scheme = POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTPS; + scheme_bytes = 8U; + default_port = 443U; + } else { + return false; + } + + for (size_t index = scheme_bytes; index < url.length; ++index) { + if (url.data[index] == '#') { + return false; + } + } + + size_t authority_end = scheme_bytes; + while (authority_end < url.length && url.data[authority_end] != '/' && + url.data[authority_end] != '?') { + if (url.data[authority_end] == '@' || url.data[authority_end] == '[' || + url.data[authority_end] == ']') { + return false; + } + ++authority_end; + } + if (authority_end == scheme_bytes) { + return false; + } + + size_t colon = authority_end; + for (size_t index = scheme_bytes; index < authority_end; ++index) { + if (url.data[index] == ':') { + if (colon != authority_end) { + return false; + } + colon = index; + } + } + size_t hostname_length = colon - scheme_bytes; + if (hostname_length == 0U || + hostname_length > POCKETJS_NET_HTTP_CLIENT_CORE_MAX_HOST_BYTES) { + return false; + } + core->port = default_port; + if (colon != authority_end && + !parse_port(url.data + colon + 1U, authority_end - colon - 1U, + &core->port)) { + return false; + } + + core->numeric_host = parse_ipv4(url.data + scheme_bytes, hostname_length, + &core->numeric_ipv4_be); + if (!core->numeric_host) { + if (looks_numeric_host(url.data + scheme_bytes, hostname_length) || + !canonicalize_dns_name(url.data + scheme_bytes, hostname_length, + core->hostname)) { + return false; + } + } else { + memcpy(core->hostname, url.data + scheme_bytes, hostname_length); + core->hostname[hostname_length] = '\0'; + } + + size_t target_length = 0U; + if (authority_end == url.length) { + core->target[target_length++] = '/'; + } else if (url.data[authority_end] == '?') { + if (url.length - authority_end + 1U > sizeof(core->target)) { + return false; + } + core->target[target_length++] = '/'; + memcpy(core->target + target_length, url.data + authority_end, + url.length - authority_end); + target_length += url.length - authority_end; + } else { + if (url.length - authority_end > sizeof(core->target)) { + return false; + } + memcpy(core->target, url.data + authority_end, url.length - authority_end); + target_length = url.length - authority_end; + } + core->target_length = target_length; + + memcpy(core->host_field, core->hostname, hostname_length); + size_t host_length = hostname_length; + if (core->port != default_port) { + core->host_field[host_length++] = ':'; + host_length += write_port(core->port, core->host_field + host_length); + } + core->host_field[host_length] = '\0'; + return true; +} + +static bool ascii_is_hex(uint8_t byte) { + return ascii_is_digit(byte) || (byte >= 'A' && byte <= 'F') || + (byte >= 'a' && byte <= 'f'); +} + +static bool redirect_status(unsigned status) { + return status == 301U || status == 302U || status == 303U || status == 307U || + status == 308U; +} + +static bool append_bytes(uint8_t *output, size_t capacity, size_t *used, + const void *bytes, size_t length) { + if (*used > capacity || length > capacity - *used) { + return false; + } + if (length != 0U) { + memcpy(output + *used, bytes, length); + } + *used += length; + return true; +} + +static bool append_current_origin(const pocketjs_net_http_client_core_t *core, + uint8_t *output, size_t capacity, + size_t *used) { + static const uint8_t http[] = "http://"; + static const uint8_t https[] = "https://"; + const uint8_t *scheme = + core->scheme == POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTPS ? https : http; + const size_t scheme_length = + core->scheme == POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTPS ? sizeof(https) - 1U + : sizeof(http) - 1U; + const size_t host_length = strlen(core->host_field); + return append_bytes(output, capacity, used, scheme, scheme_length) && + append_bytes(output, capacity, used, core->host_field, host_length); +} + +static bool serialize_current_url(const pocketjs_net_http_client_core_t *core, + uint8_t *output, size_t capacity, + size_t *out_length) { + size_t used = 0U; + if (!append_current_origin(core, output, capacity, &used) || + !append_bytes(output, capacity, &used, core->target, + core->target_length)) { + return false; + } + *out_length = used; + return true; +} + +static bool redirect_dot_segment(const uint8_t *segment, size_t length) { + return (length == 1U && segment[0] == '.') || + (length == 3U && segment[0] == '%' && segment[1] == '2' && + ascii_lower(segment[2]) == 'e'); +} + +static bool redirect_double_dot_segment(const uint8_t *segment, size_t length) { + if (length == 2U && segment[0] == '.' && segment[1] == '.') { + return true; + } + uint8_t lowered[6]; + if (length > sizeof(lowered)) { + return false; + } + for (size_t index = 0U; index < length; ++index) { + lowered[index] = ascii_lower(segment[index]); + } + static const uint8_t left_encoded[] = ".%2e"; + static const uint8_t right_encoded[] = "%2e."; + static const uint8_t both_encoded[] = "%2e%2e"; + return (length == sizeof(left_encoded) - 1U && + memcmp(lowered, left_encoded, length) == 0) || + (length == sizeof(right_encoded) - 1U && + memcmp(lowered, right_encoded, length) == 0) || + (length == sizeof(both_encoded) - 1U && + memcmp(lowered, both_encoded, length) == 0); +} + +static bool normalize_redirect_target(pocketjs_net_http_client_core_t *core) { + if (core->target_length == 0U || core->target[0] != '/') { + return false; + } + size_t path_length = core->target_length; + for (size_t index = 0U; index < core->target_length; ++index) { + const uint8_t byte = core->target[index]; + if (byte == '?') { + path_length = index; + break; + } + if (byte <= 0x20U || byte >= 0x7fU || byte == '\\' || byte == '#') { + return false; + } + if (byte == '%' && (index + 2U >= core->target_length || + !ascii_is_hex(core->target[index + 1U]) || + !ascii_is_hex(core->target[index + 2U]))) { + return false; + } + } + for (size_t index = path_length; index < core->target_length; ++index) { + const uint8_t byte = core->target[index]; + if (byte <= 0x20U || byte >= 0x7fU || byte == '\\' || byte == '#') { + return false; + } + if (byte == '%' && (index + 2U >= core->target_length || + !ascii_is_hex(core->target[index + 1U]) || + !ascii_is_hex(core->target[index + 2U]))) { + return false; + } + } + + size_t output_length = 1U; + core->write_bytes[0] = '/'; + size_t segment_start = 1U; + for (size_t cursor = 1U; cursor <= path_length; ++cursor) { + if (cursor < path_length && core->target[cursor] != '/') { + continue; + } + const size_t segment_length = cursor - segment_start; + const bool slash_follows = cursor < path_length; + const uint8_t *segment = core->target + segment_start; + if (redirect_dot_segment(segment, segment_length)) { + if (!slash_follows && core->write_bytes[output_length - 1U] != '/') { + core->write_bytes[output_length++] = '/'; + } + } else if (redirect_double_dot_segment(segment, segment_length)) { + if (output_length > 1U && core->write_bytes[output_length - 1U] == '/') { + --output_length; + } + while (output_length > 1U && + core->write_bytes[output_length - 1U] != '/') { + --output_length; + } + } else { + if (!append_bytes(core->write_bytes, sizeof(core->write_bytes), + &output_length, segment, segment_length) || + (slash_follows && + !append_bytes(core->write_bytes, sizeof(core->write_bytes), + &output_length, "/", 1U))) { + return false; + } + } + segment_start = cursor + 1U; + } + if (!append_bytes(core->write_bytes, sizeof(core->write_bytes), + &output_length, core->target + path_length, + core->target_length - path_length) || + output_length > sizeof(core->target)) { + return false; + } + memcpy(core->target, core->write_bytes, output_length); + core->target_length = output_length; + return true; +} + +static bool resolve_redirect_url(pocketjs_net_http_client_core_t *core, + bool *out_cross_origin) { + const uint8_t *location = core->redirect_location.data; + size_t start = 0U; + size_t end = core->redirect_location.length; + while (start < end && location[start] <= 0x20U) { + ++start; + } + while (end > start && location[end - 1U] <= 0x20U) { + --end; + } + for (size_t index = start; index < end; ++index) { + if (location[index] == '#') { + end = index; + break; + } + if (location[index] <= 0x20U || location[index] >= 0x7fU || + location[index] == '\\') { + return false; + } + } + + const pocketjs_net_http_client_scheme_t previous_scheme = core->scheme; + const uint16_t previous_port = core->port; + char previous_hostname[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_HOST_BYTES + 1U]; + memcpy(previous_hostname, core->hostname, strlen(core->hostname) + 1U); + + uint8_t *candidate = core->write_bytes; + const size_t candidate_capacity = POCKETJS_NET_HTTP_CLIENT_CORE_MAX_URL_BYTES; + size_t candidate_length = 0U; + const size_t location_length = end - start; + const uint8_t *value = location + start; + const bool absolute_http = + location_length >= 7U && ascii_equal_case(value, 7U, "http://"); + const bool absolute_https = + location_length >= 8U && ascii_equal_case(value, 8U, "https://"); + + if (absolute_http || absolute_https) { + if (!append_bytes(candidate, candidate_capacity, &candidate_length, value, + location_length)) { + return false; + } + } else if (location_length >= 2U && value[0] == '/' && value[1] == '/') { + static const uint8_t http_prefix[] = "http:"; + static const uint8_t https_prefix[] = "https:"; + const uint8_t *prefix = + core->scheme == POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTPS ? https_prefix + : http_prefix; + const size_t prefix_length = + core->scheme == POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTPS + ? sizeof(https_prefix) - 1U + : sizeof(http_prefix) - 1U; + if (!append_bytes(candidate, candidate_capacity, &candidate_length, prefix, + prefix_length) || + !append_bytes(candidate, candidate_capacity, &candidate_length, value, + location_length)) { + return false; + } + } else { + for (size_t index = 0U; index < location_length; ++index) { + if (value[index] == '/' || value[index] == '?') { + break; + } + if (value[index] == ':') { + return false; + } + } + if (!append_current_origin(core, candidate, candidate_capacity, + &candidate_length)) { + return false; + } + size_t base_path_length = core->target_length; + for (size_t index = 0U; index < core->target_length; ++index) { + if (core->target[index] == '?') { + base_path_length = index; + break; + } + } + if (location_length == 0U) { + if (!append_bytes(candidate, candidate_capacity, &candidate_length, + core->target, core->target_length)) { + return false; + } + } else if (value[0] == '/') { + if (!append_bytes(candidate, candidate_capacity, &candidate_length, value, + location_length)) { + return false; + } + } else if (value[0] == '?') { + if (!append_bytes(candidate, candidate_capacity, &candidate_length, + core->target, base_path_length) || + !append_bytes(candidate, candidate_capacity, &candidate_length, value, + location_length)) { + return false; + } + } else { + size_t directory_length = base_path_length; + while (directory_length > 0U && + core->target[directory_length - 1U] != '/') { + --directory_length; + } + if (!append_bytes(candidate, candidate_capacity, &candidate_length, + core->target, directory_length) || + !append_bytes(candidate, candidate_capacity, &candidate_length, value, + location_length)) { + return false; + } + } + } + + if (!parse_url(core, + (pocketjs_net_http_client_slice_t){ + .data = candidate, + .length = candidate_length, + }) || + !normalize_redirect_target(core)) { + return false; + } + *out_cross_origin = previous_scheme != core->scheme || + previous_port != core->port || + strcmp(previous_hostname, core->hostname) != 0; + return true; +} + +static bool +base_tls_policy_valid(const pocketjs_net_http_client_core_t *core, + const pocketjs_net_http_client_tls_policy_t *policy) { + if (policy == NULL || + (policy->server_name.length != 0U && policy->server_name.data == NULL) || + (policy->credential.length != 0U && policy->credential.data == NULL) || + policy->minimum_version != POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_2 || + policy->maximum_version != POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_2 || + policy->alpn_count != 0U || policy->credential.length != 0U || + policy->client_certificate != + POCKETJS_NET_HTTP_CLIENT_TLS_CLIENT_CERTIFICATE_NONE || + policy->verification != POCKETJS_NET_HTTP_CLIENT_TLS_VERIFICATION_FULL || + policy->revocation != + POCKETJS_NET_HTTP_CLIENT_TLS_REVOCATION_HOST_DEFAULT || + policy->custom_ca_bytes != 0U || core->numeric_host) { + return false; + } + const size_t hostname_length = strlen(core->hostname); + return policy->server_name.length == hostname_length && + memcmp(policy->server_name.data, core->hostname, hostname_length) == 0; +} + +static bool forbidden_request_header(const uint8_t *name, size_t length) { + static const char *const forbidden[] = { + "host", + "content-length", + "transfer-encoding", + "trailer", + "connection", + "keep-alive", + "proxy-connection", + "proxy-authorization", + "proxy-authenticate", + "te", + "upgrade", + "accept-encoding", + }; + for (size_t index = 0U; index < sizeof(forbidden) / sizeof(forbidden[0]); + ++index) { + if (ascii_equal_case(name, length, forbidden[index])) { + return true; + } + } + static const char proxy_prefix[] = "proxy-"; + if (length >= sizeof(proxy_prefix) - 1U) { + bool matches = true; + for (size_t index = 0U; index < sizeof(proxy_prefix) - 1U; ++index) { + if (ascii_lower(name[index]) != (uint8_t)proxy_prefix[index]) { + matches = false; + break; + } + } + if (matches) { + return true; + } + } + return false; +} + +static bool redirect_body_header(const uint8_t *name, size_t length) { + return ascii_equal_case(name, length, "Content-Encoding") || + ascii_equal_case(name, length, "Content-Language") || + ascii_equal_case(name, length, "Content-Location") || + ascii_equal_case(name, length, "Content-Type"); +} + +static bool redirect_sensitive_header(const uint8_t *name, size_t length) { + return ascii_equal_case(name, length, "Authorization") || + ascii_equal_case(name, length, "Proxy-Authorization") || + ascii_equal_case(name, length, "Cookie"); +} + +static void filter_redirect_headers(pocketjs_net_http_client_core_t *core, + bool remove_body_headers, + bool remove_sensitive_headers) { + size_t output = 0U; + for (size_t index = 0U; index < core->request_header_count; ++index) { + const pocketjs_net_http1_header_t header = core->request_headers[index]; + if ((remove_body_headers && + redirect_body_header(header.name.data, header.name.length)) || + (remove_sensitive_headers && + redirect_sensitive_header(header.name.data, header.name.length))) { + continue; + } + core->request_headers[output++] = header; + } + core->request_header_count = output; +} + +static pocketjs_net_http_client_start_result_t +initialize_request_encoder(pocketjs_net_http_client_core_t *core) { + pocketjs_net_http1_request_body_kind_t wire_body_kind = + POCKETJS_NET_HTTP1_REQUEST_BODY_NONE; + uint64_t wire_content_length = 0U; + if (core->request_body_kind == POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_FIXED) { + wire_body_kind = POCKETJS_NET_HTTP1_REQUEST_BODY_FIXED; + wire_content_length = core->request_body_length; + } else if (core->request_body_kind == + POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_STREAMING) { + wire_body_kind = core->request_body_length_known + ? POCKETJS_NET_HTTP1_REQUEST_BODY_FIXED + : POCKETJS_NET_HTTP1_REQUEST_BODY_CHUNKED; + wire_content_length = core->request_body_expected_length; + } + + pocketjs_net_http1_request_t wire_request = { + .method = {.data = core->method, .length = core->method_length}, + .target = {.data = core->target, .length = core->target_length}, + .host = {.data = (const uint8_t *)core->host_field, + .length = strlen(core->host_field)}, + .headers = core->request_headers, + .header_count = core->request_header_count, + .body_kind = wire_body_kind, + .content_length = wire_content_length, + }; + pocketjs_net_http1_wire_error_t wire_error = + pocketjs_net_http1_request_encoder_init( + &core->encoder, &wire_request, &pocketjs_net_http1_default_limits); + if (wire_error == POCKETJS_NET_HTTP1_WIRE_ERROR_NONE) { + return POCKETJS_NET_HTTP_CLIENT_START_OK; + } + return pocketjs_net_http1_wire_error_is_limit(wire_error) + ? POCKETJS_NET_HTTP_CLIENT_START_LIMIT_EXCEEDED + : POCKETJS_NET_HTTP_CLIENT_START_FORBIDDEN_REQUEST; +} + +static pocketjs_net_http_client_start_result_t +snapshot_request(pocketjs_net_http_client_core_t *core, + const pocketjs_net_http_client_request_t *request) { + if (request == NULL || request->operation_token == 0U || + (request->header_count != 0U && request->headers == NULL) || + request->body_kind > POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_STREAMING || + request->redirect_mode > POCKETJS_NET_HTTP_CLIENT_REDIRECT_ERROR || + request->max_redirects > POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REDIRECTS || + (request->body.length != 0U && request->body.data == NULL)) { + return POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT; + } + switch (request->body_kind) { + case POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_NONE: + if (request->body.length != 0U || request->streaming_content_length_known || + request->streaming_content_length != 0U) { + return POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT; + } + break; + case POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_FIXED: + if (request->streaming_content_length_known || + request->streaming_content_length != 0U) { + return POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT; + } + if (request->body.length > + POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_BODY_BYTES) { + return POCKETJS_NET_HTTP_CLIENT_START_LIMIT_EXCEEDED; + } + break; + case POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_STREAMING: + if (request->body.length != 0U || + (!request->streaming_content_length_known && + request->streaming_content_length != 0U)) { + return POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT; + } + if (core->last_request_body_generation == UINT64_MAX || + core->last_request_body_pull_generation == UINT64_MAX) { + return POCKETJS_NET_HTTP_CLIENT_START_TOKEN_EXHAUSTED; + } + break; + default: + return POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT; + } + if (core->last_operation_token == UINT64_MAX) { + return POCKETJS_NET_HTTP_CLIENT_START_TOKEN_EXHAUSTED; + } + if (request->operation_token <= core->last_operation_token) { + return POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT; + } + if (!parse_url(core, request->url)) { + return POCKETJS_NET_HTTP_CLIENT_START_INVALID_URL; + } + if (core->scheme == POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTPS) { + if (!core->config.allow_https || + !base_tls_policy_valid(core, request->tls)) { + return POCKETJS_NET_HTTP_CLIENT_START_UNSUPPORTED_TLS; + } + } else if (request->tls != NULL) { + return POCKETJS_NET_HTTP_CLIENT_START_UNSUPPORTED_TLS; + } + if (!valid_method(request->method.data, request->method.length)) { + return POCKETJS_NET_HTTP_CLIENT_START_FORBIDDEN_REQUEST; + } + if (request->header_count > + POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_HEADERS) { + return POCKETJS_NET_HTTP_CLIENT_START_LIMIT_EXCEEDED; + } + if (request->body_kind != POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_NONE && + (ascii_equal_case(request->method.data, request->method.length, "GET") || + ascii_equal_case(request->method.data, request->method.length, + "HEAD"))) { + return POCKETJS_NET_HTTP_CLIENT_START_FORBIDDEN_REQUEST; + } + + memcpy(core->method, request->method.data, request->method.length); + core->method_length = request->method.length; + core->request_header_storage_used = 0U; + core->request_header_count = 0U; + for (size_t index = 0U; index < request->header_count; ++index) { + pocketjs_net_http_client_header_t header = request->headers[index]; + if (header.name.data == NULL || header.name.length == 0U || + (header.value.length != 0U && header.value.data == NULL)) { + return POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT; + } + if (forbidden_request_header(header.name.data, header.name.length)) { + return POCKETJS_NET_HTTP_CLIENT_START_FORBIDDEN_REQUEST; + } + if (header.name.length > sizeof(core->request_header_storage) - + core->request_header_storage_used || + header.value.length > sizeof(core->request_header_storage) - + core->request_header_storage_used - + header.name.length) { + return POCKETJS_NET_HTTP_CLIENT_START_LIMIT_EXCEEDED; + } + uint8_t *name = + core->request_header_storage + core->request_header_storage_used; + memcpy(name, header.name.data, header.name.length); + core->request_header_storage_used += header.name.length; + uint8_t *value = + core->request_header_storage + core->request_header_storage_used; + if (header.value.length != 0U) { + memcpy(value, header.value.data, header.value.length); + } + core->request_header_storage_used += header.value.length; + core->request_headers[core->request_header_count++] = + (pocketjs_net_http1_header_t){ + .name = {.data = name, .length = header.name.length}, + .value = {.data = value, .length = header.value.length}, + }; + } + static const uint8_t connection_name[] = "Connection"; + static const uint8_t connection_value[] = "close"; + static const uint8_t encoding_name[] = "Accept-Encoding"; + static const uint8_t encoding_value[] = "identity"; + if (!core->config.enable_connection_reuse) { + core->request_headers[core->request_header_count++] = + (pocketjs_net_http1_header_t){ + .name = {.data = connection_name, + .length = sizeof(connection_name) - 1U}, + .value = {.data = connection_value, + .length = sizeof(connection_value) - 1U}, + }; + } + core->request_headers[core->request_header_count++] = + (pocketjs_net_http1_header_t){ + .name = {.data = encoding_name, .length = sizeof(encoding_name) - 1U}, + .value = {.data = encoding_value, + .length = sizeof(encoding_value) - 1U}, + }; + if (request->body_kind == POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_FIXED && + request->body.length != 0U) { + memcpy(core->request_body, request->body.data, request->body.length); + } + core->request_body_kind = request->body_kind; + core->request_body_length = + request->body_kind == POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_FIXED + ? request->body.length + : 0U; + core->request_body_length_known = + request->body_kind == POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_STREAMING && + request->streaming_content_length_known; + core->request_body_expected_length = + core->request_body_length_known ? request->streaming_content_length : 0U; + core->request_body_submitted_length = 0U; + core->request_body_pending_payload_length = 0U; + core->request_body_pull_active = false; + core->request_body_pull_event_retired = false; + core->redirect_mode = request->redirect_mode; + core->redirect_count = 0U; + core->max_redirects = request->max_redirects; + core->redirect_pending = false; + return initialize_request_encoder(core); +} + +static bool publish_event(pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_event_type_t type) { + uint64_t sequence = 0U; + bool terminal = type == POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE || + type == POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR; + if (core->event_state != EVENT_EMPTY || + (!terminal && core->event_sequence >= UINT64_MAX - 1U) || + !next_event_sequence(core, &sequence)) { + return false; + } + memset(&core->event, 0, sizeof(core->event)); + core->event.type = type; + core->event.sequence = sequence; + core->event.operation_token = core->operation_token; + core->event_state = EVENT_PENDING; + return true; +} + +typedef enum { + REDIRECT_DECISION_PUBLISH = 0, + REDIRECT_DECISION_FOLLOW, + REDIRECT_DECISION_FAIL, +} redirect_decision_t; + +static redirect_decision_t +prepare_redirect(pocketjs_net_http_client_core_t *core) { + if (!redirect_status(core->response_status) || + core->redirect_mode == POCKETJS_NET_HTTP_CLIENT_REDIRECT_MANUAL) { + return REDIRECT_DECISION_PUBLISH; + } + if (core->redirect_mode == POCKETJS_NET_HTTP_CLIENT_REDIRECT_ERROR) { + core->callback_error = POCKETJS_NET_HTTP_CLIENT_ERROR_REDIRECT; + return REDIRECT_DECISION_FAIL; + } + if (!core->redirect_location_seen) { + return REDIRECT_DECISION_PUBLISH; + } + if (core->redirect_count >= core->max_redirects) { + core->callback_error = POCKETJS_NET_HTTP_CLIENT_ERROR_REDIRECT_LIMIT; + return REDIRECT_DECISION_FAIL; + } + + const bool rewrite_get = + ((core->response_status == 301U || core->response_status == 302U) && + ascii_equal_case(core->method, core->method_length, "POST")) || + (core->response_status == 303U && + !ascii_equal_case(core->method, core->method_length, "HEAD")); + if (!rewrite_get && core->request_body_kind == + POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_STREAMING) { + core->callback_error = + POCKETJS_NET_HTTP_CLIENT_ERROR_REDIRECT_BODY_NOT_REPLAYABLE; + return REDIRECT_DECISION_FAIL; + } + + bool cross_origin = false; + if (!resolve_redirect_url(core, &cross_origin)) { + core->callback_error = POCKETJS_NET_HTTP_CLIENT_ERROR_REDIRECT; + return REDIRECT_DECISION_FAIL; + } + if (core->scheme == POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTPS && + (!core->config.allow_https || core->numeric_host)) { + core->callback_error = POCKETJS_NET_HTTP_CLIENT_ERROR_UNSUPPORTED; + return REDIRECT_DECISION_FAIL; + } + + if (rewrite_get) { + static const uint8_t get[] = "GET"; + memcpy(core->method, get, sizeof(get) - 1U); + core->method_length = sizeof(get) - 1U; + core->request_body_kind = POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_NONE; + core->request_body_length = 0U; + core->request_body_length_known = false; + core->request_body_expected_length = 0U; + core->request_body_submitted_length = 0U; + core->request_body_pending_payload_length = 0U; + core->request_body_pull_active = false; + core->request_body_pull_event_retired = false; + } + filter_redirect_headers(core, rewrite_get, cross_origin); + const pocketjs_net_http_client_start_result_t encoder_result = + initialize_request_encoder(core); + if (encoder_result != POCKETJS_NET_HTTP_CLIENT_START_OK) { + core->callback_error = + encoder_result == POCKETJS_NET_HTTP_CLIENT_START_LIMIT_EXCEEDED + ? POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT + : POCKETJS_NET_HTTP_CLIENT_ERROR_REDIRECT; + return REDIRECT_DECISION_FAIL; + } + + ++core->redirect_count; + core->redirect_pending = true; + return REDIRECT_DECISION_FOLLOW; +} + +static bool publish_headers(pocketjs_net_http_client_core_t *core) { + size_t url_length = 0U; + if (!serialize_current_url(core, core->write_bytes, + POCKETJS_NET_HTTP_CLIENT_CORE_MAX_URL_BYTES, + &url_length)) { + return false; + } + if (!publish_event(core, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS)) { + return false; + } + core->event.detail.response.status_code = core->response_status; + core->event.detail.response.status_text = core->response_status_text; + core->event.detail.response.headers = core->response_headers; + core->event.detail.response.header_count = core->response_header_count; + core->event.detail.response.url = (pocketjs_net_http_client_slice_t){ + .data = core->write_bytes, + .length = url_length, + }; + core->event.detail.response.redirected = core->redirect_count != 0U; + return true; +} + +static bool publish_terminal(pocketjs_net_http_client_core_t *core) { + pocketjs_net_http_client_event_type_t type = + core->terminal_success ? POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE + : POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR; + if (!publish_event(core, type)) { + return false; + } + if (!core->terminal_success) { + core->event.detail.error.code = core->terminal_error; + core->event.detail.error.cause_code = core->terminal_cause; + } + core->state = CORE_WAITING_TERMINAL_RETIRE; + return true; +} + +static bool response_on_status(void *context, unsigned http_minor, + unsigned status_code, const uint8_t *status_text, + size_t status_text_length, bool informational) { + pocketjs_net_http_client_core_t *core = context; + if (informational) { + return true; + } + core->callback_error = 0; + core->force_no_body = false; + core->response_header_storage_used = 0U; + core->response_header_field_bytes = 0U; + core->response_header_count = 0U; + core->redirect_location_seen = false; + core->redirect_location = (pocketjs_net_http_client_slice_t){0}; + core->response_status = status_code; + core->response_http_minor = http_minor; + core->response_body_kind = POCKETJS_NET_HTTP1_RESPONSE_BODY_NONE; + core->response_connection_close = false; + core->response_connection_reusable = false; + if (status_text_length > sizeof(core->response_header_storage)) { + core->callback_error = POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT; + return false; + } + uint8_t *copy = core->response_header_storage; + if (status_text_length != 0U) { + memcpy(copy, status_text, status_text_length); + } + core->response_header_storage_used = status_text_length; + core->response_status_text = (pocketjs_net_http_client_slice_t){ + .data = copy, + .length = status_text_length, + }; + return true; +} + +static bool response_on_header(void *context, const uint8_t *name, + size_t name_length, const uint8_t *value, + size_t value_length, bool informational) { + pocketjs_net_http_client_core_t *core = context; + if (informational) { + return true; + } + if (ascii_equal_case(name, name_length, "Content-Encoding") && + !ascii_equal_case(value, value_length, "identity")) { + core->callback_error = POCKETJS_NET_HTTP_CLIENT_ERROR_PROTOCOL; + return false; + } + if (ascii_equal_case(name, name_length, "Connection") && + !connection_value_is_valid(value, value_length, + &core->response_connection_close)) { + core->callback_error = POCKETJS_NET_HTTP_CLIENT_ERROR_PROTOCOL; + return false; + } + const size_t header_limit = + core->config.response_header_bytes_limit == 0U + ? POCKETJS_NET_HTTP_CLIENT_CORE_MAX_RESPONSE_HEADER_BYTES + : core->config.response_header_bytes_limit; + if (value_length > SIZE_MAX - 4U || + name_length > SIZE_MAX - value_length - 4U) { + core->callback_error = POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT; + return false; + } + const size_t field_bytes = name_length + value_length + 4U; + if (field_bytes > header_limit || + core->response_header_field_bytes > header_limit - field_bytes) { + core->callback_error = POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT; + return false; + } + if (core->response_header_count >= + POCKETJS_NET_HTTP_CLIENT_CORE_MAX_RESPONSE_HEADERS || + name_length > sizeof(core->response_header_storage) - + core->response_header_storage_used || + value_length > sizeof(core->response_header_storage) - + core->response_header_storage_used - name_length) { + core->callback_error = POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT; + return false; + } + uint8_t *name_copy = + core->response_header_storage + core->response_header_storage_used; + memcpy(name_copy, name, name_length); + core->response_header_storage_used += name_length; + uint8_t *value_copy = + core->response_header_storage + core->response_header_storage_used; + if (value_length != 0U) { + memcpy(value_copy, value, value_length); + } + core->response_header_storage_used += value_length; + core->response_header_field_bytes += field_bytes; + core->response_headers[core->response_header_count++] = + (pocketjs_net_http_client_header_t){ + .name = {.data = name_copy, .length = name_length}, + .value = {.data = value_copy, .length = value_length}, + }; + if (redirect_status(core->response_status) && + core->redirect_mode == POCKETJS_NET_HTTP_CLIENT_REDIRECT_FOLLOW && + ascii_equal_case(name, name_length, "Location")) { + if (core->redirect_location_seen) { + core->callback_error = POCKETJS_NET_HTTP_CLIENT_ERROR_REDIRECT; + return false; + } + core->redirect_location_seen = true; + core->redirect_location = (pocketjs_net_http_client_slice_t){ + .data = value_copy, + .length = value_length, + }; + } + return true; +} + +static bool +response_on_headers_complete(void *context, unsigned status_code, + pocketjs_net_http1_response_body_kind_t body_kind, + uint64_t content_length, bool informational) { + pocketjs_net_http_client_core_t *core = context; + if (informational) { + return true; + } + if (status_code == 205U) { + if (body_kind == POCKETJS_NET_HTTP1_RESPONSE_BODY_CHUNKED || + (body_kind == POCKETJS_NET_HTTP1_RESPONSE_BODY_FIXED && + content_length != 0U)) { + core->callback_error = POCKETJS_NET_HTTP_CLIENT_ERROR_PROTOCOL; + return false; + } + core->force_no_body = true; + } + core->response_body_kind = body_kind; + core->response_connection_reusable = + core->config.enable_connection_reuse && core->response_http_minor == 1U && + !core->response_connection_close && + body_kind != POCKETJS_NET_HTTP1_RESPONSE_BODY_UNTIL_EOF; + core->final_headers_seen = true; + const redirect_decision_t redirect = prepare_redirect(core); + if (redirect == REDIRECT_DECISION_FAIL) { + return false; + } + if (redirect == REDIRECT_DECISION_FOLLOW) { + return true; + } + if (!publish_headers(core)) { + core->callback_error = POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT; + return false; + } + return true; +} + +static bool response_on_body(void *context, const uint8_t *body, + size_t body_length) { + pocketjs_net_http_client_core_t *core = context; + if (!core->headers_delivered || body_length > core->body_credit || + body_length > sizeof(core->body_bytes) - core->body_byte_count) { + core->callback_error = POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT; + return false; + } + if (body_length != 0U) { + memcpy(core->body_bytes + core->body_byte_count, body, body_length); + } + core->body_byte_count += body_length; + core->body_credit -= body_length; + return true; +} + +static void response_on_complete(void *context) { + pocketjs_net_http_client_core_t *core = context; + core->parser_complete = true; +} + +static pocketjs_net_http_client_error_t +map_transport_error(pocketjs_net_http_client_transport_error_t error) { + switch (error) { + case POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_ABORTED: + return POCKETJS_NET_HTTP_CLIENT_ERROR_ABORTED; + case POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TIMED_OUT: + return POCKETJS_NET_HTTP_CLIENT_ERROR_TIMED_OUT; + case POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_DNS: + return POCKETJS_NET_HTTP_CLIENT_ERROR_DNS; + case POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_CONNECT: + return POCKETJS_NET_HTTP_CLIENT_ERROR_CONNECT; + case POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_IO: + return POCKETJS_NET_HTTP_CLIENT_ERROR_IO; + case POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_RESOURCE_LIMIT: + return POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT; + case POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_CERTIFICATE_INVALID: + return POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_CERTIFICATE_INVALID; + case POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_HOSTNAME_MISMATCH: + return POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_HOSTNAME_MISMATCH; + case POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_HANDSHAKE_FAILED: + return POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_HANDSHAKE_FAILED; + case POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_VERSION_UNSUPPORTED: + return POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_VERSION_UNSUPPORTED; + case POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_ALERT: + return POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_ALERT; + case POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_INVALID: + case POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_NONE: + default: + return POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT; + } +} + +static void select_failure(pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_error_t error, + int32_t cause) { + if (core->terminal_selected) { + return; + } + core->terminal_selected = true; + core->terminal_success = false; + core->terminal_error = error; + core->terminal_cause = cause; +} + +static void select_success(pocketjs_net_http_client_core_t *core) { + if (core->terminal_selected) { + return; + } + core->terminal_selected = true; + core->terminal_success = true; +} + +static void revoke_request_body_credit(pocketjs_net_http_client_core_t *core) { + core->request_body_pull_active = false; + core->request_body_pull_event_retired = false; + core->request_body_pull_maximum = 0U; + if (core->event_state == EVENT_PENDING && + core->event.type == POCKETJS_NET_HTTP_CLIENT_EVENT_REQUEST_BODY_PULL) { + core->event_state = EVENT_EMPTY; + memset(&core->event, 0, sizeof(core->event)); + } +} + +static bool publish_request_body_pull(pocketjs_net_http_client_core_t *core) { + if (core->state != CORE_WAITING_REQUEST_BODY || core->terminal_selected || + core->event_state != EVENT_EMPTY || core->request_body_pull_active) { + return false; + } + size_t maximum = POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES; + if (core->request_body_length_known) { + if (core->request_body_submitted_length >= + core->request_body_expected_length) { + select_failure( + core, POCKETJS_NET_HTTP_CLIENT_ERROR_REQUEST_BODY, + POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_CAUSE_LENGTH_UNDERFLOW); + return false; + } + uint64_t remaining = core->request_body_expected_length - + core->request_body_submitted_length; + if (remaining < maximum) { + maximum = (size_t)remaining; + } + } + if (maximum == 0U || core->last_request_body_pull_generation == UINT64_MAX) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT, 0); + return false; + } + ++core->last_request_body_pull_generation; + core->request_body_pull_generation = core->last_request_body_pull_generation; + core->request_body_pull_maximum = maximum; + core->request_body_pull_active = true; + core->request_body_pull_event_retired = false; + if (!publish_event(core, POCKETJS_NET_HTTP_CLIENT_EVENT_REQUEST_BODY_PULL)) { + revoke_request_body_credit(core); + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT, 0); + return false; + } + core->event.detail.request_body_pull.body_generation = + core->request_body_generation; + core->event.detail.request_body_pull.pull_generation = + core->request_body_pull_generation; + core->event.detail.request_body_pull.maximum_bytes = maximum; + return true; +} + +static bool +invoke_permission(pocketjs_net_http_client_core_t *core, + const pocketjs_net_http_client_endpoint_t *endpoint, + core_state_t expected_state, bool *out_allowed) { + uint64_t generation = core->lifecycle_generation; + pocketjs_net_http_client_operation_token_t operation_token = + core->operation_token; + core->permission_callback_active = true; + bool allowed = + core->config.allow_endpoint(core->config.permission_context, endpoint); + core->permission_callback_active = false; + if (!core_is_live(core) || core->lifecycle_generation != generation || + core->state != expected_state || + core->operation_token != operation_token || core->transport_active || + core->terminal_selected) { + if (core_is_live(core)) { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_PERMISSION_REENTRANCY, + 0); + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, 0); + } + *out_allowed = false; + return false; + } + *out_allowed = allowed; + return true; +} + +static bool +read_lease_equal(pocketjs_net_http_client_transport_read_lease_t left, + pocketjs_net_http_client_transport_read_lease_t right) { + return left.slot == right.slot && left.generation == right.generation; +} + +static bool +release_transport_read_lease(pocketjs_net_http_client_core_t *core) { + if (!core->transport_read_lease_valid) { + return true; + } + pocketjs_net_http_client_transport_result_t result = + core->config.transport_ops->release_read_lease( + core->config.transport_context, core->transport_read_lease); + if (result != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_READ_LEASE_RELEASE, + (int32_t)result); + return false; + } + core->transport_read_lease_valid = false; + core->transport_read_bytes = NULL; + core->transport_read_length = 0U; + core->transport_read_offset = 0U; + core->transport_read_eof_pending = false; + return true; +} + +static bool release_orphan_read_lease(pocketjs_net_http_client_core_t *core) { + if (!core->orphan_read_lease_valid) { + return true; + } + pocketjs_net_http_client_transport_result_t result = + core->config.transport_ops->release_read_lease( + core->config.transport_context, core->orphan_read_lease); + if (result != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_READ_LEASE_RELEASE, + (int32_t)result); + return false; + } + core->orphan_read_lease_valid = false; + return true; +} + +static bool cleanup_completion_read_lease( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_transport_read_lease_t lease) { + if ((core->transport_read_lease_valid && + read_lease_equal(core->transport_read_lease, lease)) || + (core->orphan_read_lease_valid && + read_lease_equal(core->orphan_read_lease, lease))) { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_STALE_COMPLETION, 0); + return false; + } + pocketjs_net_http_client_transport_result_t result = + core->config.transport_ops->release_read_lease( + core->config.transport_context, lease); + if (result == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + return true; + } + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_READ_LEASE_RELEASE, + (int32_t)result); + if (!core->transport_read_lease_valid) { + core->transport_read_lease = lease; + core->transport_read_lease_valid = true; + core->transport_read_bytes = NULL; + core->transport_read_length = 0U; + core->transport_read_offset = 0U; + core->transport_read_eof_pending = false; + } else if (!core->orphan_read_lease_valid) { + core->orphan_read_lease = lease; + core->orphan_read_lease_valid = true; + } else { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_STALE_COMPLETION, 0); + } + return false; +} + +static bool start_close(pocketjs_net_http_client_core_t *core) { + if (core->event_state != EVENT_EMPTY || core->transport_active) { + return true; + } + if (!core->connection_valid) { + return publish_terminal(core); + } + uint64_t token = 0U; + if (!next_transport_token(core, &token)) { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_ADMISSION, 0); + (void)publish_terminal(core); + return false; + } + uint64_t close_deadline = deadline_after(core->now_us, CORE_CLOSE_TIMEOUT_US); + pocketjs_net_http_client_transport_result_t result = + core->config.transport_ops->start_close(core->config.transport_context, + token, core->connection, + close_deadline); + if (result != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_ADMISSION, + (int32_t)result); + (void)publish_terminal(core); + return false; + } + core->transport_active = true; + core->transport_cancel_requested = false; + core->transport_operation_kind = TRANSPORT_OPERATION_CLOSE; + core->transport_operation_token = token; + core->close_deadline_us = close_deadline; + core->close_cancel_requested = false; + core->state = CORE_CLOSING; + return true; +} + +static bool start_idle_connection_close(pocketjs_net_http_client_core_t *core) { + if (!core->connection_valid) { + return true; + } + if (core->transport_active || core->event_state != EVENT_EMPTY) { + return false; + } + uint64_t token = 0U; + if (!next_transport_token(core, &token)) { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_ADMISSION, 0); + return false; + } + const uint64_t close_deadline = + deadline_after(core->now_us, CORE_CLOSE_TIMEOUT_US); + const pocketjs_net_http_client_transport_result_t result = + core->config.transport_ops->start_close(core->config.transport_context, + token, core->connection, + close_deadline); + if (result != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_ADMISSION, + (int32_t)result); + return false; + } + core->connection_reusable = false; + core->connection_idle_deadline_us = 0U; + core->transport_active = true; + core->transport_cancel_requested = false; + core->transport_operation_kind = TRANSPORT_OPERATION_CLOSE; + core->transport_operation_token = token; + core->close_deadline_us = close_deadline; + core->close_cancel_requested = false; + core->state = CORE_IDLE_CONNECTION_CLOSING; + return true; +} + +static bool +terminal_can_retain_connection(const pocketjs_net_http_client_core_t *core) { + return core->config.enable_connection_reuse && !core->shutdown_requested && + core->terminal_success && core->parser_complete && + core->response_connection_reusable && core->connection_valid; +} + +static void progress_terminal(pocketjs_net_http_client_core_t *core) { + if (!core->terminal_selected) { + return; + } + revoke_request_body_credit(core); + if (core->event_state != EVENT_EMPTY) { + return; + } + (void)release_transport_read_lease(core); + (void)release_orphan_read_lease(core); + core->body_credit = 0U; + core->body_byte_count = 0U; + if (core->completion_retire_pending) { + return; + } + if (core->transport_active) { + if (core->transport_operation_kind == TRANSPORT_OPERATION_CLOSE) { + return; + } + if (core->transport_cancel_requested) { + return; + } + pocketjs_net_http_client_transport_result_t result = + core->config.transport_ops->cancel(core->config.transport_context, + core->transport_operation_token); + if (result != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_CANCEL, + (int32_t)result); + (void)publish_terminal(core); + } else { + core->transport_cancel_requested = true; + } + return; + } + if (terminal_can_retain_connection(core)) { + core->connection_reusable = true; + core->connection_idle_deadline_us = + deadline_after(core->now_us, core->config.idle_timeout_us); + (void)publish_terminal(core); + return; + } + core->connection_reusable = false; + core->connection_idle_deadline_us = 0U; + (void)start_close(core); +} + +static pocketjs_net_http_client_transport_result_t +start_resolve(pocketjs_net_http_client_core_t *core) { + uint64_t token = 0U; + if (!next_transport_token(core, &token)) { + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_RESOURCE_LIMIT; + } + pocketjs_net_http_client_transport_result_t result = + core->config.transport_ops->start_resolve(core->config.transport_context, + token, core->hostname, + core->connect_deadline_us); + if (result == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + core->transport_active = true; + core->transport_cancel_requested = false; + core->transport_operation_kind = TRANSPORT_OPERATION_RESOLVE; + core->transport_operation_token = token; + core->state = CORE_RESOLVING; + } + return result; +} + +static pocketjs_net_http_client_transport_result_t +start_connect(pocketjs_net_http_client_core_t *core, uint32_t ipv4_be) { + uint64_t token = 0U; + if (!next_transport_token(core, &token)) { + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_RESOURCE_LIMIT; + } + pocketjs_net_http_client_transport_result_t result = + core->config.transport_ops->start_connect( + core->config.transport_context, token, ipv4_be, core->port, + core->scheme == POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTPS, core->hostname, + core->connect_deadline_us); + if (result == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + core->selected_ipv4_be = ipv4_be; + core->transport_active = true; + core->transport_cancel_requested = false; + core->transport_operation_kind = TRANSPORT_OPERATION_CONNECT; + core->transport_operation_token = token; + core->state = CORE_CONNECTING; + } + return result; +} + +static pocketjs_net_http_client_transport_result_t +start_next_connect_candidate(pocketjs_net_http_client_core_t *core) { + if (core->next_connect_candidate >= core->connect_candidate_count) { + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_INVALID; + } + const uint32_t ipv4_be = + core->connect_candidates[core->next_connect_candidate++]; + return start_connect(core, ipv4_be); +} + +static bool initialize_parser(pocketjs_net_http_client_core_t *core); +static bool emit_next_request_head(pocketjs_net_http_client_core_t *core); + +static bool +connection_origin_matches(const pocketjs_net_http_client_core_t *core) { + return core->connection_valid && core->connection_reusable && + core->connection_idle_deadline_us != 0U && + core->now_us < core->connection_idle_deadline_us && + core->connection_scheme == core->scheme && + core->connection_port == core->port && + strcmp(core->connection_hostname, core->hostname) == 0; +} + +static bool +begin_reused_connection_request(pocketjs_net_http_client_core_t *core) { + bool allowed = false; + if (!core->numeric_host) { + const pocketjs_net_http_client_endpoint_t hostname_endpoint = { + .phase = POCKETJS_NET_HTTP_CLIENT_PERMISSION_HOSTNAME, + .scheme = core->scheme, + .hostname = core->hostname, + .port = core->port, + .ipv4_be = 0U, + }; + if (!invoke_permission(core, &hostname_endpoint, CORE_RESOLVING, + &allowed)) { + return false; + } + if (!allowed) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_PERMISSION_DENIED, 0); + return false; + } + } + const pocketjs_net_http_client_endpoint_t numeric_endpoint = { + .phase = POCKETJS_NET_HTTP_CLIENT_PERMISSION_NUMERIC_CANDIDATE, + .scheme = core->scheme, + .hostname = core->hostname, + .port = core->port, + .ipv4_be = core->connection_ipv4_be, + }; + if (!invoke_permission(core, &numeric_endpoint, CORE_RESOLVING, &allowed)) { + return false; + } + if (!allowed) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_PERMISSION_DENIED, 0); + return false; + } + + core->connection_reusable = false; + core->connection_idle_deadline_us = 0U; + core->selected_ipv4_be = core->connection_ipv4_be; + core->headers_deadline_us = earlier_deadline( + deadline_after(core->now_us, core->config.headers_timeout_us), + core->total_deadline_us); + if (!initialize_parser(core)) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_PROTOCOL, 0); + return false; + } + return emit_next_request_head(core); +} + +static bool +start_connection_replacement_close(pocketjs_net_http_client_core_t *core) { + uint64_t token = 0U; + if (!next_transport_token(core, &token)) { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_ADMISSION, 0); + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT, 0); + return false; + } + const uint64_t close_deadline = + earlier_deadline(deadline_after(core->now_us, CORE_CLOSE_TIMEOUT_US), + core->total_deadline_us); + const pocketjs_net_http_client_transport_result_t result = + core->config.transport_ops->start_close(core->config.transport_context, + token, core->connection, + close_deadline); + if (result != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_ADMISSION, + (int32_t)result); + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, + (int32_t)result); + return false; + } + core->connection_reusable = false; + core->connection_idle_deadline_us = 0U; + core->transport_active = true; + core->transport_cancel_requested = false; + core->transport_operation_kind = TRANSPORT_OPERATION_CLOSE; + core->transport_operation_token = token; + core->close_deadline_us = close_deadline; + core->close_cancel_requested = false; + core->state = CORE_REPLACING_CONNECTION; + return true; +} + +static bool begin_current_endpoint(pocketjs_net_http_client_core_t *core) { + if (core->now_us >= core->total_deadline_us) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TIMED_OUT, 0); + return false; + } + core->connect_deadline_us = earlier_deadline( + deadline_after(core->now_us, core->config.connect_timeout_us), + core->total_deadline_us); + core->headers_deadline_us = core->total_deadline_us; + core->idle_deadline_us = core->total_deadline_us; + core->state = CORE_RESOLVING; + core->connect_candidate_count = 0U; + core->next_connect_candidate = 0U; + + if (core->connection_valid) { + if (connection_origin_matches(core)) { + return begin_reused_connection_request(core); + } + return start_connection_replacement_close(core); + } + + if (core->numeric_host) { + pocketjs_net_http_client_endpoint_t endpoint = { + .phase = POCKETJS_NET_HTTP_CLIENT_PERMISSION_NUMERIC_CANDIDATE, + .scheme = core->scheme, + .hostname = core->hostname, + .port = core->port, + .ipv4_be = core->numeric_ipv4_be, + }; + bool allowed = false; + if (!invoke_permission(core, &endpoint, CORE_RESOLVING, &allowed)) { + return false; + } + if (!allowed) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_PERMISSION_DENIED, 0); + return false; + } + pocketjs_net_http_client_transport_result_t result = + start_connect(core, core->numeric_ipv4_be); + if (result != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + select_failure(core, + result == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_RESOURCE_LIMIT + ? POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT + : POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, + (int32_t)result); + return false; + } + return true; + } + + pocketjs_net_http_client_endpoint_t endpoint = { + .phase = POCKETJS_NET_HTTP_CLIENT_PERMISSION_HOSTNAME, + .scheme = core->scheme, + .hostname = core->hostname, + .port = core->port, + .ipv4_be = 0U, + }; + bool allowed = false; + if (!invoke_permission(core, &endpoint, CORE_RESOLVING, &allowed)) { + return false; + } + if (!allowed) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_PERMISSION_DENIED, 0); + return false; + } + const pocketjs_net_http_client_transport_result_t result = + start_resolve(core); + if (result != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + select_failure(core, + result == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_RESOURCE_LIMIT + ? POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT + : POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, + (int32_t)result); + return false; + } + return true; +} + +static bool start_redirect_close(pocketjs_net_http_client_core_t *core) { + if (!core->redirect_pending || core->event_state != EVENT_EMPTY || + core->transport_active) { + return false; + } + if (!core->connection_valid) { + core->state = CORE_REDIRECT_READY; + return true; + } + uint64_t token = 0U; + if (!next_transport_token(core, &token)) { + core->redirect_pending = false; + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_ADMISSION, 0); + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT, 0); + return false; + } + const uint64_t close_deadline = + earlier_deadline(deadline_after(core->now_us, CORE_CLOSE_TIMEOUT_US), + core->total_deadline_us); + const pocketjs_net_http_client_transport_result_t result = + core->config.transport_ops->start_close(core->config.transport_context, + token, core->connection, + close_deadline); + if (result != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + core->redirect_pending = false; + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_ADMISSION, + (int32_t)result); + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, + (int32_t)result); + return false; + } + core->transport_active = true; + core->transport_cancel_requested = false; + core->transport_operation_kind = TRANSPORT_OPERATION_CLOSE; + core->transport_operation_token = token; + core->close_deadline_us = close_deadline; + core->close_cancel_requested = false; + core->state = CORE_CLOSING; + return true; +} + +static void begin_redirect_hop(pocketjs_net_http_client_core_t *core) { + if (core->state != CORE_REDIRECT_READY || !core->redirect_pending || + core->event_state != EVENT_EMPTY || core->transport_active || + core->completion_retire_pending || core->connection_valid || + core->terminal_selected) { + return; + } + core->redirect_pending = false; + core->final_headers_seen = false; + core->headers_delivered = false; + core->parser_complete = false; + core->force_no_body = false; + core->callback_error = 0; + core->response_status = 0U; + core->response_http_minor = 0U; + core->response_body_kind = POCKETJS_NET_HTTP1_RESPONSE_BODY_NONE; + core->response_connection_close = false; + core->response_connection_reusable = false; + core->response_header_storage_used = 0U; + core->response_header_field_bytes = 0U; + core->response_header_count = 0U; + core->redirect_location_seen = false; + core->redirect_location = (pocketjs_net_http_client_slice_t){0}; + core->body_credit = 0U; + core->terminal_body_pull_active = false; + core->body_byte_count = 0U; + if (!begin_current_endpoint(core)) { + progress_terminal(core); + } +} + +static void begin_queued_endpoint(pocketjs_net_http_client_core_t *core) { + if (core->state != CORE_ENDPOINT_READY || core->event_state != EVENT_EMPTY || + core->transport_active || core->completion_retire_pending || + core->connection_valid || core->terminal_selected) { + return; + } + if (!begin_current_endpoint(core)) { + progress_terminal(core); + } +} + +static pocketjs_net_http_client_transport_result_t +start_write(pocketjs_net_http_client_core_t *core, const uint8_t *bytes, + size_t length, core_state_t state) { + uint64_t token = 0U; + if (!next_transport_token(core, &token)) { + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_RESOURCE_LIMIT; + } + pocketjs_net_http_client_transport_result_t result = + core->config.transport_ops->start_write(core->config.transport_context, + token, core->connection, bytes, + length, core->total_deadline_us); + if (result == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + core->write_length = length; + core->transport_active = true; + core->transport_cancel_requested = false; + core->transport_operation_kind = TRANSPORT_OPERATION_WRITE; + core->transport_operation_token = token; + core->state = state; + } + return result; +} + +static bool emit_next_request_head(pocketjs_net_http_client_core_t *core) { + size_t length = 0U; + pocketjs_net_http1_encoder_result_t encoder_result = + pocketjs_net_http1_request_encoder_write( + &core->encoder, core->write_bytes, sizeof(core->write_bytes), + &length); + if (encoder_result == POCKETJS_NET_HTTP1_ENCODER_ERROR || length == 0U) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_PROTOCOL, + (int32_t)core->encoder.phase); + return false; + } + core->encoder_done = encoder_result == POCKETJS_NET_HTTP1_ENCODER_DONE; + core->write_length = length; + pocketjs_net_http_client_transport_result_t result = + start_write(core, core->write_bytes, length, CORE_WRITING_HEAD); + if (result != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + select_failure(core, + result == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_RESOURCE_LIMIT + ? POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT + : POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, + (int32_t)result); + return false; + } + return true; +} + +static bool initialize_parser(pocketjs_net_http_client_core_t *core) { + pocketjs_net_http1_response_callbacks_t callbacks = { + .on_status = response_on_status, + .on_header = response_on_header, + .on_headers_complete = response_on_headers_complete, + .on_body = response_on_body, + .on_complete = response_on_complete, + }; + bool response_to_head = + ascii_equal_case(core->method, core->method_length, "HEAD"); + pocketjs_net_http1_wire_error_t error = + pocketjs_net_http1_response_parser_init( + &core->parser, &pocketjs_net_http1_default_limits, &callbacks, core, + response_to_head); + return error == POCKETJS_NET_HTTP1_WIRE_ERROR_NONE; +} + +static bool start_read(pocketjs_net_http_client_core_t *core) { + if (core->transport_active || core->transport_read_lease_valid || + core->event_state != EVENT_EMPTY || core->terminal_selected) { + return true; + } + if (core->final_headers_seen && core->body_credit == 0U) { + return true; + } + uint64_t deadline = + core->final_headers_seen + ? earlier_deadline(core->idle_deadline_us, core->total_deadline_us) + : earlier_deadline(core->headers_deadline_us, + core->total_deadline_us); + if (core->now_us >= deadline) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TIMED_OUT, 0); + return false; + } + size_t maximum_bytes = core->final_headers_seen + ? core->body_credit + : POCKETJS_NET_HTTP_CLIENT_CORE_BODY_LEASE_BYTES; + if (maximum_bytes > POCKETJS_NET_HTTP_CLIENT_CORE_BODY_LEASE_BYTES) { + maximum_bytes = POCKETJS_NET_HTTP_CLIENT_CORE_BODY_LEASE_BYTES; + } + uint64_t token = 0U; + if (!next_transport_token(core, &token)) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT, 0); + return false; + } + pocketjs_net_http_client_transport_result_t result = + core->config.transport_ops->start_read(core->config.transport_context, + token, core->connection, + maximum_bytes, deadline); + if (result != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + select_failure(core, + result == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_RESOURCE_LIMIT + ? POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT + : POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, + (int32_t)result); + return false; + } + core->transport_read_maximum = maximum_bytes; + core->transport_active = true; + core->transport_cancel_requested = false; + core->transport_operation_kind = TRANSPORT_OPERATION_READ; + core->transport_operation_token = token; + core->state = CORE_READING; + return true; +} + +static bool begin_response_read(pocketjs_net_http_client_core_t *core) { + core->headers_deadline_us = earlier_deadline( + deadline_after(core->now_us, core->config.headers_timeout_us), + core->total_deadline_us); + core->state = CORE_READING; + return start_read(core); +} + +static void publish_body_if_any(pocketjs_net_http_client_core_t *core) { + if (core->body_byte_count == 0U || core->event_state != EVENT_EMPTY) { + return; + } + if (core->body_lease_generation == UINT64_MAX) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT, 0); + return; + } + ++core->body_lease_generation; + core->body_lease = (pocketjs_net_http_client_body_lease_t){ + .slot = 0U, + .generation = core->body_lease_generation, + }; + core->body_lease_active = true; + core->body_lease_released = false; + if (!publish_event(core, POCKETJS_NET_HTTP_CLIENT_EVENT_BODY)) { + core->body_lease_active = false; + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT, 0); + return; + } + core->event.detail.body.lease = core->body_lease; + core->event.detail.body.byte_count = core->body_byte_count; +} + +static void consume_retained_read(pocketjs_net_http_client_core_t *core) { + if (!core->transport_read_lease_valid || core->event_state != EVENT_EMPTY || + core->terminal_selected || + (core->final_headers_seen && core->body_credit == 0U)) { + return; + } + if (core->transport_read_bytes == NULL || + core->transport_read_offset > core->transport_read_length) { + (void)release_transport_read_lease(core); + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, 0); + return; + } + size_t remaining = core->transport_read_length - core->transport_read_offset; + size_t consumed = 0U; + size_t credit = core->headers_delivered ? core->body_credit : 0U; + pocketjs_net_http1_parse_result_t result = + pocketjs_net_http1_response_parser_feed(&core->parser, + core->transport_read_bytes + + core->transport_read_offset, + remaining, credit, &consumed); + core->transport_read_offset += consumed; + + if (result == POCKETJS_NET_HTTP1_PARSE_ERROR) { + int32_t cause = (int32_t)core->parser.error; + (void)release_transport_read_lease(core); + select_failure(core, + core->callback_error != 0 + ? core->callback_error + : POCKETJS_NET_HTTP_CLIENT_ERROR_PROTOCOL, + cause); + return; + } + if (core->redirect_pending) { + /* Redirect response bodies and trailers are intentionally abandoned. The + * current connection is never reused, so bytes already present in this + * lease can be discarded without becoming input to the next response. */ + core->transport_read_offset = core->transport_read_length; + core->body_credit = 0U; + core->body_byte_count = 0U; + if (!release_transport_read_lease(core)) { + core->redirect_pending = false; + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, 0); + return; + } + if (!start_redirect_close(core) && !core->terminal_selected) { + core->redirect_pending = false; + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, 0); + } + return; + } + if (core->force_no_body && core->final_headers_seen) { + if (core->transport_read_offset != core->transport_read_length) { + core->response_connection_reusable = false; + } + core->transport_read_offset = core->transport_read_length; + core->parser_complete = true; + result = POCKETJS_NET_HTTP1_PARSE_COMPLETE; + } + if (result == POCKETJS_NET_HTTP1_PARSE_COMPLETE) { + if (core->transport_read_eof_pending) { + core->response_connection_reusable = false; + } + if (core->transport_read_offset != core->transport_read_length) { + (void)release_transport_read_lease(core); + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_PROTOCOL, 0); + return; + } + (void)release_transport_read_lease(core); + core->parser_complete = true; + } else if (core->transport_read_offset == core->transport_read_length) { + bool eof = core->transport_read_eof_pending; + bool released = release_transport_read_lease(core); + if (eof && !core->terminal_selected && !core->parser_complete) { + pocketjs_net_http1_parse_result_t finish_result = + pocketjs_net_http1_response_parser_finish(&core->parser); + if (finish_result == POCKETJS_NET_HTTP1_PARSE_COMPLETE) { + core->parser_complete = true; + } else { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_PROTOCOL, + (int32_t)core->parser.error); + } + } else if (!released && !core->terminal_selected && + !core->parser_complete) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, 0); + } + } + + publish_body_if_any(core); + if (core->parser_complete && !core->redirect_pending && + core->event_state == EVENT_EMPTY) { + select_success(core); + } +} + +static void handle_resolved( + pocketjs_net_http_client_core_t *core, + const pocketjs_net_http_client_transport_completion_t *completion) { + size_t count = completion->detail.resolved.candidate_count; + if (count == 0U || count > POCKETJS_NET_HTTP_CLIENT_CORE_MAX_DNS_CANDIDATES) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_DNS, 0); + return; + } + bool allowed[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_DNS_CANDIDATES] = {false}; + for (size_t index = 0U; index < count; ++index) { + pocketjs_net_http_client_endpoint_t endpoint = { + .phase = POCKETJS_NET_HTTP_CLIENT_PERMISSION_NUMERIC_CANDIDATE, + .scheme = core->scheme, + .hostname = core->hostname, + .port = core->port, + .ipv4_be = completion->detail.resolved.ipv4_be[index], + }; + if (!invoke_permission(core, &endpoint, CORE_RESOLVING, &allowed[index])) { + return; + } + } + core->connect_candidate_count = 0U; + core->next_connect_candidate = 0U; + for (size_t index = 0U; index < count; ++index) { + if (allowed[index]) { + core->connect_candidates[core->connect_candidate_count++] = + completion->detail.resolved.ipv4_be[index]; + } + } + if (core->connect_candidate_count == 0U) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_PERMISSION_DENIED, 0); + return; + } + pocketjs_net_http_client_transport_result_t result = + start_next_connect_candidate(core); + if (result != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + select_failure(core, + result == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_RESOURCE_LIMIT + ? POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT + : POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, + (int32_t)result); + } +} + +static void discard_completion_payload( + pocketjs_net_http_client_core_t *core, + const pocketjs_net_http_client_transport_completion_t *completion) { + if (completion->type == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_READ && + completion->detail.read.byte_count != 0U) { + (void)cleanup_completion_read_lease(core, completion->detail.read.lease); + } +} + +static void handle_transport_completion( + pocketjs_net_http_client_core_t *core, + const pocketjs_net_http_client_transport_completion_t *completion) { + transport_operation_kind_t completed_kind = core->transport_operation_kind; + const core_state_t completed_state = core->state; + core->transport_active = false; + core->transport_cancel_requested = false; + core->transport_operation_kind = TRANSPORT_OPERATION_NONE; + + /* A terminal event may have been retired while poisoned native cleanup was + * still outstanding. Late completion is cleanup-only: it must never restart + * the state machine or publish an operation-token-zero event. */ + if (core->operation_token == 0U) { + if (completion->type == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_READ) { + discard_completion_payload(core, completion); + } + if (completed_kind == TRANSPORT_OPERATION_CLOSE) { + if (completion->type == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_CLOSED && + completion->detail.closed.connection.slot == core->connection.slot && + completion->detail.closed.connection.generation == + core->connection.generation) { + core->connection_valid = false; + } else { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_COMPLETION, + completion->type == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR + ? completion->detail.error.cause_code + : 0); + } + core->connection_reusable = false; + core->connection_idle_deadline_us = 0U; + if (completed_state == CORE_IDLE_CONNECTION_CLOSING) { + core->state = CORE_IDLE; + } + } else if (completion->type == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR && + (completed_kind == TRANSPORT_OPERATION_CONNECT || + completed_kind == TRANSPORT_OPERATION_READ || + completed_kind == TRANSPORT_OPERATION_WRITE)) { + core->connection_valid = false; + } + return; + } + + if (completion->type == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_READ && + completed_kind != TRANSPORT_OPERATION_READ) { + discard_completion_payload(core, completion); + } + + if (completion->type == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR && + completed_kind == TRANSPORT_OPERATION_CONNECT && + completion->detail.error.code == + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_CONNECT && + !core->terminal_selected && + core->next_connect_candidate < core->connect_candidate_count && + core->now_us < core->connect_deadline_us) { + core->connection_valid = false; + const pocketjs_net_http_client_transport_result_t retry_result = + start_next_connect_candidate(core); + if (retry_result == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + return; + } + select_failure(core, + retry_result == + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_RESOURCE_LIMIT + ? POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT + : POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, + (int32_t)retry_result); + return; + } + + if (completion->type == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR) { + if (completed_kind == TRANSPORT_OPERATION_CONNECT || + completed_kind == TRANSPORT_OPERATION_READ || + completed_kind == TRANSPORT_OPERATION_WRITE) { + core->connection_valid = false; + } + if (!core->terminal_selected) { + select_failure(core, map_transport_error(completion->detail.error.code), + completion->detail.error.cause_code); + } + if (completed_kind == TRANSPORT_OPERATION_CLOSE) { + core->redirect_pending = false; + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_COMPLETION, + completion->detail.error.cause_code); + core->connection_reusable = false; + core->connection_idle_deadline_us = 0U; + if (completed_state == CORE_IDLE_CONNECTION_CLOSING) { + core->state = CORE_IDLE; + return; + } + if (!core->terminal_selected) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, + completion->detail.error.cause_code); + } + (void)publish_terminal(core); + } + return; + } + + if (core->terminal_selected && completed_kind != TRANSPORT_OPERATION_CLOSE) { + if (completed_kind == TRANSPORT_OPERATION_READ) { + discard_completion_payload(core, completion); + } + return; + } + + switch (completed_kind) { + case TRANSPORT_OPERATION_RESOLVE: + if (completion->type != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_RESOLVED) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, 0); + return; + } + handle_resolved(core, completion); + break; + case TRANSPORT_OPERATION_CONNECT: + if (completion->type != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_CONNECTED || + completion->detail.connected.tls != + (core->scheme == POCKETJS_NET_HTTP_CLIENT_SCHEME_HTTPS) || + completion->detail.connected.ipv4_be != core->selected_ipv4_be || + completion->detail.connected.connection.generation == 0U) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, 0); + return; + } + core->connection = completion->detail.connected.connection; + core->connection_valid = true; + core->connection_reusable = false; + core->connection_idle_deadline_us = 0U; + core->connection_scheme = core->scheme; + memcpy(core->connection_hostname, core->hostname, + strlen(core->hostname) + 1U); + core->connection_port = core->port; + core->connection_ipv4_be = core->selected_ipv4_be; + core->connect_candidate_count = 0U; + core->next_connect_candidate = 0U; + core->headers_deadline_us = earlier_deadline( + deadline_after(core->now_us, core->config.headers_timeout_us), + core->total_deadline_us); + if (!initialize_parser(core)) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_PROTOCOL, 0); + return; + } + (void)emit_next_request_head(core); + break; + case TRANSPORT_OPERATION_WRITE: + if (completion->type != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_WRITTEN || + completion->detail.written.byte_count != core->write_length || + completion->detail.written.connection.slot != core->connection.slot || + completion->detail.written.connection.generation != + core->connection.generation) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, 0); + return; + } + if (core->state == CORE_WRITING_HEAD) { + if (!core->encoder_done) { + (void)emit_next_request_head(core); + } else if (core->request_body_kind == + POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_FIXED && + core->request_body_length != 0U) { + pocketjs_net_http_client_transport_result_t result = + start_write(core, core->request_body, core->request_body_length, + CORE_WRITING_BODY); + if (result != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + select_failure( + core, + result == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_RESOURCE_LIMIT + ? POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT + : POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, + (int32_t)result); + } + } else if (core->request_body_kind == + POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_STREAMING && + (!core->request_body_length_known || + core->request_body_expected_length != 0U)) { + core->state = CORE_WAITING_REQUEST_BODY; + (void)publish_request_body_pull(core); + } else { + (void)begin_response_read(core); + } + } else if (core->state == CORE_WRITING_BODY) { + (void)begin_response_read(core); + } else if (core->state == CORE_WRITING_REQUEST_BODY_CHUNK) { + core->request_body_pending_payload_length = 0U; + if (core->request_body_length_known && + core->request_body_submitted_length == + core->request_body_expected_length) { + (void)begin_response_read(core); + } else { + core->state = CORE_WAITING_REQUEST_BODY; + (void)publish_request_body_pull(core); + } + } else if (core->state == CORE_WRITING_REQUEST_BODY_END) { + (void)begin_response_read(core); + } else { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, 0); + } + break; + case TRANSPORT_OPERATION_READ: + if (completion->type != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_READ || + completion->detail.read.connection.slot != core->connection.slot || + completion->detail.read.connection.generation != + core->connection.generation || + completion->detail.read.byte_count > core->transport_read_maximum || + (completion->detail.read.byte_count != 0U && + completion->detail.read.lease.generation == 0U) || + (completion->detail.read.byte_count == 0U && + !completion->detail.read.eof)) { + discard_completion_payload(core, completion); + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, 0); + return; + } + if (completion->detail.read.byte_count != 0U) { + const uint8_t *bytes = NULL; + size_t capacity = 0U; + core->transport_read_lease = completion->detail.read.lease; + core->transport_read_lease_valid = true; + core->transport_read_bytes = NULL; + core->transport_read_length = completion->detail.read.byte_count; + core->transport_read_offset = 0U; + core->transport_read_eof_pending = completion->detail.read.eof; + if (core->config.transport_ops->read_lease_view( + core->config.transport_context, completion->detail.read.lease, + &bytes, &capacity) != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK || + bytes == NULL || capacity < completion->detail.read.byte_count) { + (void)release_transport_read_lease(core); + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, 0); + return; + } + core->transport_read_bytes = bytes; + core->idle_deadline_us = earlier_deadline( + deadline_after(core->now_us, core->config.idle_timeout_us), + core->total_deadline_us); + consume_retained_read(core); + } else if (completion->detail.read.eof && !core->terminal_selected && + !core->parser_complete) { + core->response_connection_reusable = false; + pocketjs_net_http1_parse_result_t result = + pocketjs_net_http1_response_parser_finish(&core->parser); + if (result == POCKETJS_NET_HTTP1_PARSE_COMPLETE) { + core->parser_complete = true; + if (!core->redirect_pending && core->event_state == EVENT_EMPTY && + core->body_byte_count == 0U) { + select_success(core); + } + } else { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_PROTOCOL, + (int32_t)core->parser.error); + } + } + break; + case TRANSPORT_OPERATION_CLOSE: + if (completion->type == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_CLOSED && + completion->detail.closed.connection.slot == core->connection.slot && + completion->detail.closed.connection.generation == + core->connection.generation) { + core->connection_valid = false; + } else { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_COMPLETION, 0); + } + if (completion->type != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_CLOSED || + completion->detail.closed.connection.slot != core->connection.slot || + completion->detail.closed.connection.generation != + core->connection.generation) { + core->connection_valid = true; + } + core->connection_reusable = false; + core->connection_idle_deadline_us = 0U; + if (completed_state == CORE_IDLE_CONNECTION_CLOSING) { + core->state = CORE_IDLE; + } else if (completed_state == CORE_REPLACING_CONNECTION && + !core->terminal_selected && !core->connection_valid) { + core->state = CORE_ENDPOINT_READY; + } else if (core->redirect_pending && !core->terminal_selected && + !core->connection_valid) { + core->state = CORE_REDIRECT_READY; + } else { + (void)publish_terminal(core); + } + break; + case TRANSPORT_OPERATION_NONE: + default: + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_STALE_COMPLETION, 0); + if (core->state != CORE_IDLE) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, 0); + } + break; + } +} + +pocketjs_net_http_client_start_result_t pocketjs_net_http_client_core_init( + pocketjs_net_http_client_core_storage_t *storage, + const pocketjs_net_http_client_core_config_t *config, + pocketjs_net_http_client_core_t **out_core) { + if (out_core != NULL) { + *out_core = NULL; + } + if (storage == NULL || out_core == NULL || !valid_config(config)) { + return POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT; + } + uint64_t existing_magic = 0U; + memcpy(&existing_magic, storage->bytes, sizeof(existing_magic)); + if (existing_magic == CORE_MAGIC) { + return POCKETJS_NET_HTTP_CLIENT_START_BUSY; + } + memset(storage->bytes, 0, sizeof(storage->bytes)); + pocketjs_net_http_client_core_t *core = (void *)storage->bytes; + core->config = *config; + if (core->config.response_header_bytes_limit == 0U) { + core->config.response_header_bytes_limit = + POCKETJS_NET_HTTP_CLIENT_CORE_MAX_RESPONSE_HEADER_BYTES; + } + core->lifecycle_generation = 1U; + core->magic = CORE_MAGIC; + *out_core = core; + return POCKETJS_NET_HTTP_CLIENT_START_OK; +} + +pocketjs_net_http_client_start_result_t pocketjs_net_http_client_core_start( + pocketjs_net_http_client_core_t *core, + const pocketjs_net_http_client_request_t *request, uint64_t now_us) { + if (!core_is_live(core) || request == NULL || now_us == 0U) { + return POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT; + } + if (core->permission_callback_active) { + return POCKETJS_NET_HTTP_CLIENT_START_REENTRANT; + } + if (core->shutdown_requested) { + return POCKETJS_NET_HTTP_CLIENT_START_SHUTTING_DOWN; + } + if (core->poison_flags != 0U) { + return POCKETJS_NET_HTTP_CLIENT_START_POISONED; + } + if (core->state != CORE_IDLE || core->event_state != EVENT_EMPTY) { + return POCKETJS_NET_HTTP_CLIENT_START_BUSY; + } + if (core->lifecycle_generation == UINT64_MAX || + core->event_sequence == UINT64_MAX) { + return POCKETJS_NET_HTTP_CLIENT_START_TOKEN_EXHAUSTED; + } + + pocketjs_net_http_client_start_result_t result = + snapshot_request(core, request); + if (result != POCKETJS_NET_HTTP_CLIENT_START_OK) { + return result; + } + if (core->request_body_kind == + POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_STREAMING) { + ++core->last_request_body_generation; + core->request_body_generation = core->last_request_body_generation; + } else { + core->request_body_generation = 0U; + } + core->request_body_pull_generation = 0U; + core->last_operation_token = request->operation_token; + core->operation_token = request->operation_token; + ++core->lifecycle_generation; + core->now_us = now_us; + core->total_deadline_us = + deadline_after(now_us, core->config.total_timeout_us); + + if (!begin_current_endpoint(core)) { + progress_terminal(core); + } + return POCKETJS_NET_HTTP_CLIENT_START_OK; +} + +bool pocketjs_net_http_client_core_abort( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_operation_token_t operation_token) { + if (!core_public_entry_allowed(core) || core->state == CORE_IDLE || + operation_token != core->operation_token || core->terminal_selected) { + return false; + } + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_ABORTED, 0); + progress_terminal(core); + return true; +} + +bool pocketjs_net_http_client_core_pump(pocketjs_net_http_client_core_t *core, + uint64_t now_us, + size_t max_native_steps, + size_t max_transport_completions) { + if (!core_public_entry_allowed(core) || now_us == 0U || + (max_native_steps == 0U && max_transport_completions == 0U)) { + return false; + } + core->now_us = now_us; + + if (core->state == CORE_IDLE && core->connection_valid && + core->connection_reusable && core->connection_idle_deadline_us != 0U && + now_us >= core->connection_idle_deadline_us) { + (void)start_idle_connection_close(core); + } + + if (core->completion_retire_pending) { + pocketjs_net_http_client_transport_result_t retry_result = + core->config.transport_ops->retire_completion( + core->config.transport_context, core->completion_retire_token); + if (retry_result == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + core->completion_retire_pending = false; + core->completion_retire_token = 0U; + } else { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_COMPLETION_RETIRE, + (int32_t)retry_result); + } + } + + if (core->transport_read_lease_valid && + (core->terminal_selected || core->shutdown_requested || + core->transport_read_offset == core->transport_read_length)) { + (void)release_transport_read_lease(core); + } + (void)release_orphan_read_lease(core); + + if (core->state != CORE_IDLE && !core->terminal_selected && + now_us >= core->total_deadline_us) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TIMED_OUT, 0); + } + + if (core->transport_active && + core->transport_operation_kind == TRANSPORT_OPERATION_CLOSE && + now_us >= core->close_deadline_us && !core->close_cancel_requested) { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_TIMEOUT, 0); + pocketjs_net_http_client_transport_result_t cancel_result = + core->config.transport_ops->cancel(core->config.transport_context, + core->transport_operation_token); + if (cancel_result == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + core->close_cancel_requested = true; + } else { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_CANCEL, + (int32_t)cancel_result); + if (core->terminal_selected) { + (void)publish_terminal(core); + } + } + } + if (core->terminal_selected) { + progress_terminal(core); + } + + if (max_native_steps != 0U) { + pocketjs_net_http_client_transport_result_t pump_result = + core->config.transport_ops->pump(core->config.transport_context, now_us, + max_native_steps); + if (pump_result != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_TRANSPORT_PUMP, + (int32_t)pump_result); + if (core->state != CORE_IDLE && !core->terminal_selected) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, + (int32_t)pump_result); + } + } + } + + for (size_t index = 0U; + index < max_transport_completions && !core->completion_retire_pending && + !core->transport_read_lease_valid && !core->orphan_read_lease_valid; + ++index) { + pocketjs_net_http_client_transport_completion_t completion; + pocketjs_net_http_client_transport_result_t take_result = + core->config.transport_ops->take_completion( + core->config.transport_context, &completion); + if (take_result == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_EMPTY) { + break; + } + if (take_result != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_COMPLETION_TAKE, + (int32_t)take_result); + if (core->state != CORE_IDLE && !core->terminal_selected) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, + (int32_t)take_result); + } + break; + } + if (!core->transport_active || + completion.operation_token != core->transport_operation_token) { + discard_completion_payload(core, &completion); + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_STALE_COMPLETION, 0); + if (core->state != CORE_IDLE && !core->terminal_selected) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, 0); + } + } else { + handle_transport_completion(core, &completion); + } + pocketjs_net_http_client_transport_result_t retire_result = + core->config.transport_ops->retire_completion( + core->config.transport_context, completion.operation_token); + if (retire_result != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + core->completion_retire_pending = true; + core->completion_retire_token = completion.operation_token; + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_COMPLETION_RETIRE, + (int32_t)retire_result); + if (core->state != CORE_IDLE && !core->terminal_selected) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, 0); + } + break; + } + } + + begin_redirect_hop(core); + begin_queued_endpoint(core); + + if (!core->terminal_selected && core->state == CORE_READING && + core->event_state == EVENT_EMPTY) { + consume_retained_read(core); + if (!core->terminal_selected && core->event_state == EVENT_EMPTY && + !core->transport_read_lease_valid && core->poison_flags == 0U) { + (void)start_read(core); + } + } + if (core->parser_complete && !core->redirect_pending && + !core->terminal_selected && core->event_state == EVENT_EMPTY && + !core->body_lease_active) { + select_success(core); + } + progress_terminal(core); + return true; +} + +bool pocketjs_net_http_client_core_grant_body_credit( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_operation_token_t operation_token, + size_t maximum_bytes) { + if (!core_public_entry_allowed(core) || + operation_token != core->operation_token || !core->headers_delivered || + core->body_lease_active || core->body_credit != 0U || + maximum_bytes == 0U || + maximum_bytes > POCKETJS_NET_HTTP_CLIENT_CORE_BODY_LEASE_BYTES) { + return false; + } + + if (core->terminal_selected) { + const bool response_exposes_body = + !ascii_equal_case(core->method, core->method_length, "HEAD") && + core->response_status != 204U && core->response_status != 205U && + core->response_status != 304U; + if (!response_exposes_body || core->terminal_body_pull_active || + (core->terminal_success && !core->parser_complete)) { + return false; + } + /* + * A terminal may be selected after headers and before the Guest issues its + * next pull. Accept one final downstream pull while that terminal is + * closing or queued so the binding can wait for BODY_END/BODY_ERROR rather + * than replacing the selected outcome with invalid_state. For successful + * responses this is also the EOF pull after the last non-empty body lease. + * Dedicated one-shot state rejects duplicates until terminal retirement + * resets the operation. + */ + core->terminal_body_pull_active = true; + return true; + } + + if (core->event_state != EVENT_EMPTY) { + return false; + } + core->body_credit = maximum_bytes; + core->body_byte_count = 0U; + return true; +} + +static bool request_body_credit_matches( + const pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_operation_token_t operation_token, + uint64_t body_generation, uint64_t pull_generation) { + return core->request_body_kind == + POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_STREAMING && + core->state == CORE_WAITING_REQUEST_BODY && !core->terminal_selected && + core->event_state == EVENT_EMPTY && core->request_body_pull_active && + core->request_body_pull_event_retired && + operation_token == core->operation_token && body_generation != 0U && + body_generation == core->request_body_generation && + pull_generation != 0U && + pull_generation == core->request_body_pull_generation; +} + +static void consume_request_body_credit(pocketjs_net_http_client_core_t *core) { + core->request_body_pull_active = false; + core->request_body_pull_event_retired = false; + core->request_body_pull_maximum = 0U; +} + +static size_t encode_chunked_request_body(pocketjs_net_http_client_core_t *core, + size_t payload_length) { + static const uint8_t hex[] = "0123456789abcdef"; + uint8_t reversed[sizeof(size_t) * 2U]; + size_t reversed_length = 0U; + size_t value = payload_length; + do { + reversed[reversed_length++] = hex[value & 0xfU]; + value >>= 4U; + } while (value != 0U); + size_t offset = 0U; + while (reversed_length != 0U) { + core->write_bytes[offset++] = reversed[--reversed_length]; + } + core->write_bytes[offset++] = '\r'; + core->write_bytes[offset++] = '\n'; + memmove(core->write_bytes + offset, core->request_body, payload_length); + offset += payload_length; + core->write_bytes[offset++] = '\r'; + core->write_bytes[offset++] = '\n'; + return offset; +} + +bool pocketjs_net_http_client_core_submit_request_body_chunk( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_operation_token_t operation_token, + uint64_t body_generation, uint64_t pull_generation, const uint8_t *bytes, + size_t length) { + if (!core_public_entry_allowed(core) || bytes == NULL || length == 0U || + length > POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES || + !request_body_credit_matches(core, operation_token, body_generation, + pull_generation) || + length > core->request_body_pull_maximum) { + return false; + } + if (core->request_body_length_known && + (core->request_body_submitted_length > + core->request_body_expected_length || + (uint64_t)length > core->request_body_expected_length - + core->request_body_submitted_length)) { + return false; + } + memmove(core->request_body, bytes, length); + consume_request_body_credit(core); + if ((uint64_t)length > UINT64_MAX - core->request_body_submitted_length) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT, 0); + progress_terminal(core); + return true; + } + core->request_body_submitted_length += (uint64_t)length; + core->request_body_pending_payload_length = length; + const uint8_t *wire_bytes = core->request_body; + size_t wire_length = length; + if (!core->request_body_length_known) { + wire_length = encode_chunked_request_body(core, length); + wire_bytes = core->write_bytes; + } + pocketjs_net_http_client_transport_result_t result = start_write( + core, wire_bytes, wire_length, CORE_WRITING_REQUEST_BODY_CHUNK); + if (result != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + select_failure(core, + result == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_RESOURCE_LIMIT + ? POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT + : POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, + (int32_t)result); + progress_terminal(core); + } + return true; +} + +bool pocketjs_net_http_client_core_submit_request_body_end( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_operation_token_t operation_token, + uint64_t body_generation, uint64_t pull_generation) { + if (!core_public_entry_allowed(core) || + !request_body_credit_matches(core, operation_token, body_generation, + pull_generation)) { + return false; + } + consume_request_body_credit(core); + if (core->request_body_length_known) { + select_failure( + core, POCKETJS_NET_HTTP_CLIENT_ERROR_REQUEST_BODY, + POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_CAUSE_LENGTH_UNDERFLOW); + progress_terminal(core); + return true; + } + static const uint8_t terminal_chunk[] = "0\r\n\r\n"; + memcpy(core->write_bytes, terminal_chunk, sizeof(terminal_chunk) - 1U); + pocketjs_net_http_client_transport_result_t result = + start_write(core, core->write_bytes, sizeof(terminal_chunk) - 1U, + CORE_WRITING_REQUEST_BODY_END); + if (result != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + select_failure(core, + result == POCKETJS_NET_HTTP_CLIENT_TRANSPORT_RESOURCE_LIMIT + ? POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT + : POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, + (int32_t)result); + progress_terminal(core); + } + return true; +} + +bool pocketjs_net_http_client_core_submit_request_body_error( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_operation_token_t operation_token, + uint64_t body_generation, uint64_t pull_generation, int32_t cause_code) { + if (!core_public_entry_allowed(core) || + !request_body_credit_matches(core, operation_token, body_generation, + pull_generation)) { + return false; + } + consume_request_body_credit(core); + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_REQUEST_BODY, cause_code); + progress_terminal(core); + return true; +} + +bool pocketjs_net_http_client_core_take_event( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_event_t *out_event) { + if (!core_public_entry_allowed(core) || out_event == NULL || + core->event_state != EVENT_PENDING) { + return false; + } + *out_event = core->event; + core->event_state = EVENT_DELIVERING; + return true; +} + +static void reset_after_terminal(pocketjs_net_http_client_core_t *core) { + core->state = CORE_IDLE; + core->operation_token = 0U; + core->request_body_kind = POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_NONE; + core->request_body_length = 0U; + core->request_body_length_known = false; + core->request_body_expected_length = 0U; + core->request_body_submitted_length = 0U; + core->request_body_generation = 0U; + core->request_body_pull_generation = 0U; + core->request_body_pull_maximum = 0U; + core->request_body_pending_payload_length = 0U; + core->request_body_pull_active = false; + core->request_body_pull_event_retired = false; + core->body_credit = 0U; + core->terminal_body_pull_active = false; + core->body_byte_count = 0U; + core->body_lease_active = false; + core->body_lease_released = false; + core->terminal_selected = false; + core->terminal_success = false; + core->terminal_error = 0; + core->terminal_cause = 0; + core->final_headers_seen = false; + core->headers_delivered = false; + core->parser_complete = false; + core->force_no_body = false; + core->callback_error = 0; + core->response_http_minor = 0U; + core->response_body_kind = POCKETJS_NET_HTTP1_RESPONSE_BODY_NONE; + core->response_connection_close = false; + core->response_connection_reusable = false; + core->redirect_location_seen = false; + core->redirect_location = (pocketjs_net_http_client_slice_t){0}; + core->redirect_pending = false; + core->redirect_mode = POCKETJS_NET_HTTP_CLIENT_REDIRECT_MANUAL; + core->redirect_count = 0U; + core->max_redirects = 0U; + core->connect_candidate_count = 0U; + core->next_connect_candidate = 0U; +} + +bool pocketjs_net_http_client_core_retire_event( + pocketjs_net_http_client_core_t *core, uint64_t sequence) { + const bool revoked_request_pull = + core_public_entry_allowed(core) && + core->event_state == EVENT_DELIVERING && + core->event.type == POCKETJS_NET_HTTP_CLIENT_EVENT_REQUEST_BODY_PULL && + !core->request_body_pull_active && core->terminal_selected; + if (!core_public_entry_allowed(core) || + core->event_state != EVENT_DELIVERING || + sequence != core->event.sequence || + (core->event.type == POCKETJS_NET_HTTP_CLIENT_EVENT_BODY && + !core->body_lease_released) || + (core->event.type == POCKETJS_NET_HTTP_CLIENT_EVENT_REQUEST_BODY_PULL && + !revoked_request_pull && + (!core->request_body_pull_active || + core->event.detail.request_body_pull.body_generation != + core->request_body_generation || + core->event.detail.request_body_pull.pull_generation != + core->request_body_pull_generation))) { + return false; + } + pocketjs_net_http_client_event_type_t type = core->event.type; + core->event_state = EVENT_EMPTY; + if (type == POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS) { + core->headers_delivered = true; + } else if (type == POCKETJS_NET_HTTP_CLIENT_EVENT_BODY) { + core->body_lease_active = false; + core->body_lease_released = false; + core->body_byte_count = 0U; + core->body_credit = 0U; + } else if (type == POCKETJS_NET_HTTP_CLIENT_EVENT_REQUEST_BODY_PULL) { + if (revoked_request_pull) { + progress_terminal(core); + return true; + } + core->request_body_pull_event_retired = true; + return true; + } else { + reset_after_terminal(core); + if (core->shutdown_requested && core->connection_valid) { + (void)start_idle_connection_close(core); + } + return true; + } + if (core->parser_complete && !core->body_lease_active) { + select_success(core); + } + progress_terminal(core); + return true; +} + +bool pocketjs_net_http_client_core_body_lease_view( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_body_lease_t lease, const uint8_t **out_bytes, + size_t *out_length) { + if (out_bytes != NULL) { + *out_bytes = NULL; + } + if (out_length != NULL) { + *out_length = 0U; + } + if (!core_public_entry_allowed(core) || out_bytes == NULL || + out_length == NULL || !core->body_lease_active || + core->body_lease_released || lease.slot != core->body_lease.slot || + lease.generation != core->body_lease.generation) { + return false; + } + *out_bytes = core->body_bytes; + *out_length = core->body_byte_count; + return true; +} + +bool pocketjs_net_http_client_core_release_body_lease( + pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_body_lease_t lease) { + if (!core_public_entry_allowed(core) || !core->body_lease_active || + core->body_lease_released || lease.slot != core->body_lease.slot || + lease.generation != core->body_lease.generation) { + return false; + } + core->body_lease_released = true; + return true; +} + +bool pocketjs_net_http_client_core_get_status( + const pocketjs_net_http_client_core_t *core, + pocketjs_net_http_client_core_status_t *out_status) { + if (!core_public_entry_allowed(core) || out_status == NULL) { + return false; + } + *out_status = (pocketjs_net_http_client_core_status_t){ + .initialized = true, + .shutdown_requested = core->shutdown_requested, + .poisoned = core->poison_flags != 0U, + .quiescent = core_is_quiescent_internal(core), + .request_active = core->operation_token != 0U, + .transport_operation_active = core->transport_active, + .connection_owned = core->connection_valid, + .completion_retire_pending = core->completion_retire_pending, + .event_outstanding = core->event_state != EVENT_EMPTY, + .request_body_credit_outstanding = core->request_body_pull_active, + .connection_reusable = + core->connection_valid && core->connection_reusable, + .transport_read_leases_owned = owned_transport_read_lease_count(core), + .poison_flags = core->poison_flags, + .first_poison_cause_code = core->first_poison_cause_code, + .lifecycle_generation = core->lifecycle_generation, + .operation_token = core->operation_token, + .request_body_generation = core->request_body_generation, + .request_body_pull_generation = core->request_body_pull_generation, + }; + return true; +} + +bool pocketjs_net_http_client_core_begin_shutdown( + pocketjs_net_http_client_core_t *core, uint64_t now_us) { + if (!core_public_entry_allowed(core) || now_us == 0U) { + return false; + } + core->shutdown_requested = true; + core->now_us = now_us; + if (core->operation_token != 0U && !core->terminal_selected) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_ABORTED, 0); + } + progress_terminal(core); + if (core->operation_token == 0U && core->state == CORE_IDLE && + core->connection_valid) { + (void)start_idle_connection_close(core); + } + return true; +} + +bool pocketjs_net_http_client_core_is_quiescent( + const pocketjs_net_http_client_core_t *core) { + return core_public_entry_allowed(core) && core_is_quiescent_internal(core); +} + +bool pocketjs_net_http_client_core_confirm_transport_shutdown( + pocketjs_net_http_client_core_t *core) { + if (!core_public_entry_allowed(core) || !core->shutdown_requested) { + return false; + } + core->transport_active = false; + core->transport_operation_kind = TRANSPORT_OPERATION_NONE; + core->transport_operation_token = 0U; + core->transport_cancel_requested = false; + core->close_cancel_requested = false; + core->connection_valid = false; + core->connection_reusable = false; + core->connection_idle_deadline_us = 0U; + core->transport_read_lease_valid = false; + core->transport_read_bytes = NULL; + core->transport_read_length = 0U; + core->transport_read_offset = 0U; + core->transport_read_eof_pending = false; + core->orphan_read_lease_valid = false; + core->completion_retire_pending = false; + core->completion_retire_token = 0U; + core->transport_shutdown_confirmed = true; + if (core->operation_token == 0U && + core->state == CORE_IDLE_CONNECTION_CLOSING) { + core->state = CORE_IDLE; + } + progress_terminal(core); + return true; +} + +bool pocketjs_net_http_client_core_report_host_event_retire_failure( + pocketjs_net_http_client_core_t *core, uint64_t sequence) { + if (!core_public_entry_allowed(core) || + core->event_state != EVENT_DELIVERING || + core->event.sequence != sequence) { + return false; + } + poison_core(core, POCKETJS_NET_HTTP_CLIENT_POISON_HOST_EVENT_RETIRE, 0); + if (core->operation_token != 0U && !core->terminal_selected) { + select_failure(core, POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT, 0); + } + return true; +} + +bool pocketjs_net_http_client_core_abandon_event_after_transport_shutdown( + pocketjs_net_http_client_core_t *core, uint64_t sequence) { + if (!core_public_entry_allowed(core) || !core->shutdown_requested || + core->poison_flags == 0U || !core->transport_shutdown_confirmed || + core->event_state != EVENT_DELIVERING || + core->event.sequence != sequence) { + return false; + } + + const pocketjs_net_http_client_event_type_t type = core->event.type; + core->event_state = EVENT_EMPTY; + memset(&core->event, 0, sizeof(core->event)); + if (type == POCKETJS_NET_HTTP_CLIENT_EVENT_BODY) { + core->body_lease_active = false; + core->body_lease_released = false; + core->body_lease = (pocketjs_net_http_client_body_lease_t){0}; + core->body_byte_count = 0U; + core->body_credit = 0U; + } else if (type == POCKETJS_NET_HTTP_CLIENT_EVENT_REQUEST_BODY_PULL) { + revoke_request_body_credit(core); + } else if (type == POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE || + type == POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR) { + reset_after_terminal(core); + return true; + } + + /* begin_shutdown selected the aborted terminal before transport teardown. + * With native ownership confirmed absent, this can only publish that retained + * terminal; it cannot call the destroyed transport. */ + progress_terminal(core); + return true; +} + +bool pocketjs_net_http_client_core_deinit( + pocketjs_net_http_client_core_t *core) { + if (!core_public_entry_allowed(core) || !core->shutdown_requested || + !core_is_quiescent_internal(core)) { + return false; + } + memset(core, 0, sizeof(*core)); + return true; +} diff --git a/hosts/esp-idf/components/pocketjs_net_http_client_core/src/http_client_core_esp.c b/hosts/esp-idf/components/pocketjs_net_http_client_core/src/http_client_core_esp.c new file mode 100644 index 00000000..6709a5cc --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http_client_core/src/http_client_core_esp.c @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: MIT + +#include "pocketjs/net/http_client_core_esp.h" + +#include + +_Static_assert(POCKETJS_NET_ESP_TRANSPORT_MAX_DNS_CANDIDATES == + POCKETJS_NET_HTTP_CLIENT_CORE_MAX_DNS_CANDIDATES, + "ESP transport and HTTP Core candidate bounds must match"); + +static pocketjs_net_http_client_transport_result_t map_result(esp_err_t error) { + switch (error) { + case ESP_OK: + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK; + case ESP_ERR_NOT_FOUND: + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_EMPTY; + case ESP_ERR_INVALID_ARG: + case ESP_ERR_INVALID_STATE: + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_INVALID; + case ESP_ERR_NO_MEM: + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_RESOURCE_LIMIT; + default: + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_FAILED; + } +} + +static pocketjs_net_http_client_transport_connection_t +map_connection(pocketjs_net_esp_connection_t connection) { + return (pocketjs_net_http_client_transport_connection_t){ + .slot = connection.slot, + .generation = connection.generation, + }; +} + +static pocketjs_net_esp_connection_t +map_esp_connection(pocketjs_net_http_client_transport_connection_t connection) { + return (pocketjs_net_esp_connection_t){ + .slot = connection.slot, + .generation = connection.generation, + }; +} + +static pocketjs_net_http_client_transport_read_lease_t +map_lease(pocketjs_net_esp_read_lease_t lease) { + return (pocketjs_net_http_client_transport_read_lease_t){ + .slot = lease.slot, + .generation = lease.generation, + }; +} + +static pocketjs_net_esp_read_lease_t +map_esp_lease(pocketjs_net_http_client_transport_read_lease_t lease) { + return (pocketjs_net_esp_read_lease_t){ + .slot = lease.slot, + .generation = lease.generation, + }; +} + +static pocketjs_net_http_client_transport_error_t +map_error(pocketjs_net_esp_error_t error) { + switch (error) { + case POCKETJS_NET_ESP_ERROR_ABORTED: + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_ABORTED; + case POCKETJS_NET_ESP_ERROR_TIMED_OUT: + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TIMED_OUT; + case POCKETJS_NET_ESP_ERROR_DNS_NOT_FOUND: + case POCKETJS_NET_ESP_ERROR_DNS_FAILED: + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_DNS; + case POCKETJS_NET_ESP_ERROR_CONNECTION_REFUSED: + case POCKETJS_NET_ESP_ERROR_NETWORK_UNREACHABLE: + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_CONNECT; + case POCKETJS_NET_ESP_ERROR_CONNECTION_RESET: + case POCKETJS_NET_ESP_ERROR_TRANSPORT_FAILED: + case POCKETJS_NET_ESP_ERROR_CLOSED: + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_IO; + case POCKETJS_NET_ESP_ERROR_TLS_CERTIFICATE_INVALID: + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_CERTIFICATE_INVALID; + case POCKETJS_NET_ESP_ERROR_TLS_HOSTNAME_MISMATCH: + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_HOSTNAME_MISMATCH; + case POCKETJS_NET_ESP_ERROR_TLS_HANDSHAKE_FAILED: + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_HANDSHAKE_FAILED; + case POCKETJS_NET_ESP_ERROR_TLS_VERSION_UNSUPPORTED: + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_VERSION_UNSUPPORTED; + case POCKETJS_NET_ESP_ERROR_TLS_ALERT: + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_ALERT; + case POCKETJS_NET_ESP_ERROR_BUSY: + case POCKETJS_NET_ESP_ERROR_RESOURCE_LIMIT: + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_RESOURCE_LIMIT; + case POCKETJS_NET_ESP_ERROR_INVALID_ARGUMENT: + case POCKETJS_NET_ESP_ERROR_UNSUPPORTED: + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_INVALID; + case POCKETJS_NET_ESP_ERROR_NONE: + default: + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_NONE; + } +} + +static pocketjs_net_http_client_transport_result_t +esp_start_resolve(void *context, uint64_t token, const char *hostname, + uint64_t deadline_us) { + pocketjs_net_esp_resolve_request_t request = { + .hostname = hostname, + .deadline_us = deadline_us, + }; + return map_result( + pocketjs_net_esp_transport_start_resolve(context, token, &request)); +} + +static pocketjs_net_http_client_transport_result_t +esp_start_connect(void *context, uint64_t token, uint32_t ipv4_be, + uint16_t port, bool tls, const char *original_hostname, + uint64_t deadline_us) { + pocketjs_net_esp_connect_request_t request = { + .ipv4_be = ipv4_be, + .port = port, + .tls = tls, + .original_hostname = original_hostname, + .deadline_us = deadline_us, + }; + return map_result( + pocketjs_net_esp_transport_start_connect(context, token, &request)); +} + +static pocketjs_net_http_client_transport_result_t +esp_start_read(void *context, uint64_t token, + pocketjs_net_http_client_transport_connection_t connection, + size_t maximum_bytes, uint64_t deadline_us) { + pocketjs_net_esp_read_request_t request = { + .connection = map_esp_connection(connection), + .maximum_bytes = maximum_bytes, + .deadline_us = deadline_us, + }; + return map_result( + pocketjs_net_esp_transport_start_read(context, token, &request)); +} + +static pocketjs_net_http_client_transport_result_t +esp_start_write(void *context, uint64_t token, + pocketjs_net_http_client_transport_connection_t connection, + const uint8_t *bytes, size_t length, uint64_t deadline_us) { + pocketjs_net_esp_write_request_t request = { + .connection = map_esp_connection(connection), + .bytes = bytes, + .length = length, + .deadline_us = deadline_us, + }; + return map_result( + pocketjs_net_esp_transport_start_write(context, token, &request)); +} + +static pocketjs_net_http_client_transport_result_t +esp_start_close(void *context, uint64_t token, + pocketjs_net_http_client_transport_connection_t connection, + uint64_t deadline_us) { + pocketjs_net_esp_close_request_t request = { + .connection = map_esp_connection(connection), + .deadline_us = deadline_us, + }; + return map_result( + pocketjs_net_esp_transport_start_close(context, token, &request)); +} + +static pocketjs_net_http_client_transport_result_t esp_cancel(void *context, + uint64_t token) { + return map_result(pocketjs_net_esp_transport_cancel(context, token)); +} + +static pocketjs_net_http_client_transport_result_t +esp_pump(void *context, uint64_t now_us, size_t max_native_steps) { + return map_result( + pocketjs_net_esp_transport_pump(context, now_us, max_native_steps)); +} + +static pocketjs_net_http_client_transport_result_t esp_take_completion( + void *context, + pocketjs_net_http_client_transport_completion_t *out_completion) { + pocketjs_net_esp_completion_t completion; + esp_err_t error = + pocketjs_net_esp_transport_take_completion(context, &completion); + if (error != ESP_OK) { + return map_result(error); + } + memset(out_completion, 0, sizeof(*out_completion)); + out_completion->operation_token = completion.operation_token; + switch (completion.type) { + case POCKETJS_NET_ESP_TERMINAL_RESOLVED: + out_completion->type = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_RESOLVED; + out_completion->detail.resolved.candidate_count = + completion.detail.resolved.candidate_count; + size_t copy_count = out_completion->detail.resolved.candidate_count; + if (copy_count > POCKETJS_NET_HTTP_CLIENT_CORE_MAX_DNS_CANDIDATES) { + copy_count = POCKETJS_NET_HTTP_CLIENT_CORE_MAX_DNS_CANDIDATES; + } + memcpy(out_completion->detail.resolved.ipv4_be, + completion.detail.resolved.ipv4_be, + copy_count * sizeof(out_completion->detail.resolved.ipv4_be[0])); + break; + case POCKETJS_NET_ESP_TERMINAL_CONNECTED: + out_completion->type = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_CONNECTED; + out_completion->detail.connected.connection = + map_connection(completion.detail.connected.connection); + out_completion->detail.connected.ipv4_be = + completion.detail.connected.ipv4_be; + out_completion->detail.connected.tls = completion.detail.connected.tls; + break; + case POCKETJS_NET_ESP_TERMINAL_READ: + out_completion->type = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_READ; + out_completion->detail.read.connection = + map_connection(completion.detail.read.connection); + out_completion->detail.read.lease = map_lease(completion.detail.read.lease); + out_completion->detail.read.byte_count = completion.detail.read.byte_count; + out_completion->detail.read.eof = completion.detail.read.eof; + break; + case POCKETJS_NET_ESP_TERMINAL_WRITTEN: + out_completion->type = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_WRITTEN; + out_completion->detail.written.connection = + map_connection(completion.detail.written.connection); + out_completion->detail.written.byte_count = + completion.detail.written.byte_count; + break; + case POCKETJS_NET_ESP_TERMINAL_CLOSED: + out_completion->type = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_CLOSED; + out_completion->detail.closed.connection = + map_connection(completion.detail.closed.connection); + break; + case POCKETJS_NET_ESP_TERMINAL_ERROR: + default: + out_completion->type = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR; + out_completion->detail.error.code = map_error(completion.detail.error.code); + out_completion->detail.error.cause_code = + completion.detail.error.cause_code; + break; + } + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK; +} + +static pocketjs_net_http_client_transport_result_t +esp_retire_completion(void *context, uint64_t token) { + return map_result( + pocketjs_net_esp_transport_retire_completion(context, token)); +} + +static pocketjs_net_http_client_transport_result_t +esp_read_lease_view(void *context, + pocketjs_net_http_client_transport_read_lease_t lease, + const uint8_t **out_bytes, size_t *out_capacity) { + return map_result(pocketjs_net_esp_transport_read_lease_view( + context, map_esp_lease(lease), out_bytes, out_capacity)); +} + +static pocketjs_net_http_client_transport_result_t +esp_release_read_lease(void *context, + pocketjs_net_http_client_transport_read_lease_t lease) { + return map_result(pocketjs_net_esp_transport_release_read_lease( + context, map_esp_lease(lease))); +} + +const pocketjs_net_http_client_transport_ops_t * +pocketjs_net_http_client_core_esp_transport_ops(void) { + static const pocketjs_net_http_client_transport_ops_t ops = { + .start_resolve = esp_start_resolve, + .start_connect = esp_start_connect, + .start_read = esp_start_read, + .start_write = esp_start_write, + .start_close = esp_start_close, + .cancel = esp_cancel, + .pump = esp_pump, + .take_completion = esp_take_completion, + .retire_completion = esp_retire_completion, + .read_lease_view = esp_read_lease_view, + .release_read_lease = esp_release_read_lease, + }; + return &ops; +} diff --git a/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/.gitignore b/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/.gitignore new file mode 100644 index 00000000..3349a3f7 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/.gitignore @@ -0,0 +1,4 @@ +/build-*/ +/dependencies.lock +/sdkconfig +/sdkconfig.old diff --git a/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/CMakeLists.txt new file mode 100644 index 00000000..d86eaf0d --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/CMakeLists.txt @@ -0,0 +1,7 @@ +cmake_minimum_required(VERSION 3.16) + +set(EXTRA_COMPONENT_DIRS "../.." "../../../pocketjs_net_http1" + "../../../pocketjs_net_esp_transport") +set(SDKCONFIG "${CMAKE_BINARY_DIR}/sdkconfig") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(pocketjs_net_http_client_core_build_smoke) diff --git a/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/README.md b/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/README.md new file mode 100644 index 00000000..12f26e91 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/README.md @@ -0,0 +1,5 @@ +# Build smoke + +This app compiles the HTTP Client Core, HTTP/1.1 wire codec, and ESP-IDF +transport together. It asserts the experimental descriptor and verifies that +an HTTPS request fails before the permission callback or transport I/O. diff --git a/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/main/CMakeLists.txt b/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/main/CMakeLists.txt new file mode 100644 index 00000000..4f11cc53 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "build_smoke.c" + INCLUDE_DIRS "." + REQUIRES pocketjs_net_http_client_core +) diff --git a/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/main/build_smoke.c b/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/main/build_smoke.c new file mode 100644 index 00000000..25e3e0a1 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/main/build_smoke.c @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT + +#include +#include + +#include "pocketjs/net/http_client_core_esp.h" + +static bool permission_called; + +static bool +allow_endpoint(void *context, + const pocketjs_net_http_client_endpoint_t *endpoint) { + (void)context; + assert(endpoint != NULL); + permission_called = true; + return true; +} + +void app_main(void) { + const pocketjs_net_http_client_core_descriptor_t *descriptor = + pocketjs_net_http_client_core_descriptor(); + assert(descriptor != NULL); + assert(strcmp(descriptor->id, POCKETJS_NET_HTTP_CLIENT_CORE_ID) == 0); + assert(descriptor->experimental); + assert(!descriptor->advertises_public_capability); + assert(descriptor->plaintext_http); + assert(descriptor->https_fail_closed_before_io); + assert(descriptor->https_explicit_opt_in); + assert(descriptor->owner_pumped); + assert(descriptor->one_operation); + assert(descriptor->fixed_core_storage); + assert(descriptor->headers_first); + assert(descriptor->explicit_body_credit); + assert(descriptor->explicit_body_lease); + assert(descriptor->connection_reuse); + assert(descriptor->bounded_connection_pool); + assert(descriptor->max_cached_connections == 1U); + assert(descriptor->redirects_followed); + assert(descriptor->redirect_manual); + assert(descriptor->redirect_error); + assert(descriptor->redirect_fixed_body_replay); + assert(!descriptor->redirect_streaming_body_replay); + assert(descriptor->connect_error_candidate_fallback); + assert(!descriptor->hidden_retry); + assert(!descriptor->hidden_auth); + assert(!descriptor->hidden_cookie_store); + assert(!descriptor->proxy); + assert(!descriptor->content_decoding); + assert(descriptor->cleanup_faults_separate_from_terminal); + assert(descriptor->poison_is_machine_readable); + assert(descriptor->explicit_shutdown_lifecycle); + assert(descriptor->fixed_request_body); + assert(descriptor->streaming_request_body); + assert(descriptor->chunked_request_body); + assert(descriptor->known_length_streaming_request_body); + assert(!descriptor->streaming_request_body_buffered_in_full); + assert(descriptor->instance_bytes == + POCKETJS_NET_HTTP_CLIENT_CORE_INSTANCE_BYTES); + assert(descriptor->max_request_body_bytes == + POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_BODY_BYTES); + assert(descriptor->max_fixed_request_body_bytes == + POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_BODY_BYTES); + assert(descriptor->max_request_body_chunk_bytes == + POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES); + + const pocketjs_net_http_client_transport_ops_t *ops = + pocketjs_net_http_client_core_esp_transport_ops(); + assert(ops != NULL); + assert(ops->start_resolve != NULL); + assert(ops->start_connect != NULL); + assert(ops->start_read != NULL); + assert(ops->start_write != NULL); + assert(ops->start_close != NULL); + + static pocketjs_net_http_client_core_storage_t storage; + pocketjs_net_http_client_core_t *core = NULL; + pocketjs_net_http_client_core_config_t config = { + .transport_ops = ops, + .transport_context = NULL, + .allow_endpoint = allow_endpoint, + .permission_context = NULL, + .connect_timeout_us = 1000000U, + .headers_timeout_us = 1000000U, + .idle_timeout_us = 1000000U, + .total_timeout_us = 4000000U, + }; + assert(pocketjs_net_http_client_core_init(&storage, &config, &core) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + + static const uint8_t url[] = "https://example.com/"; + static const uint8_t method[] = "GET"; + pocketjs_net_http_client_request_t request = { + .operation_token = 1U, + .url = {.data = url, .length = sizeof(url) - 1U}, + .method = {.data = method, .length = sizeof(method) - 1U}, + }; + assert(pocketjs_net_http_client_core_start(core, &request, 1U) == + POCKETJS_NET_HTTP_CLIENT_START_UNSUPPORTED_TLS); + assert(!permission_called); + assert(pocketjs_net_http_client_core_begin_shutdown(core, 2U)); + assert(pocketjs_net_http_client_core_is_quiescent(core)); + assert(pocketjs_net_http_client_core_deinit(core)); +} diff --git a/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/main/idf_component.yml b/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/main/idf_component.yml new file mode 100644 index 00000000..4dba5b06 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/main/idf_component.yml @@ -0,0 +1,3 @@ +dependencies: + pocketjs_net_http_client_core: + override_path: ../../.. diff --git a/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/sdkconfig.defaults b/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/sdkconfig.defaults new file mode 100644 index 00000000..6d0509a2 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http_client_core/test_apps/build_smoke/sdkconfig.defaults @@ -0,0 +1,11 @@ +CONFIG_COMPILER_OPTIMIZATION_SIZE=y +CONFIG_LWIP_DNS_MAX_HOST_IP=4 +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=y +CONFIG_MBEDTLS_SSL_PROTO_TLS1_2=y +# CONFIG_MBEDTLS_SSL_PROTO_TLS1_3 is not set +# CONFIG_MBEDTLS_SSL_RENEGOTIATION is not set +# CONFIG_MBEDTLS_ALLOW_WEAK_CERTIFICATE_VERIFICATION is not set +# CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEPRECATED_LIST is not set +# CONFIG_MBEDTLS_DES_C is not set +# CONFIG_ESP_TLS_INSECURE is not set diff --git a/hosts/esp-idf/components/pocketjs_net_http_client_core/test_host/core_test.c b/hosts/esp-idf/components/pocketjs_net_http_client_core/test_host/core_test.c new file mode 100644 index 00000000..fef3b0f2 --- /dev/null +++ b/hosts/esp-idf/components/pocketjs_net_http_client_core/test_host/core_test.c @@ -0,0 +1,3080 @@ +// SPDX-License-Identifier: MIT + +#include "pocketjs/net/http_client_core.h" + +#include +#include +#include + +typedef enum { + FAKE_NONE = 0, + FAKE_RESOLVE, + FAKE_CONNECT, + FAKE_READ, + FAKE_WRITE, + FAKE_CLOSE, +} fake_kind_t; + +typedef struct { + fake_kind_t active_kind; + uint64_t active_token; + uint64_t active_deadline; + bool completion_queued; + bool completion_delivering; + pocketjs_net_http_client_transport_completion_t completion; + pocketjs_net_http_client_transport_connection_t connection; + uint32_t connect_ipv4_be; + uint16_t connect_port; + bool connect_tls; + char resolve_hostname[254]; + char connect_hostname[254]; + uint8_t write_bytes[4096]; + size_t write_length; + size_t requested_read_bytes; + uint8_t read_bytes[4096]; + size_t read_length; + bool read_lease_active; + uint64_t read_lease_generation; + size_t starts[6]; + size_t start_attempts[6]; + size_t cancels; + size_t retires; + size_t releases; + size_t release_attempts; + size_t pump_calls; + size_t take_attempts; + pocketjs_net_http_client_transport_result_t start_failure[6]; + pocketjs_net_http_client_transport_result_t cancel_failure; + pocketjs_net_http_client_transport_result_t pump_failure; + pocketjs_net_http_client_transport_result_t take_failure; + pocketjs_net_http_client_transport_result_t retire_failure; + pocketjs_net_http_client_transport_result_t lease_view_failure; + pocketjs_net_http_client_transport_result_t lease_release_failure; +} fake_transport_t; + +static pocketjs_net_http_client_transport_result_t +fake_start(fake_transport_t *fake, fake_kind_t kind, uint64_t token, + uint64_t deadline) { + ++fake->start_attempts[kind]; + if (fake->start_failure[kind] != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + return fake->start_failure[kind]; + } + if (fake->active_kind != FAKE_NONE || token == 0U || deadline == 0U) { + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_BUSY; + } + fake->active_kind = kind; + fake->active_token = token; + fake->active_deadline = deadline; + ++fake->starts[kind]; + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK; +} + +static pocketjs_net_http_client_transport_result_t +fake_start_resolve(void *context, uint64_t token, const char *hostname, + uint64_t deadline_us) { + fake_transport_t *fake = context; + if (fake->cancel_failure != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + return fake->cancel_failure; + } + size_t length = strlen(hostname); + assert(length < sizeof(fake->resolve_hostname)); + memcpy(fake->resolve_hostname, hostname, length + 1U); + return fake_start(fake, FAKE_RESOLVE, token, deadline_us); +} + +static pocketjs_net_http_client_transport_result_t +fake_start_connect(void *context, uint64_t token, uint32_t ipv4_be, + uint16_t port, bool tls, const char *original_hostname, + uint64_t deadline_us) { + fake_transport_t *fake = context; + size_t length = strlen(original_hostname); + assert(length < sizeof(fake->connect_hostname)); + memcpy(fake->connect_hostname, original_hostname, length + 1U); + fake->connect_ipv4_be = ipv4_be; + fake->connect_port = port; + fake->connect_tls = tls; + return fake_start(fake, FAKE_CONNECT, token, deadline_us); +} + +static pocketjs_net_http_client_transport_result_t +fake_start_read(void *context, uint64_t token, + pocketjs_net_http_client_transport_connection_t connection, + size_t maximum_bytes, uint64_t deadline_us) { + fake_transport_t *fake = context; + assert(connection.slot == fake->connection.slot); + assert(connection.generation == fake->connection.generation); + assert(maximum_bytes != 0U); + fake->requested_read_bytes = maximum_bytes; + return fake_start(fake, FAKE_READ, token, deadline_us); +} + +static pocketjs_net_http_client_transport_result_t +fake_start_write(void *context, uint64_t token, + pocketjs_net_http_client_transport_connection_t connection, + const uint8_t *bytes, size_t length, uint64_t deadline_us) { + fake_transport_t *fake = context; + assert(connection.slot == fake->connection.slot); + assert(connection.generation == fake->connection.generation); + assert(length <= sizeof(fake->write_bytes)); + memcpy(fake->write_bytes, bytes, length); + fake->write_length = length; + return fake_start(fake, FAKE_WRITE, token, deadline_us); +} + +static pocketjs_net_http_client_transport_result_t +fake_start_close(void *context, uint64_t token, + pocketjs_net_http_client_transport_connection_t connection, + uint64_t deadline_us) { + fake_transport_t *fake = context; + assert(connection.slot == fake->connection.slot); + assert(connection.generation == fake->connection.generation); + return fake_start(fake, FAKE_CLOSE, token, deadline_us); +} + +static void fake_queue_error(fake_transport_t *fake, + pocketjs_net_http_client_transport_error_t error, + int32_t cause) { + assert(!fake->completion_queued && !fake->completion_delivering); + fake->completion = (pocketjs_net_http_client_transport_completion_t){ + .type = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR, + .operation_token = fake->active_token, + .detail.error = {.code = error, .cause_code = cause}, + }; + fake->completion_queued = true; + fake->active_kind = FAKE_NONE; +} + +static pocketjs_net_http_client_transport_result_t fake_cancel(void *context, + uint64_t token) { + fake_transport_t *fake = context; + if (fake->active_kind == FAKE_NONE || fake->active_token != token) { + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_INVALID; + } + ++fake->cancels; + fake_queue_error(fake, POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_ABORTED, 0); + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK; +} + +static pocketjs_net_http_client_transport_result_t +fake_pump(void *context, uint64_t now_us, size_t max_native_steps) { + fake_transport_t *fake = context; + assert(now_us != 0U); + assert(max_native_steps != 0U); + ++fake->pump_calls; + return fake->pump_failure; +} + +static pocketjs_net_http_client_transport_result_t fake_take_completion( + void *context, + pocketjs_net_http_client_transport_completion_t *out_completion) { + fake_transport_t *fake = context; + ++fake->take_attempts; + if (fake->take_failure != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + return fake->take_failure; + } + if (!fake->completion_queued) { + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_EMPTY; + } + assert(!fake->completion_delivering); + *out_completion = fake->completion; + fake->completion_queued = false; + fake->completion_delivering = true; + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK; +} + +static pocketjs_net_http_client_transport_result_t +fake_retire_completion(void *context, uint64_t token) { + fake_transport_t *fake = context; + if (fake->retire_failure != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + return fake->retire_failure; + } + if (!fake->completion_delivering || + fake->completion.operation_token != token) { + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_INVALID; + } + fake->completion_delivering = false; + ++fake->retires; + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK; +} + +static pocketjs_net_http_client_transport_result_t +fake_read_lease_view(void *context, + pocketjs_net_http_client_transport_read_lease_t lease, + const uint8_t **out_bytes, size_t *out_capacity) { + fake_transport_t *fake = context; + if (fake->lease_view_failure != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + return fake->lease_view_failure; + } + if (!fake->read_lease_active || lease.slot != 0U || + lease.generation != fake->read_lease_generation) { + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_INVALID; + } + *out_bytes = fake->read_bytes; + *out_capacity = fake->read_length; + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK; +} + +static pocketjs_net_http_client_transport_result_t +fake_release_read_lease(void *context, + pocketjs_net_http_client_transport_read_lease_t lease) { + fake_transport_t *fake = context; + ++fake->release_attempts; + if (fake->lease_release_failure != POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK) { + return fake->lease_release_failure; + } + if (!fake->read_lease_active || lease.slot != 0U || + lease.generation != fake->read_lease_generation) { + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_INVALID; + } + fake->read_lease_active = false; + ++fake->releases; + return POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK; +} + +static const pocketjs_net_http_client_transport_ops_t fake_ops = { + .start_resolve = fake_start_resolve, + .start_connect = fake_start_connect, + .start_read = fake_start_read, + .start_write = fake_start_write, + .start_close = fake_start_close, + .cancel = fake_cancel, + .pump = fake_pump, + .take_completion = fake_take_completion, + .retire_completion = fake_retire_completion, + .read_lease_view = fake_read_lease_view, + .release_read_lease = fake_release_read_lease, +}; + +static void fake_queue(fake_transport_t *fake, + pocketjs_net_http_client_transport_completion_t value) { + assert(fake->active_kind != FAKE_NONE); + assert(!fake->completion_queued && !fake->completion_delivering); + value.operation_token = fake->active_token; + fake->completion = value; + fake->completion_queued = true; + fake->active_kind = FAKE_NONE; +} + +static void fake_complete_resolve(fake_transport_t *fake, + const uint32_t *addresses, size_t count) { + assert(fake->active_kind == FAKE_RESOLVE); + pocketjs_net_http_client_transport_completion_t completion = { + .type = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_RESOLVED, + }; + assert(count <= POCKETJS_NET_HTTP_CLIENT_CORE_MAX_DNS_CANDIDATES); + memcpy(completion.detail.resolved.ipv4_be, addresses, + count * sizeof(addresses[0])); + completion.detail.resolved.candidate_count = count; + fake_queue(fake, completion); +} + +static void fake_complete_connect(fake_transport_t *fake) { + assert(fake->active_kind == FAKE_CONNECT); + fake->connection = (pocketjs_net_http_client_transport_connection_t){ + .slot = 1U, + .generation = 7U, + }; + fake_queue(fake, (pocketjs_net_http_client_transport_completion_t){ + .type = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_CONNECTED, + .detail.connected = + { + .connection = fake->connection, + .ipv4_be = fake->connect_ipv4_be, + .tls = fake->connect_tls, + }, + }); +} + +static void fake_complete_write(fake_transport_t *fake) { + assert(fake->active_kind == FAKE_WRITE); + fake_queue(fake, (pocketjs_net_http_client_transport_completion_t){ + .type = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_WRITTEN, + .detail.written = + { + .connection = fake->connection, + .byte_count = fake->write_length, + }, + }); +} + +static void fake_complete_unexpected_read( + fake_transport_t *fake, + pocketjs_net_http_client_transport_connection_t connection, + const char *bytes, bool eof) { + size_t length = strlen(bytes); + assert(length <= sizeof(fake->read_bytes)); + memcpy(fake->read_bytes, bytes, length); + fake->read_length = length; + if (length != 0U) { + ++fake->read_lease_generation; + fake->read_lease_active = true; + } + fake_queue(fake, (pocketjs_net_http_client_transport_completion_t){ + .type = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_READ, + .detail.read = + { + .connection = connection, + .lease = {.slot = 0U, + .generation = + length == 0U + ? 0U + : fake->read_lease_generation}, + .byte_count = length, + .eof = eof, + }, + }); +} + +static void fake_complete_read(fake_transport_t *fake, const char *bytes, + bool eof) { + assert(fake->active_kind == FAKE_READ); + assert(strlen(bytes) <= fake->requested_read_bytes); + fake_complete_unexpected_read(fake, fake->connection, bytes, eof); +} + +static void fake_complete_close(fake_transport_t *fake) { + assert(fake->active_kind == FAKE_CLOSE); + fake_queue(fake, (pocketjs_net_http_client_transport_completion_t){ + .type = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_CLOSED, + .detail.closed = {.connection = fake->connection}, + }); +} + +typedef struct { + bool allow_hostname; + bool allowed_candidates[4]; + size_t hostname_calls; + size_t numeric_calls; + uint32_t candidates[4]; + char observed_hostname[254]; + uint16_t observed_port; + bool exercise_reentrancy; + size_t reentrancy_calls; + pocketjs_net_http_client_core_t *core; + pocketjs_net_http_client_core_storage_t *storage; + const pocketjs_net_http_client_core_config_t *config; +} permission_log_t; + +static bool +allow_endpoint(void *context, + const pocketjs_net_http_client_endpoint_t *endpoint) { + permission_log_t *log = context; + if (log->exercise_reentrancy) { + static const uint8_t url[] = "http://example.com/"; + static const uint8_t get[] = "GET"; + pocketjs_net_http_client_request_t request = { + .operation_token = 99U, + .url = {.data = url, .length = sizeof(url) - 1U}, + .method = {.data = get, .length = sizeof(get) - 1U}, + }; + pocketjs_net_http_client_event_t event; + pocketjs_net_http_client_core_status_t status; + pocketjs_net_http_client_core_t *reinitialized = NULL; + const uint8_t *body = NULL; + size_t body_length = 0U; + pocketjs_net_http_client_body_lease_t lease = {0}; + assert(pocketjs_net_http_client_core_start(log->core, &request, 1U) == + POCKETJS_NET_HTTP_CLIENT_START_REENTRANT); + assert(!pocketjs_net_http_client_core_abort(log->core, 1U)); + assert(!pocketjs_net_http_client_core_pump(log->core, 1U, 1U, 1U)); + assert(!pocketjs_net_http_client_core_grant_body_credit(log->core, 1U, 1U)); + static const uint8_t request_body_byte[] = "x"; + assert(!pocketjs_net_http_client_core_submit_request_body_chunk( + log->core, 1U, 1U, 1U, request_body_byte, 1U)); + assert(!pocketjs_net_http_client_core_submit_request_body_end(log->core, 1U, + 1U, 1U)); + assert(!pocketjs_net_http_client_core_submit_request_body_error( + log->core, 1U, 1U, 1U, 1)); + assert(!pocketjs_net_http_client_core_take_event(log->core, &event)); + assert(!pocketjs_net_http_client_core_retire_event(log->core, 1U)); + assert(!pocketjs_net_http_client_core_body_lease_view(log->core, lease, + &body, &body_length)); + assert(!pocketjs_net_http_client_core_release_body_lease(log->core, lease)); + assert(!pocketjs_net_http_client_core_get_status(log->core, &status)); + assert(!pocketjs_net_http_client_core_begin_shutdown(log->core, 1U)); + assert(!pocketjs_net_http_client_core_is_quiescent(log->core)); + assert( + !pocketjs_net_http_client_core_confirm_transport_shutdown(log->core)); + assert(!pocketjs_net_http_client_core_report_host_event_retire_failure( + log->core, 1U)); + assert( + !pocketjs_net_http_client_core_abandon_event_after_transport_shutdown( + log->core, 1U)); + assert(!pocketjs_net_http_client_core_deinit(log->core)); + assert(pocketjs_net_http_client_core_init(log->storage, log->config, + &reinitialized) == + POCKETJS_NET_HTTP_CLIENT_START_BUSY); + assert(reinitialized == NULL); + ++log->reentrancy_calls; + } + size_t length = strlen(endpoint->hostname); + assert(length < sizeof(log->observed_hostname)); + memcpy(log->observed_hostname, endpoint->hostname, length + 1U); + log->observed_port = endpoint->port; + if (endpoint->phase == POCKETJS_NET_HTTP_CLIENT_PERMISSION_HOSTNAME) { + ++log->hostname_calls; + return log->allow_hostname; + } + size_t index = log->numeric_calls++; + assert(index < 4U); + log->candidates[index] = endpoint->ipv4_be; + return log->allowed_candidates[index]; +} + +typedef struct { + pocketjs_net_http_client_core_storage_t storage; + pocketjs_net_http_client_core_t *core; + fake_transport_t fake; + permission_log_t permissions; + pocketjs_net_http_client_core_config_t config; + uint64_t now; +} fixture_t; + +static void fixture_init_with_options_and_reuse(fixture_t *fixture, + size_t limit, bool allow_https, + bool enable_reuse) { + memset(fixture, 0, sizeof(*fixture)); + fixture->now = 1000000U; + fixture->permissions.allow_hostname = true; + for (size_t index = 0U; index < 4U; ++index) { + fixture->permissions.allowed_candidates[index] = true; + } + fixture->config = (pocketjs_net_http_client_core_config_t){ + .transport_ops = &fake_ops, + .transport_context = &fixture->fake, + .allow_endpoint = allow_endpoint, + .permission_context = &fixture->permissions, + .connect_timeout_us = 100000U, + .headers_timeout_us = 100000U, + .idle_timeout_us = 100000U, + .total_timeout_us = 1000000U, + .allow_https = allow_https, + .enable_connection_reuse = enable_reuse, + .response_header_bytes_limit = limit, + }; + assert(pocketjs_net_http_client_core_init(&fixture->storage, &fixture->config, + &fixture->core) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + fixture->permissions.core = fixture->core; + fixture->permissions.storage = &fixture->storage; + fixture->permissions.config = &fixture->config; +} + +static void fixture_init_with_options(fixture_t *fixture, size_t limit, + bool allow_https) { + fixture_init_with_options_and_reuse(fixture, limit, allow_https, false); +} + +static void fixture_init_with_reuse(fixture_t *fixture, bool allow_https) { + fixture_init_with_options_and_reuse(fixture, 0U, allow_https, true); +} + +static void fixture_init_with_response_header_limit(fixture_t *fixture, + size_t limit) { + fixture_init_with_options(fixture, limit, false); +} + +static void fixture_init(fixture_t *fixture) { + fixture_init_with_response_header_limit(fixture, 0U); +} + +static void pump(fixture_t *fixture) { + ++fixture->now; + assert( + pocketjs_net_http_client_core_pump(fixture->core, fixture->now, 8U, 8U)); +} + +static pocketjs_net_http_client_request_t make_get(uint64_t token, + const char *url) { + static const uint8_t get[] = "GET"; + return (pocketjs_net_http_client_request_t){ + .operation_token = token, + .url = {.data = (const uint8_t *)url, .length = strlen(url)}, + .method = {.data = get, .length = sizeof(get) - 1U}, + }; +} + +static pocketjs_net_http_client_request_t +make_tls_get(uint64_t token, const char *url, const char *hostname) { + static pocketjs_net_http_client_tls_policy_t policy; + pocketjs_net_http_client_request_t request = make_get(token, url); + policy = (pocketjs_net_http_client_tls_policy_t){ + .server_name = + { + .data = (const uint8_t *)hostname, + .length = strlen(hostname), + }, + .minimum_version = POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_2, + .maximum_version = POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_2, + .client_certificate = + POCKETJS_NET_HTTP_CLIENT_TLS_CLIENT_CERTIFICATE_NONE, + .verification = POCKETJS_NET_HTTP_CLIENT_TLS_VERIFICATION_FULL, + .revocation = POCKETJS_NET_HTTP_CLIENT_TLS_REVOCATION_HOST_DEFAULT, + }; + request.tls = &policy; + return request; +} + +static pocketjs_net_http_client_event_t take_event(fixture_t *fixture, + int expected) { + pocketjs_net_http_client_event_t event; + assert(pocketjs_net_http_client_core_take_event(fixture->core, &event)); + assert((int)event.type == expected); + return event; +} + +static bool bytes_contain(const uint8_t *haystack, size_t haystack_length, + const char *needle) { + size_t needle_length = strlen(needle); + if (needle_length > haystack_length) { + return false; + } + for (size_t index = 0U; index <= haystack_length - needle_length; ++index) { + if (memcmp(haystack + index, needle, needle_length) == 0) { + return true; + } + } + return false; +} + +static bool slice_equals(pocketjs_net_http_client_slice_t slice, + const char *expected) { + const size_t length = strlen(expected); + return slice.length == length && memcmp(slice.data, expected, length) == 0; +} + +static void complete_dns_connect(fixture_t *fixture, uint32_t address) { + assert(fixture->fake.active_kind == FAKE_RESOLVE); + fake_complete_resolve(&fixture->fake, &address, 1U); + pump(fixture); + assert(fixture->fake.active_kind == FAKE_CONNECT); + assert(fixture->fake.connect_ipv4_be == address); + fake_complete_connect(&fixture->fake); + pump(fixture); + assert(fixture->fake.active_kind == FAKE_WRITE); +} + +static void retire_event(fixture_t *fixture, + pocketjs_net_http_client_event_t event) { + assert(pocketjs_net_http_client_core_retire_event(fixture->core, + event.sequence)); +} + +static pocketjs_net_http_client_core_status_t get_status(fixture_t *fixture) { + pocketjs_net_http_client_core_status_t status; + assert(pocketjs_net_http_client_core_get_status(fixture->core, &status)); + return status; +} + +static void +connect_and_write_request(fixture_t *fixture, + const pocketjs_net_http_client_request_t *request, + const char *expected_request_line) { + assert(pocketjs_net_http_client_core_start(fixture->core, request, + fixture->now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + assert(fixture->fake.active_kind == FAKE_RESOLVE); + uint32_t addresses[] = {0x0100007fU, 0x0200007fU, 0x0300007fU}; + fixture->permissions.allowed_candidates[0] = false; + fixture->permissions.allowed_candidates[1] = true; + fixture->permissions.allowed_candidates[2] = false; + fake_complete_resolve(&fixture->fake, addresses, 3U); + pump(fixture); + assert(fixture->permissions.hostname_calls == 1U); + assert(fixture->permissions.numeric_calls == 3U); + assert(fixture->fake.connect_ipv4_be == addresses[1]); + assert(!fixture->fake.connect_tls); + fake_complete_connect(&fixture->fake); + pump(fixture); + assert(fixture->fake.active_kind == FAKE_WRITE); + assert(bytes_contain(fixture->fake.write_bytes, fixture->fake.write_length, + expected_request_line)); + assert(bytes_contain(fixture->fake.write_bytes, fixture->fake.write_length, + "Host: example.com:8080\r\n")); + assert(bytes_contain(fixture->fake.write_bytes, fixture->fake.write_length, + "Connection: close\r\n")); + assert(bytes_contain(fixture->fake.write_bytes, fixture->fake.write_length, + "Accept-Encoding: identity\r\n")); + fake_complete_write(&fixture->fake); + pump(fixture); + assert(fixture->fake.active_kind == FAKE_READ); +} + +static void connect_and_write_get(fixture_t *fixture, uint64_t token, + const char *url) { + pocketjs_net_http_client_request_t request = make_get(token, url); + connect_and_write_request(fixture, &request, "GET /x?q=1 HTTP/1.1\r\n"); +} + +static pocketjs_net_http_client_request_t +make_streaming_post(uint64_t token, bool length_known, + uint64_t content_length) { + static const uint8_t url[] = "http://127.0.0.1/upload"; + static const uint8_t post[] = "POST"; + return (pocketjs_net_http_client_request_t){ + .operation_token = token, + .url = {.data = url, .length = sizeof(url) - 1U}, + .method = {.data = post, .length = sizeof(post) - 1U}, + .body_kind = POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_STREAMING, + .streaming_content_length_known = length_known, + .streaming_content_length = content_length, + }; +} + +static void +start_streaming_request(fixture_t *fixture, + const pocketjs_net_http_client_request_t *request) { + assert(pocketjs_net_http_client_core_start(fixture->core, request, + fixture->now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + assert(fixture->fake.active_kind == FAKE_CONNECT); + fake_complete_connect(&fixture->fake); + pump(fixture); + assert(fixture->fake.active_kind == FAKE_WRITE); + assert(bytes_contain(fixture->fake.write_bytes, fixture->fake.write_length, + "POST /upload HTTP/1.1\r\n")); + if (request->streaming_content_length_known) { + char expected[64]; + int length = + snprintf(expected, sizeof(expected), "Content-Length: %llu\r\n", + (unsigned long long)request->streaming_content_length); + assert(length > 0 && (size_t)length < sizeof(expected)); + assert(bytes_contain(fixture->fake.write_bytes, fixture->fake.write_length, + expected)); + assert(!bytes_contain(fixture->fake.write_bytes, fixture->fake.write_length, + "Transfer-Encoding: chunked\r\n")); + } else { + assert(bytes_contain(fixture->fake.write_bytes, fixture->fake.write_length, + "Transfer-Encoding: chunked\r\n")); + assert(!bytes_contain(fixture->fake.write_bytes, fixture->fake.write_length, + "Content-Length:")); + } + fake_complete_write(&fixture->fake); + pump(fixture); +} + +static pocketjs_net_http_client_event_t +take_request_body_pull(fixture_t *fixture, size_t expected_maximum) { + pocketjs_net_http_client_event_t pull = + take_event(fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_REQUEST_BODY_PULL); + assert(pull.operation_token != 0U); + assert(pull.detail.request_body_pull.body_generation != 0U); + assert(pull.detail.request_body_pull.pull_generation != 0U); + assert(pull.detail.request_body_pull.maximum_bytes == expected_maximum); + return pull; +} + +static void retire_request_body_pull(fixture_t *fixture, + pocketjs_net_http_client_event_t pull) { + retire_event(fixture, pull); + pocketjs_net_http_client_core_status_t status = get_status(fixture); + assert(status.request_body_credit_outstanding); + assert(!status.event_outstanding); + assert(status.request_body_generation == + pull.detail.request_body_pull.body_generation); + assert(status.request_body_pull_generation == + pull.detail.request_body_pull.pull_generation); +} + +static void finish_empty_response(fixture_t *fixture) { + assert(fixture->fake.active_kind == FAKE_READ); + fake_complete_read(&fixture->fake, "HTTP/1.1 204 No Content\r\n\r\n", false); + pump(fixture); + pocketjs_net_http_client_event_t headers = + take_event(fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + assert(headers.detail.response.status_code == 204U); + retire_event(fixture, headers); + assert(fixture->fake.active_kind == FAKE_CLOSE); + fake_complete_close(&fixture->fake); + pump(fixture); + pocketjs_net_http_client_event_t complete = + take_event(fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + retire_event(fixture, complete); + assert(!pocketjs_net_http_client_core_take_event(fixture->core, &complete)); +} + +static void test_redirect_follow_resolves_relative_url(void) { + fixture_t fixture; + fixture_init(&fixture); + pocketjs_net_http_client_request_t request = + make_get(1U, "http://example.com:8080/base/one?old=1"); + request.redirect_mode = POCKETJS_NET_HTTP_CLIENT_REDIRECT_FOLLOW; + request.max_redirects = 5U; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + + complete_dns_connect(&fixture, 0x0100007fU); + assert(bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "GET /base/one?old=1 HTTP/1.1\r\n")); + fake_complete_write(&fixture.fake); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_READ); + + fake_complete_read(&fixture.fake, + "HTTP/1.1 302 Found\r\nLocation: ../next?x=1#ignored\r\n" + "Content-Length: 4\r\n\r\nbody", + false); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_CLOSE); + pocketjs_net_http_client_event_t event; + assert(!pocketjs_net_http_client_core_take_event(fixture.core, &event)); + assert(fixture.fake.releases == 1U); + + fake_complete_close(&fixture.fake); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_RESOLVE); + assert(strcmp(fixture.fake.resolve_hostname, "example.com") == 0); + assert(fixture.permissions.hostname_calls == 2U); + complete_dns_connect(&fixture, 0x0200007fU); + assert(fixture.permissions.numeric_calls == 2U); + assert(bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "GET /next?x=1 HTTP/1.1\r\n")); + assert(bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "Host: example.com:8080\r\n")); + fake_complete_write(&fixture.fake); + pump(&fixture); + fake_complete_read(&fixture.fake, "HTTP/1.1 204 No Content\r\n\r\n", false); + pump(&fixture); + + const pocketjs_net_http_client_event_t headers = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + assert(headers.detail.response.status_code == 204U); + assert(headers.detail.response.redirected); + assert(slice_equals(headers.detail.response.url, + "http://example.com:8080/next?x=1")); + retire_event(&fixture, headers); + assert(fixture.fake.active_kind == FAKE_CLOSE); + fake_complete_close(&fixture.fake); + pump(&fixture); + const pocketjs_net_http_client_event_t complete = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + retire_event(&fixture, complete); +} + +static void test_redirect_rechecks_permission_before_second_hop(void) { + fixture_t fixture; + fixture_init(&fixture); + pocketjs_net_http_client_request_t request = + make_get(1U, "http://example.com/start"); + request.redirect_mode = POCKETJS_NET_HTTP_CLIENT_REDIRECT_FOLLOW; + request.max_redirects = 5U; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + complete_dns_connect(&fixture, 0x0100007fU); + fake_complete_write(&fixture.fake); + pump(&fixture); + fake_complete_read( + &fixture.fake, + "HTTP/1.1 302 Found\r\nLocation: http://denied.example/next\r\n" + "Content-Length: 0\r\n\r\n", + false); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_CLOSE); + + fixture.permissions.allow_hostname = false; + fake_complete_close(&fixture.fake); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_NONE); + assert(fixture.permissions.hostname_calls == 2U); + assert(fixture.permissions.numeric_calls == 1U); + assert(strcmp(fixture.permissions.observed_hostname, "denied.example") == 0); + const pocketjs_net_http_client_event_t error = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == + POCKETJS_NET_HTTP_CLIENT_ERROR_PERMISSION_DENIED); + retire_event(&fixture, error); +} + +static void test_redirect_rewrites_post_and_strips_cross_origin_headers(void) { + fixture_t fixture; + fixture_init(&fixture); + static const uint8_t post[] = "POST"; + static const uint8_t body[] = "payload"; + static const uint8_t authorization[] = "Authorization"; + static const uint8_t bearer[] = "Bearer secret"; + static const uint8_t cookie[] = "Cookie"; + static const uint8_t cookie_value[] = "a=b"; + static const uint8_t content_type[] = "Content-Type"; + static const uint8_t content_type_value[] = "text/plain"; + static const uint8_t keep[] = "X-Keep"; + static const uint8_t keep_value[] = "yes"; + const pocketjs_net_http_client_header_t headers[] = { + {.name = {.data = authorization, .length = sizeof(authorization) - 1U}, + .value = {.data = bearer, .length = sizeof(bearer) - 1U}}, + {.name = {.data = cookie, .length = sizeof(cookie) - 1U}, + .value = {.data = cookie_value, .length = sizeof(cookie_value) - 1U}}, + {.name = {.data = content_type, .length = sizeof(content_type) - 1U}, + .value = {.data = content_type_value, + .length = sizeof(content_type_value) - 1U}}, + {.name = {.data = keep, .length = sizeof(keep) - 1U}, + .value = {.data = keep_value, .length = sizeof(keep_value) - 1U}}, + }; + pocketjs_net_http_client_request_t request = { + .operation_token = 1U, + .url = {.data = (const uint8_t *)"http://example.com:8080/start", + .length = sizeof("http://example.com:8080/start") - 1U}, + .method = {.data = post, .length = sizeof(post) - 1U}, + .headers = headers, + .header_count = sizeof(headers) / sizeof(headers[0]), + .body_kind = POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_FIXED, + .body = {.data = body, .length = sizeof(body) - 1U}, + .redirect_mode = POCKETJS_NET_HTTP_CLIENT_REDIRECT_FOLLOW, + .max_redirects = 5U, + }; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + complete_dns_connect(&fixture, 0x0100007fU); + assert(bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "POST /start HTTP/1.1\r\n")); + fake_complete_write(&fixture.fake); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_WRITE); + assert(fixture.fake.write_length == sizeof(body) - 1U); + assert(memcmp(fixture.fake.write_bytes, body, sizeof(body) - 1U) == 0); + fake_complete_write(&fixture.fake); + pump(&fixture); + + fake_complete_read( + &fixture.fake, + "HTTP/1.1 302 Found\r\nLocation: http://other.example/final\r\n" + "Content-Length: 0\r\n\r\n", + false); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_CLOSE); + fake_complete_close(&fixture.fake); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_RESOLVE); + assert(strcmp(fixture.fake.resolve_hostname, "other.example") == 0); + complete_dns_connect(&fixture, 0x0200007fU); + assert(bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "GET /final HTTP/1.1\r\n")); + assert(bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "Host: other.example\r\n")); + assert(bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "X-Keep: yes\r\n")); + assert(!bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "Authorization:")); + assert(!bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "Cookie:")); + assert(!bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "Content-Type:")); + assert(!bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "Content-Length:")); + fake_complete_write(&fixture.fake); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_READ); + finish_empty_response(&fixture); +} + +static void +test_redirect_replays_fixed_body_and_rejects_streaming_replay(void) { + fixture_t fixture; + fixture_init(&fixture); + static const uint8_t post[] = "POST"; + static const uint8_t body[] = "abc"; + pocketjs_net_http_client_request_t request = { + .operation_token = 1U, + .url = {.data = (const uint8_t *)"http://example.com/upload", + .length = sizeof("http://example.com/upload") - 1U}, + .method = {.data = post, .length = sizeof(post) - 1U}, + .body_kind = POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_FIXED, + .body = {.data = body, .length = sizeof(body) - 1U}, + .redirect_mode = POCKETJS_NET_HTTP_CLIENT_REDIRECT_FOLLOW, + .max_redirects = 5U, + }; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + complete_dns_connect(&fixture, 0x0100007fU); + fake_complete_write(&fixture.fake); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_WRITE); + fake_complete_write(&fixture.fake); + pump(&fixture); + fake_complete_read(&fixture.fake, + "HTTP/1.1 307 Temporary Redirect\r\nLocation: /again\r\n" + "Content-Length: 0\r\n\r\n", + false); + pump(&fixture); + fake_complete_close(&fixture.fake); + pump(&fixture); + complete_dns_connect(&fixture, 0x0200007fU); + assert(bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "POST /again HTTP/1.1\r\n")); + fake_complete_write(&fixture.fake); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_WRITE); + assert(fixture.fake.write_length == sizeof(body) - 1U); + assert(memcmp(fixture.fake.write_bytes, body, sizeof(body) - 1U) == 0); + fake_complete_write(&fixture.fake); + pump(&fixture); + finish_empty_response(&fixture); + + fixture_init(&fixture); + request = make_streaming_post(1U, false, 0U); + request.redirect_mode = POCKETJS_NET_HTTP_CLIENT_REDIRECT_FOLLOW; + request.max_redirects = 5U; + start_streaming_request(&fixture, &request); + pocketjs_net_http_client_event_t pull = take_request_body_pull( + &fixture, POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES); + retire_request_body_pull(&fixture, pull); + assert(pocketjs_net_http_client_core_submit_request_body_end( + fixture.core, pull.operation_token, + pull.detail.request_body_pull.body_generation, + pull.detail.request_body_pull.pull_generation)); + assert(fixture.fake.active_kind == FAKE_WRITE); + fake_complete_write(&fixture.fake); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_READ); + fake_complete_read(&fixture.fake, + "HTTP/1.1 307 Temporary Redirect\r\nLocation: /again\r\n" + "Content-Length: 0\r\n\r\n", + false); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_CLOSE); + fake_complete_close(&fixture.fake); + pump(&fixture); + const pocketjs_net_http_client_event_t error = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == + POCKETJS_NET_HTTP_CLIENT_ERROR_REDIRECT_BODY_NOT_REPLAYABLE); + retire_event(&fixture, error); +} + +static void test_redirect_modes_and_limit_fail_closed(void) { + fixture_t fixture; + fixture_init(&fixture); + pocketjs_net_http_client_request_t request = + make_get(1U, "http://example.com/start"); + request.redirect_mode = POCKETJS_NET_HTTP_CLIENT_REDIRECT_ERROR; + request.max_redirects = 5U; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + complete_dns_connect(&fixture, 0x0100007fU); + fake_complete_write(&fixture.fake); + pump(&fixture); + fake_complete_read(&fixture.fake, + "HTTP/1.1 302 Found\r\nLocation: /next\r\n" + "Content-Length: 0\r\n\r\n", + false); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_CLOSE); + fake_complete_close(&fixture.fake); + pump(&fixture); + pocketjs_net_http_client_event_t error = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == POCKETJS_NET_HTTP_CLIENT_ERROR_REDIRECT); + retire_event(&fixture, error); + + fixture_init(&fixture); + request = make_get(1U, "http://example.com/start"); + request.redirect_mode = POCKETJS_NET_HTTP_CLIENT_REDIRECT_FOLLOW; + request.max_redirects = 0U; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + complete_dns_connect(&fixture, 0x0100007fU); + fake_complete_write(&fixture.fake); + pump(&fixture); + fake_complete_read(&fixture.fake, + "HTTP/1.1 302 Found\r\nLocation: /next\r\n" + "Content-Length: 0\r\n\r\n", + false); + pump(&fixture); + fake_complete_close(&fixture.fake); + pump(&fixture); + error = take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == + POCKETJS_NET_HTTP_CLIENT_ERROR_REDIRECT_LIMIT); + retire_event(&fixture, error); +} + +static void test_headers_body_and_status_success(void) { + fixture_t fixture; + fixture_init(&fixture); + connect_and_write_get(&fixture, 1U, "http://EXAMPLE.com:8080/x?q=1"); + + fake_complete_read(&fixture.fake, + "HTTP/1.1 404 Not Found\r\nContent-Length: 5\r\n" + "X-Test: yes\r\n\r\nhello", + false); + pump(&fixture); + pocketjs_net_http_client_event_t headers = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + assert(headers.detail.response.status_code == 404U); + assert(headers.detail.response.header_count == 2U); + assert(headers.detail.response.status_text.length == 9U); + assert(memcmp(headers.detail.response.status_text.data, "Not Found", 9U) == + 0); + retire_event(&fixture, headers); + + assert(pocketjs_net_http_client_core_grant_body_credit(fixture.core, 1U, 3U)); + pump(&fixture); + pocketjs_net_http_client_event_t body = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_BODY); + const uint8_t *body_bytes = NULL; + size_t body_length = 0U; + assert(pocketjs_net_http_client_core_body_lease_view( + fixture.core, body.detail.body.lease, &body_bytes, &body_length)); + assert(body_length == 3U && memcmp(body_bytes, "hel", 3U) == 0); + assert( + !pocketjs_net_http_client_core_retire_event(fixture.core, body.sequence)); + assert(pocketjs_net_http_client_core_release_body_lease( + fixture.core, body.detail.body.lease)); + retire_event(&fixture, body); + + assert(pocketjs_net_http_client_core_grant_body_credit(fixture.core, 1U, 2U)); + pump(&fixture); + body = take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_BODY); + assert(pocketjs_net_http_client_core_body_lease_view( + fixture.core, body.detail.body.lease, &body_bytes, &body_length)); + assert(body_length == 2U && memcmp(body_bytes, "lo", 2U) == 0); + assert(pocketjs_net_http_client_core_release_body_lease( + fixture.core, body.detail.body.lease)); + retire_event(&fixture, body); + assert(pocketjs_net_http_client_core_grant_body_credit(fixture.core, 1U, 1U)); + pump(&fixture); + assert( + !pocketjs_net_http_client_core_grant_body_credit(fixture.core, 1U, 1U)); + assert(fixture.fake.active_kind == FAKE_CLOSE); + fake_complete_close(&fixture.fake); + pump(&fixture); + pocketjs_net_http_client_event_t complete = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + retire_event(&fixture, complete); + assert(!pocketjs_net_http_client_core_take_event(fixture.core, &complete)); + assert(fixture.fake.starts[FAKE_CONNECT] == 1U); +} + +static void test_response_header_limit_configuration_boundaries(void) { + fixture_t maximum; + fixture_init_with_response_header_limit( + &maximum, POCKETJS_NET_HTTP_CLIENT_CORE_MAX_RESPONSE_HEADER_BYTES); + assert( + pocketjs_net_http_client_core_begin_shutdown(maximum.core, maximum.now)); + assert(pocketjs_net_http_client_core_deinit(maximum.core)); + + pocketjs_net_http_client_core_storage_t invalid_storage = {0}; + pocketjs_net_http_client_core_t *invalid_core = maximum.core; + maximum.config.response_header_bytes_limit = + POCKETJS_NET_HTTP_CLIENT_CORE_MAX_RESPONSE_HEADER_BYTES + 1U; + assert(pocketjs_net_http_client_core_init(&invalid_storage, &maximum.config, + &invalid_core) == + POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT); + assert(invalid_core == NULL); + + fixture_t zero_uses_maximum; + fixture_init_with_response_header_limit(&zero_uses_maximum, 0U); + assert(pocketjs_net_http_client_core_begin_shutdown(zero_uses_maximum.core, + zero_uses_maximum.now)); + assert(pocketjs_net_http_client_core_deinit(zero_uses_maximum.core)); +} + +static void test_selected_response_header_limit(void) { + enum { SELECTED_HEADER_BYTES = 19U }; + + fixture_t exact; + fixture_init_with_response_header_limit(&exact, SELECTED_HEADER_BYTES); + connect_and_write_get(&exact, 1U, "http://example.com:8080/x?q=1"); + fake_complete_read(&exact.fake, + "HTTP/1.1 204 No Content\r\nX-Test: yes\r\nY: 1\r\n\r\n", + false); + pump(&exact); + pocketjs_net_http_client_event_t headers = + take_event(&exact, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + assert(headers.detail.response.header_count == 2U); + retire_event(&exact, headers); + assert(exact.fake.active_kind == FAKE_CLOSE); + fake_complete_close(&exact.fake); + pump(&exact); + pocketjs_net_http_client_event_t complete = + take_event(&exact, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + retire_event(&exact, complete); + + fixture_t exceeded; + fixture_init_with_response_header_limit(&exceeded, SELECTED_HEADER_BYTES); + connect_and_write_get(&exceeded, 1U, "http://example.com:8080/x?q=1"); + fake_complete_read(&exceeded.fake, + "HTTP/1.1 204 No Content\r\nX-Test: yes\r\nY: 12\r\n\r\n", + false); + pump(&exceeded); + pocketjs_net_http_client_event_t event; + assert(!pocketjs_net_http_client_core_take_event(exceeded.core, &event)); + assert(exceeded.fake.active_kind == FAKE_CLOSE); + fake_complete_close(&exceeded.fake); + pump(&exceeded); + event = take_event(&exceeded, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(event.detail.error.code == + POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT); + retire_event(&exceeded, event); + assert(!pocketjs_net_http_client_core_take_event(exceeded.core, &event)); +} + +static void test_https_and_permissions_fail_before_io(void) { + fixture_t fixture; + fixture_init(&fixture); + pocketjs_net_http_client_request_t request = + make_get(1U, "https://example.com/"); + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_UNSUPPORTED_TLS); + assert(fixture.permissions.hostname_calls == 0U); + assert(fixture.permissions.numeric_calls == 0U); + assert(fixture.fake.active_kind == FAKE_NONE); + + fixture.permissions.allow_hostname = false; + request = make_get(1U, "http://example.com/"); + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + assert(fixture.permissions.hostname_calls == 1U); + assert(fixture.fake.active_kind == FAKE_NONE); + pocketjs_net_http_client_event_t error = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == + POCKETJS_NET_HTTP_CLIENT_ERROR_PERMISSION_DENIED); + retire_event(&fixture, error); + + fixture_t tls; + fixture_init_with_options(&tls, 0U, true); + static const uint8_t server_name[] = "example.com"; + static const pocketjs_net_http_client_tls_policy_t tls_policy = { + .server_name = {.data = server_name, .length = sizeof(server_name) - 1U}, + .minimum_version = POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_2, + .maximum_version = POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_2, + .client_certificate = + POCKETJS_NET_HTTP_CLIENT_TLS_CLIENT_CERTIFICATE_NONE, + .verification = POCKETJS_NET_HTTP_CLIENT_TLS_VERIFICATION_FULL, + .revocation = POCKETJS_NET_HTTP_CLIENT_TLS_REVOCATION_HOST_DEFAULT, + }; + request = make_get(1U, "https://example.com/health"); + request.tls = &tls_policy; + assert(pocketjs_net_http_client_core_start(tls.core, &request, tls.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + assert(tls.permissions.hostname_calls == 1U); + assert(tls.fake.active_kind == FAKE_RESOLVE); + const uint32_t address = 0x0100007fU; + fake_complete_resolve(&tls.fake, &address, 1U); + pump(&tls); + assert(tls.permissions.numeric_calls == 1U); + assert(tls.fake.active_kind == FAKE_CONNECT); + assert(tls.fake.connect_tls); + assert(tls.fake.connect_port == 443U); + assert(strcmp(tls.fake.connect_hostname, "example.com") == 0); + fake_complete_connect(&tls.fake); + pump(&tls); + assert(tls.fake.active_kind == FAKE_WRITE); + assert(bytes_contain(tls.fake.write_bytes, tls.fake.write_length, + "GET /health HTTP/1.1\r\n")); + assert(bytes_contain(tls.fake.write_bytes, tls.fake.write_length, + "Host: example.com\r\n")); +} + +static void assert_tls_policy_rejected( + const char *url, const pocketjs_net_http_client_tls_policy_t *policy) { + fixture_t fixture; + fixture_init_with_options(&fixture, 0U, true); + pocketjs_net_http_client_request_t request = make_get(1U, url); + request.tls = policy; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_UNSUPPORTED_TLS); + assert(fixture.permissions.hostname_calls == 0U); + assert(fixture.permissions.numeric_calls == 0U); + assert(fixture.fake.active_kind == FAKE_NONE); +} + +static void test_https_base_policy_is_fail_closed(void) { + static const uint8_t server_name[] = "example.com"; + static const uint8_t wrong_name[] = "other.example"; + pocketjs_net_http_client_tls_policy_t policy = { + .server_name = {.data = server_name, .length = sizeof(server_name) - 1U}, + .minimum_version = POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_2, + .maximum_version = POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_2, + .client_certificate = + POCKETJS_NET_HTTP_CLIENT_TLS_CLIENT_CERTIFICATE_NONE, + .verification = POCKETJS_NET_HTTP_CLIENT_TLS_VERIFICATION_FULL, + .revocation = POCKETJS_NET_HTTP_CLIENT_TLS_REVOCATION_HOST_DEFAULT, + }; + assert_tls_policy_rejected("https://example.com/", NULL); + policy.server_name = (pocketjs_net_http_client_slice_t){ + .data = wrong_name, .length = sizeof(wrong_name) - 1U}; + assert_tls_policy_rejected("https://example.com/", &policy); + policy.server_name = (pocketjs_net_http_client_slice_t){ + .data = server_name, .length = sizeof(server_name) - 1U}; + policy.maximum_version = POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_3; + assert_tls_policy_rejected("https://example.com/", &policy); + policy.maximum_version = POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_2; + policy.alpn_count = 1U; + assert_tls_policy_rejected("https://example.com/", &policy); + policy.alpn_count = 0U; + policy.server_name = (pocketjs_net_http_client_slice_t){0}; + assert_tls_policy_rejected("https://127.0.0.1/", &policy); + + policy.server_name = (pocketjs_net_http_client_slice_t){ + .data = server_name, .length = sizeof(server_name) - 1U}; + assert_tls_policy_rejected("http://example.com/", &policy); +} + +static void assert_tls_error_mapping( + pocketjs_net_http_client_transport_error_t transport_error, + pocketjs_net_http_client_error_t core_error) { + fixture_t fixture; + fixture_init_with_options(&fixture, 0U, true); + static const uint8_t server_name[] = "example.com"; + static const pocketjs_net_http_client_tls_policy_t policy = { + .server_name = {.data = server_name, .length = sizeof(server_name) - 1U}, + .minimum_version = POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_2, + .maximum_version = POCKETJS_NET_HTTP_CLIENT_TLS_VERSION_1_2, + .client_certificate = + POCKETJS_NET_HTTP_CLIENT_TLS_CLIENT_CERTIFICATE_NONE, + .verification = POCKETJS_NET_HTTP_CLIENT_TLS_VERIFICATION_FULL, + .revocation = POCKETJS_NET_HTTP_CLIENT_TLS_REVOCATION_HOST_DEFAULT, + }; + pocketjs_net_http_client_request_t request = + make_get(1U, "https://example.com/"); + request.tls = &policy; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + const uint32_t addresses[] = {0x0100007fU, 0x0200007fU}; + fake_complete_resolve(&fixture.fake, addresses, 2U); + pump(&fixture); + assert(fixture.permissions.numeric_calls == 2U); + assert(fixture.fake.starts[FAKE_CONNECT] == 1U); + fake_queue_error(&fixture.fake, transport_error, 55); + pump(&fixture); + assert(fixture.fake.starts[FAKE_CONNECT] == 1U); + pocketjs_net_http_client_event_t error = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == core_error); + assert(error.detail.error.cause_code == 55); + retire_event(&fixture, error); +} + +static void test_tls_errors_remain_distinct(void) { + assert_tls_error_mapping( + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_CERTIFICATE_INVALID, + POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_CERTIFICATE_INVALID); + assert_tls_error_mapping( + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_HOSTNAME_MISMATCH, + POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_HOSTNAME_MISMATCH); + assert_tls_error_mapping( + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_HANDSHAKE_FAILED, + POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_HANDSHAKE_FAILED); + assert_tls_error_mapping( + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_VERSION_UNSUPPORTED, + POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_VERSION_UNSUPPORTED); + assert_tls_error_mapping(POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TLS_ALERT, + POCKETJS_NET_HTTP_CLIENT_ERROR_TLS_ALERT); +} + +static void test_all_candidates_checked_before_denial(void) { + fixture_t fixture; + fixture_init(&fixture); + for (size_t index = 0U; index < 4U; ++index) { + fixture.permissions.allowed_candidates[index] = false; + } + pocketjs_net_http_client_request_t request = + make_get(1U, "http://example.com/"); + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + uint32_t addresses[] = {0x0100000aU, 0x0200000aU, 0x0300000aU, 0x0400000aU}; + fake_complete_resolve(&fixture.fake, addresses, 4U); + pump(&fixture); + assert(fixture.permissions.numeric_calls == 4U); + assert(fixture.fake.starts[FAKE_CONNECT] == 0U); + pocketjs_net_http_client_event_t error = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == + POCKETJS_NET_HTTP_CLIENT_ERROR_PERMISSION_DENIED); + retire_event(&fixture, error); +} + +static void test_connect_error_tries_next_allowed_candidate(void) { + fixture_t fixture; + fixture_init(&fixture); + fixture.permissions.allowed_candidates[0] = true; + fixture.permissions.allowed_candidates[1] = false; + fixture.permissions.allowed_candidates[2] = true; + fixture.permissions.allowed_candidates[3] = true; + pocketjs_net_http_client_request_t request = + make_get(1U, "http://example.com/"); + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + const uint32_t addresses[] = {0x0100000aU, 0x0200000aU, 0x0300000aU, + 0x0400000aU}; + fake_complete_resolve(&fixture.fake, addresses, 4U); + pump(&fixture); + assert(fixture.permissions.numeric_calls == 4U); + assert(fixture.fake.starts[FAKE_CONNECT] == 1U); + assert(fixture.fake.connect_ipv4_be == addresses[0]); + const uint64_t connect_deadline = fixture.fake.active_deadline; + + fake_queue_error(&fixture.fake, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_CONNECT, 11); + pump(&fixture); + assert(fixture.fake.starts[FAKE_CONNECT] == 2U); + assert(fixture.fake.connect_ipv4_be == addresses[2]); + assert(fixture.fake.active_deadline == connect_deadline); + + fake_queue_error(&fixture.fake, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_CONNECT, 22); + pump(&fixture); + assert(fixture.fake.starts[FAKE_CONNECT] == 3U); + assert(fixture.fake.connect_ipv4_be == addresses[3]); + assert(fixture.fake.active_deadline == connect_deadline); + + fake_complete_connect(&fixture.fake); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_WRITE); +} + +static void test_connect_candidate_exhaustion_preserves_last_error(void) { + fixture_t fixture; + fixture_init(&fixture); + pocketjs_net_http_client_request_t request = + make_get(1U, "http://example.com/"); + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + const uint32_t addresses[] = {0x0100000aU, 0x0200000aU}; + fake_complete_resolve(&fixture.fake, addresses, 2U); + pump(&fixture); + assert(fixture.fake.connect_ipv4_be == addresses[0]); + + fake_queue_error(&fixture.fake, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_CONNECT, 11); + pump(&fixture); + assert(fixture.fake.connect_ipv4_be == addresses[1]); + assert(fixture.fake.starts[FAKE_CONNECT] == 2U); + + fake_queue_error(&fixture.fake, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_CONNECT, 22); + pump(&fixture); + assert(fixture.fake.starts[FAKE_CONNECT] == 2U); + pocketjs_net_http_client_event_t error = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == POCKETJS_NET_HTTP_CLIENT_ERROR_CONNECT); + assert(error.detail.error.cause_code == 22); + retire_event(&fixture, error); +} + +static void assert_candidate_error_does_not_retry( + pocketjs_net_http_client_transport_error_t transport_error, + pocketjs_net_http_client_error_t expected_error) { + fixture_t fixture; + fixture_init(&fixture); + pocketjs_net_http_client_request_t request = + make_get(1U, "http://example.com/"); + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + const uint32_t addresses[] = {0x0100000aU, 0x0200000aU}; + fake_complete_resolve(&fixture.fake, addresses, 2U); + pump(&fixture); + fake_queue_error(&fixture.fake, transport_error, 33); + pump(&fixture); + assert(fixture.fake.starts[FAKE_CONNECT] == 1U); + pocketjs_net_http_client_event_t error = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == expected_error); + assert(error.detail.error.cause_code == 33); + retire_event(&fixture, error); +} + +static void test_non_connect_errors_do_not_advance_candidates(void) { + assert_candidate_error_does_not_retry( + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_TIMED_OUT, + POCKETJS_NET_HTTP_CLIENT_ERROR_TIMED_OUT); + assert_candidate_error_does_not_retry( + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_IO, + POCKETJS_NET_HTTP_CLIENT_ERROR_IO); + assert_candidate_error_does_not_retry( + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_RESOURCE_LIMIT, + POCKETJS_NET_HTTP_CLIENT_ERROR_RESOURCE_LIMIT); + assert_candidate_error_does_not_retry( + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_ABORTED, + POCKETJS_NET_HTTP_CLIENT_ERROR_ABORTED); +} + +static void test_connect_error_does_not_retry_after_deadline(void) { + fixture_t fixture; + fixture_init(&fixture); + pocketjs_net_http_client_request_t request = + make_get(1U, "http://example.com/"); + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + const uint32_t addresses[] = {0x0100000aU, 0x0200000aU}; + fake_complete_resolve(&fixture.fake, addresses, 2U); + pump(&fixture); + fixture.now = fixture.fake.active_deadline; + fake_queue_error(&fixture.fake, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_CONNECT, 44); + pump(&fixture); + assert(fixture.fake.starts[FAKE_CONNECT] == 1U); + pocketjs_net_http_client_event_t error = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == POCKETJS_NET_HTTP_CLIENT_ERROR_CONNECT); + assert(error.detail.error.cause_code == 44); + retire_event(&fixture, error); +} + +static void test_protocol_error_closes_before_terminal(void) { + fixture_t fixture; + fixture_init(&fixture); + connect_and_write_get(&fixture, 1U, "http://example.com:8080/x?q=1"); + fake_complete_read(&fixture.fake, + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" + "0\r\nContent-Length: 1\r\n\r\n", + false); + pump(&fixture); + pocketjs_net_http_client_event_t headers = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + retire_event(&fixture, headers); + assert(fixture.fake.active_kind == FAKE_CLOSE); + pocketjs_net_http_client_event_t event; + assert(!pocketjs_net_http_client_core_take_event(fixture.core, &event)); + fake_complete_close(&fixture.fake); + pump(&fixture); + event = take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(event.detail.error.code == POCKETJS_NET_HTTP_CLIENT_ERROR_PROTOCOL); + retire_event(&fixture, event); +} + +static void test_chunked_body_and_valid_trailer(void) { + fixture_t fixture; + fixture_init(&fixture); + connect_and_write_get(&fixture, 1U, "http://example.com:8080/x?q=1"); + fake_complete_read(&fixture.fake, + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n" + "Trailer: X-Checksum\r\n\r\n3;ok=yes\r\nabc\r\n0\r\n" + "X-Checksum: 123\r\n\r\n", + false); + pump(&fixture); + pocketjs_net_http_client_event_t headers = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + retire_event(&fixture, headers); + assert( + pocketjs_net_http_client_core_grant_body_credit(fixture.core, 1U, 16U)); + pump(&fixture); + pocketjs_net_http_client_event_t body = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_BODY); + const uint8_t *bytes = NULL; + size_t length = 0U; + assert(pocketjs_net_http_client_core_body_lease_view( + fixture.core, body.detail.body.lease, &bytes, &length)); + assert(length == 3U && memcmp(bytes, "abc", 3U) == 0); + assert(pocketjs_net_http_client_core_release_body_lease( + fixture.core, body.detail.body.lease)); + retire_event(&fixture, body); + assert(fixture.fake.active_kind == FAKE_CLOSE); + fake_complete_close(&fixture.fake); + pump(&fixture); + pocketjs_net_http_client_event_t complete = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + retire_event(&fixture, complete); +} + +static void test_protocol_error_selected_before_body_pull(void) { + fixture_t fixture; + fixture_init(&fixture); + connect_and_write_get(&fixture, 1U, "http://example.com:8080/x?q=1"); + fake_complete_read(&fixture.fake, + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" + "Z\r\ninvalid\r\n", + false); + pump(&fixture); + pocketjs_net_http_client_event_t headers = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + retire_event(&fixture, headers); + + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_CLOSE); + assert( + pocketjs_net_http_client_core_grant_body_credit(fixture.core, 1U, 16U)); + assert( + !pocketjs_net_http_client_core_grant_body_credit(fixture.core, 1U, 16U)); + fake_complete_close(&fixture.fake); + pump(&fixture); + pocketjs_net_http_client_event_t error = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == POCKETJS_NET_HTTP_CLIENT_ERROR_PROTOCOL); + retire_event(&fixture, error); +} + +static void test_eof_delimited_body(void) { + fixture_t fixture; + fixture_init(&fixture); + connect_and_write_get(&fixture, 1U, "http://example.com:8080/x?q=1"); + fake_complete_read(&fixture.fake, "HTTP/1.1 200 OK\r\n\r\nabc", false); + pump(&fixture); + pocketjs_net_http_client_event_t headers = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + retire_event(&fixture, headers); + assert(pocketjs_net_http_client_core_grant_body_credit(fixture.core, 1U, 3U)); + pump(&fixture); + pocketjs_net_http_client_event_t body = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_BODY); + assert(pocketjs_net_http_client_core_release_body_lease( + fixture.core, body.detail.body.lease)); + retire_event(&fixture, body); + assert(pocketjs_net_http_client_core_grant_body_credit(fixture.core, 1U, 1U)); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_READ); + fake_complete_read(&fixture.fake, "", true); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_CLOSE); + fake_complete_close(&fixture.fake); + pump(&fixture); + pocketjs_net_http_client_event_t complete = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + retire_event(&fixture, complete); +} + +static void test_205_has_no_body(void) { + fixture_t fixture; + fixture_init(&fixture); + connect_and_write_get(&fixture, 1U, "http://example.com:8080/x?q=1"); + fake_complete_read(&fixture.fake, + "HTTP/1.1 205 Reset Content\r\nX-Test: yes\r\n\r\n", + false); + pump(&fixture); + pocketjs_net_http_client_event_t headers = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + assert(headers.detail.response.status_code == 205U); + retire_event(&fixture, headers); + assert(fixture.fake.active_kind == FAKE_CLOSE); + assert( + !pocketjs_net_http_client_core_grant_body_credit(fixture.core, 1U, 1U)); + fake_complete_close(&fixture.fake); + pump(&fixture); + pocketjs_net_http_client_event_t complete = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + retire_event(&fixture, complete); +} + +static void test_content_coding_fails_closed(void) { + fixture_t fixture; + fixture_init(&fixture); + connect_and_write_get(&fixture, 1U, "http://example.com:8080/x?q=1"); + fake_complete_read(&fixture.fake, + "HTTP/1.1 200 OK\r\nContent-Length: 1\r\n" + "Content-Encoding: gzip\r\n\r\nx", + false); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_CLOSE); + pocketjs_net_http_client_event_t event; + assert(!pocketjs_net_http_client_core_take_event(fixture.core, &event)); + fake_complete_close(&fixture.fake); + pump(&fixture); + event = take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(event.detail.error.code == POCKETJS_NET_HTTP_CLIENT_ERROR_PROTOCOL); + retire_event(&fixture, event); +} + +static void test_request_body_is_snapshotted(void) { + fixture_t fixture; + fixture_init(&fixture); + static const uint8_t url[] = "http://127.0.0.1/upload"; + static const uint8_t post[] = "POST"; + uint8_t source_body[] = "abc"; + pocketjs_net_http_client_request_t request = { + .operation_token = 1U, + .url = {.data = url, .length = sizeof(url) - 1U}, + .method = {.data = post, .length = sizeof(post) - 1U}, + .body_kind = POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_FIXED, + .body = {.data = source_body, .length = sizeof(source_body) - 1U}, + }; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + assert(fixture.permissions.hostname_calls == 0U); + assert(fixture.permissions.numeric_calls == 1U); + assert(fixture.fake.active_kind == FAKE_CONNECT); + source_body[0] = 'z'; + fake_complete_connect(&fixture.fake); + pump(&fixture); + assert(bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "Content-Length: 3\r\n")); + fake_complete_write(&fixture.fake); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_WRITE); + assert(fixture.fake.write_length == 3U); + assert(memcmp(fixture.fake.write_bytes, "abc", 3U) == 0); + fake_complete_write(&fixture.fake); + pump(&fixture); + fake_complete_read(&fixture.fake, "HTTP/1.1 204 No Content\r\n\r\n", false); + pump(&fixture); + pocketjs_net_http_client_event_t headers = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + retire_event(&fixture, headers); + fake_complete_close(&fixture.fake); + pump(&fixture); + pocketjs_net_http_client_event_t complete = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + retire_event(&fixture, complete); +} + +static void test_abort_exact_one(void) { + fixture_t fixture; + fixture_init(&fixture); + connect_and_write_get(&fixture, 1U, "http://example.com:8080/x?q=1"); + assert(fixture.fake.active_kind == FAKE_READ); + assert(pocketjs_net_http_client_core_abort(fixture.core, 1U)); + assert(fixture.fake.cancels == 1U); + pump(&fixture); + pocketjs_net_http_client_event_t error = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == POCKETJS_NET_HTTP_CLIENT_ERROR_ABORTED); + retire_event(&fixture, error); + pump(&fixture); + assert(!pocketjs_net_http_client_core_take_event(fixture.core, &error)); +} + +static void test_total_timeout_exact_one(void) { + fixture_t fixture; + fixture_init(&fixture); + pocketjs_net_http_client_request_t request = + make_get(1U, "http://example.com/"); + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + fixture.now += 1000001U; + assert(pocketjs_net_http_client_core_pump(fixture.core, fixture.now, 8U, 8U)); + pocketjs_net_http_client_event_t error = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == POCKETJS_NET_HTTP_CLIENT_ERROR_TIMED_OUT); + retire_event(&fixture, error); + pump(&fixture); + assert(!pocketjs_net_http_client_core_take_event(fixture.core, &error)); +} + +static void test_permission_callback_reentrancy_is_rejected(void) { + fixture_t fixture; + fixture_init(&fixture); + fixture.permissions.exercise_reentrancy = true; + pocketjs_net_http_client_request_t request = + make_get(1U, "http://example.com/"); + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + assert(fixture.permissions.reentrancy_calls == 1U); + assert(fixture.fake.active_kind == FAKE_RESOLVE); + uint32_t addresses[] = {0x0100007fU, 0x0200007fU, 0x0300007fU}; + fake_complete_resolve(&fixture.fake, addresses, 3U); + pump(&fixture); + assert(fixture.permissions.reentrancy_calls == 4U); + assert(fixture.fake.active_kind == FAKE_CONNECT); + pocketjs_net_http_client_core_status_t status = get_status(&fixture); + assert(!status.poisoned); + assert(status.request_active); + assert(pocketjs_net_http_client_core_abort(fixture.core, 1U)); + pump(&fixture); + pocketjs_net_http_client_event_t error = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == POCKETJS_NET_HTTP_CLIENT_ERROR_ABORTED); + retire_event(&fixture, error); +} + +static void test_read_bytes_with_eof_waits_for_full_consumption(void) { + fixture_t fixture; + fixture_init(&fixture); + connect_and_write_get(&fixture, 1U, "http://example.com:8080/x?q=1"); + fake_complete_read(&fixture.fake, + "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello", true); + pump(&fixture); + pocketjs_net_http_client_event_t headers = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + retire_event(&fixture, headers); + + const uint8_t *bytes = NULL; + size_t length = 0U; + assert(pocketjs_net_http_client_core_grant_body_credit(fixture.core, 1U, 3U)); + pump(&fixture); + pocketjs_net_http_client_event_t body = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_BODY); + assert(pocketjs_net_http_client_core_body_lease_view( + fixture.core, body.detail.body.lease, &bytes, &length)); + assert(length == 3U && memcmp(bytes, "hel", 3U) == 0); + assert(pocketjs_net_http_client_core_release_body_lease( + fixture.core, body.detail.body.lease)); + retire_event(&fixture, body); + + assert(pocketjs_net_http_client_core_grant_body_credit(fixture.core, 1U, 2U)); + pump(&fixture); + body = take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_BODY); + assert(pocketjs_net_http_client_core_body_lease_view( + fixture.core, body.detail.body.lease, &bytes, &length)); + assert(length == 2U && memcmp(bytes, "lo", 2U) == 0); + assert(pocketjs_net_http_client_core_release_body_lease( + fixture.core, body.detail.body.lease)); + retire_event(&fixture, body); + assert(fixture.fake.active_kind == FAKE_CLOSE); + fake_complete_close(&fixture.fake); + pump(&fixture); + pocketjs_net_http_client_event_t complete = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + retire_event(&fixture, complete); + + fixture_t eof_delimited; + fixture_init(&eof_delimited); + connect_and_write_get(&eof_delimited, 1U, "http://example.com:8080/x?q=1"); + fake_complete_read(&eof_delimited.fake, "HTTP/1.1 200 OK\r\n\r\nabc", true); + pump(&eof_delimited); + headers = take_event(&eof_delimited, + POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + retire_event(&eof_delimited, headers); + assert(pocketjs_net_http_client_core_grant_body_credit(eof_delimited.core, 1U, + 3U)); + pump(&eof_delimited); + body = take_event(&eof_delimited, POCKETJS_NET_HTTP_CLIENT_EVENT_BODY); + assert(pocketjs_net_http_client_core_body_lease_view( + eof_delimited.core, body.detail.body.lease, &bytes, &length)); + assert(length == 3U && memcmp(bytes, "abc", 3U) == 0); + assert(pocketjs_net_http_client_core_release_body_lease( + eof_delimited.core, body.detail.body.lease)); + retire_event(&eof_delimited, body); + assert(eof_delimited.fake.active_kind == FAKE_CLOSE); + fake_complete_close(&eof_delimited.fake); + pump(&eof_delimited); + complete = + take_event(&eof_delimited, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + retire_event(&eof_delimited, complete); +} + +static void test_head_informational_and_304_are_bodyless(void) { + fixture_t head_fixture; + fixture_init(&head_fixture); + static const uint8_t head[] = "HEAD"; + pocketjs_net_http_client_request_t head_request = + make_get(1U, "http://example.com:8080/x?q=1"); + head_request.method = (pocketjs_net_http_client_slice_t){ + .data = head, + .length = sizeof(head) - 1U, + }; + connect_and_write_request(&head_fixture, &head_request, + "HEAD /x?q=1 HTTP/1.1\r\n"); + fake_complete_read(&head_fixture.fake, + "HTTP/1.1 200 OK\r\nContent-Length: 4096\r\n\r\n", true); + pump(&head_fixture); + pocketjs_net_http_client_event_t headers = take_event( + &head_fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + assert(headers.detail.response.status_code == 200U); + retire_event(&head_fixture, headers); + assert(head_fixture.fake.active_kind == FAKE_CLOSE); + fake_complete_close(&head_fixture.fake); + pump(&head_fixture); + pocketjs_net_http_client_event_t complete = + take_event(&head_fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + retire_event(&head_fixture, complete); + + fixture_t not_modified_fixture; + fixture_init(¬_modified_fixture); + connect_and_write_get(¬_modified_fixture, 1U, + "http://example.com:8080/x?q=1"); + fake_complete_read( + ¬_modified_fixture.fake, + "HTTP/1.1 100 Continue\r\nX-I: ignored\r\n\r\n" + "HTTP/1.1 304 Not Modified\r\nContent-Length: 4096\r\n\r\n", + true); + pump(¬_modified_fixture); + headers = take_event(¬_modified_fixture, + POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + assert(headers.detail.response.status_code == 304U); + assert(headers.detail.response.header_count == 1U); + retire_event(¬_modified_fixture, headers); + assert(not_modified_fixture.fake.active_kind == FAKE_CLOSE); + fake_complete_close(¬_modified_fixture.fake); + pump(¬_modified_fixture); + complete = take_event(¬_modified_fixture, + POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + retire_event(¬_modified_fixture, complete); +} + +static void test_numeric_permission_denial_has_no_io(void) { + fixture_t fixture; + fixture_init(&fixture); + fixture.permissions.allowed_candidates[0] = false; + pocketjs_net_http_client_request_t request = + make_get(1U, "http://127.0.0.1/"); + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + assert(fixture.permissions.hostname_calls == 0U); + assert(fixture.permissions.numeric_calls == 1U); + assert(fixture.fake.active_kind == FAKE_NONE); + pocketjs_net_http_client_event_t error = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == + POCKETJS_NET_HTTP_CLIENT_ERROR_PERMISSION_DENIED); + retire_event(&fixture, error); +} + +static void test_maximum_request_body_boundary(void) { + fixture_t fixture; + fixture_init(&fixture); + static const uint8_t url[] = "http://127.0.0.1/upload"; + static const uint8_t post[] = "POST"; + uint8_t source_body[POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_BODY_BYTES]; + memset(source_body, 'a', sizeof(source_body)); + pocketjs_net_http_client_request_t request = { + .operation_token = 1U, + .url = {.data = url, .length = sizeof(url) - 1U}, + .method = {.data = post, .length = sizeof(post) - 1U}, + .body_kind = POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_FIXED, + .body = {.data = source_body, .length = sizeof(source_body)}, + }; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + memset(source_body, 'z', sizeof(source_body)); + fake_complete_connect(&fixture.fake); + pump(&fixture); + assert(bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "Content-Length: 4096\r\n")); + fake_complete_write(&fixture.fake); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_WRITE); + assert(fixture.fake.write_length == sizeof(source_body)); + for (size_t index = 0U; index < fixture.fake.write_length; ++index) { + assert(fixture.fake.write_bytes[index] == 'a'); + } + fake_complete_write(&fixture.fake); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_READ); + assert(pocketjs_net_http_client_core_abort(fixture.core, 1U)); + pump(&fixture); + pocketjs_net_http_client_event_t error = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == POCKETJS_NET_HTTP_CLIENT_ERROR_ABORTED); + retire_event(&fixture, error); +} + +static void test_streaming_chunked_upload_exceeds_64k(void) { + fixture_t fixture; + fixture_init(&fixture); + pocketjs_net_http_client_request_t request = + make_streaming_post(1U, false, 0U); + start_streaming_request(&fixture, &request); + + uint8_t chunk[POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES]; + memset(chunk, 'a', sizeof(chunk)); + uint64_t body_generation = 0U; + uint64_t previous_pull_generation = 0U; + uint64_t previous_event_sequence = 0U; + size_t total = 0U; + for (size_t index = 0U; index < 33U; ++index) { + chunk[0] = (uint8_t)('a' + index % 26U); + pocketjs_net_http_client_event_t pull = take_request_body_pull( + &fixture, POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES); + if (index == 0U) { + body_generation = pull.detail.request_body_pull.body_generation; + } else { + assert(pull.detail.request_body_pull.body_generation == body_generation); + } + assert(pull.detail.request_body_pull.pull_generation > + previous_pull_generation); + assert(pull.sequence > previous_event_sequence); + previous_pull_generation = pull.detail.request_body_pull.pull_generation; + previous_event_sequence = pull.sequence; + retire_request_body_pull(&fixture, pull); + assert(pocketjs_net_http_client_core_submit_request_body_chunk( + fixture.core, 1U, body_generation, previous_pull_generation, chunk, + sizeof(chunk))); + assert(fixture.fake.active_kind == FAKE_WRITE); + assert(fixture.fake.write_length == sizeof(chunk) + 7U); + assert(memcmp(fixture.fake.write_bytes, "800\r\n", 5U) == 0); + assert(memcmp(fixture.fake.write_bytes + 5U, chunk, sizeof(chunk)) == 0); + assert(memcmp(fixture.fake.write_bytes + 5U + sizeof(chunk), "\r\n", 2U) == + 0); + pocketjs_net_http_client_core_status_t status = get_status(&fixture); + assert(!status.request_body_credit_outstanding); + fake_complete_write(&fixture.fake); + pump(&fixture); + total += sizeof(chunk); + } + assert(total > 64U * 1024U); + + pocketjs_net_http_client_event_t end_pull = take_request_body_pull( + &fixture, POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES); + assert(end_pull.detail.request_body_pull.body_generation == body_generation); + assert(end_pull.detail.request_body_pull.pull_generation > + previous_pull_generation); + retire_request_body_pull(&fixture, end_pull); + assert(pocketjs_net_http_client_core_submit_request_body_end( + fixture.core, 1U, body_generation, + end_pull.detail.request_body_pull.pull_generation)); + assert(fixture.fake.active_kind == FAKE_WRITE); + assert(fixture.fake.write_length == 5U); + assert(memcmp(fixture.fake.write_bytes, "0\r\n\r\n", 5U) == 0); + fake_complete_write(&fixture.fake); + pump(&fixture); + finish_empty_response(&fixture); + + request = make_streaming_post(2U, false, 0U); + start_streaming_request(&fixture, &request); + pocketjs_net_http_client_event_t next_body_pull = take_request_body_pull( + &fixture, POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES); + assert(next_body_pull.detail.request_body_pull.body_generation > + body_generation); + assert(next_body_pull.detail.request_body_pull.pull_generation > + end_pull.detail.request_body_pull.pull_generation); + retire_request_body_pull(&fixture, next_body_pull); + assert(pocketjs_net_http_client_core_submit_request_body_end( + fixture.core, 2U, next_body_pull.detail.request_body_pull.body_generation, + next_body_pull.detail.request_body_pull.pull_generation)); + fake_complete_write(&fixture.fake); + pump(&fixture); + finish_empty_response(&fixture); +} + +static void test_streaming_credit_is_hostile_input_safe(void) { + fixture_t fixture; + fixture_init(&fixture); + pocketjs_net_http_client_request_t request = + make_streaming_post(1U, false, 0U); + static const uint8_t x[] = "x"; + assert(!pocketjs_net_http_client_core_submit_request_body_chunk( + fixture.core, 1U, 1U, 1U, x, 1U)); + assert(!pocketjs_net_http_client_core_submit_request_body_end(fixture.core, + 1U, 1U, 1U)); + assert(!pocketjs_net_http_client_core_submit_request_body_error( + fixture.core, 1U, 1U, 1U, 7)); + start_streaming_request(&fixture, &request); + + pocketjs_net_http_client_event_t first = take_request_body_pull( + &fixture, POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES); + uint64_t body_generation = first.detail.request_body_pull.body_generation; + uint64_t first_pull_generation = + first.detail.request_body_pull.pull_generation; + assert(!pocketjs_net_http_client_core_submit_request_body_chunk( + fixture.core, 1U, body_generation, first_pull_generation, x, 1U)); + assert(!pocketjs_net_http_client_core_submit_request_body_end( + fixture.core, 1U, body_generation, first_pull_generation)); + retire_request_body_pull(&fixture, first); + + uint8_t oversize[POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES + 1U]; + memset(oversize, 'z', sizeof(oversize)); + assert(!pocketjs_net_http_client_core_submit_request_body_chunk( + fixture.core, 2U, body_generation, first_pull_generation, x, 1U)); + assert(!pocketjs_net_http_client_core_submit_request_body_chunk( + fixture.core, 1U, body_generation + 1U, first_pull_generation, x, 1U)); + assert(!pocketjs_net_http_client_core_submit_request_body_chunk( + fixture.core, 1U, body_generation, first_pull_generation + 1U, x, 1U)); + assert(!pocketjs_net_http_client_core_submit_request_body_chunk( + fixture.core, 1U, body_generation, first_pull_generation, NULL, 1U)); + assert(!pocketjs_net_http_client_core_submit_request_body_chunk( + fixture.core, 1U, body_generation, first_pull_generation, x, 0U)); + assert(!pocketjs_net_http_client_core_submit_request_body_chunk( + fixture.core, 1U, body_generation, first_pull_generation, oversize, + sizeof(oversize))); + assert(get_status(&fixture).request_body_credit_outstanding); + assert(pocketjs_net_http_client_core_submit_request_body_chunk( + fixture.core, 1U, body_generation, first_pull_generation, x, 1U)); + assert(fixture.fake.write_length == 6U); + assert(memcmp(fixture.fake.write_bytes, "1\r\nx\r\n", 6U) == 0); + assert(!pocketjs_net_http_client_core_submit_request_body_chunk( + fixture.core, 1U, body_generation, first_pull_generation, x, 1U)); + assert(!pocketjs_net_http_client_core_submit_request_body_end( + fixture.core, 1U, body_generation, first_pull_generation)); + fake_complete_write(&fixture.fake); + pump(&fixture); + + pocketjs_net_http_client_event_t second = take_request_body_pull( + &fixture, POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES); + assert(second.detail.request_body_pull.pull_generation > + first_pull_generation); + retire_request_body_pull(&fixture, second); + assert(!pocketjs_net_http_client_core_submit_request_body_chunk( + fixture.core, 1U, body_generation, first_pull_generation, x, 1U)); + static const uint8_t y[] = "y"; + assert(pocketjs_net_http_client_core_submit_request_body_chunk( + fixture.core, 1U, body_generation, + second.detail.request_body_pull.pull_generation, y, 1U)); + assert(memcmp(fixture.fake.write_bytes, "1\r\ny\r\n", 6U) == 0); + fake_complete_write(&fixture.fake); + pump(&fixture); + pocketjs_net_http_client_event_t end_pull = take_request_body_pull( + &fixture, POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES); + retire_request_body_pull(&fixture, end_pull); + assert(pocketjs_net_http_client_core_submit_request_body_end( + fixture.core, 1U, body_generation, + end_pull.detail.request_body_pull.pull_generation)); + fake_complete_write(&fixture.fake); + pump(&fixture); + finish_empty_response(&fixture); +} + +static void test_known_length_streaming_accounting(void) { + fixture_t exact; + fixture_init(&exact); + pocketjs_net_http_client_request_t request = + make_streaming_post(1U, true, 5U); + start_streaming_request(&exact, &request); + pocketjs_net_http_client_event_t pull = take_request_body_pull(&exact, 5U); + uint64_t body_generation = pull.detail.request_body_pull.body_generation; + uint64_t pull_generation = pull.detail.request_body_pull.pull_generation; + retire_request_body_pull(&exact, pull); + static const uint8_t too_many[] = "123456"; + assert(!pocketjs_net_http_client_core_submit_request_body_chunk( + exact.core, 1U, body_generation, pull_generation, too_many, + sizeof(too_many) - 1U)); + assert(get_status(&exact).request_body_credit_outstanding); + static const uint8_t first[] = "12"; + assert(pocketjs_net_http_client_core_submit_request_body_chunk( + exact.core, 1U, body_generation, pull_generation, first, + sizeof(first) - 1U)); + assert(exact.fake.write_length == 2U); + assert(memcmp(exact.fake.write_bytes, "12", 2U) == 0); + fake_complete_write(&exact.fake); + pump(&exact); + pull = take_request_body_pull(&exact, 3U); + assert(pull.detail.request_body_pull.body_generation == body_generation); + assert(pull.detail.request_body_pull.pull_generation > pull_generation); + pull_generation = pull.detail.request_body_pull.pull_generation; + retire_request_body_pull(&exact, pull); + static const uint8_t last[] = "345"; + assert(pocketjs_net_http_client_core_submit_request_body_chunk( + exact.core, 1U, body_generation, pull_generation, last, + sizeof(last) - 1U)); + assert(exact.fake.write_length == 3U); + assert(memcmp(exact.fake.write_bytes, "345", 3U) == 0); + fake_complete_write(&exact.fake); + pump(&exact); + assert(exact.fake.active_kind == FAKE_READ); + assert(!pocketjs_net_http_client_core_submit_request_body_chunk( + exact.core, 1U, body_generation, pull_generation, first, + sizeof(first) - 1U)); + assert(!pocketjs_net_http_client_core_submit_request_body_end( + exact.core, 1U, body_generation, pull_generation)); + finish_empty_response(&exact); + + fixture_t underflow; + fixture_init(&underflow); + request = make_streaming_post(1U, true, 5U); + start_streaming_request(&underflow, &request); + pull = take_request_body_pull(&underflow, 5U); + body_generation = pull.detail.request_body_pull.body_generation; + pull_generation = pull.detail.request_body_pull.pull_generation; + retire_request_body_pull(&underflow, pull); + assert(pocketjs_net_http_client_core_submit_request_body_chunk( + underflow.core, 1U, body_generation, pull_generation, first, + sizeof(first) - 1U)); + fake_complete_write(&underflow.fake); + pump(&underflow); + pull = take_request_body_pull(&underflow, 3U); + pull_generation = pull.detail.request_body_pull.pull_generation; + retire_request_body_pull(&underflow, pull); + assert(pocketjs_net_http_client_core_submit_request_body_end( + underflow.core, 1U, body_generation, pull_generation)); + assert(underflow.fake.active_kind == FAKE_CLOSE); + assert(!pocketjs_net_http_client_core_submit_request_body_end( + underflow.core, 1U, body_generation, pull_generation)); + fake_complete_close(&underflow.fake); + pump(&underflow); + pocketjs_net_http_client_event_t error = + take_event(&underflow, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == + POCKETJS_NET_HTTP_CLIENT_ERROR_REQUEST_BODY); + assert(error.detail.error.cause_code == + POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_CAUSE_LENGTH_UNDERFLOW); + retire_event(&underflow, error); + assert(!pocketjs_net_http_client_core_take_event(underflow.core, &error)); + + fixture_t zero; + fixture_init(&zero); + request = make_streaming_post(1U, true, 0U); + start_streaming_request(&zero, &request); + assert(zero.fake.active_kind == FAKE_READ); + pocketjs_net_http_client_event_t unexpected; + assert(!pocketjs_net_http_client_core_take_event(zero.core, &unexpected)); + finish_empty_response(&zero); +} + +static void complete_streaming_error(fixture_t *fixture, + pocketjs_net_http_client_error_t code, + int32_t cause) { + assert(fixture->fake.active_kind == FAKE_CLOSE); + fake_complete_close(&fixture->fake); + pump(fixture); + pocketjs_net_http_client_event_t error = + take_event(fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == code); + assert(error.detail.error.cause_code == cause); + retire_event(fixture, error); + assert(!pocketjs_net_http_client_core_take_event(fixture->core, &error)); +} + +static void test_streaming_cancel_timeout_error_and_teardown(void) { + static const uint8_t x[] = "x"; + + fixture_t aborted; + fixture_init(&aborted); + pocketjs_net_http_client_request_t request = + make_streaming_post(1U, false, 0U); + start_streaming_request(&aborted, &request); + pocketjs_net_http_client_event_t pull = take_request_body_pull( + &aborted, POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES); + uint64_t body_generation = pull.detail.request_body_pull.body_generation; + uint64_t pull_generation = pull.detail.request_body_pull.pull_generation; + assert(pocketjs_net_http_client_core_abort(aborted.core, 1U)); + assert(!get_status(&aborted).request_body_credit_outstanding); + assert( + pocketjs_net_http_client_core_retire_event(aborted.core, pull.sequence)); + assert(!pocketjs_net_http_client_core_submit_request_body_chunk( + aborted.core, 1U, body_generation, pull_generation, x, 1U)); + complete_streaming_error(&aborted, POCKETJS_NET_HTTP_CLIENT_ERROR_ABORTED, 0); + + fixture_t timed_out; + fixture_init(&timed_out); + request = make_streaming_post(1U, false, 0U); + start_streaming_request(&timed_out, &request); + pull = take_request_body_pull( + &timed_out, POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES); + body_generation = pull.detail.request_body_pull.body_generation; + pull_generation = pull.detail.request_body_pull.pull_generation; + retire_request_body_pull(&timed_out, pull); + timed_out.now += timed_out.config.total_timeout_us + 1U; + assert(pocketjs_net_http_client_core_pump(timed_out.core, timed_out.now, 8U, + 8U)); + assert(!get_status(&timed_out).request_body_credit_outstanding); + assert(!pocketjs_net_http_client_core_submit_request_body_end( + timed_out.core, 1U, body_generation, pull_generation)); + complete_streaming_error(&timed_out, POCKETJS_NET_HTTP_CLIENT_ERROR_TIMED_OUT, + 0); + + fixture_t producer_error; + fixture_init(&producer_error); + request = make_streaming_post(1U, false, 0U); + start_streaming_request(&producer_error, &request); + pull = take_request_body_pull( + &producer_error, POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES); + body_generation = pull.detail.request_body_pull.body_generation; + pull_generation = pull.detail.request_body_pull.pull_generation; + retire_request_body_pull(&producer_error, pull); + assert(pocketjs_net_http_client_core_submit_request_body_error( + producer_error.core, 1U, body_generation, pull_generation, 31337)); + assert(!pocketjs_net_http_client_core_submit_request_body_error( + producer_error.core, 1U, body_generation, pull_generation, 99)); + complete_streaming_error(&producer_error, + POCKETJS_NET_HTTP_CLIENT_ERROR_REQUEST_BODY, 31337); + + fixture_t teardown; + fixture_init(&teardown); + request = make_streaming_post(1U, false, 0U); + start_streaming_request(&teardown, &request); + pull = take_request_body_pull( + &teardown, POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES); + body_generation = pull.detail.request_body_pull.body_generation; + pull_generation = pull.detail.request_body_pull.pull_generation; + retire_request_body_pull(&teardown, pull); + ++teardown.now; + assert(pocketjs_net_http_client_core_begin_shutdown(teardown.core, + teardown.now)); + assert(!pocketjs_net_http_client_core_submit_request_body_chunk( + teardown.core, 1U, body_generation, pull_generation, x, 1U)); + complete_streaming_error(&teardown, POCKETJS_NET_HTTP_CLIENT_ERROR_ABORTED, + 0); + assert(pocketjs_net_http_client_core_is_quiescent(teardown.core)); + assert(pocketjs_net_http_client_core_deinit(teardown.core)); +} + +static void test_request_body_mode_validation(void) { + fixture_t fixture; + fixture_init(&fixture); + static const uint8_t x[] = "x"; + pocketjs_net_http_client_request_t request = + make_get(1U, "http://127.0.0.1/"); + request.body_kind = POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_STREAMING; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_FORBIDDEN_REQUEST); + request.body_kind = POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_FIXED; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_FORBIDDEN_REQUEST); + static const uint8_t head[] = "HEAD"; + request.method = (pocketjs_net_http_client_slice_t){ + .data = head, + .length = sizeof(head) - 1U, + }; + request.body_kind = POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_STREAMING; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_FORBIDDEN_REQUEST); + + static const uint8_t post[] = "POST"; + request.method = (pocketjs_net_http_client_slice_t){ + .data = post, + .length = sizeof(post) - 1U, + }; + request.body_kind = POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_NONE; + request.body = (pocketjs_net_http_client_slice_t){.data = x, .length = 1U}; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT); + request.body = (pocketjs_net_http_client_slice_t){0}; + request.streaming_content_length_known = true; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT); + request.body_kind = POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_FIXED; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT); + request.streaming_content_length_known = false; + request.body_kind = POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_FIXED; + request.body = (pocketjs_net_http_client_slice_t){ + .data = x, + .length = POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REQUEST_BODY_BYTES + 1U, + }; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_LIMIT_EXCEEDED); + request.body_kind = POCKETJS_NET_HTTP_CLIENT_REQUEST_BODY_STREAMING; + request.body = (pocketjs_net_http_client_slice_t){.data = x, .length = 1U}; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT); + request.body = (pocketjs_net_http_client_slice_t){0}; + request.streaming_content_length = 1U; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT); + request.streaming_content_length = 0U; + request.body_kind = (pocketjs_net_http_client_request_body_kind_t)UINT32_MAX; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT); + assert(fixture.fake.active_kind == FAKE_NONE); +} + +static void test_idle_stale_read_is_cleanup_only(void) { + fixture_t fixture; + fixture_init(&fixture); + fixture.fake.read_bytes[0] = 'x'; + fixture.fake.read_length = 1U; + fixture.fake.read_lease_generation = 1U; + fixture.fake.read_lease_active = true; + fixture.fake.completion = (pocketjs_net_http_client_transport_completion_t){ + .type = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_READ, + .operation_token = 777U, + .detail.read = + { + .connection = {.slot = 9U, .generation = 9U}, + .lease = {.slot = 0U, .generation = 1U}, + .byte_count = 1U, + .eof = false, + }, + }; + fixture.fake.completion_queued = true; + pump(&fixture); + assert(fixture.fake.releases == 1U); + assert(fixture.fake.retires == 1U); + pocketjs_net_http_client_core_status_t status = get_status(&fixture); + assert(status.poisoned); + assert((status.poison_flags & + POCKETJS_NET_HTTP_CLIENT_POISON_STALE_COMPLETION) != 0U); + assert(status.operation_token == 0U); + assert(!status.event_outstanding); + pocketjs_net_http_client_event_t event; + assert(!pocketjs_net_http_client_core_take_event(fixture.core, &event)); + pocketjs_net_http_client_request_t request = + make_get(1U, "http://example.com/"); + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_POISONED); +} + +static void test_close_error_preserves_success_and_explicit_teardown(void) { + fixture_t fixture; + fixture_init(&fixture); + connect_and_write_get(&fixture, 1U, "http://example.com:8080/x?q=1"); + fake_complete_read(&fixture.fake, "HTTP/1.1 204 No Content\r\n\r\n", false); + pump(&fixture); + pocketjs_net_http_client_event_t headers = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + retire_event(&fixture, headers); + assert(fixture.fake.active_kind == FAKE_CLOSE); + fake_queue_error(&fixture.fake, POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_IO, + 77); + pump(&fixture); + pocketjs_net_http_client_event_t complete = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + pocketjs_net_http_client_core_status_t status = get_status(&fixture); + assert(status.poisoned && status.connection_owned); + assert((status.poison_flags & + POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_COMPLETION) != 0U); + assert(status.first_poison_cause_code == 77); + retire_event(&fixture, complete); + assert(!pocketjs_net_http_client_core_is_quiescent(fixture.core)); + assert( + pocketjs_net_http_client_core_begin_shutdown(fixture.core, fixture.now)); + assert( + pocketjs_net_http_client_core_confirm_transport_shutdown(fixture.core)); + assert(pocketjs_net_http_client_core_is_quiescent(fixture.core)); + assert(pocketjs_net_http_client_core_deinit(fixture.core)); + assert(!pocketjs_net_http_client_core_get_status(fixture.core, &status)); + pocketjs_net_http_client_core_t *reinitialized = NULL; + assert(pocketjs_net_http_client_core_init(&fixture.storage, &fixture.config, + &reinitialized) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + assert(reinitialized != NULL); +} + +static void test_close_error_preserves_protocol_failure(void) { + fixture_t fixture; + fixture_init(&fixture); + connect_and_write_get(&fixture, 1U, "http://example.com:8080/x?q=1"); + fake_complete_read(&fixture.fake, + "HTTP/1.1 200 OK\r\nContent-Length: 1\r\n" + "Content-Encoding: gzip\r\n\r\nx", + false); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_CLOSE); + fake_queue_error(&fixture.fake, POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_IO, + 88); + pump(&fixture); + pocketjs_net_http_client_event_t error = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == POCKETJS_NET_HTTP_CLIENT_ERROR_PROTOCOL); + pocketjs_net_http_client_core_status_t status = get_status(&fixture); + assert((status.poison_flags & + POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_COMPLETION) != 0U); + assert(status.first_poison_cause_code == 88); + retire_event(&fixture, error); +} + +static void test_close_admission_does_not_replace_terminals(void) { + fixture_t success_fixture; + fixture_init(&success_fixture); + connect_and_write_get(&success_fixture, 1U, "http://example.com:8080/x?q=1"); + success_fixture.fake.start_failure[FAKE_CLOSE] = + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_BUSY; + fake_complete_read(&success_fixture.fake, "HTTP/1.1 204 No Content\r\n\r\n", + false); + pump(&success_fixture); + pocketjs_net_http_client_event_t headers = take_event( + &success_fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + retire_event(&success_fixture, headers); + pocketjs_net_http_client_event_t complete = + take_event(&success_fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + pocketjs_net_http_client_core_status_t status = get_status(&success_fixture); + assert((status.poison_flags & + POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_ADMISSION) != 0U); + assert(status.connection_owned); + retire_event(&success_fixture, complete); + + fixture_t error_fixture; + fixture_init(&error_fixture); + connect_and_write_get(&error_fixture, 1U, "http://example.com:8080/x?q=1"); + error_fixture.fake.start_failure[FAKE_CLOSE] = + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_BUSY; + fake_complete_read(&error_fixture.fake, + "HTTP/1.1 200 OK\r\nContent-Length: 1\r\n" + "Content-Encoding: gzip\r\n\r\nx", + false); + pump(&error_fixture); + pocketjs_net_http_client_event_t error = + take_event(&error_fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == POCKETJS_NET_HTTP_CLIENT_ERROR_PROTOCOL); + status = get_status(&error_fixture); + assert((status.poison_flags & + POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_ADMISSION) != 0U); + retire_event(&error_fixture, error); +} + +static void test_close_timeout_preserves_success(void) { + fixture_t fixture; + fixture_init(&fixture); + connect_and_write_get(&fixture, 1U, "http://example.com:8080/x?q=1"); + fake_complete_read(&fixture.fake, "HTTP/1.1 204 No Content\r\n\r\n", false); + pump(&fixture); + pocketjs_net_http_client_event_t headers = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + retire_event(&fixture, headers); + assert(fixture.fake.active_kind == FAKE_CLOSE); + fixture.now += 1000001U; + assert(pocketjs_net_http_client_core_pump(fixture.core, fixture.now, 8U, 8U)); + assert(fixture.fake.cancels == 1U); + pocketjs_net_http_client_event_t complete = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + pocketjs_net_http_client_core_status_t status = get_status(&fixture); + assert((status.poison_flags & + POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_TIMEOUT) != 0U); + retire_event(&fixture, complete); +} + +static void test_read_lease_release_failure_is_auditable(void) { + fixture_t fixture; + fixture_init(&fixture); + connect_and_write_get(&fixture, 1U, "http://example.com:8080/x?q=1"); + fake_complete_read(&fixture.fake, + "HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\nx", false); + pump(&fixture); + pocketjs_net_http_client_event_t headers = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + retire_event(&fixture, headers); + fixture.fake.lease_release_failure = + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_FAILED; + assert(pocketjs_net_http_client_core_grant_body_credit(fixture.core, 1U, 1U)); + pump(&fixture); + pocketjs_net_http_client_event_t body = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_BODY); + pocketjs_net_http_client_core_status_t status = get_status(&fixture); + assert(status.transport_read_leases_owned == 1U); + assert((status.poison_flags & + POCKETJS_NET_HTTP_CLIENT_POISON_READ_LEASE_RELEASE) != 0U); + assert(pocketjs_net_http_client_core_release_body_lease( + fixture.core, body.detail.body.lease)); + retire_event(&fixture, body); + fixture.fake.lease_release_failure = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK; + pump(&fixture); + status = get_status(&fixture); + assert(status.transport_read_leases_owned == 0U); + assert(fixture.fake.releases == 1U); + assert(fixture.fake.active_kind == FAKE_CLOSE); + fake_complete_close(&fixture.fake); + pump(&fixture); + pocketjs_net_http_client_event_t complete = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + retire_event(&fixture, complete); + pocketjs_net_http_client_request_t request = + make_get(2U, "http://example.com/"); + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_POISONED); +} + +static void test_unexpected_read_payload_is_released_in_every_phase(void) { + pocketjs_net_http_client_transport_connection_t unexpected_connection = { + .slot = 42U, + .generation = 42U, + }; + + fixture_t resolving; + fixture_init(&resolving); + pocketjs_net_http_client_request_t request = + make_get(1U, "http://example.com/"); + assert(pocketjs_net_http_client_core_start(resolving.core, &request, + resolving.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + fake_complete_unexpected_read(&resolving.fake, unexpected_connection, "x", + false); + pump(&resolving); + assert(resolving.fake.releases == 1U); + pocketjs_net_http_client_event_t error = + take_event(&resolving, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT); + retire_event(&resolving, error); + + fixture_t connecting; + fixture_init(&connecting); + request = make_get(1U, "http://127.0.0.1/"); + assert(pocketjs_net_http_client_core_start(connecting.core, &request, + connecting.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + fake_complete_unexpected_read(&connecting.fake, unexpected_connection, "x", + false); + pump(&connecting); + assert(connecting.fake.releases == 1U); + error = take_event(&connecting, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT); + retire_event(&connecting, error); + + fixture_t writing; + fixture_init(&writing); + request = make_get(1U, "http://example.com/"); + assert(pocketjs_net_http_client_core_start(writing.core, &request, + writing.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + uint32_t address = 0x0100007fU; + fake_complete_resolve(&writing.fake, &address, 1U); + pump(&writing); + fake_complete_connect(&writing.fake); + pump(&writing); + assert(writing.fake.active_kind == FAKE_WRITE); + fake_complete_unexpected_read(&writing.fake, writing.fake.connection, "x", + false); + pump(&writing); + assert(writing.fake.releases == 1U); + assert(writing.fake.active_kind == FAKE_CLOSE); + fake_complete_close(&writing.fake); + pump(&writing); + error = take_event(&writing, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT); + retire_event(&writing, error); + + fixture_t closing; + fixture_init(&closing); + connect_and_write_get(&closing, 1U, "http://example.com:8080/x?q=1"); + fake_complete_read(&closing.fake, "HTTP/1.1 204 No Content\r\n\r\n", false); + pump(&closing); + pocketjs_net_http_client_event_t headers = + take_event(&closing, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + retire_event(&closing, headers); + assert(closing.fake.active_kind == FAKE_CLOSE); + size_t releases_before_unexpected_close = closing.fake.releases; + fake_complete_unexpected_read(&closing.fake, closing.fake.connection, "x", + false); + pump(&closing); + assert(closing.fake.releases == releases_before_unexpected_close + 1U); + pocketjs_net_http_client_event_t complete = + take_event(&closing, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + pocketjs_net_http_client_core_status_t status = get_status(&closing); + assert((status.poison_flags & + POCKETJS_NET_HTTP_CLIENT_POISON_CLOSE_COMPLETION) != 0U); + retire_event(&closing, complete); +} + +static void test_malformed_read_releases_and_view_failure_retains(void) { + fixture_t malformed; + fixture_init(&malformed); + connect_and_write_get(&malformed, 1U, "http://example.com:8080/x?q=1"); + malformed.fake.read_bytes[0] = 'x'; + malformed.fake.read_length = 1U; + ++malformed.fake.read_lease_generation; + malformed.fake.read_lease_active = true; + fake_queue( + &malformed.fake, + (pocketjs_net_http_client_transport_completion_t){ + .type = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_READ, + .detail.read = + { + .connection = {.slot = 99U, .generation = 99U}, + .lease = {.slot = 0U, + .generation = malformed.fake.read_lease_generation}, + .byte_count = 1U, + .eof = false, + }, + }); + pump(&malformed); + assert(malformed.fake.release_attempts == 1U); + assert(malformed.fake.releases == 1U); + assert(malformed.fake.active_kind == FAKE_CLOSE); + fake_complete_close(&malformed.fake); + pump(&malformed); + pocketjs_net_http_client_event_t error = + take_event(&malformed, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT); + retire_event(&malformed, error); + + fixture_t bad_view; + fixture_init(&bad_view); + connect_and_write_get(&bad_view, 1U, "http://example.com:8080/x?q=1"); + bad_view.fake.lease_view_failure = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_INVALID; + bad_view.fake.lease_release_failure = + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_FAILED; + fake_complete_read(&bad_view.fake, "x", false); + pump(&bad_view); + pocketjs_net_http_client_core_status_t status = get_status(&bad_view); + assert(status.transport_read_leases_owned == 1U); + assert((status.poison_flags & + POCKETJS_NET_HTTP_CLIENT_POISON_READ_LEASE_RELEASE) != 0U); + bad_view.fake.lease_release_failure = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK; + pump(&bad_view); + status = get_status(&bad_view); + assert(status.transport_read_leases_owned == 0U); + assert(bad_view.fake.releases == 1U); + assert(bad_view.fake.active_kind == FAKE_CLOSE); + fake_complete_close(&bad_view.fake); + pump(&bad_view); + error = take_event(&bad_view, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT); + retire_event(&bad_view, error); +} + +static void test_completion_retire_failure_is_poisoned_and_retried(void) { + fixture_t fixture; + fixture_init(&fixture); + pocketjs_net_http_client_request_t request = + make_get(1U, "http://example.com/"); + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + uint32_t address = 0x0100007fU; + fake_complete_resolve(&fixture.fake, &address, 1U); + fixture.fake.retire_failure = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_FAILED; + pump(&fixture); + pocketjs_net_http_client_core_status_t status = get_status(&fixture); + assert(status.completion_retire_pending); + assert((status.poison_flags & + POCKETJS_NET_HTTP_CLIENT_POISON_COMPLETION_RETIRE) != 0U); + fixture.fake.retire_failure = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_OK; + pump(&fixture); + status = get_status(&fixture); + assert(!status.completion_retire_pending); + pocketjs_net_http_client_event_t error = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + assert(error.detail.error.code == POCKETJS_NET_HTTP_CLIENT_ERROR_TRANSPORT); + retire_event(&fixture, error); +} + +static void test_idle_transport_faults_do_not_publish(void) { + fixture_t pump_failure; + fixture_init(&pump_failure); + pump_failure.fake.pump_failure = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_FAILED; + pump(&pump_failure); + pocketjs_net_http_client_core_status_t status = get_status(&pump_failure); + assert((status.poison_flags & + POCKETJS_NET_HTTP_CLIENT_POISON_TRANSPORT_PUMP) != 0U); + assert(status.operation_token == 0U && !status.event_outstanding); + + fixture_t take_failure; + fixture_init(&take_failure); + take_failure.fake.take_failure = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_FAILED; + pump(&take_failure); + status = get_status(&take_failure); + assert((status.poison_flags & + POCKETJS_NET_HTTP_CLIENT_POISON_COMPLETION_TAKE) != 0U); + assert(status.operation_token == 0U && !status.event_outstanding); +} + +static void test_native_only_pump_budget(void) { + fixture_t fixture; + fixture_init(&fixture); + pocketjs_net_http_client_request_t request = + make_get(1U, "http://example.com/"); + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + uint32_t address = 0x0100007fU; + fake_complete_resolve(&fixture.fake, &address, 1U); + + ++fixture.now; + assert(pocketjs_net_http_client_core_pump(fixture.core, fixture.now, 1U, 0U)); + assert(fixture.fake.pump_calls == 1U); + assert(fixture.fake.take_attempts == 0U); + assert(fixture.fake.completion_queued); + pocketjs_net_http_client_core_status_t status = get_status(&fixture); + assert(!status.poisoned && status.poison_flags == 0U); +} + +static void test_completion_only_pump_budget(void) { + fixture_t fixture; + fixture_init(&fixture); + pocketjs_net_http_client_request_t request = + make_get(1U, "http://example.com/"); + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + uint32_t address = 0x0100007fU; + fake_complete_resolve(&fixture.fake, &address, 1U); + + ++fixture.now; + assert(pocketjs_net_http_client_core_pump(fixture.core, fixture.now, 0U, 1U)); + assert(fixture.fake.pump_calls == 0U); + assert(fixture.fake.take_attempts == 1U); + assert(!fixture.fake.completion_queued); + assert(fixture.fake.active_kind == FAKE_CONNECT); + pocketjs_net_http_client_core_status_t status = get_status(&fixture); + assert(!status.poisoned && status.poison_flags == 0U); +} + +static void test_empty_pump_budget_is_rejected_without_poison(void) { + fixture_t fixture; + fixture_init(&fixture); + fixture.fake.pump_failure = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_FAILED; + fixture.fake.take_failure = POCKETJS_NET_HTTP_CLIENT_TRANSPORT_FAILED; + + assert( + !pocketjs_net_http_client_core_pump(fixture.core, fixture.now, 0U, 0U)); + assert(fixture.fake.pump_calls == 0U); + assert(fixture.fake.take_attempts == 0U); + pocketjs_net_http_client_core_status_t status = get_status(&fixture); + assert(!status.poisoned && status.poison_flags == 0U); +} + +static void test_poison_shutdown_can_abandon_exact_host_event(void) { + fixture_t healthy; + fixture_init(&healthy); + assert(!pocketjs_net_http_client_core_abandon_event_after_transport_shutdown( + healthy.core, 1U)); + connect_and_write_get(&healthy, 1U, "http://example.com:8080/x?q=1"); + fake_complete_read(&healthy.fake, "HTTP/1.1 204 No Content\r\n\r\n", false); + pump(&healthy); + pocketjs_net_http_client_event_t headers = + take_event(&healthy, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + retire_event(&healthy, headers); + fake_complete_close(&healthy.fake); + pump(&healthy); + pocketjs_net_http_client_event_t complete = + take_event(&healthy, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + assert(pocketjs_net_http_client_core_begin_shutdown(healthy.core, + ++healthy.now)); + assert( + pocketjs_net_http_client_core_confirm_transport_shutdown(healthy.core)); + assert(!pocketjs_net_http_client_core_abandon_event_after_transport_shutdown( + healthy.core, complete.sequence)); + retire_event(&healthy, complete); + assert(pocketjs_net_http_client_core_is_quiescent(healthy.core)); + assert(pocketjs_net_http_client_core_deinit(healthy.core)); + + fixture_t body_fixture; + fixture_init(&body_fixture); + connect_and_write_get(&body_fixture, 1U, "http://example.com:8080/x?q=1"); + fake_complete_read(&body_fixture.fake, + "HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\nx", false); + pump(&body_fixture); + headers = take_event(&body_fixture, + POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + retire_event(&body_fixture, headers); + assert(pocketjs_net_http_client_core_grant_body_credit(body_fixture.core, 1U, + 1U)); + pump(&body_fixture); + pocketjs_net_http_client_event_t body = + take_event(&body_fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_BODY); + const uint8_t *body_bytes = NULL; + size_t body_length = 0U; + assert(pocketjs_net_http_client_core_body_lease_view( + body_fixture.core, body.detail.body.lease, &body_bytes, &body_length)); + assert(body_length == 1U && body_bytes[0] == 'x'); + assert(pocketjs_net_http_client_core_report_host_event_retire_failure( + body_fixture.core, body.sequence)); + assert(get_status(&body_fixture).poisoned); + assert(!pocketjs_net_http_client_core_abandon_event_after_transport_shutdown( + body_fixture.core, body.sequence)); + assert(pocketjs_net_http_client_core_begin_shutdown(body_fixture.core, + ++body_fixture.now)); + assert(!pocketjs_net_http_client_core_abandon_event_after_transport_shutdown( + body_fixture.core, body.sequence)); + assert(pocketjs_net_http_client_core_confirm_transport_shutdown( + body_fixture.core)); + assert(!pocketjs_net_http_client_core_abandon_event_after_transport_shutdown( + body_fixture.core, body.sequence + 1U)); + assert(pocketjs_net_http_client_core_abandon_event_after_transport_shutdown( + body_fixture.core, body.sequence)); + assert(!pocketjs_net_http_client_core_body_lease_view( + body_fixture.core, body.detail.body.lease, &body_bytes, &body_length)); + pocketjs_net_http_client_event_t error = + take_event(&body_fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + retire_event(&body_fixture, error); + assert(pocketjs_net_http_client_core_is_quiescent(body_fixture.core)); + assert(pocketjs_net_http_client_core_deinit(body_fixture.core)); + + fixture_t pull_fixture; + fixture_init(&pull_fixture); + pocketjs_net_http_client_request_t request = + make_streaming_post(1U, false, 0U); + start_streaming_request(&pull_fixture, &request); + pocketjs_net_http_client_event_t pull = take_request_body_pull( + &pull_fixture, POCKETJS_NET_HTTP_CLIENT_CORE_REQUEST_BODY_CHUNK_BYTES); + assert(pocketjs_net_http_client_core_report_host_event_retire_failure( + pull_fixture.core, pull.sequence)); + assert(get_status(&pull_fixture).poisoned); + assert(pocketjs_net_http_client_core_begin_shutdown(pull_fixture.core, + ++pull_fixture.now)); + assert(pocketjs_net_http_client_core_confirm_transport_shutdown( + pull_fixture.core)); + assert(pocketjs_net_http_client_core_abandon_event_after_transport_shutdown( + pull_fixture.core, pull.sequence)); + pocketjs_net_http_client_core_status_t status = get_status(&pull_fixture); + assert(!status.request_body_credit_outstanding && status.event_outstanding); + error = take_event(&pull_fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_ERROR); + retire_event(&pull_fixture, error); + assert(pocketjs_net_http_client_core_is_quiescent(pull_fixture.core)); + assert(pocketjs_net_http_client_core_deinit(pull_fixture.core)); + + fixture_t terminal_fixture; + fixture_init(&terminal_fixture); + connect_and_write_get(&terminal_fixture, 1U, "http://example.com:8080/x?q=1"); + fake_complete_read(&terminal_fixture.fake, "HTTP/1.1 204 No Content\r\n\r\n", + false); + pump(&terminal_fixture); + headers = take_event(&terminal_fixture, + POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + retire_event(&terminal_fixture, headers); + fake_queue_error(&terminal_fixture.fake, + POCKETJS_NET_HTTP_CLIENT_TRANSPORT_ERROR_IO, 91); + pump(&terminal_fixture); + status = get_status(&terminal_fixture); + assert(status.poisoned && status.event_outstanding); + assert(pocketjs_net_http_client_core_begin_shutdown(terminal_fixture.core, + ++terminal_fixture.now)); + assert(pocketjs_net_http_client_core_confirm_transport_shutdown( + terminal_fixture.core)); + assert(!pocketjs_net_http_client_core_abandon_event_after_transport_shutdown( + terminal_fixture.core, status.operation_token)); + complete = + take_event(&terminal_fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + assert(pocketjs_net_http_client_core_abandon_event_after_transport_shutdown( + terminal_fixture.core, complete.sequence)); + assert(pocketjs_net_http_client_core_is_quiescent(terminal_fixture.core)); + assert(pocketjs_net_http_client_core_deinit(terminal_fixture.core)); +} + +static void start_first_reusable_get(fixture_t *fixture, uint64_t token, + const char *url, + const char *expected_target) { + const pocketjs_net_http_client_request_t request = make_get(token, url); + assert(pocketjs_net_http_client_core_start(fixture->core, &request, + fixture->now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + assert(fixture->fake.active_kind == FAKE_RESOLVE); + const uint32_t address = 0x0100007fU; + fake_complete_resolve(&fixture->fake, &address, 1U); + pump(fixture); + assert(fixture->fake.active_kind == FAKE_CONNECT); + fake_complete_connect(&fixture->fake); + pump(fixture); + assert(fixture->fake.active_kind == FAKE_WRITE); + assert(bytes_contain(fixture->fake.write_bytes, fixture->fake.write_length, + expected_target)); + assert(!bytes_contain(fixture->fake.write_bytes, fixture->fake.write_length, + "Connection: close\r\n")); + fake_complete_write(&fixture->fake); + pump(fixture); + assert(fixture->fake.active_kind == FAKE_READ); +} + +static void cache_empty_response(fixture_t *fixture) { + fake_complete_read(&fixture->fake, "HTTP/1.1 204 No Content\r\n\r\n", false); + pump(fixture); + const pocketjs_net_http_client_event_t headers = + take_event(fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + retire_event(fixture, headers); + assert(fixture->fake.active_kind == FAKE_NONE); + const pocketjs_net_http_client_event_t complete = + take_event(fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + assert(get_status(fixture).connection_reusable); + retire_event(fixture, complete); + const pocketjs_net_http_client_core_status_t status = get_status(fixture); + assert(status.connection_owned && status.connection_reusable && + !status.request_active); +} + +static void test_connection_reuse_is_same_origin_and_bounded(void) { + fixture_t fixture; + fixture_init_with_reuse(&fixture, false); + start_first_reusable_get(&fixture, 1U, "http://example.com:8080/one", + "GET /one HTTP/1.1\r\n"); + cache_empty_response(&fixture); + assert(fixture.fake.starts[FAKE_RESOLVE] == 1U); + assert(fixture.fake.starts[FAKE_CONNECT] == 1U); + + const pocketjs_net_http_client_request_t second = + make_get(2U, "http://example.com:8080/two"); + assert( + pocketjs_net_http_client_core_start(fixture.core, &second, fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + assert(fixture.fake.active_kind == FAKE_WRITE); + assert(fixture.fake.starts[FAKE_RESOLVE] == 1U); + assert(fixture.fake.starts[FAKE_CONNECT] == 1U); + assert(fixture.permissions.hostname_calls == 2U); + assert(fixture.permissions.numeric_calls == 2U); + assert(bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "GET /two HTTP/1.1\r\n")); + assert(!bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "Connection: close\r\n")); + fake_complete_write(&fixture.fake); + pump(&fixture); + fake_complete_read( + &fixture.fake, + "HTTP/1.1 204 No Content\r\nConnection: keep-alive, close\r\n\r\n", + false); + pump(&fixture); + const pocketjs_net_http_client_event_t headers = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_RESPONSE_HEADERS); + retire_event(&fixture, headers); + assert(fixture.fake.active_kind == FAKE_CLOSE); + fake_complete_close(&fixture.fake); + pump(&fixture); + const pocketjs_net_http_client_event_t complete = + take_event(&fixture, POCKETJS_NET_HTTP_CLIENT_EVENT_COMPLETE); + retire_event(&fixture, complete); + assert(!get_status(&fixture).connection_owned); +} + +static void test_https_connection_reuse_preserves_tls_origin(void) { + fixture_t fixture; + fixture_init_with_reuse(&fixture, true); + pocketjs_net_http_client_request_t first = + make_tls_get(1U, "https://example.com/one", "example.com"); + assert( + pocketjs_net_http_client_core_start(fixture.core, &first, fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + assert(fixture.fake.active_kind == FAKE_RESOLVE); + const uint32_t address = 0x0100007fU; + fake_complete_resolve(&fixture.fake, &address, 1U); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_CONNECT); + assert(fixture.fake.connect_tls); + assert(strcmp(fixture.fake.connect_hostname, "example.com") == 0); + fake_complete_connect(&fixture.fake); + pump(&fixture); + assert(fixture.fake.active_kind == FAKE_WRITE); + assert(bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "GET /one HTTP/1.1\r\n")); + assert(!bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "Connection: close\r\n")); + fake_complete_write(&fixture.fake); + pump(&fixture); + cache_empty_response(&fixture); + + pocketjs_net_http_client_request_t second = + make_tls_get(2U, "https://example.com/two", "example.com"); + assert( + pocketjs_net_http_client_core_start(fixture.core, &second, fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + assert(fixture.fake.active_kind == FAKE_WRITE); + assert(fixture.fake.starts[FAKE_RESOLVE] == 1U); + assert(fixture.fake.starts[FAKE_CONNECT] == 1U); + assert(fixture.permissions.hostname_calls == 2U); + assert(fixture.permissions.numeric_calls == 2U); + assert(bytes_contain(fixture.fake.write_bytes, fixture.fake.write_length, + "GET /two HTTP/1.1\r\n")); +} + +static void test_connection_pool_replaces_origin_and_closes_on_shutdown(void) { + fixture_t replacement; + fixture_init_with_reuse(&replacement, false); + start_first_reusable_get(&replacement, 1U, "http://example.com/one", + "GET /one HTTP/1.1\r\n"); + cache_empty_response(&replacement); + + const pocketjs_net_http_client_request_t other = + make_get(2U, "http://other.example/two"); + assert(pocketjs_net_http_client_core_start(replacement.core, &other, + replacement.now) == + POCKETJS_NET_HTTP_CLIENT_START_OK); + assert(replacement.fake.active_kind == FAKE_CLOSE); + assert(replacement.permissions.hostname_calls == 1U); + fake_complete_close(&replacement.fake); + pump(&replacement); + assert(replacement.fake.active_kind == FAKE_RESOLVE); + assert(strcmp(replacement.fake.resolve_hostname, "other.example") == 0); + assert(replacement.permissions.hostname_calls == 2U); + + fixture_t shutdown; + fixture_init_with_reuse(&shutdown, false); + start_first_reusable_get(&shutdown, 1U, "http://example.com/one", + "GET /one HTTP/1.1\r\n"); + cache_empty_response(&shutdown); + assert(pocketjs_net_http_client_core_begin_shutdown(shutdown.core, + ++shutdown.now)); + assert(shutdown.fake.active_kind == FAKE_CLOSE); + pocketjs_net_http_client_event_t event; + assert(!pocketjs_net_http_client_core_take_event(shutdown.core, &event)); + fake_complete_close(&shutdown.fake); + pump(&shutdown); + assert(pocketjs_net_http_client_core_is_quiescent(shutdown.core)); + assert(!pocketjs_net_http_client_core_take_event(shutdown.core, &event)); + assert(pocketjs_net_http_client_core_deinit(shutdown.core)); + + fixture_t expired; + fixture_init_with_reuse(&expired, false); + start_first_reusable_get(&expired, 1U, "http://example.com/one", + "GET /one HTTP/1.1\r\n"); + cache_empty_response(&expired); + expired.now += expired.config.idle_timeout_us; + pump(&expired); + assert(expired.fake.active_kind == FAKE_CLOSE); + assert(!get_status(&expired).connection_reusable); + assert(!pocketjs_net_http_client_core_take_event(expired.core, &event)); + fake_complete_close(&expired.fake); + pump(&expired); + assert(!get_status(&expired).connection_owned); + assert(!pocketjs_net_http_client_core_take_event(expired.core, &event)); +} + +static void test_invalid_inputs(void) { + fixture_t fixture; + fixture_init(&fixture); + pocketjs_net_http_client_request_t request = + make_get(1U, "http://127.0.0.01/"); + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_INVALID_URL); + request = make_get(1U, "http://example.com/%zz"); + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_FORBIDDEN_REQUEST); + static const uint8_t trace[] = "TRACK"; + request = make_get(1U, "http://example.com/"); + request.method = (pocketjs_net_http_client_slice_t){ + .data = trace, + .length = sizeof(trace) - 1U, + }; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_FORBIDDEN_REQUEST); + request = make_get(1U, "http://example.com/"); + request.redirect_mode = + (pocketjs_net_http_client_redirect_mode_t)(POCKETJS_NET_HTTP_CLIENT_REDIRECT_ERROR + + 1U); + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT); + request = make_get(1U, "http://example.com/"); + request.redirect_mode = POCKETJS_NET_HTTP_CLIENT_REDIRECT_FOLLOW; + request.max_redirects = POCKETJS_NET_HTTP_CLIENT_CORE_MAX_REDIRECTS + 1U; + assert(pocketjs_net_http_client_core_start(fixture.core, &request, + fixture.now) == + POCKETJS_NET_HTTP_CLIENT_START_INVALID_ARGUMENT); +} + +int main(void) { + test_redirect_follow_resolves_relative_url(); + test_redirect_rechecks_permission_before_second_hop(); + test_redirect_rewrites_post_and_strips_cross_origin_headers(); + test_redirect_replays_fixed_body_and_rejects_streaming_replay(); + test_redirect_modes_and_limit_fail_closed(); + test_headers_body_and_status_success(); + test_response_header_limit_configuration_boundaries(); + test_selected_response_header_limit(); + test_https_and_permissions_fail_before_io(); + test_https_base_policy_is_fail_closed(); + test_tls_errors_remain_distinct(); + test_all_candidates_checked_before_denial(); + test_connect_error_tries_next_allowed_candidate(); + test_connect_candidate_exhaustion_preserves_last_error(); + test_non_connect_errors_do_not_advance_candidates(); + test_connect_error_does_not_retry_after_deadline(); + test_protocol_error_closes_before_terminal(); + test_chunked_body_and_valid_trailer(); + test_protocol_error_selected_before_body_pull(); + test_eof_delimited_body(); + test_205_has_no_body(); + test_content_coding_fails_closed(); + test_request_body_is_snapshotted(); + test_abort_exact_one(); + test_total_timeout_exact_one(); + test_permission_callback_reentrancy_is_rejected(); + test_read_bytes_with_eof_waits_for_full_consumption(); + test_head_informational_and_304_are_bodyless(); + test_numeric_permission_denial_has_no_io(); + test_maximum_request_body_boundary(); + test_streaming_chunked_upload_exceeds_64k(); + test_streaming_credit_is_hostile_input_safe(); + test_known_length_streaming_accounting(); + test_streaming_cancel_timeout_error_and_teardown(); + test_request_body_mode_validation(); + test_idle_stale_read_is_cleanup_only(); + test_close_error_preserves_success_and_explicit_teardown(); + test_close_error_preserves_protocol_failure(); + test_close_admission_does_not_replace_terminals(); + test_close_timeout_preserves_success(); + test_read_lease_release_failure_is_auditable(); + test_unexpected_read_payload_is_released_in_every_phase(); + test_malformed_read_releases_and_view_failure_retains(); + test_completion_retire_failure_is_poisoned_and_retried(); + test_idle_transport_faults_do_not_publish(); + test_native_only_pump_budget(); + test_completion_only_pump_budget(); + test_empty_pump_budget_is_rejected_without_poison(); + test_poison_shutdown_can_abandon_exact_host_event(); + test_connection_reuse_is_same_origin_and_bounded(); + test_https_connection_reuse_preserves_tls_origin(); + test_connection_pool_replaces_origin_and_closes_on_shutdown(); + test_invalid_inputs(); + puts("http client core host tests passed"); + return 0; +} diff --git a/hosts/sim/net.ts b/hosts/sim/net.ts index f2214b3b..927574b7 100644 --- a/hosts/sim/net.ts +++ b/hosts/sim/net.ts @@ -1,6 +1,6 @@ -// 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. +// Internal NET v1 migration fixture. The stock sim does not install it. +// Conformance tests may mount it explicitly to retain the bounded parser and +// virtual-tick completion behavior while the new Network Guest Binding lands. import { NET_ERROR, diff --git a/hosts/sim/sim.ts b/hosts/sim/sim.ts index f05e6e45..757139ae 100644 --- a/hosts/sim/sim.ts +++ b/hosts/sim/sim.ts @@ -243,6 +243,12 @@ 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 + // Do not leak an old NET v1 fixture from a previous test into a stock sim + // guest. Migration tests can still install the fixture explicitly through + // extraGlobals after this reset. + if (!Reflect.deleteProperty(g, "net")) { + throw new Error("sim: legacy globalThis.net is not configurable"); + } g.__pocketApp = app; g.__simHz = hz; g.__pocketEffectTrace = (e: EffectEvent) => effects.push(e); diff --git a/hosts/web/engine.js b/hosts/web/engine.js index 7956ecba..682436e9 100644 --- a/hosts/web/engine.js +++ b/hosts/web/engine.js @@ -17,7 +17,6 @@ import { createWasmUi, FB_W as DEFAULT_FB_W, FB_H as DEFAULT_FB_H } from "./wasm-ops.js"; import { drawHud, wasmMemoryBytes } from "./hud.js"; import { createAudioHost } from "./audio.js"; -import { createNetHost } from "./net.js"; const query = new URLSearchParams(location.search); function positiveIntParam(name, fallback, max = 32000) { @@ -105,7 +104,6 @@ let acc = 0; let last = 0; let frameCb = null; let audioHost = null; // hosts/web/audio.js — created on first load() -let netHost = null; // hosts/web/net.js — browser fetch behind the NET contract // Virtual clock policy (docs/DETERMINISM.md): virtual frames per second. One // frame(buttons) transaction + 60/simHz core ticks per virtual frame, so // ms-based animations cover the same VIRTUAL time at every rate. ?hz=2 @@ -218,7 +216,6 @@ function safeFrame() { // Audio module: fold the audio clock's facts into this tick's event // batch BEFORE the guest's single turn (poll() drains them inside it). if (audioHost) audioHost.beginFrame(); - if (netHost) netHost.beginFrame(); // JS: one virtual-frame transaction (input, effects, sweep) frameCb(held, packedAnalog()); const ticks = 60 / simHz; @@ -360,11 +357,12 @@ export async function load(name, opts = {}) { if (!audioHost) audioHost = createAudioHost(); audioHost.reset(); globalThis.audio = audioHost.ns; - // NET module: browser fetch is transport-only; guest code sees the same - // bounded globalThis.net contract as native runtimes. - if (!netHost) netHost = createNetHost(); - netHost.reset(); - globalThis.net = netHost.ns; + // NET v1 is retained only as migration material. Remove a value left by an + // older load before application code runs; new network modules use their + // admitted private binding and never a public global. + if (!Reflect.deleteProperty(globalThis, "net")) { + throw new Error("legacy globalThis.net is not configurable"); + } globalThis.__simHz = simHz; // clock policy — before eval, like __pak // DevTools: identity + transport BEFORE eval; render() picks them up. globalThis.__pocketApp = name; diff --git a/hosts/web/net.js b/hosts/web/net.js index 3eefe75f..9421f8e1 100644 --- a/hosts/web/net.js +++ b/hosts/web/net.js @@ -1,6 +1,6 @@ -// 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. +// Internal NET v1 migration fixture. The stock web runtime does not import or +// publish this adapter. Tests install it explicitly while bounded reads and +// tick-staged completion delivery are migrated to the new Network Guest Binding. const MAX_INFLIGHT = 2; const MAX_REQUEST_BYTES = 64 * 1024; diff --git a/package.json b/package.json index cef70d78..a0009174 100644 --- a/package.json +++ b/package.json @@ -147,7 +147,12 @@ "./kinetics": "./framework/src/kinetics.ts", "./launcher": "./framework/src/launcher.ts", "./manifest": "./framework/src/manifest/index.ts", - "./net": "./framework/src/net-api.ts", + "./net": "./framework/src/net/index.ts", + "./net/http": "./framework/src/net/http.ts", + "./net/websocket": "./framework/src/net/websocket.ts", + "./net/mqtt": "./framework/src/net/mqtt.ts", + "./net/tcp": "./framework/src/net/tcp.ts", + "./net/udp": "./framework/src/net/udp.ts", "./osk": "./framework/src/osk.tsx", "./package": "./contracts/spec/pocket-package.ts", "./platform": "./framework/src/platform.ts", @@ -169,7 +174,6 @@ "./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/renderer": "./framework/src/renderer-vue-vapor.ts", "./octane": "./framework/src/index-octane.ts", "./octane/animation": "./framework/src/animation.ts", @@ -181,7 +185,6 @@ "./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/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.", diff --git a/site/build.ts b/site/build.ts index 45ae7a4b..47aa8f69 100644 --- a/site/build.ts +++ b/site/build.ts @@ -503,10 +503,10 @@ async function main() { await bundle("assets/pocket-stage-web.js", "assets/pocket-stage-web.js"); // 2. runtime assets - // Keep the editor-facing URL byte-identical to the schema used by the - // validator. The deployed path is POCKET_MANIFEST_SCHEMA_ID — - // /schema/pocket-2.json, independent of where the repo keeps the file. + // Keep the editor-facing URLs byte-identical to the versioned schemas used + // by the validator, independent of where the repo keeps the files. 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/docs/api.md b/site/content/docs/api.md index decd7fc7..e2ccbd82 100644 --- a/site/content/docs/api.md +++ b/site/content/docs/api.md @@ -724,7 +724,8 @@ This is the stable build/custom-host boundary: | Export | Purpose | | --- | --- | | `POCKET_MANIFEST_SCHEMA_ID` / `POCKET_MANIFEST_VERSION` | Canonical format-2 identity. | -| `pocketManifestV2Schema` / `validatePocketManifest` | Strict schema and structured diagnostics. | +| `POCKET_MANIFEST_V3_SCHEMA_ID` / `POCKET_MANIFEST_V3_VERSION` | Canonical format-3 identity for network admission inputs. | +| `pocketManifestV2Schema` / `pocketManifestV3Schema` / `validatePocketManifest` | Strict versioned schemas and structured diagnostics. | | `extractHostBuildInputs(plan, options?)` | Verify a plan checksum and project it onto stable native-host inputs. | | `hostBuildEnvironment(inputs, options)` | Produce the target-neutral Cargo environment for a custom host. | | `vitaTitleId(applicationId)` | Deterministically derive a stable Vita Title ID. | diff --git a/site/content/docs/net.md b/site/content/docs/net.md index 17097780..2ebcece7 100644 --- a/site/content/docs/net.md +++ b/site/content/docs/net.md @@ -1,90 +1,46 @@ # 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 does not currently expose working network I/O to applications.** +The previous NET v1 API has been removed from the public package and from the +stock web and simulation runtimes. There is no `globalThis.net`, and no stock +target advertises a network capability. -```ts -import { fetch } from "@pocketjs/framework/net"; - -const response = await fetch("https://api.example.com/items", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Pocket" }), - timeoutMs: 5_000, - maxBytes: 64 * 1024, -}); - -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 replacement modules share the `net` namespace: -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. +| Import | Purpose | +| --- | --- | +| `@pocketjs/framework/net` | Shared errors, URL and abort values, limits, and types | +| `@pocketjs/framework/net/http` | HTTP client and server API | +| `@pocketjs/framework/net/websocket` | WebSocket client, server, and upgrade API | +| `@pocketjs/framework/net/mqtt` | MQTT client API | +| `@pocketjs/framework/net/tcp` | TCP client and listener API | +| `@pocketjs/framework/net/udp` | UDP socket API | -## Tick delivery and polling +Shared support values and protocol types can be imported now: -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 | +```ts +import { NetworkError, URL } from "@pocketjs/framework/net"; +import type { RequestInit } from "@pocketjs/framework/net/http"; -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 endpoint = new URL("https://api.example.com/items"); +``` -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. +**Protocol functions are staged.** A PocketJS build rejects `fetch`, `serve`, +`connect`, `listen`, and `udpSocket` until the selected target supplies the +corresponding implementation and admitted capability. Calling a protocol +function outside such a binding rejects with a structured `NetworkError`. -## Limits +The old root import is not supported: -| 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 | +```ts +// Removed NET v1 API: +// import { fetch } from "@pocketjs/framework/net"; -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. +// Replacement HTTP path once HTTP client support is admitted: +// import { fetch } from "@pocketjs/framework/net/http"; +``` -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. +Native transports perform DNS, TLS, and socket work away from the QuickJS +thread. They enqueue bounded events, and the runtime delivers those events on +the QuickJS owner thread in a scheduled network service turn. Applications do +not receive a native network global. diff --git a/tests/esp-network-artifacts.test.ts b/tests/esp-network-artifacts.test.ts new file mode 100644 index 00000000..a0431923 --- /dev/null +++ b/tests/esp-network-artifacts.test.ts @@ -0,0 +1,487 @@ +import { createHash, X509Certificate } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + networkV1FeatureIdsFromBuildPlan, + networkV1PlanHashBytes, +} from "../contracts/spec/network/network-v1.ts"; +import { transformFile } from "../framework/compiler/jsx-plugin.ts"; +import { + finalizeBuildPlan, + verifyPlanHash, + type ResolvedBuildPlan, +} from "../framework/src/manifest/plan.ts"; +import { generate as generateHttp } from + "../hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/generate.ts"; +import { generate as generateTlsConformance } from + "../hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/generate.ts"; +import { generate as generateTlsSmoke } from + "../hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/generate.ts"; +import { createNetworkFactoryBuildContext } from + "../tools/network-bundle-factory.ts"; + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const COMPONENTS = join(ROOT, "hosts/esp-idf/components"); +const HTTP_COMPONENT = join(COMPONENTS, "pocketjs_net_formal_smoke_artifact"); +const TLS_COMPONENT = join(COMPONENTS, "pocketjs_net_formal_tls_smoke_artifact"); +const CONFORMANCE_COMPONENT = join( + COMPONENTS, + "pocketjs_net_formal_tls_conformance_artifact", +); +const EXPECTED_CA_DER_SHA256 = + "sha256:318ae57f0fb82d12cf86431571fb6ec3556ecb74f530a5be6f741a482b5447af"; + +type Permit = "none" | "http" | "https" | "both"; + +interface ArtifactCase { + readonly name: string; + readonly component: string; + readonly generated: string; + readonly metadataSource: string; + readonly planHash: string; + readonly featureIds: readonly number[]; + readonly origin: string; +} + +interface ArtifactMetadata { + readonly planHashBytes: readonly number[]; + readonly featureIds: readonly number[]; + readonly endpoint: { readonly origin: string }; + readonly factory: { + readonly storageBytes: number; + readonly sourceBytes: number; + readonly sha256: string; + }; + readonly providers: Record; + readonly tls?: { + readonly trustSource: string; + readonly minVersion: string; + readonly maxVersion: string; + readonly verification: string; + readonly revocation: string; + readonly caDerSha256: string; + readonly caPemBytes: number; + }; + readonly target?: { readonly id: string; readonly hostAbi: number }; + readonly stagedSurfaceBuild?: { + readonly permit: string; + readonly exactAppId: string; + readonly exactTargetId: string; + readonly publicSpecifier: string; + readonly productionGateChanged: boolean; + }; +} + +let temporary = ""; +let artifacts: readonly ArtifactCase[] = []; + +async function readJson(path: string): Promise { + return await Bun.file(path).json() as T; +} + +async function readPlan(artifact: ArtifactCase): Promise { + return await readJson(join(artifact.generated, "resolved-plan.json")); +} + +async function spawnBuild( + artifact: ArtifactCase, + planPath: string, + output: string, + permit: Permit, +): Promise<{ exitCode: number; output: string }> { + const stdoutPath = `${output}.stdout.log`; + const stderrPath = `${output}.stderr.log`; + const child = Bun.spawn({ + cmd: [ + "bun", + join(ROOT, "tools/build.ts"), + `--plan=${planPath}`, + `--project-root=${artifact.component}`, + `--outdir=${output}`, + "--no-config", + "--network-factory", + ...(permit === "http" || permit === "both" + ? ["--test-only-staged-http-client-fetch"] + : []), + ...(permit === "https" || permit === "both" + ? ["--test-only-staged-https-client-fetch"] + : []), + ], + cwd: ROOT, + stdout: Bun.file(stdoutPath), + stderr: Bun.file(stderrPath), + }); + const exitCode = await child.exited; + const [stdout, stderr] = await Promise.all([ + Bun.file(stdoutPath).text(), + Bun.file(stderrPath).text(), + ]); + return { + exitCode, + output: `exit=${exitCode} signal=${String(child.signalCode)}\n${stdout}\n${stderr}`, + }; +} + +async function expectRefused( + artifact: ArtifactCase, + plan: ResolvedBuildPlan, + label: string, + permit: Permit, + message: string, +): Promise { + const planPath = join(temporary, `${label}.json`); + await Bun.write(planPath, `${JSON.stringify(plan)}\n`); + const result = await spawnBuild( + artifact, + planPath, + join(temporary, `${label}-build`), + permit, + ); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain(message); +} + +async function runProbe( + component: string, + generated: string, +): Promise> { + const label = component === HTTP_COMPONENT ? "http" : "tls"; + const stdoutPath = join(temporary, `${label}-probe.stdout.log`); + const stderrPath = join(temporary, `${label}-probe.stderr.log`); + const child = Bun.spawn({ + cmd: ["bun", join(component, "runtime_probe.ts")], + cwd: ROOT, + env: { + ...process.env, + POCKETJS_TEST_ARTIFACT_DIR: generated, + }, + stdout: Bun.file(stdoutPath), + stderr: Bun.file(stderrPath), + }); + const exitCode = await child.exited; + const [stdout, stderr] = await Promise.all([ + Bun.file(stdoutPath).text(), + Bun.file(stderrPath).text(), + ]); + expect( + exitCode, + `exit=${exitCode} signal=${String(child.signalCode)}\n${stdout}\n${stderr}`, + ).toBe(0); + return JSON.parse(stdout) as Record; +} + +beforeAll(async () => { + temporary = await mkdtemp(join(tmpdir(), "pocketjs-net-artifacts-")); + const httpGenerated = join(temporary, "http"); + const tlsGenerated = join(temporary, "tls"); + const conformanceGenerated = join(temporary, "conformance"); + await generateHttp(httpGenerated); + await generateTlsSmoke(tlsGenerated); + await generateTlsConformance(conformanceGenerated); + artifacts = Object.freeze([ + { + name: "http-smoke", + component: HTTP_COMPONENT, + generated: httpGenerated, + metadataSource: "formal_smoke_metadata.c", + planHash: + "sha256:04856acc82e7aa31648b015e62a63a4cadf6f48a3d1d3f46f3987539520b63fd", + featureIds: [0x0100], + origin: "http://172.16.10.126:8088", + }, + { + name: "tls-smoke", + component: TLS_COMPONENT, + generated: tlsGenerated, + metadataSource: "formal_tls_smoke_metadata.c", + planHash: + "sha256:9240cfa29c1678b49b6fed67104a39b2ad32f5dedab372af1c2a0bde3d602654", + featureIds: [0x0100, 0x0101], + origin: "https://pocketjs.test:8443", + }, + { + name: "tls-conformance", + component: CONFORMANCE_COMPONENT, + generated: conformanceGenerated, + metadataSource: "formal_tls_smoke_metadata.c", + planHash: + "sha256:fe3014e4d3628eb60aaeedd414432eb8c9a5932e904b258a9d05a17c7f6abcce", + featureIds: [0x0100, 0x0101], + origin: "https://pocketjs.test:8443", + }, + ]); +}); + +afterAll(async () => { + if (temporary.length > 0) { + await rm(temporary, { recursive: true, force: true }); + } +}); + +describe("ESP network test artifacts", () => { + test("generate deterministic plans, metadata, and NUL-terminated factories", async () => { + const hashes = new Set(); + for (const artifact of artifacts) { + const [plan, metadata, storageBuffer, metadataSource] = await Promise.all([ + readPlan(artifact), + readJson(join(artifact.generated, "metadata.json")), + Bun.file(join(artifact.generated, "factory.js.bin")).arrayBuffer(), + Bun.file(join(artifact.generated, artifact.metadataSource)).text(), + ]); + expect(verifyPlanHash(plan), artifact.name).toBe(true); + expect(plan.planHash, artifact.name).toBe(artifact.planHash); + expect(metadata.planHashBytes, artifact.name).toEqual( + Array.from(networkV1PlanHashBytes(plan.planHash)), + ); + expect(metadata.featureIds, artifact.name).toEqual( + Array.from(networkV1FeatureIdsFromBuildPlan(plan.features)), + ); + expect(metadata.featureIds, artifact.name).toEqual(artifact.featureIds); + expect(metadata.endpoint.origin, artifact.name).toBe(artifact.origin); + expect(metadataSource, artifact.name).toContain( + 'asm("_binary_factory_js_bin_start")', + ); + expect(metadataSource, artifact.name).not.toContain( + "_binary_generated_factory_js_bin_start", + ); + + const storage = new Uint8Array(storageBuffer); + expect(storage.at(-1), artifact.name).toBe(0); + expect(storage.subarray(0, -1).includes(0), artifact.name).toBe(false); + expect(storage.length, artifact.name).toBe(metadata.factory.storageBytes); + expect(storage.length - 1, artifact.name).toBe(metadata.factory.sourceBytes); + expect( + `sha256:${createHash("sha256").update(storage.subarray(0, -1)).digest("hex")}`, + artifact.name, + ).toBe(metadata.factory.sha256); + hashes.add(plan.planHash); + } + expect(hashes.size).toBe(artifacts.length); + }); + + test("snapshot the intended HTTP and TLS providers and public test CA", async () => { + const [http, tls, conformance, pem] = await Promise.all([ + readJson(join(artifacts[0]!.generated, "metadata.json")), + readJson(join(artifacts[1]!.generated, "metadata.json")), + readJson(join(artifacts[2]!.generated, "metadata.json")), + Bun.file(join(TLS_COMPONENT, "fixtures/ca.cert.pem")).text(), + ]); + expect(http.providers).toEqual({ + httpClientBackendId: "pocketjs.net.http-client-core.v1.experimental", + netDriverId: "pocketjs.net.esp-idf.transport.v1.experimental", + }); + expect(tls.providers).toEqual({ + httpClientBackendId: "pocketjs.net.http-client-core.v1.experimental", + netDriverId: "pocketjs.net.esp-idf.transport.v1.experimental", + tlsProviderId: "pocketjs.net.esp-idf.esp-tls.v1.experimental", + }); + expect(tls.tls).toMatchObject({ + trustSource: "host-pinned-ca", + minVersion: "1.2", + maxVersion: "1.2", + verification: "full", + revocation: "host-default", + caDerSha256: EXPECTED_CA_DER_SHA256, + }); + const certificate = new X509Certificate(pem); + expect(certificate.ca).toBe(true); + expect( + `sha256:${createHash("sha256").update(certificate.raw).digest("hex")}`, + ).toBe(EXPECTED_CA_DER_SHA256); + expect(tls.tls?.caPemBytes).toBe(Buffer.byteLength(pem)); + expect(conformance.target).toEqual({ + id: "esp-formal-network-tls-conformance-test", + hostAbi: 1, + }); + expect(conformance.stagedSurfaceBuild).toEqual({ + permit: "test-only-staged-https-client-fetch", + exactAppId: "dev.pocketjs.esp-formal-network-tls-conformance", + exactTargetId: "esp-formal-network-tls-conformance-test", + publicSpecifier: "@pocketjs/framework/net/http", + productionGateChanged: false, + }); + }); + + test("keeps test permits private and limited to the fetch binding", async () => { + for (const artifact of artifacts.slice(0, 2)) { + const plan = await readPlan(artifact); + const normal = createNetworkFactoryBuildContext(plan); + await expect(transformFile( + `/virtual/${artifact.name}-default.ts`, + 'import { fetch } from "@pocketjs/framework/net/http"; void fetch;', + "solid", + { features: plan.features, networkPrivate: normal }, + )).rejects.toThrow("staged surface"); + const permitted = Object.freeze({ + ...normal, + ...(artifact === artifacts[0] + ? { testOnlyStagedHttpClientFetch: true as const } + : { testOnlyStagedHttpsClientFetch: true as const }), + }); + await expect(transformFile( + `/virtual/${artifact.name}-permitted.ts`, + 'import { fetch } from "@pocketjs/framework/net/http"; void fetch;', + "solid", + { features: plan.features, networkPrivate: permitted }, + )).resolves.toBeDefined(); + await expect(transformFile( + `/virtual/${artifact.name}-headers.ts`, + 'import { Headers } from "@pocketjs/framework/net/http"; void Headers;', + "solid", + { features: plan.features, networkPrivate: permitted }, + )).rejects.toThrow("staged surface"); + } + }); + + test("rejects absent, crossed, ambiguous, and mutated permits", async () => { + const [http, tls, conformance] = artifacts; + const httpPlanPath = join(http!.generated, "resolved-plan.json"); + const tlsPlanPath = join(tls!.generated, "resolved-plan.json"); + const conformancePlanPath = join(conformance!.generated, "resolved-plan.json"); + + const absent = await spawnBuild( + http!, + httpPlanPath, + join(temporary, "absent-permit"), + "none", + ); + expect(absent.exitCode).not.toBe(0); + expect(absent.output).toContain("staged surface"); + + const httpWithTlsPermit = await spawnBuild( + http!, + httpPlanPath, + join(temporary, "http-with-tls-permit"), + "https", + ); + expect(httpWithTlsPermit.exitCode).not.toBe(0); + expect(httpWithTlsPermit.output).toContain( + "restricted to the exact ESP formal TLS test plans and entries", + ); + + const tlsWithHttpPermit = await spawnBuild( + tls!, + tlsPlanPath, + join(temporary, "tls-with-http-permit"), + "http", + ); + expect(tlsWithHttpPermit.exitCode).not.toBe(0); + expect(tlsWithHttpPermit.output).toContain( + "restricted to the exact ESP formal network smoke plan and entry", + ); + + const both = await spawnBuild( + conformance!, + conformancePlanPath, + join(temporary, "both-permits"), + "both", + ); + expect(both.exitCode).not.toBe(0); + expect(both.output).toContain("mutually exclusive"); + + const crossed = await spawnBuild( + conformance!, + tlsPlanPath, + join(temporary, "crossed-tls-entry"), + "https", + ); + expect(crossed.exitCode).not.toBe(0); + expect(crossed.output).toContain( + "restricted to the exact ESP formal TLS test plans and entries", + ); + + const httpPlan = await readPlan(http!); + const { planHash: _httpHash, ...httpContent } = httpPlan; + await expectRefused( + http!, + finalizeBuildPlan({ + ...httpContent, + network: { + ...httpContent.network!, + policy: { + ...httpContent.network!.policy, + connect: [{ + protocol: "http", + host: "172.16.10.127", + port: { min: 8088, max: 8088 }, + }], + }, + }, + }), + "mutated-http-policy", + "http", + "restricted to the exact ESP formal network smoke plan and entry", + ); + + const conformancePlan = await readPlan(conformance!); + const { planHash: _tlsHash, ...conformanceContent } = conformancePlan; + await expectRefused( + conformance!, + finalizeBuildPlan({ + ...conformanceContent, + network: { + ...conformanceContent.network!, + providers: { + ...conformanceContent.network!.providers, + tlsByRole: { + "http.client": { + source: "provider", + id: "pocketjs.net.attacker", + }, + }, + }, + }, + }), + "mutated-tls-provider", + "https", + "restricted to the exact ESP formal TLS test plans and entries", + ); + }); + + test("mounts only at invocation and reports bounded Host refusal", async () => { + const [httpReport, tlsReport] = await Promise.all([ + runProbe(HTTP_COMPONENT, artifacts[0]!.generated), + runProbe(TLS_COMPONENT, artifacts[1]!.generated), + ]); + for (const report of [httpReport, tlsReport]) { + expect(report).toMatchObject({ + phase: "failed", + done: true, + ok: false, + roundsTotal: 20, + roundsStarted: 1, + roundsPassed: 0, + requestsPassed: 0, + frameCalls: 0, + errorCode: "permission_denied", + }); + } + }); + + test("rejects non-canonical Host IPv4 spellings", async () => { + const binary = join(temporary, "ipv4-validation-test"); + const compile = Bun.spawn({ + cmd: [ + "clang", + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + `-I${join(TLS_COMPONENT, "test_host/fake_include")}`, + `-I${join(TLS_COMPONENT, "private")}`, + join(TLS_COMPONENT, "src/formal_tls_smoke_ipv4.c"), + join(TLS_COMPONENT, "test_host/ipv4_validation_test.c"), + "-o", + binary, + ], + cwd: ROOT, + stdout: "inherit", + stderr: "inherit", + }); + expect(await compile.exited).toBe(0); + expect(await Bun.spawn({ cmd: [binary] }).exited).toBe(0); + }); +}); diff --git a/tests/fixtures/http-hostile-intrinsics.ts b/tests/fixtures/http-hostile-intrinsics.ts new file mode 100644 index 00000000..b0ce3a77 --- /dev/null +++ b/tests/fixtures/http-hostile-intrinsics.ts @@ -0,0 +1,106 @@ +import { + Headers, + NetworkError, + Request, + fetch, +} from "../../framework/src/net/http.ts"; +import { + installHttpClientBindingForTesting, + NETWORK_V1_ABI_MAJOR, + NETWORK_V1_ABI_MINOR, +} from "../../framework/src/net/http-binding.ts"; + +let starts = 0; +const featureSet = Object.freeze(["network.http.client"]); +const cleanup = installHttpClientBindingForTesting(Object.freeze({ + abiMajor: NETWORK_V1_ABI_MAJOR, + abiMinor: NETWORK_V1_ABI_MINOR, + featureSet, + httpClientLimits: Object.freeze({ + values: Object.freeze([ + Object.freeze({ + name: "http.bufferedBodyBytes", + default: 8 * 1024 * 1024, + hard: 8 * 1024 * 1024, + minimum: 1, + }), + Object.freeze({ + name: "http.headerBytes", + default: 64 * 1024, + hard: 64 * 1024, + minimum: 1, + }), + Object.freeze({ + name: "http.maxBodyChunkBytes", + default: 64 * 1024, + hard: 64 * 1024, + minimum: 1, + }), + Object.freeze({ + name: "http.maxOperations", + default: 8, + hard: 8, + minimum: 1, + }), + Object.freeze({ + name: "runtime.nativeBufferBytes", + default: 512 * 1024, + hard: 512 * 1024, + minimum: 1, + }), + ]), + features: featureSet, + }), + start() { + starts++; + throw new Error("native start must remain unreachable"); + }, +})); + +String.prototype.startsWith = () => false; +String.prototype.includes = () => false; +String.prototype.charCodeAt = () => 0; +String.prototype.toUpperCase = () => "GET"; +String.prototype.toLowerCase = () => "visible"; +Array.prototype.includes = () => true; +Array.prototype.join = () => "poisoned"; +Array.prototype.push = () => { throw new Error("poisoned push"); }; +Array.prototype.sort = () => { throw new Error("poisoned sort"); }; +Array.prototype.splice = () => { throw new Error("poisoned splice"); }; +Set.prototype.has = () => false; +RegExp.prototype.test = () => true; +Object.keys = (() => []) as never; +Number.isSafeInteger = () => true; + +const request = new Request("http://example.test/", { + headers: [ + ["proxy-authorization", "secret"], + ["sec-pocket", "secret"], + ["x-visible", "yes"], + ], +}); +if (request.headers.has("proxy-authorization") || request.headers.has("sec-pocket")) { + throw new Error("forbidden request header survived intrinsic poisoning"); +} +if (request.headers.get("x-visible") !== "yes") { + throw new Error("ordinary request header was not retained"); +} + +let trackRejected = false; +try { + new Request("http://example.test/", { method: "TRACK" }); +} catch (error) { + trackRejected = error instanceof TypeError; +} +if (!trackRejected) throw new Error("TRACK survived intrinsic poisoning"); + +try { + await fetch("https://example.test/"); + throw new Error("HTTPS without TLS admission unexpectedly succeeded"); +} catch (error) { + if (!(error instanceof NetworkError) || error.code !== "unsupported") throw error; +} +if (starts !== 0) throw new Error("TLS feature preflight reached native start"); + +cleanup(); +void Headers; diff --git a/tests/fixtures/http-url-hostile-intrinsics.ts b/tests/fixtures/http-url-hostile-intrinsics.ts new file mode 100644 index 00000000..8dd93943 --- /dev/null +++ b/tests/fixtures/http-url-hostile-intrinsics.ts @@ -0,0 +1,26 @@ +import { URL as PocketURL } from "../../framework/src/net/index.ts"; + +String.prototype.normalize = () => { throw new Error("poisoned normalize"); }; +String.prototype.codePointAt = () => { throw new Error("poisoned codePointAt"); }; +String.prototype.split = (() => { throw new Error("poisoned split"); }) as never; +String.prototype.startsWith = () => { throw new Error("poisoned startsWith"); }; +Array.prototype.push = () => { throw new Error("poisoned push"); }; +Array.prototype.splice = () => { throw new Error("poisoned splice"); }; +RegExp.prototype.test = () => { throw new Error("poisoned test"); }; +Math.floor = () => { throw new Error("poisoned floor"); }; +Object.freeze = (() => { throw new Error("poisoned freeze"); }) as never; +globalThis.String = class PoisonedString { + constructor() { + throw new Error("poisoned String"); + } +} as never; +globalThis.Uint8Array = class PoisonedUint8Array { + constructor() { + throw new Error("poisoned Uint8Array"); + } +} as never; + +const actual = new PocketURL("https://faß.example:443/a/../雪").href; +if (actual !== "https://xn--fa-hia.example/%E9%9B%AA") { + throw new Error(`unexpected canonical URL: ${actual}`); +} diff --git a/tests/fixtures/http-url-quickjs-entry.ts b/tests/fixtures/http-url-quickjs-entry.ts new file mode 100644 index 00000000..51f27f2e --- /dev/null +++ b/tests/fixtures/http-url-quickjs-entry.ts @@ -0,0 +1,22 @@ +import { canonicalizeHttpUrl } from "../../framework/src/net/http-url.ts"; + +declare global { + var __pocketJsHttpUrlHeapProbe: (() => number) | undefined; +} + +globalThis.__pocketJsHttpUrlHeapProbe = (): number => { + const inputs = [ + "https://faß.example:443/a/../雪?q='值'#片段", + "http://مثال.إختبار/مسار", + "http://[2001:0DB8:0:0:0:0:0:1]:80/a/%2e%2e/b", + `https://example.test/${"雪".repeat(800)}`, + ]; + let checksum = 0; + for (let round = 0; round < 16; round++) { + for (let index = 0; index < inputs.length; index++) { + const parsed = canonicalizeHttpUrl(inputs[index]!); + checksum += parsed.href.length + parsed.fragment.length + parsed.hostname.length; + } + } + return checksum; +}; diff --git a/tests/fixtures/http-url-quickjs-memory.c b/tests/fixtures/http-url-quickjs-memory.c new file mode 100644 index 00000000..94b58adc --- /dev/null +++ b/tests/fixtures/http-url-quickjs-memory.c @@ -0,0 +1,159 @@ +#include "quickjs.h" + +#include +#include +#include +#include + +typedef union Header { + struct { size_t size; } allocation; + long double alignment; + void *pointer_alignment; +} Header; + +typedef struct Tracker { + size_t current; + size_t peak; + size_t maximum_single; +} Tracker; + +static void tracker_add(Tracker *tracker, size_t size) { + tracker->current += size; + if (tracker->current > tracker->peak) tracker->peak = tracker->current; + if (size > tracker->maximum_single) tracker->maximum_single = size; +} + +static void *tracked_malloc(void *opaque, size_t size) { + Tracker *tracker = opaque; + Header *header = malloc(sizeof(*header) + size); + if (!header) return NULL; + header->allocation.size = size; + tracker_add(tracker, size); + return header + 1; +} + +static void *tracked_calloc(void *opaque, size_t count, size_t size) { + if (size != 0 && count > SIZE_MAX / size) return NULL; + size_t total = count * size; + void *pointer = tracked_malloc(opaque, total); + if (pointer) memset(pointer, 0, total); + return pointer; +} + +static void tracked_free(void *opaque, void *pointer) { + if (!pointer) return; + Tracker *tracker = opaque; + Header *header = ((Header *)pointer) - 1; + tracker->current -= header->allocation.size; + free(header); +} + +static void *tracked_realloc(void *opaque, void *pointer, size_t size) { + if (!pointer) return tracked_malloc(opaque, size); + if (size == 0) { + tracked_free(opaque, pointer); + return NULL; + } + Tracker *tracker = opaque; + Header *old_header = ((Header *)pointer) - 1; + size_t old_size = old_header->allocation.size; + Header *new_header = realloc(old_header, sizeof(*new_header) + size); + if (!new_header) return NULL; + new_header->allocation.size = size; + tracker->current -= old_size; + tracker_add(tracker, size); + return new_header + 1; +} + +static size_t tracked_usable_size(const void *pointer) { + if (!pointer) return 0; + return (((const Header *)pointer) - 1)->allocation.size; +} + +static int report_exception(JSContext *context, JSValue value, const char *stage) { + if (!JS_IsException(value)) return 0; + JSValue exception = JS_GetException(context); + const char *message = JS_ToCString(context, exception); + fprintf(stderr, "%s: %s\n", stage, message ? message : ""); + JS_FreeCString(context, message); + JS_FreeValue(context, exception); + return 1; +} + +int main(int argc, char **argv) { + if (argc != 2) return 64; + FILE *file = fopen(argv[1], "rb"); + if (!file) return 66; + fseek(file, 0, SEEK_END); + long length = ftell(file); + rewind(file); + char *source = malloc((size_t)length + 1); + if (!source || fread(source, 1, (size_t)length, file) != (size_t)length) return 74; + fclose(file); + source[length] = '\0'; + + Tracker tracker = {0}; + JSMallocFunctions functions = { + .js_calloc = tracked_calloc, + .js_malloc = tracked_malloc, + .js_free = tracked_free, + .js_realloc = tracked_realloc, + .js_malloc_usable_size = tracked_usable_size, + }; + JSRuntime *runtime = JS_NewRuntime2(&functions, &tracker); + JSContext *context = JS_NewContext(runtime); + size_t empty_runtime = tracker.current; + JSValue loaded = JS_Eval( + context, + source, + (size_t)length, + argv[1], + JS_EVAL_TYPE_GLOBAL + ); + free(source); + if (report_exception(context, loaded, "load")) return 1; + JS_FreeValue(context, loaded); + JSValue global = JS_GetGlobalObject(context); + JSValue probe = JS_GetPropertyStr(context, global, "__pocketJsHttpUrlHeapProbe"); + if (!JS_IsFunction(context, probe)) { + fprintf(stderr, "probe: global function is missing\n"); + return 1; + } + JS_RunGC(runtime); + size_t module_retained = tracker.current; + size_t load_peak = tracker.peak; + JSValue warmup = JS_Call(context, probe, JS_UNDEFINED, 0, NULL); + if (report_exception(context, warmup, "warmup")) return 1; + JS_FreeValue(context, warmup); + JS_RunGC(runtime); + size_t retained = tracker.current; + tracker.peak = tracker.current; + tracker.maximum_single = 0; + + JSValue result = JS_Call(context, probe, JS_UNDEFINED, 0, NULL); + if (report_exception(context, result, "probe")) return 1; + int32_t checksum = 0; + if (JS_ToInt32(context, &checksum, result) < 0) return 1; + JS_FreeValue(context, result); + size_t operation_peak = tracker.peak; + size_t after_operation = tracker.current; + JS_RunGC(runtime); + size_t after_gc = tracker.current; + JSMemoryUsage usage; + JS_ComputeMemoryUsage(runtime, &usage); + printf("checksum=%d\n", checksum); + printf("empty_runtime_bytes=%zu\n", empty_runtime); + printf("module_retained_bytes=%zu\n", module_retained - empty_runtime); + printf("module_load_peak_delta_bytes=%zu\n", load_peak - empty_runtime); + printf("warmup_retained_delta_bytes=%zu\n", retained - module_retained); + printf("operation_peak_delta_bytes=%zu\n", operation_peak - retained); + printf("operation_retained_before_gc_bytes=%zu\n", after_operation - retained); + printf("operation_retained_after_gc_bytes=%zu\n", after_gc - retained); + printf("operation_max_single_allocation_bytes=%zu\n", tracker.maximum_single); + printf("quickjs_memory_used_bytes=%lld\n", (long long)usage.memory_used_size); + JS_FreeValue(context, probe); + JS_FreeValue(context, global); + JS_FreeContext(context); + JS_FreeRuntime(runtime); + return tracker.current == 0 ? 0 : 2; +} diff --git a/tests/http-body-hardening.test.ts b/tests/http-body-hardening.test.ts new file mode 100644 index 00000000..774f3ade --- /dev/null +++ b/tests/http-body-hardening.test.ts @@ -0,0 +1,413 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + bodyFromBinding, + type BodyController, + type BodyStream, + extractBody, +} from "../framework/src/net/http-body.ts"; + +async function captureReadFault(controller: BodyController): Promise { + try { + await controller.stream.readInto(new Uint8Array(4)); + } catch (error) { + return error; + } + throw new Error("expected HTTP body read to fail"); +} + +function rejectOwnCall(operation: T): T { + Object.defineProperty(operation, "call", { + configurable: true, + value() { + throw new Error("user function .call must not be read"); + }, + }); + return operation; +} + +describe("HTTP body iterator records", () => { + test("snapshots the iterator factory, next, and return getters once", async () => { + let iteratorFactoryReads = 0; + let iteratorFactoryCalls = 0; + let nextReads = 0; + let nextCalls = 0; + let returnReads = 0; + let returnCalls = 0; + let factoryReceiver: unknown; + let nextReceiver: unknown; + let returnReceiver: unknown; + + const iterator = Object.create(null) as AsyncIterator; + Object.defineProperties(iterator, { + next: { + get() { + nextReads++; + if (nextReads !== 1) throw new Error("next getter read twice"); + return rejectOwnCall(async function (this: unknown) { + nextReceiver = this; + nextCalls++; + return { value: new Uint8Array([1, 2]), done: false }; + }); + }, + }, + return: { + get() { + returnReads++; + if (returnReads !== 1) throw new Error("return getter read twice"); + return rejectOwnCall(async function (this: unknown) { + returnReceiver = this; + returnCalls++; + return { value: undefined, done: true }; + }); + }, + }, + }); + + const iterable = Object.create(null) as AsyncIterable; + Object.defineProperty(iterable, Symbol.asyncIterator, { + get() { + iteratorFactoryReads++; + if (iteratorFactoryReads !== 1) throw new Error("iterator getter read twice"); + return rejectOwnCall(function (this: unknown) { + factoryReceiver = this; + iteratorFactoryCalls++; + return iterator; + }); + }, + }); + + const controller = extractBody(iterable).controller; + expect(iteratorFactoryReads).toBe(1); + expect(await controller.stream.readInto(new Uint8Array(2))).toEqual({ + bytes: 2, + done: false, + }); + await controller.stream.cancel("stop"); + await controller.stream.cancel("ignored"); + + expect(iteratorFactoryCalls).toBe(1); + expect(nextReads).toBe(1); + expect(nextCalls).toBe(1); + expect(returnReads).toBe(1); + expect(returnCalls).toBe(1); + expect(factoryReceiver).toBe(iterable); + expect(nextReceiver).toBe(iterator); + expect(returnReceiver).toBe(iterator); + }); +}); + +test("HTTP body execution survives post-load call and Promise poisoning", async () => { + const directory = await mkdtemp(join(tmpdir(), "pocketjs-http-body-intrinsics-")); + const resultPath = join(directory, "result.json"); + const bodyUrl = new URL("../framework/src/net/http-body.ts", import.meta.url).href; + const source = ` + const body = await import(${JSON.stringify(bodyUrl)}); + const defineProperty = Object.defineProperty; + const callDescriptor = Object.getOwnPropertyDescriptor(Function.prototype, "call"); + const resolveDescriptor = Object.getOwnPropertyDescriptor(Promise, "resolve"); + const thenDescriptor = Object.getOwnPropertyDescriptor(Promise.prototype, "then"); + const poisonedCall = () => { throw new Error("poisoned Function.prototype.call used"); }; + const poisonedResolve = () => { throw new Error("poisoned Promise.resolve used"); }; + const poisonedThen = () => { throw new Error("poisoned Promise.prototype.then used"); }; + let cancelCalls = 0; + let readResult; + let faultNames; + let faultMessages; + try { + defineProperty(Function.prototype, "call", { ...callDescriptor, value: poisonedCall }); + defineProperty(Promise, "resolve", { ...resolveDescriptor, value: poisonedResolve }); + defineProperty(Promise.prototype, "then", { ...thenDescriptor, value: poisonedThen }); + + const iterable = { + [Symbol.asyncIterator]() { + let delivered = false; + return { + async next() { + if (delivered) return { value: undefined, done: true }; + delivered = true; + return { value: new Uint8Array([0x61]), done: false }; + }, + }; + }, + }; + const controller = body.extractBody(iterable).controller; + const destination = new Uint8Array(1); + readResult = await controller.stream.readInto(destination); + + const faulty = { + async readInto() { return { bytes: 0, done: false }; }, + async cancel() { cancelCalls++; }, + [Symbol.asyncIterator]() { throw new Error("unused"); }, + }; + const first = body.extractBody(faulty).controller; + const second = first.tee(); + const faults = []; + for (const branch of [first, second]) { + try { + await branch.stream.readInto(new Uint8Array(1)); + } catch (error) { + faults.push(error); + } + } + faultNames = faults.map((error) => error?.name); + faultMessages = faults.map((error) => error?.message); + } finally { + defineProperty(Function.prototype, "call", callDescriptor); + defineProperty(Promise, "resolve", resolveDescriptor); + defineProperty(Promise.prototype, "then", thenDescriptor); + } + await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ + readResult, + cancelCalls, + faultNames, + faultMessages, + })); + `; + + try { + const script = join(directory, "body-intrinsics.ts"); + await Bun.write(script, source); + const child = Bun.spawn([process.execPath, script], { + stdout: "ignore", + stderr: "pipe", + }); + const [exitCode, stderr] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + ]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(await Bun.file(resultPath).json()).toEqual({ + readResult: { bytes: 1, done: false }, + cancelCalls: 1, + faultNames: ["NetworkError", "NetworkError"], + faultMessages: [ + "HTTP BodyStream returned an invalid readInto result", + "HTTP BodyStream returned an invalid readInto result", + ], + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +describe("HTTP body tee fault ownership", () => { + const sourceFault = new Error("source fault"); + const cases: readonly { + readonly title: string; + readonly readInto: BodyStream["readInto"]; + readonly expected?: unknown; + }[] = [ + { + title: "zero-byte non-terminal read", + readInto: async () => ({ bytes: 0, done: false }), + }, + { + title: "read beyond granted credit", + readInto: async (destination) => ({ bytes: destination.byteLength + 1, done: false }), + }, + { + title: "invalid read result", + readInto: async () => null as never, + }, + { + title: "source exception", + readInto: async () => { + throw sourceFault; + }, + expected: sourceFault, + }, + ]; + + for (let index = 0; index < cases.length; index++) { + const faultCase = cases[index]!; + test(`cancels once and preserves ${faultCase.title}`, async () => { + let cancelCalls = 0; + let cancelReason: unknown; + const cancellationFault = new Error("cancellation fault"); + const stream: BodyStream = { + readInto: faultCase.readInto, + async cancel(reason) { + cancelCalls++; + cancelReason = reason; + throw cancellationFault; + }, + [Symbol.asyncIterator]() { + throw new Error("BodyStream iterator must not be used by the tee"); + }, + }; + const first = extractBody(stream).controller; + const second = first.tee(); + + const firstFault = await captureReadFault(first); + const secondFault = await captureReadFault(second); + + expect(firstFault).toBe(faultCase.expected ?? secondFault); + expect(secondFault).toBe(firstFault); + expect(firstFault).not.toBe(cancellationFault); + expect(cancelReason).toBe(firstFault); + expect(cancelCalls).toBe(1); + }); + } + + test("reuses bounded tee segments for one-byte chunks", async () => { + const directory = await mkdtemp(join(tmpdir(), "pocketjs-http-body-segments-")); + const resultPath = join(directory, "result.json"); + const bodyUrl = new URL("../framework/src/net/http-body.ts", import.meta.url).href; + const source = ` + const OriginalUint8Array = Uint8Array; + let segmentAllocations = 0; + globalThis.Uint8Array = new Proxy(OriginalUint8Array, { + construct(target, argumentsList) { + if (argumentsList[0] === 64 * 1024) segmentAllocations++; + return Reflect.construct(target, argumentsList, target); + }, + }); + const body = await import(${JSON.stringify(bodyUrl)}); + let remaining = 4096; + const source = { + [Symbol.asyncIterator]() { + return { + async next() { + if (remaining-- === 0) return { value: undefined, done: true }; + return { value: new OriginalUint8Array([0x61]), done: false }; + }, + }; + }, + }; + const first = body.extractBody(source).controller; + const second = first.tee(); + const outputs = await Promise.all([ + first.aggregate("http.body.test"), + second.aggregate("http.body.test"), + ]); + await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ + firstBytes: outputs[0].byteLength, + equal: outputs[1].every((value, index) => value === outputs[0][index]), + segmentAllocations, + })); + `; + + try { + const script = join(directory, "body-segments.ts"); + await Bun.write(script, source); + const child = Bun.spawn([process.execPath, script], { + stdout: "ignore", + stderr: "pipe", + }); + const [exitCode, stderr] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + ]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + const result = await Bun.file(resultPath).json(); + expect(result.firstBytes).toBe(4096); + expect(result.equal).toBe(true); + expect(result.segmentAllocations).toBeGreaterThan(0); + expect(result.segmentAllocations).toBeLessThanOrEqual(6); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +describe("admitted HTTP body limits", () => { + const limits = Object.freeze({ + bufferedBytes: 6, + chunkBytes: 2, + teeBranchBytes: 4, + }); + + test("rejects buffered inputs and async chunks above admitted defaults", async () => { + expect(() => extractBody("1234567", limits)).toThrow(/exceeds 6 bytes/); + expect(() => extractBody(new Uint8Array(7), limits)).toThrow(/exceeds 6 bytes/); + + const iterable: AsyncIterable = { + async *[Symbol.asyncIterator]() { + yield new Uint8Array([1, 2, 3]); + }, + }; + const controller = extractBody(iterable, limits).controller; + await expect(controller.stream.readInto(new Uint8Array(2))).rejects.toMatchObject({ + code: "resource_limit", + }); + }); + + test("uses admitted chunk credit and tee backpressure", async () => { + const credits: number[] = []; + const stream: BodyStream = { + async readInto(destination) { + credits.push(destination.byteLength); + destination.fill(0x61); + return { bytes: destination.byteLength, done: false }; + }, + async cancel() {}, + async *[Symbol.asyncIterator]() {}, + }; + const first = extractBody(stream, limits).controller; + const second = first.tee(); + const destination = new Uint8Array(2); + expect(await first.stream.readInto(destination)).toEqual({ bytes: 2, done: false }); + expect(await first.stream.readInto(destination)).toEqual({ bytes: 2, done: false }); + + let settled = false; + const blocked = first.stream.readInto(destination).then((result) => { + settled = true; + return result; + }); + await Bun.sleep(0); + expect(settled).toBe(false); + await second.stream.cancel("release backpressure"); + expect(await blocked).toEqual({ bytes: 2, done: false }); + expect(credits.length).toBeGreaterThan(0); + expect(credits.every((credit) => credit <= 2)).toBe(true); + await first.stream.cancel(); + }); + + test("prefetches ignored small binding bodies without losing later reads", async () => { + const bytes = new TextEncoder().encode("hey"); + let offset = 0; + let reads = 0; + const stream: BodyStream = { + async readInto(destination) { + reads++; + if (offset === bytes.byteLength) return { bytes: 0, done: true }; + const count = Math.min(destination.byteLength, bytes.byteLength - offset); + destination.set(bytes.subarray(offset, offset + count)); + offset += count; + return { bytes: count, done: false }; + }, + async cancel() {}, + async *[Symbol.asyncIterator]() {}, + }; + const controller = bodyFromBinding(stream, limits); + await Bun.sleep(0); + expect(offset).toBe(bytes.byteLength); + expect(reads).toBeGreaterThan(1); + expect(new TextDecoder().decode(await controller.aggregate("http.body.test"))).toBe("hey"); + }); + + test("stops ignored large binding bodies at the admitted prefetch window", async () => { + let reads = 0; + let cancels = 0; + const stream: BodyStream = { + async readInto(destination) { + reads++; + destination[0] = 0x61; + return { bytes: 1, done: false }; + }, + async cancel() { cancels++; }, + async *[Symbol.asyncIterator]() {}, + }; + const controller = bodyFromBinding(stream, limits); + await Bun.sleep(0); + expect(reads).toBe(limits.teeBranchBytes); + await controller.stream.cancel(); + expect(cancels).toBe(1); + }); +}); diff --git a/tests/http-fetch-wpt-allowlist.json b/tests/http-fetch-wpt-allowlist.json new file mode 100644 index 00000000..1d5459c4 --- /dev/null +++ b/tests/http-fetch-wpt-allowlist.json @@ -0,0 +1,45 @@ +{ + "status": "staged-partial-port", + "fetchSnapshot": "586cd2a44c2a865b37c166dc0740f3fb8bb220d6", + "wptSnapshot": "6437d68e10721ed4b9b68101ec1ab1a1b67a3995", + "cases": [ + { + "path": "fetch/api/headers/header-setcookie.any.js", + "sourceSha256": "b587804553ca837bf953fea9b40c94237af803710f56f45e98750b450da42be2", + "tests": [ + "Headers.prototype.get combines set-cookie headers in order", + "Headers iterator does not combine set-cookie headers", + "Headers iterator preserves set-cookie ordering", + "Headers.prototype.getSetCookie with multiple headers", + "Set-Cookie is a forbidden response header" + ] + }, + { + "path": "fetch/api/headers/headers-combine.any.js", + "sourceSha256": "8bc26fcc318dc1fb0d0b60f2a4dd6d5fdaa8ac2e148d20856f6186b5d4f4971d", + "tests": [ + "Create headers using same name for different values", + "Iterate combined values", + "Iterate combined values in sorted order" + ] + }, + { + "path": "fetch/api/headers/headers-errors.any.js", + "sourceSha256": "4b4b9c8d9b72a52c5200feaa67f018cc91d05e9ee0c98a2a18ad56f44873047b", + "tests": [ + "Create headers giving an array having one string as init argument", + "Create headers giving an array having three strings as init argument", + "Headers forEach throws if argument is not callable" + ] + }, + { + "path": "fetch/api/headers/headers-normalize.any.js", + "sourceSha256": "8f914199d80ed14cd5dec7992464a10981e0b5b74fd26b70768f04f1fd1fb4c1", + "tests": [ + "Create headers with not normalized values", + "Check append method with not normalized values", + "Check set method with not normalized values" + ] + } + ] +} diff --git a/tests/http-fetch.test.ts b/tests/http-fetch.test.ts new file mode 100644 index 00000000..8bedccf5 --- /dev/null +++ b/tests/http-fetch.test.ts @@ -0,0 +1,1503 @@ +import { describe, expect, test } from "bun:test"; + +import * as http from "../framework/src/net/http.ts"; +import { + Headers, + NetworkError, + Request, + Response as PocketResponse, +} from "../framework/src/net/http.ts"; +import { + getHttpClientBinding, + installHttpClientBindingForTesting, + NETWORK_V1_ABI_MAJOR, + NETWORK_V1_ABI_MINOR, + NetworkV1CommandOpcode, + NetworkV1EventCode, +} from "../framework/src/net/http-binding.ts"; +import type { + HttpClientPrivateBinding, + HttpRequestStartCommand, +} from "../framework/src/net/http-binding.ts"; +import { extractBody } from "../framework/src/net/http-body.ts"; +import type { BodyStream } from "../framework/src/net/http-body.ts"; + +const HTTP_FEATURE = "network.http.client"; +const TLS_FEATURE = "network.http.client.tls"; + +interface WptAllowlist { + readonly status: string; + readonly fetchSnapshot: string; + readonly wptSnapshot: string; + readonly cases: readonly { + readonly path: string; + readonly sourceSha256: string; + readonly tests: readonly string[]; + }[]; +} + +const WPT_ALLOWLIST = await Bun.file( + new URL("./http-fetch-wpt-allowlist.json", import.meta.url), +).json() as WptAllowlist; + +test("pins the staged Fetch/WPT allowlist snapshots", () => { + expect(WPT_ALLOWLIST).toMatchObject({ + status: "staged-partial-port", + fetchSnapshot: "586cd2a44c2a865b37c166dc0740f3fb8bb220d6", + wptSnapshot: "6437d68e10721ed4b9b68101ec1ab1a1b67a3995", + }); + expect(WPT_ALLOWLIST.cases.map((entry) => [entry.path, entry.sourceSha256])).toEqual([ + [ + "fetch/api/headers/header-setcookie.any.js", + "b587804553ca837bf953fea9b40c94237af803710f56f45e98750b450da42be2", + ], + [ + "fetch/api/headers/headers-combine.any.js", + "8bc26fcc318dc1fb0d0b60f2a4dd6d5fdaa8ac2e148d20856f6186b5d4f4971d", + ], + [ + "fetch/api/headers/headers-errors.any.js", + "4b4b9c8d9b72a52c5200feaa67f018cc91d05e9ee0c98a2a18ad56f44873047b", + ], + [ + "fetch/api/headers/headers-normalize.any.js", + "8f914199d80ed14cd5dec7992464a10981e0b5b74fd26b70768f04f1fd1fb4c1", + ], + ]); +}); + +function frozenBinding( + start: HttpClientPrivateBinding["start"], + features: readonly string[] = [HTTP_FEATURE], + alpnProtocols?: readonly string[], + limitDefaults: Readonly>> = {}, +): HttpClientPrivateBinding { + const featureSet = Object.freeze([...features]); + const values = Object.freeze([ + Object.freeze({ + name: "http.bufferedBodyBytes", + default: limitDefaults["http.bufferedBodyBytes"] ?? 8 * 1024 * 1024, + hard: 8 * 1024 * 1024, + minimum: 1, + }), + Object.freeze({ + name: "http.headerBytes", + default: limitDefaults["http.headerBytes"] ?? 64 * 1024, + hard: 64 * 1024, + minimum: 1, + }), + Object.freeze({ + name: "http.maxBodyChunkBytes", + default: limitDefaults["http.maxBodyChunkBytes"] ?? 64 * 1024, + hard: 64 * 1024, + minimum: 1, + }), + Object.freeze({ + name: "http.maxOperations", + default: limitDefaults["http.maxOperations"] ?? 8, + hard: 8, + minimum: 1, + }), + Object.freeze({ + name: "runtime.nativeBufferBytes", + default: limitDefaults["runtime.nativeBufferBytes"] ?? 512 * 1024, + hard: 512 * 1024, + minimum: 1, + }), + ]); + return Object.freeze({ + abiMajor: NETWORK_V1_ABI_MAJOR, + abiMinor: NETWORK_V1_ABI_MINOR, + featureSet, + httpClientLimits: Object.freeze({ values, features: featureSet }), + ...(alpnProtocols === undefined + ? {} + : { alpnProtocols: Object.freeze([...alpnProtocols]) }), + start, + }); +} + +function okOperation( + command: HttpRequestStartCommand, + overrides: Partial<{ + status: number; + statusText: string; + headers: readonly { name: string; value: string }[]; + url: string; + redirected: boolean; + body: BodyStream | null; + bufferedBodyBytes: number; + }> = {}, + cancel: () => void = () => {}, +) { + return { + response: Promise.resolve({ + eventCode: NetworkV1EventCode.HttpResponseHeaders as const, + operationId: command.operationId, + status: 200, + statusText: "OK", + headers: [], + url: command.url, + redirected: false, + ...overrides, + }), + cancel, + }; +} + +function streamFromBytes(bytes: Uint8Array): BodyStream { + let offset = 0; + let cancelled = false; + return { + async readInto(destination) { + if (cancelled || offset === bytes.byteLength) return { bytes: 0, done: true }; + const count = Math.min(destination.byteLength, bytes.byteLength - offset); + destination.set(bytes.subarray(offset, offset + count)); + offset += count; + return { bytes: count, done: false }; + }, + async cancel() { + cancelled = true; + offset = bytes.byteLength; + }, + async *[Symbol.asyncIterator]() { + const destination = new Uint8Array(64); + for (;;) { + const result = await this.readInto(destination); + if (result.done) return; + yield destination.slice(0, result.bytes); + } + }, + }; +} + +function streamFromText(value: string): BodyStream { + return streamFromBytes(new TextEncoder().encode(value)); +} + +const WPT_PORTED_CASES: Readonly void>> = Object.freeze({ + "Headers.prototype.get combines set-cookie headers in order": () => { + const headers = new Headers([ + ["set-cookie", "foo=bar"], + ["Set-Cookie", "fizz=buzz; domain=example.com"], + ]); + expect(headers.get("set-cookie")).toBe("foo=bar, fizz=buzz; domain=example.com"); + }, + "Headers iterator does not combine set-cookie headers": () => { + expect([...new Headers([ + ["set-cookie", "foo=bar"], + ["Set-Cookie", "fizz=buzz; domain=example.com"], + ])]).toEqual([ + ["set-cookie", "foo=bar"], + ["set-cookie", "fizz=buzz; domain=example.com"], + ]); + }, + "Headers iterator preserves set-cookie ordering": () => { + expect([...new Headers([ + ["set-cookie", "z=z"], + ["set-cookie", "a=a"], + ["set-cookie", "n=n"], + ])]).toEqual([ + ["set-cookie", "z=z"], + ["set-cookie", "a=a"], + ["set-cookie", "n=n"], + ]); + }, + "Headers.prototype.getSetCookie with multiple headers": () => { + expect(new Headers([ + ["set-cookie", "foo=bar"], + ["Set-Cookie", "fizz=buzz; domain=example.com"], + ]).getSetCookie()).toEqual(["foo=bar", "fizz=buzz; domain=example.com"]); + }, + "Set-Cookie is a forbidden response header": () => { + const response = new PocketResponse(); + response.headers.append("Set-Cookie", "foo=bar"); + expect(response.headers.getSetCookie()).toEqual([]); + }, + "Create headers using same name for different values": () => { + const headers = new Headers([ + ["single", "singleValue"], + ["double", "doubleValue1"], + ["double", "doubleValue2"], + ]); + expect(headers.get("single")).toBe("singleValue"); + expect(headers.get("double")).toBe("doubleValue1, doubleValue2"); + }, + "Iterate combined values": () => { + expect([...new Headers([["1", "a"], ["1", "b"]])]).toEqual([["1", "a, b"]]); + }, + "Iterate combined values in sorted order": () => { + expect([...new Headers([["2", "a"], ["1", "b"], ["2", "b"]])]).toEqual([ + ["1", "b"], + ["2", "a, b"], + ]); + }, + "Create headers giving an array having one string as init argument": () => { + expect(() => new Headers([["name"] as unknown as [string, string]])).toThrow(TypeError); + }, + "Create headers giving an array having three strings as init argument": () => { + expect(() => new Headers([ + ["invalid", "one", "two"] as unknown as [string, string], + ])).toThrow(TypeError); + }, + "Headers forEach throws if argument is not callable": () => { + expect(() => new Headers([["name", "value"]]).forEach(undefined as never)) + .toThrow(TypeError); + }, + "Create headers with not normalized values": () => { + const headers = new Headers({ name1: " space ", name2: "\ttab\t", name4: "\r\n newLine" }); + expect([...headers]).toEqual([ + ["name1", "space"], + ["name2", "tab"], + ["name4", "newLine"], + ]); + }, + "Check append method with not normalized values": () => { + const headers = new Headers(); + headers.append("name", "\t value \t"); + expect(headers.get("name")).toBe("value"); + }, + "Check set method with not normalized values": () => { + const headers = new Headers(); + headers.set("name", "\r\n\tvalue\n"); + expect(headers.get("name")).toBe("value"); + }, +}); + +describe("pinned WPT header subset", () => { + for (const source of WPT_ALLOWLIST.cases) { + for (const title of source.tests) { + const implementation = WPT_PORTED_CASES[title]; + test(`${source.path}: ${title}`, () => { + expect(implementation).toBeFunction(); + implementation!(); + }); + } + } + test("contains no unreferenced local ports", () => { + const selected = new Set(WPT_ALLOWLIST.cases.flatMap((entry) => entry.tests)); + expect(Object.keys(WPT_PORTED_CASES).sort()).toEqual([...selected].sort()); + }); +}); + +describe("HTTP Headers snapshot", () => { + test("normalizes, combines duplicates, separates Set-Cookie, and sorts iteration", () => { + const headers = new Headers([ + ["Z-Last", " z "], + ["X-Test", "one"], + ["x-test", "two"], + ["Set-Cookie", "a=1"], + ["set-cookie", "b=2"], + ]); + + expect(headers.get("X-Test")).toBe("one, two"); + expect(headers.getSetCookie()).toEqual(["a=1", "b=2"]); + expect([...headers]).toEqual([ + ["set-cookie", "a=1"], + ["set-cookie", "b=2"], + ["x-test", "one, two"], + ["z-last", "z"], + ]); + expect([...headers.keys()]).toEqual(["set-cookie", "set-cookie", "x-test", "z-last"]); + expect([...headers.values()]).toEqual(["a=1", "b=2", "one, two", "z"]); + + const receiver = { seen: [] as unknown[] }; + headers.forEach(function (this: typeof receiver, value, key, owner) { + this.seen.push([key, value, owner]); + }, receiver); + expect(receiver.seen[0]).toEqual(["set-cookie", "a=1", headers]); + + const live = new Headers([["fizz", "buzz"], ["x-header", "test"]]); + const iterator = live[Symbol.iterator](); + expect(iterator.next().value).toEqual(["fizz", "buzz"]); + live.append("set-cookie", "a=b"); + expect(iterator.next().value).toEqual(["set-cookie", "a=b"]); + live.append("accept", "text/html"); + expect(iterator.next().value).toEqual(["set-cookie", "a=b"]); + expect(iterator.next().value).toEqual(["x-header", "test"]); + }); + + test("validates names, values, pair arity, and fixed safety ceilings", () => { + expect(() => new Headers({ "bad name": "x" })).toThrow(TypeError); + expect(() => new Headers({ good: "x\r\ninjected: yes" })).toThrow(TypeError); + expect(() => new Headers({ good: "snowman ☃" })).toThrow(TypeError); + expect(() => new Headers([["only-one"] as unknown as [string, string]])).toThrow( + /exactly two/, + ); + expect(() => new Headers(["ab" as unknown as [string, string]])).toThrow(TypeError); + + const normalized = new Headers({ + foldedEdge: "\r\n\tvalue\n", + deployedControl: "\t\f\tvalue\n", + }); + expect(normalized.get("foldedEdge")).toBe("value"); + expect(normalized.get("deployedControl")).toBe("\f\tvalue"); + expect(() => new Headers([["a", "b", "c"] as unknown as [string, string]])).toThrow( + /exactly two/, + ); + + const infinitePair = { + *[Symbol.iterator]() { + for (;;) yield "x"; + }, + }; + expect(() => new Headers([infinitePair as unknown as [string, string]])).toThrow( + /exactly two/, + ); + + const tooMany = Array.from({ length: 129 }, (_, index) => [`x-${index}`, "v"] as const); + expect(() => new Headers(tooMany)).toThrow(NetworkError); + try { + new Headers(tooMany); + } catch (error) { + expect(error).toMatchObject({ code: "resource_limit", operation: "http.Headers" }); + } + }); + + test("captures Headers iterator records and result fields once", () => { + let outerNextGets = 0; + let pairNextGets = 0; + let doneGets = 0; + let valueGets = 0; + const pair = { + [Symbol.iterator]() { + let index = 0; + return { + get next() { + pairNextGets++; + return () => { + const current = index++; + return { + get done() { + doneGets++; + return current >= 2; + }, + get value() { + valueGets++; + return current === 0 ? "x-once" : "yes"; + }, + }; + }; + }, + }; + }, + }; + const init = { + [Symbol.iterator]() { + let emitted = false; + return { + get next() { + outerNextGets++; + return () => emitted + ? { done: true, value: undefined } + : (emitted = true, { done: false, value: pair }); + }, + }; + }, + }; + const headers = new Headers(init as Iterable); + expect(headers.get("x-once")).toBe("yes"); + expect({ outerNextGets, pairNextGets, doneGets, valueGets }).toEqual({ + outerNextGets: 1, + pairNextGets: 1, + doneGets: 3, + valueGets: 2, + }); + + let pairReturnGets = 0; + let outerReturnGets = 0; + const badPair = { + [Symbol.iterator]() { + let count = 0; + return { + next: () => ({ done: false, value: count++ }), + get return() { + pairReturnGets++; + return () => ({ done: true, value: undefined }); + }, + }; + }, + }; + const badInit = { + [Symbol.iterator]() { + return { + next: () => ({ done: false, value: badPair }), + get return() { + outerReturnGets++; + return () => ({ done: true, value: undefined }); + }, + }; + }, + }; + expect(() => new Headers(badInit as Iterable)) + .toThrow(/exactly two/); + expect({ pairReturnGets, outerReturnGets }).toEqual({ + pairReturnGets: 1, + outerReturnGets: 1, + }); + }); + + test("applies request and response guards", () => { + const request = new Request("https://example.test/", { + headers: { + host: "attacker.test", + trailer: "x-checksum", + "proxy-secret": "no", + "sec-pocket": "no", + "x-http-method-override": "GET, TRACE", + "x-visible": "yes", + }, + }); + expect([...request.headers]).toEqual([["x-visible", "yes"]]); + request.headers.append("x-http-method-override", '"GET, TRACE"'); + expect(request.headers.get("x-http-method-override")).toBe('"GET, TRACE"'); + request.headers.append("x-http-method-override", '"GET, TRACE", TRACK'); + expect(request.headers.get("x-http-method-override")).toBe('"GET, TRACE"'); + request.headers.set("host", "still-no"); + expect(request.headers.has("host")).toBe(false); + + const response = new PocketResponse(null, { + headers: [["set-cookie", "hidden=1"], ["x-visible", "yes"]], + }); + expect(response.headers.getSetCookie()).toEqual([]); + expect(response.headers.get("x-visible")).toBe("yes"); + }); + + test("keeps request guards and TLS admission on captured intrinsics", () => { + const result = Bun.spawnSync([ + "bun", + "tests/fixtures/http-hostile-intrinsics.ts", + ], { + cwd: new URL("..", import.meta.url).pathname, + stdout: "pipe", + stderr: "pipe", + }); + expect(result.exitCode, result.stderr.toString()).toBe(0); + expect(result.stdout.toString()).toBe(""); + }); +}); + +describe("HTTP Request and Response objects", () => { + test("constructs Request values, snapshots data windows, and transfers input bodies", async () => { + const bytes = new Uint8Array([0, 1, 2, 3]); + const request = new Request("HTTP://EXAMPLE.TEST:80?q=1#fragment", { + method: "post", + body: bytes.subarray(1, 3), + redirect: "manual", + }); + bytes.fill(9); + expect(request.method).toBe("POST"); + expect(request.url).toBe("http://example.test/?q=1#fragment"); + expect(request.redirect).toBe("manual"); + expect(request.bodyUsed).toBe(false); + expect([...new Uint8Array(await request.arrayBuffer())]).toEqual([1, 2]); + expect(request.bodyUsed).toBe(true); + + const source = new Request("https://example.test/upload", { + method: "POST", + body: "payload", + }); + const moved = new Request(source); + expect(source.bodyUsed).toBe(true); + await expect(source.text()).rejects.toMatchObject({ code: "invalid_state" }); + expect(await moved.text()).toBe("payload"); + + const controller = new http.AbortController(); + const signalled = new Request("https://example.test/", { signal: controller.signal }); + const signalledClone = signalled.clone(); + expect(signalled.signal).not.toBe(controller.signal); + expect(signalledClone.signal).not.toBe(signalled.signal); + controller.abort("stop"); + expect(signalled.signal).toMatchObject({ aborted: true, reason: "stop" }); + expect(signalledClone.signal).toMatchObject({ aborted: true, reason: "stop" }); + }); + + test("rejects invalid methods, body combinations, URLs, and dictionaries", () => { + for (const method of ["CONNECT", "trace", "bad method"]) { + expect(() => new Request("https://example.test/", { method })).toThrow(TypeError); + } + expect(() => new Request("https://example.test/", { body: "x" })).toThrow(TypeError); + expect(() => new Request("http://")).toThrow(TypeError); + expect(() => new Request("http://user:pass@example.test/")).toThrow(TypeError); + expect(() => new Request("http://example.test:bad/")).toThrow(TypeError); + expect(() => new Request("http://example.test:65536/")).toThrow(TypeError); + expect(() => new Request("http://[::::]/")).toThrow(TypeError); + expect(new Request("http://example.test/%zz").url) + .toBe("http://example.test/%zz"); + expect(new Request("http://example.test/a/../b").url) + .toBe("http://example.test/b"); + expect(new Request("http://café.test/").url) + .toBe("http://xn--caf-dma.test/"); + expect(new Request("http://[2001:0DB8:0:0:0:0:0:1]:80/").url) + .toBe("http://[2001:db8::1]/"); + expect(() => new Request("https://example.test/", { redirect: "elsewhere" as never })) + .toThrow(TypeError); + expect(() => new Request("https://example.test/", { maxRedirects: 6 })).toThrow(TypeError); + expect(() => new Request("https://example.test/", { + tls: { verification: "broken" as never }, + })).toThrow(TypeError); + + expect(() => new Request("https://example.test/", { + tls: { ca: new Uint8Array(64 * 1024 + 1) }, + })).toThrow(/custom CA exceeds/); + expect(() => new Request("https://example.test/", { + tls: { alpn: Array.from({ length: 17 }, (_, index) => `p${index}`) }, + })).toThrow(/ALPN cannot exceed/); + expect(() => new Request("https://example.test/", { + tls: { serverName: "a".repeat(254) }, + })).toThrow(/serverName/); + expect(() => new Request("https://example.test/", { + tls: { credential: "c".repeat(257) }, + })).toThrow(/credential id/); + expect(() => new Request("https://example.test/", { + limits: Object.fromEntries( + Array.from({ length: 33 }, (_, index) => [`limit-${index}`, 1]), + ), + })).toThrow(/cannot exceed 32/); + expect(() => new Request("https://example.test/", { + limits: { ["x".repeat(65)]: 1 }, + })).toThrow(/limit name/); + }); + + test("snapshots WebIDL dictionaries and intrinsic byte views exactly once", async () => { + const reads = new Map(); + const values: Record = { + body: null, + headers: undefined, + limits: undefined, + maxRedirects: 2, + method: "GET", + redirect: { toString: () => "manual" }, + ref: true, + signal: undefined, + timeouts: undefined, + tls: undefined, + }; + const init: Record = {}; + for (const [name, value] of Object.entries(values)) { + Object.defineProperty(init, name, { + enumerable: true, + get() { + reads.set(name, (reads.get(name) ?? 0) + 1); + return value; + }, + }); + } + const request = new Request("http://example.test/#kept", init as http.RequestInit); + expect(request.url).toBe("http://example.test/#kept"); + expect(request.redirect).toBe("manual"); + expect([...reads.values()]).toEqual(Array(Object.keys(values).length).fill(1)); + + let bodyReads = 0; + const hostile = Object.defineProperty({ method: "GET" }, "body", { + get() { + bodyReads++; + return bodyReads === 1 ? "forbidden" : null; + }, + }); + expect(() => new Request("http://example.test/", hostile)).toThrow(/cannot have a body/); + expect(bodyReads).toBe(1); + + const oversized = new Uint8Array(64 * 1024 + 1); + Object.defineProperty(oversized, "byteLength", { get: () => 1 }); + expect(() => new Request("https://example.test/", { + tls: { ca: oversized }, + })).toThrow(/custom CA exceeds/); + + const bytes = new Uint8Array([1, 2, 3]); + Object.defineProperty(bytes, Symbol.iterator, { + get() { + throw new Error("user iterator must not run"); + }, + }); + const response = new PocketResponse(bytes); + expect([...new Uint8Array(await response.arrayBuffer())]).toEqual([1, 2, 3]); + }); + + test("preserves an input Request body when RequestInit.body is null", async () => { + const source = new Request("http://example.test/upload", { + method: "POST", + body: "payload", + }); + const moved = new Request(source, { body: null }); + expect(await moved.text()).toBe("payload"); + expect(source.bodyUsed).toBe(true); + }); + + test("uses fixed WebIDL constructor lengths and Response conversions", () => { + expect(Headers.length).toBe(0); + expect(Request.length).toBe(1); + expect(new PocketResponse(null, null).status).toBe(200); + expect(new PocketResponse(null, { status: "201" as unknown as number }).status).toBe(201); + expect(new PocketResponse(null, { status: 201.9 }).status).toBe(201); + expect(new PocketResponse(null, { status: 65_736 }).status).toBe(200); + expect(PocketResponse.redirect("http://example.test/", "302" as unknown as number).status) + .toBe(302); + }); + + test("keeps internal abort propagation ahead of throwing user listeners", () => { + const controller = new http.AbortController(); + controller.signal.addEventListener("abort", () => { + throw new Error("user listener"); + }); + Object.defineProperty(controller.signal, "addEventListener", { + value: () => { throw new Error("overridden method"); }, + }); + const request = new Request("http://example.test/", { signal: controller.signal }); + expect(() => controller.abort("stop")).not.toThrow(); + expect(request.signal).toMatchObject({ aborted: true, reason: "stop" }); + }); + + test("supports bounded helpers, JSON constructors, redirects, and null-body statuses", async () => { + const response = new PocketResponse("hello", { + status: 201, + statusText: "Created", + }); + expect(response.status).toBe(201); + expect(response.statusText).toBe("Created"); + expect(response.ok).toBe(true); + expect(response.headers.get("content-type")).toBe("text/plain;charset=UTF-8"); + expect(await response.text()).toBe("hello"); + await expect(response.arrayBuffer()).rejects.toMatchObject({ code: "invalid_state" }); + + const json = PocketResponse.json({ answer: 42 }); + expect(json.headers.get("content-type")).toBe("application/json"); + expect(await json.json()).toEqual({ answer: 42 }); + expect(() => PocketResponse.json(undefined)).toThrow(TypeError); + await expect(new PocketResponse("not json").json()).rejects.toBeInstanceOf(SyntaxError); + + const redirect = PocketResponse.redirect("https://example.test/next", 307); + expect(redirect.status).toBe(307); + expect(redirect.headers.get("location")).toBe("https://example.test/next"); + expect(() => redirect.headers.set("location", "https://attacker.test/")) + .toThrow(TypeError); + expect(() => PocketResponse.redirect("https://example.test/", 305)).toThrow(RangeError); + expect(() => new PocketResponse("x", { status: 204 })).toThrow(TypeError); + expect(() => new PocketResponse(null, { status: 199 })).toThrow(RangeError); + }); +}); + +describe("bounded BodyStream behavior", () => { + test("supports readInto and rejects empty, competing, or mixed readers", async () => { + let release!: (value: IteratorResult) => void; + const iterable: AsyncIterable = { + [Symbol.asyncIterator]() { + let delivered = false; + return { + next() { + if (delivered) return Promise.resolve({ value: undefined, done: true as const }); + delivered = true; + return new Promise>((resolve) => { + release = resolve; + }); + }, + }; + }, + }; + const response = new PocketResponse(iterable); + const destination = new Uint8Array(4); + expect(response.body).not.toBeNull(); + await expect(response.body!.readInto(new Uint8Array())).rejects.toMatchObject({ + code: "invalid_state", + }); + const pending = response.body!.readInto(destination); + await expect(response.body!.readInto(destination)).rejects.toMatchObject({ code: "busy" }); + expect(() => response.body![Symbol.asyncIterator]()).toThrow(NetworkError); + release({ value: new Uint8Array([1, 2]), done: false }); + expect(await pending).toEqual({ bytes: 2, done: false }); + expect([...destination.subarray(0, 2)]).toEqual([1, 2]); + expect(await response.body!.readInto(destination)).toEqual({ bytes: 0, done: true }); + + const hostileDestination = new Uint8Array(4); + Object.defineProperties(hostileDestination, { + byteLength: { get: () => { throw new Error("shadow byteLength"); } }, + buffer: { get: () => { throw new Error("shadow buffer"); } }, + byteOffset: { get: () => { throw new Error("shadow byteOffset"); } }, + set: { value: () => { throw new Error("shadow set"); } }, + }); + const hostileResponse = new PocketResponse("ok"); + expect(await hostileResponse.body!.readInto(hostileDestination)).toEqual({ + bytes: 2, + done: false, + }); + expect([hostileDestination[0], hostileDestination[1]]).toEqual([0x6f, 0x6b]); + }); + + test("rejects sequential and concurrent helper reuse", async () => { + const sequential = new PocketResponse("value"); + expect(await sequential.text()).toBe("value"); + await expect(sequential.text()).rejects.toMatchObject({ code: "invalid_state" }); + + const concurrent = new PocketResponse("value"); + const first = concurrent.text(); + await expect(concurrent.arrayBuffer()).rejects.toMatchObject({ code: "invalid_state" }); + expect(await first).toBe("value"); + + const internal = extractBody("producer once").controller; + const producer = internal.createProducer(); + expect(() => internal.createProducer()).toThrow(NetworkError); + await producer.cancel(); + }); + + test("tees two branches and backpressures a branch at the fixed ceiling", async () => { + const small = new PocketResponse("clone me"); + const smallClone = small.clone(); + expect(await small.text()).toBe("clone me"); + expect(await smallClone.text()).toBe("clone me"); + expect(() => small.clone()).toThrow(NetworkError); + + const large = new PocketResponse(new Uint8Array(300_000)); + const lagging = large.clone(); + let settled = false; + const aggregate = large.arrayBuffer().then((value) => { + settled = true; + return value; + }); + await Bun.sleep(10); + expect(settled).toBe(false); + await lagging.body!.cancel(); + expect((await aggregate).byteLength).toBe(300_000); + + const bounded = new PocketResponse("bounded clones"); + const branches: PocketResponse[] = [bounded]; + for (let index = 0; index < 7; index++) branches.push(bounded.clone()); + expect(() => bounded.clone()).toThrow(NetworkError); + try { + bounded.clone(); + } catch (error) { + expect(error).toMatchObject({ code: "resource_limit" }); + } + await Promise.all(branches.map((branch) => branch.body!.cancel())); + }); + + test("calls an AsyncIterable return hook at most once on cancellation", async () => { + let returns = 0; + const body: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + next: async () => ({ value: new Uint8Array([1]), done: false }), + return: async () => { + returns++; + return { value: undefined, done: true }; + }, + }; + }, + }; + const response = new PocketResponse(body); + await response.body!.readInto(new Uint8Array(1)); + await response.body!.cancel(); + await response.body!.cancel(); + expect(returns).toBe(1); + }); + + test("snapshots iterator and BodyStream result fields once", async () => { + let valueReads = 0; + let nextCalls = 0; + const iterable: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + next: async () => { + nextCalls++; + if (nextCalls > 1) return { value: undefined, done: true }; + return Object.defineProperty({ done: false }, "value", { + get() { + valueReads++; + return new Uint8Array([7]); + }, + }) as IteratorResult; + }, + }; + }, + }; + expect(await new PocketResponse(iterable).text()).toBe("\u0007"); + expect(valueReads).toBe(1); + + let bytesReads = 0; + let doneReads = 0; + let emitted = false; + const stream: BodyStream = { + async readInto(destination) { + if (emitted) return { bytes: 0, done: true }; + emitted = true; + destination[0] = 9; + return { + get bytes() { + bytesReads++; + return 1; + }, + get done() { + doneReads++; + return false; + }, + }; + }, + async cancel() {}, + async *[Symbol.asyncIterator]() {}, + }; + expect([...new Uint8Array(await new PocketResponse(stream).arrayBuffer())]).toEqual([9]); + expect(bytesReads).toBe(1); + expect(doneReads).toBe(1); + }); + + test("coalesces one-byte sources under fixed aggregate and tee segment counts", async () => { + let remaining = 20_000; + const tiny: AsyncIterable = { + async *[Symbol.asyncIterator]() { + while (remaining-- > 0) yield new Uint8Array([0x61]); + }, + }; + const response = new PocketResponse(tiny); + const clone = response.clone(); + const [left, right] = await Promise.all([response.text(), clone.text()]); + expect(left.length).toBe(20_000); + expect(right).toBe(left); + }); +}); + +describe("private lexical HTTP binding seam", () => { + test("is absent from the public module and never reads or writes a global binding", async () => { + expect("installHttpClientBindingForTesting" in http).toBe(false); + expect(Object.prototype.hasOwnProperty.call(globalThis, "__POCKET_NETWORK_BINDING_V1__")) + .toBe(false); + expect(Object.prototype.hasOwnProperty.call(globalThis, "net")).toBe(false); + await expect(http.fetch("https://example.test/")).rejects.toMatchObject({ + code: "unsupported", + operation: "http.fetch", + }); + }); + + test("requires one frozen ABI-compatible table and captures a safe snapshot", () => { + const mutable = { ...frozenBinding(() => { throw new Error("unused"); }) }; + expect(() => installHttpClientBindingForTesting(mutable)).toThrow(/frozen/); + + const incomplete = frozenBinding(() => { throw new Error("unused"); }); + const incompleteLimits = Object.freeze({ + values: Object.freeze(incomplete.httpClientLimits.values.slice(1)), + features: incomplete.httpClientLimits.features, + }); + expect(() => installHttpClientBindingForTesting(Object.freeze({ + ...incomplete, + httpClientLimits: incompleteLimits, + }))).toThrow(/omit required http\.bufferedBodyBytes/); + + const binding = frozenBinding(() => { throw new Error("unused"); }); + const cleanup = installHttpClientBindingForTesting(binding); + expect(getHttpClientBinding()).not.toBe(binding); + expect(getHttpClientBinding()).toMatchObject({ + abiMajor: NETWORK_V1_ABI_MAJOR, + abiMinor: NETWORK_V1_ABI_MINOR, + featureSet: [HTTP_FEATURE], + }); + expect(() => installHttpClientBindingForTesting(binding)).toThrow(/already installed/); + cleanup(); + expect(getHttpClientBinding()).toBeUndefined(); + }); + + test("rejects binding accessors without invoking them and bypasses a hostile call property", async () => { + const base = frozenBinding((command) => okOperation(command)); + let limitsGetterCalls = 0; + const accessorBinding = { ...base } as Record; + Object.defineProperty(accessorBinding, "httpClientLimits", { + enumerable: true, + get() { + limitsGetterCalls++; + return base.httpClientLimits; + }, + }); + Object.freeze(accessorBinding); + expect(() => installHttpClientBindingForTesting( + accessorBinding as unknown as HttpClientPrivateBinding, + )).toThrow(/own data property/); + expect(limitsGetterCalls).toBe(0); + + let callGetterCalls = 0; + const start = ((command: HttpRequestStartCommand) => okOperation(command)) as + HttpClientPrivateBinding["start"]; + Object.defineProperty(start, "call", { + get() { + callGetterCalls++; + throw new Error("hostile Function.call getter must remain unreachable"); + }, + }); + const cleanup = installHttpClientBindingForTesting(frozenBinding(start)); + try { + const response = await http.fetch("http://example.test/"); + expect(response.status).toBe(200); + expect(callGetterCalls).toBe(0); + } finally { + cleanup(); + } + }); + + test("marshals a numeric request command and wraps Host values in SDK identities", async () => { + let seenCommand: HttpRequestStartCommand | undefined; + let seenSignal: unknown; + let uploaded: number[] = []; + const binding = frozenBinding((command, producer, signal) => { + seenCommand = command; + seenSignal = signal; + const operation = okOperation(command, { + status: 404, + statusText: "Not Found", + headers: [ + { name: "set-cookie", value: "a=1" }, + { name: "set-cookie", value: "b=2" }, + { name: "x-source", value: "binding" }, + ], + body: streamFromText("response body"), + }); + const upload = (async () => { + if (!producer) return; + for (;;) { + const chunk = await producer.pull(2); + if (chunk === null) break; + uploaded.push(...chunk); + } + })(); + return { + ...operation, + response: upload.then(() => operation.response), + }; + }, [HTTP_FEATURE, TLS_FEATURE]); + const cleanup = installHttpClientBindingForTesting(binding); + try { + const controller = new http.AbortController(); + const response = await http.fetch("https://example.test/upload#client-only", { + method: "POST", + body: new Uint8Array([1, 2, 3]), + signal: controller.signal, + headers: [["x-request", "yes"], ["trailer", "no"]], + }); + await Bun.sleep(0); + expect(seenCommand).toMatchObject({ + opcode: NetworkV1CommandOpcode.HttpRequestStart, + url: "https://example.test/upload", + method: "POST", + hasBody: true, + redirect: "follow", + maxRedirects: 5, + ref: true, + }); + expect(Object.isFrozen(seenCommand)).toBe(true); + expect(seenCommand!.headers).toEqual([ + { name: "x-request", value: "yes" }, + ]); + expect(seenSignal).toBeInstanceOf(http.AbortSignal); + expect(seenSignal).not.toBe(controller.signal); + expect(uploaded).toEqual([1, 2, 3]); + + expect(response).toBeInstanceOf(PocketResponse); + expect(response).not.toBeInstanceOf(globalThis.Response); + expect(response.headers).toBeInstanceOf(Headers); + expect(response.status).toBe(404); + expect(response.ok).toBe(false); + expect(response.headers.getSetCookie()).toEqual(["a=1", "b=2"]); + expect(() => response.headers.set("x-source", "changed")).toThrow(TypeError); + expect(await response.text()).toBe("response body"); + } finally { + cleanup(); + } + }); + + test("fails TLS feature and option preflight before calling start", async () => { + const cases: Array<{ + features: readonly string[]; + url: string; + tls?: http.TlsOptions; + alpn?: readonly string[]; + }> = [ + { features: [HTTP_FEATURE], url: "https://example.test/" }, + { + features: [HTTP_FEATURE, TLS_FEATURE], + url: "https://example.test/", + tls: { ca: new Uint8Array([1]) }, + }, + { + features: [HTTP_FEATURE, TLS_FEATURE], + url: "https://example.test/", + tls: { credential: "device" }, + }, + { + features: [HTTP_FEATURE, TLS_FEATURE], + url: "https://example.test/", + tls: { alpn: ["http/1.1"] }, + alpn: ["http/1.1"], + }, + { + features: [HTTP_FEATURE, TLS_FEATURE], + url: "https://example.test/", + tls: { minVersion: "1.3" }, + }, + { + features: [HTTP_FEATURE, TLS_FEATURE], + url: "https://example.test/", + tls: { revocation: "required" }, + }, + { + features: [HTTP_FEATURE, TLS_FEATURE], + url: "https://example.test/", + tls: { verification: "development-insecure" }, + }, + { + features: [HTTP_FEATURE, TLS_FEATURE], + url: "https://example.test/", + tls: { serverName: "other.test" }, + }, + { + features: [HTTP_FEATURE, TLS_FEATURE], + url: "http://example.test/", + tls: { verification: "full" }, + }, + ]; + + for (const item of cases) { + let starts = 0; + const cleanup = installHttpClientBindingForTesting(frozenBinding( + (command) => { + starts++; + return okOperation(command); + }, + item.features, + item.alpn, + )); + try { + await expect(http.fetch(item.url, { tls: item.tls })).rejects.toMatchObject({ + code: "unsupported", + }); + expect(starts).toBe(0); + } finally { + cleanup(); + } + } + }); + + test("applies admitted header, body, and operation limits before Host start", async () => { + let starts = 0; + const cleanup = installHttpClientBindingForTesting(frozenBinding( + (command) => { + starts++; + return okOperation(command); + }, + [HTTP_FEATURE], + undefined, + { + "http.bufferedBodyBytes": 8, + "http.headerBytes": 24, + "http.maxBodyChunkBytes": 4, + }, + )); + try { + await expect(http.fetch("http://example.test/", { + headers: [["x-large", "01234567890123456789"]], + })).rejects.toMatchObject({ code: "resource_limit" }); + await expect(http.fetch("http://example.test/", { + method: "POST", + body: new Uint8Array(9), + })).rejects.toMatchObject({ code: "resource_limit" }); + await expect(http.fetch("http://example.test/", { + limits: { "http.maxBodyChunkBytes": 5 }, + })).rejects.toThrow(/admitted minimum\/default range/); + await expect(http.fetch("http://example.test/", { + limits: { "http.unknown": 1 }, + })).rejects.toThrow(/admitted minimum\/default range/); + expect(starts).toBe(0); + + const response = await http.fetch("http://example.test/", { + limits: { "http.maxBodyChunkBytes": 4 }, + }); + expect(response.status).toBe(200); + expect(starts).toBe(1); + } finally { + cleanup(); + } + }); + + test("lowers response helper credits and ceilings to admitted defaults", async () => { + const observedCredits: number[] = []; + let offset = 0; + const body: BodyStream = { + async readInto(destination) { + observedCredits.push(destination.byteLength); + if (offset === 7) return { bytes: 0, done: true }; + const count = Math.min(destination.byteLength, 7 - offset); + destination.fill(0x61, 0, count); + offset += count; + return { bytes: count, done: false }; + }, + async cancel() {}, + async *[Symbol.asyncIterator]() {}, + }; + const cleanup = installHttpClientBindingForTesting(frozenBinding( + (command) => okOperation(command, { body, bufferedBodyBytes: 6 }), + [HTTP_FEATURE], + undefined, + { + "http.bufferedBodyBytes": 6, + "http.maxBodyChunkBytes": 2, + }, + )); + try { + const response = await http.fetch("http://example.test/"); + await expect(response.text()).rejects.toMatchObject({ code: "resource_limit" }); + expect(observedCredits.length).toBeGreaterThan(0); + expect(observedCredits.every((credit) => credit <= 2)).toBe(true); + } finally { + cleanup(); + } + }); + + test("bounds response helpers using Host metadata", async () => { + const cleanup = installHttpClientBindingForTesting(frozenBinding((command) => + okOperation(command, { + body: streamFromText("12345"), + bufferedBodyBytes: 4, + }))); + try { + const response = await http.fetch("http://example.test/"); + await expect(response.text()).rejects.toMatchObject({ code: "resource_limit" }); + } finally { + cleanup(); + } + }); + + test("cancels operation and both body directions after invalid binding metadata", async () => { + let operationCancels = 0; + let requestCancels = 0; + let responseCancels = 0; + const requestBody: BodyStream = { + readInto: async () => new Promise(() => {}), + cancel: async () => { requestCancels++; }, + async *[Symbol.asyncIterator]() {}, + }; + const responseBody: BodyStream = { + readInto: async () => new Promise(() => {}), + cancel: async () => { responseCancels++; }, + async *[Symbol.asyncIterator]() {}, + }; + const cleanup = installHttpClientBindingForTesting(frozenBinding((command) => + okOperation(command, { + status: 99, + body: responseBody, + }, () => { operationCancels++; }))); + try { + await expect(http.fetch("http://example.test/", { + method: "POST", + body: requestBody, + })).rejects.toMatchObject({ code: "http_protocol_error" }); + await Bun.sleep(0); + expect(operationCancels).toBe(1); + expect(requestCancels).toBe(1); + expect(responseCancels).toBe(1); + } finally { + cleanup(); + } + }); + + test("snapshots response event fields once and rejects non-string headers", async () => { + let statusReads = 0; + let bodyReads = 0; + const cleanup = installHttpClientBindingForTesting(frozenBinding((command) => ({ + response: Promise.resolve({ + eventCode: NetworkV1EventCode.HttpResponseHeaders, + operationId: command.operationId, + get status() { + statusReads++; + return statusReads === 1 ? 200 : 99; + }, + statusText: "OK", + headers: [], + url: command.url, + redirected: false, + get body() { + bodyReads++; + return bodyReads === 1 ? null : streamFromText("hidden"); + }, + }), + cancel() {}, + }))); + try { + const response = await http.fetch("http://example.test/"); + expect(response.status).toBe(200); + expect(response.body).toBeNull(); + expect(statusReads).toBe(1); + expect(bodyReads).toBe(1); + } finally { + cleanup(); + } + + const badHeaderCleanup = installHttpClientBindingForTesting(frozenBinding((command) => + okOperation(command, { + headers: [{ name: 123 as unknown as string, value: "no" }], + }))); + try { + await expect(http.fetch("http://example.test/")).rejects.toMatchObject({ + code: "http_protocol_error", + }); + } finally { + badHeaderCleanup(); + } + }); + + test("rejects and cancels binding bodies forbidden by HEAD and null-body statuses", async () => { + for (const item of [ + { method: "HEAD", status: 200 }, + { method: "GET", status: 204 }, + { method: "GET", status: 205 }, + { method: "GET", status: 304 }, + ]) { + let operationCancels = 0; + let bodyCancels = 0; + const body: BodyStream = { + readInto: async () => new Promise(() => {}), + cancel: async () => { bodyCancels++; }, + async *[Symbol.asyncIterator]() {}, + }; + const cleanup = installHttpClientBindingForTesting(frozenBinding((command) => + okOperation(command, { + status: item.status, + body, + }, () => { operationCancels++; }))); + try { + await expect(http.fetch("http://example.test/", { method: item.method })) + .rejects.toMatchObject({ code: "http_protocol_error" }); + await Bun.sleep(0); + expect(operationCancels).toBe(1); + expect(bodyCancels).toBe(1); + } finally { + cleanup(); + } + } + }); + + test("maps unknown error event strings to http_protocol_error", async () => { + const cleanup = installHttpClientBindingForTesting(frozenBinding((command) => ({ + response: Promise.reject({ + eventCode: NetworkV1EventCode.HttpRequestError, + operationId: command.operationId, + category: "runtime", + code: "made_up", + message: "bad binding", + }), + cancel() {}, + }))); + try { + await expect(http.fetch("http://example.test/")).rejects.toMatchObject({ + category: "protocol", + code: "http_protocol_error", + }); + } finally { + cleanup(); + } + }); + + test("rejects protocol codes belonging to another network protocol", async () => { + const cleanup = installHttpClientBindingForTesting(frozenBinding((command) => ({ + response: Promise.reject({ + eventCode: NetworkV1EventCode.HttpRequestError, + operationId: command.operationId, + category: "protocol", + code: "mqtt_protocol_error", + message: "wrong protocol", + }), + cancel() {}, + }))); + try { + await expect(http.fetch("http://example.test/")).rejects.toMatchObject({ + code: "http_protocol_error", + }); + } finally { + cleanup(); + } + }); + + test("does not publish binding-provided error messages", async () => { + const cleanup = installHttpClientBindingForTesting(frozenBinding((command) => ({ + response: Promise.reject({ + eventCode: NetworkV1EventCode.HttpRequestError, + operationId: command.operationId, + category: "transport", + code: "connection_refused", + message: "Authorization: secret-value", + causeCode: "ECONNREFUSED", + }), + cancel() {}, + }))); + try { + let failure: unknown; + try { + await http.fetch("http://example.test/"); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ + code: "connection_refused", + causeCode: "ECONNREFUSED", + }); + expect(String((failure as Error).message)).not.toContain("secret-value"); + } finally { + cleanup(); + } + }); + + test("normalizes bounded NetworkError metadata from the binding", async () => { + const cleanup = installHttpClientBindingForTesting(frozenBinding(() => ({ + response: Promise.reject(new NetworkError("Cookie: secret-value", { + category: "transport", + code: "connection_refused", + operation: "native.connect", + temporary: true, + address: "EXAMPLE.TEST.", + port: 80, + protocol: "http", + causeCode: "ECONNREFUSED", + reasonCode: 7, + })), + cancel() {}, + }))); + try { + let failure: unknown; + try { + await http.fetch("http://example.test/"); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ + message: "HTTP request failed with connection_refused", + operation: "http.fetch", + temporary: true, + address: "example.test", + port: 80, + causeCode: "ECONNREFUSED", + reasonCode: 7, + }); + expect(String((failure as Error).message)).not.toContain("secret-value"); + } finally { + cleanup(); + } + }); + + test("rejects unsafe NetworkError metadata from the binding", async () => { + const unsafe = new NetworkError("secret-value", { + category: "transport", + code: "connection_refused", + operation: "native.connect", + address: "Authorization: secret-value", + port: 80, + }); + (unsafe as unknown as { temporary: unknown }).temporary = "yes"; + const cleanup = installHttpClientBindingForTesting(frozenBinding(() => ({ + response: Promise.reject(unsafe), + cancel() {}, + }))); + try { + await expect(http.fetch("http://example.test/")).rejects.toMatchObject({ + code: "http_protocol_error", + address: undefined, + }); + } finally { + cleanup(); + } + }); + + test("retires uploads when response headers arrive", async () => { + let uploadCancels = 0; + const requestBody: BodyStream = { + readInto: async () => new Promise(() => {}), + cancel: async () => { uploadCancels++; }, + async *[Symbol.asyncIterator]() {}, + }; + const cleanup = installHttpClientBindingForTesting(frozenBinding((command) => + okOperation(command))); + try { + await http.fetch("http://example.test/", { + method: "POST", + body: requestBody, + }); + await Bun.sleep(0); + expect(uploadCancels).toBe(1); + } finally { + cleanup(); + } + }); + + test("keeps abort active until every cloned response branch terminates", async () => { + let operationCancels = 0; + let bodyCancels = 0; + const responseBody: BodyStream = { + readInto: async () => new Promise(() => {}), + cancel: async () => { bodyCancels++; }, + async *[Symbol.asyncIterator]() {}, + }; + const cleanup = installHttpClientBindingForTesting(frozenBinding((command) => + okOperation(command, { body: responseBody }, () => { operationCancels++; }))); + try { + const controller = new http.AbortController(); + const response = await http.fetch("http://example.test/", { + signal: controller.signal, + }); + const clone = response.clone(); + await response.body!.cancel(); + expect(operationCancels).toBe(0); + expect(bodyCancels).toBe(0); + + controller.abort(); + await Bun.sleep(0); + expect(operationCancels).toBe(1); + expect(bodyCancels).toBe(1); + expect(await clone.body!.readInto(new Uint8Array(1))).toEqual({ + bytes: 0, + done: true, + }); + } finally { + cleanup(); + } + }); + + test("detaches abort propagation after a bodyless operation terminates", async () => { + let bindingSignal: http.AbortSignal | undefined; + let operationCancels = 0; + const cleanup = installHttpClientBindingForTesting(frozenBinding((command, _body, signal) => { + bindingSignal = signal; + return okOperation(command, {}, () => { operationCancels++; }); + })); + try { + const controller = new http.AbortController(); + await http.fetch("http://example.test/", { signal: controller.signal }); + controller.abort(); + await Bun.sleep(0); + expect(bindingSignal?.aborted).toBe(false); + expect(operationCancels).toBe(0); + } finally { + cleanup(); + } + }); + + test("dispatches numeric cancellation on AbortSignal", async () => { + let cancelOpcode: number | undefined; + let cancels = 0; + const cleanup = installHttpClientBindingForTesting(frozenBinding((_command) => ({ + response: new Promise(() => {}), + cancel(command) { + cancels++; + cancelOpcode = command.opcode; + }, + }))); + try { + const controller = new http.AbortController(); + const pending = http.fetch("http://example.test/", { signal: controller.signal }); + controller.abort(); + await expect(pending).rejects.toMatchObject({ code: "aborted" }); + expect(cancelOpcode).toBe(NetworkV1CommandOpcode.OperationCancel); + expect(cancels).toBe(1); + } finally { + cleanup(); + } + }); +}); diff --git a/tests/http-url.test.ts b/tests/http-url.test.ts new file mode 100644 index 00000000..33d8ede7 --- /dev/null +++ b/tests/http-url.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { URL as PocketURL } from "../framework/src/net/index.ts"; +import { + canonicalizeHttpUrl, + HTTP_URL_HOST_CODE_UNITS_LIMIT, + HTTP_URL_INPUT_CODE_UNITS_LIMIT, + HTTP_URL_PATH_SEGMENT_LIMIT, +} from "../framework/src/net/http-url.ts"; +import { domainToAscii } from "../framework/src/net/vendor/url/tr46.ts"; +import { + renderTr46Data, + renderTr46Regexes, +} from "../tools/generate-url-idna-table.ts"; + +const WPT_SNAPSHOT = "6437d68e10721ed4b9b68101ec1ab1a1b67a3995"; +const WPT_URLTESTDATA_SHA256 = + "355c9f1e5f34aae66ba8adfabf3c853f5cd30ea22964ef7a53eb292e7975d81e"; + +function serialized(input: string): string { + const parsed = canonicalizeHttpUrl(input); + return `${parsed.href}${parsed.fragment}`; +} + +describe("bounded HTTP WHATWG URL profile", () => { + test("ports selected cases from the pinned WPT urltestdata snapshot", () => { + expect(WPT_SNAPSHOT).toHaveLength(40); + expect(WPT_URLTESTDATA_SHA256).toHaveLength(64); + const cases: readonly (readonly [string, string])[] = [ + ["http://example\t.\norg", "http://example.org/"], + ["http://f:21/ b ? d # e ", "http://f:21/%20b%20?%20d%20#%20e"], + ["http://f:0/c", "http://f:0/c"], + ["http://f:00000000000000000000080/c", "http://f/c"], + ["http://example.com/foo/%2e", "http://example.com/foo/"], + ["http://example.com/foo/%2e%2", "http://example.com/foo/%2e%2"], + [ + "http://example.com/foo/%2e./%2e%2e/.%2e/%2e.bar", + "http://example.com/%2e.bar", + ], + ["https://example.com/aaa/bbb/%2e%2e?query", "https://example.com/aaa/?query"], + ["https://faß.ExAmPlE/", "https://xn--fa-hia.example/"], + ["http://example.com/你好你好", "http://example.com/%E4%BD%A0%E5%A5%BD%E4%BD%A0%E5%A5%BD"], + ["http://你好你好", "http://xn--6qqa088eba/"], + ["http://www.foo。bar.com", "http://www.foo.bar.com/"], + ["http://example.com\\\\foo\\\\bar", "http://example.com//foo//bar"], + ["http://[2001::1]", "http://[2001::1]/"], + ["http://[::127.0.0.1]", "http://[::7f00:1]/"], + ["http://[0:0:0:0:0:0:13.1.68.3]", "http://[::d01:4403]/"], + ["http://[2001::1]:80", "http://[2001::1]/"], + ["http://192.0x00A80001", "http://192.168.0.1/"], + ["http://0xffffffff", "http://255.255.255.255/"], + ["https://0x.0x.0x.0x", "https://0.0.0.0/"], + ["http://foo:80/", "http://foo/"], + ["http://foo:81/", "http://foo:81/"], + ["https://foo:443/", "https://foo/"], + ["https://foo:80/", "https://foo:80/"], + ]; + for (const [input, expected] of cases) expect(serialized(input)).toBe(expected); + }); + + test("normalizes UTS 46 labels with bidi and ContextJ validation", () => { + const cases: readonly (readonly [string, string | null])[] = [ + ["CAFÉ.example", "xn--caf-dma.example"], + ["faß.de", "xn--fa-hia.de"], + ["βόλος.com", "xn--nxasmm1c.com"], + ["مثال.إختبار", "xn--mgbh0fb.xn--kgbechtv"], + ["עברית.example", "xn--5dbqzzl.example"], + ["क्‍ष.example", "xn--11b2ezcw70k.example"], + ["EXAMPLE.com", "example.com"], + ["☃.com", "xn--n3h.com"], + ["xn--fa-hia.de", "xn--fa-hia.de"], + ["a‌b.example", null], + ["xn--", null], + ["́a.example", null], + ["a_b.example", null], + ]; + for (const [input, expected] of cases) expect(domainToAscii(input)).toBe(expected); + }); + + test("canonicalizes legacy IPv4, IPv6, path, query, and fragment forms", () => { + expect(serialized("HTTP://EXAMPLE.TEST:80/a/./b/../c?q='雪'#a b`雪")) + .toBe("http://example.test/a/c?q=%27%E9%9B%AA%27#a%20b%60%E9%9B%AA"); + expect(serialized("http://127.1/")).toBe("http://127.0.0.1/"); + expect(serialized("http://0x7f.1/")).toBe("http://127.0.0.1/"); + expect(serialized("http://[2001:0DB8:0:0:0:0:0:1]:80/")) + .toBe("http://[2001:db8::1]/"); + expect(serialized("http://example.test/%zz#")).toBe("http://example.test/%zz#"); + expect(canonicalizeHttpUrl("http://example.test/#")).toMatchObject({ + href: "http://example.test/", + hasFragment: true, + fragment: "#", + hostname: "example.test", + }); + }); + + test("applies PocketJS endpoint and credential safety deviations", () => { + expect(serialized("http://example.test./")).toBe("http://example.test/"); + expect(serialized("http://example。test。/")).toBe("http://example.test/"); + for (const input of [ + "http://user@example.test/", + "http://:@example.test/", + "http://-bad.example/", + "http://bad-.example/", + "http://bad_name.example/", + "http://example.test../", + "http://xn--/", + ]) { + expect(() => canonicalizeHttpUrl(input), input).toThrow(TypeError); + } + }); + + test("accepts only absolute HTTP(S) values", () => { + for (const input of [ + "./relative", + "example.test/path", + "ftp://example.test/", + "ws://example.test/", + "http://", + "https://?query", + "http://[::::]/", + "http://example.test:65536/", + "http://09/", + "http://4294967296/", + ]) { + expect(() => canonicalizeHttpUrl(input), input).toThrow(TypeError); + } + }); + + test("enforces input, host, output, and path-segment ceilings", () => { + expect(() => canonicalizeHttpUrl("x".repeat(HTTP_URL_INPUT_CODE_UNITS_LIMIT + 1))) + .toThrow(/input safety ceiling/); + expect(() => canonicalizeHttpUrl( + `http://${"a".repeat(HTTP_URL_HOST_CODE_UNITS_LIMIT + 1)}/`, + )).toThrow(/hostname exceeds/); + expect(() => canonicalizeHttpUrl(`http://${"a".repeat(64)}.test/`)) + .toThrow(/IDNA hostname/); + expect(() => canonicalizeHttpUrl(`http://example.test/${"雪".repeat(1000)}`)) + .toThrow(/serialization exceeds/); + expect(() => canonicalizeHttpUrl( + `http://example.test/${"/".repeat(HTTP_URL_PATH_SEGMENT_LIMIT + 1)}`, + )).toThrow(/path-segment/); + }); + + test("public URL snapshots canonical href and is frozen", () => { + const url = new PocketURL(" HTTPS://CAFÉ.TEST:443/a/../b#雪 "); + expect(url.href).toBe("https://xn--caf-dma.test/b#%E9%9B%AA"); + expect(url.toString()).toBe(url.href); + expect(url.toJSON()).toBe(url.href); + expect(Object.isFrozen(url)).toBe(true); + expect(new PocketURL(url).href).toBe(url.href); + }); + + test("keeps permission-relevant normalization on captured intrinsics", () => { + const result = Bun.spawnSync([ + "bun", + "tests/fixtures/http-url-hostile-intrinsics.ts", + ], { + cwd: new URL("..", import.meta.url).pathname, + stdout: "pipe", + stderr: "pipe", + }); + expect(result.exitCode, result.stderr.toString()).toBe(0); + expect(result.stdout.toString()).toBe(""); + }); +}); + +describe("pinned IDNA generated data", () => { + test("matches the vendored tr46 6.0.0 source byte-for-byte", () => { + const mapping = readFileSync("tools/vendor/tr46-6.0.0/mappingTable.json", "utf8"); + const regexes = readFileSync("tools/vendor/tr46-6.0.0/regexes.js", "utf8"); + expect(renderTr46Data(mapping)).toBe( + readFileSync("framework/src/net/vendor/url/idna-data.generated.ts", "utf8"), + ); + expect(renderTr46Regexes(regexes)).toBe( + readFileSync("framework/src/net/vendor/url/idna-regexes.generated.ts", "utf8"), + ); + }); +}); diff --git a/tests/http-utf8.test.ts b/tests/http-utf8.test.ts new file mode 100644 index 00000000..1398cfa7 --- /dev/null +++ b/tests/http-utf8.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { decodeUtf8, encodeUtf8 } from "../framework/src/net/utf8.ts"; + +function encoded(value: string, maximumBytes = 1024): number[] | null { + const bytes = encodeUtf8(value, maximumBytes); + return bytes === null ? null : Array.from(bytes); +} + +describe("PocketJS HTTP UTF-8 codec", () => { + test("encodes scalar values and replaces unpaired surrogates", () => { + expect(encoded("A\u00a2\u20ac\ud83d\ude00")).toEqual([ + 0x41, + 0xc2, 0xa2, + 0xe2, 0x82, 0xac, + 0xf0, 0x9f, 0x98, 0x80, + ]); + expect(encoded("A\ud800B\udc00C")).toEqual([ + 0x41, + 0xef, 0xbf, 0xbd, + 0x42, + 0xef, 0xbf, 0xbd, + 0x43, + ]); + expect(encoded("", 0)).toEqual([]); + expect(encoded("\u20ac", 2)).toBeNull(); + expect(encoded("\u20ac", 3)).toEqual([0xe2, 0x82, 0xac]); + }); + + test("decodes scalar values with default replacement and BOM behavior", () => { + expect(decodeUtf8(new Uint8Array([ + 0x41, + 0xc2, 0xa2, + 0xe2, 0x82, 0xac, + 0xf0, 0x9f, 0x98, 0x80, + ]), 10)).toBe("A\u00a2\u20ac\ud83d\ude00"); + expect(decodeUtf8( + new Uint8Array([0xef, 0xbb, 0xbf, 0x41]), + 4, + )).toBe("A"); + expect(decodeUtf8(new Uint8Array([ + 0xef, 0xbb, 0xbf, + 0xef, 0xbb, 0xbf, + 0x41, + ]), 7)).toBe("\ufeffA"); + expect(decodeUtf8(new Uint8Array([ + 0x61, + 0xef, 0xbb, 0xbf, + 0x62, + ]), 5)).toBe("a\ufeffb"); + expect(decodeUtf8(new Uint8Array(), 0)).toBe(""); + expect(decodeUtf8(new Uint8Array([0x61]), 0)).toBeNull(); + }); + + test("uses maximal-subpart replacement for malformed input", () => { + const cases: readonly [readonly number[], string][] = [ + [[0xe2, 0x28, 0xa1], "\ufffd(\ufffd"], + [[0xe2, 0x82, 0x28], "\ufffd("], + [[0xe0, 0x80, 0x80], "\ufffd\ufffd\ufffd"], + [[0xf0, 0x90, 0x80, 0x41], "\ufffdA"], + [[0x80, 0xc0, 0xaf], "\ufffd\ufffd\ufffd"], + [[0xed, 0xa0, 0x80], "\ufffd\ufffd\ufffd"], + [[0xf4, 0x90, 0x80, 0x80], "\ufffd\ufffd\ufffd\ufffd"], + [[0xe2, 0x82], "\ufffd"], + ]; + for (const [bytes, expected] of cases) { + expect(decodeUtf8(new Uint8Array(bytes), bytes.length)).toBe(expected); + } + }); +}); + +test("HTTP text paths work without encoding globals or mutable intrinsics", async () => { + const directory = await mkdtemp(join(tmpdir(), "pocketjs-http-utf8-")); + const script = join(directory, "probe.ts"); + const resultPath = join(directory, "result.json"); + const stdoutPath = join(directory, "stdout.log"); + const stderrPath = join(directory, "stderr.log"); + const httpUrl = new URL("../framework/src/net/http.ts", import.meta.url).href; + const codecUrl = new URL("../framework/src/net/utf8.ts", import.meta.url).href; + const source = ` + const defineProperty = Object.defineProperty; + defineProperty(globalThis, "TextEncoder", { + configurable: true, + value: undefined, + writable: true, + }); + defineProperty(globalThis, "TextDecoder", { + configurable: true, + value: undefined, + writable: true, + }); + const Uint8ArrayIntrinsic = Uint8Array; + const [http, codec] = await Promise.all([ + import(${JSON.stringify(httpUrl)}), + import(${JSON.stringify(codecUrl)}), + ]); + if (typeof globalThis.TextEncoder !== "undefined" || + typeof globalThis.TextDecoder !== "undefined") { + throw new Error("encoding globals unexpectedly reappeared"); + } + + const descriptors = { + apply: Object.getOwnPropertyDescriptor(Reflect, "apply"), + charCodeAt: Object.getOwnPropertyDescriptor(String.prototype, "charCodeAt"), + fromCharCode: Object.getOwnPropertyDescriptor(String, "fromCharCode"), + isSafeInteger: Object.getOwnPropertyDescriptor(Number, "isSafeInteger"), + join: Object.getOwnPropertyDescriptor(Array.prototype, "join"), + uint8Array: Object.getOwnPropertyDescriptor(globalThis, "Uint8Array"), + }; + let encodedBuffer; + let decoded; + let json; + let directEncoded; + try { + const poisoned = () => { throw new Error("poisoned intrinsic used"); }; + defineProperty(Reflect, "apply", { ...descriptors.apply, value: poisoned }); + defineProperty(String.prototype, "charCodeAt", { + ...descriptors.charCodeAt, + value: poisoned, + }); + defineProperty(String, "fromCharCode", { + ...descriptors.fromCharCode, + value: poisoned, + }); + defineProperty(Number, "isSafeInteger", { + ...descriptors.isSafeInteger, + value: poisoned, + }); + defineProperty(Array.prototype, "join", { + ...descriptors.join, + value: poisoned, + }); + defineProperty(globalThis, "Uint8Array", { + ...descriptors.uint8Array, + value: function PoisonedUint8Array() { + throw new Error("poisoned Uint8Array used"); + }, + }); + + encodedBuffer = new Uint8ArrayIntrinsic( + await new http.Response("A\\ud800\\ud83d\\udca9").arrayBuffer(), + ); + decoded = await new http.Response(new Uint8ArrayIntrinsic([ + 0xef, 0xbb, 0xbf, 0x61, 0xe2, 0x28, 0xa1, + ])).text(); + json = await new http.Response(new Uint8ArrayIntrinsic([ + 0xef, 0xbb, 0xbf, 0x7b, 0x22, 0x6f, 0x6b, 0x22, + 0x3a, 0x74, 0x72, 0x75, 0x65, 0x7d, + ])).json(); + directEncoded = codec.encodeUtf8("\\udc00", 3); + } finally { + defineProperty(Reflect, "apply", descriptors.apply); + defineProperty(String.prototype, "charCodeAt", descriptors.charCodeAt); + defineProperty(String, "fromCharCode", descriptors.fromCharCode); + defineProperty(Number, "isSafeInteger", descriptors.isSafeInteger); + defineProperty(Array.prototype, "join", descriptors.join); + defineProperty(globalThis, "Uint8Array", descriptors.uint8Array); + } + await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ + encoded: Array.from(encodedBuffer), + decoded, + json, + directEncoded: Array.from(directEncoded), + })); + `; + + try { + await Bun.write(script, source); + const child = Bun.spawn({ + cmd: ["bun", script], + stdout: Bun.file(stdoutPath), + stderr: Bun.file(stderrPath), + }); + const exitCode = await child.exited; + const [stdout, stderr] = await Promise.all([ + Bun.file(stdoutPath).text(), + Bun.file(stderrPath).text(), + ]); + expect( + exitCode, + `exit=${exitCode} signal=${String(child.signalCode)}\n${stdout}\n${stderr}`, + ).toBe(0); + expect(await Bun.file(resultPath).json()).toEqual({ + encoded: [ + 0x41, + 0xef, 0xbf, 0xbd, + 0xf0, 0x9f, 0x92, 0xa9, + ], + decoded: "a\ufffd(\ufffd", + json: { ok: true }, + directEncoded: [0xef, 0xbf, 0xbd], + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/net-limits-hardening.test.ts b/tests/net-limits-hardening.test.ts new file mode 100644 index 00000000..ac08d4c0 --- /dev/null +++ b/tests/net-limits-hardening.test.ts @@ -0,0 +1,139 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +test("getNetworkLimits survives hostile prototypes and returns an exact frozen graph", async () => { + const directory = await mkdtemp(join(tmpdir(), "pocketjs-net-limits-hardening-")); + const resultPath = join(directory, "result.json"); + const limitsUrl = new URL( + "../framework/src/net/network-limits.ts", + import.meta.url, + ).href; + const netUrl = new URL("../framework/src/net/index.ts", import.meta.url).href; + const source = ` + const limits = await import(${JSON.stringify(limitsUrl)}); + const net = await import(${JSON.stringify(netUrl)}); + limits.installNetworkLimitsProvider(() => ({ + values: [ + { name: "__proto__", default: 2, hard: 4, minimum: 1 }, + { name: "constructor", default: 8, hard: 16, minimum: 4 }, + ], + features: ["__proto__", "network.http.client"], + })); + + const createDescriptor = Object.getOwnPropertyDescriptor(Object, "create"); + const definePropertyDescriptor = Object.getOwnPropertyDescriptor(Object, "defineProperty"); + const definePropertiesDescriptor = Object.getOwnPropertyDescriptor(Object, "defineProperties"); + const freezeDescriptor = Object.getOwnPropertyDescriptor(Object, "freeze"); + const arrayIteratorDescriptor = Object.getOwnPropertyDescriptor( + Array.prototype, + Symbol.iterator, + ); + const arrayMapDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, "map"); + const arrayForEachDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, "forEach"); + const defineProperty = Object.defineProperty; + const getPrototypeOf = Object.getPrototypeOf; + const getOwnPropertyNames = Object.getOwnPropertyNames; + const isFrozen = Object.isFrozen; + const poisonNames = ["protocol", "role", "values", "features", "default", "hard", "minimum"]; + const poisonDescriptors = []; + for (let index = 0; index < poisonNames.length; index++) { + poisonDescriptors[index] = Object.getOwnPropertyDescriptor( + Object.prototype, + poisonNames[index], + ); + } + const poisoned = () => { throw new Error("poisoned intrinsic used"); }; + let snapshot; + try { + defineProperty(Object, "create", { ...createDescriptor, value: poisoned }); + defineProperty(Object, "defineProperty", { ...definePropertyDescriptor, value: poisoned }); + defineProperty(Object, "defineProperties", { ...definePropertiesDescriptor, value: poisoned }); + defineProperty(Object, "freeze", { ...freezeDescriptor, value: poisoned }); + defineProperty(Array.prototype, Symbol.iterator, { + configurable: true, + get: poisoned, + }); + defineProperty(Array.prototype, "map", { ...arrayMapDescriptor, value: poisoned }); + defineProperty(Array.prototype, "forEach", { ...arrayForEachDescriptor, value: poisoned }); + for (let index = 0; index < poisonNames.length; index++) { + defineProperty(Object.prototype, poisonNames[index], { + configurable: true, + get: poisoned, + }); + } + snapshot = net.getNetworkLimits("http", "client"); + } finally { + defineProperty(Object, "create", createDescriptor); + defineProperty(Object, "defineProperty", definePropertyDescriptor); + defineProperty(Object, "defineProperties", definePropertiesDescriptor); + defineProperty(Object, "freeze", freezeDescriptor); + defineProperty(Array.prototype, Symbol.iterator, arrayIteratorDescriptor); + defineProperty(Array.prototype, "map", arrayMapDescriptor); + defineProperty(Array.prototype, "forEach", arrayForEachDescriptor); + for (let index = 0; index < poisonNames.length; index++) { + const descriptor = poisonDescriptors[index]; + if (descriptor === undefined) delete Object.prototype[poisonNames[index]]; + else defineProperty(Object.prototype, poisonNames[index], descriptor); + } + } + + const rootNames = getOwnPropertyNames(snapshot).sort(); + const valueNames = getOwnPropertyNames(snapshot.values).sort(); + const featureNames = getOwnPropertyNames(snapshot.features).sort(); + await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ + rootNames, + valueNames, + featureNames, + nullPrototypes: + getPrototypeOf(snapshot) === null && + getPrototypeOf(snapshot.values) === null && + getPrototypeOf(snapshot.features) === null && + getPrototypeOf(snapshot.values.__proto__) === null && + getPrototypeOf(snapshot.values.constructor) === null, + recursivelyFrozen: + isFrozen(snapshot) && + isFrozen(snapshot.values) && + isFrozen(snapshot.features) && + isFrozen(snapshot.values.__proto__) && + isFrozen(snapshot.values.constructor), + protocol: snapshot.protocol, + role: snapshot.role, + protoDefault: snapshot.values.__proto__.default, + constructorHard: snapshot.values.constructor.hard, + protoFeature: snapshot.features.__proto__, + httpFeature: snapshot.features["network.http.client"], + })); + `; + + try { + const script = join(directory, "limits-hostile.ts"); + await Bun.write(script, source); + const child = Bun.spawn([process.execPath, script], { + stdout: "ignore", + stderr: "pipe", + }); + const [exitCode, stderr] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + ]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(await Bun.file(resultPath).json()).toEqual({ + rootNames: ["features", "protocol", "role", "values"], + valueNames: ["__proto__", "constructor"], + featureNames: ["__proto__", "network.http.client"], + nullPrototypes: true, + recursivelyFrozen: true, + protocol: "http", + role: "client", + protoDefault: 2, + constructorHard: 16, + protoFeature: true, + httpFeature: true, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/net-web.test.js b/tests/net-web.test.js index d8c259b7..eb3cf3ac 100644 --- a/tests/net-web.test.js +++ b/tests/net-web.test.js @@ -4,7 +4,7 @@ import { fetch as pocketFetch } from "../framework/src/net-api.ts"; import { runServicePumps } from "../framework/src/services.ts"; import { createNetHost } from "../hosts/web/net.js"; -test("browser net adapter uses native fetch but delivers only at beginFrame", async () => { +test("internal NET v1 browser fixture delivers only at beginFrame", async () => { const calls = []; const host = createNetHost(async (url, options) => { calls.push({ url, options }); @@ -41,7 +41,7 @@ test("browser net adapter uses native fetch but delivers only at beginFrame", as } }); -test("browser net adapter enforces response maxBytes while reading", async () => { +test("internal NET v1 browser fixture enforces response maxBytes while reading", async () => { const host = createNetHost(async () => new Response("12345")); globalThis.net = host.ns; try { diff --git a/tests/net.test.ts b/tests/net.test.ts index 882ab2b6..e3361f95 100644 --- a/tests/net.test.ts +++ b/tests/net.test.ts @@ -1,150 +1,274 @@ -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 { runServicePumps } from "../framework/src/services.ts"; -import { createSimNetHost } from "../hosts/sim/net.ts"; - -function mount(ns: NetOps): void { - (globalThis as { net?: NetOps }).net = ns; -} - -afterEach(() => { - delete (globalThis as { net?: NetOps }).net; -}); +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; -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": { - status: 200, - headers: { "content-type": "application/json" }, - body: '{"message":"你好"}', - }, - }); - mount(host.ns); +import * as net from "../framework/src/net/index.ts"; +import * as http from "../framework/src/net/http.ts"; +import * as websocket from "../framework/src/net/websocket.ts"; +import * as mqtt from "../framework/src/net/mqtt.ts"; +import * as tcp from "../framework/src/net/tcp.ts"; +import * as udp from "../framework/src/net/udp.ts"; +import { npmExports, SUBPATHS } from "../framework/compiler/subpaths.ts"; +import { jsxPlugin, packagePath, transformFile } from "../framework/compiler/jsx-plugin.ts"; - runServicePumps(); - expect(host.pollCalls()).toBe(0); // no pending Promise: no native poll +describe("net package namespace", () => { + test("the root exports support values without the NET v1 value API", () => { + expect(Object.keys(net).sort()).toEqual([ + "AbortController", + "AbortSignal", + "NetworkError", + "URL", + "getNetworkLimits", + ]); + expect("fetch" in net).toBe(false); + expect("netHost" in net).toBe(false); + expect("NetError" in net).toBe(false); + expect("PocketResponse" in net).toBe(false); + }); - let settled = false; - const promise = pocketFetch("https://example.test/message").then((response) => { - settled = true; - return response; - }); - runServicePumps(); - await Promise.resolve(); - expect(settled).toBe(false); // transport has not crossed a tick boundary - expect(host.pollCalls()).toBe(1); - - host.tick(); - runServicePumps(); - 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: "你好" }); - - const pollsAfterSettle = host.pollCalls(); - runServicePumps(); - runServicePumps(); - expect(host.pollCalls()).toBe(pollsAfterSettle); // pump unregistered itself - }); - - test("one poll drains every completion visible in the tick", async () => { - const host = createSimNetHost({ - "https://example.test/a": { body: "a" }, - "https://example.test/b": { body: "b" }, - }); - mount(host.ns); - const a = pocketFetch("https://example.test/a"); - const b = pocketFetch("https://example.test/b"); - host.tick(); - runServicePumps(); - - 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("request metadata and body cross as owned bounded data", 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" }; - }, - }); - 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 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 + test("every protocol re-exports the same support object identities", () => { + for (const protocol of [http, websocket, mqtt, tcp, udp]) { + expect(protocol.AbortController).toBe(net.AbortController); + expect(protocol.AbortSignal).toBe(net.AbortSignal); + expect(protocol.NetworkError).toBe(net.NetworkError); + expect(protocol.URL).toBe(net.URL); + } }); - test("whole-response cap rejects before oversized data reaches guest", async () => { - const host = createSimNetHost({ - "https://example.test/large": { body: new Uint8Array(5) }, - }); - 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); - }); - - test("the third concurrent request is refused with busy", 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 }, + test("NetworkError carries stable structured fields", () => { + const error = new net.NetworkError("connection failed", { + category: "transport", + code: "connection_refused", + operation: "tcp.connect", + temporary: true, + address: "192.0.2.1", + port: 9000, + protocol: "tcp", + causeCode: "ECONNREFUSED", }); - 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, + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(net.NetworkError); + expect(error).toMatchObject({ + name: "NetworkError", + category: "transport", + code: "connection_refused", + operation: "tcp.connect", + temporary: true, + address: "192.0.2.1", + port: 9000, + protocol: "tcp", + causeCode: "ECONNREFUSED", }); - host.tick(); - host.tick(); - runServicePumps(); - await Promise.all([a, b]); }); - test("invalid portable requests fail without entering the host", async () => { - const host = createSimNetHost({ - "https://example.test/a": { body: "unused" }, - }); - 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([]); - }); - - 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("PocketJS URL and abort values do not depend on browser constructors", () => { + const url = new net.URL("https://example.test/path?q=1"); + expect(url.href).toBe("https://example.test/path?q=1"); + expect(String(url)).toBe(url.href); + expect(() => new net.URL("/relative")).toThrow(TypeError); + + const controller = new net.AbortController(); + const seen: unknown[] = []; + controller.signal.addEventListener("abort", (event) => seen.push(event.target)); + controller.abort("stop"); + controller.abort("ignored"); + expect(controller.signal.aborted).toBe(true); + expect(controller.signal.reason).toBe("stop"); + expect(seen).toEqual([controller.signal]); + expect(() => controller.signal.throwIfAborted()).toThrow("stop"); + expect(() => new net.AbortSignal()).toThrow("Illegal constructor"); + }); + + test("unbound support queries and direct protocol calls fail explicitly", async () => { + expect(() => net.getNetworkLimits()).toThrow(net.NetworkError); + expect(() => net.getNetworkLimits()).toThrow(/unadmitted scope/); + await expect(http.fetch("https://example.test/")).rejects.toMatchObject({ + code: "unsupported", + operation: "http.fetch", + protocol: "http", }); + await expect(tcp.connect({ hostname: "example.test", port: 80 })).rejects + .toBeInstanceOf(net.NetworkError); + }); + + test("canonical protocol exports have no framework-prefixed aliases", () => { + const expected = { + net: "framework/src/net/index.ts", + "net/http": "framework/src/net/http.ts", + "net/websocket": "framework/src/net/websocket.ts", + "net/mqtt": "framework/src/net/mqtt.ts", + "net/tcp": "framework/src/net/tcp.ts", + "net/udp": "framework/src/net/udp.ts", + } as const; + for (const [name, file] of Object.entries(expected)) { + expect(SUBPATHS[name]?.file).toBe(file); + expect(SUBPATHS[name]?.aliases).toBeUndefined(); + } + + const exports = npmExports(); + for (const name of Object.keys(expected) as Array) { + expect(exports[`./${name}`]).toBe(`./${expected[name]}`); + expect(exports[`./vue-vapor/${name}`]).toBeUndefined(); + expect(exports[`./octane/${name}`]).toBeUndefined(); + } + expect(JSON.stringify(exports)).not.toContain("framework/src/net-api.ts"); + expect(packagePath("@pocketjs/framework/vue-vapor/net", "vue-vapor")).toBeNull(); + expect(packagePath("@pocketjs/framework/octane/net/http", "octane")).toBeNull(); + expect(packagePath("@pocketjs/framework/net/http", "vue-vapor")).toBe( + new URL("../framework/src/net/http.ts", import.meta.url).pathname, + ); + }); + + test("stock hosts remove a legacy global binding before app evaluation", async () => { + const [webEngine, simRuntime] = await Promise.all([ + Bun.file(new URL("../hosts/web/engine.js", import.meta.url)).text(), + Bun.file(new URL("../hosts/sim/sim.ts", import.meta.url)).text(), + ]); + expect(webEngine).not.toContain('from "./net.js"'); + expect(webEngine).not.toMatch(/globalThis\.net\s*=/); + expect(webEngine).toContain('Reflect.deleteProperty(globalThis, "net")'); + expect(simRuntime).not.toMatch(/g\.net\s*=/); + expect(simRuntime).toContain('Reflect.deleteProperty(g, "net")'); + expect(simRuntime.indexOf('Reflect.deleteProperty(g, "net")')).toBeLessThan( + simRuntime.indexOf("(0, eval)(src)"), + ); + }); +}); + +describe("network surface demand gate", () => { + const transform = ( + source: string, + features: Readonly> = {}, + ) => transformFile( + `/virtual/network-demand-${Bun.hash(source + JSON.stringify(features))}.ts`, + source, + "solid", + { features }, + ); + + test("root support values and explicit type imports create no I/O demand", async () => { + await expect(transform(` + import { NetworkError, getNetworkLimits } from "@pocketjs/framework/net"; + import type { RequestInit } from "@pocketjs/framework/net/http"; + import { NetworkError as HttpNetworkError } from "@pocketjs/framework/net/http"; + import type { Headers } from "@pocketjs/framework/net/http"; + export type { MqttClient } from "@pocketjs/framework/net/mqtt"; + void NetworkError; void HttpNetworkError; void getNetworkLimits; + `)).resolves.toBeDefined(); + }); + + test("unimplemented pure value classes fail at build time", async () => { + await expect(transform(` + import { Headers } from "@pocketjs/framework/net/http"; + void Headers; + `)).rejects.toThrow("staged surface"); + }); + + test("named value imports and re-exports fail closed without admission", async () => { + await expect(transform(` + import { fetch } from "@pocketjs/framework/net/http"; + void fetch; + `)).rejects.toThrow("network.http.client"); + await expect(transform(` + export { connect as connectMqtt } from "@pocketjs/framework/net/mqtt"; + `)).rejects.toThrow("network.mqtt.client"); + }); + + test("namespace and star exports demand every role in the protocol module", async () => { + await expect(transform(` + import * as http from "@pocketjs/framework/net/http"; + void http; + `, { "network.http.client": true })).rejects.toThrow("network.http.server"); + await expect(transform(` + export * from "@pocketjs/framework/net/websocket"; + `)).rejects.toThrow("network.websocket.server.upgrade"); + await expect(transform(` + export * as http from "@pocketjs/framework/net/http"; + `, { "network.http.server": true })).rejects.toThrow("network.http.client"); + }); + + test("bare protocol imports fail closed as all-value demand", async () => { + await expect(transform(` + import "@pocketjs/framework/net/http"; + `, { "network.http.client": true })).rejects.toThrow("network.http.server"); + await expect(transform(` + void import("@pocketjs/framework/net/udp"); + `, { "network.udp": true })).rejects.toThrow("staged surface"); + }); + + test("constant dynamic imports and CommonJS require use the same gate", async () => { + const sources = [ + "void import(`@pocketjs/framework/net/http`);", + 'void import("@pocketjs/framework/net/" + "http");', + 'const target = "@pocketjs/framework/net/http"; void import(target);', + 'require("@pocketjs/framework/net/http");', + 'const target = "@pocketjs/framework/net/" + "http"; require(target);', + ]; + for (const source of sources) { + await expect(transform(source)).rejects.toThrow("network.http.client"); + } + }); + + test("unresolved dynamic module specifiers fail closed", async () => { + await expect(transform("declare const target: string; void import(target);")) + .rejects.toThrow("compile-time strings"); + await expect(transform("declare const target: string; require(target);")) + .rejects.toThrow("compile-time strings"); + await expect(transform('const target = "./local.ts"; void import(target);')) + .resolves.toBeDefined(); + }); + + test("role admission runs before staged surface readiness", async () => { + await expect(transform(` + import { fetch } from "@pocketjs/framework/net/http"; + void fetch; + `, { "network.http.client": true })).rejects.toThrow("staged surface"); + + await expect(transform(` + import { upgrade } from "@pocketjs/framework/net/websocket"; + void upgrade; + `, { + "network.http.server": true, + "network.websocket.server": true, + })).rejects.toThrow("network.websocket.server.upgrade"); + }); + + test("resolver rejects direct and aliased public protocol source paths", async () => { + const directory = await mkdtemp(join(tmpdir(), "pocketjs-network-public-path-")); + const protocolPaths = ["http", "websocket", "mqtt", "tcp", "udp"].map((name) => + new URL(`../framework/src/net/${name}.ts`, import.meta.url).pathname + ); + try { + const link = join(directory, "protocol-link.ts"); + await symlink(protocolPaths[0]!, link); + const attacks = [ + ...protocolPaths, + link, + `${link}?audit=1#network`, + `${pathToFileURL(link).href}#network`, + ]; + for (const [index, target] of attacks.entries()) { + const entry = join(directory, `attack-${index}.ts`); + await writeFile(entry, `import * as protocol from ${JSON.stringify(target)}; void protocol;`); + let failure: unknown; + try { + await Bun.build({ + entrypoints: [entry], + format: "iife", + target: "browser", + plugins: [jsxPlugin("solid", { entry, features: {} })], + }); + } catch (error) { + failure = error; + } + const messages = (failure as { errors?: readonly { message: string }[] } | undefined) + ?.errors?.map((error) => error.message).join("\n") ?? ""; + expect(messages).toContain("canonical @pocketjs/framework/net/"); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } }); }); diff --git a/tests/network-bundle-factory.test.ts b/tests/network-bundle-factory.test.ts new file mode 100644 index 00000000..61906e9c --- /dev/null +++ b/tests/network-bundle-factory.test.ts @@ -0,0 +1,713 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + finalizeBuildPlan, + type ResolvedBuildPlanContent, +} from "../framework/src/manifest/plan.ts"; +import { + createNetworkFactoryBuildContext, + finalizeBundleArtifact, + selectBundleArtifactMode, + wrapNetworkBundleFactory, +} from "../tools/network-bundle-factory.ts"; +import { + LEGACY_NETWORK_FACTORY_PARAMETER, + NETWORK_BINDING_RESERVED_IDENTIFIER, + NETWORK_PRIVATE_SPECIFIER, + type NetworkPrivateBuildContext, +} from "../framework/compiler/network-private.ts"; +import { jsxPlugin, transformFile } from "../framework/compiler/jsx-plugin.ts"; +import { + NETWORK_V1_ABI_MAJOR, + NETWORK_V1_ABI_MINOR, + NETWORK_V1_LIMIT_PROTOCOL_ANY, + NETWORK_V1_LIMIT_ROLE_ANY, + NetworkV1CommandOpcode, + NetworkV1CompletionPollStatus, + NetworkV1DispatchStatus, + NetworkV1LimitProtocol, + NetworkV1LimitRole, + type NetworkV1AsyncCommand, + type NetworkV1BindingTable, + type NetworkV1BufferLeaseReadIntoCommand, + type NetworkV1BufferLeaseTakeCommand, + type NetworkV1FeatureId, + type NetworkV1LimitsQuery, + type NetworkV1ServiceDispatcher, +} from "../contracts/spec/network/network-v1.ts"; + +const MARKER = "__pocketNetworkFactoryTestMarker"; +const ROOT = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); +const GLOBAL_BINDING_NAMES = [ + NETWORK_BINDING_RESERVED_IDENTIFIER, + LEGACY_NETWORK_FACTORY_PARAMETER, +] as const; + +type TestGlobal = typeof globalThis & Record; + +function globals(): TestGlobal { + return globalThis as TestGlobal; +} + +function evaluateArtifact(source: string): unknown { + return (0, eval)(source); +} + +function content(withNetwork: boolean): ResolvedBuildPlanContent { + return { + app: { + id: "dev.pocketjs.network-factory-test", + title: "Network factory test", + entry: "app.ts", + framework: "solid", + output: "app", + }, + target: { id: "esp-test", hostAbi: 1 }, + viewport: { + logical: [320, 240], + physical: [320, 240], + presentation: "integer-fit", + rasterDensity: 1, + }, + features: withNetwork ? { "network.http.client": true } : {}, + ...(withNetwork + ? { + network: { + policy: { + version: 1 as const, + connect: [], + listen: [], + localNetwork: false, + insecureTransport: false, + broadcast: false, + multicast: false, + allowInvalidTlsForDevelopment: false, + browserAmbientCredentials: false, + browserOpaqueWebSocketRedirects: false, + credentials: [], + }, + providers: { + backendByRole: {}, + tlsByRole: {}, + netDriverId: "net.driver.test", + }, + resources: { minimum: {} }, + }, + } + : {}), + }; +} + +function networkContext(): NetworkPrivateBuildContext { + return createNetworkFactoryBuildContext(finalizeBuildPlan(content(true))); +} + +function formalBinding( + context: NetworkPrivateBuildContext, + hooks: Readonly<{ + dispatch?: ( + this: NetworkV1BindingTable, + command: NetworkV1AsyncCommand, + ) => void; + register?: (dispatcher: NetworkV1ServiceDispatcher) => void; + }> = {}, +): NetworkV1BindingTable { + const featureIds = Object.freeze( + [...context.featureIds] as NetworkV1FeatureId[], + ); + const handshake = Object.freeze({ + abiMajor: NETWORK_V1_ABI_MAJOR, + abiMinor: NETWORK_V1_ABI_MINOR, + runtimeGeneration: 1, + planHash: Uint8Array.from(context.planHashBytes), + featureIds, + }); + return Object.freeze({ + handshake, + getLimits(query: NetworkV1LimitsQuery) { + const inProtocol = query.protocol === NETWORK_V1_LIMIT_PROTOCOL_ANY || + query.protocol === NetworkV1LimitProtocol.Http; + const inRole = query.role === NETWORK_V1_LIMIT_ROLE_ANY || + query.role === NetworkV1LimitRole.Client; + return Object.freeze({ + runtimeGeneration: 1, + protocol: query.protocol, + role: query.role, + values: Object.freeze(inProtocol && inRole + ? [ + Object.freeze({ + name: "http.bufferedBodyBytes", + default: 256 * 1024, + hard: 256 * 1024, + minimum: 1, + }), + Object.freeze({ + name: "http.headerBytes", + default: 8 * 1024, + hard: 8 * 1024, + minimum: 1, + }), + Object.freeze({ + name: "http.maxBodyChunkBytes", + default: 2 * 1024, + hard: 2 * 1024, + minimum: 1, + }), + Object.freeze({ + name: "http.maxOperations", + default: 8, + hard: 8, + minimum: 1, + }), + Object.freeze({ + name: "runtime.nativeBufferBytes", + default: 512 * 1024, + hard: 512 * 1024, + minimum: 1, + }), + ] + : []), + featureIds: Object.freeze(inProtocol && inRole ? [...featureIds] : []), + }); + }, + dispatch(command: NetworkV1AsyncCommand) { + hooks.dispatch?.call(this, command); + return Object.freeze({ status: NetworkV1DispatchStatus.Accepted }); + }, + nextCompletion() { + return Object.freeze({ + status: NetworkV1CompletionPollStatus.Drained, + payloadBytesDelivered: 0 as const, + }); + }, + leaseTake(command: NetworkV1BufferLeaseTakeCommand) { + return Object.freeze({ + status: NetworkV1DispatchStatus.Completed, + byteLength: command.byteLength, + }); + }, + leaseReadInto(command: NetworkV1BufferLeaseReadIntoCommand) { + return Object.freeze({ + status: NetworkV1DispatchStatus.Completed, + bytesCopied: command.maxBytes, + }); + }, + leaseRelease() { + return Object.freeze({ status: NetworkV1DispatchStatus.Completed }); + }, + registerServiceDispatcher(dispatcher: NetworkV1ServiceDispatcher) { + hooks.register?.(dispatcher); + }, + }); +} + +async function bundleNetworkEntry( + entry: string, + context: NetworkPrivateBuildContext, +): Promise extends Promise ? Result : never> { + return await Bun.build({ + entrypoints: [context.bootstrapSpecifier], + format: "iife", + target: "browser", + plugins: [jsxPlugin("solid", { + entry, + features: { "network.http.client": true }, + networkPrivate: context, + })], + }); +} + +async function bundlePlainEntry( + entry: string, +): Promise extends Promise ? Result : never> { + return await Bun.build({ + entrypoints: [entry], + format: "iife", + target: "browser", + plugins: [jsxPlugin("solid", { entry })], + }); +} + +function buildMessages(result: Awaited>): string { + return result.logs.map((log) => log.message).join("\n"); +} + +afterEach(() => { + delete globals()[MARKER]; + for (const name of GLOBAL_BINDING_NAMES) delete globals()[name]; +}); + +describe("network factory admission", () => { + test("requires the verified network plan and factory flag to agree", () => { + const plainPlan = finalizeBuildPlan(content(false)); + const networkPlan = finalizeBuildPlan(content(true)); + + expect(selectBundleArtifactMode(undefined, false)).toBe("iife"); + expect(selectBundleArtifactMode(plainPlan, false)).toBe("iife"); + expect(selectBundleArtifactMode(networkPlan, true)).toBe("network-factory"); + expect(() => selectBundleArtifactMode(networkPlan, false)).toThrow( + "requires a factory-aware loader", + ); + expect(() => selectBundleArtifactMode(plainPlan, true)).toThrow( + "requires a ResolvedBuildPlan with network admission", + ); + expect(() => selectBundleArtifactMode(undefined, true)).toThrow( + "requires a ResolvedBuildPlan with network admission", + ); + }); + + test("rejects a modified plan before selecting an artifact ABI", () => { + const plan = finalizeBuildPlan(content(true)); + expect(() => selectBundleArtifactMode({ + ...plan, + target: { ...plan.target, id: "modified" }, + }, true)).toThrow("invalid ResolvedBuildPlan checksum"); + }); + + test("derives per-artifact private names without a fixed define or parameter", () => { + const first = createNetworkFactoryBuildContext(finalizeBuildPlan(content(true))); + const secondPlan = finalizeBuildPlan({ + ...content(true), + app: { ...content(true).app, id: "dev.pocketjs.network-factory-other" }, + }); + const second = createNetworkFactoryBuildContext(secondPlan); + + expect(first.takeIdentifier).not.toBe(second.takeIdentifier); + expect(first.bindingIdentifier).not.toBe(second.bindingIdentifier); + expect(first.pendingIdentifier).not.toBe(second.pendingIdentifier); + expect(first.argumentsIdentifier).not.toBe(second.argumentsIdentifier); + expect(first.takeIdentifier).not.toContain(NETWORK_BINDING_RESERVED_IDENTIFIER); + expect(first.takeIdentifier).not.toContain(LEGACY_NETWORK_FACTORY_PARAMETER); + }); +}); + +describe("network factory artifact", () => { + test("preserves a non-network IIFE byte-for-byte", () => { + const source = "\uFEFF(() => { globalThis.legacy = true; })();\n"; + expect(finalizeBundleArtifact(source, "iife")).toBe(source); + }); + + test("captures before app initialization and retires the original argument", async () => { + const directory = await mkdtemp(join(tmpdir(), "pocketjs-network-factory-")); + try { + const entry = join(directory, "entry.ts"); + const context = networkContext(); + await Bun.write(entry, ` + const direct = (source: string): unknown => { + try { return eval(source); } catch { return "unbound"; } + }; + (globalThis as Record).${MARKER} = { + reserved: direct(${JSON.stringify(`typeof ${NETWORK_BINDING_RESERVED_IDENTIFIER}`)}), + legacyParameter: direct(${JSON.stringify(`typeof ${LEGACY_NETWORK_FACTORY_PARAMETER}`)}), + privateTake: direct(${JSON.stringify(`typeof ${context.takeIdentifier}`)}), + privateSlot: direct(${JSON.stringify(`typeof ${context.bindingIdentifier}`)}), + functionSlot: Function(${JSON.stringify(`return typeof ${context.bindingIdentifier}`)})(), + globalEvalSlot: globalThis.eval(${JSON.stringify(`typeof ${context.bindingIdentifier}`)}), + factoryArgument: direct("arguments[0]"), + sourceGlobal: Object.prototype.hasOwnProperty.call( + globalThis, + ${JSON.stringify(NETWORK_BINDING_RESERVED_IDENTIFIER)}, + ), + parameterGlobal: Object.prototype.hasOwnProperty.call( + globalThis, + ${JSON.stringify(LEGACY_NETWORK_FACTORY_PARAMETER)}, + ), + }; + queueMicrotask(() => { + (globalThis as Record).${MARKER}.microtaskCheckpoint = + direct(${JSON.stringify(`typeof ${context.takeIdentifier}`)}); + }); + `); + const result = await bundleNetworkEntry(entry, context); + expect(result.success).toBe(true); + const source = await result.outputs[0]!.text(); + const factory = evaluateArtifact(wrapNetworkBundleFactory(source, context)); + + expect(typeof factory).toBe("function"); + expect((factory as Function).length).toBe(0); + expect(globals()[MARKER]).toBeUndefined(); + + const binding = formalBinding(context); + expect((factory as (binding: object) => unknown)(binding)).toBeUndefined(); + expect(globals()[MARKER]).toMatchObject({ + reserved: "undefined", + legacyParameter: "undefined", + privateTake: "undefined", + privateSlot: "undefined", + functionSlot: "undefined", + globalEvalSlot: "undefined", + sourceGlobal: false, + parameterGlobal: false, + }); + expect((globals()[MARKER] as Record).factoryArgument).not.toBe(binding); + await Promise.resolve(); + expect((globals()[MARKER] as Record).microtaskCheckpoint).toBe("undefined"); + for (const name of GLOBAL_BINDING_NAMES) { + expect(Object.prototype.hasOwnProperty.call(globalThis, name)).toBe(false); + } + for (const name of [ + context.takeIdentifier, + context.bindingIdentifier, + context.pendingIdentifier, + context.argumentsIdentifier, + ]) { + expect(Object.prototype.hasOwnProperty.call(globalThis, name)).toBe(false); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test("rejects application imports of every compiler-only internal form", async () => { + const directory = await mkdtemp(join(tmpdir(), "pocketjs-network-private-import-")); + const privateBindingPath = join(ROOT, "framework/src/net/http-binding.ts"); + const privateFormalPath = join(ROOT, "framework/src/net/network-v1-binding.ts"); + const privateLimitsPath = join(ROOT, "framework/src/net/network-limits.ts"); + const linkPath = join(directory, "binding-link.ts"); + const formalLinkPath = join(directory, "formal-link.ts"); + const limitsLinkPath = join(directory, "limits-link.ts"); + try { + await symlink(privateBindingPath, linkPath); + await symlink(privateFormalPath, formalLinkPath); + await symlink(privateLimitsPath, limitsLinkPath); + const context = networkContext(); + const javascriptAttack = join(directory, "derived-attack.js"); + await Bun.write(javascriptAttack, `void ${context.bindingIdentifier};`); + const derivedIdentifiers = [ + context.takeIdentifier, + context.bindingIdentifier, + context.pendingIdentifier, + context.argumentsIdentifier, + ]; + const unicodeEscaped = (name: string): string => + name.replace("p", "\\u0070"); + const privatePaths = [ + privateBindingPath, + privateFormalPath, + privateLimitsPath, + ]; + const directFileAttacks = privatePaths.flatMap((path) => { + const fileUrl = pathToFileURL(path).href; + return [ + `import ${JSON.stringify(path)};`, + `void import(${JSON.stringify(path)});`, + `require(${JSON.stringify(path)});`, + `import ${JSON.stringify(`${path}?audit=1#x`)};`, + `void import(${JSON.stringify(`${path}#audit`)});`, + `require(${JSON.stringify(`${path}?audit`)});`, + `import ${JSON.stringify(fileUrl)};`, + `void import(${JSON.stringify(`${fileUrl}?audit=1#x`)});`, + `require(${JSON.stringify(`${fileUrl}#audit`)});`, + ]; + }); + const attacks = [ + `import ${JSON.stringify(NETWORK_PRIVATE_SPECIFIER)};`, + `import "pocketjs:internal/future-version";`, + `void import(${JSON.stringify(NETWORK_PRIVATE_SPECIFIER)});`, + `require(${JSON.stringify(NETWORK_PRIVATE_SPECIFIER)});`, + ...directFileAttacks, + `import ${JSON.stringify(linkPath)};`, + `import "./binding-link.ts?audit";`, + `void import("./formal-link.ts#audit");`, + `require("./limits-link.ts?audit=1#x");`, + `void ${NETWORK_BINDING_RESERVED_IDENTIFIER};`, + `void arguments[0];`, + `import "./derived-attack.js";`, + ...derivedIdentifiers.map((name) => `void ${name};`), + ...derivedIdentifiers.map( + (name) => `const { value: ${name} } = { value: 1 };`, + ), + ...derivedIdentifiers.map((name) => `void ${unicodeEscaped(name)};`), + ]; + for (let index = 0; index < attacks.length; index++) { + const entry = join(directory, `attack-${index}.ts`); + await Bun.write(entry, attacks[index]!); + let message = ""; + try { + const result = await bundleNetworkEntry(entry, context); + expect(result.success, `${attacks[index]} unexpectedly built`).toBe(false); + message = buildMessages(result); + } catch (error) { + const errors = (error as { errors?: readonly { message?: string }[] }).errors; + message = errors + ? errors.map((item) => item.message ?? String(item)).join("\n") + : error instanceof Error ? error.message : String(error); + } + expect(message).toContain("private network binding"); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test("rejects private limits installers in non-network artifacts too", async () => { + const directory = await mkdtemp(join(tmpdir(), "pocketjs-network-limits-private-")); + const privateBindingPath = join(ROOT, "framework/src/net/http-binding.ts"); + const privateFormalPath = join(ROOT, "framework/src/net/network-v1-binding.ts"); + const privateLimitsPath = join(ROOT, "framework/src/net/network-limits.ts"); + const linkPath = join(directory, "limits-link.ts"); + try { + await symlink(privateLimitsPath, linkPath); + const attacks = [privateBindingPath, privateFormalPath, privateLimitsPath] + .flatMap((path) => { + const fileUrl = pathToFileURL(path).href; + return [ + `import ${JSON.stringify(`${path}?plain`)};`, + `void import(${JSON.stringify(`${fileUrl}#plain`)});`, + `require(${JSON.stringify(fileUrl)});`, + ]; + }); + attacks.push( + `import "./limits-link.ts?plain";`, + `void import("./limits-link.ts#plain");`, + `require("./limits-link.ts?plain=1#x");`, + ); + for (let index = 0; index < attacks.length; index += 1) { + const entry = join(directory, `plain-attack-${index}.ts`); + await Bun.write(entry, attacks[index]!); + let message = ""; + try { + const result = await bundlePlainEntry(entry); + expect(result.success, `${attacks[index]} unexpectedly built`).toBe(false); + message = buildMessages(result); + } catch (error) { + const errors = (error as { errors?: readonly { message?: string }[] }).errors; + message = errors + ? errors.map((item) => item.message ?? String(item)).join("\n") + : error instanceof Error ? error.message : String(error); + } + expect(message).toContain("private network binding"); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test("does not trust an application merely because its path is under framework source", async () => { + const context = networkContext(); + const existingFrameworkPath = join(ROOT, "framework/src/net/http.ts"); + await expect(transformFile( + existingFrameworkPath, + `void ${context.bindingIdentifier};`, + "solid", + { networkPrivate: context }, + )).rejects.toThrow("private network binding identifier"); + }); + + test("rejects a frozen accessor table without invoking its getter", async () => { + const directory = await mkdtemp(join(tmpdir(), "pocketjs-network-hostile-binding-")); + try { + const entry = join(directory, "entry.ts"); + const context = networkContext(); + await Bun.write(entry, `globalThis.${MARKER} = "application-ran";`); + const result = await bundleNetworkEntry(entry, context); + expect(result.success).toBe(true); + const factory = evaluateArtifact(wrapNetworkBundleFactory( + await result.outputs[0]!.text(), + context, + )) as (binding: object) => unknown; + + let getterCalls = 0; + const hostile = Object.freeze(Object.defineProperty({}, "abiMajor", { + enumerable: true, + get: () => { + getterCalls++; + return 1; + }, + })); + expect(() => factory(hostile)).toThrow("handshake must be an own data property"); + expect(getterCalls).toBe(0); + expect(globals()[MARKER]).toBeUndefined(); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test("links admitted limits through the compiler-only framework capture", async () => { + const directory = await mkdtemp(join(tmpdir(), "pocketjs-network-framework-capture-")); + try { + const entry = join(directory, "entry.ts"); + const context = networkContext(); + await Bun.write(entry, ` + import { getNetworkLimits } from "@pocketjs/framework/net"; + (globalThis as Record).${MARKER} = getNetworkLimits( + "http", + "client", + ).values["http.maxBodyChunkBytes"].default; + `); + const result = await bundleNetworkEntry(entry, context); + expect(result.success).toBe(true); + const bundled = await result.outputs[0]!.text(); + expect(bundled).not.toMatch(/\bimport\s+[^;]*pocketjs:internal\//); + + const factory = evaluateArtifact(wrapNetworkBundleFactory(bundled, context)); + const binding = formalBinding(context); + expect((factory as (value: object) => unknown)(binding)).toBeUndefined(); + expect(globals()[MARKER]).toBe(2 * 1024); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test("builds a PocketJS app whose mount and frame checkpoint start at factory call", async () => { + const directory = await mkdtemp(join(tmpdir(), "pocketjs-network-build-e2e-")); + const output = join(directory, "dist"); + const app = join(directory, "app.tsx"); + const planPath = join(directory, "resolved-plan.json"); + const priorUi = globals().ui; + const priorFrame = globals().frame; + try { + await Bun.write(app, ` + import { Text, View } from "@pocketjs/framework/components"; + import { onFrame } from "@pocketjs/framework/lifecycle"; + import { mount } from "@pocketjs/framework/solid"; + const checkpoint = (globalThis as Record).${MARKER}; + checkpoint.initializers++; + mount(() => { + onFrame(() => checkpoint.frames++); + return factory ready; + }); + checkpoint.mounted = true; + `); + const plan = finalizeBuildPlan({ + ...content(true), + app: { + ...content(true).app, + entry: "app.tsx", + output: "network-factory-e2e", + }, + }); + await Bun.write(planPath, `${JSON.stringify(plan)}\n`); + + const buildProcess = Bun.spawn([ + process.execPath, + "tools/build.ts", + `--plan=${planPath}`, + `--project-root=${directory}`, + `--outdir=${output}`, + "--no-config", + "--network-factory", + ], { + cwd: ROOT, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + buildProcess.exited, + new Response(buildProcess.stdout).text(), + new Response(buildProcess.stderr).text(), + ]); + expect(exitCode, `${stdout}\n${stderr}`).toBe(0); + + globals()[MARKER] = { initializers: 0, frames: 0, mounted: false }; + let nextNode = 2; + globals().ui = { + __host: "esp-test", + __hostAbi: 1, + __textures: Object.freeze({}), + createNode: () => nextNode++, + destroyNode: () => {}, + insertBefore: () => {}, + removeChild: () => {}, + setStyle: () => {}, + setProp: () => {}, + setText: () => {}, + replaceText: () => {}, + uploadTexture: () => 1, + setImage: () => {}, + setSprite: () => {}, + animate: () => 1, + cancelAnim: () => {}, + setFocus: () => {}, + measureText: () => 0, + }; + delete globals().frame; + + const artifact = await Bun.file( + join(output, "network-factory-e2e.js"), + ).text(); + const factory = evaluateArtifact(artifact); + expect(typeof factory).toBe("function"); + expect(globals()[MARKER]).toEqual({ + initializers: 0, + frames: 0, + mounted: false, + }); + expect(globals().frame).toBeUndefined(); + + const context = createNetworkFactoryBuildContext(plan); + const binding = formalBinding(context); + expect((factory as (value: object) => unknown)(binding)).toBeUndefined(); + expect(globals()[MARKER]).toEqual({ + initializers: 1, + frames: 0, + mounted: true, + }); + expect(typeof globals().frame).toBe("function"); + (globals().frame as (buttons: number) => void)(0); + expect(globals()[MARKER]).toEqual({ + initializers: 1, + frames: 1, + mounted: true, + }); + for (const name of GLOBAL_BINDING_NAMES) { + expect(Object.prototype.hasOwnProperty.call(globalThis, name)).toBe(false); + } + } finally { + if (priorUi === undefined) delete globals().ui; + else globals().ui = priorUi; + if (priorFrame === undefined) delete globals().frame; + else globals().frame = priorFrame; + await rm(directory, { recursive: true, force: true }); + } + }); + + test("consumes the factory on an invalid first call without running the IIFE", () => { + const context = networkContext(); + const captureSource = `${context.takeIdentifier}();\n`; + const factory = evaluateArtifact(wrapNetworkBundleFactory( + captureSource + + `globalThis.${MARKER} = true;`, + context, + )) as (...args: unknown[]) => void; + + expect(() => factory({})).toThrow("requires a frozen binding table"); + expect(globals()[MARKER]).toBeUndefined(); + expect(() => factory(Object.freeze({}))).toThrow("already invoked"); + expect(globals()[MARKER]).toBeUndefined(); + }); + + test("requires exactly one argument and does not retry a throwing initializer", () => { + const context = networkContext(); + const missing = evaluateArtifact(wrapNetworkBundleFactory( + `${context.takeIdentifier}(); globalThis.${MARKER} = "missing-ran";`, + context, + )) as (...args: unknown[]) => void; + expect(() => missing()).toThrow("requires exactly one binding argument"); + expect(globals()[MARKER]).toBeUndefined(); + + const extraContext = networkContext(); + const extra = evaluateArtifact(wrapNetworkBundleFactory( + `${extraContext.takeIdentifier}(); globalThis.${MARKER} = "extra-ran";`, + extraContext, + )) as (...args: unknown[]) => void; + expect(() => extra(Object.freeze({}), Object.freeze({}))).toThrow( + "requires exactly one binding argument", + ); + expect(globals()[MARKER]).toBeUndefined(); + + const throwingContext = networkContext(); + const throwing = evaluateArtifact(wrapNetworkBundleFactory(` + ${throwingContext.takeIdentifier}(); + globalThis.${MARKER} = ((globalThis.${MARKER} ?? 0) + 1); + throw new Error("initializer failed"); + `, throwingContext)) as (...args: unknown[]) => void; + expect(() => throwing(Object.freeze({}))).toThrow("initializer failed"); + expect(globals()[MARKER]).toBe(1); + expect(() => throwing(Object.freeze({}))).toThrow("already invoked"); + expect(globals()[MARKER]).toBe(1); + }); +}); diff --git a/tests/network-manifest.test.ts b/tests/network-manifest.test.ts new file mode 100644 index 00000000..4cb563b1 --- /dev/null +++ b/tests/network-manifest.test.ts @@ -0,0 +1,444 @@ +import { describe, expect, test } from "bun:test"; +import { + generatePocketManifestV3Schema, + POCKET_MANIFEST_V3_SCHEMA_ID, +} from "../contracts/spec/pocket-manifest.ts"; +import { + defineCapabilityRegistry, + definePlatformContractRegistry, + defineTargetRegistry, + POCKET_TARGETS, + type CapabilityId, + type TargetProfile, +} from "../contracts/spec/platforms.ts"; +import { + extractHostBuildInputs, + hostBuildEnvironment, +} from "../framework/src/manifest/host-build-inputs.ts"; +import type { HostNetworkResolutionProfile } from "../framework/src/manifest/network.ts"; +import { validateAndResolveBuildPlan } from "../framework/src/manifest/resolve.ts"; +import { validatePocketManifest } from "../framework/src/manifest/validate.ts"; + +const NETWORK_CAPABILITIES = defineCapabilityRegistry([ + "text.glyphs.baked", + "network.http.client", + "network.http.client.tls", + "network.browser.http.client", + "network.discovery.mdns", + "network.udp", + "network.udp.broadcast", +] as const); + +type NetworkCapability = CapabilityId; + +const NETWORK_TARGET_DEFINITIONS = { + "esp-test": { + ...POCKET_TARGETS.psp, + platform: "esp-idf", + capabilities: [ + "text.glyphs.baked", + "network.http.client", + "network.http.client.tls", + "network.discovery.mdns", + ], + }, + "plain-or-browser-test": { + ...POCKET_TARGETS.psp, + platform: "test", + capabilities: [ + "text.glyphs.baked", + "network.http.client", + "network.browser.http.client", + ], + }, +} as const satisfies Readonly>>; + +const NETWORK_TARGETS = defineTargetRegistry< + NetworkCapability, + typeof NETWORK_TARGET_DEFINITIONS +>(NETWORK_TARGET_DEFINITIONS); + +const NETWORK_CONTRACTS = definePlatformContractRegistry( + NETWORK_CAPABILITIES, + NETWORK_TARGETS, +); + +const networkProfile: HostNetworkResolutionProfile = { + providers: { + backendByRole: { + "http.client": "pocketjs.net.esp-http.v1", + }, + tlsByRole: { + "http.client": { + source: "backend", + id: "pocketjs.net.esp-http.v1", + }, + }, + netDriverId: "esp-idf.lwip.v1", + }, + hardLimits: { + runtime: { + connections: 4, + pendingOperations: 8, + completionDescriptors: 32, + nativeBufferBytes: 524288, + }, + http: { + connections: 2, + inflightRequests: 2, + headerBytes: 8192, + headerCount: 32, + bufferedBodyBytes: 262144, + }, + }, + developmentBuild: false, +}; + +function httpManifest(): Record { + return { + $schema: POCKET_MANIFEST_V3_SCHEMA_ID, + pocket: 3, + id: "dev.pocket-stack.network-test", + name: "network-test", + title: "Network Test", + version: "1.0.0", + engine: { + capabilities: { + requires: [ + "text.glyphs.baked", + "network.http.client", + "network.http.client.tls", + ], + }, + }, + permissions: { + network: { + connect: [ + { protocol: "https", host: "BÜCHER.Example.", port: 443 }, + ], + listen: [], + localNetwork: false, + insecureTransport: false, + broadcast: false, + multicast: false, + allowInvalidTlsForDevelopment: false, + browserAmbientCredentials: false, + browserOpaqueWebSocketRedirects: false, + credentials: [], + }, + }, + resources: { + network: { + minimum: { + runtime: { connections: 2, nativeBufferBytes: 131072 }, + http: { headerBytes: 4096, bufferedBodyBytes: 65536 }, + }, + }, + }, + app: { + entry: "app/main.tsx", + output: "network-test", + framework: "solid", + viewport: { logical: [480, 272], presentation: "integer-fit" }, + }, + }; +} + +function resolveHttp( + input: unknown, + profile: HostNetworkResolutionProfile | undefined = networkProfile, +) { + return validateAndResolveBuildPlan( + input, + { target: "esp-test", ...(profile ? { network: profile } : {}) }, + NETWORK_CONTRACTS, + ); +} + +describe("pocket.json v3 network schema", () => { + test("publishes a byte-exact strict format-3 schema", 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()); + expect(validatePocketManifest(httpManifest()).ok).toBe(true); + const siteBuild = await Bun.file(new URL("../site/build.ts", import.meta.url)).text(); + expect(siteBuild).toContain( + 'copy(ROOT + "contracts/schema/pocket-3.json", "schema/pocket-3.json")', + ); + }); + + test("rejects unknown policy fields, port zero, and malformed alternatives", () => { + const unknown = httpManifest(); + unknown.permissions.network.proxy = "ambient"; + const unknownResult = validatePocketManifest(unknown); + expect(unknownResult.ok).toBe(false); + if (!unknownResult.ok) { + expect(unknownResult.diagnostics).toContainEqual({ + code: "schema.additionalProperty", + path: "/permissions/network/proxy", + message: "unknown property", + }); + } + + const zero = httpManifest(); + zero.permissions.network.listen = [ + { protocol: "http", address: "127.0.0.1", port: 0 }, + ]; + expect(validatePocketManifest(zero).ok).toBe(false); + + const emptyOption = httpManifest(); + emptyOption.engine.capabilities.requiresOneOf = [{ options: [[]] }]; + expect(validatePocketManifest(emptyOption).ok).toBe(false); + + const mismatchedVersion = httpManifest(); + mismatchedVersion.pocket = 2; + const mismatchResult = validatePocketManifest(mismatchedVersion); + expect(mismatchResult.ok).toBe(false); + if (!mismatchResult.ok) { + expect(mismatchResult.diagnostics).toContainEqual({ + code: "schema.const", + path: "/pocket", + message: "expected 3", + }); + } + }); +}); + +describe("format-3 network resolution", () => { + test("selects the first complete requiresOneOf provider option", () => { + const input = httpManifest(); + input.engine.capabilities.requires = ["text.glyphs.baked"]; + input.engine.capabilities.requiresOneOf = [{ + options: [ + ["network.http.client", "network.http.client.tls"], + ["network.browser.http.client"], + ], + }]; + const esp = resolveHttp(input); + expect(esp.ok).toBe(true); + if (!esp.ok) return; + expect(esp.plan.selectedCapabilityOptions).toEqual([[ + "network.http.client", + "network.http.client.tls", + ]]); + expect(esp.plan.features).toMatchObject({ + "network.browser.http.client": false, + "network.http.client": true, + "network.http.client.tls": true, + }); + + const browserChoice = structuredClone(input); + delete browserChoice.permissions; + delete browserChoice.resources; + const browser = validateAndResolveBuildPlan( + browserChoice, + { target: "plain-or-browser-test" }, + NETWORK_CONTRACTS, + ); + expect(browser.ok).toBe(true); + if (!browser.ok) return; + expect(browser.plan.selectedCapabilityOptions).toEqual([["network.browser.http.client"]]); + expect(browser.plan.network).toBeUndefined(); + }); + + test("treats empty endpoint lists as deny-all and an empty minimum as Host defaults", () => { + const input = httpManifest(); + input.permissions.network.connect = []; + input.permissions.network.listen = []; + input.resources.network.minimum = {}; + const result = resolveHttp(input); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.plan.network?.policy.connect).toEqual([]); + expect(result.plan.network?.policy.listen).toEqual([]); + expect(result.plan.network?.resources.minimum).toEqual({}); + }); + + test("normalizes endpoint tuples and makes their order hash-independent", () => { + const leftInput = httpManifest(); + leftInput.permissions.network.connect.push({ + protocol: "https", + host: "api.example.com", + port: { min: 443, max: 443 }, + }); + const rightInput = structuredClone(leftInput); + rightInput.permissions.network.connect.reverse(); + rightInput.permissions.network.connect[1].host = "bücher.example"; + + const left = resolveHttp(leftInput); + const right = resolveHttp(rightInput); + expect(left.ok && right.ok).toBe(true); + if (!left.ok || !right.ok) return; + expect(left.plan.network?.policy.connect).toEqual([ + { + protocol: "https", + host: "api.example.com", + port: { min: 443, max: 443 }, + }, + { + protocol: "https", + host: "xn--bcher-kva.example", + port: { min: 443, max: 443 }, + }, + ]); + expect(left.plan.planHash).toBe(right.plan.planHash); + }); + + test("puts provider selection, policy, and resources under planHash", () => { + const first = resolveHttp(httpManifest()); + expect(first.ok).toBe(true); + if (!first.ok) return; + expect(first.plan.network).toMatchObject({ + policy: { version: 1 }, + providers: { + backendByRole: { "http.client": "pocketjs.net.esp-http.v1" }, + tlsByRole: { + "http.client": { + source: "backend", + id: "pocketjs.net.esp-http.v1", + }, + }, + netDriverId: "esp-idf.lwip.v1", + }, + resources: { + minimum: { + runtime: { connections: 2, nativeBufferBytes: 131072 }, + http: { headerBytes: 4096, bufferedBodyBytes: 65536 }, + }, + }, + }); + + const changedProfile = structuredClone(networkProfile) as any; + changedProfile.providers.netDriverId = "esp-idf.lwip.v2"; + const changed = resolveHttp(httpManifest(), changedProfile); + expect(changed.ok).toBe(true); + if (!changed.ok) return; + expect(changed.plan.planHash).not.toBe(first.plan.planHash); + }); + + test("fails closed on missing policy, profile, providers, and resource budget", () => { + const withoutPolicy = httpManifest(); + delete withoutPolicy.permissions; + const policyResult = resolveHttp(withoutPolicy); + expect(policyResult.ok).toBe(false); + if (!policyResult.ok) { + expect(policyResult.diagnostics.map((item) => item.code)).toContain("network.permissionsMissing"); + } + + const profileResult = validateAndResolveBuildPlan( + httpManifest(), + { target: "esp-test" }, + NETWORK_CONTRACTS, + ); + expect(profileResult.ok).toBe(false); + if (!profileResult.ok) { + expect(profileResult.diagnostics.map((item) => item.code)).toContain("network.profileMissing"); + } + + const withoutBackend = structuredClone(networkProfile) as any; + delete withoutBackend.providers.backendByRole["http.client"]; + const backendResult = resolveHttp(httpManifest(), withoutBackend); + expect(backendResult.ok).toBe(false); + if (!backendResult.ok) { + expect(backendResult.diagnostics.map((item) => item.code)).toContain("network.backendMissing"); + } + + const overBudget = httpManifest(); + overBudget.resources.network.minimum.http.headerBytes = 16384; + const resourceResult = resolveHttp(overBudget); + expect(resourceResult.ok).toBe(false); + if (!resourceResult.ok) { + expect(resourceResult.diagnostics.map((item) => item.code)).toContain("network.resourceExceeded"); + } + }); + + test("reports the format-3 requirement once for a format-2 network manifest", () => { + const legacy = httpManifest(); + legacy.$schema = "https://pocketjs.dev/schema/pocket-2.json"; + legacy.pocket = 2; + delete legacy.permissions; + delete legacy.resources; + const result = validateAndResolveBuildPlan( + legacy, + { target: "esp-test", network: networkProfile }, + NETWORK_CONTRACTS, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.filter((item) => item.code === "network.manifestVersion")) + .toEqual([{ + code: "network.manifestVersion", + path: "/pocket", + message: "native network capabilities require Pocket manifest format 3", + }]); + }); + + test("rejects reserved capabilities and role extensions without their base", () => { + const reserved = httpManifest(); + reserved.engine.capabilities.requires = [ + "text.glyphs.baked", + "network.discovery.mdns", + ]; + const reservedResult = resolveHttp(reserved); + expect(reservedResult.ok).toBe(false); + if (!reservedResult.ok) { + expect(reservedResult.diagnostics.map((item) => item.code)) + .toContain("network.capabilityUnsupported"); + } + + const missingBase = httpManifest(); + missingBase.engine.capabilities.requires = [ + "text.glyphs.baked", + "network.http.client.tls", + ]; + const dependencyResult = resolveHttp(missingBase); + expect(dependencyResult.ok).toBe(false); + if (!dependencyResult.ok) { + expect(dependencyResult.diagnostics.map((item) => item.code)) + .toContain("network.capabilityDependencyMissing"); + } + }); + + test("rejects duplicate normalized endpoints and production invalid-TLS authority", () => { + const duplicate = httpManifest(); + duplicate.permissions.network.connect.push({ + protocol: "https", + host: "xn--bcher-kva.example", + port: { min: 443, max: 443 }, + }); + duplicate.permissions.network.allowInvalidTlsForDevelopment = true; + const result = resolveHttp(duplicate); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.map((item) => item.code)).toEqual(expect.arrayContaining([ + "network.duplicateEndpoint", + "network.productionInvalidTls", + ])); + }); +}); + +describe("network HostBuildInputs", () => { + test("projects a deeply frozen, checksummed native network input", () => { + const result = resolveHttp(httpManifest()); + expect(result.ok).toBe(true); + if (!result.ok) return; + const inputs = extractHostBuildInputs(result.plan, { expectedTarget: "esp-test" }); + expect(inputs.network?.planHash).toBe(result.plan.planHash); + expect(inputs.network?.features["network.http.client"]).toBe(true); + expect(Object.isFrozen(inputs.network)).toBe(true); + expect(Object.isFrozen(inputs.network?.policy.connect)).toBe(true); + expect(Object.isFrozen(inputs.network?.providers.backendByRole)).toBe(true); + expect(() => { + (inputs.network!.policy.connect as any[]).push("mutated"); + }).toThrow(); + + const environment = hostBuildEnvironment(inputs, { + outputDirectory: "/tmp/pocket-network", + embedApp: true, + }); + expect(environment.POCKETJS_PLAN_HASH).toBe(result.plan.planHash); + expect(JSON.parse(environment.POCKETJS_NETWORK_BUILD_INPUTS!)).toEqual(inputs.network); + }); +}); diff --git a/tests/network-v1-binding.test.ts b/tests/network-v1-binding.test.ts new file mode 100644 index 00000000..925d12cb --- /dev/null +++ b/tests/network-v1-binding.test.ts @@ -0,0 +1,1440 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + NETWORK_V1_ABI_MAJOR, + NETWORK_V1_ABI_MINOR, + NETWORK_V1_LIMIT_PROTOCOL_ANY, + NETWORK_V1_LIMIT_ROLE_ANY, + NETWORK_V1_UINT32_MAX, + NetworkV1CommandOpcode, + NetworkV1CompletionPollStatus, + NetworkV1DispatchStatus, + NetworkV1ErrorCategory, + NetworkV1ErrorCode, + NetworkV1EventCode, + NetworkV1FeatureId, + NetworkV1LimitProtocol, + NetworkV1LimitRole, + NetworkV1ServiceTurnKind, + NetworkV1ServiceTurnStatus, + type NetworkV1AsyncCommand, + type NetworkV1BindingTable, + type NetworkV1BufferLeaseReadIntoCommand, + type NetworkV1BufferLeaseReleaseCommand, + type NetworkV1BufferLeaseTakeCommand, + type NetworkV1Completion, + type NetworkV1CompletionIdentity, + type NetworkV1Handle, + type NetworkV1LimitsQuery, + type NetworkV1LimitEntry, + type NetworkV1ServiceDispatcher, +} from "../contracts/spec/network/network-v1.ts"; +import { + createNetworkV1HttpBindingAdapterForTesting, + type NetworkV1CompiledExpectation, +} from "../framework/src/net/network-v1-binding.ts"; +import { + installHttpClientBindingForTesting, + NetworkV1CommandOpcode as HighCommandOpcode, + type HttpRequestStartCommand, +} from "../framework/src/net/http-binding.ts"; +import { AbortController } from "../framework/src/net/abort.ts"; +import { fetch as httpFetch } from "../framework/src/net/http.ts"; +import type { BodyStream, HttpBodyProducer } from "../framework/src/net/http-body.ts"; + +const RUNTIME_GENERATION = 7; +const PLAN_HASH = Object.freeze(new Array(32).fill(0x5a)); +const FEATURE_IDS = Object.freeze([NetworkV1FeatureId.HttpClient]); +const EXPECTED: NetworkV1CompiledExpectation = Object.freeze({ + planHashBytes: PLAN_HASH, + featureIds: FEATURE_IDS, +}); +const ABSENT = Object.freeze({ id: 0, generation: 0 }); +const DEFAULT_HTTP_CLIENT_LIMITS = Object.freeze([ + Object.freeze({ + name: "http.bufferedBodyBytes", + default: 4096, + hard: 8192, + minimum: 1024, + }), + Object.freeze({ + name: "http.headerBytes", + default: 8192, + hard: 16384, + minimum: 4096, + }), + Object.freeze({ + name: "http.maxBodyChunkBytes", + default: 2048, + hard: 4096, + minimum: 512, + }), + Object.freeze({ + name: "http.maxOperations", + default: 8, + hard: 8, + minimum: 1, + }), + Object.freeze({ + name: "runtime.nativeBufferBytes", + default: 512 * 1024, + hard: 1024 * 1024, + minimum: 256 * 1024, + }), +]); + +function admittedBinding( + adapter: ReturnType, +) { + if (adapter.binding === undefined) throw new Error("test expected an admitted HTTP binding"); + return adapter.binding; +} + +function failure( + code: NetworkV1ErrorCode = NetworkV1ErrorCode.SystemError, +) { + return Object.freeze({ + category: code === NetworkV1ErrorCode.HttpProtocolError + ? NetworkV1ErrorCategory.Protocol + : NetworkV1ErrorCategory.Runtime, + code, + operation: "http.fetch", + temporary: false, + }); +} + +function highStart(operationId: number, hasBody = false): HttpRequestStartCommand { + return Object.freeze({ + opcode: HighCommandOpcode.HttpRequestStart, + operationId, + url: "http://example.test/", + method: "POST", + headers: Object.freeze([]), + hasBody, + redirect: "follow", + maxRedirects: 5, + ref: true, + }); +} + +function completionIdentity( + start: Extract, + body: NetworkV1Handle, + sequence: number, +): NetworkV1CompletionIdentity { + return Object.freeze({ + runtimeGeneration: RUNTIME_GENERATION, + resource: start.identity.resource, + operation: start.identity.operation, + body, + sequence, + }); +} + +class FakeHost { + readonly commands: NetworkV1AsyncCommand[] = []; + readonly completions: NetworkV1Completion[] = []; + readonly leases = new Map(); + readonly table: NetworkV1BindingTable; + dispatcher?: NetworkV1ServiceDispatcher; + registerCount = 0; + releaseCount = 0; + pollCount = 0; + readonly limitsQueries: NetworkV1LimitsQuery[] = []; + readonly #featureIds: readonly NetworkV1FeatureId[]; + readonly #limitValues: readonly Readonly[]; + #turnId = 0; + #leaseId = 0; + + constructor( + featureIds: readonly NetworkV1FeatureId[] = FEATURE_IDS, + planHash: readonly number[] = PLAN_HASH, + limitValues: readonly Readonly[] = DEFAULT_HTTP_CLIENT_LIMITS, + ) { + this.#featureIds = Object.freeze(Array.from(featureIds)); + this.#limitValues = limitValues; + const handshake = Object.freeze({ + abiMajor: NETWORK_V1_ABI_MAJOR, + abiMinor: NETWORK_V1_ABI_MINOR, + runtimeGeneration: RUNTIME_GENERATION, + planHash: Uint8Array.from(planHash), + featureIds: Object.freeze(Array.from(featureIds)), + }); + this.table = Object.freeze({ + handshake, + getLimits: (query: NetworkV1LimitsQuery) => this.getLimits(query), + dispatch: (command: NetworkV1AsyncCommand) => { + this.commands.push(command); + return Object.freeze({ status: NetworkV1DispatchStatus.Accepted }); + }, + nextCompletion: ({ maxPayloadBytes }: { maxPayloadBytes: number }) => { + this.pollCount++; + const completion = this.completions[0]; + if (!completion) { + return Object.freeze({ + status: NetworkV1CompletionPollStatus.Drained, + payloadBytesDelivered: 0 as const, + }); + } + const payload = completion.eventCode === NetworkV1EventCode.BodyChunk + ? completion.payload.byteLength + : 0; + if (maxPayloadBytes === 0 || payload > maxPayloadBytes) { + return Object.freeze({ + status: NetworkV1CompletionPollStatus.BudgetExhausted, + payloadBytesDelivered: 0 as const, + }); + } + this.completions.shift(); + return Object.freeze({ + status: NetworkV1CompletionPollStatus.Item, + completion, + payloadBytesDelivered: payload, + }); + }, + leaseTake: (command: NetworkV1BufferLeaseTakeCommand) => { + const lease = this.leases.get(this.leaseKey(command.lease)); + if (!lease || lease.state !== "queued" || + lease.bytes.byteLength !== command.byteLength) { + return Object.freeze({ + status: NetworkV1DispatchStatus.Refused, + error: failure(NetworkV1ErrorCode.InvalidState), + }); + } + lease.state = "taken"; + return Object.freeze({ + status: NetworkV1DispatchStatus.Completed, + byteLength: lease.bytes.byteLength, + }); + }, + leaseReadInto: ( + command: NetworkV1BufferLeaseReadIntoCommand, + destination: Uint8Array, + ) => { + const lease = this.leases.get(this.leaseKey(command.lease)); + if (!lease || lease.state !== "taken") { + return Object.freeze({ + status: NetworkV1DispatchStatus.Refused, + error: failure(NetworkV1ErrorCode.InvalidState), + }); + } + const count = Math.min(destination.byteLength, lease.bytes.byteLength - command.offset); + destination.set(lease.bytes.subarray(command.offset, command.offset + count)); + return Object.freeze({ + status: NetworkV1DispatchStatus.Completed, + bytesCopied: count, + }); + }, + leaseRelease: (command: NetworkV1BufferLeaseReleaseCommand) => { + const lease = this.leases.get(this.leaseKey(command.lease)); + if (!lease || lease.state !== "taken") { + return Object.freeze({ + status: NetworkV1DispatchStatus.Refused, + error: failure(NetworkV1ErrorCode.InvalidState), + }); + } + lease.state = "released"; + this.releaseCount++; + return Object.freeze({ status: NetworkV1DispatchStatus.Completed }); + }, + registerServiceDispatcher: (dispatcher: NetworkV1ServiceDispatcher) => { + this.registerCount++; + this.dispatcher = dispatcher; + }, + }); + } + + get startCommand() { + for (let index = this.commands.length - 1; index >= 0; index--) { + const command = this.commands[index]!; + if (command.opcode === NetworkV1CommandOpcode.HttpRequestStart) return command; + } + throw new Error("test expected an HTTP request start command"); + } + + getLimits(query: NetworkV1LimitsQuery) { + this.limitsQueries.push(query); + const http = query.protocol === NETWORK_V1_LIMIT_PROTOCOL_ANY || + query.protocol === NetworkV1LimitProtocol.Http; + const client = query.role === NETWORK_V1_LIMIT_ROLE_ANY || + query.role === NetworkV1LimitRole.Client; + const featureIds = http && client ? this.#featureIds : Object.freeze([]); + const values = featureIds.length === 0 + ? Object.freeze([]) + : this.#limitValues; + return Object.freeze({ + runtimeGeneration: RUNTIME_GENERATION, + protocol: query.protocol, + role: query.role, + values, + featureIds, + }); + } + + headers( + body: NetworkV1Handle = ABSENT, + sequence = 1, + status = 200, + ): NetworkV1Completion { + return Object.freeze({ + eventCode: NetworkV1EventCode.HttpResponseHeaders, + identity: completionIdentity(this.startCommand, body, sequence), + metadata: Object.freeze({ + status, + statusText: "OK", + headers: Object.freeze([Object.freeze({ name: "content-type", value: "text/plain" })]), + url: "http://example.test/", + redirected: false, + bufferedBodyBytes: 4096, + }), + }); + } + + chunk(body: NetworkV1Handle, bytes: readonly number[], sequence: number): NetworkV1Completion { + const lease = Object.freeze({ id: ++this.#leaseId, generation: 1 }); + this.leases.set(this.leaseKey(lease), { bytes: Uint8Array.from(bytes), state: "queued" }); + return Object.freeze({ + eventCode: NetworkV1EventCode.BodyChunk, + identity: completionIdentity(this.startCommand, body, sequence), + payload: Object.freeze({ + runtimeGeneration: RUNTIME_GENERATION, + lease, + byteLength: bytes.length, + }), + }); + } + + end(body: NetworkV1Handle, sequence: number): NetworkV1Completion { + return Object.freeze({ + eventCode: NetworkV1EventCode.BodyEnd, + identity: completionIdentity(this.startCommand, body, sequence), + }); + } + + run(maxEvents = 8, maxPayloadBytes = 65_536) { + return this.dispatcher!(Object.freeze({ + runtimeGeneration: RUNTIME_GENERATION, + turnId: ++this.#turnId, + kind: NetworkV1ServiceTurnKind.Network, + maxEvents, + maxPayloadBytes, + })); + } + + private leaseKey(handle: NetworkV1Handle): string { + return `${handle.id}:${handle.generation}`; + } +} + +function overrideHostTable( + host: FakeHost, + overrides: Partial, +): NetworkV1BindingTable { + return Object.freeze({ ...host.table, ...overrides }); +} + +describe("formal network v1 mount", () => { + test("adopts only the non-zero Host runtime generation and registers once", () => { + const host = new FakeHost(); + const adapter = createNetworkV1HttpBindingAdapterForTesting(host.table, EXPECTED); + const binding = admittedBinding(adapter); + expect(host.registerCount).toBe(1); + binding.start( + highStart(1), + null, + new AbortController().signal, + ); + expect(host.startCommand.identity.runtimeGeneration).toBe(RUNTIME_GENERATION); + expect(host.startCommand.identity.commandSequence).toBe(1); + expect(binding.featureSet).toEqual(["network.http.client"]); + }); + + test("rejects plan/feature mismatch before dispatcher registration", () => { + const wrongPlan = new FakeHost(FEATURE_IDS, Object.freeze(new Array(32).fill(0))); + expect(() => createNetworkV1HttpBindingAdapterForTesting( + wrongPlan.table, + EXPECTED, + )).toThrow("Build Plan hash"); + expect(wrongPlan.registerCount).toBe(0); + + const wrongFeatures = new FakeHost(Object.freeze([ + NetworkV1FeatureId.HttpClient, + NetworkV1FeatureId.HttpClientTls, + ])); + expect(() => createNetworkV1HttpBindingAdapterForTesting( + wrongFeatures.table, + EXPECTED, + )).toThrow("feature set"); + expect(wrongFeatures.registerCount).toBe(0); + }); + + test("never invokes hostile table accessors", () => { + const base = new FakeHost(); + let reads = 0; + const hostile = Object.freeze(Object.defineProperties({}, { + handshake: { value: base.table.handshake, enumerable: true }, + getLimits: { value: base.table.getLimits, enumerable: true }, + dispatch: { + enumerable: true, + get() { + reads++; + return base.table.dispatch; + }, + }, + nextCompletion: { value: base.table.nextCompletion, enumerable: true }, + leaseTake: { value: base.table.leaseTake, enumerable: true }, + leaseReadInto: { value: base.table.leaseReadInto, enumerable: true }, + leaseRelease: { value: base.table.leaseRelease, enumerable: true }, + registerServiceDispatcher: { + value: base.table.registerServiceDispatcher, + enumerable: true, + }, + })); + expect(() => createNetworkV1HttpBindingAdapterForTesting( + hostile as NetworkV1BindingTable, + EXPECTED, + )).toThrow("data property"); + expect(reads).toBe(0); + }); + + test("does not expose or register HTTP when the client feature is absent", () => { + const host = new FakeHost(Object.freeze([])); + const expected = Object.freeze({ + planHashBytes: PLAN_HASH, + featureIds: Object.freeze([]), + }); + const adapter = createNetworkV1HttpBindingAdapterForTesting(host.table, expected); + expect(adapter.binding).toBeUndefined(); + expect(adapter.httpClientLimits).toEqual({ values: [], features: [] }); + expect(host.registerCount).toBe(0); + expect(host.commands).toHaveLength(0); + expect(host.limitsQueries).toHaveLength(1); + }); + + test("mounts only the limits provider for a build without HTTP client", async () => { + const directory = await mkdtemp(join(tmpdir(), "pocketjs-network-v1-no-http-")); + const resultPath = join(directory, "result.json"); + const adapterUrl = new URL( + "../framework/src/net/network-v1-binding.ts", + import.meta.url, + ).href; + const bindingUrl = new URL("../framework/src/net/http-binding.ts", import.meta.url).href; + const limitsUrl = new URL("../framework/src/net/network-limits.ts", import.meta.url).href; + const specUrl = new URL("../contracts/spec/network/network-v1.ts", import.meta.url).href; + const source = ` + const adapter = await import(${JSON.stringify(adapterUrl)}); + const binding = await import(${JSON.stringify(bindingUrl)}); + const limits = await import(${JSON.stringify(limitsUrl)}); + const spec = await import(${JSON.stringify(specUrl)}); + let dispatchCount = 0; + let registerCount = 0; + let limitsCount = 0; + const handshake = Object.freeze({ + abiMajor: spec.NETWORK_V1_ABI_MAJOR, + abiMinor: spec.NETWORK_V1_ABI_MINOR, + runtimeGeneration: ${RUNTIME_GENERATION}, + planHash: new Uint8Array(32).fill(0x5a), + featureIds: Object.freeze([]), + }); + const table = Object.freeze({ + handshake, + getLimits(query) { + limitsCount++; + return Object.freeze({ + runtimeGeneration: query.runtimeGeneration, + protocol: query.protocol, + role: query.role, + values: Object.freeze([]), + featureIds: Object.freeze([]), + }); + }, + dispatch() { + dispatchCount++; + return Object.freeze({ status: spec.NetworkV1DispatchStatus.Accepted }); + }, + nextCompletion() { throw new Error("completion polling must be unreachable"); }, + leaseTake() { throw new Error("lease take must be unreachable"); }, + leaseReadInto() { throw new Error("lease read must be unreachable"); }, + leaseRelease() { throw new Error("lease release must be unreachable"); }, + registerServiceDispatcher() { registerCount++; }, + }); + adapter.mountNetworkV1HttpBinding(table, Object.freeze({ + planHashBytes: Object.freeze(new Array(32).fill(0x5a)), + featureIds: Object.freeze([]), + })); + const snapshot = limits.queryInstalledNetworkLimits("http", "client"); + await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ + hasBinding: binding.getHttpClientBinding() !== undefined, + snapshot, + dispatchCount, + registerCount, + limitsCount, + })); + `; + try { + const script = join(directory, "mount.ts"); + await Bun.write(script, source); + const child = Bun.spawn([process.execPath, script], { + stdout: "ignore", + stderr: "pipe", + }); + const [exitCode, stderr] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + ]); + expect(exitCode, stderr).toBe(0); + expect(await Bun.file(resultPath).json()).toEqual({ + hasBinding: false, + snapshot: { values: [], features: [] }, + dispatchCount: 0, + registerCount: 0, + limitsCount: 1, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +describe("formal HTTP command/completion adapter", () => { + test("rejects TRACK before dispatching formal request metadata", () => { + const host = new FakeHost(); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(host.table, EXPECTED)); + expect(() => binding.start( + Object.freeze({ ...highStart(1), method: "TRACK" }), + null, + new AbortController().signal, + )).toThrow("request method is invalid"); + expect(host.commands).toHaveLength(0); + binding.start(highStart(2), null, new AbortController().signal); + expect(host.startCommand.identity.operation.generation).toBe(1); + }); + + test("snapshots every Host response metadata field exactly once", async () => { + const host = new FakeHost(); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(host.table, EXPECTED)); + const operation = binding.start(highStart(1), null, new AbortController().signal); + const reads = new Map(); + const field = (name: string, value: Value) => ({ + enumerable: true, + get() { + reads.set(name, (reads.get(name) ?? 0) + 1); + return value; + }, + }); + const metadata = Object.defineProperties({}, { + status: field("status", 200), + statusText: field("statusText", "OK"), + headers: field("headers", Object.freeze([])), + url: field("url", "http://example.test/"), + redirected: field("redirected", false), + bufferedBodyBytes: field("bufferedBodyBytes", 4096), + }); + host.completions.push({ + eventCode: NetworkV1EventCode.HttpResponseHeaders, + identity: completionIdentity(host.startCommand, ABSENT, 1), + metadata, + } as NetworkV1Completion); + host.run(); + await expect(operation.response).resolves.toMatchObject({ + status: 200, + redirected: false, + url: "http://example.test/", + }); + expect(Object.fromEntries(reads)).toEqual({ + status: 1, + statusText: 1, + headers: 1, + url: 1, + redirected: 1, + bufferedBodyBytes: 1, + }); + }); + + test("uses only canonical URL hostnames in formal TLS metadata", () => { + const tlsFeatureIds = Object.freeze([ + NetworkV1FeatureId.HttpClient, + NetworkV1FeatureId.HttpClientTls, + ]); + const expected = Object.freeze({ + planHashBytes: PLAN_HASH, + featureIds: tlsFeatureIds, + }); + const host = new FakeHost(tlsFeatureIds); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(host.table, expected)); + binding.start(Object.freeze({ + ...highStart(1), + url: "https://example.test/", + tls: Object.freeze({ serverName: "EXAMPLE.TEST." }), + }), null, new AbortController().signal); + expect(host.startCommand.metadata.tls).toMatchObject({ + serverName: "example.test", + }); + + binding.start(Object.freeze({ + ...highStart(2), + url: "https://127.0.0.1/", + tls: Object.freeze({ serverName: "127.0.0.1" }), + }), null, new AbortController().signal); + const starts = host.commands.filter((command): command is Extract => command.opcode === NetworkV1CommandOpcode.HttpRequestStart); + expect(starts[1]!.metadata.tls).toMatchObject({ serverName: "" }); + + expect(() => binding.start(Object.freeze({ + ...highStart(3), + url: "https://example.test/", + tls: Object.freeze({ serverName: "other.test" }), + }), null, new AbortController().signal)).toThrow("canonical request hostname"); + expect(host.commands.filter( + (command) => command.opcode === NetworkV1CommandOpcode.HttpRequestStart, + )).toHaveLength(2); + }); + + test("publishes headers first and copies a response lease under BODY credit", async () => { + const host = new FakeHost(); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(host.table, EXPECTED)); + const operation = binding.start(highStart(1), null, new AbortController().signal); + const responseBody = Object.freeze({ id: 101, generation: 3 }); + host.completions.push(host.headers(responseBody)); + expect(host.run()).toMatchObject({ + status: NetworkV1ServiceTurnStatus.Drained, + eventsDelivered: 1, + payloadBytesDelivered: 0, + lastSequence: 1, + }); + const response = await operation.response; + expect(response.status).toBe(200); + expect(response.body).toBeDefined(); + + const destination = new Uint8Array(8); + const read = response.body!.readInto(destination); + expect(host.commands.at(-1)).toMatchObject({ + opcode: NetworkV1CommandOpcode.BodyPull, + identity: { body: responseBody }, + maxBytes: 8, + }); + host.completions.push(host.chunk(responseBody, [1, 2, 3], 2)); + expect(host.run()).toMatchObject({ eventsDelivered: 1, payloadBytesDelivered: 3 }); + expect(await read).toEqual({ bytes: 3, done: false }); + expect([...destination.subarray(0, 3)]).toEqual([1, 2, 3]); + expect(host.releaseCount).toBe(1); + + const eof = response.body!.readInto(destination); + host.completions.push(Object.freeze({ + eventCode: NetworkV1EventCode.BodyEnd, + identity: completionIdentity(host.startCommand, responseBody, 3), + })); + host.run(); + expect(await eof).toEqual({ bytes: 0, done: true }); + }); + + test("honors event/payload budgets without dequeuing a readiness probe", async () => { + const host = new FakeHost(); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(host.table, EXPECTED)); + const operation = binding.start(highStart(1), null, new AbortController().signal); + const body = Object.freeze({ id: 9, generation: 1 }); + host.completions.push(host.headers(body)); + expect(host.run(1, 32)).toMatchObject({ status: NetworkV1ServiceTurnStatus.Drained }); + const response = await operation.response; + const read = response.body!.readInto(new Uint8Array(4)); + host.completions.push(host.chunk(body, [1, 2, 3, 4], 2)); + expect(host.run(8, 3)).toEqual({ + status: NetworkV1ServiceTurnStatus.MoreReady, + eventsDelivered: 0, + payloadBytesDelivered: 0, + lastSequence: 0, + }); + expect(host.completions).toHaveLength(1); + host.run(8, 4); + expect(await read).toEqual({ bytes: 4, done: false }); + }); + + test("detaches every service-turn request field exactly once", () => { + const host = new FakeHost(); + const adapter = createNetworkV1HttpBindingAdapterForTesting(host.table, EXPECTED); + const reads = new Map(); + const field = (name: string, value: Value) => ({ + enumerable: true, + get() { + reads.set(name, (reads.get(name) ?? 0) + 1); + return value; + }, + }); + const request = Object.defineProperties({}, { + runtimeGeneration: field("runtimeGeneration", RUNTIME_GENERATION), + turnId: field("turnId", 1), + kind: field("kind", NetworkV1ServiceTurnKind.Network), + maxEvents: field("maxEvents", 8), + maxPayloadBytes: field("maxPayloadBytes", 65_536), + }); + expect(adapter.dispatcher(request as Parameters[0])).toEqual({ + status: NetworkV1ServiceTurnStatus.Drained, + eventsDelivered: 0, + payloadBytesDelivered: 0, + lastSequence: 0, + }); + expect(Object.fromEntries(reads)).toEqual({ + runtimeGeneration: 1, + turnId: 1, + kind: 1, + maxEvents: 1, + maxPayloadBytes: 1, + }); + }); + + test("poisons oversized service-turn budgets before completion polling", () => { + for (const oversized of [ + { maxEvents: 129, maxPayloadBytes: 65_536 }, + { maxEvents: 8, maxPayloadBytes: 256 * 1024 + 1 }, + ]) { + const host = new FakeHost(); + const adapter = createNetworkV1HttpBindingAdapterForTesting(host.table, EXPECTED); + const binding = admittedBinding(adapter); + expect(() => adapter.dispatcher(Object.freeze({ + runtimeGeneration: RUNTIME_GENERATION, + turnId: 1, + kind: NetworkV1ServiceTurnKind.Network, + ...oversized, + }))).toThrow("outside"); + expect(host.pollCount).toBe(0); + expect(() => binding.start( + highStart(99), + null, + new AbortController().signal, + )).toThrow("outside"); + expect(host.commands).toHaveLength(0); + } + }); + + test("cleans stale and out-of-order selected leases without delivery", async () => { + const staleHost = new FakeHost(); + const staleAdapter = createNetworkV1HttpBindingAdapterForTesting(staleHost.table, EXPECTED); + const staleOperation = admittedBinding(staleAdapter).start( + highStart(1), + null, + new AbortController().signal, + ); + const staleBody = Object.freeze({ id: 8, generation: 1 }); + staleHost.completions.push(staleHost.headers(staleBody)); + staleHost.run(); + const staleResponse = await staleOperation.response; + await staleResponse.body!.cancel(); + staleHost.completions.push(staleHost.chunk(staleBody, [9], 2)); + expect(staleHost.run()).toMatchObject({ eventsDelivered: 1 }); + expect(staleHost.releaseCount).toBe(1); + + const orderedHost = new FakeHost(); + const orderedAdapter = createNetworkV1HttpBindingAdapterForTesting(orderedHost.table, EXPECTED); + const orderedOperation = admittedBinding(orderedAdapter).start( + highStart(2), + null, + new AbortController().signal, + ); + const orderedBody = Object.freeze({ id: 12, generation: 1 }); + orderedHost.completions.push(orderedHost.headers(orderedBody, 2)); + orderedHost.run(); + const orderedResponse = await orderedOperation.response; + const pending = orderedResponse.body!.readInto(new Uint8Array(2)); + pending.catch(() => {}); + orderedHost.completions.push(orderedHost.chunk(orderedBody, [4, 5], 1)); + expect(() => orderedHost.run()).toThrow("strictly monotonic"); + expect(orderedHost.releaseCount).toBe(1); + await expect(pending).rejects.toBeInstanceOf(Error); + }); + + test("releases a taken lease after length validation fails and permanently poisons", async () => { + const host = new FakeHost(); + const table = overrideHostTable(host, { + leaseTake: ((command: NetworkV1BufferLeaseTakeCommand) => { + const result = host.table.leaseTake(command); + if (result.status !== NetworkV1DispatchStatus.Completed) return result; + return Object.freeze({ + status: NetworkV1DispatchStatus.Completed, + byteLength: result.byteLength + 1, + }); + }) as NetworkV1BindingTable["leaseTake"], + }); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(table, EXPECTED)); + const operation = binding.start(highStart(1), null, new AbortController().signal); + const body = Object.freeze({ id: 31, generation: 1 }); + host.completions.push(host.headers(body)); + host.run(); + const response = await operation.response; + const pending = response.body!.readInto(new Uint8Array(4)); + const rejected = pending.catch((error) => error); + host.completions.push(host.chunk(body, [1, 2, 3], 2)); + expect(() => host.run()).toThrow("lease take length disagrees"); + expect(host.releaseCount).toBe(1); + expect(await rejected).toBeInstanceOf(TypeError); + expect(() => binding.start( + highStart(2), + null, + new AbortController().signal, + )).toThrow("lease take length disagrees"); + }); + + test("claims lease status once and releases after a taken length getter throws", async () => { + const host = new FakeHost(); + const marker = new Error("lease length getter failed"); + let statusReads = 0; + let lengthReads = 0; + const table = overrideHostTable(host, { + leaseTake: ((command: NetworkV1BufferLeaseTakeCommand) => { + const result = host.table.leaseTake(command); + expect(result.status).toBe(NetworkV1DispatchStatus.Completed); + return Object.freeze(Object.defineProperties({}, { + status: { + enumerable: true, + get() { + statusReads++; + return NetworkV1DispatchStatus.Completed; + }, + }, + byteLength: { + enumerable: true, + get() { + lengthReads++; + throw marker; + }, + }, + })) as ReturnType; + }) as NetworkV1BindingTable["leaseTake"], + }); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(table, EXPECTED)); + const operation = binding.start(highStart(1), null, new AbortController().signal); + const body = Object.freeze({ id: 41, generation: 1 }); + host.completions.push(host.headers(body)); + host.run(); + const response = await operation.response; + const pending = response.body!.readInto(new Uint8Array(4)); + const rejected = pending.catch((error) => error); + host.completions.push(host.chunk(body, [1], 2)); + + expect(() => host.run()).toThrow(marker); + expect(await rejected).toBe(marker); + expect(statusReads).toBe(1); + expect(lengthReads).toBe(1); + expect(host.releaseCount).toBe(1); + }); + + test("cleans a dequeued lease after payload accounting validation fails", async () => { + const host = new FakeHost(); + const table = overrideHostTable(host, { + nextCompletion: ((request: Parameters[0]) => { + const result = host.table.nextCompletion(request); + if (result.status !== NetworkV1CompletionPollStatus.Item || + result.completion.eventCode !== NetworkV1EventCode.BodyChunk) return result; + return Object.freeze({ + ...result, + payloadBytesDelivered: 0, + }); + }) as NetworkV1BindingTable["nextCompletion"], + }); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(table, EXPECTED)); + const operation = binding.start(highStart(1), null, new AbortController().signal); + const body = Object.freeze({ id: 32, generation: 1 }); + host.completions.push(host.headers(body)); + host.run(); + const response = await operation.response; + const pending = response.body!.readInto(new Uint8Array(4)); + const rejected = pending.catch((error) => error); + host.completions.push(host.chunk(body, [4, 5], 2)); + expect(() => host.run()).toThrow("payload does not match its lease descriptor"); + expect(host.releaseCount).toBe(1); + expect(await rejected).toBeInstanceOf(Error); + expect(() => binding.start( + highStart(2), + null, + new AbortController().signal, + )).toThrow("payload does not match its lease descriptor"); + }); + + test("permanently poisons when exact release of a taken lease fails", async () => { + const host = new FakeHost(); + let releaseAttempts = 0; + const table = overrideHostTable(host, { + leaseRelease: ((_command: NetworkV1BufferLeaseReleaseCommand) => { + releaseAttempts++; + return Object.freeze({ + status: NetworkV1DispatchStatus.Refused, + error: failure(NetworkV1ErrorCode.InvalidState), + }); + }) as NetworkV1BindingTable["leaseRelease"], + }); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(table, EXPECTED)); + const operation = binding.start(highStart(1), null, new AbortController().signal); + const body = Object.freeze({ id: 33, generation: 1 }); + host.completions.push(host.headers(body)); + host.run(); + const response = await operation.response; + const pending = response.body!.readInto(new Uint8Array(4)); + const rejected = pending.catch((error) => error); + host.completions.push(host.chunk(body, [7], 2)); + expect(() => host.run()).toThrow("invalid_state"); + expect(releaseAttempts).toBe(1); + expect(await rejected).toMatchObject({ code: "invalid_state" }); + expect(() => binding.start( + highStart(2), + null, + new AbortController().signal, + )).toThrow("invalid_state"); + }); + + test("persists a terminal response error when no read is pending", async () => { + const host = new FakeHost(); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(host.table, EXPECTED)); + const operation = binding.start(highStart(1), null, new AbortController().signal); + const body = Object.freeze({ id: 34, generation: 1 }); + host.completions.push(host.headers(body)); + host.run(); + const response = await operation.response; + host.completions.push(Object.freeze({ + eventCode: NetworkV1EventCode.BodyError, + identity: completionIdentity(host.startCommand, body, 2), + error: failure(NetworkV1ErrorCode.SystemError), + })); + host.run(); + await expect(response.body!.readInto(new Uint8Array(4))).rejects.toMatchObject({ + code: "system_error", + }); + await expect(response.body!.cancel()).rejects.toMatchObject({ + code: "system_error", + }); + await expect(response.body![Symbol.asyncIterator]().next()).rejects.toMatchObject({ + code: "system_error", + }); + }); + + test("sanitizes BODY_ERROR metadata and never reads a Host message", async () => { + const host = new FakeHost(); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(host.table, EXPECTED)); + const operation = binding.start(highStart(1), null, new AbortController().signal); + const body = Object.freeze({ id: 35, generation: 1 }); + host.completions.push(host.headers(body)); + host.run(); + const response = await operation.response; + const reads = new Map(); + const field = (name: string, value: Value) => ({ + enumerable: true, + get() { + reads.set(name, (reads.get(name) ?? 0) + 1); + return value; + }, + }); + const error = Object.defineProperties({}, { + category: field("category", NetworkV1ErrorCategory.Runtime), + code: field("code", NetworkV1ErrorCode.SystemError), + operation: field("operation", "native.secret.operation"), + temporary: field("temporary", true), + address: field("address", "127.0.0.1"), + port: field("port", 443), + causeCode: field("causeCode", "ESP_ERR_TLS"), + reasonCode: field("reasonCode", 42), + message: field("message", "secret Host diagnostic"), + }); + host.completions.push({ + eventCode: NetworkV1EventCode.BodyError, + identity: completionIdentity(host.startCommand, body, 2), + error, + } as NetworkV1Completion); + host.run(); + await expect(response.body!.readInto(new Uint8Array(4))).rejects.toMatchObject({ + message: "HTTP request failed with system_error", + operation: "http.body.readInto", + temporary: true, + address: "127.0.0.1", + port: 443, + causeCode: "ESP_ERR_TLS", + reasonCode: 42, + }); + expect(Object.fromEntries(reads)).toEqual({ + category: 1, + code: 1, + operation: 1, + temporary: 1, + address: 1, + port: 1, + causeCode: 1, + reasonCode: 1, + }); + expect(reads.get("message")).toBeUndefined(); + }); + + test("poisons non-token causes and non-canonical Host addresses", async () => { + for (const metadata of [ + Object.freeze({ ...failure(), causeCode: "secret diagnostic text" }), + Object.freeze({ ...failure(), address: "EXAMPLE.test" }), + ]) { + const host = new FakeHost(); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(host.table, EXPECTED)); + const operation = binding.start(highStart(1), null, new AbortController().signal); + const body = Object.freeze({ id: 36, generation: 1 }); + host.completions.push(host.headers(body)); + host.run(); + const response = await operation.response; + host.completions.push(Object.freeze({ + eventCode: NetworkV1EventCode.BodyError, + identity: completionIdentity(host.startCommand, body, 2), + error: metadata, + })); + expect(() => host.run()).toThrow(); + await expect(response.body!.readInto(new Uint8Array(4))).rejects.toBeInstanceOf( + TypeError, + ); + expect(() => binding.start( + highStart(2), + null, + new AbortController().signal, + )).toThrow(); + } + }); + + test("maps abort to one exact cancel command and a stable numeric error", async () => { + const host = new FakeHost(); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(host.table, EXPECTED)); + const operation = binding.start(highStart(22), null, new AbortController().signal); + const cancel = Object.freeze({ + opcode: HighCommandOpcode.OperationCancel, + operationId: 22, + }); + operation.cancel(cancel); + operation.cancel(cancel); + expect(host.commands.filter( + (command) => command.opcode === NetworkV1CommandOpcode.OperationCancel, + )).toHaveLength(1); + host.completions.push(Object.freeze({ + eventCode: NetworkV1EventCode.HttpRequestError, + identity: completionIdentity(host.startCommand, ABSENT, 1), + error: failure(NetworkV1ErrorCode.Aborted), + })); + host.run(); + await expect(operation.response).rejects.toMatchObject({ + category: "runtime", + code: "aborted", + operationId: 22, + }); + }); + + test("poisons and clears a response pull when Host dispatch throws", async () => { + const host = new FakeHost(); + const fault = new Error("response pull dispatch fault"); + const table = overrideHostTable(host, { + dispatch: ((command: NetworkV1AsyncCommand) => { + if (command.opcode === NetworkV1CommandOpcode.BodyPull) throw fault; + return host.table.dispatch(command); + }) as NetworkV1BindingTable["dispatch"], + }); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(table, EXPECTED)); + const operation = binding.start(highStart(1), null, new AbortController().signal); + const body = Object.freeze({ id: 41, generation: 1 }); + host.completions.push(host.headers(body)); + host.run(); + const response = await operation.response; + await expect(response.body!.readInto(new Uint8Array(4))).rejects.toBe(fault); + await expect(response.body!.cancel()).rejects.toBe(fault); + expect(() => binding.start( + highStart(2), + null, + new AbortController().signal, + )).toThrow("response pull dispatch fault"); + }); + + test("poisons and clears response read/cancel state when cancel dispatch throws", async () => { + const host = new FakeHost(); + const fault = new Error("response cancel dispatch fault"); + const table = overrideHostTable(host, { + dispatch: ((command: NetworkV1AsyncCommand) => { + if (command.opcode === NetworkV1CommandOpcode.BodyCancel) throw fault; + return host.table.dispatch(command); + }) as NetworkV1BindingTable["dispatch"], + }); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(table, EXPECTED)); + const operation = binding.start(highStart(1), null, new AbortController().signal); + const body = Object.freeze({ id: 42, generation: 1 }); + host.completions.push(host.headers(body)); + host.run(); + const response = await operation.response; + const pending = response.body!.readInto(new Uint8Array(4)); + const pendingFailure = pending.catch((error) => error); + await expect(response.body!.cancel()).rejects.toBe(fault); + expect(await pendingFailure).toBe(fault); + expect(() => binding.start( + highStart(2), + null, + new AbortController().signal, + )).toThrow("response cancel dispatch fault"); + }); + + test("poisons operation cancel dispatch faults without leaving a pending response", async () => { + const host = new FakeHost(); + const fault = new Error("operation cancel dispatch fault"); + const table = overrideHostTable(host, { + dispatch: ((command: NetworkV1AsyncCommand) => { + if (command.opcode === NetworkV1CommandOpcode.OperationCancel) throw fault; + return host.table.dispatch(command); + }) as NetworkV1BindingTable["dispatch"], + }); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(table, EXPECTED)); + const operation = binding.start(highStart(1), null, new AbortController().signal); + const responseFailure = operation.response.catch((error) => error); + expect(() => operation.cancel(Object.freeze({ + opcode: HighCommandOpcode.OperationCancel, + operationId: 1, + }))).toThrow("operation cancel dispatch fault"); + expect(await responseFailure).toBe(fault); + expect(() => binding.start( + highStart(2), + null, + new AbortController().signal, + )).toThrow("operation cancel dispatch fault"); + }); + + test("absorbs async upload callback failures after poisoning dispatch", async () => { + const host = new FakeHost(); + const fault = new Error("upload dispatch fault"); + const table = overrideHostTable(host, { + dispatch: ((command: NetworkV1AsyncCommand) => { + if (command.opcode === NetworkV1CommandOpcode.BodyChunk) throw fault; + return host.table.dispatch(command); + }) as NetworkV1BindingTable["dispatch"], + }); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(table, EXPECTED)); + let pulls = 0; + let cancels = 0; + const producer: HttpBodyProducer = Object.freeze({ + pull() { + pulls++; + return Promise.resolve(Uint8Array.from([1, 2, 3])); + }, + cancel() { + cancels++; + return Promise.reject(new Error("producer cancel rejection")); + }, + }); + const operation = binding.start( + highStart(1, true), + producer, + new AbortController().signal, + ); + const responseFailure = operation.response.catch((error) => error); + host.completions.push(Object.freeze({ + eventCode: NetworkV1EventCode.BodyPull, + identity: completionIdentity(host.startCommand, host.startCommand.identity.body, 1), + maxBytes: 3, + })); + host.run(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(await responseFailure).toBe(fault); + expect(pulls).toBe(1); + expect(cancels).toBe(1); + expect(() => binding.start( + highStart(2), + null, + new AbortController().signal, + )).toThrow("upload dispatch fault"); + }); + + test("pulls a Guest request producer once per credit and stops it at headers", async () => { + const host = new FakeHost(); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(host.table, EXPECTED)); + let pulls = 0; + let cancels = 0; + const producer: HttpBodyProducer = Object.freeze({ + async pull(maxBytes: number) { + pulls++; + expect(maxBytes).toBe(3); + return Uint8Array.from([7, 8, 9]); + }, + async cancel() { cancels++; }, + }); + const operation = binding.start(highStart(5, true), producer, new AbortController().signal); + const start = host.startCommand; + expect(start.identity.body.id).not.toBe(0); + host.completions.push(Object.freeze({ + eventCode: NetworkV1EventCode.BodyPull, + identity: completionIdentity(start, start.identity.body, 1), + maxBytes: 3, + })); + host.run(); + await Promise.resolve(); + await Promise.resolve(); + const chunk = host.commands.find( + (command) => command.opcode === NetworkV1CommandOpcode.BodyChunk, + ); + expect(chunk).toMatchObject({ input: { kind: 2 } }); + if (chunk?.opcode === NetworkV1CommandOpcode.BodyChunk) { + expect([...chunk.input.bytes]).toEqual([7, 8, 9]); + } + expect(pulls).toBe(1); + host.completions.push(host.headers(ABSENT, 2)); + host.run(); + await operation.response; + await Promise.resolve(); + expect(cancels).toBe(1); + }); + + test("retires more than eight ignored small responses through bounded prefetch", async () => { + const host = new FakeHost(); + const adapter = createNetworkV1HttpBindingAdapterForTesting(host.table, EXPECTED); + const cleanup = installHttpClientBindingForTesting(admittedBinding(adapter)); + const retainedResponses: unknown[] = []; + let sequence = 1; + try { + for (let index = 0; index < 9; index++) { + const pending = httpFetch(`http://example.test/${index}`); + const body = Object.freeze({ id: 100 + index, generation: 1 }); + host.completions.push(host.headers(body, sequence++)); + host.run(); + retainedResponses[retainedResponses.length] = await pending; + + host.completions.push( + host.chunk(body, [0x30 + index], sequence++), + host.end(body, sequence++), + ); + host.run(); + await Promise.resolve(); + await Promise.resolve(); + } + + expect(retainedResponses).toHaveLength(9); + expect(host.commands.filter((command) => + command.opcode === NetworkV1CommandOpcode.HttpRequestStart + )).toHaveLength(9); + expect(host.commands.filter((command) => + command.opcode === NetworkV1CommandOpcode.OperationCancel + )).toHaveLength(0); + expect(host.releaseCount).toBe(9); + for (const lease of host.leases.values()) expect(lease.state).toBe("released"); + } finally { + cleanup(); + } + }); + + test("holds a bodyless operation slot until native BODY_END", async () => { + const host = new FakeHost(); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting( + host.table, + EXPECTED, + { maxOperations: 1 }, + )); + const first = binding.start(highStart(1), null, new AbortController().signal); + const firstGeneration = host.startCommand.identity.operation.generation; + host.completions.push(host.headers(ABSENT, 1, 204)); + host.run(); + const response = await first.response; + expect(response.status).toBe(204); + expect("body" in response).toBe(false); + expect(() => binding.start( + highStart(2), + null, + new AbortController().signal, + )).toThrow("capacity"); + + host.completions.push(host.end(ABSENT, 2)); + host.run(); + binding.start(highStart(3), null, new AbortController().signal); + expect(host.startCommand.identity.operation.generation).toBe(firstGeneration + 1); + }); + + test("holds a cancelled body slot until native terminal cleanup", async () => { + const host = new FakeHost(); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting( + host.table, + EXPECTED, + { maxOperations: 1 }, + )); + const first = binding.start(highStart(1), null, new AbortController().signal); + const body = Object.freeze({ id: 91, generation: 1 }); + host.completions.push(host.headers(body, 1)); + host.run(); + const response = await first.response; + await response.body!.cancel(); + expect(() => binding.start( + highStart(2), + null, + new AbortController().signal, + )).toThrow("capacity"); + + host.completions.push(host.chunk(body, [0x61], 2), Object.freeze({ + eventCode: NetworkV1EventCode.BodyError, + identity: completionIdentity(host.startCommand, body, 3), + error: failure(NetworkV1ErrorCode.Aborted), + })); + host.run(); + expect(host.releaseCount).toBe(1); + expect(await response.body!.readInto(new Uint8Array(1))).toEqual({ bytes: 0, done: true }); + binding.start(highStart(3), null, new AbortController().signal); + }); + + test("bounds operation slots, advances generation, and never wraps", async () => { + const host = new FakeHost(); + const adapter = createNetworkV1HttpBindingAdapterForTesting( + host.table, + EXPECTED, + { maxOperations: 1 }, + ); + const binding = admittedBinding(adapter); + const first = binding.start(highStart(1), null, new AbortController().signal); + const firstGeneration = host.startCommand.identity.operation.generation; + expect(() => binding.start( + highStart(2), + null, + new AbortController().signal, + )).toThrow("capacity"); + first.response.catch(() => {}); + host.completions.push(Object.freeze({ + eventCode: NetworkV1EventCode.HttpRequestError, + identity: completionIdentity(host.startCommand, ABSENT, 1), + error: failure(), + })); + host.run(); + await expect(first.response).rejects.toBeDefined(); + binding.start(highStart(3), null, new AbortController().signal); + const starts = host.commands.filter((command) => + command.opcode === NetworkV1CommandOpcode.HttpRequestStart + ); + expect(starts[1]!.identity.operation.generation).toBe(firstGeneration + 1); + + const exhaustedHost = new FakeHost(); + const exhausted = createNetworkV1HttpBindingAdapterForTesting( + exhaustedHost.table, + EXPECTED, + { maxOperations: 1, initialSlotGeneration: NETWORK_V1_UINT32_MAX }, + ); + expect(() => admittedBinding(exhausted).start( + highStart(4), + null, + new AbortController().signal, + )).toThrow("capacity"); + }); +}); + +describe("formal limits projection", () => { + test("caches one immutable HTTP/client snapshot at mount", () => { + const host = new FakeHost(); + const adapter = createNetworkV1HttpBindingAdapterForTesting(host.table, EXPECTED); + expect(host.limitsQueries).toHaveLength(1); + expect(host.limitsQueries[0]).toMatchObject({ + protocol: NetworkV1LimitProtocol.Http, + role: NetworkV1LimitRole.Client, + }); + expect(adapter.limits("http", "client")).toBe(adapter.httpClientLimits); + expect(adapter.limits("http", "client")).toBe(adapter.httpClientLimits); + expect(host.limitsQueries).toHaveLength(1); + expect(Object.isFrozen(adapter.httpClientLimits)).toBe(true); + expect(Object.isFrozen(adapter.httpClientLimits.values)).toBe(true); + expect(Object.isFrozen(adapter.httpClientLimits.values[0])).toBe(true); + expect(Object.isFrozen(adapter.httpClientLimits.features)).toBe(true); + }); + + test("accepts only admitted exact operation limits within minimum/default", () => { + const host = new FakeHost(); + const binding = admittedBinding(createNetworkV1HttpBindingAdapterForTesting(host.table, EXPECTED)); + binding.start(Object.freeze({ + ...highStart(1), + limits: Object.freeze({ "http.maxBodyChunkBytes": 512 }), + }), null, new AbortController().signal); + expect(host.startCommand.metadata.limits).toEqual([ + { name: "http.maxBodyChunkBytes", value: 512 }, + ]); + expect(() => binding.start(Object.freeze({ + ...highStart(2), + limits: Object.freeze({ "http.maxBodyChunkBytes": 511 }), + }), null, new AbortController().signal)).toThrow("outside [512, 2048]"); + expect(() => binding.start(Object.freeze({ + ...highStart(3), + limits: Object.freeze({ "http.maxBodyChunkBytes": 2049 }), + }), null, new AbortController().signal)).toThrow("outside [512, 2048]"); + expect(() => binding.start(Object.freeze({ + ...highStart(4), + limits: Object.freeze({ "http.unknownLimit": 512 }), + }), null, new AbortController().signal)).toThrow("not admitted for the HTTP client"); + expect(host.commands.filter( + (command) => command.opcode === NetworkV1CommandOpcode.HttpRequestStart, + )).toHaveLength(1); + }); + + test("validates the Host snapshot and keeps unscoped capabilities empty", () => { + const host = new FakeHost(); + const adapter = createNetworkV1HttpBindingAdapterForTesting(host.table, EXPECTED); + expect(adapter.limits("http", "client")).toEqual({ + values: DEFAULT_HTTP_CLIENT_LIMITS, + features: ["network.http.client"], + }); + expect(adapter.limits("http", "server").features).toEqual([]); + }); + + test("returns a recursively frozen null-prototype public snapshot", async () => { + const directory = await mkdtemp(join(tmpdir(), "pocketjs-network-limits-")); + const resultPath = join(directory, "result.json"); + const limitsUrl = new URL( + "../framework/src/net/network-limits.ts", + import.meta.url, + ).href; + const netUrl = new URL("../framework/src/net/index.ts", import.meta.url).href; + const source = ` + const limits = await import(${JSON.stringify(limitsUrl)}); + const net = await import(${JSON.stringify(netUrl)}); + limits.installNetworkLimitsProvider((protocol, role) => ({ + values: protocol === "http" && role === "client" + ? [{ name: "http.maxBodyChunkBytes", default: 2, hard: 4, minimum: 1 }] + : [], + features: protocol === "http" && role === "client" + ? ["network.http.client"] + : [], + })); + const snapshot = net.getNetworkLimits("http", "client"); + let unadmitted = ""; + try { net.getNetworkLimits("http", "server"); } catch (error) { + unadmitted = error.message; + } + let invalid = ""; + try { net.getNetworkLimits("invalid"); } catch (error) { + invalid = error.constructor.name; + } + await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ + valuesNull: Object.getPrototypeOf(snapshot.values) === null, + featuresNull: Object.getPrototypeOf(snapshot.features) === null, + entryNull: Object.getPrototypeOf(snapshot.values["http.maxBodyChunkBytes"]) === null, + frozen: Object.isFrozen(snapshot) && Object.isFrozen(snapshot.values) && + Object.isFrozen(snapshot.features) && + Object.isFrozen(snapshot.values["http.maxBodyChunkBytes"]), + feature: snapshot.features["network.http.client"], + unadmitted, + invalid, + })); + `; + try { + const script = join(directory, "limits.ts"); + await Bun.write(script, source); + const child = Bun.spawn([process.execPath, script], { + stdout: "ignore", + stderr: "pipe", + }); + const [exitCode, stderr] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + ]); + expect(exitCode, stderr).toBe(0); + expect(await Bun.file(resultPath).json()).toEqual({ + valuesNull: true, + featuresNull: true, + entryNull: true, + frozen: true, + feature: true, + unadmitted: "Network limits are unavailable for an unadmitted scope", + invalid: "TypeError", + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index c5be4d56..ce47bf7a 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { generatePocketManifestV2Schema, POCKET_MANIFEST_SCHEMA_ID, - type PocketManifestV2, + type PocketManifest, } from "../contracts/spec/pocket-manifest.ts"; import { POCKET_CAPABILITIES, @@ -28,7 +28,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; @@ -140,6 +140,10 @@ describe("pocket.json v2 schema", () => { }); describe("platform registry", () => { + test("does not publish the retired NET v1 capability", () => { + expect(POCKET_CAPABILITIES).not.toContain("net.http"); + }); + test("production advertises only the truthful stock-host profiles", () => { expect(Object.keys(POCKET_TARGETS)).toEqual(["psp", "vita", "pocketbook", "macos-widget"]); expect(validatePlatformContractRegistry(POCKET_PLATFORM_CONTRACTS)).toEqual([]); diff --git a/tools/build.ts b/tools/build.ts index 58ed61d8..9805ea7f 100644 --- a/tools/build.ts +++ b/tools/build.ts @@ -24,6 +24,8 @@ // --outdir= write .js/.pak here instead of dist/ // (external repos build their apps against a // vendored PocketJS and keep outputs local) +// --network-factory emit a one-shot private-binding factory; +// requires a verified plan with network inputs import { existsSync, statSync } from "node:fs"; import { resolve as resolvePath, join, dirname } from "node:path"; @@ -61,6 +63,11 @@ import { placeholderImage, type PakBlob, } from "../framework/compiler/pak.ts"; +import { + createNetworkFactoryBuildContext, + finalizeBundleArtifact, + selectBundleArtifactMode, +} from "./network-bundle-factory.ts"; const ROOT = resolvePath(fileURLToPath(new URL("..", import.meta.url))); // pocketjs/ let DIST = join(ROOT, "dist/"); @@ -89,6 +96,9 @@ let planPath: string | undefined; let densityFlag: number | undefined; let hzFlag: number | undefined; let projectRoot = process.cwd(); +let networkFactoryRequested = false; +let testOnlyStagedHttpClientFetchRequested = false; +let testOnlyStagedHttpsClientFetchRequested = false; for (const a of args) { if (a.startsWith("--extra-chars=")) extraChars = a.slice("--extra-chars=".length); else if (a.startsWith("--font-regular=")) regularFontPath = resolvePath(a.slice("--font-regular=".length)); @@ -101,6 +111,13 @@ for (const a of args) { else if (a.startsWith("--outdir=")) DIST = resolvePath(a.slice("--outdir=".length)) + "/"; else if (a.startsWith("--density=")) densityFlag = Number(a.slice("--density=".length)); else if (a.startsWith("--hz=")) hzFlag = Number(a.slice("--hz=".length)); + else if (a === "--network-factory") networkFactoryRequested = true; + else if (a === "--test-only-staged-http-client-fetch") { + testOnlyStagedHttpClientFetchRequested = true; + } + else if (a === "--test-only-staged-https-client-fetch") { + testOnlyStagedHttpsClientFetchRequested = true; + } else if (!a.startsWith("-")) appArg = a; } @@ -117,8 +134,16 @@ if (planPath) { if (!configFlagged) configPath = resolvePath(projectRoot, "pocket.config.ts"); } +const bundleArtifactMode = selectBundleArtifactMode( + buildPlan, + networkFactoryRequested, +); +let networkPrivate = bundleArtifactMode === "network-factory" + ? createNetworkFactoryBuildContext(buildPlan!) + : undefined; + if (!appArg) { - console.error("usage: bun tools/build.ts [--plan=] [--framework=solid|vue-vapor|octane] [--extra-chars=...] [--density=N] [--hz=N]"); + console.error("usage: bun tools/build.ts [--plan=] [--network-factory] [--framework=solid|vue-vapor|octane] [--extra-chars=...] [--density=N] [--hz=N]"); process.exit(1); } @@ -164,6 +189,139 @@ function resolveEntry(arg: string): string { } const requestedEntry = resolveEntry(appArg); +if ( + testOnlyStagedHttpClientFetchRequested && + testOnlyStagedHttpsClientFetchRequested +) { + throw new Error("PocketJS build: test-only staged network permits are mutually exclusive"); +} +if (testOnlyStagedHttpClientFetchRequested) { + const expectedEntry = join( + ROOT, + "hosts/esp-idf/components/pocketjs_net_formal_smoke_artifact/app.ts", + ); + const features = buildPlan?.features ?? {}; + const enabledFeatures = Object.entries(features) + .filter(([, enabled]) => enabled) + .map(([name]) => name) + .sort(); + const providers = buildPlan?.network?.providers; + const backendRoles = Object.keys(providers?.backendByRole ?? {}).sort(); + const tlsRoles = Object.keys(providers?.tlsByRole ?? {}).sort(); + if ( + !networkFactoryRequested || + bundleArtifactMode !== "network-factory" || + networkPrivate === undefined || + buildPlan?.target.id !== "esp-formal-network-smoke-test" || + buildPlan.target.hostAbi !== 1 || + buildPlan.planHash !== + "sha256:04856acc82e7aa31648b015e62a63a4cadf6f48a3d1d3f46f3987539520b63fd" || + buildPlan.app.id !== "dev.pocketjs.esp-formal-network-smoke" || + buildPlan.app.output !== "esp-formal-network-smoke" || + requestedEntry !== expectedEntry || + enabledFeatures.length !== 1 || + enabledFeatures[0] !== "network.http.client" || + backendRoles.length !== 1 || + backendRoles[0] !== "http.client" || + providers?.backendByRole["http.client"] !== + "pocketjs.net.http-client-core.v1.experimental" || + tlsRoles.length !== 0 || + providers?.netDriverId !== "pocketjs.net.esp-idf.transport.v1.experimental" + ) { + throw new Error( + "PocketJS build: --test-only-staged-http-client-fetch is restricted to " + + "the exact ESP formal network smoke plan and entry", + ); + } + networkPrivate = Object.freeze({ + ...networkPrivate, + testOnlyStagedHttpClientFetch: true as const, + }); + console.warn( + "PocketJS build: TEST ONLY staged HTTP client fetch permit; " + + "public capability admission remains closed", + ); +} +if (testOnlyStagedHttpsClientFetchRequested) { + const exactArtifacts = [ + { + targetId: "esp-formal-network-tls-smoke-test", + planHash: + "sha256:9240cfa29c1678b49b6fed67104a39b2ad32f5dedab372af1c2a0bde3d602654", + appId: "dev.pocketjs.esp-formal-network-tls-smoke", + output: "esp-formal-network-tls-smoke", + entry: join( + ROOT, + "hosts/esp-idf/components/pocketjs_net_formal_tls_smoke_artifact/app.ts", + ), + }, + { + targetId: "esp-formal-network-tls-conformance-test", + planHash: + "sha256:fe3014e4d3628eb60aaeedd414432eb8c9a5932e904b258a9d05a17c7f6abcce", + appId: "dev.pocketjs.esp-formal-network-tls-conformance", + output: "esp-formal-network-tls-conformance", + entry: join( + ROOT, + "hosts/esp-idf/components/pocketjs_net_formal_tls_conformance_artifact/app.ts", + ), + }, + ] as const; + const exactArtifact = exactArtifacts.some((artifact) => + buildPlan?.target.id === artifact.targetId && + buildPlan.planHash === artifact.planHash && + buildPlan.app.id === artifact.appId && + buildPlan.app.output === artifact.output && + requestedEntry === artifact.entry + ); + const features = buildPlan?.features ?? {}; + const enabledFeatures = Object.entries(features) + .filter(([, enabled]) => enabled) + .map(([name]) => name) + .sort(); + const providers = buildPlan?.network?.providers; + const backendRoles = Object.keys(providers?.backendByRole ?? {}).sort(); + const tlsRoles = Object.keys(providers?.tlsByRole ?? {}).sort(); + const tlsProvider = providers?.tlsByRole["http.client"]; + const connect = buildPlan?.network?.policy.connect ?? []; + if ( + !networkFactoryRequested || + bundleArtifactMode !== "network-factory" || + networkPrivate === undefined || + !exactArtifact || + buildPlan?.target.hostAbi !== 1 || + enabledFeatures.length !== 2 || + enabledFeatures[0] !== "network.http.client" || + enabledFeatures[1] !== "network.http.client.tls" || + backendRoles.length !== 1 || + backendRoles[0] !== "http.client" || + providers?.backendByRole["http.client"] !== + "pocketjs.net.http-client-core.v1.experimental" || + tlsRoles.length !== 1 || + tlsRoles[0] !== "http.client" || + tlsProvider?.source !== "provider" || + tlsProvider.id !== "pocketjs.net.esp-idf.esp-tls.v1.experimental" || + providers?.netDriverId !== "pocketjs.net.esp-idf.transport.v1.experimental" || + connect.length !== 1 || + connect[0]?.protocol !== "https" || + connect[0]?.host !== "pocketjs.test" || + connect[0]?.port.min !== 8443 || + connect[0]?.port.max !== 8443 + ) { + throw new Error( + "PocketJS build: --test-only-staged-https-client-fetch is restricted to " + + "the exact ESP formal TLS test plans and entries", + ); + } + networkPrivate = Object.freeze({ + ...networkPrivate, + testOnlyStagedHttpsClientFetch: true as const, + }); + console.warn( + "PocketJS build: TEST ONLY staged HTTPS client fetch permit; " + + "public capability admission remains closed", + ); +} // An app directory can carry its own pocket.config.ts (theme/keyframes local // to the app); it wins over the repo root config unless --config was given. if (!configFlagged && useConfig) { @@ -268,7 +426,10 @@ async function walk(file: string): Promise { if (file.endsWith("/styles.generated.ts")) return; const src = await Bun.file(file).text(); // Throws with a code frame on lint errors. - const res = await transformFile(file, src, framework, { features: buildPlan?.features }); + const res = await transformFile(file, src, framework, { + features: buildPlan?.features, + networkPrivate, + }); for (const s of res.classStrings) { if (!seenClass.has(s)) { seenClass.add(s); @@ -466,7 +627,7 @@ if (!existsSync(frameworkConfig.rendererPath)) { // particular, external apps and renderer-solid.ts must share PocketJS's // browser-mode Solid runtime even when the app has its own node_modules. const result = await Bun.build({ - entrypoints: [entry], + entrypoints: [networkPrivate?.bootstrapSpecifier ?? entry], outdir: DIST, naming: `${outName}.js`, format: "iife", @@ -494,6 +655,7 @@ const result = await Bun.build({ entry, features: buildPlan?.features, generatedStyles, + networkPrivate, })], }); if (!result.success) { @@ -502,7 +664,22 @@ if (!result.success) { process.exit(1); } const bundle = result.outputs.find((o) => o.path.endsWith(".js")); -console.log(` pass 2: ${DIST}${outName}.js (${bundle ? (await bundle.arrayBuffer()).byteLength : 0} bytes)`); +let bundleBytes = 0; +if (bundle) { + const bundledSource = await bundle.text(); + const artifactSource = finalizeBundleArtifact( + bundledSource, + bundleArtifactMode, + networkPrivate, + ); + if (artifactSource !== bundledSource) { + await Bun.write(bundle.path, artifactSource); + } + bundleBytes = new TextEncoder().encode(artifactSource).byteLength; +} +console.log( + ` pass 2: ${DIST}${outName}.js (${bundleBytes} bytes, artifact=${bundleArtifactMode})`, +); console.log("PocketJS build: done"); // --------------------------------------------------------------------------- diff --git a/tools/generate-url-idna-table.ts b/tools/generate-url-idna-table.ts new file mode 100644 index 00000000..b9ccd1eb --- /dev/null +++ b/tools/generate-url-idna-table.ts @@ -0,0 +1,131 @@ +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +type MappingRange = number | readonly [number, number]; +type MappingRow = readonly [MappingRange, number, string?]; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const SOURCE_DIRECTORY = resolve(ROOT, "tools/vendor/tr46-6.0.0"); +const OUTPUT_DIRECTORY = resolve(ROOT, "framework/src/net/vendor/url"); +const MAPPING_SOURCE = resolve(SOURCE_DIRECTORY, "mappingTable.json"); +const REGEX_SOURCE = resolve(SOURCE_DIRECTORY, "regexes.js"); +const DATA_OUTPUT = resolve(OUTPUT_DIRECTORY, "idna-data.generated.ts"); +const REGEX_OUTPUT = resolve(OUTPUT_DIRECTORY, "idna-regexes.generated.ts"); + +const UPSTREAM_MAPPING_SHA256 = + "c45bd284e01f0845bc3c3b1d7594cd7b9ee8b955ddc850882b8e1dc5d0cba95d"; +const UPSTREAM_REGEX_SHA256 = + "3f43551d5109e5a300b29d5a9c261adf2bfd1621ffc086909f8ebf40835fc70f"; + +function withoutOneTrailingNewline(source: string): string { + return source.endsWith("\n") ? source.slice(0, -1) : source; +} + +function sha256(source: string): string { + return createHash("sha256").update(source).digest("hex"); +} + +function assertPinnedSource(source: string, expected: string, label: string): void { + const raw = sha256(source); + const withoutNewline = sha256(withoutOneTrailingNewline(source)); + if (raw !== expected && withoutNewline !== expected) { + throw new Error(`${label} does not match pinned tr46 6.0.0 source: ${raw}`); + } +} + +function wrapStringLiteral(value: string, width = 100): string { + const chunks: string[] = []; + for (let offset = 0; offset < value.length;) { + let end = Math.min(offset + width, value.length); + const last = value.charCodeAt(end - 1); + if (last >= 0xd800 && last <= 0xdbff) end--; + chunks.push(` ${JSON.stringify(value.slice(offset, end))}`); + offset = end; + } + return chunks.join(" +\n"); +} + +export function renderTr46Data(source: string): string { + assertPinnedSource(source, UPSTREAM_MAPPING_SHA256, "mappingTable.json"); + const rows = JSON.parse(source) as MappingRow[]; + const records = new Uint8Array(rows.length * 6); + let expectedStart = 0; + let mappingBlob = ""; + + for (let index = 0; index < rows.length; index++) { + const [range, status, mapping = ""] = rows[index]!; + const start = Array.isArray(range) ? range[0]! : range as number; + const end = Array.isArray(range) ? range[1]! : range as number; + if (start !== expectedStart || end < start || end > 0x10ffff) { + throw new Error(`mapping row ${index} is not a contiguous Unicode range`); + } + if (![1, 2, 3, 6, 7].includes(status)) { + throw new Error(`mapping row ${index} has unknown status ${status}`); + } + if (mapping.length > 31 || mappingBlob.length > 0xffff) { + throw new Error(`mapping row ${index} exceeds the packed format`); + } + + const offset = index * 6; + records[offset] = end & 0xff; + records[offset + 1] = (end >>> 8) & 0xff; + records[offset + 2] = (end >>> 16) & 0xff; + records[offset + 3] = mappingBlob.length & 0xff; + records[offset + 4] = mappingBlob.length >>> 8; + records[offset + 5] = (mapping.length << 3) | status; + mappingBlob += mapping; + expectedStart = end + 1; + } + + if (expectedStart !== 0x110000) { + throw new Error("mapping table does not cover every Unicode scalar value"); + } + + const encodedRecords = Buffer.from(records).toString("base64"); + return `// Generated by tools/generate-url-idna-table.ts. Do not edit.\n` + + `// tr46 6.0.0 / Unicode 17.0.0; source and license: tools/vendor/tr46-6.0.0.\n` + + `export const TR46_TABLE_ENTRY_COUNT = ${rows.length};\n` + + `export const TR46_TABLE_RECORDS =\n${wrapStringLiteral(encodedRecords)};\n` + + `export const TR46_MAPPING_BLOB =\n${wrapStringLiteral(mappingBlob)};\n`; +} + +export function renderTr46Regexes(source: string): string { + assertPinnedSource(source, UPSTREAM_REGEX_SHA256, "regexes.js"); + const body = withoutOneTrailingNewline(source) + .replace(/^"use strict";\n\n/u, "") + .replace(/module\.exports = \{([\s\S]*?)\n \};$/u, "export {$1\n};"); + if (!body.endsWith("};") || body.includes("module.exports")) { + throw new Error("tr46 regex source shape changed"); + } + return "// @ts-nocheck -- generated from pinned tr46 source.\n" + + "// Generated by tools/generate-url-idna-table.ts. Do not edit.\n" + body + "\n"; +} + +function update(path: string, expected: string, check: boolean): boolean { + let actual = ""; + try { + actual = readFileSync(path, "utf8"); + } catch { + // A missing generated file is ordinary drift. + } + if (actual === expected) return true; + if (check) return false; + writeFileSync(path, expected); + return true; +} + +if (import.meta.main) { + const check = process.argv.includes("--check"); + const mappingSource = readFileSync(MAPPING_SOURCE, "utf8"); + const regexSource = readFileSync(REGEX_SOURCE, "utf8"); + const clean = [ + update(DATA_OUTPUT, renderTr46Data(mappingSource), check), + update(REGEX_OUTPUT, renderTr46Regexes(regexSource), check), + ].every(Boolean); + if (!clean) { + console.error("generated URL IDNA data is stale; run bun tools/generate-url-idna-table.ts"); + process.exitCode = 1; + } +} diff --git a/tools/net-conformance-peer/.gitignore b/tools/net-conformance-peer/.gitignore new file mode 100644 index 00000000..1c42e22a --- /dev/null +++ b/tools/net-conformance-peer/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.py[cod] +.pki/ +*.key.pem diff --git a/tools/net-conformance-peer/README.md b/tools/net-conformance-peer/README.md new file mode 100644 index 00000000..d3adf544 --- /dev/null +++ b/tools/net-conformance-peer/README.md @@ -0,0 +1,544 @@ +# Independent Mac HTTP and TLS peer + +This tool provides a Python socket implementation for PocketJS HTTP/1.1 LAN +tests over plaintext or TLS. It imports no PocketJS runtime or network backend +code. Its output is NDJSON. Request events contain header names and byte counts +but no header values or bodies. TLS events contain the negotiated version, +cipher name, received SNI hostname, and handshake error class. + +The peer is a development test fixture. It has no client or application +authentication and must not be exposed to an untrusted network. + +## Plaintext ESP client to Mac server + +Find the Mac address on the Wi-Fi interface and start the peer on the LAN: + +```sh +route -n get default | rg interface +ipconfig getifaddr en1 # replace en1 with the interface printed above +python3 tools/net-conformance-peer/http_peer.py serve \ + --host 0.0.0.0 \ + --port 8088 \ + --events /tmp/pocketjs-http-peer.ndjson +``` + +Use the printed Mac IPv4 address and port as the ESP application's HTTP peer. +macOS may request permission for Python to accept incoming connections. The +default bind address is `127.0.0.1`; LAN exposure therefore requires the +explicit `--host 0.0.0.0` option. + +## Generate local TLS profiles + +The generator uses OpenSSL to create a disposable CA and six server profiles. +Add the Mac LAN address to the valid certificate's IP subjectAltName when an ESP +connects by address: + +```sh +PEER_DIR=tools/net-conformance-peer +MAC_IPV4="$(ipconfig getifaddr en1)" # use the active interface +python3 "$PEER_DIR/generate_test_pki.py" --san-ip "$MAC_IPV4" +``` + +The default SANs are `pocketjs.test`, `localhost`, `127.0.0.1`, and `::1`. +Repeated `--san-dns` and `--san-ip` arguments add names and addresses. Run the +generator again with `--force` after the Mac address changes. + +`pocketjs.test` uses the reserved `.test` suffix. A hardware SNI test must map +that name through its controlled DNS fixture; PocketJS v1 does not treat +`.local` as ordinary DNS or enable mDNS implicitly. + +**All private keys are generated under the ignored `.pki/` directory with mode +`0600`. They are test-only assets and must not be committed or installed as +production trust anchors.** The generator replaces only its known files when +`--force` is present. + +Create a fresh private evidence directory as the normal desktop user. Do not +run either peer with `sudo`: + +```sh +export POCKETJS_PEER_RUN_DIR="$(mktemp -d "${TMPDIR:-/tmp}/pocketjs-peer.XXXXXX")" +chmod 700 "$POCKETJS_PEER_RUN_DIR" +echo "$POCKETJS_PEER_RUN_DIR" # copy this path into the other peer terminals +``` + +Start the valid TLS peer and lock both ends of the version range to TLS 1.2: + +```sh +python3 "$PEER_DIR/http_peer.py" serve \ + --host 0.0.0.0 \ + --port 8443 \ + --tls-cert "$PEER_DIR/.pki/server.cert.pem" \ + --tls-key "$PEER_DIR/.pki/server.key.pem" \ + --tls-min-version 1.2 \ + --tls-max-version 1.2 \ + --socket-timeout-ms 60000 \ + --observe-tls-close-notify \ + --events "$POCKETJS_PEER_RUN_DIR/tls.ndjson" +``` + +**The command above accepts only TLS 1.2.** Its 60-second peer socket timeout is +longer than the smoke client's 30-second total request deadline, so packet loss +cannot make the peer close a reusable connection before the client selects its +own timeout outcome. Without `--tls-max-version`, the server accepts TLS 1.2 +and newer by default. Use `--tls-min-version 1.3` for a version-rejection test. +Configure the ESP client with `https://MAC_IPV4:8443` and the generated +`ca.cert.pem` trust anchor. The certificate must contain the exact DNS name or +IP address used by the URL. + +With `--observe-tls-close-notify`, each TLS `connection_close` event records +`tls_close_state` as `close_notify`, `ragged_eof`, or `not_observed` and includes +the corresponding `tls_close_notify_observed` boolean. **A Phase 1B graceful +shutdown pass requires `close_notify`; a TCP EOF alone is not sufficient.** + +`--tls-handshake-delay-ms N` waits for the bounded interval before the server +processes ClientHello. It requires TLS and cannot exceed +`--delay-ceiling-ms`. Use it with a client-side connect deadline or +AbortSignal to prove that a stalled handshake terminates without opening HTTP +or falling back to plaintext. + +The HTTPS probe exercises the same verified path from a Mac client. It has no +insecure mode: + +```sh +python3 "$PEER_DIR/http_peer.py" probe \ + --base-url https://127.0.0.1:8443 \ + --ca-cert "$PEER_DIR/.pki/ca.cert.pem" \ + --rounds 2 \ + --health-contains '"status":"ok"' +``` + +The generated negative profiles are: + +| Profile | Certificate and key | Expected verification result when `ca.cert.pem` is trusted | +|---|---|---| +| Valid | `server.cert.pem`, `server.key.pem` | TLS succeeds for an exact configured SAN | +| Wrong hostname | `wrong-host.cert.pem`, `wrong-host.key.pem` | Reject because its only SAN is `wrong-host.invalid` | +| Unknown CA | `untrusted-server.cert.pem`, `untrusted-server.key.pem` | Reject because a separate untrusted CA signed it | +| Bad signature | `bad-signature-server.cert.pem`, `server.key.pem` | Reject the deliberately corrupted certificate signature | +| Expired | `expired-server.cert.pem`, `expired-server.key.pem` | Reject the fixed 2020 validity interval | +| Not yet valid | `not-yet-valid-server.cert.pem`, `not-yet-valid-server.key.pem` | Reject the fixed 2050 validity interval | + +Stop the valid peer, then substitute one profile's certificate and key in the +same `serve` command. For every negative profile, a verifying client must fail +the TLS handshake before an HTTP `request` event appears. The peer emits +`tls_client_hello` with the received SNI hostname or `null` when the client sends +no SNI, and emits `tls_handshake_error` when the peer observes the TLS alert. +The wrong-host, expired, not-yet-valid, and bad-signature profiles are signed by +the trusted test CA, so each isolates its stated verification decision. The +unknown-CA profile has matching SANs and isolates trust-chain rejection. + +## Controlled DNS for TLS hostname and SNI + +`dns_peer.py` is a bounded IPv4 authoritative fixture for the TLS test name. +It serves UDP and TCP on the same port and has no upstream resolver. The LAN +configuration used by the ESP test boards is: + +| Setting | Value | +|---|---| +| DNS server | `172.16.10.126:53` | +| Exact A record | `pocketjs.test. 30 IN A 172.16.10.126` | +| HTTPS URL and SNI | `https://pocketjs.test:8443` | +| Trust anchor | Host-fixed `.pki/ca.cert.pem` | + +Use the fresh `POCKETJS_PEER_RUN_DIR` created above in every peer terminal. +Start the fixture on the Mac Wi-Fi interface: + +```sh +PEER_DIR=tools/net-conformance-peer +python3 "$PEER_DIR/dns_peer.py" serve \ + --host 0.0.0.0 \ + --port 53 \ + --interface en1 \ + --allow-cidr 172.16.10.0/24 \ + --name pocketjs.test \ + --address 172.16.10.126 \ + --events "$POCKETJS_PEER_RUN_DIR/dns.ndjson" +``` + +On Darwin, `--interface en1` applies `IP_BOUND_IF` to both sockets before the +wildcard bind. On platforms that cannot enforce the requested interface, the +fixture fails startup. The source CIDR is checked again for every UDP datagram +and TCP connection. Only RFC1918 and loopback subnets are accepted; public +CIDRs and ranges assembled from broad prefixes are rejected by both the CLI +and the server constructor. + +On the current Darwin test host, the exact wildcard-plus-`IP_BOUND_IF=en1` +command was verified at UID 501 on UDP and TCP port 53 without `sudo`. Direct +non-root binds to `127.0.0.1:53` and `172.16.10.126:53` returned `EACCES`; this +is why the local parser smoke below uses loopback port 1053. Do not remove the +interface constraint or add privilege based on the loopback result. + +**This fixture is not an open recursive resolver.** It sets `RA=0`, never sends +an upstream query, and silently drops sources outside the allowlist. The exact +`A/IN` question receives the one configured answer. Descendants of +`pocketjs.test` receive authoritative `NXDOMAIN`; unrelated names, other types, +and other classes receive `REFUSED`. A client may set `RD`, as normal stub +resolvers do, but it does not change those decisions. Messages without a usable +DNS header, responses sent to the server, and messages above 1232 bytes are +dropped. Other malformed requests receive `FORMERR` when a bounded reply is +possible. + +The fixed non-EDNS positive exchange is a 31-byte query and a 47-byte response, +so it is not described as zero amplification. Interface binding, private-source +allowlisting, the single fixed answer, and the absence of recursion prevent it +from serving as a public reflection endpoint. + +The parser accepts one structurally valid EDNS record, caps the advertised UDP +payload at 1232 bytes, does not echo options, and returns `BADVERS` for a +nonzero EDNS version. Compression in a query name, additional records other +than the single EDNS record, and incomplete UDP or TCP framing are rejected. + +The en1-constrained listener is intended to receive queries from the two ESP +boards. macOS routes its own `172.16.10.126` destination through `lo0`, so test +the parser locally with a separate loopback instance instead of removing the +interface constraint from the LAN listener. Start that instance in one terminal: + +```sh +python3 "$PEER_DIR/dns_peer.py" serve \ + --host 127.0.0.1 \ + --port 1053 \ + --allow-cidr 127.0.0.0/8 +``` + +Query both transports from another terminal: + +```sh +dig @127.0.0.1 -p 1053 pocketjs.test A +noall +comments +answer +dig @127.0.0.1 -p 1053 pocketjs.test A +tcp +noall +comments +answer +``` + +After DHCP and trusted-clock establishment, both ESP projects must set their +DNS server to `172.16.10.126`, retain `pocketjs.test` as the URL hostname, and +load `.pki/ca.cert.pem` as a Host-owned test trust anchor. The DNS answer must +not replace the URL hostname before ESP-TLS performs hostname verification and +constructs SNI. Use separate terminals for the two board projects: + +```sh +# AtomS3R; select the port whose USB serial is 98:88:E0:0F:34:A0. +idf.py -C "$ATOM_S3_PROJECT" -p "$ATOM_S3_PORT" flash monitor + +# Tab5; select the port whose USB serial is E8:F6:0A:E2:F6:46. +idf.py -C "$TAB5_P4_PROJECT" -p "$TAB5_P4_PORT" flash monitor +``` + +The fixture emits bounded metadata in `dns_ready`, `dns_query`, `dns_drop`, and +error records as NDJSON. Metadata includes the allowed source address and a +validated query name, but no raw packet or EDNS option data. It creates the +event file with mode `0600`, or appends only when an existing file is regular, +owned by the current user, and inaccessible to group and other users. Symbolic +links are refused. The private run directory also protects the HTTP peer's +event path. Do not substitute a predictable shared `/tmp/*.ndjson` path. + +Run one board at a time and create a new private run directory before each +acceptance run. Treat the `dns_ready` and `peer_ready` records as the beginning +of that run; do not append evidence to a directory from an earlier run. Let +`BOARD_IPV4` be the DHCP address of the board under test. A positive result +requires this ordered evidence within that run's monotonic time window: + +1. `dns_query` has `outcome=answer`, `query_name=pocketjs.test`, and + `peer_ipv4=BOARD_IPV4`. +2. A later `tls_client_hello` has `server_name=pocketjs.test` and the same + `peer_ipv4`. +3. The next matching `connection_open` has the same `peer_ipv4`, + `tls_server_name=pocketjs.test`, and `tls_version=TLSv1.2`. +4. The expected `/health` and `/echo` request records use that + `connection_id`. + +Separate DNS and TLS records without the same board address and ordering are +not acceptance evidence. Do not use `.local` for this test: it invokes mDNS +rather than the ordinary DNS path. Do not install a packet-filter redirect or +expose this fixture beyond the controlled LAN. + +The success paths are: + +| Request | Response contract | +|---|---| +| `GET /health` | HTTP 200, bounded JSON body, explicit `Content-Length` | +| `POST /echo` | HTTP 200, exact binary request body, explicit response `Content-Length` | +| `GET /chunked?fragment_ms=10` | Three chunks followed by a valid non-framing trailer | +| `GET /status/404` or `/status/500` | Exact 4xx/5xx status without transport failure | +| `GET /redirect?status=302&to=/health` | One visible redirect response | + +The controlled failure paths are: + +| Request | Wire behavior | +|---|---| +| `GET /delay?phase=headers&ms=2000` | Delays before response headers | +| `GET /delay?phase=body&ms=2000` | Sends headers, then delays the fixed body | +| `GET /delay?phase=chunk&ms=2000` | Sends one chunk, then delays the final chunk | +| `GET /disconnect?phase=before_headers` | Closes without a response | +| `GET /disconnect?phase=mid_headers` | Closes in the header block | +| `GET /disconnect?phase=mid_body` | Closes before the declared body length | +| `GET /malformed/te-cl` | Sends both `Transfer-Encoding` and `Content-Length` | +| `GET /malformed/duplicate-content-length` | Sends two `Content-Length` fields | +| `GET /malformed/obs-fold` | Sends an obsolete folded header | +| `GET /malformed/te-duplicate` | Sends two `Transfer-Encoding: chunked` fields | +| `GET /malformed/te-combined` | Sends `gzip, chunked` transfer codings | +| `GET /malformed/te-unknown` | Sends an unsupported transfer coding | +| `GET /malformed/trailer-forbidden` | Puts `Content-Length` in a chunked trailer | +| `GET /malformed/chunk-size` | Sends a non-hexadecimal chunk size | + +`GET /retry-once?token=` closes the first attempt and returns +the attempt number on later attempts. `GET /attempts?token=` reports +the count. Together they detect hidden transport retries. Token storage is +bounded to 1024 unique values for one process lifetime. + +The expected PocketJS result for every malformed path is one +`http_protocol_error` terminal and a closed connection. Delay paths should be +run with a client timeout lower than `ms` and must produce one timeout terminal. +The retry-once counter must remain one after a single PocketJS request when +hidden retries are disabled. A redirect result depends on the resolved endpoint +permission and redirect policy; the peer itself never follows a redirect. + +`POST /echo` accepts either one valid `Content-Length` or one HTTP/1.1 +`Transfer-Encoding: chunked`. **Chunked request decoding is cumulative and +bounded by `--body-limit`; transfer-coding lists, chunk extensions, declared or +actual trailers, `Content-Length` conflicts, malformed framing, and oversized +bodies fail before a `request` event is emitted.** This strict subset is the +wire peer for the formal PocketJS streaming request-body path. + +## Mac client to ESP server + +The probe independently checks an ESP HTTP server's health endpoint, exact +binary echo, and persistent-connection behavior. An `https://` URL requires +normal system trust or an explicit `--ca-cert`; certificate and hostname +verification are always enabled: + +```sh +python3 tools/net-conformance-peer/http_peer.py probe \ + --base-url http://ESP_IPV4:8080 \ + --rounds 20 \ + --interval-ms 250 \ + --health-contains '"status":"ok"' +``` + +Each round emits `probe_round_pass`; the process exits with status 1 on the +first status, body, timeout, or connection failure. The probe intentionally +does not follow redirects and does not log response bodies. + +## Local verification + +```sh +openssl version +python3 -m unittest tools/net-conformance-peer/test_http_peer.py +python3 -m unittest tools/net-conformance-peer/test_dns_peer.py +``` + +The tests generate PKI in a temporary directory and cover plaintext behavior, +bounded chunked uploads and connection reuse, a TLS 1.2 health/echo connection, +SNI capture, unknown CA, bad signature, hostname mismatch, expired certificate, +and not-yet-valid certificate. The DNS tests cover UDP and TCP answers, source +allowlisting, no-recursion behavior, `NXDOMAIN`, `REFUSED`, malformed and +oversized messages, and EDNS bounds. The fixtures do not by themselves cover +permission decisions, abort races, target resource limits, long soak, or +hardware/runtime assertions. + +## Phase 1B HTTP/TLS conformance gate + +The conformance artifact runs 20 rounds and reports 40 Guest operations. Four +manual redirect hops produce **44 wire requests over 25 TLS 1.2 connections**. +The matrix covers `HEAD`, fixed and chunked response bodies, streamed chunked +uploads, 404 and 503 responses, manual redirects, malformed framing and +trailers, connection reuse, graceful `close_notify`, forced closes, and a +retry counter that detects hidden retries. It runs without frames and requires +balanced leases, completed shutdown, and zero runtime or Core poison. + +Capture one board at a time with fresh board, authoritative DNS, and TLS peer +logs. Stop both peers after the board reports success, then validate the three +files together: + +```sh +python3 tools/net-conformance-peer/phase1b_conformance.py \ + --board s3 \ + --board-log "$POCKETJS_PEER_RUN_DIR/board.log" \ + --dns-events "$POCKETJS_PEER_RUN_DIR/dns.ndjson" \ + --tls-events "$POCKETJS_PEER_RUN_DIR/tls.ndjson" \ + --board-ipv4 172.16.10.231 \ + --peer-ipv4 172.16.10.126 +``` + +Use `--board p4 --board-ipv4 172.16.10.145` for Tab5. Replace both addresses +with the addresses selected for that run. The validator pins the artifact, +plan, CA, provider, endpoint, request order, request bodies and headers, +connection segmentation, TLS version, SNI, DNS identity, and cross-file +monotonic ordering. The P4 profile also requires the C6 power-expander +read-modify-write and readback invariant. **A passing conformance run is test +evidence; it does not advertise the public network capability.** + +Run the deterministic evidence-parser regressions with: + +```sh +python3 -m unittest tools/net-conformance-peer/test_phase1b_conformance.py +``` + +## Phase 1B repeated-handshake soak gate + +The ESP board harnesses can repeat the complete 20-round factory with a fresh +Guest, runtime, transport, and TLS connection. **The prescribed ESP Phase 1B +soak duration is 15 minutes per board.** Set the board project's +`CONFIG_POCKET_NET_FORMAL_REPEAT_INTERVAL_MS=1000`, select its success mode, +and capture one board at a time into a fresh private evidence directory. Keep +the authoritative DNS fixture and TLS 1.2 peer running for the complete window. + +After at least 15 minutes, stop the monitor and peers, then validate the board, +DNS, and TLS logs together: + +```sh +python3 tools/net-conformance-peer/phase1b_soak.py \ + --board s3 \ + --board-log "$POCKETJS_PEER_RUN_DIR/board.log" \ + --dns-events "$POCKETJS_PEER_RUN_DIR/dns.ndjson" \ + --tls-events "$POCKETJS_PEER_RUN_DIR/tls.ndjson" \ + --board-ipv4 172.16.10.231 \ + --peer-ipv4 172.16.10.126 +``` + +Use `--board p4 --board-ipv4 172.16.10.145` for Tab5. Set `--peer-ipv4` to the +same numeric address advertised by the authoritative DNS fixture. **A USB +serial monitor can reset either board when it opens the port.** For a one-run +capture, leave the newly flashed target in the bootloader (`--after no-reset`), +start fresh peers, and let opening the monitor cause the only application boot. +Starting the peers before a normal post-flash boot and then opening a resetting +monitor produces two ClientHellos and is intentionally rejected as ambiguous +evidence. + +The gate requires at least two complete runs and 900 seconds of wire evidence. +Every run must use a new connection, carry exactly 40 health/streamed-echo +requests over TLS 1.2, send SNI `pocketjs.test`, end with `close_notify`, +complete shutdown, balance all leases, retain zero poison, contain no +task-watchdog report, and record at least one cooperative Guest-owner yield. It +also enforces the measured target envelope of at most 64 KiB transient +internal-RAM high water and 4 MiB PSRAM high water, then permits no more than 16 +KiB internal and 4 KiB PSRAM steady drift after the first run. These are +hardware-smoke limits, not a claim that ESP-TLS's native allocator is +caller-owned or generally byte-bounded. + +Run its deterministic parser/resource regressions with: + +```sh +python3 -m unittest tools/net-conformance-peer/test_phase1b_soak.py +``` + +## Phase 1B TLS version-rejection gate + +Select the board harness's `version_reject` profile and run the TLS peer with +both `--tls-min-version 1.3` and `--tls-max-version 1.3`. The ESP profile is +fixed to TLS 1.2, so the attempt must fail before an opened TLS connection or +HTTP request. Capture one boot into fresh board, DNS, and peer logs, stop both +peers, then validate the three evidence streams together: + +```sh +python3 tools/net-conformance-peer/phase1b_version_reject.py \ + --board s3 \ + --board-log "$POCKETJS_PEER_RUN_DIR/board.log" \ + --dns-events "$POCKETJS_PEER_RUN_DIR/dns.ndjson" \ + --tls-events "$POCKETJS_PEER_RUN_DIR/tls.ndjson" \ + --board-ipv4 172.16.10.231 \ + --peer-ipv4 172.16.10.126 +``` + +Use `--board p4 --board-ipv4 172.16.10.145` for Tab5. The gate requires one +controlled DNS answer and exactly one TLS handshake failure from the selected +board, zero `connection_open` and HTTP `request` events, a board-side +`tls_alert`, cooperative Guest-owner yielding, balanced leases, zero poison, +and completed shutdown. ESP-IDF 6.0.2 reports this incompatible-version alert +at the current provider's conservative error granularity; the test does not +claim distinct TLS alert or certificate reasons. + +Run the deterministic evidence-parser regressions with: + +```sh +python3 -m unittest tools/net-conformance-peer/test_phase1b_version_reject.py +``` + +## Phase 1B certificate-rejection gate + +Run one board and one certificate profile at a time in a fresh private evidence +directory. Lock the TLS peer to 1.2 and keep `--observe-tls-close-notify` set, +even though the rejected handshake must never reach an opened connection. The +peer records the public certificate's DER SHA-256 in `peer_ready`, so the +validator can prove which fixture produced the wire evidence. + +Use the board harness's `hostname_reject` mode with +`wrong-host.cert.pem`/`wrong-host.key.pem`. Use `certificate_reject` with one of +`untrusted-server`, `expired-server`, `not-yet-valid-server`, or +`bad-signature-server`. After the board reports its negative pass, stop the +monitor and both peers and validate all three streams together: + +```sh +python3 tools/net-conformance-peer/phase1b_certificate_reject.py \ + --board s3 \ + --profile hostname-mismatch \ + --board-log "$POCKETJS_PEER_RUN_DIR/board.log" \ + --dns-events "$POCKETJS_PEER_RUN_DIR/dns.ndjson" \ + --tls-events "$POCKETJS_PEER_RUN_DIR/tls.ndjson" \ + --board-ipv4 172.16.10.231 \ + --peer-ipv4 172.16.10.126 +``` + +The other profile names are `unknown-ca`, `expired`, `not-yet-valid`, and +`bad-signature`. Use `--board p4 --board-ipv4 172.16.10.145` for Tab5. The gate +requires the exact fixture fingerprint, one controlled DNS answer, one +ClientHello with SNI `pocketjs.test`, one failed handshake, zero opened TLS +connections or HTTP requests, cooperative Guest-owner yielding, balanced +leases, zero poison, and completed shutdown. Hostname mismatch must surface as +`tls_hostname_mismatch`; every other listed certificate failure must surface as +`tls_certificate_invalid`. + +Run the deterministic evidence-parser regressions with: + +```sh +python3 -m unittest tools/net-conformance-peer/test_phase1b_certificate_reject.py +``` + +## Phase 1B handshake timeout and cancellation gate + +Run the valid `server.cert.pem` profile with TLS fixed to 1.2 and a 10-second +server-side handshake delay: + +```sh +python3 tools/net-conformance-peer/http_peer.py serve \ + --host 172.16.10.126 \ + --port 8443 \ + --events "$POCKETJS_PEER_RUN_DIR/tls.ndjson" \ + --tls-cert tools/net-conformance-peer/.pki/server.cert.pem \ + --tls-key tools/net-conformance-peer/.pki/server.key.pem \ + --tls-min-version 1.2 \ + --tls-max-version 1.2 \ + --tls-handshake-delay-ms 10000 \ + --socket-timeout-ms 60000 \ + --observe-tls-close-notify +``` + +Select the board harness's `handshake_timeout` mode with a 2,000,000-us +connect/TLS deadline, or its `cancel` mode with a 500-ms AbortController delay. +Build that mode with the board project's explicit `interrupt` artifact profile; +the default `conformance` artifact intentionally has no cancellation entry +point. Use a fresh evidence directory for one board and one mode. Stop the +board monitor and both peers after the delayed server reports its handshake +error, then validate all three streams: + +```sh +python3 tools/net-conformance-peer/phase1b_handshake_interrupt.py \ + --board s3 \ + --profile timeout \ + --board-log "$POCKETJS_PEER_RUN_DIR/board.log" \ + --dns-events "$POCKETJS_PEER_RUN_DIR/dns.ndjson" \ + --tls-events "$POCKETJS_PEER_RUN_DIR/tls.ndjson" \ + --board-ipv4 172.16.10.231 \ + --peer-ipv4 172.16.10.126 +``` + +Use `--profile cancel` for the cancellation run and `--board p4 +--board-ipv4 172.16.10.145` for Tab5. The gate pins the dedicated formal TLS +smoke artifact, valid certificate fingerprint, DNS identity, SNI, TLS version, +server delay, client deadline or cancellation delay, and the board's stable +error. The board must finish before the delayed server handshake, expose +`timed_out` or `aborted`, produce zero opened TLS connections and HTTP requests, +balance all leases, retain zero poison, and complete shutdown. + +Run the deterministic evidence-parser regressions with: + +```sh +python3 -m unittest tools/net-conformance-peer/test_phase1b_handshake_interrupt.py +``` diff --git a/tools/net-conformance-peer/dns_peer.py b/tools/net-conformance-peer/dns_peer.py new file mode 100644 index 00000000..db13ac31 --- /dev/null +++ b/tools/net-conformance-peer/dns_peer.py @@ -0,0 +1,894 @@ +#!/usr/bin/env python3 +"""Bounded authoritative DNS fixture for PocketJS LAN TLS tests.""" + +from __future__ import annotations + +import argparse +import errno +import ipaddress +import json +import os +import socket +import stat +import struct +import sys +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import BinaryIO, Callable + + +DNS_HEADER_BYTES = 12 +MAX_DNS_MESSAGE_BYTES = 1232 +MAX_TCP_BACKLOG = 8 +MAX_TTL_SECONDS = (1 << 31) - 1 +MAX_SOCKET_TIMEOUT_MS = 10_000 +TYPE_A = 1 +TYPE_OPT = 41 +CLASS_IN = 1 +RCODE_NOERROR = 0 +RCODE_FORMERR = 1 +RCODE_NXDOMAIN = 3 +RCODE_NOTIMP = 4 +RCODE_REFUSED = 5 +RCODE_BADVERS = 16 +FLAG_QR = 0x8000 +FLAG_OPCODE = 0x7800 +FLAG_AA = 0x0400 +FLAG_TC = 0x0200 +FLAG_RD = 0x0100 +FLAG_RA = 0x0080 +FLAG_RESERVED_Z = 0x0040 +EDNS_DO = 0x8000 +DARWIN_IP_BOUND_IF = 25 +CONTROLLED_SOURCE_SUPERNETS = ( + ipaddress.IPv4Network("10.0.0.0/8"), + ipaddress.IPv4Network("172.16.0.0/12"), + ipaddress.IPv4Network("192.168.0.0/16"), + ipaddress.IPv4Network("127.0.0.0/8"), +) + + +class DnsParseError(Exception): + def __init__(self, reason: str, *, respond: bool = True) -> None: + super().__init__(reason) + self.reason = reason + self.respond = respond + + +@dataclass(frozen=True) +class EdnsRequest: + udp_payload_bytes: int + version: int + dnssec_ok: bool + option_count: int + + +@dataclass(frozen=True) +class DnsQuery: + transaction_id: int + flags: int + opcode: int + name: str + query_type: int + query_class: int + question_wire: bytes + edns: EdnsRequest | None + + @property + def recursion_desired(self) -> bool: + return bool(self.flags & FLAG_RD) + + +@dataclass(frozen=True) +class DnsDecision: + outcome: str + rcode: int + authoritative: bool + include_answer: bool = False + + +class EventSink: + def __init__(self, path: Path | None, quiet: bool) -> None: + self._lock = threading.Lock() + self._quiet = quiet + self._file: BinaryIO | None = ( + open_private_event_file(path) if path is not None else None + ) + + def emit(self, event: str, **fields: object) -> None: + record = { + "event": event, + "monotonic_ns": time.monotonic_ns(), + **fields, + } + encoded = ( + json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n" + ).encode() + with self._lock: + if not self._quiet: + sys.stdout.buffer.write(encoded) + sys.stdout.buffer.flush() + if self._file is not None: + self._file.write(encoded) + self._file.flush() + + def close(self) -> None: + with self._lock: + if self._file is not None: + self._file.close() + self._file = None + + +def open_private_event_file(path: Path) -> BinaryIO: + no_follow = getattr(os, "O_NOFOLLOW", None) + if no_follow is None: + raise OSError( + errno.ENOTSUP, + "secure event files require O_NOFOLLOW on this platform", + ) + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND | no_follow + flags |= getattr(os, "O_CLOEXEC", 0) + flags |= getattr(os, "O_NONBLOCK", 0) + descriptor = os.open(path, flags, 0o600) + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode): + raise OSError(errno.EINVAL, "DNS event path is not a regular file") + if hasattr(os, "geteuid") and metadata.st_uid != os.geteuid(): + raise PermissionError("DNS event file is not owned by the current user") + if stat.S_IMODE(metadata.st_mode) & 0o077: + raise PermissionError("DNS event file grants group or other permissions") + return os.fdopen(descriptor, "ab") + except BaseException: + os.close(descriptor) + raise + + +def canonical_dns_name(value: str) -> str: + name = value.rstrip(".").lower() + if not name: + raise ValueError("DNS name must not be the root name") + try: + encoded = name.encode("ascii") + except UnicodeEncodeError as error: + raise ValueError("DNS name must already be an ASCII A-label") from error + if len(encoded) > 253: + raise ValueError("DNS name exceeds 253 bytes") + for label in encoded.split(b"."): + validate_hostname_label(label) + return name + + +def validate_hostname_label(label: bytes) -> None: + if not 1 <= len(label) <= 63: + raise ValueError("DNS labels must contain 1 to 63 bytes") + if not is_ascii_alphanumeric(label[0]) or not is_ascii_alphanumeric(label[-1]): + raise ValueError("DNS hostname labels must start and end with alphanumeric bytes") + if any(not is_ascii_alphanumeric(byte) and byte != ord("-") for byte in label): + raise ValueError("DNS hostname labels may contain only ASCII letters, digits, and '-'") + + +def is_ascii_alphanumeric(value: int) -> bool: + return ord("0") <= value <= ord("9") or ord("A") <= value <= ord("Z") or ord( + "a" + ) <= value <= ord("z") + + +def parse_qname(message: bytes, offset: int) -> tuple[str, int]: + labels: list[bytes] = [] + wire_bytes = 1 + while True: + if offset >= len(message): + raise DnsParseError("truncated_qname") + label_length = message[offset] + offset += 1 + if label_length == 0: + break + if label_length & 0xC0: + raise DnsParseError("compressed_qname_not_allowed") + if label_length > 63 or offset + label_length > len(message): + raise DnsParseError("invalid_qname_label") + label = message[offset : offset + label_length] + try: + validate_hostname_label(label) + except ValueError as error: + raise DnsParseError("invalid_hostname_label") from error + labels.append(label.lower()) + offset += label_length + wire_bytes += label_length + 1 + if wire_bytes > 255: + raise DnsParseError("qname_too_long") + return b".".join(labels).decode("ascii"), offset + + +def parse_edns(message: bytes, offset: int) -> tuple[EdnsRequest, int]: + if offset >= len(message) or message[offset] != 0: + raise DnsParseError("edns_owner_must_be_root") + offset += 1 + if offset + 10 > len(message): + raise DnsParseError("truncated_edns_record") + record_type, udp_payload, ttl, data_length = struct.unpack_from( + "!HHIH", message, offset + ) + offset += 10 + if record_type != TYPE_OPT: + raise DnsParseError("additional_record_must_be_opt") + if ttl >> 24: + raise DnsParseError("edns_query_extended_rcode_must_be_zero") + version = (ttl >> 16) & 0xFF + edns_flags = ttl & 0xFFFF + if edns_flags & ~EDNS_DO: + raise DnsParseError("edns_reserved_flags_nonzero") + end = offset + data_length + if end > len(message): + raise DnsParseError("truncated_edns_options") + option_count = 0 + while offset < end: + if offset + 4 > end: + raise DnsParseError("truncated_edns_option_header") + _, option_length = struct.unpack_from("!HH", message, offset) + offset += 4 + if offset + option_length > end: + raise DnsParseError("truncated_edns_option_data") + offset += option_length + option_count += 1 + return ( + EdnsRequest( + udp_payload_bytes=max(512, min(udp_payload, MAX_DNS_MESSAGE_BYTES)), + version=version, + dnssec_ok=bool(edns_flags & EDNS_DO), + option_count=option_count, + ), + offset, + ) + + +def parse_query(message: bytes) -> DnsQuery: + if len(message) < DNS_HEADER_BYTES: + raise DnsParseError("message_too_short", respond=False) + transaction_id, flags, question_count, answer_count, authority_count, additional_count = ( + struct.unpack_from("!HHHHHH", message) + ) + if flags & FLAG_QR: + raise DnsParseError("response_message_not_accepted", respond=False) + if flags & FLAG_TC: + raise DnsParseError("truncated_query_not_accepted") + if flags & FLAG_RESERVED_Z: + raise DnsParseError("reserved_header_flag_nonzero") + if flags & 0x000F: + raise DnsParseError("query_rcode_must_be_zero") + if question_count != 1: + raise DnsParseError("exactly_one_question_required") + if answer_count or authority_count or additional_count > 1: + raise DnsParseError("unexpected_query_sections") + + offset = DNS_HEADER_BYTES + name, offset = parse_qname(message, offset) + if offset + 4 > len(message): + raise DnsParseError("truncated_question") + query_type, query_class = struct.unpack_from("!HH", message, offset) + offset += 4 + question_wire = message[DNS_HEADER_BYTES:offset] + + edns = None + if additional_count: + edns, offset = parse_edns(message, offset) + if offset != len(message): + raise DnsParseError("trailing_query_bytes") + + return DnsQuery( + transaction_id=transaction_id, + flags=flags, + opcode=(flags & FLAG_OPCODE) >> 11, + name=name, + query_type=query_type, + query_class=query_class, + question_wire=question_wire, + edns=edns, + ) + + +def decide_query(query: DnsQuery, authoritative_name: str) -> DnsDecision: + if query.opcode != 0: + return DnsDecision("not_implemented", RCODE_NOTIMP, False) + if query.edns is not None and query.edns.version != 0: + return DnsDecision("bad_edns_version", RCODE_BADVERS, False) + if query.query_class != CLASS_IN: + return DnsDecision("refused_class", RCODE_REFUSED, False) + if query.name == authoritative_name: + if query.query_type == TYPE_A: + return DnsDecision("answer", RCODE_NOERROR, True, True) + return DnsDecision("refused_type", RCODE_REFUSED, False) + if query.name.endswith(f".{authoritative_name}"): + return DnsDecision("nxdomain", RCODE_NXDOMAIN, True) + return DnsDecision("refused_name", RCODE_REFUSED, False) + + +def build_response( + query: DnsQuery, + decision: DnsDecision, + address: ipaddress.IPv4Address, + ttl_seconds: int, +) -> bytes: + response_flags = FLAG_QR | (query.flags & (FLAG_OPCODE | FLAG_RD)) + if decision.authoritative: + response_flags |= FLAG_AA + response_flags |= decision.rcode & 0xF + include_edns = query.edns is not None + header = struct.pack( + "!HHHHHH", + query.transaction_id, + response_flags, + 1, + 1 if decision.include_answer else 0, + 0, + 1 if include_edns else 0, + ) + answer = b"" + if decision.include_answer: + answer = struct.pack( + "!HHHIH4s", + 0xC00C, + TYPE_A, + CLASS_IN, + ttl_seconds, + 4, + address.packed, + ) + opt = b"" + if query.edns is not None: + extended_rcode = decision.rcode >> 4 + opt_ttl = extended_rcode << 24 + opt = struct.pack( + "!BHHIH", + 0, + TYPE_OPT, + query.edns.udp_payload_bytes, + opt_ttl, + 0, + ) + response = header + query.question_wire + answer + opt + if len(response) > MAX_DNS_MESSAGE_BYTES: + raise RuntimeError("bounded DNS response exceeded the hard message limit") + return response + + +def build_format_error(message: bytes) -> bytes | None: + if len(message) < DNS_HEADER_BYTES: + return None + transaction_id, flags = struct.unpack_from("!HH", message) + if flags & FLAG_QR: + return None + response_flags = FLAG_QR | (flags & (FLAG_OPCODE | FLAG_RD)) | RCODE_FORMERR + return struct.pack("!HHHHHH", transaction_id, response_flags, 0, 0, 0, 0) + + +def parse_allow_cidr(value: str) -> ipaddress.IPv4Network: + try: + network = ipaddress.ip_network(value, strict=False) + except ValueError as error: + raise argparse.ArgumentTypeError(str(error)) from error + if not isinstance(network, ipaddress.IPv4Network): + raise argparse.ArgumentTypeError("only IPv4 allow CIDRs are supported") + try: + validate_controlled_source_network(network) + except ValueError as error: + raise argparse.ArgumentTypeError(str(error)) from error + return network + + +def validate_controlled_source_network(network: ipaddress.IPv4Network) -> None: + if not isinstance(network, ipaddress.IPv4Network): + raise ValueError("only IPv4 source CIDRs are supported") + if not any(network.subnet_of(supernet) for supernet in CONTROLLED_SOURCE_SUPERNETS): + raise ValueError( + f"source CIDR {network} is outside RFC1918 and loopback ranges" + ) + + +def parse_bind_host(value: str) -> str: + try: + return str(ipaddress.IPv4Address(value)) + except ipaddress.AddressValueError as error: + raise argparse.ArgumentTypeError( + "bind host must be an IPv4 address literal" + ) from error + + +def apply_interface_constraint(sock: socket.socket, interface: str | None) -> str: + if interface is None: + return "none" + interface_index = socket.if_nametoindex(interface) + if sys.platform == "darwin": + sock.setsockopt(socket.IPPROTO_IP, DARWIN_IP_BOUND_IF, interface_index) + return "darwin-ip-bound-if" + bind_to_device = getattr(socket, "SO_BINDTODEVICE", None) + if bind_to_device is not None: + sock.setsockopt(socket.SOL_SOCKET, bind_to_device, interface.encode() + b"\0") + return "so-bind-to-device" + raise OSError( + errno.ENOTSUP, + f"interface binding is unsupported on {sys.platform}; refusing to start", + ) + + +def make_socket( + socket_type: int, + bind_host: str, + port: int, + interface: str | None, +) -> tuple[socket.socket, str]: + sock = socket.socket(socket.AF_INET, socket_type) + try: + if socket_type == socket.SOCK_STREAM: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + interface_mode = apply_interface_constraint(sock, interface) + sock.bind((bind_host, port)) + return sock, interface_mode + except BaseException: + sock.close() + raise + + +class DnsPeerServer: + def __init__( + self, + address: tuple[str, int], + *, + authoritative_name: str, + authoritative_address: ipaddress.IPv4Address, + ttl_seconds: int, + allowed_networks: tuple[ipaddress.IPv4Network, ...], + interface: str | None, + socket_timeout_ms: int, + events: EventSink, + ) -> None: + if not allowed_networks: + raise ValueError("at least one source allow CIDR is required") + for network in allowed_networks: + validate_controlled_source_network(network) + if not 1 <= ttl_seconds <= MAX_TTL_SECONDS: + raise ValueError(f"DNS TTL must be between 1 and {MAX_TTL_SECONDS}") + if not 1 <= socket_timeout_ms <= MAX_SOCKET_TIMEOUT_MS: + raise ValueError( + f"DNS socket timeout must be between 1 and {MAX_SOCKET_TIMEOUT_MS} ms" + ) + self.authoritative_name = canonical_dns_name(authoritative_name) + self.authoritative_address = authoritative_address + self.ttl_seconds = ttl_seconds + self.allowed_networks = allowed_networks + self.interface = interface + self.socket_timeout = socket_timeout_ms / 1000 + self.events = events + self._stop = threading.Event() + self._close_lock = threading.Lock() + self._failure_lock = threading.Lock() + self._active_tcp_lock = threading.Lock() + self._closed = False + self._fatal_error: tuple[str, str] | None = None + self._active_tcp: socket.socket | None = None + + bind_address = ipaddress.IPv4Address(address[0]) + bind_host = str(bind_address) + requested_port = address[1] + if sys.platform == "darwin" and bind_address.is_unspecified and not interface: + raise ValueError( + "a Darwin wildcard bind requires --interface; use --interface en1 " + "for the controlled Wi-Fi fixture" + ) + self._tcp, interface_mode = make_socket( + socket.SOCK_STREAM, bind_host, requested_port, interface + ) + try: + self._tcp.listen(MAX_TCP_BACKLOG) + actual_port = int(self._tcp.getsockname()[1]) + self._udp, udp_interface_mode = make_socket( + socket.SOCK_DGRAM, bind_host, actual_port, interface + ) + except BaseException: + self._tcp.close() + raise + if interface_mode != udp_interface_mode: + self._tcp.close() + self._udp.close() + raise RuntimeError("UDP and TCP interface constraints differ") + self.interface_mode = interface_mode + self.server_address = (str(self._tcp.getsockname()[0]), actual_port) + self._tcp.settimeout(0.1) + self._udp.settimeout(0.1) + + def source_allowed(self, source_ipv4: str) -> bool: + address = ipaddress.IPv4Address(source_ipv4) + return any(address in network for network in self.allowed_networks) + + def serve_forever(self) -> None: + self.events.emit( + "dns_ready", + bind_host=self.server_address[0], + port=self.server_address[1], + interface=self.interface, + interface_mode=self.interface_mode, + allow_cidrs=[str(network) for network in self.allowed_networks], + authoritative_name=self.authoritative_name, + authoritative_ipv4=str(self.authoritative_address), + max_message_bytes=MAX_DNS_MESSAGE_BYTES, + recursion_available=False, + transports=["udp", "tcp"], + ) + workers = ( + threading.Thread( + target=self._run_worker, + args=("udp", self._udp_loop), + name="dns-peer-udp", + daemon=True, + ), + threading.Thread( + target=self._run_worker, + args=("tcp", self._tcp_loop), + name="dns-peer-tcp", + daemon=True, + ), + ) + started_workers: list[threading.Thread] = [] + try: + for worker in workers: + try: + worker.start() + except BaseException: + if worker.ident is not None: + started_workers.append(worker) + raise + else: + started_workers.append(worker) + self._stop.wait() + finally: + self._stop.set() + self.server_close() + for worker in started_workers: + worker.join(timeout=2) + alive_workers = [ + worker.name for worker in started_workers if worker.is_alive() + ] + if alive_workers: + raise RuntimeError( + f"DNS workers did not stop: {','.join(alive_workers)}" + ) + if self._fatal_error is not None: + transport, error_name = self._fatal_error + raise RuntimeError(f"DNS {transport} worker failed with {error_name}") + + def shutdown(self) -> None: + self._stop.set() + self.server_close() + + def server_close(self) -> None: + with self._close_lock: + if self._closed: + return + self._closed = True + self._tcp.close() + self._udp.close() + + with self._active_tcp_lock: + active_tcp = self._active_tcp + if active_tcp is not None: + try: + active_tcp.shutdown(socket.SHUT_RDWR) + except OSError: + pass + active_tcp.close() + + def _run_worker(self, transport: str, worker: Callable[[], None]) -> None: + try: + worker() + except BaseException as error: + if self._stop.is_set(): + return + self.record_fatal_error(transport, error) + + def record_fatal_error(self, transport: str, error: BaseException) -> None: + error_name = type(error).__name__ + with self._failure_lock: + if self._fatal_error is None: + self._fatal_error = (transport, error_name) + self._stop.set() + try: + self.events.emit( + "dns_internal_error", + transport=transport, + error=error_name, + ) + except BaseException: + pass + + def _udp_loop(self) -> None: + while not self._stop.is_set(): + try: + message, source = self._udp.recvfrom(MAX_DNS_MESSAGE_BYTES + 1) + except socket.timeout: + continue + except OSError as error: + if self._stop.is_set(): + return + raise error + response = self._handle_message(message, source[0], "udp") + if response is not None: + try: + self._udp.sendto(response, source) + except OSError as error: + self.events.emit( + "dns_send_error", + transport="udp", + peer_ipv4=source[0], + error=type(error).__name__, + ) + + def _tcp_loop(self) -> None: + while not self._stop.is_set(): + try: + connection, source = self._tcp.accept() + except socket.timeout: + continue + except OSError as error: + if self._stop.is_set(): + return + raise error + with self._active_tcp_lock: + if self._stop.is_set(): + connection.close() + return + self._active_tcp = connection + try: + with connection: + if not self.source_allowed(source[0]): + self.events.emit( + "dns_drop", + transport="tcp", + peer_ipv4=source[0], + reason="source_not_allowed", + ) + continue + try: + deadline = time.monotonic() + self.socket_timeout + length_bytes = receive_exact( + connection, 2, deadline, self._stop + ) + message_length = struct.unpack("!H", length_bytes)[0] + if not DNS_HEADER_BYTES <= message_length <= MAX_DNS_MESSAGE_BYTES: + self.events.emit( + "dns_drop", + transport="tcp", + peer_ipv4=source[0], + reason="invalid_message_length", + message_bytes=message_length, + ) + continue + message = receive_exact( + connection, message_length, deadline, self._stop + ) + except InterruptedError: + return + except (ConnectionError, socket.timeout): + self.events.emit( + "dns_drop", + transport="tcp", + peer_ipv4=source[0], + reason="incomplete_message", + ) + continue + response = self._handle_message(message, source[0], "tcp") + if response is not None: + try: + connection.sendall( + struct.pack("!H", len(response)) + response + ) + except OSError as error: + self.events.emit( + "dns_send_error", + transport="tcp", + peer_ipv4=source[0], + error=type(error).__name__, + ) + finally: + with self._active_tcp_lock: + if self._active_tcp is connection: + self._active_tcp = None + + def _handle_message( + self, message: bytes, source_ipv4: str, transport: str + ) -> bytes | None: + if not self.source_allowed(source_ipv4): + self.events.emit( + "dns_drop", + transport=transport, + peer_ipv4=source_ipv4, + reason="source_not_allowed", + message_bytes=len(message), + ) + return None + if len(message) > MAX_DNS_MESSAGE_BYTES: + self.events.emit( + "dns_drop", + transport=transport, + peer_ipv4=source_ipv4, + reason="message_too_large", + message_bytes=len(message), + ) + return None + try: + query = parse_query(message) + except DnsParseError as error: + response = build_format_error(message) if error.respond else None + self.events.emit( + "dns_query", + transport=transport, + peer_ipv4=source_ipv4, + message_bytes=len(message), + outcome="format_error" if response is not None else "dropped", + reason=error.reason, + rcode=RCODE_FORMERR if response is not None else None, + ) + return response + + decision = decide_query(query, self.authoritative_name) + response = build_response( + query, + decision, + self.authoritative_address, + self.ttl_seconds, + ) + self.events.emit( + "dns_query", + transport=transport, + peer_ipv4=source_ipv4, + message_bytes=len(message), + query_name=query.name, + query_type=query.query_type, + query_class=query.query_class, + recursion_desired=query.recursion_desired, + recursion_available=False, + edns_version=query.edns.version if query.edns is not None else None, + edns_option_count=( + query.edns.option_count if query.edns is not None else None + ), + outcome=decision.outcome, + rcode=decision.rcode, + answers=1 if decision.include_answer else 0, + ) + return response + + +def receive_exact( + connection: socket.socket, + byte_count: int, + deadline: float, + stop: threading.Event, +) -> bytes: + received = bytearray() + while len(received) < byte_count: + if stop.is_set(): + raise InterruptedError("DNS server is stopping") + remaining = deadline - time.monotonic() + if remaining <= 0: + raise socket.timeout("DNS TCP frame deadline expired") + connection.settimeout(min(0.1, remaining)) + try: + chunk = connection.recv(byte_count - len(received)) + except socket.timeout: + continue + if not chunk: + raise ConnectionError("DNS TCP connection closed before its framed message") + received.extend(chunk) + return bytes(received) + + +def positive_integer(value: str) -> int: + parsed = int(value) + if parsed <= 0: + raise argparse.ArgumentTypeError("must be greater than zero") + return parsed + + +def ttl_integer(value: str) -> int: + parsed = positive_integer(value) + if parsed > MAX_TTL_SECONDS: + raise argparse.ArgumentTypeError(f"must not exceed {MAX_TTL_SECONDS}") + return parsed + + +def socket_timeout_integer(value: str) -> int: + parsed = positive_integer(value) + if parsed > MAX_SOCKET_TIMEOUT_MS: + raise argparse.ArgumentTypeError(f"must not exceed {MAX_SOCKET_TIMEOUT_MS}") + return parsed + + +def nonnegative_integer(value: str) -> int: + parsed = int(value) + if parsed < 0: + raise argparse.ArgumentTypeError("must be zero or greater") + return parsed + + +def run_server(args: argparse.Namespace) -> int: + authoritative_address = ipaddress.IPv4Address(args.address) + event_path = Path(args.events).expanduser() if args.events else None + events = EventSink(event_path, args.quiet_events) + try: + server = DnsPeerServer( + (args.host, args.port), + authoritative_name=args.name, + authoritative_address=authoritative_address, + ttl_seconds=args.ttl, + allowed_networks=tuple(args.allow_cidr), + interface=args.interface, + socket_timeout_ms=args.socket_timeout_ms, + events=events, + ) + except BaseException: + events.close() + raise + try: + server.serve_forever() + except KeyboardInterrupt: + events.emit("dns_stop", reason="keyboard_interrupt") + finally: + server.shutdown() + events.close() + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + serve = commands.add_parser("serve", help="run the controlled authoritative DNS peer") + serve.add_argument("--host", type=parse_bind_host, default="0.0.0.0") + serve.add_argument("--port", type=nonnegative_integer, default=53) + serve.add_argument("--interface", help="restrict packets to this network interface") + serve.add_argument( + "--allow-cidr", + action="append", + required=True, + type=parse_allow_cidr, + help="allowed IPv4 source CIDR; repeat for another controlled subnet", + ) + serve.add_argument("--name", default="pocketjs.test") + serve.add_argument("--address", default="172.16.10.126") + serve.add_argument("--ttl", type=ttl_integer, default=30) + serve.add_argument( + "--socket-timeout-ms", type=socket_timeout_integer, default=1000 + ) + serve.add_argument( + "--events", help="append bounded-metadata NDJSON events to this path" + ) + serve.add_argument("--quiet-events", action="store_true") + serve.set_defaults(entrypoint=run_server) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.entrypoint(args) + except (OSError, RuntimeError, ValueError) as error: + print( + json.dumps( + { + "event": "dns_error", + "error": type(error).__name__, + "message": str(error), + }, + sort_keys=True, + separators=(",", ":"), + ), + file=sys.stderr, + flush=True, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/net-conformance-peer/generate_test_pki.py b/tools/net-conformance-peer/generate_test_pki.py new file mode 100644 index 00000000..ffdc807b --- /dev/null +++ b/tools/net-conformance-peer/generate_test_pki.py @@ -0,0 +1,503 @@ +#!/usr/bin/env python3 +"""Generate disposable PKI profiles for the independent PocketJS TLS peer.""" + +from __future__ import annotations + +import argparse +import ipaddress +import json +import os +import re +import subprocess +import tempfile +from pathlib import Path + + +DEFAULT_DNS_NAMES = ("pocketjs.test", "localhost") +DEFAULT_IP_ADDRESSES = ("127.0.0.1", "::1") +PRIVATE_KEY_NAMES = ( + "ca.key.pem", + "server.key.pem", + "wrong-host.key.pem", + "expired-server.key.pem", + "not-yet-valid-server.key.pem", + "untrusted-ca.key.pem", + "untrusted-server.key.pem", +) +OUTPUT_NAMES = ( + "ca.cert.pem", + *PRIVATE_KEY_NAMES, + "server.cert.pem", + "wrong-host.cert.pem", + "expired-server.cert.pem", + "not-yet-valid-server.cert.pem", + "untrusted-ca.cert.pem", + "untrusted-server.cert.pem", + "bad-signature-server.cert.pem", +) +DNS_NAME_PATTERN = re.compile( + r"(?=.{1,253}\Z)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*" + r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\Z" +) + + +class OpenSSLError(RuntimeError): + pass + + +def run_openssl( + executable: str, + *arguments: str | Path, + expected_success: bool = True, +) -> subprocess.CompletedProcess[str]: + command = [executable, *(str(argument) for argument in arguments)] + try: + result = subprocess.run( + command, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except FileNotFoundError as error: + raise OpenSSLError(f"OpenSSL executable not found: {executable}") from error + if (result.returncode == 0) != expected_success: + detail = result.stderr.strip() or result.stdout.strip() or "no diagnostic" + expectation = "succeed" if expected_success else "fail" + raise OpenSSLError( + f"OpenSSL command was expected to {expectation}: {detail}" + ) + return result + + +def validated_dns_names(values: tuple[str, ...] | list[str]) -> tuple[str, ...]: + names = tuple(dict.fromkeys(values)) + if not names: + raise ValueError("at least one DNS SAN is required") + for name in names: + if not DNS_NAME_PATTERN.fullmatch(name): + raise ValueError(f"invalid DNS SAN: {name!r}") + return names + + +def validated_ip_addresses(values: tuple[str, ...] | list[str]) -> tuple[str, ...]: + addresses = tuple(dict.fromkeys(str(ipaddress.ip_address(value)) for value in values)) + if not addresses: + raise ValueError("at least one IP SAN is required") + return addresses + + +def server_extensions(dns_names: tuple[str, ...], ip_addresses: tuple[str, ...]) -> str: + sans = [*(f"DNS:{name}" for name in dns_names), *(f"IP:{ip}" for ip in ip_addresses)] + return "\n".join( + ( + "[server]", + "basicConstraints=critical,CA:FALSE", + "keyUsage=critical,digitalSignature,keyEncipherment", + "extendedKeyUsage=serverAuth", + f"subjectAltName={','.join(sans)}", + "subjectKeyIdentifier=hash", + "authorityKeyIdentifier=keyid,issuer", + "", + ) + ) + + +def generate_ca( + executable: str, + directory: Path, + prefix: str, + common_name: str, +) -> tuple[Path, Path]: + key_path = directory / f"{prefix}.key.pem" + cert_path = directory / f"{prefix}.cert.pem" + run_openssl( + executable, + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-sha256", + "-days", + "3650", + "-subj", + f"/CN={common_name}", + "-addext", + "basicConstraints=critical,CA:TRUE,pathlen:0", + "-addext", + "keyUsage=critical,keyCertSign,cRLSign", + "-addext", + "subjectKeyIdentifier=hash", + "-keyout", + key_path, + "-out", + cert_path, + ) + return cert_path, key_path + + +def generate_server( + executable: str, + directory: Path, + prefix: str, + common_name: str, + ca_cert: Path, + ca_key: Path, + serial: str, + dns_names: tuple[str, ...], + ip_addresses: tuple[str, ...], + not_before: str | None = None, + not_after: str | None = None, +) -> tuple[Path, Path]: + key_path = directory / f"{prefix}.key.pem" + request_path = directory / f"{prefix}.csr.pem" + cert_path = directory / f"{prefix}.cert.pem" + extensions_path = directory / f"{prefix}.extensions.cnf" + extensions_path.write_text( + server_extensions(dns_names, ip_addresses), encoding="utf-8" + ) + if (not_before is None) != (not_after is None): + raise ValueError("not_before and not_after must be provided together") + run_openssl( + executable, + "req", + "-new", + "-newkey", + "rsa:2048", + "-nodes", + "-sha256", + "-subj", + f"/CN={common_name}", + "-keyout", + key_path, + "-out", + request_path, + ) + if not_before is None or not_after is None: + run_openssl( + executable, + "x509", + "-req", + "-in", + request_path, + "-CA", + ca_cert, + "-CAkey", + ca_key, + "-set_serial", + serial, + "-days", + "825", + "-sha256", + "-extfile", + extensions_path, + "-extensions", + "server", + "-out", + cert_path, + ) + else: + database = directory / f"{prefix}.ca-database" + new_certificates = database / "certificates" + new_certificates.mkdir(parents=True) + (database / "index.txt").write_text("", encoding="ascii") + (database / "serial").write_text( + f"{serial.removeprefix('0x').upper()}\n", encoding="ascii" + ) + ca_config = database / "ca.cnf" + ca_config.write_text( + "\n".join( + ( + "[ca]", + "default_ca=local_ca", + "[local_ca]", + f'database="{database / "index.txt"}"', + f'new_certs_dir="{new_certificates}"', + f'certificate="{ca_cert}"', + f'private_key="{ca_key}"', + f'serial="{database / "serial"}"', + "default_md=sha256", + "default_days=825", + "email_in_dn=no", + "unique_subject=no", + "policy=server_policy", + "x509_extensions=server", + "[server_policy]", + "commonName=supplied", + server_extensions(dns_names, ip_addresses), + ) + ), + encoding="utf-8", + ) + run_openssl( + executable, + "ca", + "-batch", + "-notext", + "-config", + ca_config, + "-in", + request_path, + "-startdate", + not_before, + "-enddate", + not_after, + "-out", + cert_path, + ) + return cert_path, key_path + + +def generate_pki( + output: Path, + *, + dns_names: tuple[str, ...] = DEFAULT_DNS_NAMES, + ip_addresses: tuple[str, ...] = DEFAULT_IP_ADDRESSES, + force: bool = False, + openssl: str = "openssl", +) -> dict[str, Path]: + dns_names = validated_dns_names(dns_names) + ip_addresses = validated_ip_addresses(ip_addresses) + output = output.expanduser().resolve() + output.mkdir(parents=True, exist_ok=True) + existing = [output / name for name in OUTPUT_NAMES if (output / name).exists()] + if existing and not force: + raise FileExistsError( + f"generated PKI already exists at {output}; pass --force to replace it" + ) + + with tempfile.TemporaryDirectory(prefix=".pki-staging-", dir=output) as temporary: + staging = Path(temporary) + ca_cert, ca_key = generate_ca( + openssl, staging, "ca", "PocketJS Local Test CA" + ) + server_cert, server_key = generate_server( + openssl, + staging, + "server", + dns_names[0], + ca_cert, + ca_key, + "0x1001", + dns_names, + ip_addresses, + ) + wrong_host_cert, wrong_host_key = generate_server( + openssl, + staging, + "wrong-host", + "wrong-host.invalid", + ca_cert, + ca_key, + "0x1002", + ("wrong-host.invalid",), + ip_addresses=(), + ) + expired_server_cert, expired_server_key = generate_server( + openssl, + staging, + "expired-server", + dns_names[0], + ca_cert, + ca_key, + "0x1003", + dns_names, + ip_addresses, + not_before="20200101000000Z", + not_after="20200102000000Z", + ) + not_yet_valid_server_cert, not_yet_valid_server_key = generate_server( + openssl, + staging, + "not-yet-valid-server", + dns_names[0], + ca_cert, + ca_key, + "0x1004", + dns_names, + ip_addresses, + not_before="20500101000000Z", + not_after="20500102000000Z", + ) + untrusted_ca_cert, untrusted_ca_key = generate_ca( + openssl, staging, "untrusted-ca", "PocketJS Untrusted Test CA" + ) + untrusted_server_cert, untrusted_server_key = generate_server( + openssl, + staging, + "untrusted-server", + dns_names[0], + untrusted_ca_cert, + untrusted_ca_key, + "0x2001", + dns_names, + ip_addresses, + ) + bad_signature_cert = staging / "bad-signature-server.cert.pem" + run_openssl( + openssl, + "x509", + "-in", + server_cert, + "-badsig", + "-out", + bad_signature_cert, + ) + + run_openssl(openssl, "verify", "-CAfile", ca_cert, server_cert) + run_openssl(openssl, "verify", "-CAfile", ca_cert, wrong_host_cert) + for time_invalid_cert in (expired_server_cert, not_yet_valid_server_cert): + run_openssl( + openssl, + "verify", + "-no_check_time", + "-CAfile", + ca_cert, + time_invalid_cert, + ) + run_openssl( + openssl, + "verify", + "-CAfile", + ca_cert, + time_invalid_cert, + expected_success=False, + ) + run_openssl( + openssl, + "verify", + "-CAfile", + untrusted_ca_cert, + untrusted_server_cert, + ) + run_openssl( + openssl, + "verify", + "-CAfile", + ca_cert, + bad_signature_cert, + expected_success=False, + ) + + generated = { + "ca_cert": ca_cert, + "ca_key": ca_key, + "server_cert": server_cert, + "server_key": server_key, + "wrong_host_cert": wrong_host_cert, + "wrong_host_key": wrong_host_key, + "expired_server_cert": expired_server_cert, + "expired_server_key": expired_server_key, + "not_yet_valid_server_cert": not_yet_valid_server_cert, + "not_yet_valid_server_key": not_yet_valid_server_key, + "untrusted_ca_cert": untrusted_ca_cert, + "untrusted_ca_key": untrusted_ca_key, + "untrusted_server_cert": untrusted_server_cert, + "untrusted_server_key": untrusted_server_key, + "bad_signature_server_cert": bad_signature_cert, + } + result: dict[str, Path] = {} + for name, source in generated.items(): + destination = output / source.name + os.replace(source, destination) + result[name] = destination + + for name in PRIVATE_KEY_NAMES: + (output / name).chmod(0o600) + return result + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output", + type=Path, + default=Path(__file__).with_name(".pki"), + help="output directory (default: peer tool .pki directory)", + ) + parser.add_argument( + "--san-dns", + action="append", + dest="extra_dns_names", + default=[], + help="additional DNS subjectAltName; may be repeated", + ) + parser.add_argument( + "--san-ip", + action="append", + dest="extra_ip_addresses", + default=[], + help="additional IPv4/IPv6 subjectAltName; may be repeated", + ) + parser.add_argument("--openssl", default="openssl", help="OpenSSL executable") + parser.add_argument("--force", action="store_true", help="replace generated profiles") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + result = generate_pki( + args.output, + dns_names=(*DEFAULT_DNS_NAMES, *args.extra_dns_names), + ip_addresses=(*DEFAULT_IP_ADDRESSES, *args.extra_ip_addresses), + force=args.force, + openssl=args.openssl, + ) + except (FileExistsError, OpenSSLError, ValueError) as error: + print( + json.dumps( + { + "event": "pki_error", + "error": type(error).__name__, + "message": str(error), + }, + sort_keys=True, + separators=(",", ":"), + ), + ) + return 1 + + print( + json.dumps( + { + "event": "pki_ready", + "output": str(args.output.expanduser().resolve()), + "ca_cert": str(result["ca_cert"]), + "profiles": { + "valid": { + "cert": str(result["server_cert"]), + "key": str(result["server_key"]), + }, + "wrong_hostname": { + "cert": str(result["wrong_host_cert"]), + "key": str(result["wrong_host_key"]), + }, + "untrusted": { + "cert": str(result["untrusted_server_cert"]), + "key": str(result["untrusted_server_key"]), + }, + "bad_signature": { + "cert": str(result["bad_signature_server_cert"]), + "key": str(result["server_key"]), + }, + "expired": { + "cert": str(result["expired_server_cert"]), + "key": str(result["expired_server_key"]), + }, + "not_yet_valid": { + "cert": str(result["not_yet_valid_server_cert"]), + "key": str(result["not_yet_valid_server_key"]), + }, + }, + }, + sort_keys=True, + separators=(",", ":"), + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/net-conformance-peer/http_peer.py b/tools/net-conformance-peer/http_peer.py new file mode 100644 index 00000000..dc5960db --- /dev/null +++ b/tools/net-conformance-peer/http_peer.py @@ -0,0 +1,1092 @@ +#!/usr/bin/env python3 +"""Independent HTTP/1.1 peer for PocketJS LAN smoke and wire tests. + +The server intentionally uses only Python's socket APIs. It does not import +PocketJS code or share an HTTP parser with a PocketJS backend. +""" + +from __future__ import annotations + +import argparse +import hashlib +import http.client +import json +import socket +import socketserver +import ssl +import sys +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import BinaryIO, Iterable +from urllib.parse import parse_qs, urlsplit + + +SERVER_NAME = "pocketjs-independent-mac-peer/1" +DEFAULT_BODY_LIMIT = 16 * 1024 +DEFAULT_HEADER_LIMIT = 16 * 1024 +MAX_CHUNK_SIZE_LINE_BYTES = 18 +MAX_ATTEMPT_TOKENS = 1024 +VALID_REDIRECT_STATUSES = {301, 302, 303, 307, 308} +STATUS_REASONS = { + 200: "OK", + 301: "Moved Permanently", + 302: "Found", + 303: "See Other", + 307: "Temporary Redirect", + 308: "Permanent Redirect", + 400: "Bad Request", + 404: "Not Found", + 405: "Method Not Allowed", + 413: "Content Too Large", + 431: "Request Header Fields Too Large", + 500: "Internal Server Error", + 503: "Service Unavailable", +} + + +class RequestError(Exception): + def __init__(self, status: int, message: str) -> None: + super().__init__(message) + self.status = status + self.message = message + + +@dataclass(frozen=True) +class Request: + method: str + target: str + path: str + query: dict[str, list[str]] + version: str + headers: tuple[tuple[str, str], ...] + body: bytes + + def header_values(self, name: str) -> list[str]: + lowered = name.lower() + return [value for key, value in self.headers if key.lower() == lowered] + + +class EventSink: + def __init__(self, path: Path | None, quiet: bool) -> None: + self._lock = threading.Lock() + self._quiet = quiet + self._file: BinaryIO | None = path.open("ab") if path is not None else None + + def emit(self, event: str, **fields: object) -> None: + record = { + "event": event, + "monotonic_ns": time.monotonic_ns(), + **fields, + } + encoded = (json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n").encode() + with self._lock: + if not self._quiet: + sys.stdout.buffer.write(encoded) + sys.stdout.buffer.flush() + if self._file is not None: + self._file.write(encoded) + self._file.flush() + + def close(self) -> None: + with self._lock: + if self._file is not None: + self._file.close() + self._file = None + + +class PeerState: + def __init__(self) -> None: + self._lock = threading.Lock() + self._next_connection_id = 1 + self._connections = 0 + self._active_connections = 0 + self._requests = 0 + self._attempts: dict[str, int] = {} + + def connection_opened(self) -> int: + with self._lock: + connection_id = self._next_connection_id + self._next_connection_id += 1 + self._connections += 1 + self._active_connections += 1 + return connection_id + + def connection_closed(self) -> None: + with self._lock: + self._active_connections -= 1 + + def request_received(self) -> int: + with self._lock: + self._requests += 1 + return self._requests + + def attempt(self, token: str) -> int | None: + with self._lock: + if token not in self._attempts and len(self._attempts) >= MAX_ATTEMPT_TOKENS: + return None + count = self._attempts.get(token, 0) + 1 + self._attempts[token] = count + return count + + def attempt_count(self, token: str) -> int: + with self._lock: + return self._attempts.get(token, 0) + + def metrics(self) -> dict[str, int]: + with self._lock: + return { + "connections": self._connections, + "active_connections": self._active_connections, + "requests": self._requests, + "attempt_tokens": len(self._attempts), + } + + +class ThreadingPeerServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True + + def __init__( + self, + address: tuple[str, int], + *, + body_limit: int, + header_limit: int, + socket_timeout_ms: int, + delay_ceiling_ms: int, + events: EventSink, + tls_context: ssl.SSLContext | None = None, + observe_tls_close_notify: bool = False, + tls_handshake_delay_ms: int = 0, + ) -> None: + self.body_limit = body_limit + self.header_limit = header_limit + self.socket_timeout = socket_timeout_ms / 1000 + self.delay_ceiling_ms = delay_ceiling_ms + self.events = events + self.state = PeerState() + self.tls_context = tls_context + self.observe_tls_close_notify = observe_tls_close_notify + self.tls_handshake_delay_ms = tls_handshake_delay_ms + if self.tls_context is not None: + self.tls_context.set_servername_callback(self._record_tls_server_name) + super().__init__(address, PeerHandler) + + def _record_tls_server_name( + self, + secure_request: ssl.SSLSocket, + server_name: str | None, + initial_context: ssl.SSLContext, + ) -> None: + del initial_context + secure_request.pocketjs_server_name = server_name # type: ignore[attr-defined] + try: + peer_ipv4 = secure_request.getpeername()[0] + except OSError: + peer_ipv4 = None + self.events.emit( + "tls_client_hello", + peer_ipv4=peer_ipv4, + server_name=server_name, + ) + + def get_request(self) -> tuple[socket.socket, tuple[str, int]]: + request, address = super().get_request() + if self.tls_context is None: + return request, address + + request.settimeout(self.socket_timeout) + secure_request = self.tls_context.wrap_socket( + request, + server_side=True, + do_handshake_on_connect=False, + suppress_ragged_eofs=not self.observe_tls_close_notify, + ) + return secure_request, address + + def handle_error( + self, + request: socket.socket, + client_address: tuple[str, int], + ) -> None: + if getattr(request, "pocketjs_handshake_failed", False): + return + super().handle_error(request, client_address) + + +class PeerHandler(socketserver.BaseRequestHandler): + server: ThreadingPeerServer + + def setup(self) -> None: + super().setup() + self.tls_close_state: str | None = None + self.request.settimeout(self.server.socket_timeout) + if isinstance(self.request, ssl.SSLSocket): + if self.server.observe_tls_close_notify: + self.tls_close_state = "not_observed" + try: + if self.server.tls_handshake_delay_ms: + self.server.events.emit( + "tls_handshake_delay", + peer_ipv4=self.client_address[0], + delay_ms=self.server.tls_handshake_delay_ms, + ) + time.sleep(self.server.tls_handshake_delay_ms / 1000) + self.request.do_handshake() + except OSError as error: + self.request.pocketjs_handshake_failed = True # type: ignore[attr-defined] + self.server.events.emit( + "tls_handshake_error", + peer_ipv4=self.client_address[0], + error=type(error).__name__, + ) + raise + self.connection_id = self.server.state.connection_opened() + self.connection_request_index = 0 + fields: dict[str, object] = { + "connection_id": self.connection_id, + "peer_ipv4": self.client_address[0], + "transport": "plaintext", + } + if isinstance(self.request, ssl.SSLSocket): + cipher = self.request.cipher() + fields.update( + transport="tls", + tls_version=self.request.version(), + tls_cipher=cipher[0] if cipher is not None else None, + tls_server_name=getattr( + self.request, "pocketjs_server_name", None + ), + ) + self.server.events.emit("connection_open", **fields) + + def finish(self) -> None: + self.server.state.connection_closed() + fields: dict[str, object] = { + "connection_id": self.connection_id, + "requests": self.connection_request_index, + } + if self.tls_close_state is not None: + fields["tls_close_state"] = self.tls_close_state + fields["tls_close_notify_observed"] = ( + self.tls_close_state == "close_notify" + ) + self.server.events.emit("connection_close", **fields) + super().finish() + + def handle(self) -> None: + buffered = b"" + while True: + try: + request, buffered = self._read_request(buffered) + except RequestError as error: + self._send_fixed(error.status, error.message.encode(), keep_alive=False) + return + except ssl.SSLEOFError: + if self.tls_close_state is not None: + self.tls_close_state = "ragged_eof" + return + except (ConnectionError, socket.timeout): + return + + if request is None: + return + + self.connection_request_index += 1 + request_id = self.server.state.request_received() + self.server.events.emit( + "request", + connection_id=self.connection_id, + connection_request_index=self.connection_request_index, + request_id=request_id, + method=request.method, + path=request.path, + query_names=sorted(request.query), + header_names=sorted({name.lower() for name, _ in request.headers}), + body_bytes=len(request.body), + ) + + keep_alive = request.version == "HTTP/1.1" + connection_values = request.header_values("connection") + if any(value.lower() == "close" for value in connection_values): + keep_alive = False + + try: + keep_alive = self._route(request, keep_alive) + except RequestError as error: + self._send_fixed(error.status, error.message.encode(), keep_alive=False) + return + except (BrokenPipeError, ConnectionResetError, socket.timeout): + return + if not keep_alive: + return + + def _read_request(self, buffered: bytes) -> tuple[Request | None, bytes]: + marker = b"\r\n\r\n" + while marker not in buffered: + if len(buffered) >= self.server.header_limit: + raise RequestError(431, "request headers exceed configured limit") + chunk = self.request.recv(min(4096, self.server.header_limit - len(buffered))) + if not chunk: + if buffered: + raise RequestError(400, "connection closed in request headers") + if self.tls_close_state is not None: + self.tls_close_state = "close_notify" + return None, b"" + buffered += chunk + + header_block, buffered = buffered.split(marker, 1) + if len(header_block) + len(marker) > self.server.header_limit: + raise RequestError(431, "request headers exceed configured limit") + + lines = header_block.split(b"\r\n") + if not lines or not lines[0]: + raise RequestError(400, "missing request line") + try: + method, target, version = lines[0].decode("ascii").split(" ") + except (UnicodeDecodeError, ValueError) as error: + raise RequestError(400, "invalid request line") from error + if version not in {"HTTP/1.0", "HTTP/1.1"} or not target.startswith("/"): + raise RequestError(400, "unsupported request target or HTTP version") + + headers: list[tuple[str, str]] = [] + for raw_line in lines[1:]: + if raw_line.startswith((b" ", b"\t")) or b":" not in raw_line: + raise RequestError(400, "invalid request header") + raw_name, raw_value = raw_line.split(b":", 1) + try: + name = raw_name.decode("ascii") + value = raw_value.decode("iso-8859-1").strip(" \t") + except UnicodeDecodeError as error: + raise RequestError(400, "invalid request header encoding") from error + if not name or not all(character.isalnum() or character in "!#$%&'*+-.^_`|~" for character in name): + raise RequestError(400, "invalid request header name") + headers.append((name, value)) + + content_lengths = [ + value for name, value in headers if name.lower() == "content-length" + ] + transfer_encodings = [ + value for name, value in headers if name.lower() == "transfer-encoding" + ] + trailer_declarations = [ + value for name, value in headers if name.lower() == "trailer" + ] + if len(transfer_encodings) > 1: + raise RequestError(400, "duplicate Transfer-Encoding") + if transfer_encodings and content_lengths: + raise RequestError(400, "Transfer-Encoding conflicts with Content-Length") + if transfer_encodings: + if version != "HTTP/1.1" or transfer_encodings[0].lower() != "chunked": + raise RequestError(400, "unsupported request transfer coding") + if trailer_declarations: + raise RequestError(400, "chunked request trailers are not supported") + body, buffered = self._read_chunked_request_body(buffered) + parsed = urlsplit(target) + return ( + Request( + method=method, + target=target, + path=parsed.path, + query=parse_qs(parsed.query, keep_blank_values=True), + version=version, + headers=tuple(headers), + body=body, + ), + buffered, + ) + if len(content_lengths) > 1: + raise RequestError(400, "duplicate Content-Length") + if content_lengths and ( + not content_lengths[0] + or any(character < "0" or character > "9" for character in content_lengths[0]) + ): + raise RequestError(400, "invalid Content-Length") + body_length = int(content_lengths[0]) if content_lengths else 0 + if body_length > self.server.body_limit: + raise RequestError(413, "request body exceeds configured limit") + + while len(buffered) < body_length: + chunk = self.request.recv(min(4096, body_length - len(buffered))) + if not chunk: + raise RequestError(400, "connection closed in request body") + buffered += chunk + body, buffered = buffered[:body_length], buffered[body_length:] + parsed = urlsplit(target) + return ( + Request( + method=method, + target=target, + path=parsed.path, + query=parse_qs(parsed.query, keep_blank_values=True), + version=version, + headers=tuple(headers), + body=body, + ), + buffered, + ) + + def _read_chunked_request_body(self, buffered: bytes) -> tuple[bytes, bytes]: + body = bytearray() + while True: + size_line, buffered = self._read_bounded_line( + buffered, + MAX_CHUNK_SIZE_LINE_BYTES, + "chunk size line exceeds configured limit", + ) + if not size_line or any( + byte not in b"0123456789abcdefABCDEF" for byte in size_line + ): + raise RequestError(400, "invalid chunk size") + chunk_size = int(size_line, 16) + if chunk_size > self.server.body_limit - len(body): + raise RequestError(413, "request body exceeds configured limit") + if chunk_size == 0: + while len(buffered) < 2: + chunk = self.request.recv(2 - len(buffered)) + if not chunk: + raise RequestError(400, "connection closed in chunk trailer") + buffered += chunk + if not buffered.startswith(b"\r\n"): + raise RequestError(400, "chunked request trailers are not supported") + return bytes(body), buffered[2:] + + required = chunk_size + 2 + while len(buffered) < required: + chunk = self.request.recv(min(4096, required - len(buffered))) + if not chunk: + raise RequestError(400, "connection closed in chunk data") + buffered += chunk + if buffered[chunk_size:required] != b"\r\n": + raise RequestError(400, "chunk data is missing CRLF") + body.extend(buffered[:chunk_size]) + buffered = buffered[required:] + + def _read_bounded_line( + self, + buffered: bytes, + maximum_bytes: int, + limit_message: str, + ) -> tuple[bytes, bytes]: + marker = b"\r\n" + while marker not in buffered: + if len(buffered) >= maximum_bytes: + raise RequestError(400, limit_message) + chunk = self.request.recv(min(4096, maximum_bytes - len(buffered))) + if not chunk: + raise RequestError(400, "connection closed in chunk size") + buffered += chunk + line, remainder = buffered.split(marker, 1) + if len(line) + len(marker) > maximum_bytes: + raise RequestError(400, limit_message) + return line, remainder + + def _route(self, request: Request, keep_alive: bool) -> bool: + if request.path == "/health": + if request.method not in {"GET", "HEAD"}: + return self._method_not_allowed(keep_alive) + body = json.dumps( + { + "status": "ok", + "peer": "mac", + "protocol": "pocketjs-independent-peer-v1", + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + return self._send_fixed( + 200, + b"" if request.method == "HEAD" else body, + keep_alive=keep_alive, + content_length=len(body), + headers=(("Content-Type", "application/json"),), + ) + + if request.path == "/echo": + if request.method != "POST": + return self._method_not_allowed(keep_alive) + return self._send_fixed( + 200, + request.body, + keep_alive=keep_alive, + headers=(("Content-Type", "application/octet-stream"),), + ) + + if request.path == "/chunked": + if request.method != "GET": + return self._method_not_allowed(keep_alive) + delay_ms = self._query_milliseconds(request, "fragment_ms", 0) + self._send_headers( + 200, + keep_alive=keep_alive, + headers=( + ("Content-Type", "application/octet-stream"), + ("Transfer-Encoding", "chunked"), + ("Trailer", "X-PocketJS-Trailer"), + ), + ) + for chunk in (b"PocketJS", b"-independent-", b"peer"): + self._send_fragment(b"%X\r\n" % len(chunk) + chunk + b"\r\n", delay_ms) + self._send_fragment(b"0\r\nX-PocketJS-Trailer: complete\r\n\r\n", delay_ms) + return keep_alive + + if request.path == "/delay": + if request.method != "GET": + return self._method_not_allowed(keep_alive) + delay_ms = self._query_milliseconds(request, "ms", 1000) + phase = self._single_query_value(request, "phase", "headers") + body = b"delayed-response" + if phase == "headers": + time.sleep(delay_ms / 1000) + return self._send_fixed(200, body, keep_alive=keep_alive) + if phase == "body": + self._send_headers( + 200, + keep_alive=keep_alive, + headers=(("Content-Length", str(len(body))),), + ) + time.sleep(delay_ms / 1000) + self.request.sendall(body) + return keep_alive + if phase == "chunk": + self._send_headers( + 200, + keep_alive=keep_alive, + headers=(("Transfer-Encoding", "chunked"),), + ) + self.request.sendall(b"7\r\ndelayed\r\n") + time.sleep(delay_ms / 1000) + self.request.sendall(b"9\r\n-response\r\n0\r\n\r\n") + return keep_alive + return self._send_fixed(400, b"unknown delay phase", keep_alive=False) + + if request.path == "/disconnect": + if request.method != "GET": + return self._method_not_allowed(keep_alive) + phase = self._single_query_value(request, "phase", "before_headers") + if phase == "before_headers": + return False + if phase == "mid_headers": + self.request.sendall( + b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nX-PocketJS" + ) + return False + if phase == "mid_body": + self._send_headers( + 200, + keep_alive=False, + headers=(("Content-Length", "32"),), + ) + self.request.sendall(b"partial") + return False + return self._send_fixed(400, b"unknown disconnect phase", keep_alive=False) + + if request.path.startswith("/malformed/"): + if request.method != "GET": + return self._method_not_allowed(False) + return self._send_malformed(request.path.removeprefix("/malformed/")) + + if request.path == "/redirect": + if request.method != "GET": + return self._method_not_allowed(keep_alive) + target = self._single_query_value(request, "to", "/health") + try: + status = int(self._single_query_value(request, "status", "302")) + except ValueError: + return self._send_fixed(400, b"invalid redirect status", keep_alive=False) + if ( + status not in VALID_REDIRECT_STATUSES + or not target.startswith("/") + or "\r" in target + or "\n" in target + ): + return self._send_fixed(400, b"invalid redirect", keep_alive=False) + return self._send_fixed( + status, + b"redirect", + keep_alive=keep_alive, + headers=(("Location", target),), + ) + + if request.path.startswith("/status/"): + if request.method != "GET": + return self._method_not_allowed(keep_alive) + try: + status = int(request.path.removeprefix("/status/")) + except ValueError: + status = 0 + if status < 400 or status > 599: + return self._send_fixed(400, b"status must be in 400..599", keep_alive=False) + return self._send_fixed(status, f"status-{status}".encode(), keep_alive=keep_alive) + + if request.path == "/retry-once": + if request.method != "GET": + return self._method_not_allowed(keep_alive) + token = self._attempt_token(request) + if token is None: + return self._send_fixed(400, b"token must contain 1..64 safe characters", keep_alive=False) + attempt = self.server.state.attempt(token) + if attempt is None: + return self._send_fixed(503, b"attempt token capacity exhausted", keep_alive=False) + if attempt == 1: + return False + return self._send_fixed( + 200, + f"attempt-{attempt}".encode(), + keep_alive=keep_alive, + headers=(("X-PocketJS-Attempt", str(attempt)),), + ) + + if request.path == "/attempts": + if request.method != "GET": + return self._method_not_allowed(keep_alive) + token = self._attempt_token(request) + if token is None: + return self._send_fixed(400, b"token must contain 1..64 safe characters", keep_alive=False) + body = json.dumps( + {"attempts": self.server.state.attempt_count(token)}, + separators=(",", ":"), + ).encode() + return self._send_fixed( + 200, + body, + keep_alive=keep_alive, + headers=(("Content-Type", "application/json"),), + ) + + if request.path == "/metrics": + if request.method != "GET": + return self._method_not_allowed(keep_alive) + body = json.dumps( + self.server.state.metrics(), sort_keys=True, separators=(",", ":") + ).encode() + return self._send_fixed( + 200, + body, + keep_alive=keep_alive, + headers=(("Content-Type", "application/json"),), + ) + + return self._send_fixed(404, b"not found", keep_alive=keep_alive) + + def _send_malformed(self, case: str) -> bool: + responses = { + "te-cl": ( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n" + b"Content-Length: 5\r\nConnection: close\r\n\r\n5\r\nhello\r\n0\r\n\r\n" + ), + "duplicate-content-length": ( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nContent-Length: 5\r\n" + b"Connection: close\r\n\r\nhello" + ), + "obs-fold": ( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nX-PocketJS: first\r\n" + b" second\r\nConnection: close\r\n\r\nok" + ), + "te-duplicate": ( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n" + b"Transfer-Encoding: chunked\r\nConnection: close\r\n\r\n0\r\n\r\n" + ), + "te-combined": ( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip, chunked\r\n" + b"Connection: close\r\n\r\n0\r\n\r\n" + ), + "te-unknown": ( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip\r\n" + b"Connection: close\r\n\r\nopaque" + ), + "trailer-forbidden": ( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n" + b"Trailer: Content-Length\r\nConnection: close\r\n\r\n" + b"2\r\nok\r\n0\r\nContent-Length: 2\r\n\r\n" + ), + "chunk-size": ( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n" + b"Connection: close\r\n\r\nZ\r\ninvalid\r\n0\r\n\r\n" + ), + } + response = responses.get(case) + if response is None: + return self._send_fixed(404, b"unknown malformed case", keep_alive=False) + self.request.sendall(response) + return False + + def _method_not_allowed(self, keep_alive: bool) -> bool: + return self._send_fixed(405, b"method not allowed", keep_alive=keep_alive) + + def _send_fixed( + self, + status: int, + body: bytes, + *, + keep_alive: bool, + content_length: int | None = None, + headers: Iterable[tuple[str, str]] = (), + ) -> bool: + all_headers = list(headers) + all_headers.append( + ("Content-Length", str(len(body) if content_length is None else content_length)) + ) + self._send_headers(status, keep_alive=keep_alive, headers=all_headers) + if body: + self.request.sendall(body) + return keep_alive + + def _send_headers( + self, + status: int, + *, + keep_alive: bool, + headers: Iterable[tuple[str, str]], + ) -> None: + reason = STATUS_REASONS.get(status, "Test Status") + lines = [ + f"HTTP/1.1 {status} {reason}", + f"Server: {SERVER_NAME}", + f"X-PocketJS-Connection: {self.connection_id}", + f"X-PocketJS-Request: {self.connection_request_index}", + f"Connection: {'keep-alive' if keep_alive else 'close'}", + ] + lines.extend(f"{name}: {value}" for name, value in headers) + self.request.sendall(("\r\n".join(lines) + "\r\n\r\n").encode("ascii")) + + def _send_fragment(self, fragment: bytes, delay_ms: int) -> None: + self.request.sendall(fragment) + if delay_ms: + time.sleep(delay_ms / 1000) + + def _query_milliseconds(self, request: Request, name: str, default: int) -> int: + try: + value = int(self._single_query_value(request, name, str(default))) + except ValueError as error: + raise RequestError(400, f"{name} must be an integer") from error + if value < 0 or value > self.server.delay_ceiling_ms: + raise RequestError( + 400, f"{name} must be in 0..{self.server.delay_ceiling_ms}" + ) + return value + + @staticmethod + def _single_query_value(request: Request, name: str, default: str) -> str: + values = request.query.get(name) + return values[0] if values else default + + @staticmethod + def _attempt_token(request: Request) -> str | None: + values = request.query.get("token") + if not values: + return None + token = values[0] + if not 1 <= len(token) <= 64: + return None + if not all(character.isalnum() or character in "-_." for character in token): + return None + return token + + +def build_probe_path(prefix: str, endpoint: str) -> str: + normalized = prefix.rstrip("/") + return f"{normalized}{endpoint}" if normalized else endpoint + + +def read_bounded(response: http.client.HTTPResponse, limit: int) -> bytes: + body = response.read(limit + 1) + if len(body) > limit: + raise RuntimeError(f"response body exceeds probe limit of {limit} bytes") + return body + + +def connection_identity(connection: http.client.HTTPConnection) -> tuple[str, int]: + if connection.sock is None: + raise RuntimeError("peer closed a connection that should remain persistent") + address = connection.sock.getsockname() + return str(address[0]), int(address[1]) + + +def create_server_tls_context( + cert_path: Path, + key_path: Path, + minimum_version: str, +) -> ssl.SSLContext: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = tls_version(minimum_version) + context.load_cert_chain(certfile=cert_path, keyfile=key_path) + return context + + +def certificate_der_sha256(cert_path: Path) -> str: + pem = cert_path.read_text(encoding="ascii") + der = ssl.PEM_cert_to_DER_cert(pem) + return hashlib.sha256(der).hexdigest() + + +def tls_version(value: str) -> ssl.TLSVersion: + return { + "1.2": ssl.TLSVersion.TLSv1_2, + "1.3": ssl.TLSVersion.TLSv1_3, + }[value] + + +def run_probe(args: argparse.Namespace) -> int: + parsed = urlsplit(args.base_url) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.query + or parsed.fragment + ): + raise ValueError( + "--base-url must be an http:// or https:// host[:port][/prefix] URL" + ) + if parsed.scheme == "http" and args.ca_cert: + raise ValueError("--ca-cert requires an https:// base URL") + + port = parsed.port or (443 if parsed.scheme == "https" else 80) + if parsed.scheme == "https": + ca_path = Path(args.ca_cert).expanduser() if args.ca_cert else None + context = ssl.create_default_context(cafile=ca_path) + connection: http.client.HTTPConnection = http.client.HTTPSConnection( + parsed.hostname, + port, + timeout=args.timeout_ms / 1000, + context=context, + ) + else: + connection = http.client.HTTPConnection( + parsed.hostname, port, timeout=args.timeout_ms / 1000 + ) + health_path = build_probe_path(parsed.path, "/health") + echo_path = build_probe_path(parsed.path, "/echo") + started = time.monotonic() + try: + for round_index in range(1, args.rounds + 1): + round_started = time.monotonic() + connection.request("GET", health_path, headers={"Connection": "keep-alive"}) + health = connection.getresponse() + health_body = read_bounded(health, args.response_limit_bytes) + if health.status != 200: + raise RuntimeError(f"health returned HTTP {health.status}") + if args.health_contains.encode() not in health_body: + raise RuntimeError( + f"health body does not contain {args.health_contains!r}" + ) + persistent_identity = connection_identity(connection) + + echo_body = ( + f"pocketjs-mac-probe-round-{round_index}:".encode() + + bytes((0, 1, 127, 128, 254, 255)) + ) + connection.request( + "POST", + echo_path, + body=echo_body, + headers={ + "Content-Type": "application/octet-stream", + "Connection": "keep-alive", + }, + ) + if connection_identity(connection) != persistent_identity: + raise RuntimeError("server did not reuse the health connection for echo") + echo = connection.getresponse() + echoed = read_bounded(echo, args.response_limit_bytes) + if echo.status != 200: + raise RuntimeError(f"echo returned HTTP {echo.status}") + if echoed != echo_body: + raise RuntimeError( + f"echo mismatch: sent {len(echo_body)} bytes, received {len(echoed)}" + ) + if connection_identity(connection) != persistent_identity: + raise RuntimeError("server closed the persistent connection after echo") + + record = { + "event": "probe_round_pass", + "round": round_index, + "health_bytes": len(health_body), + "echo_bytes": len(echoed), + "latency_ms": round((time.monotonic() - round_started) * 1000, 3), + } + print(json.dumps(record, sort_keys=True, separators=(",", ":")), flush=True) + if round_index != args.rounds and args.interval_ms: + time.sleep(args.interval_ms / 1000) + finally: + connection.close() + + print( + json.dumps( + { + "event": "probe_pass", + "rounds": args.rounds, + "elapsed_ms": round((time.monotonic() - started) * 1000, 3), + }, + sort_keys=True, + separators=(",", ":"), + ), + flush=True, + ) + return 0 + + +def run_server(args: argparse.Namespace) -> int: + if bool(args.tls_cert) != bool(args.tls_key): + raise ValueError("--tls-cert and --tls-key must be provided together") + if args.observe_tls_close_notify and not args.tls_cert: + raise ValueError("--observe-tls-close-notify requires TLS") + if args.tls_handshake_delay_ms and not args.tls_cert: + raise ValueError("--tls-handshake-delay-ms requires TLS") + if args.tls_handshake_delay_ms > args.delay_ceiling_ms: + raise ValueError("--tls-handshake-delay-ms exceeds --delay-ceiling-ms") + tls_context = None + tls_certificate_der_sha256 = None + if args.tls_cert: + if args.tls_max_version and tls_version( + args.tls_max_version + ) < tls_version(args.tls_min_version): + raise ValueError("--tls-max-version must not be below --tls-min-version") + cert_path = Path(args.tls_cert).expanduser() + tls_context = create_server_tls_context( + cert_path, + Path(args.tls_key).expanduser(), + args.tls_min_version, + ) + tls_certificate_der_sha256 = certificate_der_sha256(cert_path) + if args.tls_max_version: + tls_context.maximum_version = tls_version(args.tls_max_version) + + event_path = Path(args.events).expanduser() if args.events else None + if event_path is not None: + event_path.parent.mkdir(parents=True, exist_ok=True) + events = EventSink(event_path, args.quiet_events) + server = ThreadingPeerServer( + (args.host, args.port), + body_limit=args.max_request_body_bytes, + header_limit=args.max_header_bytes, + socket_timeout_ms=args.socket_timeout_ms, + delay_ceiling_ms=args.delay_ceiling_ms, + events=events, + tls_context=tls_context, + observe_tls_close_notify=args.observe_tls_close_notify, + tls_handshake_delay_ms=args.tls_handshake_delay_ms, + ) + host, port = server.server_address[:2] + events.emit( + "peer_ready", + bind_host=host, + port=port, + transport="tls" if tls_context is not None else "plaintext", + tls_min_version=args.tls_min_version if tls_context is not None else None, + tls_max_version=( + args.tls_max_version if tls_context is not None else None + ), + tls_certificate_der_sha256=tls_certificate_der_sha256, + observe_tls_close_notify=args.observe_tls_close_notify, + tls_handshake_delay_ms=args.tls_handshake_delay_ms, + socket_timeout_ms=args.socket_timeout_ms, + max_header_bytes=args.max_header_bytes, + max_request_body_bytes=args.max_request_body_bytes, + ) + try: + server.serve_forever(poll_interval=0.1) + except KeyboardInterrupt: + events.emit("peer_stop", reason="keyboard_interrupt") + finally: + server.server_close() + events.close() + return 0 + + +def positive_integer(value: str) -> int: + parsed = int(value) + if parsed <= 0: + raise argparse.ArgumentTypeError("must be greater than zero") + return parsed + + +def nonnegative_integer(value: str) -> int: + parsed = int(value) + if parsed < 0: + raise argparse.ArgumentTypeError("must be zero or greater") + return parsed + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + + serve = commands.add_parser("serve", help="run the independent HTTP peer") + serve.add_argument("--host", default="127.0.0.1") + serve.add_argument("--port", type=nonnegative_integer, default=8088) + serve.add_argument( + "--max-request-body-bytes", type=positive_integer, default=DEFAULT_BODY_LIMIT + ) + serve.add_argument( + "--max-header-bytes", type=positive_integer, default=DEFAULT_HEADER_LIMIT + ) + serve.add_argument("--socket-timeout-ms", type=positive_integer, default=5000) + serve.add_argument("--delay-ceiling-ms", type=positive_integer, default=120000) + serve.add_argument("--events", help="append redacted NDJSON events to this path") + serve.add_argument("--quiet-events", action="store_true") + serve.add_argument("--tls-cert", help="PEM server certificate chain") + serve.add_argument("--tls-key", help="PEM private key for --tls-cert") + serve.add_argument( + "--tls-min-version", + choices=("1.2", "1.3"), + default="1.2", + help="minimum accepted TLS version (default: 1.2)", + ) + serve.add_argument( + "--tls-max-version", + choices=("1.2", "1.3"), + help="optional maximum accepted TLS version for version-specific tests", + ) + serve.add_argument( + "--observe-tls-close-notify", + action="store_true", + help="distinguish a client TLS close_notify from an abrupt EOF", + ) + serve.add_argument( + "--tls-handshake-delay-ms", + type=nonnegative_integer, + default=0, + help="bounded delay before the server processes ClientHello", + ) + serve.set_defaults(entrypoint=run_server) + + probe = commands.add_parser( + "probe", help="run Mac-to-device /health and binary /echo smoke" + ) + probe.add_argument("--base-url", required=True) + probe.add_argument("--rounds", type=positive_integer, default=1) + probe.add_argument("--interval-ms", type=nonnegative_integer, default=0) + probe.add_argument("--timeout-ms", type=positive_integer, default=5000) + probe.add_argument("--response-limit-bytes", type=positive_integer, default=65536) + probe.add_argument("--health-contains", default="") + probe.add_argument( + "--ca-cert", + help="PEM CA bundle for HTTPS verification; system trust is used when omitted", + ) + probe.set_defaults(entrypoint=run_probe) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.entrypoint(args) + except (OSError, RuntimeError, ValueError) as error: + print( + json.dumps( + {"event": "peer_error", "error": type(error).__name__, "message": str(error)}, + sort_keys=True, + separators=(",", ":"), + ), + file=sys.stderr, + flush=True, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/net-conformance-peer/phase1b_certificate_reject.py b/tools/net-conformance-peer/phase1b_certificate_reject.py new file mode 100644 index 00000000..357bb336 --- /dev/null +++ b/tools/net-conformance-peer/phase1b_certificate_reject.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import ipaddress +import json +import re +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + + +ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") +KEY_VALUE = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)=([^\s]+)") +ORIGIN = "https://pocketjs.test:8443" +EXPECTED_PLAN = "sha256:fe3014e4d3628eb60aaeedd414432eb8c9a5932e904b258a9d05a17c7f6abcce" +EXPECTED_FACTORY = "sha256:f71e8e98407f49d71589df91acaab864c8b1f759eb4d2b2ffb26edfd8b3ce3e6" +PROFILES = { + "hostname-mismatch": ( + "hostname_reject", + "tls_hostname_mismatch", + "3751d9105ff41923f6c683acccdf50e42d9ff5d3a0d1c15eb70b866b680afcae", + ), + "unknown-ca": ( + "certificate_reject", + "tls_certificate_invalid", + "292097740611d88eca9444cd67bbfc345c318acd9108524510d48afb749f6e3c", + ), + "expired": ( + "certificate_reject", + "tls_certificate_invalid", + "20187c3b45165626069b97c343e10b2c75025712520c80dc8e5b0597ba015565", + ), + "not-yet-valid": ( + "certificate_reject", + "tls_certificate_invalid", + "733521cb7dead745e11696e2f41c96e1a29bcb1492a1b0c4dbc4ea3a9b6875bd", + ), + "bad-signature": ( + "certificate_reject", + "tls_certificate_invalid", + "c2021f4c1bc33bbf83e5cbc6dd91dfc28c4c77e401402455ba1628d9d669c752", + ), +} + + +class CertificateRejectValidationError(ValueError): + pass + + +@dataclass(frozen=True) +class CertificateRejectSummary: + board: str + board_ipv4: str + profile: str + certificate_der_sha256: str + dns_answers: int + tls_client_hellos: int + tls_handshake_errors: int + guest_yields: int + elapsed_ms: int + error_code: str + + +def fail(message: str) -> None: + raise CertificateRejectValidationError(message) + + +def fields(line: str) -> dict[str, str]: + return dict(KEY_VALUE.findall(ANSI_ESCAPE.sub("", line))) + + +def integer(values: dict[str, str], name: str) -> int: + value = values.get(name) + if value is None: + fail(f"board log is missing {name}") + try: + return int(value, 0) + except ValueError as error: + raise CertificateRejectValidationError(f"invalid integer {name}={value}") from error + + +def monotonic_ns(event: dict[str, Any], context: str) -> int: + value = event.get("monotonic_ns") + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + fail(f"{context} has no valid monotonic_ns") + return value + + +def canonical_ipv4(value: str, option: str) -> str: + try: + parsed = ipaddress.IPv4Address(value) + except ipaddress.AddressValueError as error: + raise CertificateRejectValidationError( + f"{option} must be an IPv4 literal" + ) from error + if str(parsed) != value or parsed.is_unspecified: + fail(f"{option} must be a canonical, specified IPv4 literal") + return value + + +def load_events(path: Path) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + for line_number, line in enumerate(path.read_text().splitlines(), 1): + try: + event = json.loads(line) + except json.JSONDecodeError as error: + raise CertificateRejectValidationError( + f"{path}:{line_number}: invalid NDJSON" + ) from error + if not isinstance(event, dict): + fail(f"{path}:{line_number}: event must be an object") + events.append(event) + return events + + +def exactly_one( + events: list[dict[str, Any]], event_name: str, evidence_name: str +) -> dict[str, Any]: + matches = [event for event in events if event.get("event") == event_name] + if len(matches) != 1: + fail(f"{evidence_name} must contain exactly one {event_name} event") + return matches[0] + + +def validate_board_log( + lines: list[str], board: str, peer_ipv4: str, mode: str, error_code: str +) -> tuple[int, int]: + if any("task_wdt" in line for line in lines): + fail("board log contains a task watchdog report") + if any("Guru Meditation" in line or "assert failed" in line for line in lines): + fail("board log contains a crash or assertion") + if any("POCKET_NET_FORMAL_TLS_FAIL" in line for line in lines): + fail("board log contains POCKET_NET_FORMAL_TLS_FAIL") + + boots = [line for line in lines if "POCKET_NET_FORMAL_TLS_BOOT" in line] + if len(boots) != 1: + fail("board evidence must contain exactly one formal TLS boot") + boot = fields(boots[0]) + if ( + boot.get("origin") != ORIGIN + or boot.get("peer_ipv4") != peer_ipv4 + or boot.get("public_capability") != "0" + ): + fail("board boot is not the exact test-only TLS certificate profile") + + if board == "s3": + if ( + boot.get("plan_hash") != EXPECTED_PLAN + or boot.get("factory_sha256") != EXPECTED_FACTORY + ): + fail("S3 boot is not the exact TLS conformance artifact") + starts = [line for line in lines if "POCKET_NET_FORMAL_TLS_START" in line] + contracts = [line for line in lines if "POCKET_NET_TLS_CONTRACT" in line] + if len(starts) != 1 or len(contracts) != 1: + fail("S3 evidence is missing its exact start or descriptor snapshot") + start = fields(starts[0]) + contract = fields(contracts[0]) + if ( + start.get("mode") != mode + or start.get("origin") != ORIGIN + or start.get("peer_ipv4") != peer_ipv4 + or contract.get("valid") != "1" + or contract.get("distinct_tls_errors") != "1" + or contract.get("expected_distinct_tls_errors") != "1" + or contract.get("public") != "0" + ): + fail("S3 start is not the exact certificate rejection profile") + elif ( + boot.get("test_mode") != mode + or boot.get("distinct_tls_errors") != "1" + or boot.get("plan") != EXPECTED_PLAN + or boot.get("factory") != EXPECTED_FACTORY + ): + fail("P4 boot is not the exact certificate rejection profile") + + runs = [line for line in lines if "POCKET_NET_FORMAL_TLS_RUN" in line] + if len(runs) != 1: + fail("board evidence must contain exactly one formal TLS run") + run = fields(runs[0]) + if ( + run.get("status") != "ESP_ERR_INVALID_RESPONSE" + or run.get("rounds") != "0/20" + or run.get("requests") != "0/40" + or run.get("frame_calls") != "0" + or run.get("shutdown") != "1" + or run.get("poison") != "0x00000000" + or run.get("core_poison") != "0x00000000" + or run.get("poisoned_cores") != "0" + ): + fail("formal TLS run did not fail closed and shut down cleanly") + guest_yields = integer(run, "guest_yields") + elapsed_ms = integer(run, "elapsed_ms") + if guest_yields <= 0 or elapsed_ms <= 0: + fail("formal TLS run did not record bounded cooperative execution") + + passes = [line for line in lines if "POCKET_NET_FORMAL_TLS_NEGATIVE_PASS" in line] + if len(passes) != 1: + fail("board evidence must contain exactly one negative-pass record") + passed = fields(passes[0]) + if ( + passed.get("mode") != mode + or passed.get("error_code") != error_code + or passed.get("shutdown") != "1" + or passed.get("poison") != "0x00000000" + ): + fail("board did not report the selected certificate rejection") + + if board == "s3": + results = [ + line + for line in lines + if "POCKET_NET_FORMAL_TLS_RESULT" in line + and "POCKET_NET_FORMAL_TLS_RUN" not in line + ] + if len(results) != 1: + fail("S3 evidence must contain exactly one result record") + result = fields(results[0]) + if ( + result.get("mode") != mode + or result.get("pass") != "1" + or result.get("error_code") != error_code + or result.get("requests") != "0/40" + or result.get("shutdown") != "1" + or result.get("poison") != "0x00000000" + or result.get("leases_balanced") != "1" + or result.get("queued_leases") != "0" + or result.get("taken_leases") != "0" + ): + fail("S3 result has poison, live leases, or unexpected HTTP work") + else: + resources = [ + line for line in lines if "POCKET_NET_FORMAL_TLS_RESOURCE" in line + ] + if len(resources) != 1: + fail("P4 evidence must contain exactly one resource record") + resource = fields(resources[0]) + if ( + resource.get("active") != "0" + or resource.get("pending") != "0" + or resource.get("queued") != "0" + or resource.get("taken") != "0" + or resource.get("leases_taken") != "0" + or resource.get("leases_released") != "0" + or resource.get("leases_cleaned") != "0" + or resource.get("poison") != "0x00000000" + or resource.get("shutdown") != "1" + or passed.get("leases") != "0/0" + ): + fail("P4 result has poison, live leases, or active operations") + + return guest_yields, elapsed_ms + + +def analyze( + *, + board: str, + profile: str, + board_log: Path, + tls_events_path: Path, + dns_events_path: Path, + board_ipv4: str, + peer_ipv4: str, +) -> CertificateRejectSummary: + board_ipv4 = canonical_ipv4(board_ipv4, "--board-ipv4") + peer_ipv4 = canonical_ipv4(peer_ipv4, "--peer-ipv4") + mode, error_code, certificate_sha = PROFILES[profile] + lines = board_log.read_text(errors="replace").splitlines() + guest_yields, elapsed_ms = validate_board_log( + lines, board, peer_ipv4, mode, error_code + ) + + tls_events = load_events(tls_events_path) + dns_events = load_events(dns_events_path) + tls_ready = exactly_one(tls_events, "peer_ready", "TLS evidence") + tls_stop = exactly_one(tls_events, "peer_stop", "TLS evidence") + if ( + tls_ready.get("transport") != "tls" + or tls_ready.get("port") != 8443 + or tls_ready.get("tls_min_version") != "1.2" + or tls_ready.get("tls_max_version") != "1.2" + or tls_ready.get("tls_certificate_der_sha256") != certificate_sha + or tls_ready.get("observe_tls_close_notify") is not True + or not isinstance(tls_ready.get("socket_timeout_ms"), int) + or int(tls_ready["socket_timeout_ms"]) <= 30_000 + or tls_stop.get("reason") != "keyboard_interrupt" + ): + fail("peer readiness is not the exact certificate rejection profile") + + dns_ready = exactly_one(dns_events, "dns_ready", "DNS evidence") + dns_stop = exactly_one(dns_events, "dns_stop", "DNS evidence") + if ( + dns_ready.get("authoritative_name") != "pocketjs.test" + or dns_ready.get("authoritative_ipv4") != peer_ipv4 + or dns_ready.get("port") != 53 + or dns_ready.get("interface") != "en1" + or dns_ready.get("recursion_available") is not False + or dns_ready.get("transports") != ["udp", "tcp"] + or dns_stop.get("reason") != "keyboard_interrupt" + ): + fail("DNS readiness is not the exact controlled authoritative profile") + + queries = [event for event in dns_events if event.get("event") == "dns_query"] + if len(queries) != 1: + fail("DNS evidence must contain exactly one query from the selected board") + answer = queries[0] + if ( + answer.get("outcome") != "answer" + or answer.get("peer_ipv4") != board_ipv4 + or answer.get("query_name") != "pocketjs.test" + or answer.get("query_type") != 1 + or answer.get("query_class") != 1 + or answer.get("answers") != 1 + or answer.get("rcode") != 0 + or answer.get("recursion_available") is not False + ): + fail("DNS answer is not confined to the selected board and hostname") + + hello = exactly_one(tls_events, "tls_client_hello", "TLS evidence") + handshake_error = exactly_one(tls_events, "tls_handshake_error", "TLS evidence") + if ( + hello.get("peer_ipv4") != board_ipv4 + or hello.get("server_name") != "pocketjs.test" + or handshake_error.get("peer_ipv4") != board_ipv4 + or handshake_error.get("error") != "SSLError" + ): + fail("TLS handshake evidence has the wrong board identity or SNI") + + forbidden_events = {"connection_open", "request", "response", "connection_close"} + if any(event.get("event") in forbidden_events for event in tls_events): + fail("certificate rejection reached an opened TLS connection or HTTP request") + + tls_ready_ns = monotonic_ns(tls_ready, "peer_ready") + dns_ready_ns = monotonic_ns(dns_ready, "dns_ready") + answer_ns = monotonic_ns(answer, "dns_query") + hello_ns = monotonic_ns(hello, "tls_client_hello") + handshake_ns = monotonic_ns(handshake_error, "tls_handshake_error") + tls_stop_ns = monotonic_ns(tls_stop, "peer_stop") + dns_stop_ns = monotonic_ns(dns_stop, "dns_stop") + if not ( + dns_ready_ns < answer_ns < dns_stop_ns + and tls_ready_ns < hello_ns < handshake_ns < tls_stop_ns + and answer_ns < hello_ns + ): + fail("DNS and TLS evidence is stale, incomplete, or out of order") + + return CertificateRejectSummary( + board=board, + board_ipv4=board_ipv4, + profile=profile, + certificate_der_sha256=certificate_sha, + dns_answers=1, + tls_client_hellos=1, + tls_handshake_errors=1, + guest_yields=guest_yields, + elapsed_ms=elapsed_ms, + error_code=error_code, + ) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Validate PocketJS ESP Phase 1B certificate rejection" + ) + parser.add_argument("--board", choices=("s3", "p4"), required=True) + parser.add_argument("--profile", choices=tuple(PROFILES), required=True) + parser.add_argument("--board-log", type=Path, required=True) + parser.add_argument("--tls-events", type=Path, required=True) + parser.add_argument("--dns-events", type=Path, required=True) + parser.add_argument("--board-ipv4", required=True) + parser.add_argument("--peer-ipv4", required=True) + args = parser.parse_args() + try: + summary = analyze( + board=args.board, + profile=args.profile, + board_log=args.board_log, + tls_events_path=args.tls_events, + dns_events_path=args.dns_events, + board_ipv4=args.board_ipv4, + peer_ipv4=args.peer_ipv4, + ) + except (OSError, CertificateRejectValidationError) as error: + parser.exit(1, f"phase1b certificate rejection failed: {error}\n") + print(json.dumps(asdict(summary), sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/net-conformance-peer/phase1b_conformance.py b/tools/net-conformance-peer/phase1b_conformance.py new file mode 100644 index 00000000..bf48ce76 --- /dev/null +++ b/tools/net-conformance-peer/phase1b_conformance.py @@ -0,0 +1,549 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import re +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Iterable + + +ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") +KEY_VALUE = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)=([^\s]+)") +EXPECTED_PLAN = "sha256:fe3014e4d3628eb60aaeedd414432eb8c9a5932e904b258a9d05a17c7f6abcce" +EXPECTED_FACTORY = "sha256:f71e8e98407f49d71589df91acaab864c8b1f759eb4d2b2ffb26edfd8b3ce3e6" +EXPECTED_CA = "sha256:318ae57f0fb82d12cf86431571fb6ec3556ecb74f530a5be6f741a482b5447af" +EXPECTED_PROVIDER = "pocketjs.net.esp-idf.esp-tls.v1.experimental" +EXPECTED_ORIGIN = "https://pocketjs.test:8443" +EXPECTED_GUEST_OPERATIONS = 40 +EXPECTED_WIRE_REQUESTS = 44 +EXPECTED_CONNECTIONS = 25 +EXPECTED_LEASES = 26 +GRACEFUL_CONNECTIONS = frozenset((1, 2, 3, 13, 14, 15, 25)) +CONNECTION_LENGTHS = (5, 2, 3, 1, 1, 1, 1, 1, 1, 1, 1, 3, 6, 2, 3, 1, 1, 1, 1, 1, 1, 1, 1, 3, 1) + + +class ConformanceValidationError(ValueError): + pass + + +@dataclass(frozen=True) +class RequestExpectation: + method: str + path: str + query_names: tuple[str, ...] = () + body_bytes: int = 0 + + @property + def header_names(self) -> tuple[str, ...]: + if self.method == "POST": + return ("accept-encoding", "host", "transfer-encoding") + return ("accept-encoding", "host") + + +@dataclass(frozen=True) +class ConformanceSummary: + board: str + board_ipv4: str + guest_operations: int + wire_requests: int + tls_connections: int + close_notify_connections: int + forced_close_connections: int + dns_answers: int + maximum_requests_per_connection: int + leases: int + guest_yields: int + elapsed_ms: int + + +BASE_REQUESTS = ( + RequestExpectation("HEAD", "/health"), + RequestExpectation("GET", "/chunked", ("fragment_ms",)), + RequestExpectation("GET", "/status/404"), + RequestExpectation("GET", "/status/503"), + RequestExpectation("GET", "/redirect", ("status", "to")), + RequestExpectation("GET", "/health"), + RequestExpectation("GET", "/redirect", ("status", "to")), + RequestExpectation("GET", "/chunked", ("fragment_ms",)), + RequestExpectation("GET", "/redirect", ("status", "to")), + RequestExpectation("GET", "/redirect", ("status", "to")), + RequestExpectation("GET", "/malformed/te-cl"), + RequestExpectation("GET", "/malformed/duplicate-content-length"), + RequestExpectation("GET", "/malformed/obs-fold"), + RequestExpectation("GET", "/malformed/te-duplicate"), + RequestExpectation("GET", "/malformed/te-combined"), + RequestExpectation("GET", "/malformed/te-unknown"), + RequestExpectation("GET", "/malformed/trailer-forbidden"), + RequestExpectation("GET", "/malformed/chunk-size"), + RequestExpectation("POST", "/echo", body_bytes=7), + RequestExpectation("POST", "/echo", body_bytes=10), + RequestExpectation("GET", "/retry-once", ("token",)), + RequestExpectation("GET", "/attempts", ("token",)), +) +EXPECTED_REQUESTS = BASE_REQUESTS + BASE_REQUESTS + + +def fail(message: str) -> None: + raise ConformanceValidationError(message) + + +def fields(line: str) -> dict[str, str]: + return dict(KEY_VALUE.findall(ANSI_ESCAPE.sub("", line))) + + +def integer(values: dict[str, str], name: str) -> int: + value = values.get(name) + if value is None: + fail(f"board log is missing {name}") + try: + return int(value, 0) + except ValueError as error: + raise ConformanceValidationError(f"invalid integer {name}={value}") from error + + +def exact_line(lines: Iterable[str], marker: str) -> str: + matches = [line for line in lines if marker in line] + if len(matches) != 1: + fail(f"board evidence must contain exactly one {marker} record") + return matches[0] + + +def expect_fields(values: dict[str, str], expected: dict[str, str], label: str) -> None: + mismatches = [ + f"{name}={values.get(name)!r}" + for name, value in expected.items() + if values.get(name) != value + ] + if mismatches: + fail(f"{label} has unexpected fields: {', '.join(mismatches)}") + + +def parse_leases(value: str | None, label: str) -> int: + if value is None: + fail(f"{label} is missing leases") + pieces = value.split("/", 1) + if len(pieces) != 2: + fail(f"{label} has malformed leases={value}") + try: + taken, released = (int(piece) for piece in pieces) + except ValueError as error: + raise ConformanceValidationError(f"{label} has malformed leases={value}") from error + if taken != EXPECTED_LEASES or released != EXPECTED_LEASES: + fail(f"{label} has unbalanced or unexpected leases={value}") + return taken + + +def validate_board_log(lines: list[str], board: str, board_ipv4: str, peer_ipv4: str) -> tuple[int, int, int]: + crash_markers = ( + "task_wdt", + "Guru Meditation", + "assert failed", + "abort() was called", + "POCKET_NET_FORMAL_TLS_FAIL", + ) + if any(marker in line for marker in crash_markers for line in lines): + fail("board log contains a crash, watchdog, assertion, or formal failure") + + run = fields(exact_line(lines, "POCKET_NET_FORMAL_TLS_RUN")) + expect_fields( + run, + { + "status": "ESP_OK", + "rounds": "20/20", + "requests": "40/40", + "frame_calls": "0", + "shutdown": "1", + "poison": "0x00000000", + "core_poison": "0x00000000", + "poisoned_cores": "0", + "core_cause": "0", + }, + "formal run", + ) + guest_yields = integer(run, "guest_yields") + elapsed_ms = integer(run, "elapsed_ms") + if guest_yields <= 0 or elapsed_ms <= 0: + fail("formal run did not yield cooperatively or report elapsed time") + + if board == "s3": + contract = fields(exact_line(lines, "POCKET_NET_TLS_CONTRACT")) + expect_fields( + contract, + { + "valid": "1", + "https_explicit_opt_in": "1", + "exact_host_tls_profile": "1", + "distinct_tls_errors": "1", + "expected_distinct_tls_errors": "1", + "public": "0", + "provider": EXPECTED_PROVIDER, + }, + "S3 TLS contract", + ) + boot = fields(exact_line(lines, "POCKET_NET_FORMAL_TLS_BOOT")) + expect_fields( + boot, + { + "origin": EXPECTED_ORIGIN, + "peer_ipv4": peer_ipv4, + "plan_hash": EXPECTED_PLAN, + "factory_sha256": EXPECTED_FACTORY, + "ca_der_sha256": EXPECTED_CA, + "tls_provider": EXPECTED_PROVIDER, + "public_capability": "0", + "frame_calls": "0", + }, + "S3 boot", + ) + start = fields(exact_line(lines, "POCKET_NET_FORMAL_TLS_START")) + expect_fields( + start, + { + "origin": EXPECTED_ORIGIN, + "peer_ipv4": peer_ipv4, + "rounds": "20", + "mode": "success", + "clock_trusted": "1", + }, + "S3 start", + ) + result = fields(exact_line(lines, "POCKET_NET_FORMAL_TLS_RESULT")) + expect_fields( + result, + { + "mode": "success", + "pass": "1", + "esp_err": "ESP_OK", + "rounds": "20/20", + "requests": "40/40", + "frame_calls": "0", + "shutdown": "1", + "poison": "0x00000000", + "leases_balanced": "1", + "queued_leases": "0", + "taken_leases": "0", + "runtime_requests": "40", + }, + "S3 result", + ) + taken = integer(result, "leases_taken") + released = integer(result, "leases_released") + if taken != EXPECTED_LEASES or released != EXPECTED_LEASES: + fail("S3 result has unbalanced or unexpected body leases") + got_ip = fields(exact_line(lines, "POCKET_NET_WIFI_GOT_IP")) + if got_ip.get("ip") != board_ipv4: + fail("S3 board IP does not match the selected evidence") + dns = fields(exact_line(lines, "POCKET_NET_DNS_READY")) + expect_fields( + dns, + { + "hostname": "pocketjs.test", + "main": peer_ipv4, + "backup": peer_ipv4, + "fallback": peer_ipv4, + "readback": "exact", + }, + "S3 DNS snapshot", + ) + return taken, guest_yields, elapsed_ms + + exact_line(lines, "boot: chip revision: v1.3") + exact_line(lines, "Card init success, TRANSPORT_RX_ACTIVE") + boot = fields(exact_line(lines, "POCKET_NET_FORMAL_TLS_BOOT")) + expect_fields( + boot, + { + "board": "tab5-esp32p4", + "origin": EXPECTED_ORIGIN, + "peer_ipv4": peer_ipv4, + "dns_server": peer_ipv4, + "rounds": "20", + "requests": "40", + "test_mode": "success", + "public_capability": "0", + "distinct_tls_errors": "1", + "plan": EXPECTED_PLAN, + "factory": EXPECTED_FACTORY, + "ca": EXPECTED_CA, + "tls_provider": EXPECTED_PROVIDER, + }, + "P4 boot", + ) + resource = fields(exact_line(lines, "POCKET_NET_FORMAL_TLS_RESOURCE")) + expect_fields( + resource, + { + "run_result": "ESP_OK", + "runtime_slots": "1/1", + "active": "0", + "pending": "0", + "queued": "0", + "taken": "0", + "requests_started": "40", + "leases_taken": str(EXPECTED_LEASES), + "leases_released": str(EXPECTED_LEASES), + "poison": "0x00000000", + "guest_psram_only": "1", + "shutdown": "1", + }, + "P4 resource result", + ) + if integer(resource, "owner_stack_low_water_bytes") <= 0: + fail("P4 owner task exhausted its stack") + success = fields(exact_line(lines, "POCKET_NET_FORMAL_TLS_PASS")) + expect_fields( + success, + { + "board": "tab5-esp32p4", + "rounds": "20", + "requests": "40", + "frame_calls": "0", + "plan": EXPECTED_PLAN, + "factory": EXPECTED_FACTORY, + "ca": EXPECTED_CA, + "tls_provider": EXPECTED_PROVIDER, + "shutdown": "1", + "poison": "0x00000000", + }, + "P4 pass", + ) + leases = parse_leases(success.get("leases"), "P4 pass") + connected = fields(exact_line(lines, "POCKET_NET_WIFI_CONNECTED")) + if connected.get("ip") != board_ipv4 or connected.get("dns") != peer_ipv4: + fail("P4 board IP or DNS server does not match the selected evidence") + power = fields(exact_line(lines, "POCKET_NET_TAB5_C6_POWER")) + before_dir = integer(power, "before_dir") + before_out = integer(power, "before_out") + before_high_z = integer(power, "before_high_z") + after_dir = integer(power, "after_dir") + after_out = integer(power, "after_out") + after_high_z = integer(power, "after_high_z") + if ( + ((before_dir ^ after_dir) & ~1) != 0 + or ((before_out ^ after_out) & ~1) != 0 + or ((before_high_z ^ after_high_z) & ~1) != 0 + or (after_dir & 1) != 1 + or (after_out & 1) != 1 + or (after_high_z & 1) != 0 + ): + fail("P4 C6 power expander RMW/readback invariant failed") + return leases, guest_yields, elapsed_ms + + +def load_events(path: Path) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + previous_ns = -1 + for line_number, line in enumerate(path.read_text().splitlines(), 1): + try: + event = json.loads(line) + except json.JSONDecodeError as error: + raise ConformanceValidationError(f"{path}:{line_number}: invalid NDJSON") from error + if not isinstance(event, dict): + fail(f"{path}:{line_number}: event must be an object") + timestamp = event.get("monotonic_ns") + if not isinstance(timestamp, int) or timestamp <= previous_ns: + fail(f"{path}:{line_number}: event clock is missing or out of order") + previous_ns = timestamp + events.append(event) + return events + + +def one_event(events: list[dict[str, Any]], event_name: str, label: str) -> dict[str, Any]: + matches = [event for event in events if event.get("event") == event_name] + if len(matches) != 1: + fail(f"{label} must contain exactly one {event_name} event") + return matches[0] + + +def validate_dns(events: list[dict[str, Any]], board_ipv4: str, peer_ipv4: str) -> list[dict[str, Any]]: + if {event.get("event") for event in events} - {"dns_ready", "dns_query", "dns_stop"}: + fail("DNS evidence contains an unexpected event") + ready = one_event(events, "dns_ready", "DNS evidence") + stop = one_event(events, "dns_stop", "DNS evidence") + if ( + ready.get("authoritative_name") != "pocketjs.test" + or ready.get("authoritative_ipv4") != peer_ipv4 + or ready.get("port") != 53 + or ready.get("interface") != "en1" + or ready.get("recursion_available") is not False + or ready.get("transports") != ["udp", "tcp"] + or stop.get("reason") != "keyboard_interrupt" + ): + fail("DNS evidence is not the exact controlled authoritative profile") + queries = [event for event in events if event.get("event") == "dns_query"] + if not queries: + fail("DNS evidence contains no authoritative answer") + if any( + event.get("peer_ipv4") != board_ipv4 + or event.get("query_name") != "pocketjs.test" + or event.get("query_type") != 1 + or event.get("query_class") != 1 + or event.get("outcome") != "answer" + or event.get("answers") != 1 + or event.get("rcode") != 0 + or event.get("recursion_available") is not False + for event in queries + ): + fail("DNS evidence is not confined to the selected board and hostname") + return queries + + +def expected_connection_ids() -> list[int]: + result: list[int] = [] + for connection_id, length in enumerate(CONNECTION_LENGTHS, 1): + result.extend([connection_id] * length) + return result + + +def validate_wire(events: list[dict[str, Any]], board_ipv4: str) -> tuple[int, int, int]: + allowed = { + "peer_ready", + "tls_client_hello", + "connection_open", + "request", + "connection_close", + "peer_stop", + } + if {event.get("event") for event in events} - allowed: + fail("TLS evidence contains an unexpected or failed handshake event") + ready = one_event(events, "peer_ready", "TLS evidence") + stop = one_event(events, "peer_stop", "TLS evidence") + if ( + ready.get("transport") != "tls" + or ready.get("tls_min_version") != "1.2" + or ready.get("tls_max_version") != "1.2" + or ready.get("observe_tls_close_notify") is not True + or not isinstance(ready.get("socket_timeout_ms"), int) + or int(ready["socket_timeout_ms"]) <= 30_000 + or stop.get("reason") != "keyboard_interrupt" + ): + fail("TLS readiness is not the exact TLS 1.2 close-notify profile") + + hellos = [event for event in events if event.get("event") == "tls_client_hello"] + opens = [event for event in events if event.get("event") == "connection_open"] + requests = [event for event in events if event.get("event") == "request"] + closes = [event for event in events if event.get("event") == "connection_close"] + if len(hellos) != EXPECTED_CONNECTIONS or len(opens) != EXPECTED_CONNECTIONS or len(closes) != EXPECTED_CONNECTIONS: + fail("TLS connection lifecycle count does not match the conformance artifact") + if len(requests) != EXPECTED_WIRE_REQUESTS: + fail(f"wire request count is {len(requests)}; expected {EXPECTED_WIRE_REQUESTS}") + if any( + event.get("peer_ipv4") != board_ipv4 or event.get("server_name") != "pocketjs.test" + for event in hellos + ): + fail("ClientHello evidence has the wrong board or SNI") + if any( + event.get("peer_ipv4") != board_ipv4 + or event.get("tls_server_name") != "pocketjs.test" + or event.get("tls_version") != "TLSv1.2" + or not isinstance(event.get("tls_cipher"), str) + or not event.get("tls_cipher") + for event in opens + ): + fail("opened connection is not exact TLS 1.2 with pocketjs.test SNI") + if [event.get("connection_id") for event in opens] != list(range(1, EXPECTED_CONNECTIONS + 1)): + fail("TLS connection identifiers are missing, reused, or out of order") + + connection_ids = expected_connection_ids() + next_index = [0] * (EXPECTED_CONNECTIONS + 1) + for request_id, (event, expected, connection_id) in enumerate( + zip(requests, EXPECTED_REQUESTS, connection_ids, strict=True), 1 + ): + next_index[connection_id] += 1 + if ( + event.get("request_id") != request_id + or event.get("connection_id") != connection_id + or event.get("connection_request_index") != next_index[connection_id] + or event.get("method") != expected.method + or event.get("path") != expected.path + or event.get("query_names") != list(expected.query_names) + or event.get("body_bytes") != expected.body_bytes + or event.get("header_names") != list(expected.header_names) + ): + fail(f"wire request {request_id} does not match the exact conformance sequence") + + close_notify = 0 + for connection_id, (event, expected_requests) in enumerate(zip(closes, CONNECTION_LENGTHS, strict=True), 1): + graceful = connection_id in GRACEFUL_CONNECTIONS + if ( + event.get("connection_id") != connection_id + or event.get("requests") != expected_requests + or event.get("tls_close_notify_observed") is not graceful + or event.get("tls_close_state") != ("close_notify" if graceful else "not_observed") + ): + fail(f"connection {connection_id} close/reuse semantics do not match the matrix") + close_notify += int(graceful) + return len(requests), len(opens), close_notify + + +def analyze( + *, + board: str, + board_log: Path, + tls_events_path: Path, + dns_events_path: Path, + board_ipv4: str, + peer_ipv4: str, +) -> ConformanceSummary: + lines = board_log.read_text(errors="replace").splitlines() + leases, guest_yields, elapsed_ms = validate_board_log(lines, board, board_ipv4, peer_ipv4) + tls_events = load_events(tls_events_path) + dns_events = load_events(dns_events_path) + dns_queries = validate_dns(dns_events, board_ipv4, peer_ipv4) + wire_requests, connections, close_notify = validate_wire(tls_events, board_ipv4) + first_dns = dns_queries[0]["monotonic_ns"] + first_hello = next(event["monotonic_ns"] for event in tls_events if event.get("event") == "tls_client_hello") + last_close = next(event["monotonic_ns"] for event in reversed(tls_events) if event.get("event") == "connection_close") + if not ( + dns_events[0]["monotonic_ns"] < first_dns < first_hello + and last_close < tls_events[-1]["monotonic_ns"] + and last_close < dns_events[-1]["monotonic_ns"] + ): + fail("DNS, TLS, and stop evidence is stale, incomplete, or out of order") + return ConformanceSummary( + board=board, + board_ipv4=board_ipv4, + guest_operations=EXPECTED_GUEST_OPERATIONS, + wire_requests=wire_requests, + tls_connections=connections, + close_notify_connections=close_notify, + forced_close_connections=connections - close_notify, + dns_answers=len(dns_queries), + maximum_requests_per_connection=max(CONNECTION_LENGTHS), + leases=leases, + guest_yields=guest_yields, + elapsed_ms=elapsed_ms, + ) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Validate one PocketJS ESP Phase 1B TLS/HTTP conformance run" + ) + parser.add_argument("--board", choices=("s3", "p4"), required=True) + parser.add_argument("--board-log", type=Path, required=True) + parser.add_argument("--tls-events", type=Path, required=True) + parser.add_argument("--dns-events", type=Path, required=True) + parser.add_argument("--board-ipv4", required=True) + parser.add_argument("--peer-ipv4", required=True) + args = parser.parse_args() + try: + summary = analyze( + board=args.board, + board_log=args.board_log, + tls_events_path=args.tls_events, + dns_events_path=args.dns_events, + board_ipv4=args.board_ipv4, + peer_ipv4=args.peer_ipv4, + ) + except (OSError, ConformanceValidationError) as error: + parser.exit(1, f"phase1b conformance failed: {error}\n") + print(json.dumps(asdict(summary), sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/net-conformance-peer/phase1b_handshake_interrupt.py b/tools/net-conformance-peer/phase1b_handshake_interrupt.py new file mode 100644 index 00000000..4365b77c --- /dev/null +++ b/tools/net-conformance-peer/phase1b_handshake_interrupt.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import ipaddress +import json +import re +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + + +ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") +KEY_VALUE = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)=([^\s]+)") +ORIGIN = "https://pocketjs.test:8443" +EXPECTED_PLAN = ( + "sha256:9240cfa29c1678b49b6fed67104a39b2ad32f5dedab372af1c2a0bde3d602654" +) +EXPECTED_FACTORY = ( + "sha256:7290243f0b188d4ddbe0f55c994be320b7d2d5c848baf6dd836f14934a63a6dc" +) +EXPECTED_CERTIFICATE = ( + "9b5980ca2a7dcda57666566c06f55d85c1be5ec52fbcffe26c9976a9b520a786" +) +HANDSHAKE_DELAY_MS = 10_000 +PROFILES = { + "timeout": { + "mode": "handshake_timeout", + "error_code": "timed_out", + "connect_timeout_us": 2_000_000, + "cancel_after_ms": 0, + "minimum_elapsed_ms": 1_500, + }, + "cancel": { + "mode": "cancel", + "error_code": "aborted", + "connect_timeout_us": 0, + "cancel_after_ms": 500, + "minimum_elapsed_ms": 350, + }, +} + + +class HandshakeInterruptValidationError(ValueError): + pass + + +@dataclass(frozen=True) +class HandshakeInterruptSummary: + board: str + board_ipv4: str + profile: str + error_code: str + elapsed_ms: int + guest_yields: int + dns_answers: int + tls_client_hellos: int + tls_handshake_errors: int + handshake_delay_ms: int + + +def fail(message: str) -> None: + raise HandshakeInterruptValidationError(message) + + +def fields(line: str) -> dict[str, str]: + return dict(KEY_VALUE.findall(ANSI_ESCAPE.sub("", line))) + + +def integer(values: dict[str, str], name: str) -> int: + value = values.get(name) + if value is None: + fail(f"board log is missing {name}") + try: + return int(value, 0) + except ValueError as error: + raise HandshakeInterruptValidationError( + f"invalid integer {name}={value}" + ) from error + + +def canonical_ipv4(value: str, option: str) -> str: + try: + parsed = ipaddress.IPv4Address(value) + except ipaddress.AddressValueError as error: + raise HandshakeInterruptValidationError( + f"{option} must be an IPv4 literal" + ) from error + if str(parsed) != value or parsed.is_unspecified: + fail(f"{option} must be a canonical, specified IPv4 literal") + return value + + +def load_events(path: Path) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + for line_number, line in enumerate(path.read_text().splitlines(), 1): + try: + event = json.loads(line) + except json.JSONDecodeError as error: + raise HandshakeInterruptValidationError( + f"{path}:{line_number}: invalid NDJSON" + ) from error + if not isinstance(event, dict): + fail(f"{path}:{line_number}: event must be an object") + events.append(event) + return events + + +def exactly_one( + events: list[dict[str, Any]], event_name: str, evidence_name: str +) -> dict[str, Any]: + matches = [event for event in events if event.get("event") == event_name] + if len(matches) != 1: + fail(f"{evidence_name} must contain exactly one {event_name} event") + return matches[0] + + +def monotonic_ns(event: dict[str, Any], context: str) -> int: + value = event.get("monotonic_ns") + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + fail(f"{context} has no valid monotonic_ns") + return value + + +def validate_board_log( + lines: list[str], + board: str, + peer_ipv4: str, + profile: dict[str, object], +) -> tuple[int, int]: + if any("task_wdt" in line for line in lines): + fail("board log contains a task watchdog report") + if any("Guru Meditation" in line or "assert failed" in line for line in lines): + fail("board log contains a crash or assertion") + if any("POCKET_NET_FORMAL_TLS_FAIL" in line for line in lines): + fail("board log contains POCKET_NET_FORMAL_TLS_FAIL") + + boots = [line for line in lines if "POCKET_NET_FORMAL_TLS_BOOT" in line] + if len(boots) != 1: + fail("board evidence must contain exactly one formal TLS boot") + boot = fields(boots[0]) + if ( + boot.get("origin") != ORIGIN + or boot.get("peer_ipv4") != peer_ipv4 + or boot.get("public_capability") != "0" + ): + fail("board boot is not the exact test-only TLS interrupt profile") + + expected_mode = str(profile["mode"]) + expected_error = str(profile["error_code"]) + expected_connect_us = int(profile["connect_timeout_us"]) + expected_cancel_ms = int(profile["cancel_after_ms"]) + if board == "s3": + if ( + boot.get("plan_hash") != EXPECTED_PLAN + or boot.get("factory_sha256") != EXPECTED_FACTORY + ): + fail("S3 boot is not the exact TLS interruption artifact") + starts = [line for line in lines if "POCKET_NET_FORMAL_TLS_START" in line] + contracts = [line for line in lines if "POCKET_NET_TLS_CONTRACT" in line] + if len(starts) != 1 or len(contracts) != 1: + fail("S3 evidence is missing its exact start or descriptor snapshot") + start = fields(starts[0]) + contract = fields(contracts[0]) + if ( + start.get("mode") != expected_mode + or start.get("origin") != ORIGIN + or start.get("peer_ipv4") != peer_ipv4 + or integer(start, "connect_timeout_us") != expected_connect_us + or integer(start, "cancel_after_ms") != expected_cancel_ms + or contract.get("valid") != "1" + or contract.get("distinct_tls_errors") != "1" + or contract.get("public") != "0" + ): + fail("S3 start is not the exact delayed-handshake interrupt profile") + elif ( + boot.get("test_mode") != expected_mode + or boot.get("distinct_tls_errors") != "1" + or boot.get("plan") != EXPECTED_PLAN + or boot.get("factory") != EXPECTED_FACTORY + or integer(boot, "connect_timeout_us") != expected_connect_us + or integer(boot, "cancel_after_ms") != expected_cancel_ms + ): + fail("P4 boot is not the exact delayed-handshake interrupt profile") + + runs = [line for line in lines if "POCKET_NET_FORMAL_TLS_RUN" in line] + if len(runs) != 1: + fail("board evidence must contain exactly one formal TLS run") + run = fields(runs[0]) + if ( + run.get("status") != "ESP_ERR_INVALID_RESPONSE" + or run.get("rounds") != "0/20" + or run.get("requests") != "0/40" + or run.get("frame_calls") != "0" + or run.get("shutdown") != "1" + or run.get("poison") != "0x00000000" + or run.get("core_poison") != "0x00000000" + or run.get("poisoned_cores") != "0" + ): + fail("formal TLS run did not interrupt and shut down cleanly") + guest_yields = integer(run, "guest_yields") + elapsed_ms = integer(run, "elapsed_ms") + if ( + guest_yields <= 0 + or elapsed_ms < int(profile["minimum_elapsed_ms"]) + or elapsed_ms >= 30_000 + ): + fail("formal TLS interruption did not occur inside its bounded window") + + passes = [line for line in lines if "POCKET_NET_FORMAL_TLS_NEGATIVE_PASS" in line] + if len(passes) != 1: + fail("board evidence must contain exactly one negative-pass record") + passed = fields(passes[0]) + if ( + passed.get("mode") != expected_mode + or passed.get("error_code") != expected_error + or passed.get("shutdown") != "1" + or passed.get("poison") != "0x00000000" + ): + fail("board did not report the selected handshake interruption") + + if board == "s3": + results = [ + line + for line in lines + if "POCKET_NET_FORMAL_TLS_RESULT" in line + and "POCKET_NET_FORMAL_TLS_RUN" not in line + ] + if len(results) != 1: + fail("S3 evidence must contain exactly one result record") + result = fields(results[0]) + if ( + result.get("mode") != expected_mode + or result.get("pass") != "1" + or result.get("error_code") != expected_error + or result.get("error_operation") != "http.fetch" + or result.get("requests") != "0/40" + or result.get("shutdown") != "1" + or result.get("poison") != "0x00000000" + or result.get("leases_balanced") != "1" + or result.get("queued_leases") != "0" + or result.get("taken_leases") != "0" + ): + fail("S3 result has poison, live leases, or unexpected HTTP work") + else: + resources = [ + line for line in lines if "POCKET_NET_FORMAL_TLS_RESOURCE" in line + ] + if len(resources) != 1: + fail("P4 evidence must contain exactly one resource record") + resource = fields(resources[0]) + if ( + resource.get("active") != "0" + or resource.get("pending") != "0" + or resource.get("queued") != "0" + or resource.get("taken") != "0" + or resource.get("leases_taken") != "0" + or resource.get("leases_released") != "0" + or resource.get("leases_cleaned") != "0" + or resource.get("poison") != "0x00000000" + or resource.get("shutdown") != "1" + or passed.get("leases") != "0/0" + ): + fail("P4 result has poison, live leases, or active operations") + return guest_yields, elapsed_ms + + +def analyze( + *, + board: str, + profile_name: str, + board_log: Path, + tls_events_path: Path, + dns_events_path: Path, + board_ipv4: str, + peer_ipv4: str, +) -> HandshakeInterruptSummary: + board_ipv4 = canonical_ipv4(board_ipv4, "--board-ipv4") + peer_ipv4 = canonical_ipv4(peer_ipv4, "--peer-ipv4") + profile = PROFILES[profile_name] + lines = board_log.read_text(errors="replace").splitlines() + guest_yields, elapsed_ms = validate_board_log( + lines, board, peer_ipv4, profile + ) + + tls_events = load_events(tls_events_path) + dns_events = load_events(dns_events_path) + tls_ready = exactly_one(tls_events, "peer_ready", "TLS evidence") + tls_stop = exactly_one(tls_events, "peer_stop", "TLS evidence") + if ( + tls_ready.get("transport") != "tls" + or tls_ready.get("port") != 8443 + or tls_ready.get("tls_min_version") != "1.2" + or tls_ready.get("tls_max_version") != "1.2" + or tls_ready.get("tls_certificate_der_sha256") != EXPECTED_CERTIFICATE + or tls_ready.get("tls_handshake_delay_ms") != HANDSHAKE_DELAY_MS + or tls_ready.get("observe_tls_close_notify") is not True + or not isinstance(tls_ready.get("socket_timeout_ms"), int) + or int(tls_ready["socket_timeout_ms"]) <= HANDSHAKE_DELAY_MS + or tls_stop.get("reason") != "keyboard_interrupt" + ): + fail("peer readiness is not the exact delayed TLS 1.2 profile") + + dns_ready = exactly_one(dns_events, "dns_ready", "DNS evidence") + dns_stop = exactly_one(dns_events, "dns_stop", "DNS evidence") + if ( + dns_ready.get("authoritative_name") != "pocketjs.test" + or dns_ready.get("authoritative_ipv4") != peer_ipv4 + or dns_ready.get("port") != 53 + or dns_ready.get("interface") != "en1" + or dns_ready.get("recursion_available") is not False + or dns_ready.get("transports") != ["udp", "tcp"] + or dns_stop.get("reason") != "keyboard_interrupt" + ): + fail("DNS readiness is not the exact controlled authoritative profile") + queries = [event for event in dns_events if event.get("event") == "dns_query"] + if len(queries) != 1: + fail("DNS evidence must contain exactly one query from the selected board") + answer = queries[0] + if ( + answer.get("outcome") != "answer" + or answer.get("peer_ipv4") != board_ipv4 + or answer.get("query_name") != "pocketjs.test" + or answer.get("query_type") != 1 + or answer.get("query_class") != 1 + or answer.get("answers") != 1 + or answer.get("rcode") != 0 + or answer.get("recursion_available") is not False + ): + fail("DNS answer is not confined to the selected board and hostname") + + handshake_delay = exactly_one(tls_events, "tls_handshake_delay", "TLS evidence") + hello = exactly_one(tls_events, "tls_client_hello", "TLS evidence") + handshake_error = exactly_one(tls_events, "tls_handshake_error", "TLS evidence") + if ( + handshake_delay.get("peer_ipv4") != board_ipv4 + or handshake_delay.get("delay_ms") != HANDSHAKE_DELAY_MS + or hello.get("peer_ipv4") != board_ipv4 + or hello.get("server_name") != "pocketjs.test" + or handshake_error.get("peer_ipv4") != board_ipv4 + or handshake_error.get("error") != "SSLEOFError" + ): + fail("TLS handshake evidence has the wrong board identity or SNI") + forbidden_events = {"connection_open", "request", "response", "connection_close"} + if any(event.get("event") in forbidden_events for event in tls_events): + fail("interrupted handshake reached an opened TLS connection or HTTP request") + + tls_ready_ns = monotonic_ns(tls_ready, "peer_ready") + dns_ready_ns = monotonic_ns(dns_ready, "dns_ready") + answer_ns = monotonic_ns(answer, "dns_query") + delay_ns = monotonic_ns(handshake_delay, "tls_handshake_delay") + hello_ns = monotonic_ns(hello, "tls_client_hello") + handshake_ns = monotonic_ns(handshake_error, "tls_handshake_error") + tls_stop_ns = monotonic_ns(tls_stop, "peer_stop") + dns_stop_ns = monotonic_ns(dns_stop, "dns_stop") + if not ( + dns_ready_ns < answer_ns < dns_stop_ns + and tls_ready_ns < delay_ns < hello_ns < handshake_ns < tls_stop_ns + and answer_ns < delay_ns + and hello_ns - delay_ns >= HANDSHAKE_DELAY_MS * 1_000_000 + ): + fail("DNS and delayed-handshake evidence is stale, incomplete, or out of order") + + return HandshakeInterruptSummary( + board=board, + board_ipv4=board_ipv4, + profile=profile_name, + error_code=str(profile["error_code"]), + elapsed_ms=elapsed_ms, + guest_yields=guest_yields, + dns_answers=1, + tls_client_hellos=1, + tls_handshake_errors=1, + handshake_delay_ms=HANDSHAKE_DELAY_MS, + ) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Validate PocketJS ESP Phase 1B TLS timeout or cancellation" + ) + parser.add_argument("--board", choices=("s3", "p4"), required=True) + parser.add_argument("--profile", choices=tuple(PROFILES), required=True) + parser.add_argument("--board-log", type=Path, required=True) + parser.add_argument("--tls-events", type=Path, required=True) + parser.add_argument("--dns-events", type=Path, required=True) + parser.add_argument("--board-ipv4", required=True) + parser.add_argument("--peer-ipv4", required=True) + args = parser.parse_args() + try: + summary = analyze( + board=args.board, + profile_name=args.profile, + board_log=args.board_log, + tls_events_path=args.tls_events, + dns_events_path=args.dns_events, + board_ipv4=args.board_ipv4, + peer_ipv4=args.peer_ipv4, + ) + except (OSError, HandshakeInterruptValidationError) as error: + parser.exit(1, f"phase1b handshake interruption failed: {error}\n") + print(json.dumps(asdict(summary), sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/net-conformance-peer/phase1b_soak.py b/tools/net-conformance-peer/phase1b_soak.py new file mode 100644 index 00000000..8652f4cf --- /dev/null +++ b/tools/net-conformance-peer/phase1b_soak.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import re +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Iterable + + +ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") +KEY_VALUE = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)=([^\s]+)") +REQUESTS_PER_RUN = 40 + + +class SoakValidationError(ValueError): + pass + + +@dataclass(frozen=True) +class ResourceSample: + internal_before: int + internal_after: int + internal_minimum: int + psram_before: int + psram_after: int + psram_minimum: int + + +@dataclass(frozen=True) +class SoakSummary: + board_ipv4: str + runs: int + requests: int + duration_seconds: float + internal_peak_bytes: int + psram_peak_bytes: int + steady_internal_drift_bytes: int + steady_psram_drift_bytes: int + tls_connections: int + close_notify_connections: int + minimum_guest_yields: int + + +def fail(message: str) -> None: + raise SoakValidationError(message) + + +def fields(line: str) -> dict[str, str]: + return dict(KEY_VALUE.findall(ANSI_ESCAPE.sub("", line))) + + +def integer(values: dict[str, str], name: str) -> int: + value = values.get(name) + if value is None: + fail(f"board log is missing {name}") + try: + return int(value, 0) + except ValueError as error: + raise SoakValidationError(f"invalid integer {name}={value}") from error + + +def load_events(path: Path) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + for line_number, line in enumerate(path.read_text().splitlines(), 1): + try: + event = json.loads(line) + except json.JSONDecodeError as error: + raise SoakValidationError( + f"{path}:{line_number}: invalid NDJSON" + ) from error + if not isinstance(event, dict): + fail(f"{path}:{line_number}: event must be an object") + events.append(event) + return events + + +def parse_s3_resources(lines: Iterable[str]) -> list[ResourceSample]: + before: dict[str, str] | None = None + samples: list[ResourceSample] = [] + for line in lines: + if "POCKET_NET_RESOURCE phase=formal_before" in line: + if before is not None: + fail("S3 resource log has two formal_before records") + before = fields(line) + elif "POCKET_NET_RESOURCE phase=formal_after" in line: + if before is None: + fail("S3 formal_after has no matching formal_before") + after = fields(line) + samples.append( + ResourceSample( + internal_before=integer(before, "internal_free"), + internal_after=integer(after, "internal_free"), + internal_minimum=integer(after, "internal_min"), + psram_before=integer(before, "psram_free"), + psram_after=integer(after, "psram_free"), + psram_minimum=integer(after, "psram_min"), + ) + ) + before = None + if before is not None: + fail("S3 final formal_before has no matching formal_after") + return samples + + +def parse_p4_resources(lines: Iterable[str]) -> list[ResourceSample]: + samples: list[ResourceSample] = [] + for line in lines: + if "POCKET_NET_FORMAL_TLS_RESOURCE" not in line: + continue + values = fields(line) + samples.append( + ResourceSample( + internal_before=integer(values, "internal_before"), + internal_after=integer(values, "internal_after"), + internal_minimum=integer(values, "internal_min"), + psram_before=integer(values, "psram_before"), + psram_after=integer(values, "psram_after"), + psram_minimum=integer(values, "psram_min"), + ) + ) + return samples + + +def successful_run_lines(lines: Iterable[str], board: str) -> list[str]: + if board == "s3": + result = [ + line + for line in lines + if "POCKET_NET_FORMAL_TLS_RESULT" in line + and "mode=success" in line + and "pass=1" in line + ] + for line in result: + values = fields(line) + if ( + values.get("rounds") != "20/20" + or values.get("requests") != "40/40" + or values.get("shutdown") != "1" + or values.get("poison") != "0x00000000" + or values.get("leases_balanced") != "1" + ): + fail(f"invalid S3 success record: {line.strip()}") + return result + + result = [line for line in lines if "POCKET_NET_FORMAL_TLS_PASS" in line] + for line in result: + values = fields(line) + if ( + values.get("rounds") != "20" + or values.get("requests") != "40" + or values.get("shutdown") != "1" + or values.get("poison") != "0x00000000" + or values.get("leases") != "40/40" + ): + fail(f"invalid P4 success record: {line.strip()}") + return result + + +def validate_connection_requests( + requests: list[dict[str, Any]], connection_id: object +) -> None: + connection_requests = [ + event for event in requests if event.get("connection_id") == connection_id + ] + if len(connection_requests) != REQUESTS_PER_RUN: + fail(f"connection {connection_id} carried {len(connection_requests)} requests") + for expected_index, event in enumerate(connection_requests, 1): + expected_health = expected_index % 2 == 1 + expected_headers = ( + ["accept-encoding", "host"] + if expected_health + else ["accept-encoding", "host", "transfer-encoding"] + ) + if ( + event.get("connection_request_index") != expected_index + or event.get("method") != ("GET" if expected_health else "POST") + or event.get("path") != ("/health" if expected_health else "/echo") + or event.get("body_bytes") != (0 if expected_health else 12) + or event.get("query_names") != [] + or event.get("header_names") != expected_headers + ): + fail( + f"connection {connection_id} request {expected_index} " + "does not match the exact health/streamed-echo sequence" + ) + + +def analyze( + *, + board: str, + board_log: Path, + tls_events_path: Path, + dns_events_path: Path, + board_ipv4: str, + peer_ipv4: str, + minimum_duration_seconds: float, + minimum_runs: int, + max_internal_peak_bytes: int, + max_psram_peak_bytes: int, + max_internal_drift_bytes: int, + max_psram_drift_bytes: int, +) -> SoakSummary: + lines = board_log.read_text(errors="replace").splitlines() + if any("task_wdt" in line for line in lines): + fail("board log contains a task watchdog report") + if any("POCKET_NET_FORMAL_TLS_FAIL" in line for line in lines): + fail("board log contains POCKET_NET_FORMAL_TLS_FAIL") + successes = successful_run_lines(lines, board) + if len(successes) < minimum_runs: + fail(f"only {len(successes)} successful runs; need {minimum_runs}") + guest_yields = [integer(fields(line), "guest_yields") for line in successes] + if any(value == 0 for value in guest_yields): + fail("a successful run did not cooperatively yield the Guest owner task") + + resources = ( + parse_s3_resources(lines) if board == "s3" else parse_p4_resources(lines) + ) + if len(resources) != len(successes): + fail( + f"resource samples ({len(resources)}) do not match successful runs " + f"({len(successes)})" + ) + + tls_events = load_events(tls_events_path) + dns_events = load_events(dns_events_path) + ready = [event for event in tls_events if event.get("event") == "peer_ready"] + if len(ready) != 1 or not isinstance(ready[0].get("socket_timeout_ms"), int): + fail("TLS evidence must contain one peer timeout snapshot") + if int(ready[0]["socket_timeout_ms"]) <= 30_000: + fail("peer socket timeout must exceed the 30-second client deadline") + if ( + ready[0].get("transport") != "tls" + or ready[0].get("tls_min_version") != "1.2" + or ready[0].get("tls_max_version") != "1.2" + or ready[0].get("observe_tls_close_notify") is not True + ): + fail("peer readiness is not the exact TLS 1.2 close-notify profile") + dns_ready = [event for event in dns_events if event.get("event") == "dns_ready"] + if ( + len(dns_ready) != 1 + or dns_ready[0].get("authoritative_name") != "pocketjs.test" + or dns_ready[0].get("authoritative_ipv4") != peer_ipv4 + or dns_ready[0].get("port") != 53 + or dns_ready[0].get("interface") != "en1" + or dns_ready[0].get("recursion_available") is not False + or dns_ready[0].get("transports") != ["udp", "tcp"] + ): + fail("DNS readiness is not the exact controlled authoritative profile") + answers = [ + event + for event in dns_events + if event.get("event") == "dns_query" and event.get("outcome") == "answer" + ] + if not answers: + fail("no authoritative DNS answer was recorded") + if any( + event.get("peer_ipv4") != board_ipv4 + or event.get("query_name") != "pocketjs.test" + or event.get("query_type") != 1 + or event.get("query_class") != 1 + or event.get("answers") != 1 + or event.get("rcode") != 0 + or event.get("recursion_available") is not False + for event in answers + ): + fail("DNS evidence is not confined to the selected board and hostname") + + hellos = [event for event in tls_events if event.get("event") == "tls_client_hello"] + opens = [event for event in tls_events if event.get("event") == "connection_open"] + requests = [event for event in tls_events if event.get("event") == "request"] + closes = [event for event in tls_events if event.get("event") == "connection_close"] + handshake_errors = [ + event for event in tls_events if event.get("event") == "tls_handshake_error" + ] + runs = len(successes) + if handshake_errors: + fail("positive soak contains a TLS handshake error") + if len(hellos) != runs or len(opens) != runs or len(closes) != runs: + fail( + "successful runs and TLS connection lifecycle counts differ: " + f"runs={runs} hellos={len(hellos)} opens={len(opens)} closes={len(closes)}" + ) + if len(requests) != runs * REQUESTS_PER_RUN: + fail(f"received {len(requests)} requests; expected {runs * REQUESTS_PER_RUN}") + if any( + event.get("peer_ipv4") != board_ipv4 + or event.get("server_name") != "pocketjs.test" + for event in hellos + ): + fail("ClientHello evidence has the wrong peer or SNI") + if any( + event.get("peer_ipv4") != board_ipv4 + or event.get("tls_server_name") != "pocketjs.test" + or event.get("tls_version") != "TLSv1.2" + for event in opens + ): + fail("opened connection is not exact TLS 1.2 with pocketjs.test SNI") + if any( + event.get("tls_close_state") != "close_notify" + or event.get("tls_close_notify_observed") is not True + or event.get("requests") != REQUESTS_PER_RUN + for event in closes + ): + fail("connection did not end with close_notify after 40 requests") + + connection_ids = {event.get("connection_id") for event in opens} + if None in connection_ids or len(connection_ids) != runs: + fail("connection identifiers are missing or reused") + for connection_id in connection_ids: + validate_connection_requests(requests, connection_id) + + first_ns = min(int(event["monotonic_ns"]) for event in hellos) + last_ns = max(int(event["monotonic_ns"]) for event in closes) + duration_seconds = (last_ns - first_ns) / 1_000_000_000 + if duration_seconds < minimum_duration_seconds: + fail( + f"wire duration {duration_seconds:.3f}s is below " + f"{minimum_duration_seconds:.3f}s" + ) + + initial = resources[0] + internal_peak = initial.internal_before - min( + sample.internal_minimum for sample in resources + ) + psram_peak = initial.psram_before - min(sample.psram_minimum for sample in resources) + if internal_peak < 0 or internal_peak > max_internal_peak_bytes: + fail(f"internal peak {internal_peak} exceeds {max_internal_peak_bytes}") + if psram_peak < 0 or psram_peak > max_psram_peak_bytes: + fail(f"PSRAM peak {psram_peak} exceeds {max_psram_peak_bytes}") + + steady_internal = resources[0].internal_after + steady_psram = resources[0].psram_after + internal_drift = ( + max(0, steady_internal - min(sample.internal_after for sample in resources[1:])) + if len(resources) > 1 + else 0 + ) + psram_drift = ( + max(0, steady_psram - min(sample.psram_after for sample in resources[1:])) + if len(resources) > 1 + else 0 + ) + if internal_drift > max_internal_drift_bytes: + fail(f"steady internal drift {internal_drift} exceeds {max_internal_drift_bytes}") + if psram_drift > max_psram_drift_bytes: + fail(f"steady PSRAM drift {psram_drift} exceeds {max_psram_drift_bytes}") + + return SoakSummary( + board_ipv4=board_ipv4, + runs=runs, + requests=len(requests), + duration_seconds=duration_seconds, + internal_peak_bytes=internal_peak, + psram_peak_bytes=psram_peak, + steady_internal_drift_bytes=internal_drift, + steady_psram_drift_bytes=psram_drift, + tls_connections=len(opens), + close_notify_connections=len(closes), + minimum_guest_yields=min(guest_yields), + ) + + +def positive_integer(value: str) -> int: + parsed = int(value) + if parsed <= 0: + raise argparse.ArgumentTypeError("must be positive") + return parsed + + +def nonnegative_integer(value: str) -> int: + parsed = int(value) + if parsed < 0: + raise argparse.ArgumentTypeError("must be nonnegative") + return parsed + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Validate the PocketJS ESP Phase 1B repeated-handshake soak" + ) + parser.add_argument("--board", choices=("s3", "p4"), required=True) + parser.add_argument("--board-log", type=Path, required=True) + parser.add_argument("--tls-events", type=Path, required=True) + parser.add_argument("--dns-events", type=Path, required=True) + parser.add_argument("--board-ipv4", required=True) + parser.add_argument("--peer-ipv4", required=True) + parser.add_argument("--minimum-duration-seconds", type=float, default=900.0) + parser.add_argument("--minimum-runs", type=positive_integer, default=2) + parser.add_argument("--max-internal-peak-bytes", type=positive_integer, default=65536) + parser.add_argument("--max-psram-peak-bytes", type=positive_integer, default=4194304) + parser.add_argument("--max-internal-drift-bytes", type=nonnegative_integer, default=16384) + parser.add_argument("--max-psram-drift-bytes", type=nonnegative_integer, default=4096) + args = parser.parse_args() + if args.minimum_duration_seconds <= 0: + parser.error("--minimum-duration-seconds must be positive") + try: + summary = analyze( + board=args.board, + board_log=args.board_log, + tls_events_path=args.tls_events, + dns_events_path=args.dns_events, + board_ipv4=args.board_ipv4, + peer_ipv4=args.peer_ipv4, + minimum_duration_seconds=args.minimum_duration_seconds, + minimum_runs=args.minimum_runs, + max_internal_peak_bytes=args.max_internal_peak_bytes, + max_psram_peak_bytes=args.max_psram_peak_bytes, + max_internal_drift_bytes=args.max_internal_drift_bytes, + max_psram_drift_bytes=args.max_psram_drift_bytes, + ) + except (OSError, SoakValidationError) as error: + parser.exit(1, f"phase1b soak failed: {error}\n") + print(json.dumps(asdict(summary), sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/net-conformance-peer/phase1b_version_reject.py b/tools/net-conformance-peer/phase1b_version_reject.py new file mode 100644 index 00000000..b1eabd9a --- /dev/null +++ b/tools/net-conformance-peer/phase1b_version_reject.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import ipaddress +import json +import re +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + + +ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") +KEY_VALUE = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)=([^\s]+)") + + +class VersionRejectValidationError(ValueError): + pass + + +@dataclass(frozen=True) +class VersionRejectSummary: + board: str + board_ipv4: str + dns_answers: int + tls_handshake_errors: int + guest_yields: int + elapsed_ms: int + error_code: str + + +def fail(message: str) -> None: + raise VersionRejectValidationError(message) + + +def fields(line: str) -> dict[str, str]: + return dict(KEY_VALUE.findall(ANSI_ESCAPE.sub("", line))) + + +def integer(values: dict[str, str], name: str) -> int: + value = values.get(name) + if value is None: + fail(f"board log is missing {name}") + try: + return int(value, 0) + except ValueError as error: + raise VersionRejectValidationError(f"invalid integer {name}={value}") from error + + +def monotonic_ns(event: dict[str, Any], context: str) -> int: + value = event.get("monotonic_ns") + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + fail(f"{context} has no valid monotonic_ns") + return value + + +def canonical_ipv4(value: str, option: str) -> str: + try: + parsed = ipaddress.IPv4Address(value) + except ipaddress.AddressValueError as error: + raise VersionRejectValidationError(f"{option} must be an IPv4 literal") from error + if str(parsed) != value or parsed.is_unspecified: + fail(f"{option} must be a canonical, specified IPv4 literal") + return value + + +def load_events(path: Path) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + for line_number, line in enumerate(path.read_text().splitlines(), 1): + try: + event = json.loads(line) + except json.JSONDecodeError as error: + raise VersionRejectValidationError( + f"{path}:{line_number}: invalid NDJSON" + ) from error + if not isinstance(event, dict): + fail(f"{path}:{line_number}: event must be an object") + events.append(event) + return events + + +def exactly_one( + events: list[dict[str, Any]], event_name: str, evidence_name: str +) -> dict[str, Any]: + matches = [event for event in events if event.get("event") == event_name] + if len(matches) != 1: + fail(f"{evidence_name} must contain exactly one {event_name} event") + return matches[0] + + +def validate_board_log( + lines: list[str], board: str, peer_ipv4: str +) -> tuple[int, int]: + if any("task_wdt" in line for line in lines): + fail("board log contains a task watchdog report") + if any("Guru Meditation" in line or "assert failed" in line for line in lines): + fail("board log contains a crash or assertion") + if any("POCKET_NET_FORMAL_TLS_FAIL" in line for line in lines): + fail("board log contains POCKET_NET_FORMAL_TLS_FAIL") + + boots = [line for line in lines if "POCKET_NET_FORMAL_TLS_BOOT" in line] + if len(boots) != 1: + fail("board evidence must contain exactly one formal TLS boot") + boot = fields(boots[0]) + if ( + boot.get("origin") != "https://pocketjs.test:8443" + or boot.get("peer_ipv4") != peer_ipv4 + or boot.get("public_capability") != "0" + ): + fail("board boot is not the exact test-only TLS version profile") + if board == "s3": + starts = [line for line in lines if "POCKET_NET_FORMAL_TLS_START" in line] + contracts = [line for line in lines if "POCKET_NET_TLS_CONTRACT" in line] + if len(starts) != 1 or len(contracts) != 1: + fail("S3 evidence is missing its exact start or descriptor snapshot") + start = fields(starts[0]) + contract = fields(contracts[0]) + if ( + start.get("mode") != "version_reject" + or start.get("origin") != "https://pocketjs.test:8443" + or start.get("peer_ipv4") != peer_ipv4 + or contract.get("valid") != "1" + or contract.get("distinct_tls_errors") != "1" + or contract.get("public") != "0" + ): + fail("S3 start is not the exact test-only TLS version profile") + elif ( + boot.get("test_mode") != "version_reject" + or boot.get("distinct_tls_errors") != "1" + ): + fail("P4 boot is not the exact test-only TLS version profile") + + runs = [line for line in lines if "POCKET_NET_FORMAL_TLS_RUN" in line] + if len(runs) != 1: + fail("board evidence must contain exactly one formal TLS run") + run = fields(runs[0]) + if ( + run.get("status") != "ESP_ERR_INVALID_RESPONSE" + or run.get("rounds") != "0/20" + or run.get("requests") != "0/40" + or run.get("frame_calls") != "0" + or run.get("shutdown") != "1" + or run.get("poison") != "0x00000000" + or run.get("core_poison") != "0x00000000" + or run.get("poisoned_cores") != "0" + ): + fail("formal TLS run did not fail closed and shut down cleanly") + guest_yields = integer(run, "guest_yields") + elapsed_ms = integer(run, "elapsed_ms") + if guest_yields <= 0 or elapsed_ms <= 0: + fail("formal TLS run did not record bounded cooperative execution") + + passes = [line for line in lines if "POCKET_NET_FORMAL_TLS_NEGATIVE_PASS" in line] + if len(passes) != 1: + fail("board evidence must contain exactly one negative-pass record") + passed = fields(passes[0]) + if ( + passed.get("mode") != "version_reject" + or passed.get("error_code") != "tls_alert" + or passed.get("shutdown") != "1" + or passed.get("poison") != "0x00000000" + ): + fail("board did not report the expected tls_alert version rejection") + + if board == "s3": + results = [ + line + for line in lines + if "POCKET_NET_FORMAL_TLS_RESULT" in line + and "POCKET_NET_FORMAL_TLS_RUN" not in line + ] + if len(results) != 1: + fail("S3 evidence must contain exactly one result record") + result = fields(results[0]) + if ( + result.get("mode") != "version_reject" + or result.get("pass") != "1" + or result.get("requests") != "0/40" + or result.get("shutdown") != "1" + or result.get("poison") != "0x00000000" + or result.get("leases_balanced") != "1" + or result.get("queued_leases") != "0" + or result.get("taken_leases") != "0" + ): + fail("S3 result has poison, live leases, or unexpected HTTP work") + else: + resources = [ + line for line in lines if "POCKET_NET_FORMAL_TLS_RESOURCE" in line + ] + if len(resources) != 1: + fail("P4 evidence must contain exactly one resource record") + resource = fields(resources[0]) + if ( + resource.get("active") != "0" + or resource.get("pending") != "0" + or resource.get("queued") != "0" + or resource.get("taken") != "0" + or resource.get("leases_taken") != "0" + or resource.get("leases_released") != "0" + or resource.get("leases_cleaned") != "0" + or resource.get("poison") != "0x00000000" + or resource.get("shutdown") != "1" + ): + fail("P4 result has poison, live leases, or active operations") + if passed.get("leases") != "0/0": + fail("P4 negative-pass record does not report balanced leases") + + return guest_yields, elapsed_ms + + +def analyze( + *, + board: str, + board_log: Path, + tls_events_path: Path, + dns_events_path: Path, + board_ipv4: str, + peer_ipv4: str, +) -> VersionRejectSummary: + board_ipv4 = canonical_ipv4(board_ipv4, "--board-ipv4") + peer_ipv4 = canonical_ipv4(peer_ipv4, "--peer-ipv4") + lines = board_log.read_text(errors="replace").splitlines() + guest_yields, elapsed_ms = validate_board_log(lines, board, peer_ipv4) + + tls_events = load_events(tls_events_path) + dns_events = load_events(dns_events_path) + tls_ready = exactly_one(tls_events, "peer_ready", "TLS evidence") + tls_stop = exactly_one(tls_events, "peer_stop", "TLS evidence") + if ( + tls_ready.get("transport") != "tls" + or tls_ready.get("port") != 8443 + or tls_ready.get("tls_min_version") != "1.3" + or tls_ready.get("tls_max_version") != "1.3" + or tls_ready.get("observe_tls_close_notify") is not True + or not isinstance(tls_ready.get("socket_timeout_ms"), int) + or int(tls_ready["socket_timeout_ms"]) <= 30_000 + or tls_stop.get("reason") != "keyboard_interrupt" + ): + fail("peer readiness is not the exact TLS 1.3-only rejection profile") + + dns_ready = exactly_one(dns_events, "dns_ready", "DNS evidence") + dns_stop = exactly_one(dns_events, "dns_stop", "DNS evidence") + if ( + dns_ready.get("authoritative_name") != "pocketjs.test" + or dns_ready.get("authoritative_ipv4") != peer_ipv4 + or dns_ready.get("port") != 53 + or dns_ready.get("interface") != "en1" + or dns_ready.get("recursion_available") is not False + or dns_ready.get("transports") != ["udp", "tcp"] + or dns_stop.get("reason") != "keyboard_interrupt" + ): + fail("DNS readiness is not the exact controlled authoritative profile") + + queries = [event for event in dns_events if event.get("event") == "dns_query"] + if len(queries) != 1: + fail("DNS evidence must contain exactly one query from the selected board") + answer = queries[0] + if ( + answer.get("outcome") != "answer" + or answer.get("peer_ipv4") != board_ipv4 + or answer.get("query_name") != "pocketjs.test" + or answer.get("query_type") != 1 + or answer.get("query_class") != 1 + or answer.get("answers") != 1 + or answer.get("rcode") != 0 + or answer.get("recursion_available") is not False + ): + fail("DNS answer is not confined to the selected board and hostname") + + handshake_errors = [ + event for event in tls_events if event.get("event") == "tls_handshake_error" + ] + if len(handshake_errors) != 1: + fail("TLS evidence must contain exactly one failed handshake") + handshake_error = handshake_errors[0] + if ( + handshake_error.get("peer_ipv4") != board_ipv4 + or handshake_error.get("error") != "SSLError" + ): + fail("TLS handshake failure does not belong to the selected board") + + hellos = [event for event in tls_events if event.get("event") == "tls_client_hello"] + if len(hellos) > 1: + fail("TLS evidence contains more than one ClientHello") + if hellos and ( + hellos[0].get("peer_ipv4") != board_ipv4 + or hellos[0].get("server_name") != "pocketjs.test" + ): + fail("ClientHello evidence has the wrong peer or SNI") + + forbidden_events = { + "connection_open", + "request", + "response", + "connection_close", + } + if any(event.get("event") in forbidden_events for event in tls_events): + fail("version rejection reached an opened TLS connection or HTTP request") + + tls_ready_ns = monotonic_ns(tls_ready, "peer_ready") + dns_ready_ns = monotonic_ns(dns_ready, "dns_ready") + answer_ns = monotonic_ns(answer, "dns_query") + handshake_ns = monotonic_ns(handshake_error, "tls_handshake_error") + tls_stop_ns = monotonic_ns(tls_stop, "peer_stop") + dns_stop_ns = monotonic_ns(dns_stop, "dns_stop") + if not ( + dns_ready_ns < answer_ns < dns_stop_ns + and tls_ready_ns < handshake_ns < tls_stop_ns + and answer_ns < handshake_ns + ): + fail("DNS and TLS evidence is stale, incomplete, or out of order") + + return VersionRejectSummary( + board=board, + board_ipv4=board_ipv4, + dns_answers=1, + tls_handshake_errors=1, + guest_yields=guest_yields, + elapsed_ms=elapsed_ms, + error_code="tls_alert", + ) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Validate the PocketJS ESP Phase 1B TLS version rejection" + ) + parser.add_argument("--board", choices=("s3", "p4"), required=True) + parser.add_argument("--board-log", type=Path, required=True) + parser.add_argument("--tls-events", type=Path, required=True) + parser.add_argument("--dns-events", type=Path, required=True) + parser.add_argument("--board-ipv4", required=True) + parser.add_argument("--peer-ipv4", required=True) + args = parser.parse_args() + try: + summary = analyze( + board=args.board, + board_log=args.board_log, + tls_events_path=args.tls_events, + dns_events_path=args.dns_events, + board_ipv4=args.board_ipv4, + peer_ipv4=args.peer_ipv4, + ) + except (OSError, VersionRejectValidationError) as error: + parser.exit(1, f"phase1b version rejection failed: {error}\n") + print(json.dumps(asdict(summary), sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/net-conformance-peer/test_dns_peer.py b/tools/net-conformance-peer/test_dns_peer.py new file mode 100644 index 00000000..c59cb81d --- /dev/null +++ b/tools/net-conformance-peer/test_dns_peer.py @@ -0,0 +1,625 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import importlib.util +import ipaddress +import json +import os +import signal +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path +from unittest import mock + + +MODULE_PATH = Path(__file__).with_name("dns_peer.py") +SPEC = importlib.util.spec_from_file_location("pocketjs_dns_peer", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +dns_peer = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = dns_peer +SPEC.loader.exec_module(dns_peer) + + +class RecordingSink: + def __init__(self) -> None: + self._condition = threading.Condition() + self.records: list[tuple[str, dict[str, object]]] = [] + + def emit(self, event: str, **fields: object) -> None: + with self._condition: + self.records.append((event, fields)) + self._condition.notify_all() + + def wait_for(self, event: str, timeout: float = 2) -> bool: + with self._condition: + return self._condition.wait_for( + lambda: any(name == event for name, _ in self.records), timeout + ) + + def wait_for_count(self, event: str, count: int, timeout: float = 2) -> bool: + with self._condition: + return self._condition.wait_for( + lambda: sum(name == event for name, _ in self.records) >= count, + timeout, + ) + + +class FailingSink: + def __init__(self) -> None: + self.ready = threading.Event() + + def emit(self, event: str, **fields: object) -> None: + del fields + if event == "dns_ready": + self.ready.set() + return + raise OSError("injected event sink failure") + +def encode_name(name: str) -> bytes: + labels = name.rstrip(".").split(".") if name.rstrip(".") else [] + return b"".join(bytes((len(label),)) + label.encode() for label in labels) + b"\0" + + +def make_opt( + *, + version: int = 0, + payload_bytes: int = 4096, + flags: int = 0, + options: bytes = b"", + declared_length: int | None = None, +) -> bytes: + ttl = (version << 16) | flags + data_length = len(options) if declared_length is None else declared_length + return b"\0" + struct.pack( + "!HHIH", dns_peer.TYPE_OPT, payload_bytes, ttl, data_length + ) + options + + +def make_query( + name: str = "pocketjs.test", + *, + transaction_id: int = 0x1234, + flags: int = dns_peer.FLAG_RD, + query_type: int = dns_peer.TYPE_A, + query_class: int = dns_peer.CLASS_IN, + additional: tuple[bytes, ...] = (), + question_count: int = 1, +) -> bytes: + return ( + struct.pack( + "!HHHHHH", + transaction_id, + flags, + question_count, + 0, + 0, + len(additional), + ) + + encode_name(name) + + struct.pack("!HH", query_type, query_class) + + b"".join(additional) + ) + + +def response_header(response: bytes) -> tuple[int, int, int, int, int, int]: + return struct.unpack_from("!HHHHHH", response) + + +def question_end(response: bytes) -> int: + offset = dns_peer.DNS_HEADER_BYTES + while response[offset]: + offset += 1 + response[offset] + return offset + 1 + 4 + + +def answer_ipv4(response: bytes) -> tuple[int, int, str]: + offset = question_end(response) + pointer, record_type, record_class, ttl, data_length = struct.unpack_from( + "!HHHIH", response, offset + ) + self_address = socket.inet_ntoa(response[offset + 12 : offset + 16]) + if pointer != 0xC00C or record_type != 1 or record_class != 1 or data_length != 4: + raise AssertionError("unexpected A response encoding") + return ttl, offset + 16, self_address + + +def receive_exact(connection: socket.socket, byte_count: int) -> bytes: + chunks = bytearray() + while len(chunks) < byte_count: + chunk = connection.recv(byte_count - len(chunks)) + if not chunk: + raise ConnectionError("connection closed") + chunks.extend(chunk) + return bytes(chunks) + + +class DnsPeerTest(unittest.TestCase): + def ensure_process_stopped(self, process: subprocess.Popen[str]) -> None: + if process.poll() is not None: + return + process.send_signal(signal.SIGINT) + try: + process.communicate(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.communicate(timeout=3) + + def start_server( + self, allowed_cidrs: tuple[str, ...] = ("127.0.0.0/8",) + ) -> tuple[dns_peer.DnsPeerServer, RecordingSink, int]: + events = RecordingSink() + server = dns_peer.DnsPeerServer( + ("127.0.0.1", 0), + authoritative_name="pocketjs.test", + authoritative_address=ipaddress.IPv4Address("172.16.10.126"), + ttl_seconds=30, + allowed_networks=tuple( + ipaddress.IPv4Network(cidr) for cidr in allowed_cidrs + ), + interface=None, + socket_timeout_ms=200, + events=events, + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + self.assertTrue(events.wait_for("dns_ready")) + + def stop() -> None: + server.shutdown() + thread.join(timeout=2) + self.assertFalse(thread.is_alive()) + + self.addCleanup(stop) + return server, events, server.server_address[1] + + def udp_query(self, port: int, query: bytes, timeout: float = 0.5) -> bytes: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client: + client.settimeout(timeout) + client.sendto(query, ("127.0.0.1", port)) + return client.recvfrom(dns_peer.MAX_DNS_MESSAGE_BYTES + 1)[0] + + def tcp_query(self, port: int, query: bytes) -> bytes: + with socket.create_connection(("127.0.0.1", port), timeout=1) as client: + client.sendall(struct.pack("!H", len(query)) + query) + response_length = struct.unpack("!H", receive_exact(client, 2))[0] + return receive_exact(client, response_length) + + def assert_exact_answer(self, response: bytes, transaction_id: int = 0x1234) -> None: + response_id, flags, questions, answers, authorities, additionals = response_header( + response + ) + self.assertEqual(response_id, transaction_id) + self.assertTrue(flags & dns_peer.FLAG_QR) + self.assertTrue(flags & dns_peer.FLAG_AA) + self.assertTrue(flags & dns_peer.FLAG_RD) + self.assertFalse(flags & dns_peer.FLAG_RA) + self.assertEqual(flags & 0xF, dns_peer.RCODE_NOERROR) + self.assertEqual((questions, answers, authorities, additionals), (1, 1, 0, 0)) + ttl, end, address = answer_ipv4(response) + self.assertEqual(ttl, 30) + self.assertEqual(address, "172.16.10.126") + self.assertEqual(end, len(response)) + + def test_udp_and_tcp_exact_a_are_authoritative(self) -> None: + _, events, port = self.start_server() + query = make_query(name="PocketJS.TEST") + self.assert_exact_answer(self.udp_query(port, query)) + self.assert_exact_answer(self.tcp_query(port, query)) + answers = [ + fields + for event, fields in events.records + if event == "dns_query" and fields["outcome"] == "answer" + ] + self.assertEqual({answer["transport"] for answer in answers}, {"udp", "tcp"}) + self.assertTrue(all(answer["recursion_available"] is False for answer in answers)) + + def test_recursion_desired_never_enables_recursion(self) -> None: + _, _, port = self.start_server() + exact = self.udp_query(port, make_query(flags=dns_peer.FLAG_RD)) + self.assertEqual(response_header(exact)[1] & 0xF, dns_peer.RCODE_NOERROR) + self.assertFalse(response_header(exact)[1] & dns_peer.FLAG_RA) + + outside = self.udp_query(port, make_query("example.com", flags=dns_peer.FLAG_RD)) + _, flags, questions, answers, _, _ = response_header(outside) + self.assertEqual(flags & 0xF, dns_peer.RCODE_REFUSED) + self.assertTrue(flags & dns_peer.FLAG_RD) + self.assertFalse(flags & dns_peer.FLAG_RA) + self.assertEqual((questions, answers), (1, 0)) + + def test_descendant_is_nxdomain_and_other_type_is_refused(self) -> None: + _, _, port = self.start_server() + missing = self.udp_query(port, make_query("missing.pocketjs.test")) + missing_flags = response_header(missing)[1] + self.assertEqual(missing_flags & 0xF, dns_peer.RCODE_NXDOMAIN) + self.assertTrue(missing_flags & dns_peer.FLAG_AA) + + aaaa = self.udp_query(port, make_query(query_type=28)) + aaaa_flags = response_header(aaaa)[1] + self.assertEqual(aaaa_flags & 0xF, dns_peer.RCODE_REFUSED) + self.assertFalse(aaaa_flags & dns_peer.FLAG_AA) + + def test_disallowed_source_is_silently_dropped(self) -> None: + _, events, port = self.start_server(("10.0.0.0/8",)) + with self.assertRaises(socket.timeout): + self.udp_query(port, make_query(), timeout=0.2) + drops = [fields for event, fields in events.records if event == "dns_drop"] + self.assertEqual(drops[0]["reason"], "source_not_allowed") + + with socket.create_connection(("127.0.0.1", port), timeout=1) as client: + client.sendall(struct.pack("!H", len(make_query())) + make_query()) + try: + closed = client.recv(1) + except ConnectionResetError: + closed = b"" + self.assertEqual(closed, b"") + + def test_short_oversized_and_response_messages_are_dropped(self) -> None: + _, events, port = self.start_server() + messages = ( + b"\x12\x34", + b"x" * (dns_peer.MAX_DNS_MESSAGE_BYTES + 1), + make_query(flags=dns_peer.FLAG_QR), + ) + for message in messages: + with self.subTest(message_bytes=len(message)): + with self.assertRaises(socket.timeout): + self.udp_query(port, message, timeout=0.2) + outcomes = [ + fields.get("reason") + for event, fields in events.records + if event in {"dns_drop", "dns_query"} + ] + self.assertIn("message_too_short", outcomes) + self.assertIn("message_too_large", outcomes) + self.assertIn("response_message_not_accepted", outcomes) + + def test_malformed_question_returns_format_error(self) -> None: + _, _, port = self.start_server() + malformed = ( + make_query(question_count=2), + struct.pack("!HHHHHH", 0x1234, 0, 1, 0, 0, 0) + + b"\xc0\x0c" + + struct.pack("!HH", 1, 1), + make_query() + b"trailing", + ) + for query in malformed: + with self.subTest(query=query): + response = self.udp_query(port, query) + _, flags, questions, answers, authorities, additionals = response_header( + response + ) + self.assertEqual(flags & 0xF, dns_peer.RCODE_FORMERR) + self.assertEqual( + (questions, answers, authorities, additionals), (0, 0, 0, 0) + ) + + def test_edns0_is_bounded_and_unknown_options_are_not_echoed(self) -> None: + _, _, port = self.start_server() + option = struct.pack("!HH3s", 65001, 3, b"abc") + response = self.udp_query( + port, + make_query( + additional=( + make_opt( + payload_bytes=65535, + flags=dns_peer.EDNS_DO, + options=option, + ), + ) + ), + ) + _, flags, _, answers, _, additionals = response_header(response) + self.assertEqual(flags & 0xF, dns_peer.RCODE_NOERROR) + self.assertEqual((answers, additionals), (1, 1)) + _, offset, _ = answer_ipv4(response) + owner, record_type, payload, ttl, data_length = struct.unpack_from( + "!BHHIH", response, offset + ) + self.assertEqual(owner, 0) + self.assertEqual(record_type, dns_peer.TYPE_OPT) + self.assertEqual(payload, dns_peer.MAX_DNS_MESSAGE_BYTES) + self.assertEqual(ttl, 0) + self.assertEqual(data_length, 0) + self.assertEqual(offset + 11, len(response)) + + def test_edns_bad_version_returns_badvers(self) -> None: + _, _, port = self.start_server() + response = self.udp_query( + port, make_query(additional=(make_opt(version=1),)) + ) + _, flags, _, answers, _, additionals = response_header(response) + self.assertEqual(flags & 0xF, 0) + self.assertEqual((answers, additionals), (0, 1)) + offset = question_end(response) + _, record_type, _, ttl, _ = struct.unpack_from("!BHHIH", response, offset) + self.assertEqual(record_type, dns_peer.TYPE_OPT) + self.assertEqual(ttl >> 24, 1) + + def test_malformed_edns_returns_format_error(self) -> None: + _, _, port = self.start_server() + malformed = ( + make_query(additional=(make_opt(options=b"xx", declared_length=4),)), + make_query(additional=(make_opt(), make_opt())), + make_query(additional=(b"\x03bad" + make_opt()[1:],)), + ) + for query in malformed: + with self.subTest(query=query): + response = self.udp_query(port, query) + self.assertEqual( + response_header(response)[1] & 0xF, dns_peer.RCODE_FORMERR + ) + + def test_tcp_oversized_frame_is_closed_without_allocation(self) -> None: + _, events, port = self.start_server() + with socket.create_connection(("127.0.0.1", port), timeout=1) as client: + client.sendall(struct.pack("!H", dns_peer.MAX_DNS_MESSAGE_BYTES + 1)) + self.assertEqual(client.recv(1), b"") + drops = [fields for event, fields in events.records if event == "dns_drop"] + self.assertTrue( + any(drop["reason"] == "invalid_message_length" for drop in drops) + ) + + def test_tcp_partial_frames_and_timeout_are_rejected(self) -> None: + _, events, port = self.start_server() + + def send_incomplete(payload: bytes, *, close_write: bool) -> None: + with socket.create_connection(("127.0.0.1", port), timeout=1) as client: + client.sendall(payload) + if close_write: + client.shutdown(socket.SHUT_WR) + try: + closed = client.recv(1) + except ConnectionResetError: + closed = b"" + self.assertEqual(closed, b"") + + query = make_query() + send_incomplete(b"\x00", close_write=True) + send_incomplete( + struct.pack("!H", len(query)) + query[:5], + close_write=True, + ) + send_incomplete(struct.pack("!H", len(query)), close_write=False) + self.assertTrue(events.wait_for_count("dns_drop", 3)) + incomplete = [ + fields + for event, fields in events.records + if event == "dns_drop" and fields["reason"] == "incomplete_message" + ] + self.assertEqual(len(incomplete), 3) + + def test_shutdown_closes_active_tcp_before_long_timeout(self) -> None: + events = RecordingSink() + server = dns_peer.DnsPeerServer( + ("127.0.0.1", 0), + authoritative_name="pocketjs.test", + authoritative_address=ipaddress.IPv4Address("172.16.10.126"), + ttl_seconds=30, + allowed_networks=(ipaddress.IPv4Network("127.0.0.0/8"),), + interface=None, + socket_timeout_ms=dns_peer.MAX_SOCKET_TIMEOUT_MS, + events=events, + ) + failures: list[BaseException] = [] + + def serve() -> None: + try: + server.serve_forever() + except BaseException as error: + failures.append(error) + + thread = threading.Thread(target=serve, daemon=True) + thread.start() + self.assertTrue(events.wait_for("dns_ready")) + try: + with socket.create_connection(server.server_address, timeout=1) as client: + client.sendall(b"\x00") + deadline = time.monotonic() + 1 + while time.monotonic() < deadline: + with server._active_tcp_lock: + if server._active_tcp is not None: + break + time.sleep(0.01) + else: + self.fail("TCP worker did not enter its bounded read") + started = time.monotonic() + server.shutdown() + thread.join(timeout=1) + self.assertFalse(thread.is_alive()) + self.assertLess(time.monotonic() - started, 1) + try: + closed = client.recv(1) + except (ConnectionResetError, OSError): + closed = b"" + self.assertEqual(closed, b"") + finally: + server.shutdown() + thread.join(timeout=1) + self.assertEqual(failures, []) + + def test_worker_event_failure_stops_server(self) -> None: + events = FailingSink() + server = dns_peer.DnsPeerServer( + ("127.0.0.1", 0), + authoritative_name="pocketjs.test", + authoritative_address=ipaddress.IPv4Address("172.16.10.126"), + ttl_seconds=30, + allowed_networks=(ipaddress.IPv4Network("127.0.0.0/8"),), + interface=None, + socket_timeout_ms=200, + events=events, + ) + failures: list[BaseException] = [] + + def serve() -> None: + try: + server.serve_forever() + except BaseException as error: + failures.append(error) + + thread = threading.Thread(target=serve, daemon=True) + thread.start() + self.assertTrue(events.ready.wait(timeout=1)) + with self.assertRaises(socket.timeout): + self.udp_query(server.server_address[1], make_query(), timeout=0.2) + thread.join(timeout=1) + self.assertFalse(thread.is_alive()) + self.assertEqual(len(failures), 1) + self.assertIsInstance(failures[0], RuntimeError) + self.assertIn("worker failed", str(failures[0])) + + def test_partial_worker_start_is_torn_down(self) -> None: + real_thread = threading.Thread + for failure in ( + RuntimeError("injected second worker start failure"), + KeyboardInterrupt(), + ): + with self.subTest(failure=type(failure).__name__): + events = RecordingSink() + server = dns_peer.DnsPeerServer( + ("127.0.0.1", 0), + authoritative_name="pocketjs.test", + authoritative_address=ipaddress.IPv4Address("172.16.10.126"), + ttl_seconds=30, + allowed_networks=(ipaddress.IPv4Network("127.0.0.0/8"),), + interface=None, + socket_timeout_ms=200, + events=events, + ) + created_threads: list[threading.Thread] = [] + + class FailingThread: + ident = None + + def start(self) -> None: + raise failure + + def make_thread(*args: object, **kwargs: object) -> object: + if created_threads: + return FailingThread() + worker = real_thread(*args, **kwargs) + created_threads.append(worker) + return worker + + with mock.patch.object( + dns_peer.threading, "Thread", side_effect=make_thread + ): + with self.assertRaises(type(failure)): + server.serve_forever() + self.assertTrue(server._closed) + self.assertEqual(len(created_threads), 1) + self.assertFalse(created_threads[0].is_alive()) + + def test_cli_configuration_bounds_are_rejected(self) -> None: + for cidr in ("0.0.0.0/1", "128.0.0.0/1", "8.8.8.0/24"): + with self.subTest(cidr=cidr), self.assertRaises(Exception) as raised: + dns_peer.parse_allow_cidr(cidr) + self.assertIn("outside RFC1918", str(raised.exception)) + with self.assertRaises(Exception) as ttl_error: + dns_peer.ttl_integer(str(dns_peer.MAX_TTL_SECONDS + 1)) + self.assertIn("must not exceed", str(ttl_error.exception)) + with self.assertRaises(Exception) as timeout_error: + dns_peer.socket_timeout_integer(str(dns_peer.MAX_SOCKET_TIMEOUT_MS + 1)) + self.assertIn("must not exceed", str(timeout_error.exception)) + + with self.assertRaisesRegex(ValueError, "outside RFC1918"): + dns_peer.DnsPeerServer( + ("127.0.0.1", 0), + authoritative_name="pocketjs.test", + authoritative_address=ipaddress.IPv4Address("172.16.10.126"), + ttl_seconds=30, + allowed_networks=(ipaddress.IPv4Network("8.8.8.0/24"),), + interface=None, + socket_timeout_ms=200, + events=RecordingSink(), + ) + + @unittest.skipUnless(sys.platform == "darwin", "Darwin safety policy") + def test_darwin_wildcard_aliases_require_interface(self) -> None: + args = dns_peer.build_parser().parse_args( + ( + "serve", + "--host", + "0.0.0.0", + "--port", + "0", + "--allow-cidr", + "127.0.0.0/8", + ) + ) + self.assertEqual(args.host, "0.0.0.0") + with self.assertRaisesRegex(ValueError, "requires --interface"): + dns_peer.run_server(args) + for host in ("0", "", "localhost"): + with self.subTest(host=host), self.assertRaises(Exception): + dns_peer.parse_bind_host(host) + + @unittest.skipUnless(hasattr(os, "O_NOFOLLOW"), "secure event files") + def test_event_file_rejects_symlink_nonregular_and_unsafe_mode(self) -> None: + with tempfile.TemporaryDirectory(prefix="pocketjs-dns-events-") as temporary: + directory = Path(temporary) + safe_path = directory / "safe.ndjson" + sink = dns_peer.EventSink(safe_path, quiet=True) + sink.emit("test") + sink.close() + self.assertEqual(safe_path.stat().st_mode & 0o777, 0o600) + + target = directory / "target.ndjson" + target.write_text("") + symlink = directory / "symlink.ndjson" + symlink.symlink_to(target) + with self.assertRaises(OSError): + dns_peer.EventSink(symlink, quiet=True) + + fifo = directory / "events.fifo" + os.mkfifo(fifo) + with self.assertRaises(OSError): + dns_peer.EventSink(fifo, quiet=True) + + unsafe = directory / "unsafe.ndjson" + unsafe.write_text("") + unsafe.chmod(0o644) + with self.assertRaises(PermissionError): + dns_peer.EventSink(unsafe, quiet=True) + + def test_cli_sigint_is_a_clean_stop(self) -> None: + process = subprocess.Popen( + ( + sys.executable, + str(MODULE_PATH), + "serve", + "--host", + "127.0.0.1", + "--port", + "0", + "--allow-cidr", + "127.0.0.0/8", + ), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + self.addCleanup(self.ensure_process_stopped, process) + assert process.stdout is not None and process.stderr is not None + ready_line = process.stdout.readline() + self.assertTrue(ready_line) + ready = json.loads(ready_line) + self.assertEqual(ready["event"], "dns_ready") + self.assert_exact_answer(self.udp_query(int(ready["port"]), make_query())) + process.send_signal(signal.SIGINT) + output, error_output = process.communicate(timeout=3) + self.assertEqual(process.returncode, 0, error_output) + events = [ready] + [json.loads(line) for line in output.splitlines()] + self.assertTrue(any(event["event"] == "dns_stop" for event in events)) + self.assertFalse(any(event["event"] == "dns_internal_error" for event in events)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/net-conformance-peer/test_http_peer.py b/tools/net-conformance-peer/test_http_peer.py new file mode 100644 index 00000000..dc418762 --- /dev/null +++ b/tools/net-conformance-peer/test_http_peer.py @@ -0,0 +1,619 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import contextlib +import http.client +import importlib.util +import io +import json +import os +import signal +import socket +import ssl +import subprocess +import sys +import tempfile +import threading +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("http_peer.py") +SPEC = importlib.util.spec_from_file_location("pocketjs_http_peer", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +http_peer = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = http_peer +SPEC.loader.exec_module(http_peer) + +PKI_MODULE_PATH = Path(__file__).with_name("generate_test_pki.py") +PKI_SPEC = importlib.util.spec_from_file_location( + "pocketjs_generate_test_pki", PKI_MODULE_PATH +) +assert PKI_SPEC is not None and PKI_SPEC.loader is not None +generate_test_pki = importlib.util.module_from_spec(PKI_SPEC) +sys.modules[PKI_SPEC.name] = generate_test_pki +PKI_SPEC.loader.exec_module(generate_test_pki) + + +class QuietSink: + def emit(self, event: str, **fields: object) -> None: + del event, fields + + +class RecordingSink: + def __init__(self) -> None: + self._condition = threading.Condition() + self.records: list[tuple[str, dict[str, object]]] = [] + + def emit(self, event: str, **fields: object) -> None: + with self._condition: + self.records.append((event, fields)) + self._condition.notify_all() + + def wait_for(self, event: str, timeout: float = 2) -> bool: + with self._condition: + return self._condition.wait_for( + lambda: any(name == event for name, _ in self.records), timeout + ) + + def snapshot(self) -> list[tuple[str, dict[str, object]]]: + with self._condition: + return list(self.records) + + +def make_server( + tls_context: ssl.SSLContext | None = None, + events: QuietSink | RecordingSink | None = None, + *, + observe_tls_close_notify: bool = False, + tls_handshake_delay_ms: int = 0, +) -> http_peer.ThreadingPeerServer: + return http_peer.ThreadingPeerServer( + ("127.0.0.1", 0), + body_limit=16 * 1024, + header_limit=16 * 1024, + socket_timeout_ms=1000, + delay_ceiling_ms=2000, + events=events if events is not None else QuietSink(), + tls_context=tls_context, + observe_tls_close_notify=observe_tls_close_notify, + tls_handshake_delay_ms=tls_handshake_delay_ms, + ) + + +class PeerTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.server = make_server() + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + cls.host, cls.port = cls.server.server_address + + @classmethod + def tearDownClass(cls) -> None: + cls.server.shutdown() + cls.server.server_close() + cls.thread.join(timeout=2) + + def test_health_echo_and_keep_alive(self) -> None: + connection = http.client.HTTPConnection(self.host, self.port, timeout=2) + connection.request("GET", "/health") + health = connection.getresponse() + connection_id = health.getheader("X-PocketJS-Connection") + self.assertEqual(health.status, 200) + self.assertIn(b'"status":"ok"', health.read()) + + payload = b"binary:\x00\xff" + connection.request("POST", "/echo", body=payload) + echo = connection.getresponse() + self.assertEqual(echo.status, 200) + self.assertEqual(echo.getheader("X-PocketJS-Connection"), connection_id) + self.assertEqual(echo.read(), payload) + connection.close() + + def test_chunked_echo_and_keep_alive(self) -> None: + connection = http.client.HTTPConnection(self.host, self.port, timeout=2) + payload_chunks = (b"binary:", b"\x00", b"\xff", b"-chunked") + connection.request( + "POST", + "/echo", + body=iter(payload_chunks), + encode_chunked=True, + ) + echo = connection.getresponse() + connection_id = echo.getheader("X-PocketJS-Connection") + self.assertEqual(echo.status, 200) + self.assertEqual(echo.read(), b"binary:\x00\xff-chunked") + + connection.request("GET", "/health", headers={"Connection": "close"}) + health = connection.getresponse() + self.assertEqual(health.status, 200) + self.assertEqual(health.getheader("X-PocketJS-Connection"), connection_id) + self.assertIn(b'"status":"ok"', health.read()) + connection.close() + + def test_invalid_chunked_uploads_are_rejected(self) -> None: + cases = { + "transfer-encoding-and-content-length": ( + b"Transfer-Encoding: chunked\r\nContent-Length: 1\r\n", + b"0\r\n\r\n", + 400, + ), + "duplicate-transfer-encoding": ( + b"Transfer-Encoding: chunked\r\n" + b"Transfer-Encoding: chunked\r\n", + b"0\r\n\r\n", + 400, + ), + "combined-transfer-coding": ( + b"Transfer-Encoding: gzip, chunked\r\n", + b"0\r\n\r\n", + 400, + ), + "http-1.0": ( + b"Transfer-Encoding: chunked\r\n", + b"0\r\n\r\n", + 400, + ), + "chunk-extension": ( + b"Transfer-Encoding: chunked\r\n", + b"1;extension=yes\r\na\r\n0\r\n\r\n", + 400, + ), + "invalid-chunk-size": ( + b"Transfer-Encoding: chunked\r\n", + b"Z\r\ninvalid\r\n", + 400, + ), + "declared-trailer": ( + b"Transfer-Encoding: chunked\r\nTrailer: X-Test\r\n", + b"0\r\n\r\n", + 400, + ), + "actual-trailer": ( + b"Transfer-Encoding: chunked\r\n", + b"1\r\na\r\n0\r\nX-Test: value\r\n\r\n", + 400, + ), + "body-limit": ( + b"Transfer-Encoding: chunked\r\n", + b"4001\r\n", + 413, + ), + "size-line-limit": ( + b"Transfer-Encoding: chunked\r\n", + b"00000000000000000\r\n", + 400, + ), + } + for name, (headers, body, expected_status) in cases.items(): + version = "HTTP/1.0" if name == "http-1.0" else "HTTP/1.1" + with self.subTest(case=name): + wire = self.raw_request( + f"POST /echo {version}\r\n".encode() + + b"Host: peer\r\nConnection: close\r\n" + + headers + + b"\r\n" + + body + ) + status_line = wire.split(b"\r\n", 1)[0] + self.assertEqual( + int(status_line.split(b" ", 2)[1]), + expected_status, + ) + + def test_valid_chunked_response_has_trailer(self) -> None: + wire = self.raw_request(b"GET /chunked HTTP/1.1\r\nHost: peer\r\nConnection: close\r\n\r\n") + self.assertIn(b"Transfer-Encoding: chunked\r\n", wire) + self.assertIn(b"8\r\nPocketJS\r\n", wire) + self.assertTrue(wire.endswith(b"0\r\nX-PocketJS-Trailer: complete\r\n\r\n")) + + def test_malformed_te_cl_is_sent_verbatim(self) -> None: + cases = { + "te-cl": (b"Transfer-Encoding: chunked\r\n", b"Content-Length: 5\r\n"), + "duplicate-content-length": ( + b"Content-Length: 5\r\nContent-Length: 5\r\n", + ), + "obs-fold": (b"X-PocketJS: first\r\n second\r\n",), + "te-duplicate": ( + b"Transfer-Encoding: chunked\r\nTransfer-Encoding: chunked\r\n", + ), + "te-combined": (b"Transfer-Encoding: gzip, chunked\r\n",), + "te-unknown": (b"Transfer-Encoding: gzip\r\n",), + "trailer-forbidden": (b"0\r\nContent-Length: 2\r\n\r\n",), + "chunk-size": (b"Z\r\ninvalid\r\n",), + } + for case, expected_fragments in cases.items(): + with self.subTest(case=case): + wire = self.raw_request( + ( + f"GET /malformed/{case} HTTP/1.1\r\n" + "Host: peer\r\nConnection: close\r\n\r\n" + ).encode() + ) + for fragment in expected_fragments: + self.assertIn(fragment, wire) + + def test_disconnect_mid_body_is_incomplete(self) -> None: + wire = self.raw_request( + b"GET /disconnect?phase=mid_body HTTP/1.1\r\nHost: peer\r\n\r\n" + ) + headers, body = wire.split(b"\r\n\r\n", 1) + self.assertIn(b"Content-Length: 32", headers) + self.assertEqual(body, b"partial") + + def test_retry_once_counts_without_logging_token(self) -> None: + first = self.raw_request( + b"GET /retry-once?token=test-token HTTP/1.1\r\nHost: peer\r\n\r\n" + ) + self.assertEqual(first, b"") + second = self.raw_request( + b"GET /retry-once?token=test-token HTTP/1.1\r\nHost: peer\r\n\r\n" + ) + self.assertIn(b"X-PocketJS-Attempt: 2\r\n", second) + + def raw_request(self, request: bytes) -> bytes: + with socket.create_connection((self.host, self.port), timeout=2) as connection: + connection.sendall(request) + chunks: list[bytes] = [] + while True: + chunk = connection.recv(4096) + if not chunk: + return b"".join(chunks) + chunks.append(chunk) + + +class TLSPeerTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.temporary = tempfile.TemporaryDirectory(prefix="pocketjs-peer-pki-") + cls.pki = generate_test_pki.generate_pki(Path(cls.temporary.name)) + + @classmethod + def tearDownClass(cls) -> None: + cls.temporary.cleanup() + + def start_profile( + self, + cert_name: str, + key_name: str, + *, + maximum_version: ssl.TLSVersion | None = None, + events: RecordingSink | None = None, + observe_tls_close_notify: bool = False, + tls_handshake_delay_ms: int = 0, + ) -> tuple[http_peer.ThreadingPeerServer, str, int]: + tls_context = http_peer.create_server_tls_context( + self.pki[cert_name], self.pki[key_name], "1.2" + ) + if maximum_version is not None: + tls_context.maximum_version = maximum_version + server = make_server( + tls_context, + events, + observe_tls_close_notify=observe_tls_close_notify, + tls_handshake_delay_ms=tls_handshake_delay_ms, + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def stop() -> None: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + self.addCleanup(stop) + host, port = server.server_address + return server, host, port + + def trusted_context(self) -> ssl.SSLContext: + return ssl.create_default_context(cafile=self.pki["ca_cert"]) + + def ensure_cli_stopped(self, process: subprocess.Popen[str]) -> None: + if process.poll() is not None: + return + process.send_signal(signal.SIGINT) + try: + process.communicate(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.communicate(timeout=3) + + def start_cli_profile( + self, + cert_name: str, + key_name: str, + *, + maximum_version: str | None = None, + ) -> tuple[subprocess.Popen[str], dict[str, object]]: + command = [ + sys.executable, + str(MODULE_PATH), + "serve", + "--host", + "127.0.0.1", + "--port", + "0", + "--tls-cert", + str(self.pki[cert_name]), + "--tls-key", + str(self.pki[key_name]), + ] + if maximum_version is not None: + command.extend(("--tls-max-version", maximum_version)) + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + self.addCleanup(self.ensure_cli_stopped, process) + assert process.stdout is not None and process.stderr is not None + ready_line = process.stdout.readline() + if not ready_line: + _, error_output = process.communicate(timeout=3) + self.fail(f"TLS peer CLI did not become ready: {error_output}") + ready = json.loads(ready_line) + self.assertEqual(ready["event"], "peer_ready") + self.assertEqual(ready["socket_timeout_ms"], 5000) + self.assertEqual( + ready["tls_certificate_der_sha256"], + http_peer.certificate_der_sha256(self.pki[cert_name]), + ) + return process, ready + + def stop_cli_profile( + self, process: subprocess.Popen[str] + ) -> list[dict[str, object]]: + process.send_signal(signal.SIGINT) + output, error_output = process.communicate(timeout=3) + self.assertEqual(process.returncode, 0, error_output) + return [json.loads(line) for line in output.splitlines()] + + def run_cli_probe(self, port: int) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ( + sys.executable, + str(MODULE_PATH), + "probe", + "--base-url", + f"https://localhost:{port}", + "--ca-cert", + str(self.pki["ca_cert"]), + "--rounds", + "2", + "--health-contains", + '"status":"ok"', + ), + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + def assert_cli_profile_rejected(self, cert_name: str, key_name: str) -> None: + process, ready = self.start_cli_profile(cert_name, key_name) + result = self.run_cli_probe(int(ready["port"])) + events = self.stop_cli_profile(process) + self.assertEqual(result.returncode, 1) + error_event = json.loads(result.stderr) + self.assertEqual(error_event["event"], "peer_error") + self.assertEqual(error_event["error"], "SSLCertVerificationError") + self.assertFalse(any(event["event"] == "request" for event in events)) + hellos = [ + event for event in events if event["event"] == "tls_client_hello" + ] + self.assertEqual(hellos[0]["server_name"], "localhost") + self.assertTrue( + any(event["event"] == "tls_handshake_error" for event in events) + ) + + def assert_profile_rejected( + self, cert_name: str, key_name: str + ) -> ssl.SSLCertVerificationError: + events = RecordingSink() + _, _, port = self.start_profile( + cert_name, + key_name, + events=events, + ) + connection = http.client.HTTPSConnection( + "localhost", port, timeout=2, context=self.trusted_context() + ) + self.addCleanup(connection.close) + with self.assertRaises(ssl.SSLCertVerificationError) as raised: + connection.request("GET", "/health") + self.assertTrue(events.wait_for("tls_handshake_error")) + records = events.snapshot() + self.assertFalse(any(event == "request" for event, _ in records)) + hellos = [ + fields for event, fields in records if event == "tls_client_hello" + ] + self.assertEqual(hellos[0]["server_name"], "localhost") + return raised.exception + + def test_tls_probe_health_echo_and_keep_alive(self) -> None: + events = RecordingSink() + _, _, port = self.start_profile( + "server_cert", + "server_key", + maximum_version=ssl.TLSVersion.TLSv1_2, + events=events, + ) + args = argparse.Namespace( + base_url=f"https://localhost:{port}", + ca_cert=str(self.pki["ca_cert"]), + timeout_ms=2000, + response_limit_bytes=16 * 1024, + health_contains='"status":"ok"', + rounds=2, + interval_ms=0, + ) + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(http_peer.run_probe(args), 0) + output_lines = [json_line for json_line in output.getvalue().splitlines()] + self.assertEqual(len(output_lines), 3) + self.assertIn('"event":"probe_pass"', output_lines[-1]) + connection_events = [ + fields for event, fields in events.records if event == "connection_open" + ] + self.assertEqual(connection_events[0]["tls_version"], "TLSv1.2") + self.assertEqual(connection_events[0]["tls_server_name"], "localhost") + hello_events = [ + fields for event, fields in events.records if event == "tls_client_hello" + ] + self.assertEqual(hello_events[0]["server_name"], "localhost") + + def tls_health_connection(self, port: int) -> ssl.SSLSocket: + raw = socket.create_connection(("127.0.0.1", port), timeout=2) + secure = self.trusted_context().wrap_socket(raw, server_hostname="localhost") + secure.sendall( + b"GET /health HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Connection: keep-alive\r\n\r\n" + ) + response = http.client.HTTPResponse(secure) + response.begin() + self.assertEqual(response.status, 200) + self.assertIn(b'"status":"ok"', response.read()) + return secure + + def test_observes_client_close_notify(self) -> None: + events = RecordingSink() + _, _, port = self.start_profile( + "server_cert", + "server_key", + maximum_version=ssl.TLSVersion.TLSv1_2, + events=events, + observe_tls_close_notify=True, + ) + secure = self.tls_health_connection(port) + try: + try: + raw = secure.unwrap() + except OSError: + secure.close() + else: + raw.close() + finally: + secure.close() + self.assertTrue(events.wait_for("connection_close")) + closes = [ + fields for event, fields in events.snapshot() if event == "connection_close" + ] + self.assertEqual(closes[0]["tls_close_state"], "close_notify") + self.assertIs(closes[0]["tls_close_notify_observed"], True) + + def test_observes_ragged_tls_eof(self) -> None: + events = RecordingSink() + _, _, port = self.start_profile( + "server_cert", + "server_key", + maximum_version=ssl.TLSVersion.TLSv1_2, + events=events, + observe_tls_close_notify=True, + ) + secure = self.tls_health_connection(port) + descriptor = secure.detach() + os.close(descriptor) + self.assertTrue(events.wait_for("connection_close")) + closes = [ + fields for event, fields in events.snapshot() if event == "connection_close" + ] + self.assertEqual(closes[0]["tls_close_state"], "ragged_eof") + self.assertIs(closes[0]["tls_close_notify_observed"], False) + + def test_delays_tls_handshake_without_serving_http(self) -> None: + events = RecordingSink() + _, _, port = self.start_profile( + "server_cert", + "server_key", + maximum_version=ssl.TLSVersion.TLSv1_2, + events=events, + tls_handshake_delay_ms=250, + ) + raw = socket.create_connection(("127.0.0.1", port), timeout=1) + raw.settimeout(0.05) + with self.assertRaises((TimeoutError, socket.timeout)): + self.trusted_context().wrap_socket(raw, server_hostname="localhost") + raw.close() + self.assertTrue(events.wait_for("tls_handshake_delay")) + self.assertTrue(events.wait_for("tls_handshake_error")) + records = events.snapshot() + self.assertFalse(any(event == "connection_open" for event, _ in records)) + self.assertFalse(any(event == "request" for event, _ in records)) + + def test_wrong_hostname_is_rejected(self) -> None: + error = self.assert_profile_rejected("wrong_host_cert", "wrong_host_key") + self.assertIn("Hostname mismatch", str(error)) + + def test_untrusted_ca_is_rejected(self) -> None: + error = self.assert_profile_rejected( + "untrusted_server_cert", "untrusted_server_key" + ) + self.assertIn("certificate verify failed", str(error)) + + def test_bad_signature_is_rejected(self) -> None: + error = self.assert_profile_rejected( + "bad_signature_server_cert", "server_key" + ) + self.assertIn("certificate verify failed", str(error)) + + def test_expired_certificate_is_rejected(self) -> None: + error = self.assert_profile_rejected( + "expired_server_cert", "expired_server_key" + ) + self.assertEqual(error.verify_code, 10) + + def test_not_yet_valid_certificate_is_rejected(self) -> None: + error = self.assert_profile_rejected( + "not_yet_valid_server_cert", "not_yet_valid_server_key" + ) + self.assertEqual(error.verify_code, 9) + + def test_cli_tls12_serve_and_probe(self) -> None: + process, ready = self.start_cli_profile( + "server_cert", "server_key", maximum_version="1.2" + ) + result = self.run_cli_probe(int(ready["port"])) + events = self.stop_cli_profile(process) + self.assertEqual(result.returncode, 0, result.stderr) + probe_events = [json.loads(line) for line in result.stdout.splitlines()] + self.assertEqual(probe_events[-1]["event"], "probe_pass") + self.assertEqual(probe_events[-1]["rounds"], 2) + connections = [ + event for event in events if event["event"] == "connection_open" + ] + self.assertEqual(connections[0]["tls_version"], "TLSv1.2") + self.assertEqual(connections[0]["tls_server_name"], "localhost") + self.assertEqual( + len([event for event in events if event["event"] == "request"]), 4 + ) + + def test_cli_rejects_wrong_hostname_before_http(self) -> None: + self.assert_cli_profile_rejected("wrong_host_cert", "wrong_host_key") + + def test_cli_rejects_unknown_ca_before_http(self) -> None: + self.assert_cli_profile_rejected( + "untrusted_server_cert", "untrusted_server_key" + ) + + def test_cli_rejects_bad_signature_before_http(self) -> None: + self.assert_cli_profile_rejected("bad_signature_server_cert", "server_key") + + def test_cli_rejects_expired_certificate_before_http(self) -> None: + self.assert_cli_profile_rejected( + "expired_server_cert", "expired_server_key" + ) + + def test_cli_rejects_not_yet_valid_certificate_before_http(self) -> None: + self.assert_cli_profile_rejected( + "not_yet_valid_server_cert", "not_yet_valid_server_key" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/net-conformance-peer/test_phase1b_certificate_reject.py b/tools/net-conformance-peer/test_phase1b_certificate_reject.py new file mode 100644 index 00000000..96b80672 --- /dev/null +++ b/tools/net-conformance-peer/test_phase1b_certificate_reject.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path +from typing import Callable + + +MODULE_PATH = Path(__file__).with_name("phase1b_certificate_reject.py") +SPEC = importlib.util.spec_from_file_location( + "pocketjs_phase1b_certificate_reject", MODULE_PATH +) +assert SPEC is not None and SPEC.loader is not None +phase1b_certificate_reject = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = phase1b_certificate_reject +SPEC.loader.exec_module(phase1b_certificate_reject) + + +class Phase1BCertificateRejectTest(unittest.TestCase): + def fixture( + self, *, board: str = "s3", profile: str = "hostname-mismatch" + ) -> tuple[Path, Path, Path, tempfile.TemporaryDirectory[str]]: + temporary = tempfile.TemporaryDirectory() + root = Path(temporary.name) + board_log = root / "board.log" + tls_path = root / "tls.ndjson" + dns_path = root / "dns.ndjson" + board_ip = "172.16.10.231" if board == "s3" else "172.16.10.145" + mode, error_code, certificate_sha = phase1b_certificate_reject.PROFILES[ + profile + ] + + lines = [ + "POCKET_NET_TLS_CONTRACT valid=1 https_explicit_opt_in=1 " + "exact_host_tls_profile=1 distinct_tls_errors=1 " + "expected_distinct_tls_errors=1 public=0", + "POCKET_NET_FORMAL_TLS_BOOT board=test " + "origin=https://pocketjs.test:8443 peer_ipv4=172.16.10.126 " + f"test_mode={mode} public_capability=0 distinct_tls_errors=1 " + f"plan_hash={phase1b_certificate_reject.EXPECTED_PLAN} " + f"factory_sha256={phase1b_certificate_reject.EXPECTED_FACTORY} " + f"plan={phase1b_certificate_reject.EXPECTED_PLAN} " + f"factory={phase1b_certificate_reject.EXPECTED_FACTORY}", + "POCKET_NET_FORMAL_TLS_START probe=1 " + "origin=https://pocketjs.test:8443 peer_ipv4=172.16.10.126 " + f"mode={mode}", + "POCKET_NET_FORMAL_TLS_RUN status=ESP_ERR_INVALID_RESPONSE " + "rounds=0/20 requests=0/40 frame_calls=0 service_turns=5 jobs=4 " + "shutdown=1 poison=0x00000000 core_poison=0x00000000 " + "poisoned_cores=0 guest_yields=28 elapsed_ms=1530", + ] + if board == "s3": + lines.extend( + ( + "POCKET_NET_FORMAL_TLS_RESULT probe=1 " + f"mode={mode} pass=1 esp_err=ESP_ERR_INVALID_RESPONSE " + "rounds=0/20 requests=0/40 shutdown=1 poison=0x00000000 " + "leases_balanced=1 queued_leases=0 taken_leases=0 " + f"error_code={error_code}", + "POCKET_NET_FORMAL_TLS_NEGATIVE_PASS " + f"mode={mode} error_code={error_code} shutdown=1 " + "poison=0x00000000", + ) + ) + else: + lines.extend( + ( + "POCKET_NET_FORMAL_TLS_RESOURCE probe=1 active=0 pending=0 " + "queued=0 taken=0 leases_taken=0 leases_released=0 " + "leases_cleaned=0 poison=0x00000000 shutdown=1", + "POCKET_NET_FORMAL_TLS_NEGATIVE_PASS board=tab5-esp32p4 " + f"probe=1 mode={mode} error_code={error_code} shutdown=1 " + "poison=0x00000000 leases=0/0 elapsed_ms=1530", + ) + ) + board_log.write_text("\n".join(lines) + "\n") + + tls = [ + { + "event": "peer_ready", + "monotonic_ns": 100, + "transport": "tls", + "port": 8443, + "tls_min_version": "1.2", + "tls_max_version": "1.2", + "tls_certificate_der_sha256": certificate_sha, + "observe_tls_close_notify": True, + "socket_timeout_ms": 60_000, + }, + { + "event": "tls_client_hello", + "monotonic_ns": 400, + "peer_ipv4": board_ip, + "server_name": "pocketjs.test", + }, + { + "event": "tls_handshake_error", + "monotonic_ns": 500, + "peer_ipv4": board_ip, + "error": "SSLError", + }, + { + "event": "peer_stop", + "monotonic_ns": 700, + "reason": "keyboard_interrupt", + }, + ] + dns = [ + { + "event": "dns_ready", + "monotonic_ns": 50, + "authoritative_name": "pocketjs.test", + "authoritative_ipv4": "172.16.10.126", + "port": 53, + "interface": "en1", + "recursion_available": False, + "transports": ["udp", "tcp"], + }, + { + "event": "dns_query", + "monotonic_ns": 300, + "outcome": "answer", + "peer_ipv4": board_ip, + "query_name": "pocketjs.test", + "query_type": 1, + "query_class": 1, + "answers": 1, + "rcode": 0, + "recursion_available": False, + }, + { + "event": "dns_stop", + "monotonic_ns": 800, + "reason": "keyboard_interrupt", + }, + ] + tls_path.write_text("".join(json.dumps(event) + "\n" for event in tls)) + dns_path.write_text("".join(json.dumps(event) + "\n" for event in dns)) + return board_log, tls_path, dns_path, temporary + + def call_analyze( + self, + board: str, + profile: str, + board_log: Path, + tls_path: Path, + dns_path: Path, + ) -> phase1b_certificate_reject.CertificateRejectSummary: + return phase1b_certificate_reject.analyze( + board=board, + profile=profile, + board_log=board_log, + tls_events_path=tls_path, + dns_events_path=dns_path, + board_ipv4="172.16.10.231" if board == "s3" else "172.16.10.145", + peer_ipv4="172.16.10.126", + ) + + def mutate_events( + self, + path: Path, + mutate: Callable[[list[dict[str, object]]], None], + ) -> None: + events = [json.loads(line) for line in path.read_text().splitlines()] + mutate(events) + path.write_text("".join(json.dumps(event) + "\n" for event in events)) + + def test_accepts_all_profiles_for_both_boards(self) -> None: + for board in ("s3", "p4"): + for profile, (_, error_code, certificate_sha) in ( + phase1b_certificate_reject.PROFILES.items() + ): + with self.subTest(board=board, profile=profile): + board_log, tls_path, dns_path, temporary = self.fixture( + board=board, profile=profile + ) + try: + summary = self.call_analyze( + board, profile, board_log, tls_path, dns_path + ) + finally: + temporary.cleanup() + self.assertEqual(summary.error_code, error_code) + self.assertEqual(summary.certificate_der_sha256, certificate_sha) + self.assertEqual(summary.tls_client_hellos, 1) + self.assertEqual(summary.tls_handshake_errors, 1) + + def test_rejects_certificate_fingerprint_mismatch(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + + def replace_hash(events: list[dict[str, object]]) -> None: + events[0]["tls_certificate_der_sha256"] = "0" * 64 + + self.mutate_events(tls_path, replace_hash) + with self.assertRaisesRegex( + phase1b_certificate_reject.CertificateRejectValidationError, + "exact certificate rejection profile", + ): + self.call_analyze( + "s3", "hostname-mismatch", board_log, tls_path, dns_path + ) + + def test_rejects_wrong_artifact_identity(self) -> None: + for board, field in (("s3", "plan_hash"), ("p4", "plan")): + with self.subTest(board=board): + board_log, tls_path, dns_path, temporary = self.fixture(board=board) + self.addCleanup(temporary.cleanup) + board_log.write_text( + board_log.read_text().replace( + f"{field}={phase1b_certificate_reject.EXPECTED_PLAN}", + f"{field}=sha256:{'0' * 64}", + 1, + ) + ) + with self.assertRaisesRegex( + phase1b_certificate_reject.CertificateRejectValidationError, + "exact TLS conformance artifact|exact certificate rejection profile", + ): + self.call_analyze( + board, "hostname-mismatch", board_log, tls_path, dns_path + ) + + def test_rejects_wrong_guest_error_class(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + board_log.write_text( + board_log.read_text().replace( + "error_code=tls_hostname_mismatch", + "error_code=tls_certificate_invalid", + ) + ) + with self.assertRaisesRegex( + phase1b_certificate_reject.CertificateRejectValidationError, + "selected certificate rejection|unexpected HTTP work", + ): + self.call_analyze( + "s3", "hostname-mismatch", board_log, tls_path, dns_path + ) + + def test_rejects_wrong_or_duplicate_sni(self) -> None: + for duplicate in (False, True): + with self.subTest(duplicate=duplicate): + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + + def corrupt(events: list[dict[str, object]]) -> None: + if duplicate: + events.insert(2, dict(events[1])) + else: + events[1]["server_name"] = "wrong.test" + + self.mutate_events(tls_path, corrupt) + with self.assertRaisesRegex( + phase1b_certificate_reject.CertificateRejectValidationError, + "exactly one tls_client_hello|wrong board identity or SNI", + ): + self.call_analyze( + "s3", "hostname-mismatch", board_log, tls_path, dns_path + ) + + def test_rejects_opened_connection_or_http_activity(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + + def add_open(events: list[dict[str, object]]) -> None: + events.insert(3, {"event": "connection_open", "monotonic_ns": 600}) + + self.mutate_events(tls_path, add_open) + with self.assertRaisesRegex( + phase1b_certificate_reject.CertificateRejectValidationError, + "opened TLS connection", + ): + self.call_analyze( + "s3", "hostname-mismatch", board_log, tls_path, dns_path + ) + + def test_rejects_reset_contamination_or_wrong_board(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + + def duplicate_query(events: list[dict[str, object]]) -> None: + events.insert(2, dict(events[1])) + + self.mutate_events(dns_path, duplicate_query) + with self.assertRaisesRegex( + phase1b_certificate_reject.CertificateRejectValidationError, + "exactly one query", + ): + self.call_analyze( + "s3", "hostname-mismatch", board_log, tls_path, dns_path + ) + + def test_rejects_unlocked_tls_or_unclean_shutdown(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + + def unlock(events: list[dict[str, object]]) -> None: + events[0]["tls_max_version"] = "1.3" + + self.mutate_events(tls_path, unlock) + with self.assertRaisesRegex( + phase1b_certificate_reject.CertificateRejectValidationError, + "exact certificate rejection profile", + ): + self.call_analyze( + "s3", "hostname-mismatch", board_log, tls_path, dns_path + ) + self.mutate_events( + tls_path, lambda events: events[0].__setitem__("tls_max_version", "1.2") + ) + board_log.write_text(board_log.read_text().replace("shutdown=1", "shutdown=0")) + with self.assertRaisesRegex( + phase1b_certificate_reject.CertificateRejectValidationError, + "shut down cleanly|selected certificate rejection", + ): + self.call_analyze( + "s3", "hostname-mismatch", board_log, tls_path, dns_path + ) + + def test_rejects_out_of_order_evidence(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + + def reorder(events: list[dict[str, object]]) -> None: + events[1]["monotonic_ns"] = 550 + + self.mutate_events(tls_path, reorder) + with self.assertRaisesRegex( + phase1b_certificate_reject.CertificateRejectValidationError, + "out of order", + ): + self.call_analyze( + "s3", "hostname-mismatch", board_log, tls_path, dns_path + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/net-conformance-peer/test_phase1b_conformance.py b/tools/net-conformance-peer/test_phase1b_conformance.py new file mode 100644 index 00000000..f2c06da8 --- /dev/null +++ b/tools/net-conformance-peer/test_phase1b_conformance.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("phase1b_conformance.py") +SPEC = importlib.util.spec_from_file_location("pocketjs_phase1b_conformance", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +phase1b_conformance = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = phase1b_conformance +SPEC.loader.exec_module(phase1b_conformance) + + +class Phase1BConformanceTest(unittest.TestCase): + def fixture( + self, *, board: str = "s3" + ) -> tuple[Path, Path, Path, tempfile.TemporaryDirectory[str]]: + temporary = tempfile.TemporaryDirectory() + root = Path(temporary.name) + board_log = root / "board.log" + tls_path = root / "tls.ndjson" + dns_path = root / "dns.ndjson" + board_ip = "172.16.10.231" if board == "s3" else "172.16.10.145" + + if board == "s3": + lines = ( + "POCKET_NET_TLS_CONTRACT valid=1 https_explicit_opt_in=1 " + "exact_host_tls_profile=1 distinct_tls_errors=1 " + "expected_distinct_tls_errors=1 public=0 " + f"provider={phase1b_conformance.EXPECTED_PROVIDER}", + "POCKET_NET_FORMAL_TLS_BOOT frame_calls=0 " + f"origin={phase1b_conformance.EXPECTED_ORIGIN} " + "peer_ipv4=172.16.10.126 " + f"plan_hash={phase1b_conformance.EXPECTED_PLAN} " + f"factory_sha256={phase1b_conformance.EXPECTED_FACTORY} " + f"ca_der_sha256={phase1b_conformance.EXPECTED_CA} " + f"tls_provider={phase1b_conformance.EXPECTED_PROVIDER} " + "public_capability=0", + f"POCKET_NET_WIFI_GOT_IP ip={board_ip}", + "POCKET_NET_DNS_READY hostname=pocketjs.test " + "main=172.16.10.126 backup=172.16.10.126 " + "fallback=172.16.10.126 readback=exact", + "POCKET_NET_FORMAL_TLS_START " + f"origin={phase1b_conformance.EXPECTED_ORIGIN} " + "peer_ipv4=172.16.10.126 rounds=20 mode=success clock_trusted=1", + "POCKET_NET_FORMAL_TLS_RUN status=ESP_OK rounds=20/20 " + "requests=40/40 frame_calls=0 shutdown=1 poison=0x00000000 " + "core_poison=0x00000000 poisoned_cores=0 core_cause=0 " + "guest_yields=397 elapsed_ms=63279", + "POCKET_NET_FORMAL_TLS_RESULT mode=success pass=1 esp_err=ESP_OK " + "rounds=20/20 requests=40/40 frame_calls=0 shutdown=1 " + "poison=0x00000000 leases_balanced=1 queued_leases=0 " + "taken_leases=0 leases_taken=26 leases_released=26 " + "runtime_requests=40", + ) + else: + lines = ( + "boot: chip revision: v1.3", + "H_SDIO_DRV: Card init success, TRANSPORT_RX_ACTIVE", + "POCKET_NET_TAB5_C6_POWER before_dir=0x01 before_out=0x01 " + "before_high_z=0xfe after_dir=0x01 after_out=0x01 " + "after_high_z=0xfe", + "POCKET_NET_FORMAL_TLS_BOOT board=tab5-esp32p4 " + f"origin={phase1b_conformance.EXPECTED_ORIGIN} " + "peer_ipv4=172.16.10.126 dns_server=172.16.10.126 " + "rounds=20 requests=40 test_mode=success public_capability=0 " + "distinct_tls_errors=1 " + f"plan={phase1b_conformance.EXPECTED_PLAN} " + f"factory={phase1b_conformance.EXPECTED_FACTORY} " + f"ca={phase1b_conformance.EXPECTED_CA} " + f"tls_provider={phase1b_conformance.EXPECTED_PROVIDER}", + f"POCKET_NET_WIFI_CONNECTED ip={board_ip} dns=172.16.10.126 " + "hostname=pocketjs.test fixture_ready=1", + "POCKET_NET_FORMAL_TLS_RUN status=ESP_OK rounds=20/20 " + "requests=40/40 frame_calls=0 shutdown=1 poison=0x00000000 " + "core_poison=0x00000000 poisoned_cores=0 core_cause=0 " + "guest_yields=101 elapsed_ms=24932", + "POCKET_NET_FORMAL_TLS_RESOURCE run_result=ESP_OK " + "runtime_slots=1/1 active=0 pending=0 queued=0 taken=0 " + "requests_started=40 leases_taken=26 leases_released=26 " + "poison=0x00000000 guest_psram_only=1 shutdown=1 " + "owner_stack_low_water_bytes=26696", + "POCKET_NET_FORMAL_TLS_PASS board=tab5-esp32p4 rounds=20 " + "requests=40 frame_calls=0 " + f"plan={phase1b_conformance.EXPECTED_PLAN} " + f"factory={phase1b_conformance.EXPECTED_FACTORY} " + f"ca={phase1b_conformance.EXPECTED_CA} " + f"tls_provider={phase1b_conformance.EXPECTED_PROVIDER} " + "shutdown=1 poison=0x00000000 leases=26/26", + ) + board_log.write_text("\n".join(lines) + "\n") + + tls: list[dict[str, object]] = [ + { + "event": "peer_ready", + "monotonic_ns": 30, + "transport": "tls", + "tls_min_version": "1.2", + "tls_max_version": "1.2", + "observe_tls_close_notify": True, + "socket_timeout_ms": 60_000, + } + ] + timestamp = 30 + request_id = 0 + expected_requests = iter(phase1b_conformance.EXPECTED_REQUESTS) + for connection_id, request_count in enumerate( + phase1b_conformance.CONNECTION_LENGTHS, 1 + ): + timestamp += 1 + tls.append( + { + "event": "tls_client_hello", + "monotonic_ns": timestamp, + "peer_ipv4": board_ip, + "server_name": "pocketjs.test", + } + ) + timestamp += 1 + tls.append( + { + "event": "connection_open", + "monotonic_ns": timestamp, + "connection_id": connection_id, + "peer_ipv4": board_ip, + "tls_server_name": "pocketjs.test", + "tls_version": "TLSv1.2", + "tls_cipher": "ECDHE-RSA-AES256-GCM-SHA384", + } + ) + for index in range(1, request_count + 1): + expected = next(expected_requests) + request_id += 1 + timestamp += 1 + tls.append( + { + "event": "request", + "monotonic_ns": timestamp, + "request_id": request_id, + "connection_id": connection_id, + "connection_request_index": index, + "method": expected.method, + "path": expected.path, + "query_names": list(expected.query_names), + "body_bytes": expected.body_bytes, + "header_names": list(expected.header_names), + } + ) + timestamp += 1 + graceful = connection_id in phase1b_conformance.GRACEFUL_CONNECTIONS + tls.append( + { + "event": "connection_close", + "monotonic_ns": timestamp, + "connection_id": connection_id, + "requests": request_count, + "tls_close_notify_observed": graceful, + "tls_close_state": "close_notify" if graceful else "not_observed", + } + ) + timestamp += 1 + tls.append( + { + "event": "peer_stop", + "monotonic_ns": timestamp, + "reason": "keyboard_interrupt", + } + ) + self.write_events(tls_path, tls) + self.write_events( + dns_path, + [ + { + "event": "dns_ready", + "monotonic_ns": 10, + "authoritative_name": "pocketjs.test", + "authoritative_ipv4": "172.16.10.126", + "port": 53, + "interface": "en1", + "recursion_available": False, + "transports": ["udp", "tcp"], + }, + { + "event": "dns_query", + "monotonic_ns": 20, + "outcome": "answer", + "peer_ipv4": board_ip, + "query_name": "pocketjs.test", + "query_type": 1, + "query_class": 1, + "answers": 1, + "rcode": 0, + "recursion_available": False, + }, + { + "event": "dns_stop", + "monotonic_ns": timestamp + 1, + "reason": "keyboard_interrupt", + }, + ], + ) + return board_log, tls_path, dns_path, temporary + + @staticmethod + def read_events(path: Path) -> list[dict[str, object]]: + return [json.loads(line) for line in path.read_text().splitlines()] + + @staticmethod + def write_events(path: Path, events: list[dict[str, object]]) -> None: + path.write_text("".join(json.dumps(event, sort_keys=True) + "\n" for event in events)) + + def analyze(self, board: str = "s3") -> phase1b_conformance.ConformanceSummary: + board_log, tls_path, dns_path, temporary = self.fixture(board=board) + self.addCleanup(temporary.cleanup) + return self.call_analyze(board, board_log, tls_path, dns_path) + + def call_analyze( + self, board: str, board_log: Path, tls_path: Path, dns_path: Path + ) -> phase1b_conformance.ConformanceSummary: + return phase1b_conformance.analyze( + board=board, + board_log=board_log, + tls_events_path=tls_path, + dns_events_path=dns_path, + board_ipv4="172.16.10.231" if board == "s3" else "172.16.10.145", + peer_ipv4="172.16.10.126", + ) + + def test_accepts_exact_s3_and_p4_evidence(self) -> None: + for board in ("s3", "p4"): + with self.subTest(board=board): + summary = self.analyze(board) + self.assertEqual(summary.guest_operations, 40) + self.assertEqual(summary.wire_requests, 44) + self.assertEqual(summary.tls_connections, 25) + self.assertEqual(summary.close_notify_connections, 7) + self.assertEqual(summary.forced_close_connections, 18) + self.assertEqual(summary.maximum_requests_per_connection, 6) + self.assertEqual(summary.leases, 26) + + def test_rejects_plan_or_runtime_integrity_failure(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + original = board_log.read_text() + board_log.write_text( + original.replace(phase1b_conformance.EXPECTED_PLAN, "sha256:wrong", 1) + ) + with self.assertRaisesRegex(phase1b_conformance.ConformanceValidationError, "S3 boot"): + self.call_analyze("s3", board_log, tls_path, dns_path) + board_log.write_text( + original.replace("poison=0x00000000", "poison=0x00000001", 1) + ) + with self.assertRaises(phase1b_conformance.ConformanceValidationError): + self.call_analyze("s3", board_log, tls_path, dns_path) + + def test_rejects_missing_redirect_hop_or_hidden_retry(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + events = self.read_events(tls_path) + del events[next(index for index, event in enumerate(events) if event.get("event") == "request" and event.get("request_id") == 6)] + self.write_events(tls_path, events) + with self.assertRaisesRegex(phase1b_conformance.ConformanceValidationError, "request count"): + self.call_analyze("s3", board_log, tls_path, dns_path) + + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + events = self.read_events(tls_path) + retry = next(event for event in events if event.get("event") == "request" and event.get("path") == "/retry-once") + retry["path"] = "/attempts" + self.write_events(tls_path, events) + with self.assertRaisesRegex(phase1b_conformance.ConformanceValidationError, "exact conformance sequence"): + self.call_analyze("s3", board_log, tls_path, dns_path) + + def test_rejects_connection_reuse_or_close_drift(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + events = self.read_events(tls_path) + request = next(event for event in events if event.get("event") == "request" and event.get("request_id") == 5) + request["connection_id"] = 2 + self.write_events(tls_path, events) + with self.assertRaisesRegex(phase1b_conformance.ConformanceValidationError, "exact conformance sequence"): + self.call_analyze("s3", board_log, tls_path, dns_path) + + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + events = self.read_events(tls_path) + close = next(event for event in events if event.get("event") == "connection_close" and event.get("connection_id") == 4) + close["tls_close_notify_observed"] = True + close["tls_close_state"] = "close_notify" + self.write_events(tls_path, events) + with self.assertRaisesRegex(phase1b_conformance.ConformanceValidationError, "close/reuse"): + self.call_analyze("s3", board_log, tls_path, dns_path) + + def test_rejects_upload_or_header_drift(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + events = self.read_events(tls_path) + echo = next(event for event in events if event.get("event") == "request" and event.get("path") == "/echo") + echo["body_bytes"] = 8 + echo["header_names"] = ["host", "content-length"] + self.write_events(tls_path, events) + with self.assertRaisesRegex(phase1b_conformance.ConformanceValidationError, "exact conformance sequence"): + self.call_analyze("s3", board_log, tls_path, dns_path) + + def test_rejects_wrong_sni_version_or_failed_handshake(self) -> None: + for mutation in ("sni", "version", "handshake"): + with self.subTest(mutation=mutation): + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + events = self.read_events(tls_path) + if mutation == "sni": + next(event for event in events if event.get("event") == "tls_client_hello")["server_name"] = "wrong.invalid" + elif mutation == "version": + next(event for event in events if event.get("event") == "connection_open")["tls_version"] = "TLSv1.3" + else: + stop = events.pop() + events.append({"event": "tls_handshake_error", "monotonic_ns": int(stop["monotonic_ns"]) - 1}) + events.append(stop) + self.write_events(tls_path, events) + with self.assertRaises(phase1b_conformance.ConformanceValidationError): + self.call_analyze("s3", board_log, tls_path, dns_path) + + def test_rejects_cross_board_or_stale_dns_evidence(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + events = self.read_events(dns_path) + events[1]["peer_ipv4"] = "172.16.10.145" + self.write_events(dns_path, events) + with self.assertRaisesRegex(phase1b_conformance.ConformanceValidationError, "selected board"): + self.call_analyze("s3", board_log, tls_path, dns_path) + + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + events = self.read_events(dns_path) + events[1]["monotonic_ns"] = 32 + self.write_events(dns_path, events) + with self.assertRaisesRegex(phase1b_conformance.ConformanceValidationError, "stale"): + self.call_analyze("s3", board_log, tls_path, dns_path) + + def test_rejects_p4_power_expander_drift(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture(board="p4") + self.addCleanup(temporary.cleanup) + board_log.write_text(board_log.read_text().replace("after_high_z=0xfe", "after_high_z=0xff")) + with self.assertRaisesRegex(phase1b_conformance.ConformanceValidationError, "power expander"): + self.call_analyze("p4", board_log, tls_path, dns_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/net-conformance-peer/test_phase1b_handshake_interrupt.py b/tools/net-conformance-peer/test_phase1b_handshake_interrupt.py new file mode 100644 index 00000000..d865e0b3 --- /dev/null +++ b/tools/net-conformance-peer/test_phase1b_handshake_interrupt.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path +from typing import Callable + + +MODULE_PATH = Path(__file__).with_name("phase1b_handshake_interrupt.py") +SPEC = importlib.util.spec_from_file_location( + "pocketjs_phase1b_handshake_interrupt", MODULE_PATH +) +assert SPEC is not None and SPEC.loader is not None +phase1b_handshake_interrupt = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = phase1b_handshake_interrupt +SPEC.loader.exec_module(phase1b_handshake_interrupt) + + +class Phase1BHandshakeInterruptTest(unittest.TestCase): + def fixture( + self, *, board: str = "s3", profile_name: str = "timeout" + ) -> tuple[Path, Path, Path, tempfile.TemporaryDirectory[str]]: + temporary = tempfile.TemporaryDirectory() + root = Path(temporary.name) + board_log = root / "board.log" + tls_path = root / "tls.ndjson" + dns_path = root / "dns.ndjson" + board_ip = "172.16.10.231" if board == "s3" else "172.16.10.145" + profile = phase1b_handshake_interrupt.PROFILES[profile_name] + mode = profile["mode"] + error_code = profile["error_code"] + connect_timeout_us = profile["connect_timeout_us"] + cancel_after_ms = profile["cancel_after_ms"] + elapsed_ms = 2_200 if profile_name == "timeout" else 650 + + lines = [ + "POCKET_NET_TLS_CONTRACT valid=1 https_explicit_opt_in=1 " + "exact_host_tls_profile=1 distinct_tls_errors=1 public=0", + "POCKET_NET_FORMAL_TLS_BOOT board=test " + "origin=https://pocketjs.test:8443 peer_ipv4=172.16.10.126 " + f"test_mode={mode} public_capability=0 distinct_tls_errors=1 " + f"connect_timeout_us={connect_timeout_us} " + f"cancel_after_ms={cancel_after_ms} " + f"plan_hash={phase1b_handshake_interrupt.EXPECTED_PLAN} " + f"factory_sha256={phase1b_handshake_interrupt.EXPECTED_FACTORY} " + f"plan={phase1b_handshake_interrupt.EXPECTED_PLAN} " + f"factory={phase1b_handshake_interrupt.EXPECTED_FACTORY}", + "POCKET_NET_FORMAL_TLS_START probe=1 " + "origin=https://pocketjs.test:8443 peer_ipv4=172.16.10.126 " + f"mode={mode} connect_timeout_us={connect_timeout_us} " + f"cancel_after_ms={cancel_after_ms}", + "POCKET_NET_FORMAL_TLS_RUN status=ESP_ERR_INVALID_RESPONSE " + "rounds=0/20 requests=0/40 frame_calls=0 service_turns=5 jobs=4 " + "shutdown=1 poison=0x00000000 core_poison=0x00000000 " + f"poisoned_cores=0 guest_yields=28 elapsed_ms={elapsed_ms}", + ] + if board == "s3": + lines.extend( + ( + "POCKET_NET_FORMAL_TLS_RESULT probe=1 " + f"mode={mode} pass=1 esp_err=ESP_ERR_INVALID_RESPONSE " + "rounds=0/20 requests=0/40 shutdown=1 poison=0x00000000 " + "leases_balanced=1 queued_leases=0 taken_leases=0 " + f"error_code={error_code} error_operation=http.fetch", + "POCKET_NET_FORMAL_TLS_NEGATIVE_PASS " + f"mode={mode} error_code={error_code} shutdown=1 " + "poison=0x00000000", + ) + ) + else: + lines.extend( + ( + "POCKET_NET_FORMAL_TLS_RESOURCE probe=1 active=0 pending=0 " + "queued=0 taken=0 leases_taken=0 leases_released=0 " + "leases_cleaned=0 poison=0x00000000 shutdown=1", + "POCKET_NET_FORMAL_TLS_NEGATIVE_PASS board=tab5-esp32p4 " + f"probe=1 mode={mode} error_code={error_code} shutdown=1 " + "poison=0x00000000 leases=0/0", + ) + ) + board_log.write_text("\n".join(lines) + "\n") + + tls = [ + { + "event": "peer_ready", + "monotonic_ns": 100, + "transport": "tls", + "port": 8443, + "tls_min_version": "1.2", + "tls_max_version": "1.2", + "tls_certificate_der_sha256": ( + phase1b_handshake_interrupt.EXPECTED_CERTIFICATE + ), + "tls_handshake_delay_ms": ( + phase1b_handshake_interrupt.HANDSHAKE_DELAY_MS + ), + "observe_tls_close_notify": True, + "socket_timeout_ms": 60_000, + }, + { + "event": "tls_handshake_delay", + "monotonic_ns": 400, + "peer_ipv4": board_ip, + "delay_ms": 10_000, + }, + { + "event": "tls_client_hello", + "monotonic_ns": 10_000_000_400, + "peer_ipv4": board_ip, + "server_name": "pocketjs.test", + }, + { + "event": "tls_handshake_error", + "monotonic_ns": 10_000_000_500, + "peer_ipv4": board_ip, + "error": "SSLEOFError", + }, + { + "event": "peer_stop", + "monotonic_ns": 10_000_000_700, + "reason": "keyboard_interrupt", + }, + ] + dns = [ + { + "event": "dns_ready", + "monotonic_ns": 50, + "authoritative_name": "pocketjs.test", + "authoritative_ipv4": "172.16.10.126", + "port": 53, + "interface": "en1", + "recursion_available": False, + "transports": ["udp", "tcp"], + }, + { + "event": "dns_query", + "monotonic_ns": 300, + "outcome": "answer", + "peer_ipv4": board_ip, + "query_name": "pocketjs.test", + "query_type": 1, + "query_class": 1, + "answers": 1, + "rcode": 0, + "recursion_available": False, + }, + { + "event": "dns_stop", + "monotonic_ns": 10_000_000_800, + "reason": "keyboard_interrupt", + }, + ] + tls_path.write_text("".join(json.dumps(event) + "\n" for event in tls)) + dns_path.write_text("".join(json.dumps(event) + "\n" for event in dns)) + return board_log, tls_path, dns_path, temporary + + def analyze( + self, + board: str, + profile_name: str, + board_log: Path, + tls_path: Path, + dns_path: Path, + ) -> phase1b_handshake_interrupt.HandshakeInterruptSummary: + return phase1b_handshake_interrupt.analyze( + board=board, + profile_name=profile_name, + board_log=board_log, + tls_events_path=tls_path, + dns_events_path=dns_path, + board_ipv4="172.16.10.231" if board == "s3" else "172.16.10.145", + peer_ipv4="172.16.10.126", + ) + + def mutate_events( + self, + path: Path, + mutate: Callable[[list[dict[str, object]]], None], + ) -> None: + events = [json.loads(line) for line in path.read_text().splitlines()] + mutate(events) + path.write_text("".join(json.dumps(event) + "\n" for event in events)) + + def test_accepts_timeout_and_cancel_for_both_boards(self) -> None: + for board in ("s3", "p4"): + for profile_name, profile in ( + phase1b_handshake_interrupt.PROFILES.items() + ): + with self.subTest(board=board, profile=profile_name): + board_log, tls_path, dns_path, temporary = self.fixture( + board=board, profile_name=profile_name + ) + try: + summary = self.analyze( + board, profile_name, board_log, tls_path, dns_path + ) + finally: + temporary.cleanup() + self.assertEqual(summary.error_code, profile["error_code"]) + self.assertEqual(summary.handshake_delay_ms, 10_000) + self.assertGreater(summary.guest_yields, 0) + + def test_rejects_wrong_deadline_or_cancel_snapshot(self) -> None: + for profile_name, field in ( + ("timeout", "connect_timeout_us"), + ("cancel", "cancel_after_ms"), + ): + with self.subTest(profile=profile_name): + board_log, tls_path, dns_path, temporary = self.fixture( + profile_name=profile_name + ) + self.addCleanup(temporary.cleanup) + expected = phase1b_handshake_interrupt.PROFILES[profile_name][field] + board_log.write_text( + board_log.read_text().replace( + f"{field}={expected}", f"{field}=1" + ) + ) + with self.assertRaisesRegex( + phase1b_handshake_interrupt.HandshakeInterruptValidationError, + "exact delayed-handshake interrupt profile", + ): + self.analyze( + "s3", profile_name, board_log, tls_path, dns_path + ) + + def test_rejects_late_or_impossibly_early_board_result(self) -> None: + for elapsed in (100, 30_000): + with self.subTest(elapsed=elapsed): + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + board_log.write_text( + board_log.read_text().replace( + "elapsed_ms=2200", f"elapsed_ms={elapsed}" + ) + ) + with self.assertRaisesRegex( + phase1b_handshake_interrupt.HandshakeInterruptValidationError, + "bounded window", + ): + self.analyze("s3", "timeout", board_log, tls_path, dns_path) + + def test_rejects_wrong_artifact_or_guest_error(self) -> None: + for expected in ( + phase1b_handshake_interrupt.EXPECTED_PLAN, + phase1b_handshake_interrupt.EXPECTED_FACTORY, + ): + with self.subTest(artifact=expected): + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + board_log.write_text( + board_log.read_text().replace(expected, f"sha256:{'0' * 64}") + ) + with self.assertRaisesRegex( + phase1b_handshake_interrupt.HandshakeInterruptValidationError, + "exact TLS interruption artifact", + ): + self.analyze("s3", "timeout", board_log, tls_path, dns_path) + + board_log, tls_path, dns_path, temporary = self.fixture(profile_name="cancel") + self.addCleanup(temporary.cleanup) + board_log.write_text( + board_log.read_text().replace("error_code=aborted", "error_code=timed_out") + ) + with self.assertRaisesRegex( + phase1b_handshake_interrupt.HandshakeInterruptValidationError, + "selected handshake interruption|unexpected HTTP work", + ): + self.analyze("s3", "cancel", board_log, tls_path, dns_path) + + def test_rejects_wrong_peer_profile_or_certificate(self) -> None: + for field, value in ( + ("tls_handshake_delay_ms", 1), + ("tls_max_version", "1.3"), + ("tls_certificate_der_sha256", "0" * 64), + ): + with self.subTest(field=field): + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + self.mutate_events( + tls_path, lambda events: events[0].__setitem__(field, value) + ) + with self.assertRaisesRegex( + phase1b_handshake_interrupt.HandshakeInterruptValidationError, + "exact delayed TLS 1.2 profile", + ): + self.analyze("s3", "timeout", board_log, tls_path, dns_path) + + def test_rejects_opened_connection_or_http_activity(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + self.mutate_events( + tls_path, + lambda events: events.insert( + 4, {"event": "connection_open", "monotonic_ns": 10_000_000_600} + ), + ) + with self.assertRaisesRegex( + phase1b_handshake_interrupt.HandshakeInterruptValidationError, + "opened TLS connection", + ): + self.analyze("s3", "timeout", board_log, tls_path, dns_path) + + def test_rejects_wrong_board_duplicate_sni_or_dns(self) -> None: + for target in ("sni", "dns"): + with self.subTest(target=target): + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + if target == "sni": + self.mutate_events( + tls_path, + lambda events: events[2].__setitem__( + "peer_ipv4", "172.16.10.145" + ), + ) + else: + self.mutate_events( + dns_path, lambda events: events.insert(2, dict(events[1])) + ) + with self.assertRaises( + phase1b_handshake_interrupt.HandshakeInterruptValidationError + ): + self.analyze("s3", "timeout", board_log, tls_path, dns_path) + + def test_rejects_short_or_out_of_order_server_delay(self) -> None: + for monotonic in (9_000_000_000, 350): + with self.subTest(monotonic=monotonic): + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + self.mutate_events( + tls_path, + lambda events: events[2].__setitem__( + "monotonic_ns", monotonic + ), + ) + with self.assertRaisesRegex( + phase1b_handshake_interrupt.HandshakeInterruptValidationError, + "out of order", + ): + self.analyze("s3", "timeout", board_log, tls_path, dns_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/net-conformance-peer/test_phase1b_soak.py b/tools/net-conformance-peer/test_phase1b_soak.py new file mode 100644 index 00000000..b22525a5 --- /dev/null +++ b/tools/net-conformance-peer/test_phase1b_soak.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("phase1b_soak.py") +SPEC = importlib.util.spec_from_file_location("pocketjs_phase1b_soak", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +phase1b_soak = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = phase1b_soak +SPEC.loader.exec_module(phase1b_soak) + + +class Phase1BSoakTest(unittest.TestCase): + def fixture( + self, *, board: str = "s3" + ) -> tuple[Path, Path, Path, tempfile.TemporaryDirectory[str]]: + temporary = tempfile.TemporaryDirectory() + root = Path(temporary.name) + board_log = root / "board.log" + tls_path = root / "tls.ndjson" + dns_path = root / "dns.ndjson" + board_ip = "172.16.10.231" if board == "s3" else "172.16.10.145" + + lines: list[str] = [] + for run in range(1, 4): + if board == "s3": + lines.extend( + ( + "POCKET_NET_RESOURCE phase=formal_before " + "internal_free=240000 internal_min=239000 " + "psram_free=8380000 psram_min=8379000", + f"POCKET_NET_FORMAL_TLS_RESULT probe={run} mode=success " + "pass=1 rounds=20/20 requests=40/40 shutdown=1 " + "poison=0x00000000 leases_balanced=1 guest_yields=400", + "POCKET_NET_RESOURCE phase=formal_after " + f"internal_free={230000 - run * 100} internal_min=190000 " + f"psram_free={8370000 - run * 10} psram_min=5070000", + ) + ) + else: + lines.extend( + ( + f"POCKET_NET_FORMAL_TLS_RESOURCE probe={run} " + f"internal_before=410000 internal_after={407000 - run * 100} " + "internal_min=365000 psram_before=33549000 " + f"psram_after={33548000 - run * 10} psram_min=30240000", + f"POCKET_NET_FORMAL_TLS_PASS board=tab5-esp32p4 probe={run} " + "rounds=20 requests=40 shutdown=1 poison=0x00000000 " + "leases=40/40 guest_yields=400", + ) + ) + board_log.write_text("\n".join(lines) + "\n") + + tls: list[dict[str, object]] = [ + { + "event": "peer_ready", + "socket_timeout_ms": 60_000, + "transport": "tls", + "tls_min_version": "1.2", + "tls_max_version": "1.2", + "observe_tls_close_notify": True, + } + ] + for run in range(1, 4): + base = (run - 1) * 600_000_000_000 + tls.append( + { + "event": "tls_client_hello", + "monotonic_ns": base, + "peer_ipv4": board_ip, + "server_name": "pocketjs.test", + } + ) + tls.append( + { + "event": "connection_open", + "monotonic_ns": base + 1, + "peer_ipv4": board_ip, + "connection_id": run, + "tls_server_name": "pocketjs.test", + "tls_version": "TLSv1.2", + } + ) + for request in range(1, 41): + health = request % 2 == 1 + tls.append( + { + "event": "request", + "monotonic_ns": base + request + 1, + "connection_id": run, + "connection_request_index": request, + "method": "GET" if health else "POST", + "path": "/health" if health else "/echo", + "body_bytes": 0 if health else 12, + "query_names": [], + "header_names": ( + ["accept-encoding", "host"] + if health + else ["accept-encoding", "host", "transfer-encoding"] + ), + } + ) + tls.append( + { + "event": "connection_close", + "monotonic_ns": base + 500_000_000_000, + "connection_id": run, + "requests": 40, + "tls_close_state": "close_notify", + "tls_close_notify_observed": True, + } + ) + tls_path.write_text("".join(json.dumps(event) + "\n" for event in tls)) + dns_path.write_text( + json.dumps( + { + "event": "dns_ready", + "authoritative_name": "pocketjs.test", + "authoritative_ipv4": "172.16.10.126", + "port": 53, + "interface": "en1", + "recursion_available": False, + "transports": ["udp", "tcp"], + } + ) + + "\n" + + json.dumps( + { + "event": "dns_query", + "outcome": "answer", + "peer_ipv4": board_ip, + "query_name": "pocketjs.test", + "query_type": 1, + "query_class": 1, + "answers": 1, + "rcode": 0, + "recursion_available": False, + } + ) + + "\n" + ) + return board_log, tls_path, dns_path, temporary + + def analyze(self, board: str = "s3") -> phase1b_soak.SoakSummary: + board_log, tls_path, dns_path, temporary = self.fixture(board=board) + self.addCleanup(temporary.cleanup) + return phase1b_soak.analyze( + board=board, + board_log=board_log, + tls_events_path=tls_path, + dns_events_path=dns_path, + board_ipv4="172.16.10.231" if board == "s3" else "172.16.10.145", + peer_ipv4="172.16.10.126", + minimum_duration_seconds=900, + minimum_runs=3, + max_internal_peak_bytes=65536, + max_psram_peak_bytes=4194304, + max_internal_drift_bytes=16384, + max_psram_drift_bytes=4096, + ) + + def test_accepts_s3_and_p4_soak_evidence(self) -> None: + for board in ("s3", "p4"): + with self.subTest(board=board): + summary = self.analyze(board) + self.assertEqual(summary.runs, 3) + self.assertEqual(summary.requests, 120) + self.assertEqual(summary.close_notify_connections, 3) + self.assertEqual(summary.minimum_guest_yields, 400) + + def test_rejects_task_watchdog_report(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + board_log.write_text(board_log.read_text() + "E task_wdt: timeout\n") + with self.assertRaisesRegex(phase1b_soak.SoakValidationError, "watchdog"): + self.call_analyze(board_log, tls_path, dns_path) + + def test_rejects_missing_cooperative_yield(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + board_log.write_text( + board_log.read_text().replace("guest_yields=400", "guest_yields=0", 1) + ) + with self.assertRaisesRegex(phase1b_soak.SoakValidationError, "yield"): + self.call_analyze(board_log, tls_path, dns_path) + + def test_rejects_wrong_sni(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + events = [json.loads(line) for line in tls_path.read_text().splitlines()] + hello = next( + event for event in events if event.get("event") == "tls_client_hello" + ) + hello["server_name"] = "wrong.invalid" + tls_path.write_text("".join(json.dumps(event) + "\n" for event in events)) + with self.assertRaisesRegex(phase1b_soak.SoakValidationError, "SNI"): + self.call_analyze(board_log, tls_path, dns_path) + + def test_rejects_peer_timeout_that_preempts_client(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + events = [json.loads(line) for line in tls_path.read_text().splitlines()] + events[0]["socket_timeout_ms"] = 5_000 + tls_path.write_text("".join(json.dumps(event) + "\n" for event in events)) + with self.assertRaisesRegex(phase1b_soak.SoakValidationError, "peer socket"): + self.call_analyze(board_log, tls_path, dns_path) + + def test_rejects_peer_without_locked_tls_profile(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + events = [json.loads(line) for line in tls_path.read_text().splitlines()] + events[0]["tls_max_version"] = None + tls_path.write_text("".join(json.dumps(event) + "\n" for event in events)) + with self.assertRaisesRegex(phase1b_soak.SoakValidationError, "TLS 1.2"): + self.call_analyze(board_log, tls_path, dns_path) + + def test_rejects_reset_contaminated_handshake(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + events = [json.loads(line) for line in tls_path.read_text().splitlines()] + events.extend( + ( + { + "event": "tls_client_hello", + "monotonic_ns": 1_700_000_000_001, + "peer_ipv4": "172.16.10.231", + "server_name": "pocketjs.test", + }, + { + "event": "tls_handshake_error", + "monotonic_ns": 1_760_000_000_001, + "peer_ipv4": "172.16.10.231", + "error_type": "TimeoutError", + }, + ) + ) + tls_path.write_text("".join(json.dumps(event) + "\n" for event in events)) + with self.assertRaisesRegex(phase1b_soak.SoakValidationError, "handshake"): + self.call_analyze(board_log, tls_path, dns_path) + + def test_rejects_resource_drift(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + board_log.write_text( + board_log.read_text().replace( + "internal_free=229700 internal_min=190000", + "internal_free=200000 internal_min=190000", + ) + ) + with self.assertRaisesRegex(phase1b_soak.SoakValidationError, "drift"): + self.call_analyze(board_log, tls_path, dns_path) + + def test_rejects_incomplete_request_set(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + events = [json.loads(line) for line in tls_path.read_text().splitlines()] + request_index = next( + index + for index, event in enumerate(events) + if event.get("event") == "request" and event.get("connection_id") == 3 + ) + del events[request_index] + tls_path.write_text("".join(json.dumps(event) + "\n" for event in events)) + with self.assertRaisesRegex(phase1b_soak.SoakValidationError, "requests"): + self.call_analyze(board_log, tls_path, dns_path) + + def test_rejects_wrong_request_sequence(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + events = [json.loads(line) for line in tls_path.read_text().splitlines()] + request = next(event for event in events if event.get("event") == "request") + request["path"] = "/status/200" + tls_path.write_text("".join(json.dumps(event) + "\n" for event in events)) + with self.assertRaisesRegex(phase1b_soak.SoakValidationError, "sequence"): + self.call_analyze(board_log, tls_path, dns_path) + + def call_analyze(self, board_log: Path, tls_path: Path, dns_path: Path) -> None: + phase1b_soak.analyze( + board="s3", + board_log=board_log, + tls_events_path=tls_path, + dns_events_path=dns_path, + board_ipv4="172.16.10.231", + peer_ipv4="172.16.10.126", + minimum_duration_seconds=900, + minimum_runs=3, + max_internal_peak_bytes=65536, + max_psram_peak_bytes=4194304, + max_internal_drift_bytes=16384, + max_psram_drift_bytes=4096, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/net-conformance-peer/test_phase1b_version_reject.py b/tools/net-conformance-peer/test_phase1b_version_reject.py new file mode 100644 index 00000000..9ceac563 --- /dev/null +++ b/tools/net-conformance-peer/test_phase1b_version_reject.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path +from typing import Callable + + +MODULE_PATH = Path(__file__).with_name("phase1b_version_reject.py") +SPEC = importlib.util.spec_from_file_location( + "pocketjs_phase1b_version_reject", MODULE_PATH +) +assert SPEC is not None and SPEC.loader is not None +phase1b_version_reject = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = phase1b_version_reject +SPEC.loader.exec_module(phase1b_version_reject) + + +class Phase1BVersionRejectTest(unittest.TestCase): + def fixture( + self, *, board: str = "s3" + ) -> tuple[Path, Path, Path, tempfile.TemporaryDirectory[str]]: + temporary = tempfile.TemporaryDirectory() + root = Path(temporary.name) + board_log = root / "board.log" + tls_path = root / "tls.ndjson" + dns_path = root / "dns.ndjson" + board_ip = "172.16.10.231" if board == "s3" else "172.16.10.145" + + lines = [ + "POCKET_NET_TLS_CONTRACT valid=1 distinct_tls_errors=1 public=0", + "POCKET_NET_FORMAL_TLS_BOOT board=test " + "origin=https://pocketjs.test:8443 peer_ipv4=172.16.10.126 " + "dns_server=172.16.10.126 test_mode=version_reject " + "public_capability=0 distinct_tls_errors=1", + "POCKET_NET_FORMAL_TLS_START probe=1 " + "origin=https://pocketjs.test:8443 peer_ipv4=172.16.10.126 " + "mode=version_reject", + "POCKET_NET_FORMAL_TLS_RUN status=ESP_ERR_INVALID_RESPONSE " + "rounds=0/20 requests=0/40 frame_calls=0 service_turns=5 jobs=4 " + "shutdown=1 poison=0x00000000 core_poison=0x00000000 " + "poisoned_cores=0 guest_yields=62 elapsed_ms=7219", + ] + if board == "s3": + lines.append( + "POCKET_NET_FORMAL_TLS_RESULT probe=1 mode=version_reject pass=1 " + "esp_err=ESP_ERR_INVALID_RESPONSE rounds=0/20 requests=0/40 " + "shutdown=1 poison=0x00000000 leases_balanced=1 queued_leases=0 " + "taken_leases=0 error_code=tls_alert" + ) + lines.append( + "POCKET_NET_FORMAL_TLS_NEGATIVE_PASS mode=version_reject " + "error_code=tls_alert shutdown=1 poison=0x00000000" + ) + else: + lines.extend( + ( + "POCKET_NET_FORMAL_TLS_RESOURCE probe=1 active=0 pending=0 " + "queued=0 taken=0 leases_taken=0 leases_released=0 " + "leases_cleaned=0 poison=0x00000000 shutdown=1", + "POCKET_NET_FORMAL_TLS_NEGATIVE_PASS board=tab5-esp32p4 " + "probe=1 mode=version_reject error_code=tls_alert shutdown=1 " + "poison=0x00000000 leases=0/0 elapsed_ms=2580", + ) + ) + board_log.write_text("\n".join(lines) + "\n") + + tls = [ + { + "event": "peer_ready", + "monotonic_ns": 100, + "transport": "tls", + "port": 8443, + "tls_min_version": "1.3", + "tls_max_version": "1.3", + "observe_tls_close_notify": True, + "socket_timeout_ms": 60_000, + }, + { + "event": "tls_handshake_error", + "monotonic_ns": 400, + "peer_ipv4": board_ip, + "error": "SSLError", + }, + { + "event": "peer_stop", + "monotonic_ns": 600, + "reason": "keyboard_interrupt", + }, + ] + dns = [ + { + "event": "dns_ready", + "monotonic_ns": 50, + "authoritative_name": "pocketjs.test", + "authoritative_ipv4": "172.16.10.126", + "port": 53, + "interface": "en1", + "recursion_available": False, + "transports": ["udp", "tcp"], + }, + { + "event": "dns_query", + "monotonic_ns": 300, + "outcome": "answer", + "peer_ipv4": board_ip, + "query_name": "pocketjs.test", + "query_type": 1, + "query_class": 1, + "answers": 1, + "rcode": 0, + "recursion_available": False, + }, + { + "event": "dns_stop", + "monotonic_ns": 700, + "reason": "keyboard_interrupt", + }, + ] + tls_path.write_text("".join(json.dumps(event) + "\n" for event in tls)) + dns_path.write_text("".join(json.dumps(event) + "\n" for event in dns)) + return board_log, tls_path, dns_path, temporary + + def analyze(self, board: str = "s3") -> phase1b_version_reject.VersionRejectSummary: + board_log, tls_path, dns_path, temporary = self.fixture(board=board) + self.addCleanup(temporary.cleanup) + return self.call_analyze(board, board_log, tls_path, dns_path) + + def call_analyze( + self, board: str, board_log: Path, tls_path: Path, dns_path: Path + ) -> phase1b_version_reject.VersionRejectSummary: + return phase1b_version_reject.analyze( + board=board, + board_log=board_log, + tls_events_path=tls_path, + dns_events_path=dns_path, + board_ipv4="172.16.10.231" if board == "s3" else "172.16.10.145", + peer_ipv4="172.16.10.126", + ) + + def mutate_events( + self, + path: Path, + mutate: Callable[[list[dict[str, object]]], None], + ) -> list[dict[str, object]]: + events = [json.loads(line) for line in path.read_text().splitlines()] + mutate(events) + path.write_text("".join(json.dumps(event) + "\n" for event in events)) + return events + + def test_accepts_exact_s3_and_p4_evidence(self) -> None: + for board in ("s3", "p4"): + with self.subTest(board=board): + summary = self.analyze(board) + self.assertEqual(summary.board, board) + self.assertEqual(summary.error_code, "tls_alert") + self.assertEqual(summary.tls_handshake_errors, 1) + self.assertGreater(summary.guest_yields, 0) + + def test_rejects_open_connection_or_http_activity(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + + def add_request(events: list[dict[str, object]]) -> None: + events.insert(2, {"event": "connection_open", "monotonic_ns": 450}) + + self.mutate_events(tls_path, add_request) + with self.assertRaisesRegex( + phase1b_version_reject.VersionRejectValidationError, "opened TLS" + ): + self.call_analyze("s3", board_log, tls_path, dns_path) + + def test_rejects_unlocked_peer_version(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + + def unlock(events: list[dict[str, object]]) -> None: + events[0]["tls_min_version"] = "1.2" + + self.mutate_events(tls_path, unlock) + with self.assertRaisesRegex( + phase1b_version_reject.VersionRejectValidationError, "TLS 1.3-only" + ): + self.call_analyze("s3", board_log, tls_path, dns_path) + + def test_rejects_reset_contaminated_second_handshake(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + + def duplicate(events: list[dict[str, object]]) -> None: + events.insert( + 2, + { + "event": "tls_handshake_error", + "monotonic_ns": 500, + "peer_ipv4": "172.16.10.231", + "error": "SSLError", + }, + ) + + self.mutate_events(tls_path, duplicate) + with self.assertRaisesRegex( + phase1b_version_reject.VersionRejectValidationError, + "exactly one failed handshake", + ): + self.call_analyze("s3", board_log, tls_path, dns_path) + + def test_rejects_wrong_board_identity(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + + def wrong_peer(events: list[dict[str, object]]) -> None: + events[1]["peer_ipv4"] = "172.16.10.99" + + self.mutate_events(dns_path, wrong_peer) + with self.assertRaisesRegex( + phase1b_version_reject.VersionRejectValidationError, "selected board" + ): + self.call_analyze("s3", board_log, tls_path, dns_path) + + def test_rejects_unclean_shutdown_or_live_lease(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + board_log.write_text( + board_log.read_text().replace("leases_balanced=1", "leases_balanced=0") + ) + with self.assertRaisesRegex( + phase1b_version_reject.VersionRejectValidationError, "live leases" + ): + self.call_analyze("s3", board_log, tls_path, dns_path) + + def test_rejects_missing_cooperative_yield(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + board_log.write_text( + board_log.read_text().replace("guest_yields=62", "guest_yields=0") + ) + with self.assertRaisesRegex( + phase1b_version_reject.VersionRejectValidationError, "cooperative" + ): + self.call_analyze("s3", board_log, tls_path, dns_path) + + def test_rejects_stale_or_out_of_order_evidence(self) -> None: + board_log, tls_path, dns_path, temporary = self.fixture() + self.addCleanup(temporary.cleanup) + + def stale(events: list[dict[str, object]]) -> None: + events[1]["monotonic_ns"] = 40 + + self.mutate_events(dns_path, stale) + with self.assertRaisesRegex( + phase1b_version_reject.VersionRejectValidationError, "out of order" + ): + self.call_analyze("s3", board_log, tls_path, dns_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/network-bundle-factory.ts b/tools/network-bundle-factory.ts new file mode 100644 index 00000000..e8c15757 --- /dev/null +++ b/tools/network-bundle-factory.ts @@ -0,0 +1,172 @@ +import { + verifyPlanHash, + type ResolvedBuildPlan, +} from "../framework/src/manifest/plan.ts"; +import type { NetworkPrivateBuildContext } from + "../framework/compiler/network-private.ts"; +import { + networkV1FeatureIdsFromBuildPlan, + networkV1PlanHashBytes, +} from "../contracts/spec/network/network-v1.ts"; + +export type BundleArtifactMode = "iife" | "network-factory"; + +/** + * Select the artifact ABI after checking the plan checksum. Network authority + * and an explicitly selected factory-aware loader must agree in both + * directions; legacy loaders must never receive a callable artifact by + * accident. + */ +export function selectBundleArtifactMode( + plan: ResolvedBuildPlan | undefined, + networkFactoryRequested: boolean, +): BundleArtifactMode { + if (plan !== undefined && !verifyPlanHash(plan)) { + throw new TypeError("PocketJS network factory: invalid ResolvedBuildPlan checksum"); + } + + const hasNetworkPlan = plan?.network !== undefined; + if (hasNetworkPlan && !networkFactoryRequested) { + throw new TypeError( + "PocketJS build: a network ResolvedBuildPlan requires a factory-aware loader (--network-factory)", + ); + } + if (!hasNetworkPlan && networkFactoryRequested) { + throw new TypeError( + "PocketJS build: --network-factory requires a ResolvedBuildPlan with network admission", + ); + } + return hasNetworkPlan ? "network-factory" : "iife"; +} + +/** + * Derive identifiers from the verified plan so they are artifact-specific but + * reproducible. They are transport names between the compiler and finalizer, + * never an application-facing ABI. + */ +export function createNetworkFactoryBuildContext( + plan: ResolvedBuildPlan, +): NetworkPrivateBuildContext { + if (!verifyPlanHash(plan) || plan.network === undefined) { + throw new TypeError( + "PocketJS network factory: private context requires a verified network plan", + ); + } + const digest = /^sha256:([0-9a-f]{64})$/.exec(plan.planHash)?.[1]; + if (!digest) { + throw new TypeError("PocketJS network factory: unsupported plan checksum"); + } + const token = digest.slice(0, 24); + const planHashBytes = Object.freeze(Array.from(networkV1PlanHashBytes(plan.planHash))); + const featureIds = Object.freeze(Array.from(networkV1FeatureIdsFromBuildPlan(plan.features))); + return Object.freeze({ + token, + bootstrapSpecifier: `pocketjs:network-bootstrap-v1-${token}`, + takeIdentifier: `__pocket_take_${token}`, + bindingIdentifier: `__pocket_binding_${token}`, + pendingIdentifier: `__pocket_pending_${token}`, + argumentsIdentifier: `__pocket_arguments_${token}`, + planHashBytes, + featureIds, + }); +} + +function assertContext(context: NetworkPrivateBuildContext): void { + if ( + !/^[0-9a-f]{24}$/.test(context.token) || + context.bootstrapSpecifier !== + `pocketjs:network-bootstrap-v1-${context.token}` || + context.takeIdentifier !== `__pocket_take_${context.token}` || + context.bindingIdentifier !== `__pocket_binding_${context.token}` || + context.pendingIdentifier !== `__pocket_pending_${context.token}` || + context.argumentsIdentifier !== `__pocket_arguments_${context.token}` || + !Array.isArray(context.planHashBytes) || + !Object.isFrozen(context.planHashBytes) || + context.planHashBytes.length !== 32 || + context.planHashBytes.some( + (byte) => !Number.isInteger(byte) || byte < 0 || byte > 0xff, + ) || + !Array.isArray(context.featureIds) || + !Object.isFrozen(context.featureIds) || + context.featureIds.some( + (feature, index) => + !Number.isInteger(feature) || + feature <= 0 || + feature > 0xffff || + (index > 0 && feature <= context.featureIds[index - 1]!), + ) + ) { + throw new TypeError("PocketJS network factory: invalid private build context"); + } +} + +/** + * Wrap Bun's deferred bootstrap IIFE. The public factory deliberately has no + * formal binding parameter. A per-artifact one-shot capture function transfers + * the frozen Host table into the compiler-only framework module, clears both + * its pending slot and the factory argument, and then deletes itself before + * application initialization begins. + */ +export function wrapNetworkBundleFactory( + bundle: string, + context: NetworkPrivateBuildContext, +): string { + if (typeof bundle !== "string" || bundle.length === 0) { + throw new TypeError("PocketJS network factory: bundle source must not be empty"); + } + assertContext(context); + + const body = bundle + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); + return `(function () { + "use strict"; + let __pocketNetworkFactoryConsumedV1 = false; + return function () { + if (__pocketNetworkFactoryConsumedV1) { + throw new TypeError("PocketJS network bundle factory was already invoked"); + } + __pocketNetworkFactoryConsumedV1 = true; + if (arguments.length !== 1) { + throw new TypeError("PocketJS network bundle factory requires exactly one binding argument"); + } + if ( + arguments[0] === null || + typeof arguments[0] !== "object" || + !Object.isFrozen(arguments[0]) + ) { + throw new TypeError("PocketJS network bundle factory requires a frozen binding table"); + } + let ${context.pendingIdentifier} = arguments[0]; + let ${context.argumentsIdentifier} = arguments; + let ${context.takeIdentifier} = function () { + if (${context.pendingIdentifier} === undefined) { + throw new TypeError("PocketJS private network binding was already captured"); + } + const value = ${context.pendingIdentifier}; + ${context.pendingIdentifier} = undefined; + ${context.argumentsIdentifier}[0] = undefined; + ${context.argumentsIdentifier} = undefined; + ${context.takeIdentifier} = undefined; + return value; + }; + return (function () { +${body} + }).call(undefined); + }; +})()`; +} + +/** Preserve legacy artifacts byte-for-byte; wrap only the admitted mode. */ +export function finalizeBundleArtifact( + bundle: string, + mode: BundleArtifactMode, + context?: NetworkPrivateBuildContext, +): string { + if (mode === "iife") return bundle; + if (context === undefined) { + throw new TypeError("PocketJS network factory: missing private build context"); + } + return wrapNetworkBundleFactory(bundle, context); +} diff --git a/tools/network-bundle-factory/README.md b/tools/network-bundle-factory/README.md new file mode 100644 index 00000000..5478d189 --- /dev/null +++ b/tools/network-bundle-factory/README.md @@ -0,0 +1,22 @@ +# Network bundle factory + +`tools/build.ts --network-factory` emits an artifact whose evaluation returns a +one-argument factory. **Evaluation does not run the application bundle.** The +Host calls the factory with its frozen private network binding table; only that +call starts the original IIFE. The binding remains lexical and is never written +to `globalThis`. + +The option is accepted only with a checksummed `ResolvedBuildPlan` containing +network admission. A network plan without the option, or the option without a +network plan, fails the build. The factory is consumed by its first call, +including a call that supplies an invalid argument or whose application +initializer throws. A fresh attempt requires evaluating the artifact again. +After a successful initializer the generated factory returns `undefined`; +loaders observe the installed runtime/frame checkpoints instead of treating the +original IIFE's completion value as an artifact ABI. + +**The experimental ESP-IDF Guest loader is currently the only implemented +factory consumer.** Stock target profiles do not advertise ESP network +capabilities, and the normal PSP, Vita, web, Apple, Symbian, and iPhone loaders +still consume the unchanged IIFE artifact. Formal target registration and +loader wiring remain staged behind network conformance. diff --git a/tools/test-artifact-output.ts b/tools/test-artifact-output.ts new file mode 100644 index 00000000..79c98af3 --- /dev/null +++ b/tools/test-artifact-output.ts @@ -0,0 +1,37 @@ +import { mkdir } from "node:fs/promises"; +import { resolve } from "node:path"; + +export interface GeneratedTestArtifactOutput { + readonly name: string; + readonly bytes: Uint8Array; +} + +export function testArtifactOutputDirectory(args: readonly string[]): string { + if (args.length !== 1 || !args[0]?.startsWith("--output-dir=")) { + throw new Error("usage: bun generate.ts --output-dir="); + } + const value = args[0].slice("--output-dir=".length); + if (value.length === 0) { + throw new Error("artifact output directory cannot be empty"); + } + return resolve(value); +} + +export async function writeTestArtifactOutputs( + outputDirectory: string, + outputs: readonly GeneratedTestArtifactOutput[], +): Promise { + await mkdir(outputDirectory, { recursive: true }); + for (const output of outputs) { + if ( + output.name.length === 0 || + output.name === "." || + output.name === ".." || + output.name.includes("/") || + output.name.includes("\\") + ) { + throw new Error(`invalid generated artifact name: ${output.name}`); + } + await Bun.write(resolve(outputDirectory, output.name), output.bytes); + } +} diff --git a/tools/test.ts b/tools/test.ts index bbdb6ee5..ca4d03ff 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -46,6 +46,7 @@ const SUITE: readonly Stage[] = [ "tests/note.test.ts", "tests/site-stage.test.ts", "tests/host-build-inputs.test.ts", + "tests/network-manifest.test.ts", "tests/iphone2g-profile.test.ts", "tests/iphone4s-profile.test.ts", "tests/ipodtouch-profile.test.ts", @@ -66,6 +67,15 @@ const SUITE: readonly Stage[] = [ "tests/db.test.ts", "tests/fs.test.ts", "tests/net.test.ts", + "tests/http-fetch.test.ts", + "tests/http-body-hardening.test.ts", + "tests/http-utf8.test.ts", + "tests/http-url.test.ts", + "tests/net-limits-hardening.test.ts", + "tests/network-bundle-factory.test.ts", + "tests/network-v1-binding.test.ts", + "contracts/spec/network/network-v1.test.ts", + "tests/esp-network-artifacts.test.ts", "tests/net-web.test.js", "tests/vita-package.test.ts", "tests/psp-toolchain.test.ts", @@ -100,6 +110,21 @@ const SUITE: readonly Stage[] = [ "tests/tiles.test.ts", ], }, + { + name: "network peer", + script: [ + "python3", + "-m", + "unittest", + "tools/net-conformance-peer/test_http_peer.py", + "tools/net-conformance-peer/test_dns_peer.py", + "tools/net-conformance-peer/test_phase1b_soak.py", + "tools/net-conformance-peer/test_phase1b_version_reject.py", + "tools/net-conformance-peer/test_phase1b_certificate_reject.py", + "tools/net-conformance-peer/test_phase1b_handshake_interrupt.py", + "tools/net-conformance-peer/test_phase1b_conformance.py", + ], + }, { name: "vue-sfc journeys", prep: [ diff --git a/tools/vendor/tr46-6.0.0/LICENSE.md b/tools/vendor/tr46-6.0.0/LICENSE.md new file mode 100644 index 00000000..62c0de28 --- /dev/null +++ b/tools/vendor/tr46-6.0.0/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Sebastian Mayr + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tools/vendor/tr46-6.0.0/README.md b/tools/vendor/tr46-6.0.0/README.md new file mode 100644 index 00000000..9286ee41 --- /dev/null +++ b/tools/vendor/tr46-6.0.0/README.md @@ -0,0 +1,14 @@ +# tr46 6.0.0 source snapshot + +`mappingTable.json` and `regexes.js` are unmodified files from the npm +`tr46@6.0.0` tarball. They describe Unicode 17.0.0 UTS #46 processing data. + +- Source: https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz +- Tarball SHA-256: `8737146639e92140a7bf77090b8a09539ec7610b60fcf9a412567cb7280e2881` +- `mappingTable.json` SHA-256: `c45bd284e01f0845bc3c3b1d7594cd7b9ee8b955ddc850882b8e1dc5d0cba95d` +- `regexes.js` SHA-256: `3f43551d5109e5a300b29d5a9c261adf2bfd1621ffc086909f8ebf40835fc70f` +- License: MIT; the upstream notice is retained in `LICENSE.md`. + +`tools/generate-url-idna-table.ts` converts the mapping table into a +fixed-width base64 record string. Runtime lookup decodes one six-byte record +at a time and never expands the 9,262 source rows into JS objects. diff --git a/tools/vendor/tr46-6.0.0/mappingTable.json b/tools/vendor/tr46-6.0.0/mappingTable.json new file mode 100644 index 00000000..73adac51 --- /dev/null +++ b/tools/vendor/tr46-6.0.0/mappingTable.json @@ -0,0 +1 @@ +[[[0,44],2],[[45,46],2],[47,2],[[48,57],2],[[58,64],2],[65,1,"a"],[66,1,"b"],[67,1,"c"],[68,1,"d"],[69,1,"e"],[70,1,"f"],[71,1,"g"],[72,1,"h"],[73,1,"i"],[74,1,"j"],[75,1,"k"],[76,1,"l"],[77,1,"m"],[78,1,"n"],[79,1,"o"],[80,1,"p"],[81,1,"q"],[82,1,"r"],[83,1,"s"],[84,1,"t"],[85,1,"u"],[86,1,"v"],[87,1,"w"],[88,1,"x"],[89,1,"y"],[90,1,"z"],[[91,96],2],[[97,122],2],[[123,127],2],[[128,159],3],[160,1," "],[[161,167],2],[168,1," ̈"],[169,2],[170,1,"a"],[[171,172],2],[173,7],[174,2],[175,1," ̄"],[[176,177],2],[178,1,"2"],[179,1,"3"],[180,1," ́"],[181,1,"μ"],[182,2],[183,2],[184,1," ̧"],[185,1,"1"],[186,1,"o"],[187,2],[188,1,"1⁄4"],[189,1,"1⁄2"],[190,1,"3⁄4"],[191,2],[192,1,"à"],[193,1,"á"],[194,1,"â"],[195,1,"ã"],[196,1,"ä"],[197,1,"å"],[198,1,"æ"],[199,1,"ç"],[200,1,"è"],[201,1,"é"],[202,1,"ê"],[203,1,"ë"],[204,1,"ì"],[205,1,"í"],[206,1,"î"],[207,1,"ï"],[208,1,"ð"],[209,1,"ñ"],[210,1,"ò"],[211,1,"ó"],[212,1,"ô"],[213,1,"õ"],[214,1,"ö"],[215,2],[216,1,"ø"],[217,1,"ù"],[218,1,"ú"],[219,1,"û"],[220,1,"ü"],[221,1,"ý"],[222,1,"þ"],[223,6,"ss"],[[224,246],2],[247,2],[[248,255],2],[256,1,"ā"],[257,2],[258,1,"ă"],[259,2],[260,1,"ą"],[261,2],[262,1,"ć"],[263,2],[264,1,"ĉ"],[265,2],[266,1,"ċ"],[267,2],[268,1,"č"],[269,2],[270,1,"ď"],[271,2],[272,1,"đ"],[273,2],[274,1,"ē"],[275,2],[276,1,"ĕ"],[277,2],[278,1,"ė"],[279,2],[280,1,"ę"],[281,2],[282,1,"ě"],[283,2],[284,1,"ĝ"],[285,2],[286,1,"ğ"],[287,2],[288,1,"ġ"],[289,2],[290,1,"ģ"],[291,2],[292,1,"ĥ"],[293,2],[294,1,"ħ"],[295,2],[296,1,"ĩ"],[297,2],[298,1,"ī"],[299,2],[300,1,"ĭ"],[301,2],[302,1,"į"],[303,2],[304,1,"i̇"],[305,2],[[306,307],1,"ij"],[308,1,"ĵ"],[309,2],[310,1,"ķ"],[[311,312],2],[313,1,"ĺ"],[314,2],[315,1,"ļ"],[316,2],[317,1,"ľ"],[318,2],[[319,320],1,"l·"],[321,1,"ł"],[322,2],[323,1,"ń"],[324,2],[325,1,"ņ"],[326,2],[327,1,"ň"],[328,2],[329,1,"ʼn"],[330,1,"ŋ"],[331,2],[332,1,"ō"],[333,2],[334,1,"ŏ"],[335,2],[336,1,"ő"],[337,2],[338,1,"œ"],[339,2],[340,1,"ŕ"],[341,2],[342,1,"ŗ"],[343,2],[344,1,"ř"],[345,2],[346,1,"ś"],[347,2],[348,1,"ŝ"],[349,2],[350,1,"ş"],[351,2],[352,1,"š"],[353,2],[354,1,"ţ"],[355,2],[356,1,"ť"],[357,2],[358,1,"ŧ"],[359,2],[360,1,"ũ"],[361,2],[362,1,"ū"],[363,2],[364,1,"ŭ"],[365,2],[366,1,"ů"],[367,2],[368,1,"ű"],[369,2],[370,1,"ų"],[371,2],[372,1,"ŵ"],[373,2],[374,1,"ŷ"],[375,2],[376,1,"ÿ"],[377,1,"ź"],[378,2],[379,1,"ż"],[380,2],[381,1,"ž"],[382,2],[383,1,"s"],[384,2],[385,1,"ɓ"],[386,1,"ƃ"],[387,2],[388,1,"ƅ"],[389,2],[390,1,"ɔ"],[391,1,"ƈ"],[392,2],[393,1,"ɖ"],[394,1,"ɗ"],[395,1,"ƌ"],[[396,397],2],[398,1,"ǝ"],[399,1,"ə"],[400,1,"ɛ"],[401,1,"ƒ"],[402,2],[403,1,"ɠ"],[404,1,"ɣ"],[405,2],[406,1,"ɩ"],[407,1,"ɨ"],[408,1,"ƙ"],[[409,411],2],[412,1,"ɯ"],[413,1,"ɲ"],[414,2],[415,1,"ɵ"],[416,1,"ơ"],[417,2],[418,1,"ƣ"],[419,2],[420,1,"ƥ"],[421,2],[422,1,"ʀ"],[423,1,"ƨ"],[424,2],[425,1,"ʃ"],[[426,427],2],[428,1,"ƭ"],[429,2],[430,1,"ʈ"],[431,1,"ư"],[432,2],[433,1,"ʊ"],[434,1,"ʋ"],[435,1,"ƴ"],[436,2],[437,1,"ƶ"],[438,2],[439,1,"ʒ"],[440,1,"ƹ"],[[441,443],2],[444,1,"ƽ"],[[445,451],2],[[452,454],1,"dž"],[[455,457],1,"lj"],[[458,460],1,"nj"],[461,1,"ǎ"],[462,2],[463,1,"ǐ"],[464,2],[465,1,"ǒ"],[466,2],[467,1,"ǔ"],[468,2],[469,1,"ǖ"],[470,2],[471,1,"ǘ"],[472,2],[473,1,"ǚ"],[474,2],[475,1,"ǜ"],[[476,477],2],[478,1,"ǟ"],[479,2],[480,1,"ǡ"],[481,2],[482,1,"ǣ"],[483,2],[484,1,"ǥ"],[485,2],[486,1,"ǧ"],[487,2],[488,1,"ǩ"],[489,2],[490,1,"ǫ"],[491,2],[492,1,"ǭ"],[493,2],[494,1,"ǯ"],[[495,496],2],[[497,499],1,"dz"],[500,1,"ǵ"],[501,2],[502,1,"ƕ"],[503,1,"ƿ"],[504,1,"ǹ"],[505,2],[506,1,"ǻ"],[507,2],[508,1,"ǽ"],[509,2],[510,1,"ǿ"],[511,2],[512,1,"ȁ"],[513,2],[514,1,"ȃ"],[515,2],[516,1,"ȅ"],[517,2],[518,1,"ȇ"],[519,2],[520,1,"ȉ"],[521,2],[522,1,"ȋ"],[523,2],[524,1,"ȍ"],[525,2],[526,1,"ȏ"],[527,2],[528,1,"ȑ"],[529,2],[530,1,"ȓ"],[531,2],[532,1,"ȕ"],[533,2],[534,1,"ȗ"],[535,2],[536,1,"ș"],[537,2],[538,1,"ț"],[539,2],[540,1,"ȝ"],[541,2],[542,1,"ȟ"],[543,2],[544,1,"ƞ"],[545,2],[546,1,"ȣ"],[547,2],[548,1,"ȥ"],[549,2],[550,1,"ȧ"],[551,2],[552,1,"ȩ"],[553,2],[554,1,"ȫ"],[555,2],[556,1,"ȭ"],[557,2],[558,1,"ȯ"],[559,2],[560,1,"ȱ"],[561,2],[562,1,"ȳ"],[563,2],[[564,566],2],[[567,569],2],[570,1,"ⱥ"],[571,1,"ȼ"],[572,2],[573,1,"ƚ"],[574,1,"ⱦ"],[[575,576],2],[577,1,"ɂ"],[578,2],[579,1,"ƀ"],[580,1,"ʉ"],[581,1,"ʌ"],[582,1,"ɇ"],[583,2],[584,1,"ɉ"],[585,2],[586,1,"ɋ"],[587,2],[588,1,"ɍ"],[589,2],[590,1,"ɏ"],[591,2],[[592,680],2],[[681,685],2],[[686,687],2],[688,1,"h"],[689,1,"ɦ"],[690,1,"j"],[691,1,"r"],[692,1,"ɹ"],[693,1,"ɻ"],[694,1,"ʁ"],[695,1,"w"],[696,1,"y"],[[697,705],2],[[706,709],2],[[710,721],2],[[722,727],2],[728,1," ̆"],[729,1," ̇"],[730,1," ̊"],[731,1," ̨"],[732,1," ̃"],[733,1," ̋"],[734,2],[735,2],[736,1,"ɣ"],[737,1,"l"],[738,1,"s"],[739,1,"x"],[740,1,"ʕ"],[[741,745],2],[[746,747],2],[748,2],[749,2],[750,2],[[751,767],2],[[768,831],2],[832,1,"̀"],[833,1,"́"],[834,2],[835,1,"̓"],[836,1,"̈́"],[837,1,"ι"],[[838,846],2],[847,7],[[848,855],2],[[856,860],2],[[861,863],2],[[864,865],2],[866,2],[[867,879],2],[880,1,"ͱ"],[881,2],[882,1,"ͳ"],[883,2],[884,1,"ʹ"],[885,2],[886,1,"ͷ"],[887,2],[[888,889],3],[890,1," ι"],[[891,893],2],[894,1,";"],[895,1,"ϳ"],[[896,899],3],[900,1," ́"],[901,1," ̈́"],[902,1,"ά"],[903,1,"·"],[904,1,"έ"],[905,1,"ή"],[906,1,"ί"],[907,3],[908,1,"ό"],[909,3],[910,1,"ύ"],[911,1,"ώ"],[912,2],[913,1,"α"],[914,1,"β"],[915,1,"γ"],[916,1,"δ"],[917,1,"ε"],[918,1,"ζ"],[919,1,"η"],[920,1,"θ"],[921,1,"ι"],[922,1,"κ"],[923,1,"λ"],[924,1,"μ"],[925,1,"ν"],[926,1,"ξ"],[927,1,"ο"],[928,1,"π"],[929,1,"ρ"],[930,3],[931,1,"σ"],[932,1,"τ"],[933,1,"υ"],[934,1,"φ"],[935,1,"χ"],[936,1,"ψ"],[937,1,"ω"],[938,1,"ϊ"],[939,1,"ϋ"],[[940,961],2],[962,6,"σ"],[[963,974],2],[975,1,"ϗ"],[976,1,"β"],[977,1,"θ"],[978,1,"υ"],[979,1,"ύ"],[980,1,"ϋ"],[981,1,"φ"],[982,1,"π"],[983,2],[984,1,"ϙ"],[985,2],[986,1,"ϛ"],[987,2],[988,1,"ϝ"],[989,2],[990,1,"ϟ"],[991,2],[992,1,"ϡ"],[993,2],[994,1,"ϣ"],[995,2],[996,1,"ϥ"],[997,2],[998,1,"ϧ"],[999,2],[1000,1,"ϩ"],[1001,2],[1002,1,"ϫ"],[1003,2],[1004,1,"ϭ"],[1005,2],[1006,1,"ϯ"],[1007,2],[1008,1,"κ"],[1009,1,"ρ"],[1010,1,"σ"],[1011,2],[1012,1,"θ"],[1013,1,"ε"],[1014,2],[1015,1,"ϸ"],[1016,2],[1017,1,"σ"],[1018,1,"ϻ"],[1019,2],[1020,2],[1021,1,"ͻ"],[1022,1,"ͼ"],[1023,1,"ͽ"],[1024,1,"ѐ"],[1025,1,"ё"],[1026,1,"ђ"],[1027,1,"ѓ"],[1028,1,"є"],[1029,1,"ѕ"],[1030,1,"і"],[1031,1,"ї"],[1032,1,"ј"],[1033,1,"љ"],[1034,1,"њ"],[1035,1,"ћ"],[1036,1,"ќ"],[1037,1,"ѝ"],[1038,1,"ў"],[1039,1,"џ"],[1040,1,"а"],[1041,1,"б"],[1042,1,"в"],[1043,1,"г"],[1044,1,"д"],[1045,1,"е"],[1046,1,"ж"],[1047,1,"з"],[1048,1,"и"],[1049,1,"й"],[1050,1,"к"],[1051,1,"л"],[1052,1,"м"],[1053,1,"н"],[1054,1,"о"],[1055,1,"п"],[1056,1,"р"],[1057,1,"с"],[1058,1,"т"],[1059,1,"у"],[1060,1,"ф"],[1061,1,"х"],[1062,1,"ц"],[1063,1,"ч"],[1064,1,"ш"],[1065,1,"щ"],[1066,1,"ъ"],[1067,1,"ы"],[1068,1,"ь"],[1069,1,"э"],[1070,1,"ю"],[1071,1,"я"],[[1072,1103],2],[1104,2],[[1105,1116],2],[1117,2],[[1118,1119],2],[1120,1,"ѡ"],[1121,2],[1122,1,"ѣ"],[1123,2],[1124,1,"ѥ"],[1125,2],[1126,1,"ѧ"],[1127,2],[1128,1,"ѩ"],[1129,2],[1130,1,"ѫ"],[1131,2],[1132,1,"ѭ"],[1133,2],[1134,1,"ѯ"],[1135,2],[1136,1,"ѱ"],[1137,2],[1138,1,"ѳ"],[1139,2],[1140,1,"ѵ"],[1141,2],[1142,1,"ѷ"],[1143,2],[1144,1,"ѹ"],[1145,2],[1146,1,"ѻ"],[1147,2],[1148,1,"ѽ"],[1149,2],[1150,1,"ѿ"],[1151,2],[1152,1,"ҁ"],[1153,2],[1154,2],[[1155,1158],2],[1159,2],[[1160,1161],2],[1162,1,"ҋ"],[1163,2],[1164,1,"ҍ"],[1165,2],[1166,1,"ҏ"],[1167,2],[1168,1,"ґ"],[1169,2],[1170,1,"ғ"],[1171,2],[1172,1,"ҕ"],[1173,2],[1174,1,"җ"],[1175,2],[1176,1,"ҙ"],[1177,2],[1178,1,"қ"],[1179,2],[1180,1,"ҝ"],[1181,2],[1182,1,"ҟ"],[1183,2],[1184,1,"ҡ"],[1185,2],[1186,1,"ң"],[1187,2],[1188,1,"ҥ"],[1189,2],[1190,1,"ҧ"],[1191,2],[1192,1,"ҩ"],[1193,2],[1194,1,"ҫ"],[1195,2],[1196,1,"ҭ"],[1197,2],[1198,1,"ү"],[1199,2],[1200,1,"ұ"],[1201,2],[1202,1,"ҳ"],[1203,2],[1204,1,"ҵ"],[1205,2],[1206,1,"ҷ"],[1207,2],[1208,1,"ҹ"],[1209,2],[1210,1,"һ"],[1211,2],[1212,1,"ҽ"],[1213,2],[1214,1,"ҿ"],[1215,2],[1216,1,"ӏ"],[1217,1,"ӂ"],[1218,2],[1219,1,"ӄ"],[1220,2],[1221,1,"ӆ"],[1222,2],[1223,1,"ӈ"],[1224,2],[1225,1,"ӊ"],[1226,2],[1227,1,"ӌ"],[1228,2],[1229,1,"ӎ"],[1230,2],[1231,2],[1232,1,"ӑ"],[1233,2],[1234,1,"ӓ"],[1235,2],[1236,1,"ӕ"],[1237,2],[1238,1,"ӗ"],[1239,2],[1240,1,"ә"],[1241,2],[1242,1,"ӛ"],[1243,2],[1244,1,"ӝ"],[1245,2],[1246,1,"ӟ"],[1247,2],[1248,1,"ӡ"],[1249,2],[1250,1,"ӣ"],[1251,2],[1252,1,"ӥ"],[1253,2],[1254,1,"ӧ"],[1255,2],[1256,1,"ө"],[1257,2],[1258,1,"ӫ"],[1259,2],[1260,1,"ӭ"],[1261,2],[1262,1,"ӯ"],[1263,2],[1264,1,"ӱ"],[1265,2],[1266,1,"ӳ"],[1267,2],[1268,1,"ӵ"],[1269,2],[1270,1,"ӷ"],[1271,2],[1272,1,"ӹ"],[1273,2],[1274,1,"ӻ"],[1275,2],[1276,1,"ӽ"],[1277,2],[1278,1,"ӿ"],[1279,2],[1280,1,"ԁ"],[1281,2],[1282,1,"ԃ"],[1283,2],[1284,1,"ԅ"],[1285,2],[1286,1,"ԇ"],[1287,2],[1288,1,"ԉ"],[1289,2],[1290,1,"ԋ"],[1291,2],[1292,1,"ԍ"],[1293,2],[1294,1,"ԏ"],[1295,2],[1296,1,"ԑ"],[1297,2],[1298,1,"ԓ"],[1299,2],[1300,1,"ԕ"],[1301,2],[1302,1,"ԗ"],[1303,2],[1304,1,"ԙ"],[1305,2],[1306,1,"ԛ"],[1307,2],[1308,1,"ԝ"],[1309,2],[1310,1,"ԟ"],[1311,2],[1312,1,"ԡ"],[1313,2],[1314,1,"ԣ"],[1315,2],[1316,1,"ԥ"],[1317,2],[1318,1,"ԧ"],[1319,2],[1320,1,"ԩ"],[1321,2],[1322,1,"ԫ"],[1323,2],[1324,1,"ԭ"],[1325,2],[1326,1,"ԯ"],[1327,2],[1328,3],[1329,1,"ա"],[1330,1,"բ"],[1331,1,"գ"],[1332,1,"դ"],[1333,1,"ե"],[1334,1,"զ"],[1335,1,"է"],[1336,1,"ը"],[1337,1,"թ"],[1338,1,"ժ"],[1339,1,"ի"],[1340,1,"լ"],[1341,1,"խ"],[1342,1,"ծ"],[1343,1,"կ"],[1344,1,"հ"],[1345,1,"ձ"],[1346,1,"ղ"],[1347,1,"ճ"],[1348,1,"մ"],[1349,1,"յ"],[1350,1,"ն"],[1351,1,"շ"],[1352,1,"ո"],[1353,1,"չ"],[1354,1,"պ"],[1355,1,"ջ"],[1356,1,"ռ"],[1357,1,"ս"],[1358,1,"վ"],[1359,1,"տ"],[1360,1,"ր"],[1361,1,"ց"],[1362,1,"ւ"],[1363,1,"փ"],[1364,1,"ք"],[1365,1,"օ"],[1366,1,"ֆ"],[[1367,1368],3],[1369,2],[[1370,1375],2],[1376,2],[[1377,1414],2],[1415,1,"եւ"],[1416,2],[1417,2],[1418,2],[[1419,1420],3],[[1421,1422],2],[1423,2],[1424,3],[[1425,1441],2],[1442,2],[[1443,1455],2],[[1456,1465],2],[1466,2],[[1467,1469],2],[1470,2],[1471,2],[1472,2],[[1473,1474],2],[1475,2],[1476,2],[1477,2],[1478,2],[1479,2],[[1480,1487],3],[[1488,1514],2],[[1515,1518],3],[1519,2],[[1520,1524],2],[[1525,1535],3],[[1536,1539],3],[1540,3],[1541,3],[[1542,1546],2],[1547,2],[1548,2],[[1549,1551],2],[[1552,1557],2],[[1558,1562],2],[1563,2],[1564,3],[1565,2],[1566,2],[1567,2],[1568,2],[[1569,1594],2],[[1595,1599],2],[1600,2],[[1601,1618],2],[[1619,1621],2],[[1622,1624],2],[[1625,1630],2],[1631,2],[[1632,1641],2],[[1642,1645],2],[[1646,1647],2],[[1648,1652],2],[1653,1,"اٴ"],[1654,1,"وٴ"],[1655,1,"ۇٴ"],[1656,1,"يٴ"],[[1657,1719],2],[[1720,1721],2],[[1722,1726],2],[1727,2],[[1728,1742],2],[1743,2],[[1744,1747],2],[1748,2],[[1749,1756],2],[1757,3],[1758,2],[[1759,1768],2],[1769,2],[[1770,1773],2],[[1774,1775],2],[[1776,1785],2],[[1786,1790],2],[1791,2],[[1792,1805],2],[1806,3],[1807,3],[[1808,1836],2],[[1837,1839],2],[[1840,1866],2],[[1867,1868],3],[[1869,1871],2],[[1872,1901],2],[[1902,1919],2],[[1920,1968],2],[1969,2],[[1970,1983],3],[[1984,2037],2],[[2038,2042],2],[[2043,2044],3],[2045,2],[[2046,2047],2],[[2048,2093],2],[[2094,2095],3],[[2096,2110],2],[2111,3],[[2112,2139],2],[[2140,2141],3],[2142,2],[2143,3],[[2144,2154],2],[[2155,2159],3],[[2160,2183],2],[2184,2],[[2185,2190],2],[2191,2],[[2192,2193],3],[[2194,2198],3],[2199,2],[[2200,2207],2],[2208,2],[2209,2],[[2210,2220],2],[[2221,2226],2],[[2227,2228],2],[2229,2],[[2230,2237],2],[[2238,2247],2],[[2248,2258],2],[2259,2],[[2260,2273],2],[2274,3],[2275,2],[[2276,2302],2],[2303,2],[2304,2],[[2305,2307],2],[2308,2],[[2309,2361],2],[[2362,2363],2],[[2364,2381],2],[2382,2],[2383,2],[[2384,2388],2],[2389,2],[[2390,2391],2],[2392,1,"क़"],[2393,1,"ख़"],[2394,1,"ग़"],[2395,1,"ज़"],[2396,1,"ड़"],[2397,1,"ढ़"],[2398,1,"फ़"],[2399,1,"य़"],[[2400,2403],2],[[2404,2405],2],[[2406,2415],2],[2416,2],[[2417,2418],2],[[2419,2423],2],[2424,2],[[2425,2426],2],[[2427,2428],2],[2429,2],[[2430,2431],2],[2432,2],[[2433,2435],2],[2436,3],[[2437,2444],2],[[2445,2446],3],[[2447,2448],2],[[2449,2450],3],[[2451,2472],2],[2473,3],[[2474,2480],2],[2481,3],[2482,2],[[2483,2485],3],[[2486,2489],2],[[2490,2491],3],[2492,2],[2493,2],[[2494,2500],2],[[2501,2502],3],[[2503,2504],2],[[2505,2506],3],[[2507,2509],2],[2510,2],[[2511,2518],3],[2519,2],[[2520,2523],3],[2524,1,"ড়"],[2525,1,"ঢ়"],[2526,3],[2527,1,"য়"],[[2528,2531],2],[[2532,2533],3],[[2534,2545],2],[[2546,2554],2],[2555,2],[2556,2],[2557,2],[2558,2],[[2559,2560],3],[2561,2],[2562,2],[2563,2],[2564,3],[[2565,2570],2],[[2571,2574],3],[[2575,2576],2],[[2577,2578],3],[[2579,2600],2],[2601,3],[[2602,2608],2],[2609,3],[2610,2],[2611,1,"ਲ਼"],[2612,3],[2613,2],[2614,1,"ਸ਼"],[2615,3],[[2616,2617],2],[[2618,2619],3],[2620,2],[2621,3],[[2622,2626],2],[[2627,2630],3],[[2631,2632],2],[[2633,2634],3],[[2635,2637],2],[[2638,2640],3],[2641,2],[[2642,2648],3],[2649,1,"ਖ਼"],[2650,1,"ਗ਼"],[2651,1,"ਜ਼"],[2652,2],[2653,3],[2654,1,"ਫ਼"],[[2655,2661],3],[[2662,2676],2],[2677,2],[2678,2],[[2679,2688],3],[[2689,2691],2],[2692,3],[[2693,2699],2],[2700,2],[2701,2],[2702,3],[[2703,2705],2],[2706,3],[[2707,2728],2],[2729,3],[[2730,2736],2],[2737,3],[[2738,2739],2],[2740,3],[[2741,2745],2],[[2746,2747],3],[[2748,2757],2],[2758,3],[[2759,2761],2],[2762,3],[[2763,2765],2],[[2766,2767],3],[2768,2],[[2769,2783],3],[2784,2],[[2785,2787],2],[[2788,2789],3],[[2790,2799],2],[2800,2],[2801,2],[[2802,2808],3],[2809,2],[[2810,2815],2],[2816,3],[[2817,2819],2],[2820,3],[[2821,2828],2],[[2829,2830],3],[[2831,2832],2],[[2833,2834],3],[[2835,2856],2],[2857,3],[[2858,2864],2],[2865,3],[[2866,2867],2],[2868,3],[2869,2],[[2870,2873],2],[[2874,2875],3],[[2876,2883],2],[2884,2],[[2885,2886],3],[[2887,2888],2],[[2889,2890],3],[[2891,2893],2],[[2894,2900],3],[2901,2],[[2902,2903],2],[[2904,2907],3],[2908,1,"ଡ଼"],[2909,1,"ଢ଼"],[2910,3],[[2911,2913],2],[[2914,2915],2],[[2916,2917],3],[[2918,2927],2],[2928,2],[2929,2],[[2930,2935],2],[[2936,2945],3],[[2946,2947],2],[2948,3],[[2949,2954],2],[[2955,2957],3],[[2958,2960],2],[2961,3],[[2962,2965],2],[[2966,2968],3],[[2969,2970],2],[2971,3],[2972,2],[2973,3],[[2974,2975],2],[[2976,2978],3],[[2979,2980],2],[[2981,2983],3],[[2984,2986],2],[[2987,2989],3],[[2990,2997],2],[2998,2],[[2999,3001],2],[[3002,3005],3],[[3006,3010],2],[[3011,3013],3],[[3014,3016],2],[3017,3],[[3018,3021],2],[[3022,3023],3],[3024,2],[[3025,3030],3],[3031,2],[[3032,3045],3],[3046,2],[[3047,3055],2],[[3056,3058],2],[[3059,3066],2],[[3067,3071],3],[3072,2],[[3073,3075],2],[3076,2],[[3077,3084],2],[3085,3],[[3086,3088],2],[3089,3],[[3090,3112],2],[3113,3],[[3114,3123],2],[3124,2],[[3125,3129],2],[[3130,3131],3],[3132,2],[3133,2],[[3134,3140],2],[3141,3],[[3142,3144],2],[3145,3],[[3146,3149],2],[[3150,3156],3],[[3157,3158],2],[3159,3],[[3160,3161],2],[3162,2],[3163,3],[3164,2],[3165,2],[[3166,3167],3],[[3168,3169],2],[[3170,3171],2],[[3172,3173],3],[[3174,3183],2],[[3184,3190],3],[3191,2],[[3192,3199],2],[3200,2],[3201,2],[[3202,3203],2],[3204,2],[[3205,3212],2],[3213,3],[[3214,3216],2],[3217,3],[[3218,3240],2],[3241,3],[[3242,3251],2],[3252,3],[[3253,3257],2],[[3258,3259],3],[[3260,3261],2],[[3262,3268],2],[3269,3],[[3270,3272],2],[3273,3],[[3274,3277],2],[[3278,3284],3],[[3285,3286],2],[[3287,3291],3],[3292,2],[3293,2],[3294,2],[3295,3],[[3296,3297],2],[[3298,3299],2],[[3300,3301],3],[[3302,3311],2],[3312,3],[[3313,3314],2],[3315,2],[[3316,3327],3],[3328,2],[3329,2],[[3330,3331],2],[3332,2],[[3333,3340],2],[3341,3],[[3342,3344],2],[3345,3],[[3346,3368],2],[3369,2],[[3370,3385],2],[3386,2],[[3387,3388],2],[3389,2],[[3390,3395],2],[3396,2],[3397,3],[[3398,3400],2],[3401,3],[[3402,3405],2],[3406,2],[3407,2],[[3408,3411],3],[[3412,3414],2],[3415,2],[[3416,3422],2],[3423,2],[[3424,3425],2],[[3426,3427],2],[[3428,3429],3],[[3430,3439],2],[[3440,3445],2],[[3446,3448],2],[3449,2],[[3450,3455],2],[3456,3],[3457,2],[[3458,3459],2],[3460,3],[[3461,3478],2],[[3479,3481],3],[[3482,3505],2],[3506,3],[[3507,3515],2],[3516,3],[3517,2],[[3518,3519],3],[[3520,3526],2],[[3527,3529],3],[3530,2],[[3531,3534],3],[[3535,3540],2],[3541,3],[3542,2],[3543,3],[[3544,3551],2],[[3552,3557],3],[[3558,3567],2],[[3568,3569],3],[[3570,3571],2],[3572,2],[[3573,3584],3],[[3585,3634],2],[3635,1,"ํา"],[[3636,3642],2],[[3643,3646],3],[3647,2],[[3648,3662],2],[3663,2],[[3664,3673],2],[[3674,3675],2],[[3676,3712],3],[[3713,3714],2],[3715,3],[3716,2],[3717,3],[3718,2],[[3719,3720],2],[3721,2],[3722,2],[3723,3],[3724,2],[3725,2],[[3726,3731],2],[[3732,3735],2],[3736,2],[[3737,3743],2],[3744,2],[[3745,3747],2],[3748,3],[3749,2],[3750,3],[3751,2],[[3752,3753],2],[[3754,3755],2],[3756,2],[[3757,3762],2],[3763,1,"ໍາ"],[[3764,3769],2],[3770,2],[[3771,3773],2],[[3774,3775],3],[[3776,3780],2],[3781,3],[3782,2],[3783,3],[[3784,3789],2],[3790,2],[3791,3],[[3792,3801],2],[[3802,3803],3],[3804,1,"ຫນ"],[3805,1,"ຫມ"],[[3806,3807],2],[[3808,3839],3],[3840,2],[[3841,3850],2],[3851,2],[3852,1,"་"],[[3853,3863],2],[[3864,3865],2],[[3866,3871],2],[[3872,3881],2],[[3882,3892],2],[3893,2],[3894,2],[3895,2],[3896,2],[3897,2],[[3898,3901],2],[[3902,3906],2],[3907,1,"གྷ"],[[3908,3911],2],[3912,3],[[3913,3916],2],[3917,1,"ཌྷ"],[[3918,3921],2],[3922,1,"དྷ"],[[3923,3926],2],[3927,1,"བྷ"],[[3928,3931],2],[3932,1,"ཛྷ"],[[3933,3944],2],[3945,1,"ཀྵ"],[3946,2],[[3947,3948],2],[[3949,3952],3],[[3953,3954],2],[3955,1,"ཱི"],[3956,2],[3957,1,"ཱུ"],[3958,1,"ྲྀ"],[3959,1,"ྲཱྀ"],[3960,1,"ླྀ"],[3961,1,"ླཱྀ"],[[3962,3968],2],[3969,1,"ཱྀ"],[[3970,3972],2],[3973,2],[[3974,3979],2],[[3980,3983],2],[[3984,3986],2],[3987,1,"ྒྷ"],[[3988,3989],2],[3990,2],[3991,2],[3992,3],[[3993,3996],2],[3997,1,"ྜྷ"],[[3998,4001],2],[4002,1,"ྡྷ"],[[4003,4006],2],[4007,1,"ྦྷ"],[[4008,4011],2],[4012,1,"ྫྷ"],[4013,2],[[4014,4016],2],[[4017,4023],2],[4024,2],[4025,1,"ྐྵ"],[[4026,4028],2],[4029,3],[[4030,4037],2],[4038,2],[[4039,4044],2],[4045,3],[4046,2],[4047,2],[[4048,4049],2],[[4050,4052],2],[[4053,4056],2],[[4057,4058],2],[[4059,4095],3],[[4096,4129],2],[4130,2],[[4131,4135],2],[4136,2],[[4137,4138],2],[4139,2],[[4140,4146],2],[[4147,4149],2],[[4150,4153],2],[[4154,4159],2],[[4160,4169],2],[[4170,4175],2],[[4176,4185],2],[[4186,4249],2],[[4250,4253],2],[[4254,4255],2],[4256,1,"ⴀ"],[4257,1,"ⴁ"],[4258,1,"ⴂ"],[4259,1,"ⴃ"],[4260,1,"ⴄ"],[4261,1,"ⴅ"],[4262,1,"ⴆ"],[4263,1,"ⴇ"],[4264,1,"ⴈ"],[4265,1,"ⴉ"],[4266,1,"ⴊ"],[4267,1,"ⴋ"],[4268,1,"ⴌ"],[4269,1,"ⴍ"],[4270,1,"ⴎ"],[4271,1,"ⴏ"],[4272,1,"ⴐ"],[4273,1,"ⴑ"],[4274,1,"ⴒ"],[4275,1,"ⴓ"],[4276,1,"ⴔ"],[4277,1,"ⴕ"],[4278,1,"ⴖ"],[4279,1,"ⴗ"],[4280,1,"ⴘ"],[4281,1,"ⴙ"],[4282,1,"ⴚ"],[4283,1,"ⴛ"],[4284,1,"ⴜ"],[4285,1,"ⴝ"],[4286,1,"ⴞ"],[4287,1,"ⴟ"],[4288,1,"ⴠ"],[4289,1,"ⴡ"],[4290,1,"ⴢ"],[4291,1,"ⴣ"],[4292,1,"ⴤ"],[4293,1,"ⴥ"],[4294,3],[4295,1,"ⴧ"],[[4296,4300],3],[4301,1,"ⴭ"],[[4302,4303],3],[[4304,4342],2],[[4343,4344],2],[[4345,4346],2],[4347,2],[4348,1,"ნ"],[[4349,4351],2],[[4352,4441],2],[[4442,4446],2],[[4447,4448],7],[[4449,4514],2],[[4515,4519],2],[[4520,4601],2],[[4602,4607],2],[[4608,4614],2],[4615,2],[[4616,4678],2],[4679,2],[4680,2],[4681,3],[[4682,4685],2],[[4686,4687],3],[[4688,4694],2],[4695,3],[4696,2],[4697,3],[[4698,4701],2],[[4702,4703],3],[[4704,4742],2],[4743,2],[4744,2],[4745,3],[[4746,4749],2],[[4750,4751],3],[[4752,4782],2],[4783,2],[4784,2],[4785,3],[[4786,4789],2],[[4790,4791],3],[[4792,4798],2],[4799,3],[4800,2],[4801,3],[[4802,4805],2],[[4806,4807],3],[[4808,4814],2],[4815,2],[[4816,4822],2],[4823,3],[[4824,4846],2],[4847,2],[[4848,4878],2],[4879,2],[4880,2],[4881,3],[[4882,4885],2],[[4886,4887],3],[[4888,4894],2],[4895,2],[[4896,4934],2],[4935,2],[[4936,4954],2],[[4955,4956],3],[[4957,4958],2],[4959,2],[4960,2],[[4961,4988],2],[[4989,4991],3],[[4992,5007],2],[[5008,5017],2],[[5018,5023],3],[[5024,5108],2],[5109,2],[[5110,5111],3],[5112,1,"Ᏸ"],[5113,1,"Ᏹ"],[5114,1,"Ᏺ"],[5115,1,"Ᏻ"],[5116,1,"Ᏼ"],[5117,1,"Ᏽ"],[[5118,5119],3],[5120,2],[[5121,5740],2],[[5741,5742],2],[[5743,5750],2],[[5751,5759],2],[5760,3],[[5761,5786],2],[[5787,5788],2],[[5789,5791],3],[[5792,5866],2],[[5867,5872],2],[[5873,5880],2],[[5881,5887],3],[[5888,5900],2],[5901,2],[[5902,5908],2],[5909,2],[[5910,5918],3],[5919,2],[[5920,5940],2],[[5941,5942],2],[[5943,5951],3],[[5952,5971],2],[[5972,5983],3],[[5984,5996],2],[5997,3],[[5998,6000],2],[6001,3],[[6002,6003],2],[[6004,6015],3],[[6016,6067],2],[[6068,6069],7],[[6070,6099],2],[[6100,6102],2],[6103,2],[[6104,6107],2],[6108,2],[6109,2],[[6110,6111],3],[[6112,6121],2],[[6122,6127],3],[[6128,6137],2],[[6138,6143],3],[[6144,6154],2],[[6155,6158],7],[6159,7],[[6160,6169],2],[[6170,6175],3],[[6176,6263],2],[6264,2],[[6265,6271],3],[[6272,6313],2],[6314,2],[[6315,6319],3],[[6320,6389],2],[[6390,6399],3],[[6400,6428],2],[[6429,6430],2],[6431,3],[[6432,6443],2],[[6444,6447],3],[[6448,6459],2],[[6460,6463],3],[6464,2],[[6465,6467],3],[[6468,6469],2],[[6470,6509],2],[[6510,6511],3],[[6512,6516],2],[[6517,6527],3],[[6528,6569],2],[[6570,6571],2],[[6572,6575],3],[[6576,6601],2],[[6602,6607],3],[[6608,6617],2],[6618,2],[[6619,6621],3],[[6622,6623],2],[[6624,6655],2],[[6656,6683],2],[[6684,6685],3],[[6686,6687],2],[[6688,6750],2],[6751,3],[[6752,6780],2],[[6781,6782],3],[[6783,6793],2],[[6794,6799],3],[[6800,6809],2],[[6810,6815],3],[[6816,6822],2],[6823,2],[[6824,6829],2],[[6830,6831],3],[[6832,6845],2],[6846,2],[[6847,6848],2],[[6849,6862],2],[[6863,6877],2],[[6878,6879],3],[[6880,6891],2],[[6892,6911],3],[[6912,6987],2],[6988,2],[6989,3],[[6990,6991],2],[[6992,7001],2],[[7002,7018],2],[[7019,7027],2],[[7028,7036],2],[[7037,7038],2],[7039,2],[[7040,7082],2],[[7083,7085],2],[[7086,7097],2],[[7098,7103],2],[[7104,7155],2],[[7156,7163],3],[[7164,7167],2],[[7168,7223],2],[[7224,7226],3],[[7227,7231],2],[[7232,7241],2],[[7242,7244],3],[[7245,7293],2],[[7294,7295],2],[7296,1,"в"],[7297,1,"д"],[7298,1,"о"],[7299,1,"с"],[[7300,7301],1,"т"],[7302,1,"ъ"],[7303,1,"ѣ"],[7304,1,"ꙋ"],[7305,1,"ᲊ"],[7306,2],[[7307,7311],3],[7312,1,"ა"],[7313,1,"ბ"],[7314,1,"გ"],[7315,1,"დ"],[7316,1,"ე"],[7317,1,"ვ"],[7318,1,"ზ"],[7319,1,"თ"],[7320,1,"ი"],[7321,1,"კ"],[7322,1,"ლ"],[7323,1,"მ"],[7324,1,"ნ"],[7325,1,"ო"],[7326,1,"პ"],[7327,1,"ჟ"],[7328,1,"რ"],[7329,1,"ს"],[7330,1,"ტ"],[7331,1,"უ"],[7332,1,"ფ"],[7333,1,"ქ"],[7334,1,"ღ"],[7335,1,"ყ"],[7336,1,"შ"],[7337,1,"ჩ"],[7338,1,"ც"],[7339,1,"ძ"],[7340,1,"წ"],[7341,1,"ჭ"],[7342,1,"ხ"],[7343,1,"ჯ"],[7344,1,"ჰ"],[7345,1,"ჱ"],[7346,1,"ჲ"],[7347,1,"ჳ"],[7348,1,"ჴ"],[7349,1,"ჵ"],[7350,1,"ჶ"],[7351,1,"ჷ"],[7352,1,"ჸ"],[7353,1,"ჹ"],[7354,1,"ჺ"],[[7355,7356],3],[7357,1,"ჽ"],[7358,1,"ჾ"],[7359,1,"ჿ"],[[7360,7367],2],[[7368,7375],3],[[7376,7378],2],[7379,2],[[7380,7410],2],[[7411,7414],2],[7415,2],[[7416,7417],2],[7418,2],[[7419,7423],3],[[7424,7467],2],[7468,1,"a"],[7469,1,"æ"],[7470,1,"b"],[7471,2],[7472,1,"d"],[7473,1,"e"],[7474,1,"ǝ"],[7475,1,"g"],[7476,1,"h"],[7477,1,"i"],[7478,1,"j"],[7479,1,"k"],[7480,1,"l"],[7481,1,"m"],[7482,1,"n"],[7483,2],[7484,1,"o"],[7485,1,"ȣ"],[7486,1,"p"],[7487,1,"r"],[7488,1,"t"],[7489,1,"u"],[7490,1,"w"],[7491,1,"a"],[7492,1,"ɐ"],[7493,1,"ɑ"],[7494,1,"ᴂ"],[7495,1,"b"],[7496,1,"d"],[7497,1,"e"],[7498,1,"ə"],[7499,1,"ɛ"],[7500,1,"ɜ"],[7501,1,"g"],[7502,2],[7503,1,"k"],[7504,1,"m"],[7505,1,"ŋ"],[7506,1,"o"],[7507,1,"ɔ"],[7508,1,"ᴖ"],[7509,1,"ᴗ"],[7510,1,"p"],[7511,1,"t"],[7512,1,"u"],[7513,1,"ᴝ"],[7514,1,"ɯ"],[7515,1,"v"],[7516,1,"ᴥ"],[7517,1,"β"],[7518,1,"γ"],[7519,1,"δ"],[7520,1,"φ"],[7521,1,"χ"],[7522,1,"i"],[7523,1,"r"],[7524,1,"u"],[7525,1,"v"],[7526,1,"β"],[7527,1,"γ"],[7528,1,"ρ"],[7529,1,"φ"],[7530,1,"χ"],[7531,2],[[7532,7543],2],[7544,1,"н"],[[7545,7578],2],[7579,1,"ɒ"],[7580,1,"c"],[7581,1,"ɕ"],[7582,1,"ð"],[7583,1,"ɜ"],[7584,1,"f"],[7585,1,"ɟ"],[7586,1,"ɡ"],[7587,1,"ɥ"],[7588,1,"ɨ"],[7589,1,"ɩ"],[7590,1,"ɪ"],[7591,1,"ᵻ"],[7592,1,"ʝ"],[7593,1,"ɭ"],[7594,1,"ᶅ"],[7595,1,"ʟ"],[7596,1,"ɱ"],[7597,1,"ɰ"],[7598,1,"ɲ"],[7599,1,"ɳ"],[7600,1,"ɴ"],[7601,1,"ɵ"],[7602,1,"ɸ"],[7603,1,"ʂ"],[7604,1,"ʃ"],[7605,1,"ƫ"],[7606,1,"ʉ"],[7607,1,"ʊ"],[7608,1,"ᴜ"],[7609,1,"ʋ"],[7610,1,"ʌ"],[7611,1,"z"],[7612,1,"ʐ"],[7613,1,"ʑ"],[7614,1,"ʒ"],[7615,1,"θ"],[[7616,7619],2],[[7620,7626],2],[[7627,7654],2],[[7655,7669],2],[[7670,7673],2],[7674,2],[7675,2],[7676,2],[7677,2],[[7678,7679],2],[7680,1,"ḁ"],[7681,2],[7682,1,"ḃ"],[7683,2],[7684,1,"ḅ"],[7685,2],[7686,1,"ḇ"],[7687,2],[7688,1,"ḉ"],[7689,2],[7690,1,"ḋ"],[7691,2],[7692,1,"ḍ"],[7693,2],[7694,1,"ḏ"],[7695,2],[7696,1,"ḑ"],[7697,2],[7698,1,"ḓ"],[7699,2],[7700,1,"ḕ"],[7701,2],[7702,1,"ḗ"],[7703,2],[7704,1,"ḙ"],[7705,2],[7706,1,"ḛ"],[7707,2],[7708,1,"ḝ"],[7709,2],[7710,1,"ḟ"],[7711,2],[7712,1,"ḡ"],[7713,2],[7714,1,"ḣ"],[7715,2],[7716,1,"ḥ"],[7717,2],[7718,1,"ḧ"],[7719,2],[7720,1,"ḩ"],[7721,2],[7722,1,"ḫ"],[7723,2],[7724,1,"ḭ"],[7725,2],[7726,1,"ḯ"],[7727,2],[7728,1,"ḱ"],[7729,2],[7730,1,"ḳ"],[7731,2],[7732,1,"ḵ"],[7733,2],[7734,1,"ḷ"],[7735,2],[7736,1,"ḹ"],[7737,2],[7738,1,"ḻ"],[7739,2],[7740,1,"ḽ"],[7741,2],[7742,1,"ḿ"],[7743,2],[7744,1,"ṁ"],[7745,2],[7746,1,"ṃ"],[7747,2],[7748,1,"ṅ"],[7749,2],[7750,1,"ṇ"],[7751,2],[7752,1,"ṉ"],[7753,2],[7754,1,"ṋ"],[7755,2],[7756,1,"ṍ"],[7757,2],[7758,1,"ṏ"],[7759,2],[7760,1,"ṑ"],[7761,2],[7762,1,"ṓ"],[7763,2],[7764,1,"ṕ"],[7765,2],[7766,1,"ṗ"],[7767,2],[7768,1,"ṙ"],[7769,2],[7770,1,"ṛ"],[7771,2],[7772,1,"ṝ"],[7773,2],[7774,1,"ṟ"],[7775,2],[7776,1,"ṡ"],[7777,2],[7778,1,"ṣ"],[7779,2],[7780,1,"ṥ"],[7781,2],[7782,1,"ṧ"],[7783,2],[7784,1,"ṩ"],[7785,2],[7786,1,"ṫ"],[7787,2],[7788,1,"ṭ"],[7789,2],[7790,1,"ṯ"],[7791,2],[7792,1,"ṱ"],[7793,2],[7794,1,"ṳ"],[7795,2],[7796,1,"ṵ"],[7797,2],[7798,1,"ṷ"],[7799,2],[7800,1,"ṹ"],[7801,2],[7802,1,"ṻ"],[7803,2],[7804,1,"ṽ"],[7805,2],[7806,1,"ṿ"],[7807,2],[7808,1,"ẁ"],[7809,2],[7810,1,"ẃ"],[7811,2],[7812,1,"ẅ"],[7813,2],[7814,1,"ẇ"],[7815,2],[7816,1,"ẉ"],[7817,2],[7818,1,"ẋ"],[7819,2],[7820,1,"ẍ"],[7821,2],[7822,1,"ẏ"],[7823,2],[7824,1,"ẑ"],[7825,2],[7826,1,"ẓ"],[7827,2],[7828,1,"ẕ"],[[7829,7833],2],[7834,1,"aʾ"],[7835,1,"ṡ"],[[7836,7837],2],[7838,1,"ß"],[7839,2],[7840,1,"ạ"],[7841,2],[7842,1,"ả"],[7843,2],[7844,1,"ấ"],[7845,2],[7846,1,"ầ"],[7847,2],[7848,1,"ẩ"],[7849,2],[7850,1,"ẫ"],[7851,2],[7852,1,"ậ"],[7853,2],[7854,1,"ắ"],[7855,2],[7856,1,"ằ"],[7857,2],[7858,1,"ẳ"],[7859,2],[7860,1,"ẵ"],[7861,2],[7862,1,"ặ"],[7863,2],[7864,1,"ẹ"],[7865,2],[7866,1,"ẻ"],[7867,2],[7868,1,"ẽ"],[7869,2],[7870,1,"ế"],[7871,2],[7872,1,"ề"],[7873,2],[7874,1,"ể"],[7875,2],[7876,1,"ễ"],[7877,2],[7878,1,"ệ"],[7879,2],[7880,1,"ỉ"],[7881,2],[7882,1,"ị"],[7883,2],[7884,1,"ọ"],[7885,2],[7886,1,"ỏ"],[7887,2],[7888,1,"ố"],[7889,2],[7890,1,"ồ"],[7891,2],[7892,1,"ổ"],[7893,2],[7894,1,"ỗ"],[7895,2],[7896,1,"ộ"],[7897,2],[7898,1,"ớ"],[7899,2],[7900,1,"ờ"],[7901,2],[7902,1,"ở"],[7903,2],[7904,1,"ỡ"],[7905,2],[7906,1,"ợ"],[7907,2],[7908,1,"ụ"],[7909,2],[7910,1,"ủ"],[7911,2],[7912,1,"ứ"],[7913,2],[7914,1,"ừ"],[7915,2],[7916,1,"ử"],[7917,2],[7918,1,"ữ"],[7919,2],[7920,1,"ự"],[7921,2],[7922,1,"ỳ"],[7923,2],[7924,1,"ỵ"],[7925,2],[7926,1,"ỷ"],[7927,2],[7928,1,"ỹ"],[7929,2],[7930,1,"ỻ"],[7931,2],[7932,1,"ỽ"],[7933,2],[7934,1,"ỿ"],[7935,2],[[7936,7943],2],[7944,1,"ἀ"],[7945,1,"ἁ"],[7946,1,"ἂ"],[7947,1,"ἃ"],[7948,1,"ἄ"],[7949,1,"ἅ"],[7950,1,"ἆ"],[7951,1,"ἇ"],[[7952,7957],2],[[7958,7959],3],[7960,1,"ἐ"],[7961,1,"ἑ"],[7962,1,"ἒ"],[7963,1,"ἓ"],[7964,1,"ἔ"],[7965,1,"ἕ"],[[7966,7967],3],[[7968,7975],2],[7976,1,"ἠ"],[7977,1,"ἡ"],[7978,1,"ἢ"],[7979,1,"ἣ"],[7980,1,"ἤ"],[7981,1,"ἥ"],[7982,1,"ἦ"],[7983,1,"ἧ"],[[7984,7991],2],[7992,1,"ἰ"],[7993,1,"ἱ"],[7994,1,"ἲ"],[7995,1,"ἳ"],[7996,1,"ἴ"],[7997,1,"ἵ"],[7998,1,"ἶ"],[7999,1,"ἷ"],[[8000,8005],2],[[8006,8007],3],[8008,1,"ὀ"],[8009,1,"ὁ"],[8010,1,"ὂ"],[8011,1,"ὃ"],[8012,1,"ὄ"],[8013,1,"ὅ"],[[8014,8015],3],[[8016,8023],2],[8024,3],[8025,1,"ὑ"],[8026,3],[8027,1,"ὓ"],[8028,3],[8029,1,"ὕ"],[8030,3],[8031,1,"ὗ"],[[8032,8039],2],[8040,1,"ὠ"],[8041,1,"ὡ"],[8042,1,"ὢ"],[8043,1,"ὣ"],[8044,1,"ὤ"],[8045,1,"ὥ"],[8046,1,"ὦ"],[8047,1,"ὧ"],[8048,2],[8049,1,"ά"],[8050,2],[8051,1,"έ"],[8052,2],[8053,1,"ή"],[8054,2],[8055,1,"ί"],[8056,2],[8057,1,"ό"],[8058,2],[8059,1,"ύ"],[8060,2],[8061,1,"ώ"],[[8062,8063],3],[8064,1,"ἀι"],[8065,1,"ἁι"],[8066,1,"ἂι"],[8067,1,"ἃι"],[8068,1,"ἄι"],[8069,1,"ἅι"],[8070,1,"ἆι"],[8071,1,"ἇι"],[8072,1,"ἀι"],[8073,1,"ἁι"],[8074,1,"ἂι"],[8075,1,"ἃι"],[8076,1,"ἄι"],[8077,1,"ἅι"],[8078,1,"ἆι"],[8079,1,"ἇι"],[8080,1,"ἠι"],[8081,1,"ἡι"],[8082,1,"ἢι"],[8083,1,"ἣι"],[8084,1,"ἤι"],[8085,1,"ἥι"],[8086,1,"ἦι"],[8087,1,"ἧι"],[8088,1,"ἠι"],[8089,1,"ἡι"],[8090,1,"ἢι"],[8091,1,"ἣι"],[8092,1,"ἤι"],[8093,1,"ἥι"],[8094,1,"ἦι"],[8095,1,"ἧι"],[8096,1,"ὠι"],[8097,1,"ὡι"],[8098,1,"ὢι"],[8099,1,"ὣι"],[8100,1,"ὤι"],[8101,1,"ὥι"],[8102,1,"ὦι"],[8103,1,"ὧι"],[8104,1,"ὠι"],[8105,1,"ὡι"],[8106,1,"ὢι"],[8107,1,"ὣι"],[8108,1,"ὤι"],[8109,1,"ὥι"],[8110,1,"ὦι"],[8111,1,"ὧι"],[[8112,8113],2],[8114,1,"ὰι"],[8115,1,"αι"],[8116,1,"άι"],[8117,3],[8118,2],[8119,1,"ᾶι"],[8120,1,"ᾰ"],[8121,1,"ᾱ"],[8122,1,"ὰ"],[8123,1,"ά"],[8124,1,"αι"],[8125,1," ̓"],[8126,1,"ι"],[8127,1," ̓"],[8128,1," ͂"],[8129,1," ̈͂"],[8130,1,"ὴι"],[8131,1,"ηι"],[8132,1,"ήι"],[8133,3],[8134,2],[8135,1,"ῆι"],[8136,1,"ὲ"],[8137,1,"έ"],[8138,1,"ὴ"],[8139,1,"ή"],[8140,1,"ηι"],[8141,1," ̓̀"],[8142,1," ̓́"],[8143,1," ̓͂"],[[8144,8146],2],[8147,1,"ΐ"],[[8148,8149],3],[[8150,8151],2],[8152,1,"ῐ"],[8153,1,"ῑ"],[8154,1,"ὶ"],[8155,1,"ί"],[8156,3],[8157,1," ̔̀"],[8158,1," ̔́"],[8159,1," ̔͂"],[[8160,8162],2],[8163,1,"ΰ"],[[8164,8167],2],[8168,1,"ῠ"],[8169,1,"ῡ"],[8170,1,"ὺ"],[8171,1,"ύ"],[8172,1,"ῥ"],[8173,1," ̈̀"],[8174,1," ̈́"],[8175,1,"`"],[[8176,8177],3],[8178,1,"ὼι"],[8179,1,"ωι"],[8180,1,"ώι"],[8181,3],[8182,2],[8183,1,"ῶι"],[8184,1,"ὸ"],[8185,1,"ό"],[8186,1,"ὼ"],[8187,1,"ώ"],[8188,1,"ωι"],[8189,1," ́"],[8190,1," ̔"],[8191,3],[[8192,8202],1," "],[8203,7],[[8204,8205],6,""],[[8206,8207],3],[8208,2],[8209,1,"‐"],[[8210,8214],2],[8215,1," ̳"],[[8216,8227],2],[[8228,8230],3],[8231,2],[[8232,8238],3],[8239,1," "],[[8240,8242],2],[8243,1,"′′"],[8244,1,"′′′"],[8245,2],[8246,1,"‵‵"],[8247,1,"‵‵‵"],[[8248,8251],2],[8252,1,"!!"],[8253,2],[8254,1," ̅"],[[8255,8262],2],[8263,1,"??"],[8264,1,"?!"],[8265,1,"!?"],[[8266,8269],2],[[8270,8274],2],[[8275,8276],2],[[8277,8278],2],[8279,1,"′′′′"],[[8280,8286],2],[8287,1," "],[[8288,8291],7],[8292,7],[8293,3],[[8294,8297],3],[[8298,8303],7],[8304,1,"0"],[8305,1,"i"],[[8306,8307],3],[8308,1,"4"],[8309,1,"5"],[8310,1,"6"],[8311,1,"7"],[8312,1,"8"],[8313,1,"9"],[8314,1,"+"],[8315,1,"−"],[8316,1,"="],[8317,1,"("],[8318,1,")"],[8319,1,"n"],[8320,1,"0"],[8321,1,"1"],[8322,1,"2"],[8323,1,"3"],[8324,1,"4"],[8325,1,"5"],[8326,1,"6"],[8327,1,"7"],[8328,1,"8"],[8329,1,"9"],[8330,1,"+"],[8331,1,"−"],[8332,1,"="],[8333,1,"("],[8334,1,")"],[8335,3],[8336,1,"a"],[8337,1,"e"],[8338,1,"o"],[8339,1,"x"],[8340,1,"ə"],[8341,1,"h"],[8342,1,"k"],[8343,1,"l"],[8344,1,"m"],[8345,1,"n"],[8346,1,"p"],[8347,1,"s"],[8348,1,"t"],[[8349,8351],3],[[8352,8359],2],[8360,1,"rs"],[[8361,8362],2],[8363,2],[8364,2],[[8365,8367],2],[[8368,8369],2],[[8370,8373],2],[[8374,8376],2],[8377,2],[8378,2],[[8379,8381],2],[8382,2],[8383,2],[8384,2],[8385,2],[[8386,8399],3],[[8400,8417],2],[[8418,8419],2],[[8420,8426],2],[8427,2],[[8428,8431],2],[8432,2],[[8433,8447],3],[8448,1,"a/c"],[8449,1,"a/s"],[8450,1,"c"],[8451,1,"°c"],[8452,2],[8453,1,"c/o"],[8454,1,"c/u"],[8455,1,"ɛ"],[8456,2],[8457,1,"°f"],[8458,1,"g"],[[8459,8462],1,"h"],[8463,1,"ħ"],[[8464,8465],1,"i"],[[8466,8467],1,"l"],[8468,2],[8469,1,"n"],[8470,1,"no"],[[8471,8472],2],[8473,1,"p"],[8474,1,"q"],[[8475,8477],1,"r"],[[8478,8479],2],[8480,1,"sm"],[8481,1,"tel"],[8482,1,"tm"],[8483,2],[8484,1,"z"],[8485,2],[8486,1,"ω"],[8487,2],[8488,1,"z"],[8489,2],[8490,1,"k"],[8491,1,"å"],[8492,1,"b"],[8493,1,"c"],[8494,2],[[8495,8496],1,"e"],[8497,1,"f"],[8498,1,"ⅎ"],[8499,1,"m"],[8500,1,"o"],[8501,1,"א"],[8502,1,"ב"],[8503,1,"ג"],[8504,1,"ד"],[8505,1,"i"],[8506,2],[8507,1,"fax"],[8508,1,"π"],[[8509,8510],1,"γ"],[8511,1,"π"],[8512,1,"∑"],[[8513,8516],2],[[8517,8518],1,"d"],[8519,1,"e"],[8520,1,"i"],[8521,1,"j"],[[8522,8523],2],[8524,2],[8525,2],[8526,2],[8527,2],[8528,1,"1⁄7"],[8529,1,"1⁄9"],[8530,1,"1⁄10"],[8531,1,"1⁄3"],[8532,1,"2⁄3"],[8533,1,"1⁄5"],[8534,1,"2⁄5"],[8535,1,"3⁄5"],[8536,1,"4⁄5"],[8537,1,"1⁄6"],[8538,1,"5⁄6"],[8539,1,"1⁄8"],[8540,1,"3⁄8"],[8541,1,"5⁄8"],[8542,1,"7⁄8"],[8543,1,"1⁄"],[8544,1,"i"],[8545,1,"ii"],[8546,1,"iii"],[8547,1,"iv"],[8548,1,"v"],[8549,1,"vi"],[8550,1,"vii"],[8551,1,"viii"],[8552,1,"ix"],[8553,1,"x"],[8554,1,"xi"],[8555,1,"xii"],[8556,1,"l"],[8557,1,"c"],[8558,1,"d"],[8559,1,"m"],[8560,1,"i"],[8561,1,"ii"],[8562,1,"iii"],[8563,1,"iv"],[8564,1,"v"],[8565,1,"vi"],[8566,1,"vii"],[8567,1,"viii"],[8568,1,"ix"],[8569,1,"x"],[8570,1,"xi"],[8571,1,"xii"],[8572,1,"l"],[8573,1,"c"],[8574,1,"d"],[8575,1,"m"],[[8576,8578],2],[8579,1,"ↄ"],[8580,2],[[8581,8584],2],[8585,1,"0⁄3"],[[8586,8587],2],[[8588,8591],3],[[8592,8682],2],[[8683,8691],2],[[8692,8703],2],[[8704,8747],2],[8748,1,"∫∫"],[8749,1,"∫∫∫"],[8750,2],[8751,1,"∮∮"],[8752,1,"∮∮∮"],[[8753,8945],2],[[8946,8959],2],[8960,2],[8961,2],[[8962,9000],2],[9001,1,"〈"],[9002,1,"〉"],[[9003,9082],2],[9083,2],[9084,2],[[9085,9114],2],[[9115,9166],2],[[9167,9168],2],[[9169,9179],2],[[9180,9191],2],[9192,2],[[9193,9203],2],[[9204,9210],2],[[9211,9214],2],[9215,2],[[9216,9252],2],[[9253,9254],2],[[9255,9257],2],[[9258,9279],3],[[9280,9290],2],[[9291,9311],3],[9312,1,"1"],[9313,1,"2"],[9314,1,"3"],[9315,1,"4"],[9316,1,"5"],[9317,1,"6"],[9318,1,"7"],[9319,1,"8"],[9320,1,"9"],[9321,1,"10"],[9322,1,"11"],[9323,1,"12"],[9324,1,"13"],[9325,1,"14"],[9326,1,"15"],[9327,1,"16"],[9328,1,"17"],[9329,1,"18"],[9330,1,"19"],[9331,1,"20"],[9332,1,"(1)"],[9333,1,"(2)"],[9334,1,"(3)"],[9335,1,"(4)"],[9336,1,"(5)"],[9337,1,"(6)"],[9338,1,"(7)"],[9339,1,"(8)"],[9340,1,"(9)"],[9341,1,"(10)"],[9342,1,"(11)"],[9343,1,"(12)"],[9344,1,"(13)"],[9345,1,"(14)"],[9346,1,"(15)"],[9347,1,"(16)"],[9348,1,"(17)"],[9349,1,"(18)"],[9350,1,"(19)"],[9351,1,"(20)"],[[9352,9371],3],[9372,1,"(a)"],[9373,1,"(b)"],[9374,1,"(c)"],[9375,1,"(d)"],[9376,1,"(e)"],[9377,1,"(f)"],[9378,1,"(g)"],[9379,1,"(h)"],[9380,1,"(i)"],[9381,1,"(j)"],[9382,1,"(k)"],[9383,1,"(l)"],[9384,1,"(m)"],[9385,1,"(n)"],[9386,1,"(o)"],[9387,1,"(p)"],[9388,1,"(q)"],[9389,1,"(r)"],[9390,1,"(s)"],[9391,1,"(t)"],[9392,1,"(u)"],[9393,1,"(v)"],[9394,1,"(w)"],[9395,1,"(x)"],[9396,1,"(y)"],[9397,1,"(z)"],[9398,1,"a"],[9399,1,"b"],[9400,1,"c"],[9401,1,"d"],[9402,1,"e"],[9403,1,"f"],[9404,1,"g"],[9405,1,"h"],[9406,1,"i"],[9407,1,"j"],[9408,1,"k"],[9409,1,"l"],[9410,1,"m"],[9411,1,"n"],[9412,1,"o"],[9413,1,"p"],[9414,1,"q"],[9415,1,"r"],[9416,1,"s"],[9417,1,"t"],[9418,1,"u"],[9419,1,"v"],[9420,1,"w"],[9421,1,"x"],[9422,1,"y"],[9423,1,"z"],[9424,1,"a"],[9425,1,"b"],[9426,1,"c"],[9427,1,"d"],[9428,1,"e"],[9429,1,"f"],[9430,1,"g"],[9431,1,"h"],[9432,1,"i"],[9433,1,"j"],[9434,1,"k"],[9435,1,"l"],[9436,1,"m"],[9437,1,"n"],[9438,1,"o"],[9439,1,"p"],[9440,1,"q"],[9441,1,"r"],[9442,1,"s"],[9443,1,"t"],[9444,1,"u"],[9445,1,"v"],[9446,1,"w"],[9447,1,"x"],[9448,1,"y"],[9449,1,"z"],[9450,1,"0"],[[9451,9470],2],[9471,2],[[9472,9621],2],[[9622,9631],2],[[9632,9711],2],[[9712,9719],2],[[9720,9727],2],[[9728,9747],2],[[9748,9749],2],[[9750,9751],2],[9752,2],[9753,2],[[9754,9839],2],[[9840,9841],2],[[9842,9853],2],[[9854,9855],2],[[9856,9865],2],[[9866,9873],2],[[9874,9884],2],[9885,2],[[9886,9887],2],[[9888,9889],2],[[9890,9905],2],[9906,2],[[9907,9916],2],[[9917,9919],2],[[9920,9923],2],[[9924,9933],2],[9934,2],[[9935,9953],2],[9954,2],[9955,2],[[9956,9959],2],[[9960,9983],2],[9984,2],[[9985,9988],2],[9989,2],[[9990,9993],2],[[9994,9995],2],[[9996,10023],2],[10024,2],[[10025,10059],2],[10060,2],[10061,2],[10062,2],[[10063,10066],2],[[10067,10069],2],[10070,2],[10071,2],[[10072,10078],2],[[10079,10080],2],[[10081,10087],2],[[10088,10101],2],[[10102,10132],2],[[10133,10135],2],[[10136,10159],2],[10160,2],[[10161,10174],2],[10175,2],[[10176,10182],2],[[10183,10186],2],[10187,2],[10188,2],[10189,2],[[10190,10191],2],[[10192,10219],2],[[10220,10223],2],[[10224,10239],2],[[10240,10495],2],[[10496,10763],2],[10764,1,"∫∫∫∫"],[[10765,10867],2],[10868,1,"::="],[10869,1,"=="],[10870,1,"==="],[[10871,10971],2],[10972,1,"⫝̸"],[[10973,11007],2],[[11008,11021],2],[[11022,11027],2],[[11028,11034],2],[[11035,11039],2],[[11040,11043],2],[[11044,11084],2],[[11085,11087],2],[[11088,11092],2],[[11093,11097],2],[[11098,11123],2],[[11124,11125],3],[[11126,11157],2],[11158,2],[11159,2],[[11160,11193],2],[[11194,11196],2],[[11197,11208],2],[11209,2],[[11210,11217],2],[11218,2],[[11219,11243],2],[[11244,11247],2],[[11248,11262],2],[11263,2],[11264,1,"ⰰ"],[11265,1,"ⰱ"],[11266,1,"ⰲ"],[11267,1,"ⰳ"],[11268,1,"ⰴ"],[11269,1,"ⰵ"],[11270,1,"ⰶ"],[11271,1,"ⰷ"],[11272,1,"ⰸ"],[11273,1,"ⰹ"],[11274,1,"ⰺ"],[11275,1,"ⰻ"],[11276,1,"ⰼ"],[11277,1,"ⰽ"],[11278,1,"ⰾ"],[11279,1,"ⰿ"],[11280,1,"ⱀ"],[11281,1,"ⱁ"],[11282,1,"ⱂ"],[11283,1,"ⱃ"],[11284,1,"ⱄ"],[11285,1,"ⱅ"],[11286,1,"ⱆ"],[11287,1,"ⱇ"],[11288,1,"ⱈ"],[11289,1,"ⱉ"],[11290,1,"ⱊ"],[11291,1,"ⱋ"],[11292,1,"ⱌ"],[11293,1,"ⱍ"],[11294,1,"ⱎ"],[11295,1,"ⱏ"],[11296,1,"ⱐ"],[11297,1,"ⱑ"],[11298,1,"ⱒ"],[11299,1,"ⱓ"],[11300,1,"ⱔ"],[11301,1,"ⱕ"],[11302,1,"ⱖ"],[11303,1,"ⱗ"],[11304,1,"ⱘ"],[11305,1,"ⱙ"],[11306,1,"ⱚ"],[11307,1,"ⱛ"],[11308,1,"ⱜ"],[11309,1,"ⱝ"],[11310,1,"ⱞ"],[11311,1,"ⱟ"],[[11312,11358],2],[11359,2],[11360,1,"ⱡ"],[11361,2],[11362,1,"ɫ"],[11363,1,"ᵽ"],[11364,1,"ɽ"],[[11365,11366],2],[11367,1,"ⱨ"],[11368,2],[11369,1,"ⱪ"],[11370,2],[11371,1,"ⱬ"],[11372,2],[11373,1,"ɑ"],[11374,1,"ɱ"],[11375,1,"ɐ"],[11376,1,"ɒ"],[11377,2],[11378,1,"ⱳ"],[11379,2],[11380,2],[11381,1,"ⱶ"],[[11382,11383],2],[[11384,11387],2],[11388,1,"j"],[11389,1,"v"],[11390,1,"ȿ"],[11391,1,"ɀ"],[11392,1,"ⲁ"],[11393,2],[11394,1,"ⲃ"],[11395,2],[11396,1,"ⲅ"],[11397,2],[11398,1,"ⲇ"],[11399,2],[11400,1,"ⲉ"],[11401,2],[11402,1,"ⲋ"],[11403,2],[11404,1,"ⲍ"],[11405,2],[11406,1,"ⲏ"],[11407,2],[11408,1,"ⲑ"],[11409,2],[11410,1,"ⲓ"],[11411,2],[11412,1,"ⲕ"],[11413,2],[11414,1,"ⲗ"],[11415,2],[11416,1,"ⲙ"],[11417,2],[11418,1,"ⲛ"],[11419,2],[11420,1,"ⲝ"],[11421,2],[11422,1,"ⲟ"],[11423,2],[11424,1,"ⲡ"],[11425,2],[11426,1,"ⲣ"],[11427,2],[11428,1,"ⲥ"],[11429,2],[11430,1,"ⲧ"],[11431,2],[11432,1,"ⲩ"],[11433,2],[11434,1,"ⲫ"],[11435,2],[11436,1,"ⲭ"],[11437,2],[11438,1,"ⲯ"],[11439,2],[11440,1,"ⲱ"],[11441,2],[11442,1,"ⲳ"],[11443,2],[11444,1,"ⲵ"],[11445,2],[11446,1,"ⲷ"],[11447,2],[11448,1,"ⲹ"],[11449,2],[11450,1,"ⲻ"],[11451,2],[11452,1,"ⲽ"],[11453,2],[11454,1,"ⲿ"],[11455,2],[11456,1,"ⳁ"],[11457,2],[11458,1,"ⳃ"],[11459,2],[11460,1,"ⳅ"],[11461,2],[11462,1,"ⳇ"],[11463,2],[11464,1,"ⳉ"],[11465,2],[11466,1,"ⳋ"],[11467,2],[11468,1,"ⳍ"],[11469,2],[11470,1,"ⳏ"],[11471,2],[11472,1,"ⳑ"],[11473,2],[11474,1,"ⳓ"],[11475,2],[11476,1,"ⳕ"],[11477,2],[11478,1,"ⳗ"],[11479,2],[11480,1,"ⳙ"],[11481,2],[11482,1,"ⳛ"],[11483,2],[11484,1,"ⳝ"],[11485,2],[11486,1,"ⳟ"],[11487,2],[11488,1,"ⳡ"],[11489,2],[11490,1,"ⳣ"],[[11491,11492],2],[[11493,11498],2],[11499,1,"ⳬ"],[11500,2],[11501,1,"ⳮ"],[[11502,11505],2],[11506,1,"ⳳ"],[11507,2],[[11508,11512],3],[[11513,11519],2],[[11520,11557],2],[11558,3],[11559,2],[[11560,11564],3],[11565,2],[[11566,11567],3],[[11568,11621],2],[[11622,11623],2],[[11624,11630],3],[11631,1,"ⵡ"],[11632,2],[[11633,11646],3],[11647,2],[[11648,11670],2],[[11671,11679],3],[[11680,11686],2],[11687,3],[[11688,11694],2],[11695,3],[[11696,11702],2],[11703,3],[[11704,11710],2],[11711,3],[[11712,11718],2],[11719,3],[[11720,11726],2],[11727,3],[[11728,11734],2],[11735,3],[[11736,11742],2],[11743,3],[[11744,11775],2],[[11776,11799],2],[[11800,11803],2],[[11804,11805],2],[[11806,11822],2],[11823,2],[11824,2],[11825,2],[[11826,11835],2],[[11836,11842],2],[[11843,11844],2],[[11845,11849],2],[[11850,11854],2],[11855,2],[[11856,11858],2],[[11859,11869],2],[[11870,11903],3],[[11904,11929],2],[11930,3],[[11931,11934],2],[11935,1,"母"],[[11936,12018],2],[12019,1,"龟"],[[12020,12031],3],[12032,1,"一"],[12033,1,"丨"],[12034,1,"丶"],[12035,1,"丿"],[12036,1,"乙"],[12037,1,"亅"],[12038,1,"二"],[12039,1,"亠"],[12040,1,"人"],[12041,1,"儿"],[12042,1,"入"],[12043,1,"八"],[12044,1,"冂"],[12045,1,"冖"],[12046,1,"冫"],[12047,1,"几"],[12048,1,"凵"],[12049,1,"刀"],[12050,1,"力"],[12051,1,"勹"],[12052,1,"匕"],[12053,1,"匚"],[12054,1,"匸"],[12055,1,"十"],[12056,1,"卜"],[12057,1,"卩"],[12058,1,"厂"],[12059,1,"厶"],[12060,1,"又"],[12061,1,"口"],[12062,1,"囗"],[12063,1,"土"],[12064,1,"士"],[12065,1,"夂"],[12066,1,"夊"],[12067,1,"夕"],[12068,1,"大"],[12069,1,"女"],[12070,1,"子"],[12071,1,"宀"],[12072,1,"寸"],[12073,1,"小"],[12074,1,"尢"],[12075,1,"尸"],[12076,1,"屮"],[12077,1,"山"],[12078,1,"巛"],[12079,1,"工"],[12080,1,"己"],[12081,1,"巾"],[12082,1,"干"],[12083,1,"幺"],[12084,1,"广"],[12085,1,"廴"],[12086,1,"廾"],[12087,1,"弋"],[12088,1,"弓"],[12089,1,"彐"],[12090,1,"彡"],[12091,1,"彳"],[12092,1,"心"],[12093,1,"戈"],[12094,1,"戶"],[12095,1,"手"],[12096,1,"支"],[12097,1,"攴"],[12098,1,"文"],[12099,1,"斗"],[12100,1,"斤"],[12101,1,"方"],[12102,1,"无"],[12103,1,"日"],[12104,1,"曰"],[12105,1,"月"],[12106,1,"木"],[12107,1,"欠"],[12108,1,"止"],[12109,1,"歹"],[12110,1,"殳"],[12111,1,"毋"],[12112,1,"比"],[12113,1,"毛"],[12114,1,"氏"],[12115,1,"气"],[12116,1,"水"],[12117,1,"火"],[12118,1,"爪"],[12119,1,"父"],[12120,1,"爻"],[12121,1,"爿"],[12122,1,"片"],[12123,1,"牙"],[12124,1,"牛"],[12125,1,"犬"],[12126,1,"玄"],[12127,1,"玉"],[12128,1,"瓜"],[12129,1,"瓦"],[12130,1,"甘"],[12131,1,"生"],[12132,1,"用"],[12133,1,"田"],[12134,1,"疋"],[12135,1,"疒"],[12136,1,"癶"],[12137,1,"白"],[12138,1,"皮"],[12139,1,"皿"],[12140,1,"目"],[12141,1,"矛"],[12142,1,"矢"],[12143,1,"石"],[12144,1,"示"],[12145,1,"禸"],[12146,1,"禾"],[12147,1,"穴"],[12148,1,"立"],[12149,1,"竹"],[12150,1,"米"],[12151,1,"糸"],[12152,1,"缶"],[12153,1,"网"],[12154,1,"羊"],[12155,1,"羽"],[12156,1,"老"],[12157,1,"而"],[12158,1,"耒"],[12159,1,"耳"],[12160,1,"聿"],[12161,1,"肉"],[12162,1,"臣"],[12163,1,"自"],[12164,1,"至"],[12165,1,"臼"],[12166,1,"舌"],[12167,1,"舛"],[12168,1,"舟"],[12169,1,"艮"],[12170,1,"色"],[12171,1,"艸"],[12172,1,"虍"],[12173,1,"虫"],[12174,1,"血"],[12175,1,"行"],[12176,1,"衣"],[12177,1,"襾"],[12178,1,"見"],[12179,1,"角"],[12180,1,"言"],[12181,1,"谷"],[12182,1,"豆"],[12183,1,"豕"],[12184,1,"豸"],[12185,1,"貝"],[12186,1,"赤"],[12187,1,"走"],[12188,1,"足"],[12189,1,"身"],[12190,1,"車"],[12191,1,"辛"],[12192,1,"辰"],[12193,1,"辵"],[12194,1,"邑"],[12195,1,"酉"],[12196,1,"釆"],[12197,1,"里"],[12198,1,"金"],[12199,1,"長"],[12200,1,"門"],[12201,1,"阜"],[12202,1,"隶"],[12203,1,"隹"],[12204,1,"雨"],[12205,1,"靑"],[12206,1,"非"],[12207,1,"面"],[12208,1,"革"],[12209,1,"韋"],[12210,1,"韭"],[12211,1,"音"],[12212,1,"頁"],[12213,1,"風"],[12214,1,"飛"],[12215,1,"食"],[12216,1,"首"],[12217,1,"香"],[12218,1,"馬"],[12219,1,"骨"],[12220,1,"高"],[12221,1,"髟"],[12222,1,"鬥"],[12223,1,"鬯"],[12224,1,"鬲"],[12225,1,"鬼"],[12226,1,"魚"],[12227,1,"鳥"],[12228,1,"鹵"],[12229,1,"鹿"],[12230,1,"麥"],[12231,1,"麻"],[12232,1,"黃"],[12233,1,"黍"],[12234,1,"黑"],[12235,1,"黹"],[12236,1,"黽"],[12237,1,"鼎"],[12238,1,"鼓"],[12239,1,"鼠"],[12240,1,"鼻"],[12241,1,"齊"],[12242,1,"齒"],[12243,1,"龍"],[12244,1,"龜"],[12245,1,"龠"],[[12246,12271],3],[[12272,12283],3],[[12284,12287],3],[12288,1," "],[12289,2],[12290,1,"."],[[12291,12292],2],[[12293,12295],2],[[12296,12329],2],[[12330,12333],2],[[12334,12341],2],[12342,1,"〒"],[12343,2],[12344,1,"十"],[12345,1,"卄"],[12346,1,"卅"],[12347,2],[12348,2],[12349,2],[12350,2],[12351,2],[12352,3],[[12353,12436],2],[[12437,12438],2],[[12439,12440],3],[[12441,12442],2],[12443,1," ゙"],[12444,1," ゚"],[[12445,12446],2],[12447,1,"より"],[12448,2],[[12449,12542],2],[12543,1,"コト"],[[12544,12548],3],[[12549,12588],2],[12589,2],[12590,2],[12591,2],[12592,3],[12593,1,"ᄀ"],[12594,1,"ᄁ"],[12595,1,"ᆪ"],[12596,1,"ᄂ"],[12597,1,"ᆬ"],[12598,1,"ᆭ"],[12599,1,"ᄃ"],[12600,1,"ᄄ"],[12601,1,"ᄅ"],[12602,1,"ᆰ"],[12603,1,"ᆱ"],[12604,1,"ᆲ"],[12605,1,"ᆳ"],[12606,1,"ᆴ"],[12607,1,"ᆵ"],[12608,1,"ᄚ"],[12609,1,"ᄆ"],[12610,1,"ᄇ"],[12611,1,"ᄈ"],[12612,1,"ᄡ"],[12613,1,"ᄉ"],[12614,1,"ᄊ"],[12615,1,"ᄋ"],[12616,1,"ᄌ"],[12617,1,"ᄍ"],[12618,1,"ᄎ"],[12619,1,"ᄏ"],[12620,1,"ᄐ"],[12621,1,"ᄑ"],[12622,1,"ᄒ"],[12623,1,"ᅡ"],[12624,1,"ᅢ"],[12625,1,"ᅣ"],[12626,1,"ᅤ"],[12627,1,"ᅥ"],[12628,1,"ᅦ"],[12629,1,"ᅧ"],[12630,1,"ᅨ"],[12631,1,"ᅩ"],[12632,1,"ᅪ"],[12633,1,"ᅫ"],[12634,1,"ᅬ"],[12635,1,"ᅭ"],[12636,1,"ᅮ"],[12637,1,"ᅯ"],[12638,1,"ᅰ"],[12639,1,"ᅱ"],[12640,1,"ᅲ"],[12641,1,"ᅳ"],[12642,1,"ᅴ"],[12643,1,"ᅵ"],[12644,7],[12645,1,"ᄔ"],[12646,1,"ᄕ"],[12647,1,"ᇇ"],[12648,1,"ᇈ"],[12649,1,"ᇌ"],[12650,1,"ᇎ"],[12651,1,"ᇓ"],[12652,1,"ᇗ"],[12653,1,"ᇙ"],[12654,1,"ᄜ"],[12655,1,"ᇝ"],[12656,1,"ᇟ"],[12657,1,"ᄝ"],[12658,1,"ᄞ"],[12659,1,"ᄠ"],[12660,1,"ᄢ"],[12661,1,"ᄣ"],[12662,1,"ᄧ"],[12663,1,"ᄩ"],[12664,1,"ᄫ"],[12665,1,"ᄬ"],[12666,1,"ᄭ"],[12667,1,"ᄮ"],[12668,1,"ᄯ"],[12669,1,"ᄲ"],[12670,1,"ᄶ"],[12671,1,"ᅀ"],[12672,1,"ᅇ"],[12673,1,"ᅌ"],[12674,1,"ᇱ"],[12675,1,"ᇲ"],[12676,1,"ᅗ"],[12677,1,"ᅘ"],[12678,1,"ᅙ"],[12679,1,"ᆄ"],[12680,1,"ᆅ"],[12681,1,"ᆈ"],[12682,1,"ᆑ"],[12683,1,"ᆒ"],[12684,1,"ᆔ"],[12685,1,"ᆞ"],[12686,1,"ᆡ"],[12687,3],[[12688,12689],2],[12690,1,"一"],[12691,1,"二"],[12692,1,"三"],[12693,1,"四"],[12694,1,"上"],[12695,1,"中"],[12696,1,"下"],[12697,1,"甲"],[12698,1,"乙"],[12699,1,"丙"],[12700,1,"丁"],[12701,1,"天"],[12702,1,"地"],[12703,1,"人"],[[12704,12727],2],[[12728,12730],2],[[12731,12735],2],[[12736,12751],2],[[12752,12771],2],[[12772,12773],2],[[12774,12782],3],[12783,3],[[12784,12799],2],[12800,1,"(ᄀ)"],[12801,1,"(ᄂ)"],[12802,1,"(ᄃ)"],[12803,1,"(ᄅ)"],[12804,1,"(ᄆ)"],[12805,1,"(ᄇ)"],[12806,1,"(ᄉ)"],[12807,1,"(ᄋ)"],[12808,1,"(ᄌ)"],[12809,1,"(ᄎ)"],[12810,1,"(ᄏ)"],[12811,1,"(ᄐ)"],[12812,1,"(ᄑ)"],[12813,1,"(ᄒ)"],[12814,1,"(가)"],[12815,1,"(나)"],[12816,1,"(다)"],[12817,1,"(라)"],[12818,1,"(마)"],[12819,1,"(바)"],[12820,1,"(사)"],[12821,1,"(아)"],[12822,1,"(자)"],[12823,1,"(차)"],[12824,1,"(카)"],[12825,1,"(타)"],[12826,1,"(파)"],[12827,1,"(하)"],[12828,1,"(주)"],[12829,1,"(오전)"],[12830,1,"(오후)"],[12831,3],[12832,1,"(一)"],[12833,1,"(二)"],[12834,1,"(三)"],[12835,1,"(四)"],[12836,1,"(五)"],[12837,1,"(六)"],[12838,1,"(七)"],[12839,1,"(八)"],[12840,1,"(九)"],[12841,1,"(十)"],[12842,1,"(月)"],[12843,1,"(火)"],[12844,1,"(水)"],[12845,1,"(木)"],[12846,1,"(金)"],[12847,1,"(土)"],[12848,1,"(日)"],[12849,1,"(株)"],[12850,1,"(有)"],[12851,1,"(社)"],[12852,1,"(名)"],[12853,1,"(特)"],[12854,1,"(財)"],[12855,1,"(祝)"],[12856,1,"(労)"],[12857,1,"(代)"],[12858,1,"(呼)"],[12859,1,"(学)"],[12860,1,"(監)"],[12861,1,"(企)"],[12862,1,"(資)"],[12863,1,"(協)"],[12864,1,"(祭)"],[12865,1,"(休)"],[12866,1,"(自)"],[12867,1,"(至)"],[12868,1,"問"],[12869,1,"幼"],[12870,1,"文"],[12871,1,"箏"],[[12872,12879],2],[12880,1,"pte"],[12881,1,"21"],[12882,1,"22"],[12883,1,"23"],[12884,1,"24"],[12885,1,"25"],[12886,1,"26"],[12887,1,"27"],[12888,1,"28"],[12889,1,"29"],[12890,1,"30"],[12891,1,"31"],[12892,1,"32"],[12893,1,"33"],[12894,1,"34"],[12895,1,"35"],[12896,1,"ᄀ"],[12897,1,"ᄂ"],[12898,1,"ᄃ"],[12899,1,"ᄅ"],[12900,1,"ᄆ"],[12901,1,"ᄇ"],[12902,1,"ᄉ"],[12903,1,"ᄋ"],[12904,1,"ᄌ"],[12905,1,"ᄎ"],[12906,1,"ᄏ"],[12907,1,"ᄐ"],[12908,1,"ᄑ"],[12909,1,"ᄒ"],[12910,1,"가"],[12911,1,"나"],[12912,1,"다"],[12913,1,"라"],[12914,1,"마"],[12915,1,"바"],[12916,1,"사"],[12917,1,"아"],[12918,1,"자"],[12919,1,"차"],[12920,1,"카"],[12921,1,"타"],[12922,1,"파"],[12923,1,"하"],[12924,1,"참고"],[12925,1,"주의"],[12926,1,"우"],[12927,2],[12928,1,"一"],[12929,1,"二"],[12930,1,"三"],[12931,1,"四"],[12932,1,"五"],[12933,1,"六"],[12934,1,"七"],[12935,1,"八"],[12936,1,"九"],[12937,1,"十"],[12938,1,"月"],[12939,1,"火"],[12940,1,"水"],[12941,1,"木"],[12942,1,"金"],[12943,1,"土"],[12944,1,"日"],[12945,1,"株"],[12946,1,"有"],[12947,1,"社"],[12948,1,"名"],[12949,1,"特"],[12950,1,"財"],[12951,1,"祝"],[12952,1,"労"],[12953,1,"秘"],[12954,1,"男"],[12955,1,"女"],[12956,1,"適"],[12957,1,"優"],[12958,1,"印"],[12959,1,"注"],[12960,1,"項"],[12961,1,"休"],[12962,1,"写"],[12963,1,"正"],[12964,1,"上"],[12965,1,"中"],[12966,1,"下"],[12967,1,"左"],[12968,1,"右"],[12969,1,"医"],[12970,1,"宗"],[12971,1,"学"],[12972,1,"監"],[12973,1,"企"],[12974,1,"資"],[12975,1,"協"],[12976,1,"夜"],[12977,1,"36"],[12978,1,"37"],[12979,1,"38"],[12980,1,"39"],[12981,1,"40"],[12982,1,"41"],[12983,1,"42"],[12984,1,"43"],[12985,1,"44"],[12986,1,"45"],[12987,1,"46"],[12988,1,"47"],[12989,1,"48"],[12990,1,"49"],[12991,1,"50"],[12992,1,"1月"],[12993,1,"2月"],[12994,1,"3月"],[12995,1,"4月"],[12996,1,"5月"],[12997,1,"6月"],[12998,1,"7月"],[12999,1,"8月"],[13000,1,"9月"],[13001,1,"10月"],[13002,1,"11月"],[13003,1,"12月"],[13004,1,"hg"],[13005,1,"erg"],[13006,1,"ev"],[13007,1,"ltd"],[13008,1,"ア"],[13009,1,"イ"],[13010,1,"ウ"],[13011,1,"エ"],[13012,1,"オ"],[13013,1,"カ"],[13014,1,"キ"],[13015,1,"ク"],[13016,1,"ケ"],[13017,1,"コ"],[13018,1,"サ"],[13019,1,"シ"],[13020,1,"ス"],[13021,1,"セ"],[13022,1,"ソ"],[13023,1,"タ"],[13024,1,"チ"],[13025,1,"ツ"],[13026,1,"テ"],[13027,1,"ト"],[13028,1,"ナ"],[13029,1,"ニ"],[13030,1,"ヌ"],[13031,1,"ネ"],[13032,1,"ノ"],[13033,1,"ハ"],[13034,1,"ヒ"],[13035,1,"フ"],[13036,1,"ヘ"],[13037,1,"ホ"],[13038,1,"マ"],[13039,1,"ミ"],[13040,1,"ム"],[13041,1,"メ"],[13042,1,"モ"],[13043,1,"ヤ"],[13044,1,"ユ"],[13045,1,"ヨ"],[13046,1,"ラ"],[13047,1,"リ"],[13048,1,"ル"],[13049,1,"レ"],[13050,1,"ロ"],[13051,1,"ワ"],[13052,1,"ヰ"],[13053,1,"ヱ"],[13054,1,"ヲ"],[13055,1,"令和"],[13056,1,"アパート"],[13057,1,"アルファ"],[13058,1,"アンペア"],[13059,1,"アール"],[13060,1,"イニング"],[13061,1,"インチ"],[13062,1,"ウォン"],[13063,1,"エスクード"],[13064,1,"エーカー"],[13065,1,"オンス"],[13066,1,"オーム"],[13067,1,"カイリ"],[13068,1,"カラット"],[13069,1,"カロリー"],[13070,1,"ガロン"],[13071,1,"ガンマ"],[13072,1,"ギガ"],[13073,1,"ギニー"],[13074,1,"キュリー"],[13075,1,"ギルダー"],[13076,1,"キロ"],[13077,1,"キログラム"],[13078,1,"キロメートル"],[13079,1,"キロワット"],[13080,1,"グラム"],[13081,1,"グラムトン"],[13082,1,"クルゼイロ"],[13083,1,"クローネ"],[13084,1,"ケース"],[13085,1,"コルナ"],[13086,1,"コーポ"],[13087,1,"サイクル"],[13088,1,"サンチーム"],[13089,1,"シリング"],[13090,1,"センチ"],[13091,1,"セント"],[13092,1,"ダース"],[13093,1,"デシ"],[13094,1,"ドル"],[13095,1,"トン"],[13096,1,"ナノ"],[13097,1,"ノット"],[13098,1,"ハイツ"],[13099,1,"パーセント"],[13100,1,"パーツ"],[13101,1,"バーレル"],[13102,1,"ピアストル"],[13103,1,"ピクル"],[13104,1,"ピコ"],[13105,1,"ビル"],[13106,1,"ファラッド"],[13107,1,"フィート"],[13108,1,"ブッシェル"],[13109,1,"フラン"],[13110,1,"ヘクタール"],[13111,1,"ペソ"],[13112,1,"ペニヒ"],[13113,1,"ヘルツ"],[13114,1,"ペンス"],[13115,1,"ページ"],[13116,1,"ベータ"],[13117,1,"ポイント"],[13118,1,"ボルト"],[13119,1,"ホン"],[13120,1,"ポンド"],[13121,1,"ホール"],[13122,1,"ホーン"],[13123,1,"マイクロ"],[13124,1,"マイル"],[13125,1,"マッハ"],[13126,1,"マルク"],[13127,1,"マンション"],[13128,1,"ミクロン"],[13129,1,"ミリ"],[13130,1,"ミリバール"],[13131,1,"メガ"],[13132,1,"メガトン"],[13133,1,"メートル"],[13134,1,"ヤード"],[13135,1,"ヤール"],[13136,1,"ユアン"],[13137,1,"リットル"],[13138,1,"リラ"],[13139,1,"ルピー"],[13140,1,"ルーブル"],[13141,1,"レム"],[13142,1,"レントゲン"],[13143,1,"ワット"],[13144,1,"0点"],[13145,1,"1点"],[13146,1,"2点"],[13147,1,"3点"],[13148,1,"4点"],[13149,1,"5点"],[13150,1,"6点"],[13151,1,"7点"],[13152,1,"8点"],[13153,1,"9点"],[13154,1,"10点"],[13155,1,"11点"],[13156,1,"12点"],[13157,1,"13点"],[13158,1,"14点"],[13159,1,"15点"],[13160,1,"16点"],[13161,1,"17点"],[13162,1,"18点"],[13163,1,"19点"],[13164,1,"20点"],[13165,1,"21点"],[13166,1,"22点"],[13167,1,"23点"],[13168,1,"24点"],[13169,1,"hpa"],[13170,1,"da"],[13171,1,"au"],[13172,1,"bar"],[13173,1,"ov"],[13174,1,"pc"],[13175,1,"dm"],[13176,1,"dm2"],[13177,1,"dm3"],[13178,1,"iu"],[13179,1,"平成"],[13180,1,"昭和"],[13181,1,"大正"],[13182,1,"明治"],[13183,1,"株式会社"],[13184,1,"pa"],[13185,1,"na"],[13186,1,"μa"],[13187,1,"ma"],[13188,1,"ka"],[13189,1,"kb"],[13190,1,"mb"],[13191,1,"gb"],[13192,1,"cal"],[13193,1,"kcal"],[13194,1,"pf"],[13195,1,"nf"],[13196,1,"μf"],[13197,1,"μg"],[13198,1,"mg"],[13199,1,"kg"],[13200,1,"hz"],[13201,1,"khz"],[13202,1,"mhz"],[13203,1,"ghz"],[13204,1,"thz"],[13205,1,"μl"],[13206,1,"ml"],[13207,1,"dl"],[13208,1,"kl"],[13209,1,"fm"],[13210,1,"nm"],[13211,1,"μm"],[13212,1,"mm"],[13213,1,"cm"],[13214,1,"km"],[13215,1,"mm2"],[13216,1,"cm2"],[13217,1,"m2"],[13218,1,"km2"],[13219,1,"mm3"],[13220,1,"cm3"],[13221,1,"m3"],[13222,1,"km3"],[13223,1,"m∕s"],[13224,1,"m∕s2"],[13225,1,"pa"],[13226,1,"kpa"],[13227,1,"mpa"],[13228,1,"gpa"],[13229,1,"rad"],[13230,1,"rad∕s"],[13231,1,"rad∕s2"],[13232,1,"ps"],[13233,1,"ns"],[13234,1,"μs"],[13235,1,"ms"],[13236,1,"pv"],[13237,1,"nv"],[13238,1,"μv"],[13239,1,"mv"],[13240,1,"kv"],[13241,1,"mv"],[13242,1,"pw"],[13243,1,"nw"],[13244,1,"μw"],[13245,1,"mw"],[13246,1,"kw"],[13247,1,"mw"],[13248,1,"kω"],[13249,1,"mω"],[13250,3],[13251,1,"bq"],[13252,1,"cc"],[13253,1,"cd"],[13254,1,"c∕kg"],[13255,3],[13256,1,"db"],[13257,1,"gy"],[13258,1,"ha"],[13259,1,"hp"],[13260,1,"in"],[13261,1,"kk"],[13262,1,"km"],[13263,1,"kt"],[13264,1,"lm"],[13265,1,"ln"],[13266,1,"log"],[13267,1,"lx"],[13268,1,"mb"],[13269,1,"mil"],[13270,1,"mol"],[13271,1,"ph"],[13272,3],[13273,1,"ppm"],[13274,1,"pr"],[13275,1,"sr"],[13276,1,"sv"],[13277,1,"wb"],[13278,1,"v∕m"],[13279,1,"a∕m"],[13280,1,"1日"],[13281,1,"2日"],[13282,1,"3日"],[13283,1,"4日"],[13284,1,"5日"],[13285,1,"6日"],[13286,1,"7日"],[13287,1,"8日"],[13288,1,"9日"],[13289,1,"10日"],[13290,1,"11日"],[13291,1,"12日"],[13292,1,"13日"],[13293,1,"14日"],[13294,1,"15日"],[13295,1,"16日"],[13296,1,"17日"],[13297,1,"18日"],[13298,1,"19日"],[13299,1,"20日"],[13300,1,"21日"],[13301,1,"22日"],[13302,1,"23日"],[13303,1,"24日"],[13304,1,"25日"],[13305,1,"26日"],[13306,1,"27日"],[13307,1,"28日"],[13308,1,"29日"],[13309,1,"30日"],[13310,1,"31日"],[13311,1,"gal"],[[13312,19893],2],[[19894,19903],2],[[19904,19967],2],[[19968,40869],2],[[40870,40891],2],[[40892,40899],2],[[40900,40907],2],[40908,2],[[40909,40917],2],[[40918,40938],2],[[40939,40943],2],[[40944,40956],2],[[40957,40959],2],[[40960,42124],2],[[42125,42127],3],[[42128,42145],2],[[42146,42147],2],[[42148,42163],2],[42164,2],[[42165,42176],2],[42177,2],[[42178,42180],2],[42181,2],[42182,2],[[42183,42191],3],[[42192,42237],2],[[42238,42239],2],[[42240,42508],2],[[42509,42511],2],[[42512,42539],2],[[42540,42559],3],[42560,1,"ꙁ"],[42561,2],[42562,1,"ꙃ"],[42563,2],[42564,1,"ꙅ"],[42565,2],[42566,1,"ꙇ"],[42567,2],[42568,1,"ꙉ"],[42569,2],[42570,1,"ꙋ"],[42571,2],[42572,1,"ꙍ"],[42573,2],[42574,1,"ꙏ"],[42575,2],[42576,1,"ꙑ"],[42577,2],[42578,1,"ꙓ"],[42579,2],[42580,1,"ꙕ"],[42581,2],[42582,1,"ꙗ"],[42583,2],[42584,1,"ꙙ"],[42585,2],[42586,1,"ꙛ"],[42587,2],[42588,1,"ꙝ"],[42589,2],[42590,1,"ꙟ"],[42591,2],[42592,1,"ꙡ"],[42593,2],[42594,1,"ꙣ"],[42595,2],[42596,1,"ꙥ"],[42597,2],[42598,1,"ꙧ"],[42599,2],[42600,1,"ꙩ"],[42601,2],[42602,1,"ꙫ"],[42603,2],[42604,1,"ꙭ"],[[42605,42607],2],[[42608,42611],2],[[42612,42619],2],[[42620,42621],2],[42622,2],[42623,2],[42624,1,"ꚁ"],[42625,2],[42626,1,"ꚃ"],[42627,2],[42628,1,"ꚅ"],[42629,2],[42630,1,"ꚇ"],[42631,2],[42632,1,"ꚉ"],[42633,2],[42634,1,"ꚋ"],[42635,2],[42636,1,"ꚍ"],[42637,2],[42638,1,"ꚏ"],[42639,2],[42640,1,"ꚑ"],[42641,2],[42642,1,"ꚓ"],[42643,2],[42644,1,"ꚕ"],[42645,2],[42646,1,"ꚗ"],[42647,2],[42648,1,"ꚙ"],[42649,2],[42650,1,"ꚛ"],[42651,2],[42652,1,"ъ"],[42653,1,"ь"],[42654,2],[42655,2],[[42656,42725],2],[[42726,42735],2],[[42736,42737],2],[[42738,42743],2],[[42744,42751],3],[[42752,42774],2],[[42775,42778],2],[[42779,42783],2],[[42784,42785],2],[42786,1,"ꜣ"],[42787,2],[42788,1,"ꜥ"],[42789,2],[42790,1,"ꜧ"],[42791,2],[42792,1,"ꜩ"],[42793,2],[42794,1,"ꜫ"],[42795,2],[42796,1,"ꜭ"],[42797,2],[42798,1,"ꜯ"],[[42799,42801],2],[42802,1,"ꜳ"],[42803,2],[42804,1,"ꜵ"],[42805,2],[42806,1,"ꜷ"],[42807,2],[42808,1,"ꜹ"],[42809,2],[42810,1,"ꜻ"],[42811,2],[42812,1,"ꜽ"],[42813,2],[42814,1,"ꜿ"],[42815,2],[42816,1,"ꝁ"],[42817,2],[42818,1,"ꝃ"],[42819,2],[42820,1,"ꝅ"],[42821,2],[42822,1,"ꝇ"],[42823,2],[42824,1,"ꝉ"],[42825,2],[42826,1,"ꝋ"],[42827,2],[42828,1,"ꝍ"],[42829,2],[42830,1,"ꝏ"],[42831,2],[42832,1,"ꝑ"],[42833,2],[42834,1,"ꝓ"],[42835,2],[42836,1,"ꝕ"],[42837,2],[42838,1,"ꝗ"],[42839,2],[42840,1,"ꝙ"],[42841,2],[42842,1,"ꝛ"],[42843,2],[42844,1,"ꝝ"],[42845,2],[42846,1,"ꝟ"],[42847,2],[42848,1,"ꝡ"],[42849,2],[42850,1,"ꝣ"],[42851,2],[42852,1,"ꝥ"],[42853,2],[42854,1,"ꝧ"],[42855,2],[42856,1,"ꝩ"],[42857,2],[42858,1,"ꝫ"],[42859,2],[42860,1,"ꝭ"],[42861,2],[42862,1,"ꝯ"],[42863,2],[42864,1,"ꝯ"],[[42865,42872],2],[42873,1,"ꝺ"],[42874,2],[42875,1,"ꝼ"],[42876,2],[42877,1,"ᵹ"],[42878,1,"ꝿ"],[42879,2],[42880,1,"ꞁ"],[42881,2],[42882,1,"ꞃ"],[42883,2],[42884,1,"ꞅ"],[42885,2],[42886,1,"ꞇ"],[[42887,42888],2],[[42889,42890],2],[42891,1,"ꞌ"],[42892,2],[42893,1,"ɥ"],[42894,2],[42895,2],[42896,1,"ꞑ"],[42897,2],[42898,1,"ꞓ"],[42899,2],[[42900,42901],2],[42902,1,"ꞗ"],[42903,2],[42904,1,"ꞙ"],[42905,2],[42906,1,"ꞛ"],[42907,2],[42908,1,"ꞝ"],[42909,2],[42910,1,"ꞟ"],[42911,2],[42912,1,"ꞡ"],[42913,2],[42914,1,"ꞣ"],[42915,2],[42916,1,"ꞥ"],[42917,2],[42918,1,"ꞧ"],[42919,2],[42920,1,"ꞩ"],[42921,2],[42922,1,"ɦ"],[42923,1,"ɜ"],[42924,1,"ɡ"],[42925,1,"ɬ"],[42926,1,"ɪ"],[42927,2],[42928,1,"ʞ"],[42929,1,"ʇ"],[42930,1,"ʝ"],[42931,1,"ꭓ"],[42932,1,"ꞵ"],[42933,2],[42934,1,"ꞷ"],[42935,2],[42936,1,"ꞹ"],[42937,2],[42938,1,"ꞻ"],[42939,2],[42940,1,"ꞽ"],[42941,2],[42942,1,"ꞿ"],[42943,2],[42944,1,"ꟁ"],[42945,2],[42946,1,"ꟃ"],[42947,2],[42948,1,"ꞔ"],[42949,1,"ʂ"],[42950,1,"ᶎ"],[42951,1,"ꟈ"],[42952,2],[42953,1,"ꟊ"],[42954,2],[42955,1,"ɤ"],[42956,1,"ꟍ"],[42957,2],[42958,1,"꟏"],[42959,2],[42960,1,"ꟑ"],[42961,2],[42962,1,"ꟓ"],[42963,2],[42964,1,"ꟕ"],[42965,2],[42966,1,"ꟗ"],[42967,2],[42968,1,"ꟙ"],[42969,2],[42970,1,"ꟛ"],[42971,2],[42972,1,"ƛ"],[[42973,42992],3],[42993,1,"s"],[42994,1,"c"],[42995,1,"f"],[42996,1,"q"],[42997,1,"ꟶ"],[42998,2],[42999,2],[43000,1,"ħ"],[43001,1,"œ"],[43002,2],[[43003,43007],2],[[43008,43047],2],[[43048,43051],2],[43052,2],[[43053,43055],3],[[43056,43065],2],[[43066,43071],3],[[43072,43123],2],[[43124,43127],2],[[43128,43135],3],[[43136,43204],2],[43205,2],[[43206,43213],3],[[43214,43215],2],[[43216,43225],2],[[43226,43231],3],[[43232,43255],2],[[43256,43258],2],[43259,2],[43260,2],[43261,2],[[43262,43263],2],[[43264,43309],2],[[43310,43311],2],[[43312,43347],2],[[43348,43358],3],[43359,2],[[43360,43388],2],[[43389,43391],3],[[43392,43456],2],[[43457,43469],2],[43470,3],[[43471,43481],2],[[43482,43485],3],[[43486,43487],2],[[43488,43518],2],[43519,3],[[43520,43574],2],[[43575,43583],3],[[43584,43597],2],[[43598,43599],3],[[43600,43609],2],[[43610,43611],3],[[43612,43615],2],[[43616,43638],2],[[43639,43641],2],[[43642,43643],2],[[43644,43647],2],[[43648,43714],2],[[43715,43738],3],[[43739,43741],2],[[43742,43743],2],[[43744,43759],2],[[43760,43761],2],[[43762,43766],2],[[43767,43776],3],[[43777,43782],2],[[43783,43784],3],[[43785,43790],2],[[43791,43792],3],[[43793,43798],2],[[43799,43807],3],[[43808,43814],2],[43815,3],[[43816,43822],2],[43823,3],[[43824,43866],2],[43867,2],[43868,1,"ꜧ"],[43869,1,"ꬷ"],[43870,1,"ɫ"],[43871,1,"ꭒ"],[[43872,43875],2],[[43876,43877],2],[[43878,43879],2],[43880,2],[43881,1,"ʍ"],[[43882,43883],2],[[43884,43887],3],[43888,1,"Ꭰ"],[43889,1,"Ꭱ"],[43890,1,"Ꭲ"],[43891,1,"Ꭳ"],[43892,1,"Ꭴ"],[43893,1,"Ꭵ"],[43894,1,"Ꭶ"],[43895,1,"Ꭷ"],[43896,1,"Ꭸ"],[43897,1,"Ꭹ"],[43898,1,"Ꭺ"],[43899,1,"Ꭻ"],[43900,1,"Ꭼ"],[43901,1,"Ꭽ"],[43902,1,"Ꭾ"],[43903,1,"Ꭿ"],[43904,1,"Ꮀ"],[43905,1,"Ꮁ"],[43906,1,"Ꮂ"],[43907,1,"Ꮃ"],[43908,1,"Ꮄ"],[43909,1,"Ꮅ"],[43910,1,"Ꮆ"],[43911,1,"Ꮇ"],[43912,1,"Ꮈ"],[43913,1,"Ꮉ"],[43914,1,"Ꮊ"],[43915,1,"Ꮋ"],[43916,1,"Ꮌ"],[43917,1,"Ꮍ"],[43918,1,"Ꮎ"],[43919,1,"Ꮏ"],[43920,1,"Ꮐ"],[43921,1,"Ꮑ"],[43922,1,"Ꮒ"],[43923,1,"Ꮓ"],[43924,1,"Ꮔ"],[43925,1,"Ꮕ"],[43926,1,"Ꮖ"],[43927,1,"Ꮗ"],[43928,1,"Ꮘ"],[43929,1,"Ꮙ"],[43930,1,"Ꮚ"],[43931,1,"Ꮛ"],[43932,1,"Ꮜ"],[43933,1,"Ꮝ"],[43934,1,"Ꮞ"],[43935,1,"Ꮟ"],[43936,1,"Ꮠ"],[43937,1,"Ꮡ"],[43938,1,"Ꮢ"],[43939,1,"Ꮣ"],[43940,1,"Ꮤ"],[43941,1,"Ꮥ"],[43942,1,"Ꮦ"],[43943,1,"Ꮧ"],[43944,1,"Ꮨ"],[43945,1,"Ꮩ"],[43946,1,"Ꮪ"],[43947,1,"Ꮫ"],[43948,1,"Ꮬ"],[43949,1,"Ꮭ"],[43950,1,"Ꮮ"],[43951,1,"Ꮯ"],[43952,1,"Ꮰ"],[43953,1,"Ꮱ"],[43954,1,"Ꮲ"],[43955,1,"Ꮳ"],[43956,1,"Ꮴ"],[43957,1,"Ꮵ"],[43958,1,"Ꮶ"],[43959,1,"Ꮷ"],[43960,1,"Ꮸ"],[43961,1,"Ꮹ"],[43962,1,"Ꮺ"],[43963,1,"Ꮻ"],[43964,1,"Ꮼ"],[43965,1,"Ꮽ"],[43966,1,"Ꮾ"],[43967,1,"Ꮿ"],[[43968,44010],2],[44011,2],[[44012,44013],2],[[44014,44015],3],[[44016,44025],2],[[44026,44031],3],[[44032,55203],2],[[55204,55215],3],[[55216,55238],2],[[55239,55242],3],[[55243,55291],2],[[55292,55295],3],[[55296,57343],3],[[57344,63743],3],[63744,1,"豈"],[63745,1,"更"],[63746,1,"車"],[63747,1,"賈"],[63748,1,"滑"],[63749,1,"串"],[63750,1,"句"],[[63751,63752],1,"龜"],[63753,1,"契"],[63754,1,"金"],[63755,1,"喇"],[63756,1,"奈"],[63757,1,"懶"],[63758,1,"癩"],[63759,1,"羅"],[63760,1,"蘿"],[63761,1,"螺"],[63762,1,"裸"],[63763,1,"邏"],[63764,1,"樂"],[63765,1,"洛"],[63766,1,"烙"],[63767,1,"珞"],[63768,1,"落"],[63769,1,"酪"],[63770,1,"駱"],[63771,1,"亂"],[63772,1,"卵"],[63773,1,"欄"],[63774,1,"爛"],[63775,1,"蘭"],[63776,1,"鸞"],[63777,1,"嵐"],[63778,1,"濫"],[63779,1,"藍"],[63780,1,"襤"],[63781,1,"拉"],[63782,1,"臘"],[63783,1,"蠟"],[63784,1,"廊"],[63785,1,"朗"],[63786,1,"浪"],[63787,1,"狼"],[63788,1,"郎"],[63789,1,"來"],[63790,1,"冷"],[63791,1,"勞"],[63792,1,"擄"],[63793,1,"櫓"],[63794,1,"爐"],[63795,1,"盧"],[63796,1,"老"],[63797,1,"蘆"],[63798,1,"虜"],[63799,1,"路"],[63800,1,"露"],[63801,1,"魯"],[63802,1,"鷺"],[63803,1,"碌"],[63804,1,"祿"],[63805,1,"綠"],[63806,1,"菉"],[63807,1,"錄"],[63808,1,"鹿"],[63809,1,"論"],[63810,1,"壟"],[63811,1,"弄"],[63812,1,"籠"],[63813,1,"聾"],[63814,1,"牢"],[63815,1,"磊"],[63816,1,"賂"],[63817,1,"雷"],[63818,1,"壘"],[63819,1,"屢"],[63820,1,"樓"],[63821,1,"淚"],[63822,1,"漏"],[63823,1,"累"],[63824,1,"縷"],[63825,1,"陋"],[63826,1,"勒"],[63827,1,"肋"],[63828,1,"凜"],[63829,1,"凌"],[63830,1,"稜"],[63831,1,"綾"],[63832,1,"菱"],[63833,1,"陵"],[63834,1,"讀"],[63835,1,"拏"],[63836,1,"樂"],[63837,1,"諾"],[63838,1,"丹"],[63839,1,"寧"],[63840,1,"怒"],[63841,1,"率"],[63842,1,"異"],[63843,1,"北"],[63844,1,"磻"],[63845,1,"便"],[63846,1,"復"],[63847,1,"不"],[63848,1,"泌"],[63849,1,"數"],[63850,1,"索"],[63851,1,"參"],[63852,1,"塞"],[63853,1,"省"],[63854,1,"葉"],[63855,1,"說"],[63856,1,"殺"],[63857,1,"辰"],[63858,1,"沈"],[63859,1,"拾"],[63860,1,"若"],[63861,1,"掠"],[63862,1,"略"],[63863,1,"亮"],[63864,1,"兩"],[63865,1,"凉"],[63866,1,"梁"],[63867,1,"糧"],[63868,1,"良"],[63869,1,"諒"],[63870,1,"量"],[63871,1,"勵"],[63872,1,"呂"],[63873,1,"女"],[63874,1,"廬"],[63875,1,"旅"],[63876,1,"濾"],[63877,1,"礪"],[63878,1,"閭"],[63879,1,"驪"],[63880,1,"麗"],[63881,1,"黎"],[63882,1,"力"],[63883,1,"曆"],[63884,1,"歷"],[63885,1,"轢"],[63886,1,"年"],[63887,1,"憐"],[63888,1,"戀"],[63889,1,"撚"],[63890,1,"漣"],[63891,1,"煉"],[63892,1,"璉"],[63893,1,"秊"],[63894,1,"練"],[63895,1,"聯"],[63896,1,"輦"],[63897,1,"蓮"],[63898,1,"連"],[63899,1,"鍊"],[63900,1,"列"],[63901,1,"劣"],[63902,1,"咽"],[63903,1,"烈"],[63904,1,"裂"],[63905,1,"說"],[63906,1,"廉"],[63907,1,"念"],[63908,1,"捻"],[63909,1,"殮"],[63910,1,"簾"],[63911,1,"獵"],[63912,1,"令"],[63913,1,"囹"],[63914,1,"寧"],[63915,1,"嶺"],[63916,1,"怜"],[63917,1,"玲"],[63918,1,"瑩"],[63919,1,"羚"],[63920,1,"聆"],[63921,1,"鈴"],[63922,1,"零"],[63923,1,"靈"],[63924,1,"領"],[63925,1,"例"],[63926,1,"禮"],[63927,1,"醴"],[63928,1,"隸"],[63929,1,"惡"],[63930,1,"了"],[63931,1,"僚"],[63932,1,"寮"],[63933,1,"尿"],[63934,1,"料"],[63935,1,"樂"],[63936,1,"燎"],[63937,1,"療"],[63938,1,"蓼"],[63939,1,"遼"],[63940,1,"龍"],[63941,1,"暈"],[63942,1,"阮"],[63943,1,"劉"],[63944,1,"杻"],[63945,1,"柳"],[63946,1,"流"],[63947,1,"溜"],[63948,1,"琉"],[63949,1,"留"],[63950,1,"硫"],[63951,1,"紐"],[63952,1,"類"],[63953,1,"六"],[63954,1,"戮"],[63955,1,"陸"],[63956,1,"倫"],[63957,1,"崙"],[63958,1,"淪"],[63959,1,"輪"],[63960,1,"律"],[63961,1,"慄"],[63962,1,"栗"],[63963,1,"率"],[63964,1,"隆"],[63965,1,"利"],[63966,1,"吏"],[63967,1,"履"],[63968,1,"易"],[63969,1,"李"],[63970,1,"梨"],[63971,1,"泥"],[63972,1,"理"],[63973,1,"痢"],[63974,1,"罹"],[63975,1,"裏"],[63976,1,"裡"],[63977,1,"里"],[63978,1,"離"],[63979,1,"匿"],[63980,1,"溺"],[63981,1,"吝"],[63982,1,"燐"],[63983,1,"璘"],[63984,1,"藺"],[63985,1,"隣"],[63986,1,"鱗"],[63987,1,"麟"],[63988,1,"林"],[63989,1,"淋"],[63990,1,"臨"],[63991,1,"立"],[63992,1,"笠"],[63993,1,"粒"],[63994,1,"狀"],[63995,1,"炙"],[63996,1,"識"],[63997,1,"什"],[63998,1,"茶"],[63999,1,"刺"],[64000,1,"切"],[64001,1,"度"],[64002,1,"拓"],[64003,1,"糖"],[64004,1,"宅"],[64005,1,"洞"],[64006,1,"暴"],[64007,1,"輻"],[64008,1,"行"],[64009,1,"降"],[64010,1,"見"],[64011,1,"廓"],[64012,1,"兀"],[64013,1,"嗀"],[[64014,64015],2],[64016,1,"塚"],[64017,2],[64018,1,"晴"],[[64019,64020],2],[64021,1,"凞"],[64022,1,"猪"],[64023,1,"益"],[64024,1,"礼"],[64025,1,"神"],[64026,1,"祥"],[64027,1,"福"],[64028,1,"靖"],[64029,1,"精"],[64030,1,"羽"],[64031,2],[64032,1,"蘒"],[64033,2],[64034,1,"諸"],[[64035,64036],2],[64037,1,"逸"],[64038,1,"都"],[[64039,64041],2],[64042,1,"飯"],[64043,1,"飼"],[64044,1,"館"],[64045,1,"鶴"],[64046,1,"郞"],[64047,1,"隷"],[64048,1,"侮"],[64049,1,"僧"],[64050,1,"免"],[64051,1,"勉"],[64052,1,"勤"],[64053,1,"卑"],[64054,1,"喝"],[64055,1,"嘆"],[64056,1,"器"],[64057,1,"塀"],[64058,1,"墨"],[64059,1,"層"],[64060,1,"屮"],[64061,1,"悔"],[64062,1,"慨"],[64063,1,"憎"],[64064,1,"懲"],[64065,1,"敏"],[64066,1,"既"],[64067,1,"暑"],[64068,1,"梅"],[64069,1,"海"],[64070,1,"渚"],[64071,1,"漢"],[64072,1,"煮"],[64073,1,"爫"],[64074,1,"琢"],[64075,1,"碑"],[64076,1,"社"],[64077,1,"祉"],[64078,1,"祈"],[64079,1,"祐"],[64080,1,"祖"],[64081,1,"祝"],[64082,1,"禍"],[64083,1,"禎"],[64084,1,"穀"],[64085,1,"突"],[64086,1,"節"],[64087,1,"練"],[64088,1,"縉"],[64089,1,"繁"],[64090,1,"署"],[64091,1,"者"],[64092,1,"臭"],[[64093,64094],1,"艹"],[64095,1,"著"],[64096,1,"褐"],[64097,1,"視"],[64098,1,"謁"],[64099,1,"謹"],[64100,1,"賓"],[64101,1,"贈"],[64102,1,"辶"],[64103,1,"逸"],[64104,1,"難"],[64105,1,"響"],[64106,1,"頻"],[64107,1,"恵"],[64108,1,"𤋮"],[64109,1,"舘"],[[64110,64111],3],[64112,1,"並"],[64113,1,"况"],[64114,1,"全"],[64115,1,"侀"],[64116,1,"充"],[64117,1,"冀"],[64118,1,"勇"],[64119,1,"勺"],[64120,1,"喝"],[64121,1,"啕"],[64122,1,"喙"],[64123,1,"嗢"],[64124,1,"塚"],[64125,1,"墳"],[64126,1,"奄"],[64127,1,"奔"],[64128,1,"婢"],[64129,1,"嬨"],[64130,1,"廒"],[64131,1,"廙"],[64132,1,"彩"],[64133,1,"徭"],[64134,1,"惘"],[64135,1,"慎"],[64136,1,"愈"],[64137,1,"憎"],[64138,1,"慠"],[64139,1,"懲"],[64140,1,"戴"],[64141,1,"揄"],[64142,1,"搜"],[64143,1,"摒"],[64144,1,"敖"],[64145,1,"晴"],[64146,1,"朗"],[64147,1,"望"],[64148,1,"杖"],[64149,1,"歹"],[64150,1,"殺"],[64151,1,"流"],[64152,1,"滛"],[64153,1,"滋"],[64154,1,"漢"],[64155,1,"瀞"],[64156,1,"煮"],[64157,1,"瞧"],[64158,1,"爵"],[64159,1,"犯"],[64160,1,"猪"],[64161,1,"瑱"],[64162,1,"甆"],[64163,1,"画"],[64164,1,"瘝"],[64165,1,"瘟"],[64166,1,"益"],[64167,1,"盛"],[64168,1,"直"],[64169,1,"睊"],[64170,1,"着"],[64171,1,"磌"],[64172,1,"窱"],[64173,1,"節"],[64174,1,"类"],[64175,1,"絛"],[64176,1,"練"],[64177,1,"缾"],[64178,1,"者"],[64179,1,"荒"],[64180,1,"華"],[64181,1,"蝹"],[64182,1,"襁"],[64183,1,"覆"],[64184,1,"視"],[64185,1,"調"],[64186,1,"諸"],[64187,1,"請"],[64188,1,"謁"],[64189,1,"諾"],[64190,1,"諭"],[64191,1,"謹"],[64192,1,"變"],[64193,1,"贈"],[64194,1,"輸"],[64195,1,"遲"],[64196,1,"醙"],[64197,1,"鉶"],[64198,1,"陼"],[64199,1,"難"],[64200,1,"靖"],[64201,1,"韛"],[64202,1,"響"],[64203,1,"頋"],[64204,1,"頻"],[64205,1,"鬒"],[64206,1,"龜"],[64207,1,"𢡊"],[64208,1,"𢡄"],[64209,1,"𣏕"],[64210,1,"㮝"],[64211,1,"䀘"],[64212,1,"䀹"],[64213,1,"𥉉"],[64214,1,"𥳐"],[64215,1,"𧻓"],[64216,1,"齃"],[64217,1,"龎"],[[64218,64255],3],[64256,1,"ff"],[64257,1,"fi"],[64258,1,"fl"],[64259,1,"ffi"],[64260,1,"ffl"],[[64261,64262],1,"st"],[[64263,64274],3],[64275,1,"մն"],[64276,1,"մե"],[64277,1,"մի"],[64278,1,"վն"],[64279,1,"մխ"],[[64280,64284],3],[64285,1,"יִ"],[64286,2],[64287,1,"ײַ"],[64288,1,"ע"],[64289,1,"א"],[64290,1,"ד"],[64291,1,"ה"],[64292,1,"כ"],[64293,1,"ל"],[64294,1,"ם"],[64295,1,"ר"],[64296,1,"ת"],[64297,1,"+"],[64298,1,"שׁ"],[64299,1,"שׂ"],[64300,1,"שּׁ"],[64301,1,"שּׂ"],[64302,1,"אַ"],[64303,1,"אָ"],[64304,1,"אּ"],[64305,1,"בּ"],[64306,1,"גּ"],[64307,1,"דּ"],[64308,1,"הּ"],[64309,1,"וּ"],[64310,1,"זּ"],[64311,3],[64312,1,"טּ"],[64313,1,"יּ"],[64314,1,"ךּ"],[64315,1,"כּ"],[64316,1,"לּ"],[64317,3],[64318,1,"מּ"],[64319,3],[64320,1,"נּ"],[64321,1,"סּ"],[64322,3],[64323,1,"ףּ"],[64324,1,"פּ"],[64325,3],[64326,1,"צּ"],[64327,1,"קּ"],[64328,1,"רּ"],[64329,1,"שּ"],[64330,1,"תּ"],[64331,1,"וֹ"],[64332,1,"בֿ"],[64333,1,"כֿ"],[64334,1,"פֿ"],[64335,1,"אל"],[[64336,64337],1,"ٱ"],[[64338,64341],1,"ٻ"],[[64342,64345],1,"پ"],[[64346,64349],1,"ڀ"],[[64350,64353],1,"ٺ"],[[64354,64357],1,"ٿ"],[[64358,64361],1,"ٹ"],[[64362,64365],1,"ڤ"],[[64366,64369],1,"ڦ"],[[64370,64373],1,"ڄ"],[[64374,64377],1,"ڃ"],[[64378,64381],1,"چ"],[[64382,64385],1,"ڇ"],[[64386,64387],1,"ڍ"],[[64388,64389],1,"ڌ"],[[64390,64391],1,"ڎ"],[[64392,64393],1,"ڈ"],[[64394,64395],1,"ژ"],[[64396,64397],1,"ڑ"],[[64398,64401],1,"ک"],[[64402,64405],1,"گ"],[[64406,64409],1,"ڳ"],[[64410,64413],1,"ڱ"],[[64414,64415],1,"ں"],[[64416,64419],1,"ڻ"],[[64420,64421],1,"ۀ"],[[64422,64425],1,"ہ"],[[64426,64429],1,"ھ"],[[64430,64431],1,"ے"],[[64432,64433],1,"ۓ"],[[64434,64449],2],[64450,2],[[64451,64466],2],[[64467,64470],1,"ڭ"],[[64471,64472],1,"ۇ"],[[64473,64474],1,"ۆ"],[[64475,64476],1,"ۈ"],[64477,1,"ۇٴ"],[[64478,64479],1,"ۋ"],[[64480,64481],1,"ۅ"],[[64482,64483],1,"ۉ"],[[64484,64487],1,"ې"],[[64488,64489],1,"ى"],[[64490,64491],1,"ئا"],[[64492,64493],1,"ئە"],[[64494,64495],1,"ئو"],[[64496,64497],1,"ئۇ"],[[64498,64499],1,"ئۆ"],[[64500,64501],1,"ئۈ"],[[64502,64504],1,"ئې"],[[64505,64507],1,"ئى"],[[64508,64511],1,"ی"],[64512,1,"ئج"],[64513,1,"ئح"],[64514,1,"ئم"],[64515,1,"ئى"],[64516,1,"ئي"],[64517,1,"بج"],[64518,1,"بح"],[64519,1,"بخ"],[64520,1,"بم"],[64521,1,"بى"],[64522,1,"بي"],[64523,1,"تج"],[64524,1,"تح"],[64525,1,"تخ"],[64526,1,"تم"],[64527,1,"تى"],[64528,1,"تي"],[64529,1,"ثج"],[64530,1,"ثم"],[64531,1,"ثى"],[64532,1,"ثي"],[64533,1,"جح"],[64534,1,"جم"],[64535,1,"حج"],[64536,1,"حم"],[64537,1,"خج"],[64538,1,"خح"],[64539,1,"خم"],[64540,1,"سج"],[64541,1,"سح"],[64542,1,"سخ"],[64543,1,"سم"],[64544,1,"صح"],[64545,1,"صم"],[64546,1,"ضج"],[64547,1,"ضح"],[64548,1,"ضخ"],[64549,1,"ضم"],[64550,1,"طح"],[64551,1,"طم"],[64552,1,"ظم"],[64553,1,"عج"],[64554,1,"عم"],[64555,1,"غج"],[64556,1,"غم"],[64557,1,"فج"],[64558,1,"فح"],[64559,1,"فخ"],[64560,1,"فم"],[64561,1,"فى"],[64562,1,"في"],[64563,1,"قح"],[64564,1,"قم"],[64565,1,"قى"],[64566,1,"قي"],[64567,1,"كا"],[64568,1,"كج"],[64569,1,"كح"],[64570,1,"كخ"],[64571,1,"كل"],[64572,1,"كم"],[64573,1,"كى"],[64574,1,"كي"],[64575,1,"لج"],[64576,1,"لح"],[64577,1,"لخ"],[64578,1,"لم"],[64579,1,"لى"],[64580,1,"لي"],[64581,1,"مج"],[64582,1,"مح"],[64583,1,"مخ"],[64584,1,"مم"],[64585,1,"مى"],[64586,1,"مي"],[64587,1,"نج"],[64588,1,"نح"],[64589,1,"نخ"],[64590,1,"نم"],[64591,1,"نى"],[64592,1,"ني"],[64593,1,"هج"],[64594,1,"هم"],[64595,1,"هى"],[64596,1,"هي"],[64597,1,"يج"],[64598,1,"يح"],[64599,1,"يخ"],[64600,1,"يم"],[64601,1,"يى"],[64602,1,"يي"],[64603,1,"ذٰ"],[64604,1,"رٰ"],[64605,1,"ىٰ"],[64606,1," ٌّ"],[64607,1," ٍّ"],[64608,1," َّ"],[64609,1," ُّ"],[64610,1," ِّ"],[64611,1," ّٰ"],[64612,1,"ئر"],[64613,1,"ئز"],[64614,1,"ئم"],[64615,1,"ئن"],[64616,1,"ئى"],[64617,1,"ئي"],[64618,1,"بر"],[64619,1,"بز"],[64620,1,"بم"],[64621,1,"بن"],[64622,1,"بى"],[64623,1,"بي"],[64624,1,"تر"],[64625,1,"تز"],[64626,1,"تم"],[64627,1,"تن"],[64628,1,"تى"],[64629,1,"تي"],[64630,1,"ثر"],[64631,1,"ثز"],[64632,1,"ثم"],[64633,1,"ثن"],[64634,1,"ثى"],[64635,1,"ثي"],[64636,1,"فى"],[64637,1,"في"],[64638,1,"قى"],[64639,1,"قي"],[64640,1,"كا"],[64641,1,"كل"],[64642,1,"كم"],[64643,1,"كى"],[64644,1,"كي"],[64645,1,"لم"],[64646,1,"لى"],[64647,1,"لي"],[64648,1,"ما"],[64649,1,"مم"],[64650,1,"نر"],[64651,1,"نز"],[64652,1,"نم"],[64653,1,"نن"],[64654,1,"نى"],[64655,1,"ني"],[64656,1,"ىٰ"],[64657,1,"ير"],[64658,1,"يز"],[64659,1,"يم"],[64660,1,"ين"],[64661,1,"يى"],[64662,1,"يي"],[64663,1,"ئج"],[64664,1,"ئح"],[64665,1,"ئخ"],[64666,1,"ئم"],[64667,1,"ئه"],[64668,1,"بج"],[64669,1,"بح"],[64670,1,"بخ"],[64671,1,"بم"],[64672,1,"به"],[64673,1,"تج"],[64674,1,"تح"],[64675,1,"تخ"],[64676,1,"تم"],[64677,1,"ته"],[64678,1,"ثم"],[64679,1,"جح"],[64680,1,"جم"],[64681,1,"حج"],[64682,1,"حم"],[64683,1,"خج"],[64684,1,"خم"],[64685,1,"سج"],[64686,1,"سح"],[64687,1,"سخ"],[64688,1,"سم"],[64689,1,"صح"],[64690,1,"صخ"],[64691,1,"صم"],[64692,1,"ضج"],[64693,1,"ضح"],[64694,1,"ضخ"],[64695,1,"ضم"],[64696,1,"طح"],[64697,1,"ظم"],[64698,1,"عج"],[64699,1,"عم"],[64700,1,"غج"],[64701,1,"غم"],[64702,1,"فج"],[64703,1,"فح"],[64704,1,"فخ"],[64705,1,"فم"],[64706,1,"قح"],[64707,1,"قم"],[64708,1,"كج"],[64709,1,"كح"],[64710,1,"كخ"],[64711,1,"كل"],[64712,1,"كم"],[64713,1,"لج"],[64714,1,"لح"],[64715,1,"لخ"],[64716,1,"لم"],[64717,1,"له"],[64718,1,"مج"],[64719,1,"مح"],[64720,1,"مخ"],[64721,1,"مم"],[64722,1,"نج"],[64723,1,"نح"],[64724,1,"نخ"],[64725,1,"نم"],[64726,1,"نه"],[64727,1,"هج"],[64728,1,"هم"],[64729,1,"هٰ"],[64730,1,"يج"],[64731,1,"يح"],[64732,1,"يخ"],[64733,1,"يم"],[64734,1,"يه"],[64735,1,"ئم"],[64736,1,"ئه"],[64737,1,"بم"],[64738,1,"به"],[64739,1,"تم"],[64740,1,"ته"],[64741,1,"ثم"],[64742,1,"ثه"],[64743,1,"سم"],[64744,1,"سه"],[64745,1,"شم"],[64746,1,"شه"],[64747,1,"كل"],[64748,1,"كم"],[64749,1,"لم"],[64750,1,"نم"],[64751,1,"نه"],[64752,1,"يم"],[64753,1,"يه"],[64754,1,"ـَّ"],[64755,1,"ـُّ"],[64756,1,"ـِّ"],[64757,1,"طى"],[64758,1,"طي"],[64759,1,"عى"],[64760,1,"عي"],[64761,1,"غى"],[64762,1,"غي"],[64763,1,"سى"],[64764,1,"سي"],[64765,1,"شى"],[64766,1,"شي"],[64767,1,"حى"],[64768,1,"حي"],[64769,1,"جى"],[64770,1,"جي"],[64771,1,"خى"],[64772,1,"خي"],[64773,1,"صى"],[64774,1,"صي"],[64775,1,"ضى"],[64776,1,"ضي"],[64777,1,"شج"],[64778,1,"شح"],[64779,1,"شخ"],[64780,1,"شم"],[64781,1,"شر"],[64782,1,"سر"],[64783,1,"صر"],[64784,1,"ضر"],[64785,1,"طى"],[64786,1,"طي"],[64787,1,"عى"],[64788,1,"عي"],[64789,1,"غى"],[64790,1,"غي"],[64791,1,"سى"],[64792,1,"سي"],[64793,1,"شى"],[64794,1,"شي"],[64795,1,"حى"],[64796,1,"حي"],[64797,1,"جى"],[64798,1,"جي"],[64799,1,"خى"],[64800,1,"خي"],[64801,1,"صى"],[64802,1,"صي"],[64803,1,"ضى"],[64804,1,"ضي"],[64805,1,"شج"],[64806,1,"شح"],[64807,1,"شخ"],[64808,1,"شم"],[64809,1,"شر"],[64810,1,"سر"],[64811,1,"صر"],[64812,1,"ضر"],[64813,1,"شج"],[64814,1,"شح"],[64815,1,"شخ"],[64816,1,"شم"],[64817,1,"سه"],[64818,1,"شه"],[64819,1,"طم"],[64820,1,"سج"],[64821,1,"سح"],[64822,1,"سخ"],[64823,1,"شج"],[64824,1,"شح"],[64825,1,"شخ"],[64826,1,"طم"],[64827,1,"ظم"],[[64828,64829],1,"اً"],[[64830,64831],2],[[64832,64847],2],[64848,1,"تجم"],[[64849,64850],1,"تحج"],[64851,1,"تحم"],[64852,1,"تخم"],[64853,1,"تمج"],[64854,1,"تمح"],[64855,1,"تمخ"],[[64856,64857],1,"جمح"],[64858,1,"حمي"],[64859,1,"حمى"],[64860,1,"سحج"],[64861,1,"سجح"],[64862,1,"سجى"],[[64863,64864],1,"سمح"],[64865,1,"سمج"],[[64866,64867],1,"سمم"],[[64868,64869],1,"صحح"],[64870,1,"صمم"],[[64871,64872],1,"شحم"],[64873,1,"شجي"],[[64874,64875],1,"شمخ"],[[64876,64877],1,"شمم"],[64878,1,"ضحى"],[[64879,64880],1,"ضخم"],[[64881,64882],1,"طمح"],[64883,1,"طمم"],[64884,1,"طمي"],[64885,1,"عجم"],[[64886,64887],1,"عمم"],[64888,1,"عمى"],[64889,1,"غمم"],[64890,1,"غمي"],[64891,1,"غمى"],[[64892,64893],1,"فخم"],[64894,1,"قمح"],[64895,1,"قمم"],[64896,1,"لحم"],[64897,1,"لحي"],[64898,1,"لحى"],[[64899,64900],1,"لجج"],[[64901,64902],1,"لخم"],[[64903,64904],1,"لمح"],[64905,1,"محج"],[64906,1,"محم"],[64907,1,"محي"],[64908,1,"مجح"],[64909,1,"مجم"],[64910,1,"مخج"],[64911,1,"مخم"],[[64912,64913],2],[64914,1,"مجخ"],[64915,1,"همج"],[64916,1,"همم"],[64917,1,"نحم"],[64918,1,"نحى"],[[64919,64920],1,"نجم"],[64921,1,"نجى"],[64922,1,"نمي"],[64923,1,"نمى"],[[64924,64925],1,"يمم"],[64926,1,"بخي"],[64927,1,"تجي"],[64928,1,"تجى"],[64929,1,"تخي"],[64930,1,"تخى"],[64931,1,"تمي"],[64932,1,"تمى"],[64933,1,"جمي"],[64934,1,"جحى"],[64935,1,"جمى"],[64936,1,"سخى"],[64937,1,"صحي"],[64938,1,"شحي"],[64939,1,"ضحي"],[64940,1,"لجي"],[64941,1,"لمي"],[64942,1,"يحي"],[64943,1,"يجي"],[64944,1,"يمي"],[64945,1,"ممي"],[64946,1,"قمي"],[64947,1,"نحي"],[64948,1,"قمح"],[64949,1,"لحم"],[64950,1,"عمي"],[64951,1,"كمي"],[64952,1,"نجح"],[64953,1,"مخي"],[64954,1,"لجم"],[64955,1,"كمم"],[64956,1,"لجم"],[64957,1,"نجح"],[64958,1,"جحي"],[64959,1,"حجي"],[64960,1,"مجي"],[64961,1,"فمي"],[64962,1,"بحي"],[64963,1,"كمم"],[64964,1,"عجم"],[64965,1,"صمم"],[64966,1,"سخي"],[64967,1,"نجي"],[[64968,64974],2],[64975,2],[[64976,65007],3],[65008,1,"صلے"],[65009,1,"قلے"],[65010,1,"الله"],[65011,1,"اكبر"],[65012,1,"محمد"],[65013,1,"صلعم"],[65014,1,"رسول"],[65015,1,"عليه"],[65016,1,"وسلم"],[65017,1,"صلى"],[65018,1,"صلى الله عليه وسلم"],[65019,1,"جل جلاله"],[65020,1,"ریال"],[65021,2],[[65022,65023],2],[[65024,65039],7],[65040,1,","],[65041,1,"、"],[65042,3],[65043,1,":"],[65044,1,";"],[65045,1,"!"],[65046,1,"?"],[65047,1,"〖"],[65048,1,"〗"],[65049,3],[[65050,65055],3],[[65056,65059],2],[[65060,65062],2],[[65063,65069],2],[[65070,65071],2],[65072,3],[65073,1,"—"],[65074,1,"–"],[[65075,65076],1,"_"],[65077,1,"("],[65078,1,")"],[65079,1,"{"],[65080,1,"}"],[65081,1,"〔"],[65082,1,"〕"],[65083,1,"【"],[65084,1,"】"],[65085,1,"《"],[65086,1,"》"],[65087,1,"〈"],[65088,1,"〉"],[65089,1,"「"],[65090,1,"」"],[65091,1,"『"],[65092,1,"』"],[[65093,65094],2],[65095,1,"["],[65096,1,"]"],[[65097,65100],1," ̅"],[[65101,65103],1,"_"],[65104,1,","],[65105,1,"、"],[65106,3],[65107,3],[65108,1,";"],[65109,1,":"],[65110,1,"?"],[65111,1,"!"],[65112,1,"—"],[65113,1,"("],[65114,1,")"],[65115,1,"{"],[65116,1,"}"],[65117,1,"〔"],[65118,1,"〕"],[65119,1,"#"],[65120,1,"&"],[65121,1,"*"],[65122,1,"+"],[65123,1,"-"],[65124,1,"<"],[65125,1,">"],[65126,1,"="],[65127,3],[65128,1,"\\"],[65129,1,"$"],[65130,1,"%"],[65131,1,"@"],[[65132,65135],3],[65136,1," ً"],[65137,1,"ـً"],[65138,1," ٌ"],[65139,2],[65140,1," ٍ"],[65141,3],[65142,1," َ"],[65143,1,"ـَ"],[65144,1," ُ"],[65145,1,"ـُ"],[65146,1," ِ"],[65147,1,"ـِ"],[65148,1," ّ"],[65149,1,"ـّ"],[65150,1," ْ"],[65151,1,"ـْ"],[65152,1,"ء"],[[65153,65154],1,"آ"],[[65155,65156],1,"أ"],[[65157,65158],1,"ؤ"],[[65159,65160],1,"إ"],[[65161,65164],1,"ئ"],[[65165,65166],1,"ا"],[[65167,65170],1,"ب"],[[65171,65172],1,"ة"],[[65173,65176],1,"ت"],[[65177,65180],1,"ث"],[[65181,65184],1,"ج"],[[65185,65188],1,"ح"],[[65189,65192],1,"خ"],[[65193,65194],1,"د"],[[65195,65196],1,"ذ"],[[65197,65198],1,"ر"],[[65199,65200],1,"ز"],[[65201,65204],1,"س"],[[65205,65208],1,"ش"],[[65209,65212],1,"ص"],[[65213,65216],1,"ض"],[[65217,65220],1,"ط"],[[65221,65224],1,"ظ"],[[65225,65228],1,"ع"],[[65229,65232],1,"غ"],[[65233,65236],1,"ف"],[[65237,65240],1,"ق"],[[65241,65244],1,"ك"],[[65245,65248],1,"ل"],[[65249,65252],1,"م"],[[65253,65256],1,"ن"],[[65257,65260],1,"ه"],[[65261,65262],1,"و"],[[65263,65264],1,"ى"],[[65265,65268],1,"ي"],[[65269,65270],1,"لآ"],[[65271,65272],1,"لأ"],[[65273,65274],1,"لإ"],[[65275,65276],1,"لا"],[[65277,65278],3],[65279,7],[65280,3],[65281,1,"!"],[65282,1,"\""],[65283,1,"#"],[65284,1,"$"],[65285,1,"%"],[65286,1,"&"],[65287,1,"'"],[65288,1,"("],[65289,1,")"],[65290,1,"*"],[65291,1,"+"],[65292,1,","],[65293,1,"-"],[65294,1,"."],[65295,1,"/"],[65296,1,"0"],[65297,1,"1"],[65298,1,"2"],[65299,1,"3"],[65300,1,"4"],[65301,1,"5"],[65302,1,"6"],[65303,1,"7"],[65304,1,"8"],[65305,1,"9"],[65306,1,":"],[65307,1,";"],[65308,1,"<"],[65309,1,"="],[65310,1,">"],[65311,1,"?"],[65312,1,"@"],[65313,1,"a"],[65314,1,"b"],[65315,1,"c"],[65316,1,"d"],[65317,1,"e"],[65318,1,"f"],[65319,1,"g"],[65320,1,"h"],[65321,1,"i"],[65322,1,"j"],[65323,1,"k"],[65324,1,"l"],[65325,1,"m"],[65326,1,"n"],[65327,1,"o"],[65328,1,"p"],[65329,1,"q"],[65330,1,"r"],[65331,1,"s"],[65332,1,"t"],[65333,1,"u"],[65334,1,"v"],[65335,1,"w"],[65336,1,"x"],[65337,1,"y"],[65338,1,"z"],[65339,1,"["],[65340,1,"\\"],[65341,1,"]"],[65342,1,"^"],[65343,1,"_"],[65344,1,"`"],[65345,1,"a"],[65346,1,"b"],[65347,1,"c"],[65348,1,"d"],[65349,1,"e"],[65350,1,"f"],[65351,1,"g"],[65352,1,"h"],[65353,1,"i"],[65354,1,"j"],[65355,1,"k"],[65356,1,"l"],[65357,1,"m"],[65358,1,"n"],[65359,1,"o"],[65360,1,"p"],[65361,1,"q"],[65362,1,"r"],[65363,1,"s"],[65364,1,"t"],[65365,1,"u"],[65366,1,"v"],[65367,1,"w"],[65368,1,"x"],[65369,1,"y"],[65370,1,"z"],[65371,1,"{"],[65372,1,"|"],[65373,1,"}"],[65374,1,"~"],[65375,1,"⦅"],[65376,1,"⦆"],[65377,1,"."],[65378,1,"「"],[65379,1,"」"],[65380,1,"、"],[65381,1,"・"],[65382,1,"ヲ"],[65383,1,"ァ"],[65384,1,"ィ"],[65385,1,"ゥ"],[65386,1,"ェ"],[65387,1,"ォ"],[65388,1,"ャ"],[65389,1,"ュ"],[65390,1,"ョ"],[65391,1,"ッ"],[65392,1,"ー"],[65393,1,"ア"],[65394,1,"イ"],[65395,1,"ウ"],[65396,1,"エ"],[65397,1,"オ"],[65398,1,"カ"],[65399,1,"キ"],[65400,1,"ク"],[65401,1,"ケ"],[65402,1,"コ"],[65403,1,"サ"],[65404,1,"シ"],[65405,1,"ス"],[65406,1,"セ"],[65407,1,"ソ"],[65408,1,"タ"],[65409,1,"チ"],[65410,1,"ツ"],[65411,1,"テ"],[65412,1,"ト"],[65413,1,"ナ"],[65414,1,"ニ"],[65415,1,"ヌ"],[65416,1,"ネ"],[65417,1,"ノ"],[65418,1,"ハ"],[65419,1,"ヒ"],[65420,1,"フ"],[65421,1,"ヘ"],[65422,1,"ホ"],[65423,1,"マ"],[65424,1,"ミ"],[65425,1,"ム"],[65426,1,"メ"],[65427,1,"モ"],[65428,1,"ヤ"],[65429,1,"ユ"],[65430,1,"ヨ"],[65431,1,"ラ"],[65432,1,"リ"],[65433,1,"ル"],[65434,1,"レ"],[65435,1,"ロ"],[65436,1,"ワ"],[65437,1,"ン"],[65438,1,"゙"],[65439,1,"゚"],[65440,7],[65441,1,"ᄀ"],[65442,1,"ᄁ"],[65443,1,"ᆪ"],[65444,1,"ᄂ"],[65445,1,"ᆬ"],[65446,1,"ᆭ"],[65447,1,"ᄃ"],[65448,1,"ᄄ"],[65449,1,"ᄅ"],[65450,1,"ᆰ"],[65451,1,"ᆱ"],[65452,1,"ᆲ"],[65453,1,"ᆳ"],[65454,1,"ᆴ"],[65455,1,"ᆵ"],[65456,1,"ᄚ"],[65457,1,"ᄆ"],[65458,1,"ᄇ"],[65459,1,"ᄈ"],[65460,1,"ᄡ"],[65461,1,"ᄉ"],[65462,1,"ᄊ"],[65463,1,"ᄋ"],[65464,1,"ᄌ"],[65465,1,"ᄍ"],[65466,1,"ᄎ"],[65467,1,"ᄏ"],[65468,1,"ᄐ"],[65469,1,"ᄑ"],[65470,1,"ᄒ"],[[65471,65473],3],[65474,1,"ᅡ"],[65475,1,"ᅢ"],[65476,1,"ᅣ"],[65477,1,"ᅤ"],[65478,1,"ᅥ"],[65479,1,"ᅦ"],[[65480,65481],3],[65482,1,"ᅧ"],[65483,1,"ᅨ"],[65484,1,"ᅩ"],[65485,1,"ᅪ"],[65486,1,"ᅫ"],[65487,1,"ᅬ"],[[65488,65489],3],[65490,1,"ᅭ"],[65491,1,"ᅮ"],[65492,1,"ᅯ"],[65493,1,"ᅰ"],[65494,1,"ᅱ"],[65495,1,"ᅲ"],[[65496,65497],3],[65498,1,"ᅳ"],[65499,1,"ᅴ"],[65500,1,"ᅵ"],[[65501,65503],3],[65504,1,"¢"],[65505,1,"£"],[65506,1,"¬"],[65507,1," ̄"],[65508,1,"¦"],[65509,1,"¥"],[65510,1,"₩"],[65511,3],[65512,1,"│"],[65513,1,"←"],[65514,1,"↑"],[65515,1,"→"],[65516,1,"↓"],[65517,1,"■"],[65518,1,"○"],[[65519,65528],3],[[65529,65531],3],[65532,3],[65533,3],[[65534,65535],3],[[65536,65547],2],[65548,3],[[65549,65574],2],[65575,3],[[65576,65594],2],[65595,3],[[65596,65597],2],[65598,3],[[65599,65613],2],[[65614,65615],3],[[65616,65629],2],[[65630,65663],3],[[65664,65786],2],[[65787,65791],3],[[65792,65794],2],[[65795,65798],3],[[65799,65843],2],[[65844,65846],3],[[65847,65855],2],[[65856,65930],2],[[65931,65932],2],[[65933,65934],2],[65935,3],[[65936,65947],2],[65948,2],[[65949,65951],3],[65952,2],[[65953,65999],3],[[66000,66044],2],[66045,2],[[66046,66175],3],[[66176,66204],2],[[66205,66207],3],[[66208,66256],2],[[66257,66271],3],[66272,2],[[66273,66299],2],[[66300,66303],3],[[66304,66334],2],[66335,2],[[66336,66339],2],[[66340,66348],3],[[66349,66351],2],[[66352,66368],2],[66369,2],[[66370,66377],2],[66378,2],[[66379,66383],3],[[66384,66426],2],[[66427,66431],3],[[66432,66461],2],[66462,3],[66463,2],[[66464,66499],2],[[66500,66503],3],[[66504,66511],2],[[66512,66517],2],[[66518,66559],3],[66560,1,"𐐨"],[66561,1,"𐐩"],[66562,1,"𐐪"],[66563,1,"𐐫"],[66564,1,"𐐬"],[66565,1,"𐐭"],[66566,1,"𐐮"],[66567,1,"𐐯"],[66568,1,"𐐰"],[66569,1,"𐐱"],[66570,1,"𐐲"],[66571,1,"𐐳"],[66572,1,"𐐴"],[66573,1,"𐐵"],[66574,1,"𐐶"],[66575,1,"𐐷"],[66576,1,"𐐸"],[66577,1,"𐐹"],[66578,1,"𐐺"],[66579,1,"𐐻"],[66580,1,"𐐼"],[66581,1,"𐐽"],[66582,1,"𐐾"],[66583,1,"𐐿"],[66584,1,"𐑀"],[66585,1,"𐑁"],[66586,1,"𐑂"],[66587,1,"𐑃"],[66588,1,"𐑄"],[66589,1,"𐑅"],[66590,1,"𐑆"],[66591,1,"𐑇"],[66592,1,"𐑈"],[66593,1,"𐑉"],[66594,1,"𐑊"],[66595,1,"𐑋"],[66596,1,"𐑌"],[66597,1,"𐑍"],[66598,1,"𐑎"],[66599,1,"𐑏"],[[66600,66637],2],[[66638,66717],2],[[66718,66719],3],[[66720,66729],2],[[66730,66735],3],[66736,1,"𐓘"],[66737,1,"𐓙"],[66738,1,"𐓚"],[66739,1,"𐓛"],[66740,1,"𐓜"],[66741,1,"𐓝"],[66742,1,"𐓞"],[66743,1,"𐓟"],[66744,1,"𐓠"],[66745,1,"𐓡"],[66746,1,"𐓢"],[66747,1,"𐓣"],[66748,1,"𐓤"],[66749,1,"𐓥"],[66750,1,"𐓦"],[66751,1,"𐓧"],[66752,1,"𐓨"],[66753,1,"𐓩"],[66754,1,"𐓪"],[66755,1,"𐓫"],[66756,1,"𐓬"],[66757,1,"𐓭"],[66758,1,"𐓮"],[66759,1,"𐓯"],[66760,1,"𐓰"],[66761,1,"𐓱"],[66762,1,"𐓲"],[66763,1,"𐓳"],[66764,1,"𐓴"],[66765,1,"𐓵"],[66766,1,"𐓶"],[66767,1,"𐓷"],[66768,1,"𐓸"],[66769,1,"𐓹"],[66770,1,"𐓺"],[66771,1,"𐓻"],[[66772,66775],3],[[66776,66811],2],[[66812,66815],3],[[66816,66855],2],[[66856,66863],3],[[66864,66915],2],[[66916,66926],3],[66927,2],[66928,1,"𐖗"],[66929,1,"𐖘"],[66930,1,"𐖙"],[66931,1,"𐖚"],[66932,1,"𐖛"],[66933,1,"𐖜"],[66934,1,"𐖝"],[66935,1,"𐖞"],[66936,1,"𐖟"],[66937,1,"𐖠"],[66938,1,"𐖡"],[66939,3],[66940,1,"𐖣"],[66941,1,"𐖤"],[66942,1,"𐖥"],[66943,1,"𐖦"],[66944,1,"𐖧"],[66945,1,"𐖨"],[66946,1,"𐖩"],[66947,1,"𐖪"],[66948,1,"𐖫"],[66949,1,"𐖬"],[66950,1,"𐖭"],[66951,1,"𐖮"],[66952,1,"𐖯"],[66953,1,"𐖰"],[66954,1,"𐖱"],[66955,3],[66956,1,"𐖳"],[66957,1,"𐖴"],[66958,1,"𐖵"],[66959,1,"𐖶"],[66960,1,"𐖷"],[66961,1,"𐖸"],[66962,1,"𐖹"],[66963,3],[66964,1,"𐖻"],[66965,1,"𐖼"],[66966,3],[[66967,66977],2],[66978,3],[[66979,66993],2],[66994,3],[[66995,67001],2],[67002,3],[[67003,67004],2],[[67005,67007],3],[[67008,67059],2],[[67060,67071],3],[[67072,67382],2],[[67383,67391],3],[[67392,67413],2],[[67414,67423],3],[[67424,67431],2],[[67432,67455],3],[67456,2],[67457,1,"ː"],[67458,1,"ˑ"],[67459,1,"æ"],[67460,1,"ʙ"],[67461,1,"ɓ"],[67462,3],[67463,1,"ʣ"],[67464,1,"ꭦ"],[67465,1,"ʥ"],[67466,1,"ʤ"],[67467,1,"ɖ"],[67468,1,"ɗ"],[67469,1,"ᶑ"],[67470,1,"ɘ"],[67471,1,"ɞ"],[67472,1,"ʩ"],[67473,1,"ɤ"],[67474,1,"ɢ"],[67475,1,"ɠ"],[67476,1,"ʛ"],[67477,1,"ħ"],[67478,1,"ʜ"],[67479,1,"ɧ"],[67480,1,"ʄ"],[67481,1,"ʪ"],[67482,1,"ʫ"],[67483,1,"ɬ"],[67484,1,"𝼄"],[67485,1,"ꞎ"],[67486,1,"ɮ"],[67487,1,"𝼅"],[67488,1,"ʎ"],[67489,1,"𝼆"],[67490,1,"ø"],[67491,1,"ɶ"],[67492,1,"ɷ"],[67493,1,"q"],[67494,1,"ɺ"],[67495,1,"𝼈"],[67496,1,"ɽ"],[67497,1,"ɾ"],[67498,1,"ʀ"],[67499,1,"ʨ"],[67500,1,"ʦ"],[67501,1,"ꭧ"],[67502,1,"ʧ"],[67503,1,"ʈ"],[67504,1,"ⱱ"],[67505,3],[67506,1,"ʏ"],[67507,1,"ʡ"],[67508,1,"ʢ"],[67509,1,"ʘ"],[67510,1,"ǀ"],[67511,1,"ǁ"],[67512,1,"ǂ"],[67513,1,"𝼊"],[67514,1,"𝼞"],[[67515,67583],3],[[67584,67589],2],[[67590,67591],3],[67592,2],[67593,3],[[67594,67637],2],[67638,3],[[67639,67640],2],[[67641,67643],3],[67644,2],[[67645,67646],3],[67647,2],[[67648,67669],2],[67670,3],[[67671,67679],2],[[67680,67702],2],[[67703,67711],2],[[67712,67742],2],[[67743,67750],3],[[67751,67759],2],[[67760,67807],3],[[67808,67826],2],[67827,3],[[67828,67829],2],[[67830,67834],3],[[67835,67839],2],[[67840,67861],2],[[67862,67865],2],[[67866,67867],2],[[67868,67870],3],[67871,2],[[67872,67897],2],[[67898,67902],3],[67903,2],[[67904,67929],2],[[67930,67967],3],[[67968,68023],2],[[68024,68027],3],[[68028,68029],2],[[68030,68031],2],[[68032,68047],2],[[68048,68049],3],[[68050,68095],2],[[68096,68099],2],[68100,3],[[68101,68102],2],[[68103,68107],3],[[68108,68115],2],[68116,3],[[68117,68119],2],[68120,3],[[68121,68147],2],[[68148,68149],2],[[68150,68151],3],[[68152,68154],2],[[68155,68158],3],[68159,2],[[68160,68167],2],[68168,2],[[68169,68175],3],[[68176,68184],2],[[68185,68191],3],[[68192,68220],2],[[68221,68223],2],[[68224,68252],2],[[68253,68255],2],[[68256,68287],3],[[68288,68295],2],[68296,2],[[68297,68326],2],[[68327,68330],3],[[68331,68342],2],[[68343,68351],3],[[68352,68405],2],[[68406,68408],3],[[68409,68415],2],[[68416,68437],2],[[68438,68439],3],[[68440,68447],2],[[68448,68466],2],[[68467,68471],3],[[68472,68479],2],[[68480,68497],2],[[68498,68504],3],[[68505,68508],2],[[68509,68520],3],[[68521,68527],2],[[68528,68607],3],[[68608,68680],2],[[68681,68735],3],[68736,1,"𐳀"],[68737,1,"𐳁"],[68738,1,"𐳂"],[68739,1,"𐳃"],[68740,1,"𐳄"],[68741,1,"𐳅"],[68742,1,"𐳆"],[68743,1,"𐳇"],[68744,1,"𐳈"],[68745,1,"𐳉"],[68746,1,"𐳊"],[68747,1,"𐳋"],[68748,1,"𐳌"],[68749,1,"𐳍"],[68750,1,"𐳎"],[68751,1,"𐳏"],[68752,1,"𐳐"],[68753,1,"𐳑"],[68754,1,"𐳒"],[68755,1,"𐳓"],[68756,1,"𐳔"],[68757,1,"𐳕"],[68758,1,"𐳖"],[68759,1,"𐳗"],[68760,1,"𐳘"],[68761,1,"𐳙"],[68762,1,"𐳚"],[68763,1,"𐳛"],[68764,1,"𐳜"],[68765,1,"𐳝"],[68766,1,"𐳞"],[68767,1,"𐳟"],[68768,1,"𐳠"],[68769,1,"𐳡"],[68770,1,"𐳢"],[68771,1,"𐳣"],[68772,1,"𐳤"],[68773,1,"𐳥"],[68774,1,"𐳦"],[68775,1,"𐳧"],[68776,1,"𐳨"],[68777,1,"𐳩"],[68778,1,"𐳪"],[68779,1,"𐳫"],[68780,1,"𐳬"],[68781,1,"𐳭"],[68782,1,"𐳮"],[68783,1,"𐳯"],[68784,1,"𐳰"],[68785,1,"𐳱"],[68786,1,"𐳲"],[[68787,68799],3],[[68800,68850],2],[[68851,68857],3],[[68858,68863],2],[[68864,68903],2],[[68904,68911],3],[[68912,68921],2],[[68922,68927],3],[[68928,68943],2],[68944,1,"𐵰"],[68945,1,"𐵱"],[68946,1,"𐵲"],[68947,1,"𐵳"],[68948,1,"𐵴"],[68949,1,"𐵵"],[68950,1,"𐵶"],[68951,1,"𐵷"],[68952,1,"𐵸"],[68953,1,"𐵹"],[68954,1,"𐵺"],[68955,1,"𐵻"],[68956,1,"𐵼"],[68957,1,"𐵽"],[68958,1,"𐵾"],[68959,1,"𐵿"],[68960,1,"𐶀"],[68961,1,"𐶁"],[68962,1,"𐶂"],[68963,1,"𐶃"],[68964,1,"𐶄"],[68965,1,"𐶅"],[[68966,68968],3],[[68969,68973],2],[68974,2],[[68975,68997],2],[[68998,69005],3],[[69006,69007],2],[[69008,69215],3],[[69216,69246],2],[69247,3],[[69248,69289],2],[69290,3],[[69291,69292],2],[69293,2],[[69294,69295],3],[[69296,69297],2],[[69298,69313],3],[[69314,69316],2],[[69317,69319],2],[[69320,69327],3],[[69328,69336],2],[[69337,69369],3],[[69370,69371],2],[69372,2],[[69373,69375],2],[[69376,69404],2],[[69405,69414],2],[69415,2],[[69416,69423],3],[[69424,69456],2],[[69457,69465],2],[[69466,69487],3],[[69488,69509],2],[[69510,69513],2],[[69514,69551],3],[[69552,69572],2],[[69573,69579],2],[[69580,69599],3],[[69600,69622],2],[[69623,69631],3],[[69632,69702],2],[[69703,69709],2],[[69710,69713],3],[[69714,69733],2],[[69734,69743],2],[[69744,69749],2],[[69750,69758],3],[69759,2],[[69760,69818],2],[[69819,69820],2],[69821,3],[[69822,69825],2],[69826,2],[[69827,69836],3],[69837,3],[[69838,69839],3],[[69840,69864],2],[[69865,69871],3],[[69872,69881],2],[[69882,69887],3],[[69888,69940],2],[69941,3],[[69942,69951],2],[[69952,69955],2],[[69956,69958],2],[69959,2],[[69960,69967],3],[[69968,70003],2],[[70004,70005],2],[70006,2],[[70007,70015],3],[[70016,70084],2],[[70085,70088],2],[[70089,70092],2],[70093,2],[[70094,70095],2],[[70096,70105],2],[70106,2],[70107,2],[70108,2],[[70109,70111],2],[70112,3],[[70113,70132],2],[[70133,70143],3],[[70144,70161],2],[70162,3],[[70163,70199],2],[[70200,70205],2],[70206,2],[[70207,70209],2],[[70210,70271],3],[[70272,70278],2],[70279,3],[70280,2],[70281,3],[[70282,70285],2],[70286,3],[[70287,70301],2],[70302,3],[[70303,70312],2],[70313,2],[[70314,70319],3],[[70320,70378],2],[[70379,70383],3],[[70384,70393],2],[[70394,70399],3],[70400,2],[[70401,70403],2],[70404,3],[[70405,70412],2],[[70413,70414],3],[[70415,70416],2],[[70417,70418],3],[[70419,70440],2],[70441,3],[[70442,70448],2],[70449,3],[[70450,70451],2],[70452,3],[[70453,70457],2],[70458,3],[70459,2],[[70460,70468],2],[[70469,70470],3],[[70471,70472],2],[[70473,70474],3],[[70475,70477],2],[[70478,70479],3],[70480,2],[[70481,70486],3],[70487,2],[[70488,70492],3],[[70493,70499],2],[[70500,70501],3],[[70502,70508],2],[[70509,70511],3],[[70512,70516],2],[[70517,70527],3],[[70528,70537],2],[70538,3],[70539,2],[[70540,70541],3],[70542,2],[70543,3],[[70544,70581],2],[70582,3],[[70583,70592],2],[70593,3],[70594,2],[[70595,70596],3],[70597,2],[70598,3],[[70599,70602],2],[70603,3],[[70604,70611],2],[[70612,70613],2],[70614,3],[[70615,70616],2],[[70617,70624],3],[[70625,70626],2],[[70627,70655],3],[[70656,70730],2],[[70731,70735],2],[[70736,70745],2],[70746,2],[70747,2],[70748,3],[70749,2],[70750,2],[70751,2],[[70752,70753],2],[[70754,70783],3],[[70784,70853],2],[70854,2],[70855,2],[[70856,70863],3],[[70864,70873],2],[[70874,71039],3],[[71040,71093],2],[[71094,71095],3],[[71096,71104],2],[[71105,71113],2],[[71114,71127],2],[[71128,71133],2],[[71134,71167],3],[[71168,71232],2],[[71233,71235],2],[71236,2],[[71237,71247],3],[[71248,71257],2],[[71258,71263],3],[[71264,71276],2],[[71277,71295],3],[[71296,71351],2],[71352,2],[71353,2],[[71354,71359],3],[[71360,71369],2],[[71370,71375],3],[[71376,71395],2],[[71396,71423],3],[[71424,71449],2],[71450,2],[[71451,71452],3],[[71453,71467],2],[[71468,71471],3],[[71472,71481],2],[[71482,71487],2],[[71488,71494],2],[[71495,71679],3],[[71680,71738],2],[71739,2],[[71740,71839],3],[71840,1,"𑣀"],[71841,1,"𑣁"],[71842,1,"𑣂"],[71843,1,"𑣃"],[71844,1,"𑣄"],[71845,1,"𑣅"],[71846,1,"𑣆"],[71847,1,"𑣇"],[71848,1,"𑣈"],[71849,1,"𑣉"],[71850,1,"𑣊"],[71851,1,"𑣋"],[71852,1,"𑣌"],[71853,1,"𑣍"],[71854,1,"𑣎"],[71855,1,"𑣏"],[71856,1,"𑣐"],[71857,1,"𑣑"],[71858,1,"𑣒"],[71859,1,"𑣓"],[71860,1,"𑣔"],[71861,1,"𑣕"],[71862,1,"𑣖"],[71863,1,"𑣗"],[71864,1,"𑣘"],[71865,1,"𑣙"],[71866,1,"𑣚"],[71867,1,"𑣛"],[71868,1,"𑣜"],[71869,1,"𑣝"],[71870,1,"𑣞"],[71871,1,"𑣟"],[[71872,71913],2],[[71914,71922],2],[[71923,71934],3],[71935,2],[[71936,71942],2],[[71943,71944],3],[71945,2],[[71946,71947],3],[[71948,71955],2],[71956,3],[[71957,71958],2],[71959,3],[[71960,71989],2],[71990,3],[[71991,71992],2],[[71993,71994],3],[[71995,72003],2],[[72004,72006],2],[[72007,72015],3],[[72016,72025],2],[[72026,72095],3],[[72096,72103],2],[[72104,72105],3],[[72106,72151],2],[[72152,72153],3],[[72154,72161],2],[72162,2],[[72163,72164],2],[[72165,72191],3],[[72192,72254],2],[[72255,72262],2],[72263,2],[[72264,72271],3],[[72272,72323],2],[[72324,72325],2],[[72326,72345],2],[[72346,72348],2],[72349,2],[[72350,72354],2],[[72355,72367],3],[[72368,72383],2],[[72384,72440],2],[[72441,72447],3],[[72448,72457],2],[[72458,72543],3],[[72544,72551],2],[[72552,72639],3],[[72640,72672],2],[72673,2],[[72674,72687],3],[[72688,72697],2],[[72698,72703],3],[[72704,72712],2],[72713,3],[[72714,72758],2],[72759,3],[[72760,72768],2],[[72769,72773],2],[[72774,72783],3],[[72784,72793],2],[[72794,72812],2],[[72813,72815],3],[[72816,72817],2],[[72818,72847],2],[[72848,72849],3],[[72850,72871],2],[72872,3],[[72873,72886],2],[[72887,72959],3],[[72960,72966],2],[72967,3],[[72968,72969],2],[72970,3],[[72971,73014],2],[[73015,73017],3],[73018,2],[73019,3],[[73020,73021],2],[73022,3],[[73023,73031],2],[[73032,73039],3],[[73040,73049],2],[[73050,73055],3],[[73056,73061],2],[73062,3],[[73063,73064],2],[73065,3],[[73066,73102],2],[73103,3],[[73104,73105],2],[73106,3],[[73107,73112],2],[[73113,73119],3],[[73120,73129],2],[[73130,73135],3],[[73136,73179],2],[[73180,73183],3],[[73184,73193],2],[[73194,73439],3],[[73440,73462],2],[[73463,73464],2],[[73465,73471],3],[[73472,73488],2],[73489,3],[[73490,73530],2],[[73531,73533],3],[[73534,73538],2],[[73539,73551],2],[[73552,73561],2],[73562,2],[[73563,73647],3],[73648,2],[[73649,73663],3],[[73664,73713],2],[[73714,73726],3],[73727,2],[[73728,74606],2],[[74607,74648],2],[74649,2],[[74650,74751],3],[[74752,74850],2],[[74851,74862],2],[74863,3],[[74864,74867],2],[74868,2],[[74869,74879],3],[[74880,75075],2],[[75076,77711],3],[[77712,77808],2],[[77809,77810],2],[[77811,77823],3],[[77824,78894],2],[78895,2],[[78896,78904],3],[[78905,78911],3],[[78912,78933],2],[[78934,78943],3],[[78944,82938],2],[[82939,82943],3],[[82944,83526],2],[[83527,90367],3],[[90368,90425],2],[[90426,92159],3],[[92160,92728],2],[[92729,92735],3],[[92736,92766],2],[92767,3],[[92768,92777],2],[[92778,92781],3],[[92782,92783],2],[[92784,92862],2],[92863,3],[[92864,92873],2],[[92874,92879],3],[[92880,92909],2],[[92910,92911],3],[[92912,92916],2],[92917,2],[[92918,92927],3],[[92928,92982],2],[[92983,92991],2],[[92992,92995],2],[[92996,92997],2],[[92998,93007],3],[[93008,93017],2],[93018,3],[[93019,93025],2],[93026,3],[[93027,93047],2],[[93048,93052],3],[[93053,93071],2],[[93072,93503],3],[[93504,93548],2],[[93549,93551],2],[[93552,93561],2],[[93562,93759],3],[93760,1,"𖹠"],[93761,1,"𖹡"],[93762,1,"𖹢"],[93763,1,"𖹣"],[93764,1,"𖹤"],[93765,1,"𖹥"],[93766,1,"𖹦"],[93767,1,"𖹧"],[93768,1,"𖹨"],[93769,1,"𖹩"],[93770,1,"𖹪"],[93771,1,"𖹫"],[93772,1,"𖹬"],[93773,1,"𖹭"],[93774,1,"𖹮"],[93775,1,"𖹯"],[93776,1,"𖹰"],[93777,1,"𖹱"],[93778,1,"𖹲"],[93779,1,"𖹳"],[93780,1,"𖹴"],[93781,1,"𖹵"],[93782,1,"𖹶"],[93783,1,"𖹷"],[93784,1,"𖹸"],[93785,1,"𖹹"],[93786,1,"𖹺"],[93787,1,"𖹻"],[93788,1,"𖹼"],[93789,1,"𖹽"],[93790,1,"𖹾"],[93791,1,"𖹿"],[[93792,93823],2],[[93824,93850],2],[[93851,93855],3],[93856,1,"𖺻"],[93857,1,"𖺼"],[93858,1,"𖺽"],[93859,1,"𖺾"],[93860,1,"𖺿"],[93861,1,"𖻀"],[93862,1,"𖻁"],[93863,1,"𖻂"],[93864,1,"𖻃"],[93865,1,"𖻄"],[93866,1,"𖻅"],[93867,1,"𖻆"],[93868,1,"𖻇"],[93869,1,"𖻈"],[93870,1,"𖻉"],[93871,1,"𖻊"],[93872,1,"𖻋"],[93873,1,"𖻌"],[93874,1,"𖻍"],[93875,1,"𖻎"],[93876,1,"𖻏"],[93877,1,"𖻐"],[93878,1,"𖻑"],[93879,1,"𖻒"],[93880,1,"𖻓"],[[93881,93882],3],[[93883,93907],2],[[93908,93951],3],[[93952,94020],2],[[94021,94026],2],[[94027,94030],3],[94031,2],[[94032,94078],2],[[94079,94087],2],[[94088,94094],3],[[94095,94111],2],[[94112,94175],3],[94176,2],[94177,2],[94178,2],[94179,2],[94180,2],[[94181,94191],3],[[94192,94193],2],[[94194,94195],2],[[94196,94198],2],[[94199,94207],3],[[94208,100332],2],[[100333,100337],2],[[100338,100343],2],[[100344,100351],2],[[100352,101106],2],[[101107,101589],2],[[101590,101630],3],[101631,2],[[101632,101640],2],[[101641,101662],2],[[101663,101759],3],[[101760,101874],2],[[101875,110575],3],[[110576,110579],2],[110580,3],[[110581,110587],2],[110588,3],[[110589,110590],2],[110591,3],[[110592,110593],2],[[110594,110878],2],[[110879,110882],2],[[110883,110897],3],[110898,2],[[110899,110927],3],[[110928,110930],2],[[110931,110932],3],[110933,2],[[110934,110947],3],[[110948,110951],2],[[110952,110959],3],[[110960,111355],2],[[111356,113663],3],[[113664,113770],2],[[113771,113775],3],[[113776,113788],2],[[113789,113791],3],[[113792,113800],2],[[113801,113807],3],[[113808,113817],2],[[113818,113819],3],[113820,2],[[113821,113822],2],[113823,2],[[113824,113827],7],[[113828,117759],3],[[117760,117973],2],[117974,1,"a"],[117975,1,"b"],[117976,1,"c"],[117977,1,"d"],[117978,1,"e"],[117979,1,"f"],[117980,1,"g"],[117981,1,"h"],[117982,1,"i"],[117983,1,"j"],[117984,1,"k"],[117985,1,"l"],[117986,1,"m"],[117987,1,"n"],[117988,1,"o"],[117989,1,"p"],[117990,1,"q"],[117991,1,"r"],[117992,1,"s"],[117993,1,"t"],[117994,1,"u"],[117995,1,"v"],[117996,1,"w"],[117997,1,"x"],[117998,1,"y"],[117999,1,"z"],[118000,1,"0"],[118001,1,"1"],[118002,1,"2"],[118003,1,"3"],[118004,1,"4"],[118005,1,"5"],[118006,1,"6"],[118007,1,"7"],[118008,1,"8"],[118009,1,"9"],[[118010,118012],2],[[118013,118015],3],[[118016,118451],2],[[118452,118457],3],[[118458,118480],2],[[118481,118495],3],[[118496,118512],2],[[118513,118527],3],[[118528,118573],2],[[118574,118575],3],[[118576,118598],2],[[118599,118607],3],[[118608,118723],2],[[118724,118783],3],[[118784,119029],2],[[119030,119039],3],[[119040,119078],2],[[119079,119080],3],[119081,2],[[119082,119133],2],[119134,1,"𝅗𝅥"],[119135,1,"𝅘𝅥"],[119136,1,"𝅘𝅥𝅮"],[119137,1,"𝅘𝅥𝅯"],[119138,1,"𝅘𝅥𝅰"],[119139,1,"𝅘𝅥𝅱"],[119140,1,"𝅘𝅥𝅲"],[[119141,119154],2],[[119155,119162],7],[[119163,119226],2],[119227,1,"𝆹𝅥"],[119228,1,"𝆺𝅥"],[119229,1,"𝆹𝅥𝅮"],[119230,1,"𝆺𝅥𝅮"],[119231,1,"𝆹𝅥𝅯"],[119232,1,"𝆺𝅥𝅯"],[[119233,119261],2],[[119262,119272],2],[[119273,119274],2],[[119275,119295],3],[[119296,119365],2],[[119366,119487],3],[[119488,119507],2],[[119508,119519],3],[[119520,119539],2],[[119540,119551],3],[[119552,119638],2],[[119639,119647],3],[[119648,119665],2],[[119666,119672],2],[[119673,119807],3],[119808,1,"a"],[119809,1,"b"],[119810,1,"c"],[119811,1,"d"],[119812,1,"e"],[119813,1,"f"],[119814,1,"g"],[119815,1,"h"],[119816,1,"i"],[119817,1,"j"],[119818,1,"k"],[119819,1,"l"],[119820,1,"m"],[119821,1,"n"],[119822,1,"o"],[119823,1,"p"],[119824,1,"q"],[119825,1,"r"],[119826,1,"s"],[119827,1,"t"],[119828,1,"u"],[119829,1,"v"],[119830,1,"w"],[119831,1,"x"],[119832,1,"y"],[119833,1,"z"],[119834,1,"a"],[119835,1,"b"],[119836,1,"c"],[119837,1,"d"],[119838,1,"e"],[119839,1,"f"],[119840,1,"g"],[119841,1,"h"],[119842,1,"i"],[119843,1,"j"],[119844,1,"k"],[119845,1,"l"],[119846,1,"m"],[119847,1,"n"],[119848,1,"o"],[119849,1,"p"],[119850,1,"q"],[119851,1,"r"],[119852,1,"s"],[119853,1,"t"],[119854,1,"u"],[119855,1,"v"],[119856,1,"w"],[119857,1,"x"],[119858,1,"y"],[119859,1,"z"],[119860,1,"a"],[119861,1,"b"],[119862,1,"c"],[119863,1,"d"],[119864,1,"e"],[119865,1,"f"],[119866,1,"g"],[119867,1,"h"],[119868,1,"i"],[119869,1,"j"],[119870,1,"k"],[119871,1,"l"],[119872,1,"m"],[119873,1,"n"],[119874,1,"o"],[119875,1,"p"],[119876,1,"q"],[119877,1,"r"],[119878,1,"s"],[119879,1,"t"],[119880,1,"u"],[119881,1,"v"],[119882,1,"w"],[119883,1,"x"],[119884,1,"y"],[119885,1,"z"],[119886,1,"a"],[119887,1,"b"],[119888,1,"c"],[119889,1,"d"],[119890,1,"e"],[119891,1,"f"],[119892,1,"g"],[119893,3],[119894,1,"i"],[119895,1,"j"],[119896,1,"k"],[119897,1,"l"],[119898,1,"m"],[119899,1,"n"],[119900,1,"o"],[119901,1,"p"],[119902,1,"q"],[119903,1,"r"],[119904,1,"s"],[119905,1,"t"],[119906,1,"u"],[119907,1,"v"],[119908,1,"w"],[119909,1,"x"],[119910,1,"y"],[119911,1,"z"],[119912,1,"a"],[119913,1,"b"],[119914,1,"c"],[119915,1,"d"],[119916,1,"e"],[119917,1,"f"],[119918,1,"g"],[119919,1,"h"],[119920,1,"i"],[119921,1,"j"],[119922,1,"k"],[119923,1,"l"],[119924,1,"m"],[119925,1,"n"],[119926,1,"o"],[119927,1,"p"],[119928,1,"q"],[119929,1,"r"],[119930,1,"s"],[119931,1,"t"],[119932,1,"u"],[119933,1,"v"],[119934,1,"w"],[119935,1,"x"],[119936,1,"y"],[119937,1,"z"],[119938,1,"a"],[119939,1,"b"],[119940,1,"c"],[119941,1,"d"],[119942,1,"e"],[119943,1,"f"],[119944,1,"g"],[119945,1,"h"],[119946,1,"i"],[119947,1,"j"],[119948,1,"k"],[119949,1,"l"],[119950,1,"m"],[119951,1,"n"],[119952,1,"o"],[119953,1,"p"],[119954,1,"q"],[119955,1,"r"],[119956,1,"s"],[119957,1,"t"],[119958,1,"u"],[119959,1,"v"],[119960,1,"w"],[119961,1,"x"],[119962,1,"y"],[119963,1,"z"],[119964,1,"a"],[119965,3],[119966,1,"c"],[119967,1,"d"],[[119968,119969],3],[119970,1,"g"],[[119971,119972],3],[119973,1,"j"],[119974,1,"k"],[[119975,119976],3],[119977,1,"n"],[119978,1,"o"],[119979,1,"p"],[119980,1,"q"],[119981,3],[119982,1,"s"],[119983,1,"t"],[119984,1,"u"],[119985,1,"v"],[119986,1,"w"],[119987,1,"x"],[119988,1,"y"],[119989,1,"z"],[119990,1,"a"],[119991,1,"b"],[119992,1,"c"],[119993,1,"d"],[119994,3],[119995,1,"f"],[119996,3],[119997,1,"h"],[119998,1,"i"],[119999,1,"j"],[120000,1,"k"],[120001,1,"l"],[120002,1,"m"],[120003,1,"n"],[120004,3],[120005,1,"p"],[120006,1,"q"],[120007,1,"r"],[120008,1,"s"],[120009,1,"t"],[120010,1,"u"],[120011,1,"v"],[120012,1,"w"],[120013,1,"x"],[120014,1,"y"],[120015,1,"z"],[120016,1,"a"],[120017,1,"b"],[120018,1,"c"],[120019,1,"d"],[120020,1,"e"],[120021,1,"f"],[120022,1,"g"],[120023,1,"h"],[120024,1,"i"],[120025,1,"j"],[120026,1,"k"],[120027,1,"l"],[120028,1,"m"],[120029,1,"n"],[120030,1,"o"],[120031,1,"p"],[120032,1,"q"],[120033,1,"r"],[120034,1,"s"],[120035,1,"t"],[120036,1,"u"],[120037,1,"v"],[120038,1,"w"],[120039,1,"x"],[120040,1,"y"],[120041,1,"z"],[120042,1,"a"],[120043,1,"b"],[120044,1,"c"],[120045,1,"d"],[120046,1,"e"],[120047,1,"f"],[120048,1,"g"],[120049,1,"h"],[120050,1,"i"],[120051,1,"j"],[120052,1,"k"],[120053,1,"l"],[120054,1,"m"],[120055,1,"n"],[120056,1,"o"],[120057,1,"p"],[120058,1,"q"],[120059,1,"r"],[120060,1,"s"],[120061,1,"t"],[120062,1,"u"],[120063,1,"v"],[120064,1,"w"],[120065,1,"x"],[120066,1,"y"],[120067,1,"z"],[120068,1,"a"],[120069,1,"b"],[120070,3],[120071,1,"d"],[120072,1,"e"],[120073,1,"f"],[120074,1,"g"],[[120075,120076],3],[120077,1,"j"],[120078,1,"k"],[120079,1,"l"],[120080,1,"m"],[120081,1,"n"],[120082,1,"o"],[120083,1,"p"],[120084,1,"q"],[120085,3],[120086,1,"s"],[120087,1,"t"],[120088,1,"u"],[120089,1,"v"],[120090,1,"w"],[120091,1,"x"],[120092,1,"y"],[120093,3],[120094,1,"a"],[120095,1,"b"],[120096,1,"c"],[120097,1,"d"],[120098,1,"e"],[120099,1,"f"],[120100,1,"g"],[120101,1,"h"],[120102,1,"i"],[120103,1,"j"],[120104,1,"k"],[120105,1,"l"],[120106,1,"m"],[120107,1,"n"],[120108,1,"o"],[120109,1,"p"],[120110,1,"q"],[120111,1,"r"],[120112,1,"s"],[120113,1,"t"],[120114,1,"u"],[120115,1,"v"],[120116,1,"w"],[120117,1,"x"],[120118,1,"y"],[120119,1,"z"],[120120,1,"a"],[120121,1,"b"],[120122,3],[120123,1,"d"],[120124,1,"e"],[120125,1,"f"],[120126,1,"g"],[120127,3],[120128,1,"i"],[120129,1,"j"],[120130,1,"k"],[120131,1,"l"],[120132,1,"m"],[120133,3],[120134,1,"o"],[[120135,120137],3],[120138,1,"s"],[120139,1,"t"],[120140,1,"u"],[120141,1,"v"],[120142,1,"w"],[120143,1,"x"],[120144,1,"y"],[120145,3],[120146,1,"a"],[120147,1,"b"],[120148,1,"c"],[120149,1,"d"],[120150,1,"e"],[120151,1,"f"],[120152,1,"g"],[120153,1,"h"],[120154,1,"i"],[120155,1,"j"],[120156,1,"k"],[120157,1,"l"],[120158,1,"m"],[120159,1,"n"],[120160,1,"o"],[120161,1,"p"],[120162,1,"q"],[120163,1,"r"],[120164,1,"s"],[120165,1,"t"],[120166,1,"u"],[120167,1,"v"],[120168,1,"w"],[120169,1,"x"],[120170,1,"y"],[120171,1,"z"],[120172,1,"a"],[120173,1,"b"],[120174,1,"c"],[120175,1,"d"],[120176,1,"e"],[120177,1,"f"],[120178,1,"g"],[120179,1,"h"],[120180,1,"i"],[120181,1,"j"],[120182,1,"k"],[120183,1,"l"],[120184,1,"m"],[120185,1,"n"],[120186,1,"o"],[120187,1,"p"],[120188,1,"q"],[120189,1,"r"],[120190,1,"s"],[120191,1,"t"],[120192,1,"u"],[120193,1,"v"],[120194,1,"w"],[120195,1,"x"],[120196,1,"y"],[120197,1,"z"],[120198,1,"a"],[120199,1,"b"],[120200,1,"c"],[120201,1,"d"],[120202,1,"e"],[120203,1,"f"],[120204,1,"g"],[120205,1,"h"],[120206,1,"i"],[120207,1,"j"],[120208,1,"k"],[120209,1,"l"],[120210,1,"m"],[120211,1,"n"],[120212,1,"o"],[120213,1,"p"],[120214,1,"q"],[120215,1,"r"],[120216,1,"s"],[120217,1,"t"],[120218,1,"u"],[120219,1,"v"],[120220,1,"w"],[120221,1,"x"],[120222,1,"y"],[120223,1,"z"],[120224,1,"a"],[120225,1,"b"],[120226,1,"c"],[120227,1,"d"],[120228,1,"e"],[120229,1,"f"],[120230,1,"g"],[120231,1,"h"],[120232,1,"i"],[120233,1,"j"],[120234,1,"k"],[120235,1,"l"],[120236,1,"m"],[120237,1,"n"],[120238,1,"o"],[120239,1,"p"],[120240,1,"q"],[120241,1,"r"],[120242,1,"s"],[120243,1,"t"],[120244,1,"u"],[120245,1,"v"],[120246,1,"w"],[120247,1,"x"],[120248,1,"y"],[120249,1,"z"],[120250,1,"a"],[120251,1,"b"],[120252,1,"c"],[120253,1,"d"],[120254,1,"e"],[120255,1,"f"],[120256,1,"g"],[120257,1,"h"],[120258,1,"i"],[120259,1,"j"],[120260,1,"k"],[120261,1,"l"],[120262,1,"m"],[120263,1,"n"],[120264,1,"o"],[120265,1,"p"],[120266,1,"q"],[120267,1,"r"],[120268,1,"s"],[120269,1,"t"],[120270,1,"u"],[120271,1,"v"],[120272,1,"w"],[120273,1,"x"],[120274,1,"y"],[120275,1,"z"],[120276,1,"a"],[120277,1,"b"],[120278,1,"c"],[120279,1,"d"],[120280,1,"e"],[120281,1,"f"],[120282,1,"g"],[120283,1,"h"],[120284,1,"i"],[120285,1,"j"],[120286,1,"k"],[120287,1,"l"],[120288,1,"m"],[120289,1,"n"],[120290,1,"o"],[120291,1,"p"],[120292,1,"q"],[120293,1,"r"],[120294,1,"s"],[120295,1,"t"],[120296,1,"u"],[120297,1,"v"],[120298,1,"w"],[120299,1,"x"],[120300,1,"y"],[120301,1,"z"],[120302,1,"a"],[120303,1,"b"],[120304,1,"c"],[120305,1,"d"],[120306,1,"e"],[120307,1,"f"],[120308,1,"g"],[120309,1,"h"],[120310,1,"i"],[120311,1,"j"],[120312,1,"k"],[120313,1,"l"],[120314,1,"m"],[120315,1,"n"],[120316,1,"o"],[120317,1,"p"],[120318,1,"q"],[120319,1,"r"],[120320,1,"s"],[120321,1,"t"],[120322,1,"u"],[120323,1,"v"],[120324,1,"w"],[120325,1,"x"],[120326,1,"y"],[120327,1,"z"],[120328,1,"a"],[120329,1,"b"],[120330,1,"c"],[120331,1,"d"],[120332,1,"e"],[120333,1,"f"],[120334,1,"g"],[120335,1,"h"],[120336,1,"i"],[120337,1,"j"],[120338,1,"k"],[120339,1,"l"],[120340,1,"m"],[120341,1,"n"],[120342,1,"o"],[120343,1,"p"],[120344,1,"q"],[120345,1,"r"],[120346,1,"s"],[120347,1,"t"],[120348,1,"u"],[120349,1,"v"],[120350,1,"w"],[120351,1,"x"],[120352,1,"y"],[120353,1,"z"],[120354,1,"a"],[120355,1,"b"],[120356,1,"c"],[120357,1,"d"],[120358,1,"e"],[120359,1,"f"],[120360,1,"g"],[120361,1,"h"],[120362,1,"i"],[120363,1,"j"],[120364,1,"k"],[120365,1,"l"],[120366,1,"m"],[120367,1,"n"],[120368,1,"o"],[120369,1,"p"],[120370,1,"q"],[120371,1,"r"],[120372,1,"s"],[120373,1,"t"],[120374,1,"u"],[120375,1,"v"],[120376,1,"w"],[120377,1,"x"],[120378,1,"y"],[120379,1,"z"],[120380,1,"a"],[120381,1,"b"],[120382,1,"c"],[120383,1,"d"],[120384,1,"e"],[120385,1,"f"],[120386,1,"g"],[120387,1,"h"],[120388,1,"i"],[120389,1,"j"],[120390,1,"k"],[120391,1,"l"],[120392,1,"m"],[120393,1,"n"],[120394,1,"o"],[120395,1,"p"],[120396,1,"q"],[120397,1,"r"],[120398,1,"s"],[120399,1,"t"],[120400,1,"u"],[120401,1,"v"],[120402,1,"w"],[120403,1,"x"],[120404,1,"y"],[120405,1,"z"],[120406,1,"a"],[120407,1,"b"],[120408,1,"c"],[120409,1,"d"],[120410,1,"e"],[120411,1,"f"],[120412,1,"g"],[120413,1,"h"],[120414,1,"i"],[120415,1,"j"],[120416,1,"k"],[120417,1,"l"],[120418,1,"m"],[120419,1,"n"],[120420,1,"o"],[120421,1,"p"],[120422,1,"q"],[120423,1,"r"],[120424,1,"s"],[120425,1,"t"],[120426,1,"u"],[120427,1,"v"],[120428,1,"w"],[120429,1,"x"],[120430,1,"y"],[120431,1,"z"],[120432,1,"a"],[120433,1,"b"],[120434,1,"c"],[120435,1,"d"],[120436,1,"e"],[120437,1,"f"],[120438,1,"g"],[120439,1,"h"],[120440,1,"i"],[120441,1,"j"],[120442,1,"k"],[120443,1,"l"],[120444,1,"m"],[120445,1,"n"],[120446,1,"o"],[120447,1,"p"],[120448,1,"q"],[120449,1,"r"],[120450,1,"s"],[120451,1,"t"],[120452,1,"u"],[120453,1,"v"],[120454,1,"w"],[120455,1,"x"],[120456,1,"y"],[120457,1,"z"],[120458,1,"a"],[120459,1,"b"],[120460,1,"c"],[120461,1,"d"],[120462,1,"e"],[120463,1,"f"],[120464,1,"g"],[120465,1,"h"],[120466,1,"i"],[120467,1,"j"],[120468,1,"k"],[120469,1,"l"],[120470,1,"m"],[120471,1,"n"],[120472,1,"o"],[120473,1,"p"],[120474,1,"q"],[120475,1,"r"],[120476,1,"s"],[120477,1,"t"],[120478,1,"u"],[120479,1,"v"],[120480,1,"w"],[120481,1,"x"],[120482,1,"y"],[120483,1,"z"],[120484,1,"ı"],[120485,1,"ȷ"],[[120486,120487],3],[120488,1,"α"],[120489,1,"β"],[120490,1,"γ"],[120491,1,"δ"],[120492,1,"ε"],[120493,1,"ζ"],[120494,1,"η"],[120495,1,"θ"],[120496,1,"ι"],[120497,1,"κ"],[120498,1,"λ"],[120499,1,"μ"],[120500,1,"ν"],[120501,1,"ξ"],[120502,1,"ο"],[120503,1,"π"],[120504,1,"ρ"],[120505,1,"θ"],[120506,1,"σ"],[120507,1,"τ"],[120508,1,"υ"],[120509,1,"φ"],[120510,1,"χ"],[120511,1,"ψ"],[120512,1,"ω"],[120513,1,"∇"],[120514,1,"α"],[120515,1,"β"],[120516,1,"γ"],[120517,1,"δ"],[120518,1,"ε"],[120519,1,"ζ"],[120520,1,"η"],[120521,1,"θ"],[120522,1,"ι"],[120523,1,"κ"],[120524,1,"λ"],[120525,1,"μ"],[120526,1,"ν"],[120527,1,"ξ"],[120528,1,"ο"],[120529,1,"π"],[120530,1,"ρ"],[[120531,120532],1,"σ"],[120533,1,"τ"],[120534,1,"υ"],[120535,1,"φ"],[120536,1,"χ"],[120537,1,"ψ"],[120538,1,"ω"],[120539,1,"∂"],[120540,1,"ε"],[120541,1,"θ"],[120542,1,"κ"],[120543,1,"φ"],[120544,1,"ρ"],[120545,1,"π"],[120546,1,"α"],[120547,1,"β"],[120548,1,"γ"],[120549,1,"δ"],[120550,1,"ε"],[120551,1,"ζ"],[120552,1,"η"],[120553,1,"θ"],[120554,1,"ι"],[120555,1,"κ"],[120556,1,"λ"],[120557,1,"μ"],[120558,1,"ν"],[120559,1,"ξ"],[120560,1,"ο"],[120561,1,"π"],[120562,1,"ρ"],[120563,1,"θ"],[120564,1,"σ"],[120565,1,"τ"],[120566,1,"υ"],[120567,1,"φ"],[120568,1,"χ"],[120569,1,"ψ"],[120570,1,"ω"],[120571,1,"∇"],[120572,1,"α"],[120573,1,"β"],[120574,1,"γ"],[120575,1,"δ"],[120576,1,"ε"],[120577,1,"ζ"],[120578,1,"η"],[120579,1,"θ"],[120580,1,"ι"],[120581,1,"κ"],[120582,1,"λ"],[120583,1,"μ"],[120584,1,"ν"],[120585,1,"ξ"],[120586,1,"ο"],[120587,1,"π"],[120588,1,"ρ"],[[120589,120590],1,"σ"],[120591,1,"τ"],[120592,1,"υ"],[120593,1,"φ"],[120594,1,"χ"],[120595,1,"ψ"],[120596,1,"ω"],[120597,1,"∂"],[120598,1,"ε"],[120599,1,"θ"],[120600,1,"κ"],[120601,1,"φ"],[120602,1,"ρ"],[120603,1,"π"],[120604,1,"α"],[120605,1,"β"],[120606,1,"γ"],[120607,1,"δ"],[120608,1,"ε"],[120609,1,"ζ"],[120610,1,"η"],[120611,1,"θ"],[120612,1,"ι"],[120613,1,"κ"],[120614,1,"λ"],[120615,1,"μ"],[120616,1,"ν"],[120617,1,"ξ"],[120618,1,"ο"],[120619,1,"π"],[120620,1,"ρ"],[120621,1,"θ"],[120622,1,"σ"],[120623,1,"τ"],[120624,1,"υ"],[120625,1,"φ"],[120626,1,"χ"],[120627,1,"ψ"],[120628,1,"ω"],[120629,1,"∇"],[120630,1,"α"],[120631,1,"β"],[120632,1,"γ"],[120633,1,"δ"],[120634,1,"ε"],[120635,1,"ζ"],[120636,1,"η"],[120637,1,"θ"],[120638,1,"ι"],[120639,1,"κ"],[120640,1,"λ"],[120641,1,"μ"],[120642,1,"ν"],[120643,1,"ξ"],[120644,1,"ο"],[120645,1,"π"],[120646,1,"ρ"],[[120647,120648],1,"σ"],[120649,1,"τ"],[120650,1,"υ"],[120651,1,"φ"],[120652,1,"χ"],[120653,1,"ψ"],[120654,1,"ω"],[120655,1,"∂"],[120656,1,"ε"],[120657,1,"θ"],[120658,1,"κ"],[120659,1,"φ"],[120660,1,"ρ"],[120661,1,"π"],[120662,1,"α"],[120663,1,"β"],[120664,1,"γ"],[120665,1,"δ"],[120666,1,"ε"],[120667,1,"ζ"],[120668,1,"η"],[120669,1,"θ"],[120670,1,"ι"],[120671,1,"κ"],[120672,1,"λ"],[120673,1,"μ"],[120674,1,"ν"],[120675,1,"ξ"],[120676,1,"ο"],[120677,1,"π"],[120678,1,"ρ"],[120679,1,"θ"],[120680,1,"σ"],[120681,1,"τ"],[120682,1,"υ"],[120683,1,"φ"],[120684,1,"χ"],[120685,1,"ψ"],[120686,1,"ω"],[120687,1,"∇"],[120688,1,"α"],[120689,1,"β"],[120690,1,"γ"],[120691,1,"δ"],[120692,1,"ε"],[120693,1,"ζ"],[120694,1,"η"],[120695,1,"θ"],[120696,1,"ι"],[120697,1,"κ"],[120698,1,"λ"],[120699,1,"μ"],[120700,1,"ν"],[120701,1,"ξ"],[120702,1,"ο"],[120703,1,"π"],[120704,1,"ρ"],[[120705,120706],1,"σ"],[120707,1,"τ"],[120708,1,"υ"],[120709,1,"φ"],[120710,1,"χ"],[120711,1,"ψ"],[120712,1,"ω"],[120713,1,"∂"],[120714,1,"ε"],[120715,1,"θ"],[120716,1,"κ"],[120717,1,"φ"],[120718,1,"ρ"],[120719,1,"π"],[120720,1,"α"],[120721,1,"β"],[120722,1,"γ"],[120723,1,"δ"],[120724,1,"ε"],[120725,1,"ζ"],[120726,1,"η"],[120727,1,"θ"],[120728,1,"ι"],[120729,1,"κ"],[120730,1,"λ"],[120731,1,"μ"],[120732,1,"ν"],[120733,1,"ξ"],[120734,1,"ο"],[120735,1,"π"],[120736,1,"ρ"],[120737,1,"θ"],[120738,1,"σ"],[120739,1,"τ"],[120740,1,"υ"],[120741,1,"φ"],[120742,1,"χ"],[120743,1,"ψ"],[120744,1,"ω"],[120745,1,"∇"],[120746,1,"α"],[120747,1,"β"],[120748,1,"γ"],[120749,1,"δ"],[120750,1,"ε"],[120751,1,"ζ"],[120752,1,"η"],[120753,1,"θ"],[120754,1,"ι"],[120755,1,"κ"],[120756,1,"λ"],[120757,1,"μ"],[120758,1,"ν"],[120759,1,"ξ"],[120760,1,"ο"],[120761,1,"π"],[120762,1,"ρ"],[[120763,120764],1,"σ"],[120765,1,"τ"],[120766,1,"υ"],[120767,1,"φ"],[120768,1,"χ"],[120769,1,"ψ"],[120770,1,"ω"],[120771,1,"∂"],[120772,1,"ε"],[120773,1,"θ"],[120774,1,"κ"],[120775,1,"φ"],[120776,1,"ρ"],[120777,1,"π"],[[120778,120779],1,"ϝ"],[[120780,120781],3],[120782,1,"0"],[120783,1,"1"],[120784,1,"2"],[120785,1,"3"],[120786,1,"4"],[120787,1,"5"],[120788,1,"6"],[120789,1,"7"],[120790,1,"8"],[120791,1,"9"],[120792,1,"0"],[120793,1,"1"],[120794,1,"2"],[120795,1,"3"],[120796,1,"4"],[120797,1,"5"],[120798,1,"6"],[120799,1,"7"],[120800,1,"8"],[120801,1,"9"],[120802,1,"0"],[120803,1,"1"],[120804,1,"2"],[120805,1,"3"],[120806,1,"4"],[120807,1,"5"],[120808,1,"6"],[120809,1,"7"],[120810,1,"8"],[120811,1,"9"],[120812,1,"0"],[120813,1,"1"],[120814,1,"2"],[120815,1,"3"],[120816,1,"4"],[120817,1,"5"],[120818,1,"6"],[120819,1,"7"],[120820,1,"8"],[120821,1,"9"],[120822,1,"0"],[120823,1,"1"],[120824,1,"2"],[120825,1,"3"],[120826,1,"4"],[120827,1,"5"],[120828,1,"6"],[120829,1,"7"],[120830,1,"8"],[120831,1,"9"],[[120832,121343],2],[[121344,121398],2],[[121399,121402],2],[[121403,121452],2],[[121453,121460],2],[121461,2],[[121462,121475],2],[121476,2],[[121477,121483],2],[[121484,121498],3],[[121499,121503],2],[121504,3],[[121505,121519],2],[[121520,122623],3],[[122624,122654],2],[[122655,122660],3],[[122661,122666],2],[[122667,122879],3],[[122880,122886],2],[122887,3],[[122888,122904],2],[[122905,122906],3],[[122907,122913],2],[122914,3],[[122915,122916],2],[122917,3],[[122918,122922],2],[[122923,122927],3],[122928,1,"а"],[122929,1,"б"],[122930,1,"в"],[122931,1,"г"],[122932,1,"д"],[122933,1,"е"],[122934,1,"ж"],[122935,1,"з"],[122936,1,"и"],[122937,1,"к"],[122938,1,"л"],[122939,1,"м"],[122940,1,"о"],[122941,1,"п"],[122942,1,"р"],[122943,1,"с"],[122944,1,"т"],[122945,1,"у"],[122946,1,"ф"],[122947,1,"х"],[122948,1,"ц"],[122949,1,"ч"],[122950,1,"ш"],[122951,1,"ы"],[122952,1,"э"],[122953,1,"ю"],[122954,1,"ꚉ"],[122955,1,"ә"],[122956,1,"і"],[122957,1,"ј"],[122958,1,"ө"],[122959,1,"ү"],[122960,1,"ӏ"],[122961,1,"а"],[122962,1,"б"],[122963,1,"в"],[122964,1,"г"],[122965,1,"д"],[122966,1,"е"],[122967,1,"ж"],[122968,1,"з"],[122969,1,"и"],[122970,1,"к"],[122971,1,"л"],[122972,1,"о"],[122973,1,"п"],[122974,1,"с"],[122975,1,"у"],[122976,1,"ф"],[122977,1,"х"],[122978,1,"ц"],[122979,1,"ч"],[122980,1,"ш"],[122981,1,"ъ"],[122982,1,"ы"],[122983,1,"ґ"],[122984,1,"і"],[122985,1,"ѕ"],[122986,1,"џ"],[122987,1,"ҫ"],[122988,1,"ꙑ"],[122989,1,"ұ"],[[122990,123022],3],[123023,2],[[123024,123135],3],[[123136,123180],2],[[123181,123183],3],[[123184,123197],2],[[123198,123199],3],[[123200,123209],2],[[123210,123213],3],[123214,2],[123215,2],[[123216,123535],3],[[123536,123566],2],[[123567,123583],3],[[123584,123641],2],[[123642,123646],3],[123647,2],[[123648,124111],3],[[124112,124153],2],[[124154,124367],3],[[124368,124410],2],[[124411,124414],3],[124415,2],[[124416,124607],3],[[124608,124638],2],[124639,3],[[124640,124661],2],[[124662,124669],3],[[124670,124671],2],[[124672,124895],3],[[124896,124902],2],[124903,3],[[124904,124907],2],[124908,3],[[124909,124910],2],[124911,3],[[124912,124926],2],[124927,3],[[124928,125124],2],[[125125,125126],3],[[125127,125135],2],[[125136,125142],2],[[125143,125183],3],[125184,1,"𞤢"],[125185,1,"𞤣"],[125186,1,"𞤤"],[125187,1,"𞤥"],[125188,1,"𞤦"],[125189,1,"𞤧"],[125190,1,"𞤨"],[125191,1,"𞤩"],[125192,1,"𞤪"],[125193,1,"𞤫"],[125194,1,"𞤬"],[125195,1,"𞤭"],[125196,1,"𞤮"],[125197,1,"𞤯"],[125198,1,"𞤰"],[125199,1,"𞤱"],[125200,1,"𞤲"],[125201,1,"𞤳"],[125202,1,"𞤴"],[125203,1,"𞤵"],[125204,1,"𞤶"],[125205,1,"𞤷"],[125206,1,"𞤸"],[125207,1,"𞤹"],[125208,1,"𞤺"],[125209,1,"𞤻"],[125210,1,"𞤼"],[125211,1,"𞤽"],[125212,1,"𞤾"],[125213,1,"𞤿"],[125214,1,"𞥀"],[125215,1,"𞥁"],[125216,1,"𞥂"],[125217,1,"𞥃"],[[125218,125258],2],[125259,2],[[125260,125263],3],[[125264,125273],2],[[125274,125277],3],[[125278,125279],2],[[125280,126064],3],[[126065,126132],2],[[126133,126208],3],[[126209,126269],2],[[126270,126463],3],[126464,1,"ا"],[126465,1,"ب"],[126466,1,"ج"],[126467,1,"د"],[126468,3],[126469,1,"و"],[126470,1,"ز"],[126471,1,"ح"],[126472,1,"ط"],[126473,1,"ي"],[126474,1,"ك"],[126475,1,"ل"],[126476,1,"م"],[126477,1,"ن"],[126478,1,"س"],[126479,1,"ع"],[126480,1,"ف"],[126481,1,"ص"],[126482,1,"ق"],[126483,1,"ر"],[126484,1,"ش"],[126485,1,"ت"],[126486,1,"ث"],[126487,1,"خ"],[126488,1,"ذ"],[126489,1,"ض"],[126490,1,"ظ"],[126491,1,"غ"],[126492,1,"ٮ"],[126493,1,"ں"],[126494,1,"ڡ"],[126495,1,"ٯ"],[126496,3],[126497,1,"ب"],[126498,1,"ج"],[126499,3],[126500,1,"ه"],[[126501,126502],3],[126503,1,"ح"],[126504,3],[126505,1,"ي"],[126506,1,"ك"],[126507,1,"ل"],[126508,1,"م"],[126509,1,"ن"],[126510,1,"س"],[126511,1,"ع"],[126512,1,"ف"],[126513,1,"ص"],[126514,1,"ق"],[126515,3],[126516,1,"ش"],[126517,1,"ت"],[126518,1,"ث"],[126519,1,"خ"],[126520,3],[126521,1,"ض"],[126522,3],[126523,1,"غ"],[[126524,126529],3],[126530,1,"ج"],[[126531,126534],3],[126535,1,"ح"],[126536,3],[126537,1,"ي"],[126538,3],[126539,1,"ل"],[126540,3],[126541,1,"ن"],[126542,1,"س"],[126543,1,"ع"],[126544,3],[126545,1,"ص"],[126546,1,"ق"],[126547,3],[126548,1,"ش"],[[126549,126550],3],[126551,1,"خ"],[126552,3],[126553,1,"ض"],[126554,3],[126555,1,"غ"],[126556,3],[126557,1,"ں"],[126558,3],[126559,1,"ٯ"],[126560,3],[126561,1,"ب"],[126562,1,"ج"],[126563,3],[126564,1,"ه"],[[126565,126566],3],[126567,1,"ح"],[126568,1,"ط"],[126569,1,"ي"],[126570,1,"ك"],[126571,3],[126572,1,"م"],[126573,1,"ن"],[126574,1,"س"],[126575,1,"ع"],[126576,1,"ف"],[126577,1,"ص"],[126578,1,"ق"],[126579,3],[126580,1,"ش"],[126581,1,"ت"],[126582,1,"ث"],[126583,1,"خ"],[126584,3],[126585,1,"ض"],[126586,1,"ظ"],[126587,1,"غ"],[126588,1,"ٮ"],[126589,3],[126590,1,"ڡ"],[126591,3],[126592,1,"ا"],[126593,1,"ب"],[126594,1,"ج"],[126595,1,"د"],[126596,1,"ه"],[126597,1,"و"],[126598,1,"ز"],[126599,1,"ح"],[126600,1,"ط"],[126601,1,"ي"],[126602,3],[126603,1,"ل"],[126604,1,"م"],[126605,1,"ن"],[126606,1,"س"],[126607,1,"ع"],[126608,1,"ف"],[126609,1,"ص"],[126610,1,"ق"],[126611,1,"ر"],[126612,1,"ش"],[126613,1,"ت"],[126614,1,"ث"],[126615,1,"خ"],[126616,1,"ذ"],[126617,1,"ض"],[126618,1,"ظ"],[126619,1,"غ"],[[126620,126624],3],[126625,1,"ب"],[126626,1,"ج"],[126627,1,"د"],[126628,3],[126629,1,"و"],[126630,1,"ز"],[126631,1,"ح"],[126632,1,"ط"],[126633,1,"ي"],[126634,3],[126635,1,"ل"],[126636,1,"م"],[126637,1,"ن"],[126638,1,"س"],[126639,1,"ع"],[126640,1,"ف"],[126641,1,"ص"],[126642,1,"ق"],[126643,1,"ر"],[126644,1,"ش"],[126645,1,"ت"],[126646,1,"ث"],[126647,1,"خ"],[126648,1,"ذ"],[126649,1,"ض"],[126650,1,"ظ"],[126651,1,"غ"],[[126652,126703],3],[[126704,126705],2],[[126706,126975],3],[[126976,127019],2],[[127020,127023],3],[[127024,127123],2],[[127124,127135],3],[[127136,127150],2],[[127151,127152],3],[[127153,127166],2],[127167,2],[127168,3],[[127169,127183],2],[127184,3],[[127185,127199],2],[[127200,127221],2],[[127222,127231],3],[127232,3],[127233,1,"0,"],[127234,1,"1,"],[127235,1,"2,"],[127236,1,"3,"],[127237,1,"4,"],[127238,1,"5,"],[127239,1,"6,"],[127240,1,"7,"],[127241,1,"8,"],[127242,1,"9,"],[[127243,127244],2],[[127245,127247],2],[127248,1,"(a)"],[127249,1,"(b)"],[127250,1,"(c)"],[127251,1,"(d)"],[127252,1,"(e)"],[127253,1,"(f)"],[127254,1,"(g)"],[127255,1,"(h)"],[127256,1,"(i)"],[127257,1,"(j)"],[127258,1,"(k)"],[127259,1,"(l)"],[127260,1,"(m)"],[127261,1,"(n)"],[127262,1,"(o)"],[127263,1,"(p)"],[127264,1,"(q)"],[127265,1,"(r)"],[127266,1,"(s)"],[127267,1,"(t)"],[127268,1,"(u)"],[127269,1,"(v)"],[127270,1,"(w)"],[127271,1,"(x)"],[127272,1,"(y)"],[127273,1,"(z)"],[127274,1,"〔s〕"],[127275,1,"c"],[127276,1,"r"],[127277,1,"cd"],[127278,1,"wz"],[127279,2],[127280,1,"a"],[127281,1,"b"],[127282,1,"c"],[127283,1,"d"],[127284,1,"e"],[127285,1,"f"],[127286,1,"g"],[127287,1,"h"],[127288,1,"i"],[127289,1,"j"],[127290,1,"k"],[127291,1,"l"],[127292,1,"m"],[127293,1,"n"],[127294,1,"o"],[127295,1,"p"],[127296,1,"q"],[127297,1,"r"],[127298,1,"s"],[127299,1,"t"],[127300,1,"u"],[127301,1,"v"],[127302,1,"w"],[127303,1,"x"],[127304,1,"y"],[127305,1,"z"],[127306,1,"hv"],[127307,1,"mv"],[127308,1,"sd"],[127309,1,"ss"],[127310,1,"ppv"],[127311,1,"wc"],[[127312,127318],2],[127319,2],[[127320,127326],2],[127327,2],[[127328,127337],2],[127338,1,"mc"],[127339,1,"md"],[127340,1,"mr"],[[127341,127343],2],[[127344,127352],2],[127353,2],[127354,2],[[127355,127356],2],[[127357,127358],2],[127359,2],[[127360,127369],2],[[127370,127373],2],[[127374,127375],2],[127376,1,"dj"],[[127377,127386],2],[[127387,127404],2],[127405,2],[[127406,127461],3],[[127462,127487],2],[127488,1,"ほか"],[127489,1,"ココ"],[127490,1,"サ"],[[127491,127503],3],[127504,1,"手"],[127505,1,"字"],[127506,1,"双"],[127507,1,"デ"],[127508,1,"二"],[127509,1,"多"],[127510,1,"解"],[127511,1,"天"],[127512,1,"交"],[127513,1,"映"],[127514,1,"無"],[127515,1,"料"],[127516,1,"前"],[127517,1,"後"],[127518,1,"再"],[127519,1,"新"],[127520,1,"初"],[127521,1,"終"],[127522,1,"生"],[127523,1,"販"],[127524,1,"声"],[127525,1,"吹"],[127526,1,"演"],[127527,1,"投"],[127528,1,"捕"],[127529,1,"一"],[127530,1,"三"],[127531,1,"遊"],[127532,1,"左"],[127533,1,"中"],[127534,1,"右"],[127535,1,"指"],[127536,1,"走"],[127537,1,"打"],[127538,1,"禁"],[127539,1,"空"],[127540,1,"合"],[127541,1,"満"],[127542,1,"有"],[127543,1,"月"],[127544,1,"申"],[127545,1,"割"],[127546,1,"営"],[127547,1,"配"],[[127548,127551],3],[127552,1,"〔本〕"],[127553,1,"〔三〕"],[127554,1,"〔二〕"],[127555,1,"〔安〕"],[127556,1,"〔点〕"],[127557,1,"〔打〕"],[127558,1,"〔盗〕"],[127559,1,"〔勝〕"],[127560,1,"〔敗〕"],[[127561,127567],3],[127568,1,"得"],[127569,1,"可"],[[127570,127583],3],[[127584,127589],2],[[127590,127743],3],[[127744,127776],2],[[127777,127788],2],[[127789,127791],2],[[127792,127797],2],[127798,2],[[127799,127868],2],[127869,2],[[127870,127871],2],[[127872,127891],2],[[127892,127903],2],[[127904,127940],2],[127941,2],[[127942,127946],2],[[127947,127950],2],[[127951,127955],2],[[127956,127967],2],[[127968,127984],2],[[127985,127991],2],[[127992,127999],2],[[128000,128062],2],[128063,2],[128064,2],[128065,2],[[128066,128247],2],[128248,2],[[128249,128252],2],[[128253,128254],2],[128255,2],[[128256,128317],2],[[128318,128319],2],[[128320,128323],2],[[128324,128330],2],[[128331,128335],2],[[128336,128359],2],[[128360,128377],2],[128378,2],[[128379,128419],2],[128420,2],[[128421,128506],2],[[128507,128511],2],[128512,2],[[128513,128528],2],[128529,2],[[128530,128532],2],[128533,2],[128534,2],[128535,2],[128536,2],[128537,2],[128538,2],[128539,2],[[128540,128542],2],[128543,2],[[128544,128549],2],[[128550,128551],2],[[128552,128555],2],[128556,2],[128557,2],[[128558,128559],2],[[128560,128563],2],[128564,2],[[128565,128576],2],[[128577,128578],2],[[128579,128580],2],[[128581,128591],2],[[128592,128639],2],[[128640,128709],2],[[128710,128719],2],[128720,2],[[128721,128722],2],[[128723,128724],2],[128725,2],[[128726,128727],2],[128728,2],[[128729,128731],3],[128732,2],[[128733,128735],2],[[128736,128748],2],[[128749,128751],3],[[128752,128755],2],[[128756,128758],2],[[128759,128760],2],[128761,2],[128762,2],[[128763,128764],2],[[128765,128767],3],[[128768,128883],2],[[128884,128886],2],[[128887,128890],2],[[128891,128895],2],[[128896,128980],2],[[128981,128984],2],[128985,2],[[128986,128991],3],[[128992,129003],2],[[129004,129007],3],[129008,2],[[129009,129023],3],[[129024,129035],2],[[129036,129039],3],[[129040,129095],2],[[129096,129103],3],[[129104,129113],2],[[129114,129119],3],[[129120,129159],2],[[129160,129167],3],[[129168,129197],2],[[129198,129199],3],[[129200,129201],2],[[129202,129211],2],[[129212,129215],3],[[129216,129217],2],[[129218,129231],3],[[129232,129240],2],[[129241,129279],3],[[129280,129291],2],[129292,2],[[129293,129295],2],[[129296,129304],2],[[129305,129310],2],[129311,2],[[129312,129319],2],[[129320,129327],2],[129328,2],[[129329,129330],2],[[129331,129342],2],[129343,2],[[129344,129355],2],[129356,2],[[129357,129359],2],[[129360,129374],2],[[129375,129387],2],[[129388,129392],2],[129393,2],[129394,2],[[129395,129398],2],[[129399,129400],2],[129401,2],[129402,2],[129403,2],[[129404,129407],2],[[129408,129412],2],[[129413,129425],2],[[129426,129431],2],[[129432,129442],2],[[129443,129444],2],[[129445,129450],2],[[129451,129453],2],[[129454,129455],2],[[129456,129465],2],[[129466,129471],2],[129472,2],[[129473,129474],2],[[129475,129482],2],[129483,2],[129484,2],[[129485,129487],2],[[129488,129510],2],[[129511,129535],2],[[129536,129619],2],[[129620,129623],2],[[129624,129631],3],[[129632,129645],2],[[129646,129647],3],[[129648,129651],2],[129652,2],[[129653,129655],2],[[129656,129658],2],[[129659,129660],2],[[129661,129663],3],[[129664,129666],2],[[129667,129670],2],[[129671,129672],2],[129673,2],[129674,2],[[129675,129677],3],[129678,2],[129679,2],[[129680,129685],2],[[129686,129704],2],[[129705,129708],2],[[129709,129711],2],[[129712,129718],2],[[129719,129722],2],[[129723,129725],2],[129726,2],[129727,2],[[129728,129730],2],[[129731,129733],2],[129734,2],[129735,3],[129736,2],[[129737,129740],3],[129741,2],[[129742,129743],2],[[129744,129750],2],[[129751,129753],2],[[129754,129755],2],[129756,2],[[129757,129758],3],[129759,2],[[129760,129767],2],[129768,2],[129769,2],[129770,2],[[129771,129774],3],[129775,2],[[129776,129782],2],[[129783,129784],2],[[129785,129791],3],[[129792,129938],2],[129939,3],[[129940,129994],2],[[129995,130031],2],[130032,1,"0"],[130033,1,"1"],[130034,1,"2"],[130035,1,"3"],[130036,1,"4"],[130037,1,"5"],[130038,1,"6"],[130039,1,"7"],[130040,1,"8"],[130041,1,"9"],[130042,2],[[130043,131069],3],[[131070,131071],3],[[131072,173782],2],[[173783,173789],2],[[173790,173791],2],[[173792,173823],3],[[173824,177972],2],[[177973,177976],2],[177977,2],[[177978,177983],2],[[177984,178205],2],[[178206,178207],3],[[178208,183969],2],[[183970,183981],2],[[183982,183983],3],[[183984,191456],2],[[191457,191471],3],[[191472,192093],2],[[192094,194559],3],[194560,1,"丽"],[194561,1,"丸"],[194562,1,"乁"],[194563,1,"𠄢"],[194564,1,"你"],[194565,1,"侮"],[194566,1,"侻"],[194567,1,"倂"],[194568,1,"偺"],[194569,1,"備"],[194570,1,"僧"],[194571,1,"像"],[194572,1,"㒞"],[194573,1,"𠘺"],[194574,1,"免"],[194575,1,"兔"],[194576,1,"兤"],[194577,1,"具"],[194578,1,"𠔜"],[194579,1,"㒹"],[194580,1,"內"],[194581,1,"再"],[194582,1,"𠕋"],[194583,1,"冗"],[194584,1,"冤"],[194585,1,"仌"],[194586,1,"冬"],[194587,1,"况"],[194588,1,"𩇟"],[194589,1,"凵"],[194590,1,"刃"],[194591,1,"㓟"],[194592,1,"刻"],[194593,1,"剆"],[194594,1,"割"],[194595,1,"剷"],[194596,1,"㔕"],[194597,1,"勇"],[194598,1,"勉"],[194599,1,"勤"],[194600,1,"勺"],[194601,1,"包"],[194602,1,"匆"],[194603,1,"北"],[194604,1,"卉"],[194605,1,"卑"],[194606,1,"博"],[194607,1,"即"],[194608,1,"卽"],[[194609,194611],1,"卿"],[194612,1,"𠨬"],[194613,1,"灰"],[194614,1,"及"],[194615,1,"叟"],[194616,1,"𠭣"],[194617,1,"叫"],[194618,1,"叱"],[194619,1,"吆"],[194620,1,"咞"],[194621,1,"吸"],[194622,1,"呈"],[194623,1,"周"],[194624,1,"咢"],[194625,1,"哶"],[194626,1,"唐"],[194627,1,"啓"],[194628,1,"啣"],[[194629,194630],1,"善"],[194631,1,"喙"],[194632,1,"喫"],[194633,1,"喳"],[194634,1,"嗂"],[194635,1,"圖"],[194636,1,"嘆"],[194637,1,"圗"],[194638,1,"噑"],[194639,1,"噴"],[194640,1,"切"],[194641,1,"壮"],[194642,1,"城"],[194643,1,"埴"],[194644,1,"堍"],[194645,1,"型"],[194646,1,"堲"],[194647,1,"報"],[194648,1,"墬"],[194649,1,"𡓤"],[194650,1,"売"],[194651,1,"壷"],[194652,1,"夆"],[194653,1,"多"],[194654,1,"夢"],[194655,1,"奢"],[194656,1,"𡚨"],[194657,1,"𡛪"],[194658,1,"姬"],[194659,1,"娛"],[194660,1,"娧"],[194661,1,"姘"],[194662,1,"婦"],[194663,1,"㛮"],[194664,1,"㛼"],[194665,1,"嬈"],[[194666,194667],1,"嬾"],[194668,1,"𡧈"],[194669,1,"寃"],[194670,1,"寘"],[194671,1,"寧"],[194672,1,"寳"],[194673,1,"𡬘"],[194674,1,"寿"],[194675,1,"将"],[194676,1,"当"],[194677,1,"尢"],[194678,1,"㞁"],[194679,1,"屠"],[194680,1,"屮"],[194681,1,"峀"],[194682,1,"岍"],[194683,1,"𡷤"],[194684,1,"嵃"],[194685,1,"𡷦"],[194686,1,"嵮"],[194687,1,"嵫"],[194688,1,"嵼"],[194689,1,"巡"],[194690,1,"巢"],[194691,1,"㠯"],[194692,1,"巽"],[194693,1,"帨"],[194694,1,"帽"],[194695,1,"幩"],[194696,1,"㡢"],[194697,1,"𢆃"],[194698,1,"㡼"],[194699,1,"庰"],[194700,1,"庳"],[194701,1,"庶"],[194702,1,"廊"],[194703,1,"𪎒"],[194704,1,"廾"],[[194705,194706],1,"𢌱"],[194707,1,"舁"],[[194708,194709],1,"弢"],[194710,1,"㣇"],[194711,1,"𣊸"],[194712,1,"𦇚"],[194713,1,"形"],[194714,1,"彫"],[194715,1,"㣣"],[194716,1,"徚"],[194717,1,"忍"],[194718,1,"志"],[194719,1,"忹"],[194720,1,"悁"],[194721,1,"㤺"],[194722,1,"㤜"],[194723,1,"悔"],[194724,1,"𢛔"],[194725,1,"惇"],[194726,1,"慈"],[194727,1,"慌"],[194728,1,"慎"],[194729,1,"慌"],[194730,1,"慺"],[194731,1,"憎"],[194732,1,"憲"],[194733,1,"憤"],[194734,1,"憯"],[194735,1,"懞"],[194736,1,"懲"],[194737,1,"懶"],[194738,1,"成"],[194739,1,"戛"],[194740,1,"扝"],[194741,1,"抱"],[194742,1,"拔"],[194743,1,"捐"],[194744,1,"𢬌"],[194745,1,"挽"],[194746,1,"拼"],[194747,1,"捨"],[194748,1,"掃"],[194749,1,"揤"],[194750,1,"𢯱"],[194751,1,"搢"],[194752,1,"揅"],[194753,1,"掩"],[194754,1,"㨮"],[194755,1,"摩"],[194756,1,"摾"],[194757,1,"撝"],[194758,1,"摷"],[194759,1,"㩬"],[194760,1,"敏"],[194761,1,"敬"],[194762,1,"𣀊"],[194763,1,"旣"],[194764,1,"書"],[194765,1,"晉"],[194766,1,"㬙"],[194767,1,"暑"],[194768,1,"㬈"],[194769,1,"㫤"],[194770,1,"冒"],[194771,1,"冕"],[194772,1,"最"],[194773,1,"暜"],[194774,1,"肭"],[194775,1,"䏙"],[194776,1,"朗"],[194777,1,"望"],[194778,1,"朡"],[194779,1,"杞"],[194780,1,"杓"],[194781,1,"𣏃"],[194782,1,"㭉"],[194783,1,"柺"],[194784,1,"枅"],[194785,1,"桒"],[194786,1,"梅"],[194787,1,"𣑭"],[194788,1,"梎"],[194789,1,"栟"],[194790,1,"椔"],[194791,1,"㮝"],[194792,1,"楂"],[194793,1,"榣"],[194794,1,"槪"],[194795,1,"檨"],[194796,1,"𣚣"],[194797,1,"櫛"],[194798,1,"㰘"],[194799,1,"次"],[194800,1,"𣢧"],[194801,1,"歔"],[194802,1,"㱎"],[194803,1,"歲"],[194804,1,"殟"],[194805,1,"殺"],[194806,1,"殻"],[194807,1,"𣪍"],[194808,1,"𡴋"],[194809,1,"𣫺"],[194810,1,"汎"],[194811,1,"𣲼"],[194812,1,"沿"],[194813,1,"泍"],[194814,1,"汧"],[194815,1,"洖"],[194816,1,"派"],[194817,1,"海"],[194818,1,"流"],[194819,1,"浩"],[194820,1,"浸"],[194821,1,"涅"],[194822,1,"𣴞"],[194823,1,"洴"],[194824,1,"港"],[194825,1,"湮"],[194826,1,"㴳"],[194827,1,"滋"],[194828,1,"滇"],[194829,1,"𣻑"],[194830,1,"淹"],[194831,1,"潮"],[194832,1,"𣽞"],[194833,1,"𣾎"],[194834,1,"濆"],[194835,1,"瀹"],[194836,1,"瀞"],[194837,1,"瀛"],[194838,1,"㶖"],[194839,1,"灊"],[194840,1,"災"],[194841,1,"灷"],[194842,1,"炭"],[194843,1,"𠔥"],[194844,1,"煅"],[194845,1,"𤉣"],[194846,1,"熜"],[194847,1,"𤎫"],[194848,1,"爨"],[194849,1,"爵"],[194850,1,"牐"],[194851,1,"𤘈"],[194852,1,"犀"],[194853,1,"犕"],[194854,1,"𤜵"],[194855,1,"𤠔"],[194856,1,"獺"],[194857,1,"王"],[194858,1,"㺬"],[194859,1,"玥"],[[194860,194861],1,"㺸"],[194862,1,"瑇"],[194863,1,"瑜"],[194864,1,"瑱"],[194865,1,"璅"],[194866,1,"瓊"],[194867,1,"㼛"],[194868,1,"甤"],[194869,1,"𤰶"],[194870,1,"甾"],[194871,1,"𤲒"],[194872,1,"異"],[194873,1,"𢆟"],[194874,1,"瘐"],[194875,1,"𤾡"],[194876,1,"𤾸"],[194877,1,"𥁄"],[194878,1,"㿼"],[194879,1,"䀈"],[194880,1,"直"],[194881,1,"𥃳"],[194882,1,"𥃲"],[194883,1,"𥄙"],[194884,1,"𥄳"],[194885,1,"眞"],[[194886,194887],1,"真"],[194888,1,"睊"],[194889,1,"䀹"],[194890,1,"瞋"],[194891,1,"䁆"],[194892,1,"䂖"],[194893,1,"𥐝"],[194894,1,"硎"],[194895,1,"碌"],[194896,1,"磌"],[194897,1,"䃣"],[194898,1,"𥘦"],[194899,1,"祖"],[194900,1,"𥚚"],[194901,1,"𥛅"],[194902,1,"福"],[194903,1,"秫"],[194904,1,"䄯"],[194905,1,"穀"],[194906,1,"穊"],[194907,1,"穏"],[194908,1,"𥥼"],[[194909,194910],1,"𥪧"],[194911,1,"竮"],[194912,1,"䈂"],[194913,1,"𥮫"],[194914,1,"篆"],[194915,1,"築"],[194916,1,"䈧"],[194917,1,"𥲀"],[194918,1,"糒"],[194919,1,"䊠"],[194920,1,"糨"],[194921,1,"糣"],[194922,1,"紀"],[194923,1,"𥾆"],[194924,1,"絣"],[194925,1,"䌁"],[194926,1,"緇"],[194927,1,"縂"],[194928,1,"繅"],[194929,1,"䌴"],[194930,1,"𦈨"],[194931,1,"𦉇"],[194932,1,"䍙"],[194933,1,"𦋙"],[194934,1,"罺"],[194935,1,"𦌾"],[194936,1,"羕"],[194937,1,"翺"],[194938,1,"者"],[194939,1,"𦓚"],[194940,1,"𦔣"],[194941,1,"聠"],[194942,1,"𦖨"],[194943,1,"聰"],[194944,1,"𣍟"],[194945,1,"䏕"],[194946,1,"育"],[194947,1,"脃"],[194948,1,"䐋"],[194949,1,"脾"],[194950,1,"媵"],[194951,1,"𦞧"],[194952,1,"𦞵"],[194953,1,"𣎓"],[194954,1,"𣎜"],[194955,1,"舁"],[194956,1,"舄"],[194957,1,"辞"],[194958,1,"䑫"],[194959,1,"芑"],[194960,1,"芋"],[194961,1,"芝"],[194962,1,"劳"],[194963,1,"花"],[194964,1,"芳"],[194965,1,"芽"],[194966,1,"苦"],[194967,1,"𦬼"],[194968,1,"若"],[194969,1,"茝"],[194970,1,"荣"],[194971,1,"莭"],[194972,1,"茣"],[194973,1,"莽"],[194974,1,"菧"],[194975,1,"著"],[194976,1,"荓"],[194977,1,"菊"],[194978,1,"菌"],[194979,1,"菜"],[194980,1,"𦰶"],[194981,1,"𦵫"],[194982,1,"𦳕"],[194983,1,"䔫"],[194984,1,"蓱"],[194985,1,"蓳"],[194986,1,"蔖"],[194987,1,"𧏊"],[194988,1,"蕤"],[194989,1,"𦼬"],[194990,1,"䕝"],[194991,1,"䕡"],[194992,1,"𦾱"],[194993,1,"𧃒"],[194994,1,"䕫"],[194995,1,"虐"],[194996,1,"虜"],[194997,1,"虧"],[194998,1,"虩"],[194999,1,"蚩"],[195000,1,"蚈"],[195001,1,"蜎"],[195002,1,"蛢"],[195003,1,"蝹"],[195004,1,"蜨"],[195005,1,"蝫"],[195006,1,"螆"],[195007,1,"䗗"],[195008,1,"蟡"],[195009,1,"蠁"],[195010,1,"䗹"],[195011,1,"衠"],[195012,1,"衣"],[195013,1,"𧙧"],[195014,1,"裗"],[195015,1,"裞"],[195016,1,"䘵"],[195017,1,"裺"],[195018,1,"㒻"],[195019,1,"𧢮"],[195020,1,"𧥦"],[195021,1,"䚾"],[195022,1,"䛇"],[195023,1,"誠"],[195024,1,"諭"],[195025,1,"變"],[195026,1,"豕"],[195027,1,"𧲨"],[195028,1,"貫"],[195029,1,"賁"],[195030,1,"贛"],[195031,1,"起"],[195032,1,"𧼯"],[195033,1,"𠠄"],[195034,1,"跋"],[195035,1,"趼"],[195036,1,"跰"],[195037,1,"𠣞"],[195038,1,"軔"],[195039,1,"輸"],[195040,1,"𨗒"],[195041,1,"𨗭"],[195042,1,"邔"],[195043,1,"郱"],[195044,1,"鄑"],[195045,1,"𨜮"],[195046,1,"鄛"],[195047,1,"鈸"],[195048,1,"鋗"],[195049,1,"鋘"],[195050,1,"鉼"],[195051,1,"鏹"],[195052,1,"鐕"],[195053,1,"𨯺"],[195054,1,"開"],[195055,1,"䦕"],[195056,1,"閷"],[195057,1,"𨵷"],[195058,1,"䧦"],[195059,1,"雃"],[195060,1,"嶲"],[195061,1,"霣"],[195062,1,"𩅅"],[195063,1,"𩈚"],[195064,1,"䩮"],[195065,1,"䩶"],[195066,1,"韠"],[195067,1,"𩐊"],[195068,1,"䪲"],[195069,1,"𩒖"],[[195070,195071],1,"頋"],[195072,1,"頩"],[195073,1,"𩖶"],[195074,1,"飢"],[195075,1,"䬳"],[195076,1,"餩"],[195077,1,"馧"],[195078,1,"駂"],[195079,1,"駾"],[195080,1,"䯎"],[195081,1,"𩬰"],[195082,1,"鬒"],[195083,1,"鱀"],[195084,1,"鳽"],[195085,1,"䳎"],[195086,1,"䳭"],[195087,1,"鵧"],[195088,1,"𪃎"],[195089,1,"䳸"],[195090,1,"𪄅"],[195091,1,"𪈎"],[195092,1,"𪊑"],[195093,1,"麻"],[195094,1,"䵖"],[195095,1,"黹"],[195096,1,"黾"],[195097,1,"鼅"],[195098,1,"鼏"],[195099,1,"鼖"],[195100,1,"鼻"],[195101,1,"𪘀"],[[195102,196605],3],[[196606,196607],3],[[196608,201546],2],[[201547,201551],3],[[201552,205743],2],[[205744,210041],2],[[210042,262141],3],[[262142,262143],3],[[262144,327677],3],[[327678,327679],3],[[327680,393213],3],[[393214,393215],3],[[393216,458749],3],[[458750,458751],3],[[458752,524285],3],[[524286,524287],3],[[524288,589821],3],[[589822,589823],3],[[589824,655357],3],[[655358,655359],3],[[655360,720893],3],[[720894,720895],3],[[720896,786429],3],[[786430,786431],3],[[786432,851965],3],[[851966,851967],3],[[851968,917501],3],[[917502,917503],3],[917504,3],[917505,3],[[917506,917535],3],[[917536,917631],3],[[917632,917759],3],[[917760,917999],7],[[918000,983037],3],[[983038,983039],3],[[983040,1048573],3],[[1048574,1048575],3],[[1048576,1114109],3],[[1114110,1114111],3]] diff --git a/tools/vendor/tr46-6.0.0/regexes.js b/tools/vendor/tr46-6.0.0/regexes.js new file mode 100644 index 00000000..2df5ef13 --- /dev/null +++ b/tools/vendor/tr46-6.0.0/regexes.js @@ -0,0 +1,29 @@ +"use strict"; + +const combiningMarks = /[\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u0897-\u089F\u08CA-\u08E1\u08E3-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u09FE\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0AFA-\u0AFF\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B55-\u0B57\u0B62\u0B63\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C00-\u0C04\u0C3C\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81-\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0CF3\u0D00-\u0D03\u0D3B\u0D3C\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D81-\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2\u0DF3\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EBC\u0EC8-\u0ECE\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102B-\u103E\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F\u109A-\u109D\u135D-\u135F\u1712-\u1715\u1732-\u1734\u1752\u1753\u1772\u1773\u17B4-\u17D3\u17DD\u180B-\u180D\u180F\u1885\u1886\u18A9\u1920-\u192B\u1930-\u193B\u1A17-\u1A1B\u1A55-\u1A5E\u1A60-\u1A7C\u1A7F\u1AB0-\u1ADD\u1AE0-\u1AEB\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF4\u1CF7-\u1CF9\u1DC0-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA82C\uA880\uA881\uA8B4-\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA953\uA980-\uA983\uA9B3-\uA9C0\uA9E5\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAA7B-\uAA7D\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEB-\uAAEF\uAAF5\uAAF6\uABE3-\uABEA\uABEC\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F\u{101FD}\u{102E0}\u{10376}-\u{1037A}\u{10A01}-\u{10A03}\u{10A05}\u{10A06}\u{10A0C}-\u{10A0F}\u{10A38}-\u{10A3A}\u{10A3F}\u{10AE5}\u{10AE6}\u{10D24}-\u{10D27}\u{10D69}-\u{10D6D}\u{10EAB}\u{10EAC}\u{10EFA}-\u{10EFF}\u{10F46}-\u{10F50}\u{10F82}-\u{10F85}\u{11000}-\u{11002}\u{11038}-\u{11046}\u{11070}\u{11073}\u{11074}\u{1107F}-\u{11082}\u{110B0}-\u{110BA}\u{110C2}\u{11100}-\u{11102}\u{11127}-\u{11134}\u{11145}\u{11146}\u{11173}\u{11180}-\u{11182}\u{111B3}-\u{111C0}\u{111C9}-\u{111CC}\u{111CE}\u{111CF}\u{1122C}-\u{11237}\u{1123E}\u{11241}\u{112DF}-\u{112EA}\u{11300}-\u{11303}\u{1133B}\u{1133C}\u{1133E}-\u{11344}\u{11347}\u{11348}\u{1134B}-\u{1134D}\u{11357}\u{11362}\u{11363}\u{11366}-\u{1136C}\u{11370}-\u{11374}\u{113B8}-\u{113C0}\u{113C2}\u{113C5}\u{113C7}-\u{113CA}\u{113CC}-\u{113D0}\u{113D2}\u{113E1}\u{113E2}\u{11435}-\u{11446}\u{1145E}\u{114B0}-\u{114C3}\u{115AF}-\u{115B5}\u{115B8}-\u{115C0}\u{115DC}\u{115DD}\u{11630}-\u{11640}\u{116AB}-\u{116B7}\u{1171D}-\u{1172B}\u{1182C}-\u{1183A}\u{11930}-\u{11935}\u{11937}\u{11938}\u{1193B}-\u{1193E}\u{11940}\u{11942}\u{11943}\u{119D1}-\u{119D7}\u{119DA}-\u{119E0}\u{119E4}\u{11A01}-\u{11A0A}\u{11A33}-\u{11A39}\u{11A3B}-\u{11A3E}\u{11A47}\u{11A51}-\u{11A5B}\u{11A8A}-\u{11A99}\u{11B60}-\u{11B67}\u{11C2F}-\u{11C36}\u{11C38}-\u{11C3F}\u{11C92}-\u{11CA7}\u{11CA9}-\u{11CB6}\u{11D31}-\u{11D36}\u{11D3A}\u{11D3C}\u{11D3D}\u{11D3F}-\u{11D45}\u{11D47}\u{11D8A}-\u{11D8E}\u{11D90}\u{11D91}\u{11D93}-\u{11D97}\u{11EF3}-\u{11EF6}\u{11F00}\u{11F01}\u{11F03}\u{11F34}-\u{11F3A}\u{11F3E}-\u{11F42}\u{11F5A}\u{13440}\u{13447}-\u{13455}\u{1611E}-\u{1612F}\u{16AF0}-\u{16AF4}\u{16B30}-\u{16B36}\u{16F4F}\u{16F51}-\u{16F87}\u{16F8F}-\u{16F92}\u{16FE4}\u{16FF0}\u{16FF1}\u{1BC9D}\u{1BC9E}\u{1CF00}-\u{1CF2D}\u{1CF30}-\u{1CF46}\u{1D165}-\u{1D169}\u{1D16D}-\u{1D172}\u{1D17B}-\u{1D182}\u{1D185}-\u{1D18B}\u{1D1AA}-\u{1D1AD}\u{1D242}-\u{1D244}\u{1DA00}-\u{1DA36}\u{1DA3B}-\u{1DA6C}\u{1DA75}\u{1DA84}\u{1DA9B}-\u{1DA9F}\u{1DAA1}-\u{1DAAF}\u{1E000}-\u{1E006}\u{1E008}-\u{1E018}\u{1E01B}-\u{1E021}\u{1E023}\u{1E024}\u{1E026}-\u{1E02A}\u{1E08F}\u{1E130}-\u{1E136}\u{1E2AE}\u{1E2EC}-\u{1E2EF}\u{1E4EC}-\u{1E4EF}\u{1E5EE}\u{1E5EF}\u{1E6E3}\u{1E6E6}\u{1E6EE}\u{1E6EF}\u{1E6F5}\u{1E8D0}-\u{1E8D6}\u{1E944}-\u{1E94A}\u{E0100}-\u{E01EF}]/u; +const combiningClassVirama = /[\u094D\u09CD\u0A4D\u0ACD\u0B4D\u0BCD\u0C4D\u0CCD\u0D3B\u0D3C\u0D4D\u0DCA\u0E3A\u0EBA\u0F84\u1039\u103A\u1714\u1715\u1734\u17D2\u1A60\u1B44\u1BAA\u1BAB\u1BF2\u1BF3\u2D7F\uA806\uA82C\uA8C4\uA953\uA9C0\uAAF6\uABED\u{10A3F}\u{11046}\u{11070}\u{1107F}\u{110B9}\u{11133}\u{11134}\u{111C0}\u{11235}\u{112EA}\u{1134D}\u{113CE}-\u{113D0}\u{11442}\u{114C2}\u{115BF}\u{1163F}\u{116B6}\u{1172B}\u{11839}\u{1193D}\u{1193E}\u{119E0}\u{11A34}\u{11A47}\u{11A99}\u{11C3F}\u{11D44}\u{11D45}\u{11D97}\u{11F41}\u{11F42}\u{1612F}]/u; +const validZWNJ = /[\u0620\u0626\u0628\u062A-\u062E\u0633-\u063F\u0641-\u0647\u0649\u064A\u066E\u066F\u0678-\u0687\u069A-\u06BF\u06C1\u06C2\u06CC\u06CE\u06D0\u06D1\u06FA-\u06FC\u06FF\u0712-\u0714\u071A-\u071D\u071F-\u0727\u0729\u072B\u072D\u072E\u074E-\u0758\u075C-\u076A\u076D-\u0770\u0772\u0775-\u0777\u077A-\u077F\u07CA-\u07EA\u0841-\u0845\u0848\u084A-\u0853\u0855\u0860\u0862-\u0865\u0868\u0886\u0889-\u088D\u088F\u08A0-\u08A9\u08AF\u08B0\u08B3-\u08B8\u08BA-\u08C8\u1807\u1820-\u1878\u1887-\u18A8\u18AA\uA840-\uA872\u{10AC0}-\u{10AC4}\u{10ACD}\u{10AD3}-\u{10ADC}\u{10ADE}-\u{10AE0}\u{10AEB}-\u{10AEE}\u{10B80}\u{10B82}\u{10B86}-\u{10B88}\u{10B8A}\u{10B8B}\u{10B8D}\u{10B90}\u{10BAD}\u{10BAE}\u{10D00}-\u{10D21}\u{10D23}\u{10EC3}\u{10EC4}\u{10EC6}\u{10EC7}\u{10F30}-\u{10F32}\u{10F34}-\u{10F44}\u{10F51}-\u{10F53}\u{10F70}-\u{10F73}\u{10F76}-\u{10F81}\u{10FB0}\u{10FB2}\u{10FB3}\u{10FB8}\u{10FBB}\u{10FBC}\u{10FBE}\u{10FBF}\u{10FC1}\u{10FC4}\u{10FCA}\u{10FCB}\u{1E900}-\u{1E943}][\xAD\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u061C\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u070F\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u0897-\u089F\u08CA-\u08E1\u08E3-\u0902\u093A\u093C\u0941-\u0948\u094D\u0951-\u0957\u0962\u0963\u0981\u09BC\u09C1-\u09C4\u09CD\u09E2\u09E3\u09FE\u0A01\u0A02\u0A3C\u0A41\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81\u0A82\u0ABC\u0AC1-\u0AC5\u0AC7\u0AC8\u0ACD\u0AE2\u0AE3\u0AFA-\u0AFF\u0B01\u0B3C\u0B3F\u0B41-\u0B44\u0B4D\u0B55\u0B56\u0B62\u0B63\u0B82\u0BC0\u0BCD\u0C00\u0C04\u0C3C\u0C3E-\u0C40\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81\u0CBC\u0CBF\u0CC6\u0CCC\u0CCD\u0CE2\u0CE3\u0D00\u0D01\u0D3B\u0D3C\u0D41-\u0D44\u0D4D\u0D62\u0D63\u0D81\u0DCA\u0DD2-\u0DD4\u0DD6\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EBC\u0EC8-\u0ECE\u0F18\u0F19\u0F35\u0F37\u0F39\u0F71-\u0F7E\u0F80-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102D-\u1030\u1032-\u1037\u1039\u103A\u103D\u103E\u1058\u1059\u105E-\u1060\u1071-\u1074\u1082\u1085\u1086\u108D\u109D\u135D-\u135F\u1712-\u1714\u1732\u1733\u1752\u1753\u1772\u1773\u17B4\u17B5\u17B7-\u17BD\u17C6\u17C9-\u17D3\u17DD\u180B-\u180D\u180F\u1885\u1886\u18A9\u1920-\u1922\u1927\u1928\u1932\u1939-\u193B\u1A17\u1A18\u1A1B\u1A56\u1A58-\u1A5E\u1A60\u1A62\u1A65-\u1A6C\u1A73-\u1A7C\u1A7F\u1AB0-\u1ADD\u1AE0-\u1AEB\u1B00-\u1B03\u1B34\u1B36-\u1B3A\u1B3C\u1B42\u1B6B-\u1B73\u1B80\u1B81\u1BA2-\u1BA5\u1BA8\u1BA9\u1BAB-\u1BAD\u1BE6\u1BE8\u1BE9\u1BED\u1BEF-\u1BF1\u1C2C-\u1C33\u1C36\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE0\u1CE2-\u1CE8\u1CED\u1CF4\u1CF8\u1CF9\u1DC0-\u1DFF\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u206A-\u206F\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302D\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA825\uA826\uA82C\uA8C4\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA951\uA980-\uA982\uA9B3\uA9B6-\uA9B9\uA9BC\uA9BD\uA9E5\uAA29-\uAA2E\uAA31\uAA32\uAA35\uAA36\uAA43\uAA4C\uAA7C\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEC\uAAED\uAAF6\uABE5\uABE8\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F\uFEFF\uFFF9-\uFFFB\u{101FD}\u{102E0}\u{10376}-\u{1037A}\u{10A01}-\u{10A03}\u{10A05}\u{10A06}\u{10A0C}-\u{10A0F}\u{10A38}-\u{10A3A}\u{10A3F}\u{10AE5}\u{10AE6}\u{10D24}-\u{10D27}\u{10D69}-\u{10D6D}\u{10EAB}\u{10EAC}\u{10EFA}-\u{10EFF}\u{10F46}-\u{10F50}\u{10F82}-\u{10F85}\u{11001}\u{11038}-\u{11046}\u{11070}\u{11073}\u{11074}\u{1107F}-\u{11081}\u{110B3}-\u{110B6}\u{110B9}\u{110BA}\u{110C2}\u{11100}-\u{11102}\u{11127}-\u{1112B}\u{1112D}-\u{11134}\u{11173}\u{11180}\u{11181}\u{111B6}-\u{111BE}\u{111C9}-\u{111CC}\u{111CF}\u{1122F}-\u{11231}\u{11234}\u{11236}\u{11237}\u{1123E}\u{11241}\u{112DF}\u{112E3}-\u{112EA}\u{11300}\u{11301}\u{1133B}\u{1133C}\u{11340}\u{11366}-\u{1136C}\u{11370}-\u{11374}\u{113BB}-\u{113C0}\u{113CE}\u{113D0}\u{113D2}\u{113E1}\u{113E2}\u{11438}-\u{1143F}\u{11442}-\u{11444}\u{11446}\u{1145E}\u{114B3}-\u{114B8}\u{114BA}\u{114BF}\u{114C0}\u{114C2}\u{114C3}\u{115B2}-\u{115B5}\u{115BC}\u{115BD}\u{115BF}\u{115C0}\u{115DC}\u{115DD}\u{11633}-\u{1163A}\u{1163D}\u{1163F}\u{11640}\u{116AB}\u{116AD}\u{116B0}-\u{116B5}\u{116B7}\u{1171D}\u{1171F}\u{11722}-\u{11725}\u{11727}-\u{1172B}\u{1182F}-\u{11837}\u{11839}\u{1183A}\u{1193B}\u{1193C}\u{1193E}\u{11943}\u{119D4}-\u{119D7}\u{119DA}\u{119DB}\u{119E0}\u{11A01}-\u{11A0A}\u{11A33}-\u{11A38}\u{11A3B}-\u{11A3E}\u{11A47}\u{11A51}-\u{11A56}\u{11A59}-\u{11A5B}\u{11A8A}-\u{11A96}\u{11A98}\u{11A99}\u{11B60}\u{11B62}-\u{11B64}\u{11B66}\u{11C30}-\u{11C36}\u{11C38}-\u{11C3D}\u{11C3F}\u{11C92}-\u{11CA7}\u{11CAA}-\u{11CB0}\u{11CB2}\u{11CB3}\u{11CB5}\u{11CB6}\u{11D31}-\u{11D36}\u{11D3A}\u{11D3C}\u{11D3D}\u{11D3F}-\u{11D45}\u{11D47}\u{11D90}\u{11D91}\u{11D95}\u{11D97}\u{11EF3}\u{11EF4}\u{11F00}\u{11F01}\u{11F36}-\u{11F3A}\u{11F40}\u{11F42}\u{11F5A}\u{13430}-\u{13440}\u{13447}-\u{13455}\u{1611E}-\u{16129}\u{1612D}-\u{1612F}\u{16AF0}-\u{16AF4}\u{16B30}-\u{16B36}\u{16F4F}\u{16F8F}-\u{16F92}\u{16FE4}\u{1BC9D}\u{1BC9E}\u{1BCA0}-\u{1BCA3}\u{1CF00}-\u{1CF2D}\u{1CF30}-\u{1CF46}\u{1D167}-\u{1D169}\u{1D173}-\u{1D182}\u{1D185}-\u{1D18B}\u{1D1AA}-\u{1D1AD}\u{1D242}-\u{1D244}\u{1DA00}-\u{1DA36}\u{1DA3B}-\u{1DA6C}\u{1DA75}\u{1DA84}\u{1DA9B}-\u{1DA9F}\u{1DAA1}-\u{1DAAF}\u{1E000}-\u{1E006}\u{1E008}-\u{1E018}\u{1E01B}-\u{1E021}\u{1E023}\u{1E024}\u{1E026}-\u{1E02A}\u{1E08F}\u{1E130}-\u{1E136}\u{1E2AE}\u{1E2EC}-\u{1E2EF}\u{1E4EC}-\u{1E4EF}\u{1E5EE}\u{1E5EF}\u{1E6E3}\u{1E6E6}\u{1E6EE}\u{1E6EF}\u{1E6F5}\u{1E8D0}-\u{1E8D6}\u{1E944}-\u{1E94B}\u{E0001}\u{E0020}-\u{E007F}\u{E0100}-\u{E01EF}]*\u200C[\xAD\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u061C\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u070F\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u0897-\u089F\u08CA-\u08E1\u08E3-\u0902\u093A\u093C\u0941-\u0948\u094D\u0951-\u0957\u0962\u0963\u0981\u09BC\u09C1-\u09C4\u09CD\u09E2\u09E3\u09FE\u0A01\u0A02\u0A3C\u0A41\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81\u0A82\u0ABC\u0AC1-\u0AC5\u0AC7\u0AC8\u0ACD\u0AE2\u0AE3\u0AFA-\u0AFF\u0B01\u0B3C\u0B3F\u0B41-\u0B44\u0B4D\u0B55\u0B56\u0B62\u0B63\u0B82\u0BC0\u0BCD\u0C00\u0C04\u0C3C\u0C3E-\u0C40\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81\u0CBC\u0CBF\u0CC6\u0CCC\u0CCD\u0CE2\u0CE3\u0D00\u0D01\u0D3B\u0D3C\u0D41-\u0D44\u0D4D\u0D62\u0D63\u0D81\u0DCA\u0DD2-\u0DD4\u0DD6\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EBC\u0EC8-\u0ECE\u0F18\u0F19\u0F35\u0F37\u0F39\u0F71-\u0F7E\u0F80-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102D-\u1030\u1032-\u1037\u1039\u103A\u103D\u103E\u1058\u1059\u105E-\u1060\u1071-\u1074\u1082\u1085\u1086\u108D\u109D\u135D-\u135F\u1712-\u1714\u1732\u1733\u1752\u1753\u1772\u1773\u17B4\u17B5\u17B7-\u17BD\u17C6\u17C9-\u17D3\u17DD\u180B-\u180D\u180F\u1885\u1886\u18A9\u1920-\u1922\u1927\u1928\u1932\u1939-\u193B\u1A17\u1A18\u1A1B\u1A56\u1A58-\u1A5E\u1A60\u1A62\u1A65-\u1A6C\u1A73-\u1A7C\u1A7F\u1AB0-\u1ADD\u1AE0-\u1AEB\u1B00-\u1B03\u1B34\u1B36-\u1B3A\u1B3C\u1B42\u1B6B-\u1B73\u1B80\u1B81\u1BA2-\u1BA5\u1BA8\u1BA9\u1BAB-\u1BAD\u1BE6\u1BE8\u1BE9\u1BED\u1BEF-\u1BF1\u1C2C-\u1C33\u1C36\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE0\u1CE2-\u1CE8\u1CED\u1CF4\u1CF8\u1CF9\u1DC0-\u1DFF\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u206A-\u206F\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302D\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA825\uA826\uA82C\uA8C4\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA951\uA980-\uA982\uA9B3\uA9B6-\uA9B9\uA9BC\uA9BD\uA9E5\uAA29-\uAA2E\uAA31\uAA32\uAA35\uAA36\uAA43\uAA4C\uAA7C\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEC\uAAED\uAAF6\uABE5\uABE8\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F\uFEFF\uFFF9-\uFFFB\u{101FD}\u{102E0}\u{10376}-\u{1037A}\u{10A01}-\u{10A03}\u{10A05}\u{10A06}\u{10A0C}-\u{10A0F}\u{10A38}-\u{10A3A}\u{10A3F}\u{10AE5}\u{10AE6}\u{10D24}-\u{10D27}\u{10D69}-\u{10D6D}\u{10EAB}\u{10EAC}\u{10EFA}-\u{10EFF}\u{10F46}-\u{10F50}\u{10F82}-\u{10F85}\u{11001}\u{11038}-\u{11046}\u{11070}\u{11073}\u{11074}\u{1107F}-\u{11081}\u{110B3}-\u{110B6}\u{110B9}\u{110BA}\u{110C2}\u{11100}-\u{11102}\u{11127}-\u{1112B}\u{1112D}-\u{11134}\u{11173}\u{11180}\u{11181}\u{111B6}-\u{111BE}\u{111C9}-\u{111CC}\u{111CF}\u{1122F}-\u{11231}\u{11234}\u{11236}\u{11237}\u{1123E}\u{11241}\u{112DF}\u{112E3}-\u{112EA}\u{11300}\u{11301}\u{1133B}\u{1133C}\u{11340}\u{11366}-\u{1136C}\u{11370}-\u{11374}\u{113BB}-\u{113C0}\u{113CE}\u{113D0}\u{113D2}\u{113E1}\u{113E2}\u{11438}-\u{1143F}\u{11442}-\u{11444}\u{11446}\u{1145E}\u{114B3}-\u{114B8}\u{114BA}\u{114BF}\u{114C0}\u{114C2}\u{114C3}\u{115B2}-\u{115B5}\u{115BC}\u{115BD}\u{115BF}\u{115C0}\u{115DC}\u{115DD}\u{11633}-\u{1163A}\u{1163D}\u{1163F}\u{11640}\u{116AB}\u{116AD}\u{116B0}-\u{116B5}\u{116B7}\u{1171D}\u{1171F}\u{11722}-\u{11725}\u{11727}-\u{1172B}\u{1182F}-\u{11837}\u{11839}\u{1183A}\u{1193B}\u{1193C}\u{1193E}\u{11943}\u{119D4}-\u{119D7}\u{119DA}\u{119DB}\u{119E0}\u{11A01}-\u{11A0A}\u{11A33}-\u{11A38}\u{11A3B}-\u{11A3E}\u{11A47}\u{11A51}-\u{11A56}\u{11A59}-\u{11A5B}\u{11A8A}-\u{11A96}\u{11A98}\u{11A99}\u{11B60}\u{11B62}-\u{11B64}\u{11B66}\u{11C30}-\u{11C36}\u{11C38}-\u{11C3D}\u{11C3F}\u{11C92}-\u{11CA7}\u{11CAA}-\u{11CB0}\u{11CB2}\u{11CB3}\u{11CB5}\u{11CB6}\u{11D31}-\u{11D36}\u{11D3A}\u{11D3C}\u{11D3D}\u{11D3F}-\u{11D45}\u{11D47}\u{11D90}\u{11D91}\u{11D95}\u{11D97}\u{11EF3}\u{11EF4}\u{11F00}\u{11F01}\u{11F36}-\u{11F3A}\u{11F40}\u{11F42}\u{11F5A}\u{13430}-\u{13440}\u{13447}-\u{13455}\u{1611E}-\u{16129}\u{1612D}-\u{1612F}\u{16AF0}-\u{16AF4}\u{16B30}-\u{16B36}\u{16F4F}\u{16F8F}-\u{16F92}\u{16FE4}\u{1BC9D}\u{1BC9E}\u{1BCA0}-\u{1BCA3}\u{1CF00}-\u{1CF2D}\u{1CF30}-\u{1CF46}\u{1D167}-\u{1D169}\u{1D173}-\u{1D182}\u{1D185}-\u{1D18B}\u{1D1AA}-\u{1D1AD}\u{1D242}-\u{1D244}\u{1DA00}-\u{1DA36}\u{1DA3B}-\u{1DA6C}\u{1DA75}\u{1DA84}\u{1DA9B}-\u{1DA9F}\u{1DAA1}-\u{1DAAF}\u{1E000}-\u{1E006}\u{1E008}-\u{1E018}\u{1E01B}-\u{1E021}\u{1E023}\u{1E024}\u{1E026}-\u{1E02A}\u{1E08F}\u{1E130}-\u{1E136}\u{1E2AE}\u{1E2EC}-\u{1E2EF}\u{1E4EC}-\u{1E4EF}\u{1E5EE}\u{1E5EF}\u{1E6E3}\u{1E6E6}\u{1E6EE}\u{1E6EF}\u{1E6F5}\u{1E8D0}-\u{1E8D6}\u{1E944}-\u{1E94B}\u{E0001}\u{E0020}-\u{E007F}\u{E0100}-\u{E01EF}]*[\u0620\u0622-\u063F\u0641-\u064A\u066E\u066F\u0671-\u0673\u0675-\u06D3\u06D5\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u077F\u07CA-\u07EA\u0840-\u0858\u0860\u0862-\u0865\u0867-\u086A\u0870-\u0882\u0886\u0889-\u088F\u08A0-\u08AC\u08AE-\u08C8\u1807\u1820-\u1878\u1887-\u18A8\u18AA\uA840-\uA871\u{10AC0}-\u{10AC5}\u{10AC7}\u{10AC9}\u{10ACA}\u{10ACE}-\u{10AD6}\u{10AD8}-\u{10AE1}\u{10AE4}\u{10AEB}-\u{10AEF}\u{10B80}-\u{10B91}\u{10BA9}-\u{10BAE}\u{10D01}-\u{10D23}\u{10EC2}-\u{10EC4}\u{10EC6}\u{10EC7}\u{10F30}-\u{10F44}\u{10F51}-\u{10F54}\u{10F70}-\u{10F81}\u{10FB0}\u{10FB2}-\u{10FB6}\u{10FB8}-\u{10FBF}\u{10FC1}-\u{10FC4}\u{10FC9}\u{10FCA}\u{1E900}-\u{1E943}]/u; +const bidiDomain = /[\u05BE\u05C0\u05C3\u05C6\u05D0-\u05EA\u05EF-\u05F4\u0600-\u0605\u0608\u060B\u060D\u061B-\u064A\u0660-\u0669\u066B-\u066F\u0671-\u06D5\u06DD\u06E5\u06E6\u06EE\u06EF\u06FA-\u070D\u070F\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u07FE-\u0815\u081A\u0824\u0828\u0830-\u083E\u0840-\u0858\u085E\u0860-\u086A\u0870-\u0891\u08A0-\u08C9\u08E2\u200F\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBC2\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFC\uFE70-\uFE74\uFE76-\uFEFC\u{10800}-\u{10805}\u{10808}\u{1080A}-\u{10835}\u{10837}\u{10838}\u{1083C}\u{1083F}-\u{10855}\u{10857}-\u{1089E}\u{108A7}-\u{108AF}\u{108E0}-\u{108F2}\u{108F4}\u{108F5}\u{108FB}-\u{1091B}\u{10920}-\u{10939}\u{1093F}-\u{10959}\u{10980}-\u{109B7}\u{109BC}-\u{109CF}\u{109D2}-\u{10A00}\u{10A10}-\u{10A13}\u{10A15}-\u{10A17}\u{10A19}-\u{10A35}\u{10A40}-\u{10A48}\u{10A50}-\u{10A58}\u{10A60}-\u{10A9F}\u{10AC0}-\u{10AE4}\u{10AEB}-\u{10AF6}\u{10B00}-\u{10B35}\u{10B40}-\u{10B55}\u{10B58}-\u{10B72}\u{10B78}-\u{10B91}\u{10B99}-\u{10B9C}\u{10BA9}-\u{10BAF}\u{10C00}-\u{10C48}\u{10C80}-\u{10CB2}\u{10CC0}-\u{10CF2}\u{10CFA}-\u{10D23}\u{10D30}-\u{10D39}\u{10D40}-\u{10D65}\u{10D6F}-\u{10D85}\u{10D8E}\u{10D8F}\u{10E60}-\u{10E7E}\u{10E80}-\u{10EA9}\u{10EAD}\u{10EB0}\u{10EB1}\u{10EC2}-\u{10EC7}\u{10F00}-\u{10F27}\u{10F30}-\u{10F45}\u{10F51}-\u{10F59}\u{10F70}-\u{10F81}\u{10F86}-\u{10F89}\u{10FB0}-\u{10FCB}\u{10FE0}-\u{10FF6}\u{1E800}-\u{1E8C4}\u{1E8C7}-\u{1E8CF}\u{1E900}-\u{1E943}\u{1E94B}\u{1E950}-\u{1E959}\u{1E95E}\u{1E95F}\u{1EC71}-\u{1ECB4}\u{1ED01}-\u{1ED3D}\u{1EE00}-\u{1EE03}\u{1EE05}-\u{1EE1F}\u{1EE21}\u{1EE22}\u{1EE24}\u{1EE27}\u{1EE29}-\u{1EE32}\u{1EE34}-\u{1EE37}\u{1EE39}\u{1EE3B}\u{1EE42}\u{1EE47}\u{1EE49}\u{1EE4B}\u{1EE4D}-\u{1EE4F}\u{1EE51}\u{1EE52}\u{1EE54}\u{1EE57}\u{1EE59}\u{1EE5B}\u{1EE5D}\u{1EE5F}\u{1EE61}\u{1EE62}\u{1EE64}\u{1EE67}-\u{1EE6A}\u{1EE6C}-\u{1EE72}\u{1EE74}-\u{1EE77}\u{1EE79}-\u{1EE7C}\u{1EE7E}\u{1EE80}-\u{1EE89}\u{1EE8B}-\u{1EE9B}\u{1EEA1}-\u{1EEA3}\u{1EEA5}-\u{1EEA9}\u{1EEAB}-\u{1EEBB}]/u; +const bidiS1LTR = /[A-Za-z\xAA\xB5\xBA\xC0-\xD6\xD8-\xF6\xF8-\u02B8\u02BB-\u02C1\u02D0\u02D1\u02E0-\u02E4\u02EE\u0370-\u0373\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0482\u048A-\u052F\u0531-\u0556\u0559-\u0589\u0903-\u0939\u093B\u093D-\u0940\u0949-\u094C\u094E-\u0950\u0958-\u0961\u0964-\u0980\u0982\u0983\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD-\u09C0\u09C7\u09C8\u09CB\u09CC\u09CE\u09D7\u09DC\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\u09FA\u09FC\u09FD\u0A03\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A3E-\u0A40\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A76\u0A83\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD-\u0AC0\u0AC9\u0ACB\u0ACC\u0AD0\u0AE0\u0AE1\u0AE6-\u0AF0\u0AF9\u0B02\u0B03\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B3E\u0B40\u0B47\u0B48\u0B4B\u0B4C\u0B57\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BBE\u0BBF\u0BC1\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCC\u0BD0\u0BD7\u0BE6-\u0BF2\u0C01-\u0C03\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C41-\u0C44\u0C58-\u0C5A\u0C5C\u0C5D\u0C60\u0C61\u0C66-\u0C6F\u0C77\u0C7F\u0C80\u0C82-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD-\u0CC4\u0CC6-\u0CC8\u0CCA\u0CCB\u0CD5\u0CD6\u0CDC-\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0CF1-\u0CF3\u0D02-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D-\u0D40\u0D46-\u0D48\u0D4A-\u0D4C\u0D4E\u0D4F\u0D54-\u0D61\u0D66-\u0D7F\u0D82\u0D83\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DCF-\u0DD1\u0DD8-\u0DDF\u0DE6-\u0DEF\u0DF2-\u0DF4\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E4F-\u0E5B\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00-\u0F17\u0F1A-\u0F34\u0F36\u0F38\u0F3E-\u0F47\u0F49-\u0F6C\u0F7F\u0F85\u0F88-\u0F8C\u0FBE-\u0FC5\u0FC7-\u0FCC\u0FCE-\u0FDA\u1000-\u102C\u1031\u1038\u103B\u103C\u103F-\u1057\u105A-\u105D\u1061-\u1070\u1075-\u1081\u1083\u1084\u1087-\u108C\u108E-\u109C\u109E-\u10C5\u10C7\u10CD\u10D0-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1360-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u167F\u1681-\u169A\u16A0-\u16F8\u1700-\u1711\u1715\u171F-\u1731\u1734-\u1736\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17B6\u17BE-\u17C5\u17C7\u17C8\u17D4-\u17DA\u17DC\u17E0-\u17E9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1923-\u1926\u1929-\u192B\u1930\u1931\u1933-\u1938\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A19\u1A1A\u1A1E-\u1A55\u1A57\u1A61\u1A63\u1A64\u1A6D-\u1A72\u1A80-\u1A89\u1A90-\u1A99\u1AA0-\u1AAD\u1B04-\u1B33\u1B35\u1B3B\u1B3D-\u1B41\u1B43-\u1B4C\u1B4E-\u1B6A\u1B74-\u1B7F\u1B82-\u1BA1\u1BA6\u1BA7\u1BAA\u1BAE-\u1BE5\u1BE7\u1BEA-\u1BEC\u1BEE\u1BF2\u1BF3\u1BFC-\u1C2B\u1C34\u1C35\u1C3B-\u1C49\u1C4D-\u1C8A\u1C90-\u1CBA\u1CBD-\u1CC7\u1CD3\u1CE1\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5-\u1CF7\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u200E\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u214F\u2160-\u2188\u2336-\u237A\u2395\u249C-\u24E9\u26AC\u2800-\u28FF\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D70\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u3005-\u3007\u3021-\u3029\u302E\u302F\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3190-\u31BF\u31F0-\u321C\u3220-\u324F\u3260-\u327B\u327F-\u32B0\u32C0-\u32CB\u32D0-\u3376\u337B-\u33DD\u33E0-\u33FE\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA60C\uA610-\uA62B\uA640-\uA66E\uA680-\uA69D\uA6A0-\uA6EF\uA6F2-\uA6F7\uA722-\uA787\uA789-\uA7DC\uA7F1-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA824\uA827\uA830-\uA837\uA840-\uA873\uA880-\uA8C3\uA8CE-\uA8D9\uA8F2-\uA8FE\uA900-\uA925\uA92E-\uA946\uA952\uA953\uA95F-\uA97C\uA983-\uA9B2\uA9B4\uA9B5\uA9BA\uA9BB\uA9BE-\uA9CD\uA9CF-\uA9D9\uA9DE-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA2F\uAA30\uAA33\uAA34\uAA40-\uAA42\uAA44-\uAA4B\uAA4D\uAA50-\uAA59\uAA5C-\uAA7B\uAA7D-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAAEB\uAAEE-\uAAF5\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB69\uAB70-\uABE4\uABE6\uABE7\uABE9-\uABEC\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uD800-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC\u{10000}-\u{1000B}\u{1000D}-\u{10026}\u{10028}-\u{1003A}\u{1003C}\u{1003D}\u{1003F}-\u{1004D}\u{10050}-\u{1005D}\u{10080}-\u{100FA}\u{10100}\u{10102}\u{10107}-\u{10133}\u{10137}-\u{1013F}\u{1018D}\u{1018E}\u{101D0}-\u{101FC}\u{10280}-\u{1029C}\u{102A0}-\u{102D0}\u{10300}-\u{10323}\u{1032D}-\u{1034A}\u{10350}-\u{10375}\u{10380}-\u{1039D}\u{1039F}-\u{103C3}\u{103C8}-\u{103D5}\u{10400}-\u{1049D}\u{104A0}-\u{104A9}\u{104B0}-\u{104D3}\u{104D8}-\u{104FB}\u{10500}-\u{10527}\u{10530}-\u{10563}\u{1056F}-\u{1057A}\u{1057C}-\u{1058A}\u{1058C}-\u{10592}\u{10594}\u{10595}\u{10597}-\u{105A1}\u{105A3}-\u{105B1}\u{105B3}-\u{105B9}\u{105BB}\u{105BC}\u{105C0}-\u{105F3}\u{10600}-\u{10736}\u{10740}-\u{10755}\u{10760}-\u{10767}\u{10780}-\u{10785}\u{10787}-\u{107B0}\u{107B2}-\u{107BA}\u{11000}\u{11002}-\u{11037}\u{11047}-\u{1104D}\u{11066}-\u{1106F}\u{11071}\u{11072}\u{11075}\u{11082}-\u{110B2}\u{110B7}\u{110B8}\u{110BB}-\u{110C1}\u{110CD}\u{110D0}-\u{110E8}\u{110F0}-\u{110F9}\u{11103}-\u{11126}\u{1112C}\u{11136}-\u{11147}\u{11150}-\u{11172}\u{11174}-\u{11176}\u{11182}-\u{111B5}\u{111BF}-\u{111C8}\u{111CD}\u{111CE}\u{111D0}-\u{111DF}\u{111E1}-\u{111F4}\u{11200}-\u{11211}\u{11213}-\u{1122E}\u{11232}\u{11233}\u{11235}\u{11238}-\u{1123D}\u{1123F}\u{11240}\u{11280}-\u{11286}\u{11288}\u{1128A}-\u{1128D}\u{1128F}-\u{1129D}\u{1129F}-\u{112A9}\u{112B0}-\u{112DE}\u{112E0}-\u{112E2}\u{112F0}-\u{112F9}\u{11302}\u{11303}\u{11305}-\u{1130C}\u{1130F}\u{11310}\u{11313}-\u{11328}\u{1132A}-\u{11330}\u{11332}\u{11333}\u{11335}-\u{11339}\u{1133D}-\u{1133F}\u{11341}-\u{11344}\u{11347}\u{11348}\u{1134B}-\u{1134D}\u{11350}\u{11357}\u{1135D}-\u{11363}\u{11380}-\u{11389}\u{1138B}\u{1138E}\u{11390}-\u{113B5}\u{113B7}-\u{113BA}\u{113C2}\u{113C5}\u{113C7}-\u{113CA}\u{113CC}\u{113CD}\u{113CF}\u{113D1}\u{113D3}-\u{113D5}\u{113D7}\u{113D8}\u{11400}-\u{11437}\u{11440}\u{11441}\u{11445}\u{11447}-\u{1145B}\u{1145D}\u{1145F}-\u{11461}\u{11480}-\u{114B2}\u{114B9}\u{114BB}-\u{114BE}\u{114C1}\u{114C4}-\u{114C7}\u{114D0}-\u{114D9}\u{11580}-\u{115B1}\u{115B8}-\u{115BB}\u{115BE}\u{115C1}-\u{115DB}\u{11600}-\u{11632}\u{1163B}\u{1163C}\u{1163E}\u{11641}-\u{11644}\u{11650}-\u{11659}\u{11680}-\u{116AA}\u{116AC}\u{116AE}\u{116AF}\u{116B6}\u{116B8}\u{116B9}\u{116C0}-\u{116C9}\u{116D0}-\u{116E3}\u{11700}-\u{1171A}\u{1171E}\u{11720}\u{11721}\u{11726}\u{11730}-\u{11746}\u{11800}-\u{1182E}\u{11838}\u{1183B}\u{118A0}-\u{118F2}\u{118FF}-\u{11906}\u{11909}\u{1190C}-\u{11913}\u{11915}\u{11916}\u{11918}-\u{11935}\u{11937}\u{11938}\u{1193D}\u{1193F}-\u{11942}\u{11944}-\u{11946}\u{11950}-\u{11959}\u{119A0}-\u{119A7}\u{119AA}-\u{119D3}\u{119DC}-\u{119DF}\u{119E1}-\u{119E4}\u{11A00}\u{11A07}\u{11A08}\u{11A0B}-\u{11A32}\u{11A39}\u{11A3A}\u{11A3F}-\u{11A46}\u{11A50}\u{11A57}\u{11A58}\u{11A5C}-\u{11A89}\u{11A97}\u{11A9A}-\u{11AA2}\u{11AB0}-\u{11AF8}\u{11B00}-\u{11B09}\u{11B61}\u{11B65}\u{11B67}\u{11BC0}-\u{11BE1}\u{11BF0}-\u{11BF9}\u{11C00}-\u{11C08}\u{11C0A}-\u{11C2F}\u{11C3E}-\u{11C45}\u{11C50}-\u{11C6C}\u{11C70}-\u{11C8F}\u{11CA9}\u{11CB1}\u{11CB4}\u{11D00}-\u{11D06}\u{11D08}\u{11D09}\u{11D0B}-\u{11D30}\u{11D46}\u{11D50}-\u{11D59}\u{11D60}-\u{11D65}\u{11D67}\u{11D68}\u{11D6A}-\u{11D8E}\u{11D93}\u{11D94}\u{11D96}\u{11D98}\u{11DA0}-\u{11DA9}\u{11DB0}-\u{11DDB}\u{11DE0}-\u{11DE9}\u{11EE0}-\u{11EF2}\u{11EF5}-\u{11EF8}\u{11F02}-\u{11F10}\u{11F12}-\u{11F35}\u{11F3E}\u{11F3F}\u{11F41}\u{11F43}-\u{11F59}\u{11FB0}\u{11FC0}-\u{11FD4}\u{11FFF}-\u{12399}\u{12400}-\u{1246E}\u{12470}-\u{12474}\u{12480}-\u{12543}\u{12F90}-\u{12FF2}\u{13000}-\u{1343F}\u{13441}-\u{13446}\u{13460}-\u{143FA}\u{14400}-\u{14646}\u{16100}-\u{1611D}\u{1612A}-\u{1612C}\u{16130}-\u{16139}\u{16800}-\u{16A38}\u{16A40}-\u{16A5E}\u{16A60}-\u{16A69}\u{16A6E}-\u{16ABE}\u{16AC0}-\u{16AC9}\u{16AD0}-\u{16AED}\u{16AF5}\u{16B00}-\u{16B2F}\u{16B37}-\u{16B45}\u{16B50}-\u{16B59}\u{16B5B}-\u{16B61}\u{16B63}-\u{16B77}\u{16B7D}-\u{16B8F}\u{16D40}-\u{16D79}\u{16E40}-\u{16E9A}\u{16EA0}-\u{16EB8}\u{16EBB}-\u{16ED3}\u{16F00}-\u{16F4A}\u{16F50}-\u{16F87}\u{16F93}-\u{16F9F}\u{16FE0}\u{16FE1}\u{16FE3}\u{16FF0}-\u{16FF6}\u{17000}-\u{18CD5}\u{18CFF}-\u{18D1E}\u{18D80}-\u{18DF2}\u{1AFF0}-\u{1AFF3}\u{1AFF5}-\u{1AFFB}\u{1AFFD}\u{1AFFE}\u{1B000}-\u{1B122}\u{1B132}\u{1B150}-\u{1B152}\u{1B155}\u{1B164}-\u{1B167}\u{1B170}-\u{1B2FB}\u{1BC00}-\u{1BC6A}\u{1BC70}-\u{1BC7C}\u{1BC80}-\u{1BC88}\u{1BC90}-\u{1BC99}\u{1BC9C}\u{1BC9F}\u{1CCD6}-\u{1CCEF}\u{1CF50}-\u{1CFC3}\u{1D000}-\u{1D0F5}\u{1D100}-\u{1D126}\u{1D129}-\u{1D166}\u{1D16A}-\u{1D172}\u{1D183}\u{1D184}\u{1D18C}-\u{1D1A9}\u{1D1AE}-\u{1D1E8}\u{1D2C0}-\u{1D2D3}\u{1D2E0}-\u{1D2F3}\u{1D360}-\u{1D378}\u{1D400}-\u{1D454}\u{1D456}-\u{1D49C}\u{1D49E}\u{1D49F}\u{1D4A2}\u{1D4A5}\u{1D4A6}\u{1D4A9}-\u{1D4AC}\u{1D4AE}-\u{1D4B9}\u{1D4BB}\u{1D4BD}-\u{1D4C3}\u{1D4C5}-\u{1D505}\u{1D507}-\u{1D50A}\u{1D50D}-\u{1D514}\u{1D516}-\u{1D51C}\u{1D51E}-\u{1D539}\u{1D53B}-\u{1D53E}\u{1D540}-\u{1D544}\u{1D546}\u{1D54A}-\u{1D550}\u{1D552}-\u{1D6A5}\u{1D6A8}-\u{1D6C0}\u{1D6C2}-\u{1D6DA}\u{1D6DC}-\u{1D6FA}\u{1D6FC}-\u{1D714}\u{1D716}-\u{1D734}\u{1D736}-\u{1D74E}\u{1D750}-\u{1D76E}\u{1D770}-\u{1D788}\u{1D78A}-\u{1D7A8}\u{1D7AA}-\u{1D7C2}\u{1D7C4}-\u{1D7CB}\u{1D800}-\u{1D9FF}\u{1DA37}-\u{1DA3A}\u{1DA6D}-\u{1DA74}\u{1DA76}-\u{1DA83}\u{1DA85}-\u{1DA8B}\u{1DF00}-\u{1DF1E}\u{1DF25}-\u{1DF2A}\u{1E030}-\u{1E06D}\u{1E100}-\u{1E12C}\u{1E137}-\u{1E13D}\u{1E140}-\u{1E149}\u{1E14E}\u{1E14F}\u{1E290}-\u{1E2AD}\u{1E2C0}-\u{1E2EB}\u{1E2F0}-\u{1E2F9}\u{1E4D0}-\u{1E4EB}\u{1E4F0}-\u{1E4F9}\u{1E5D0}-\u{1E5ED}\u{1E5F0}-\u{1E5FA}\u{1E5FF}\u{1E6C0}-\u{1E6DE}\u{1E6E0}-\u{1E6E2}\u{1E6E4}\u{1E6E5}\u{1E6E7}-\u{1E6ED}\u{1E6F0}-\u{1E6F4}\u{1E6FE}\u{1E6FF}\u{1E7E0}-\u{1E7E6}\u{1E7E8}-\u{1E7EB}\u{1E7ED}\u{1E7EE}\u{1E7F0}-\u{1E7FE}\u{1F110}-\u{1F12E}\u{1F130}-\u{1F169}\u{1F170}-\u{1F1AC}\u{1F1E6}-\u{1F202}\u{1F210}-\u{1F23B}\u{1F240}-\u{1F248}\u{1F250}\u{1F251}\u{20000}-\u{2A6DF}\u{2A700}-\u{2B81D}\u{2B820}-\u{2CEAD}\u{2CEB0}-\u{2EBE0}\u{2EBF0}-\u{2EE5D}\u{2F800}-\u{2FA1D}\u{30000}-\u{3134A}\u{31350}-\u{33479}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/u; +const bidiS1RTL = /[\u05BE\u05C0\u05C3\u05C6\u05D0-\u05EA\u05EF-\u05F4\u0608\u060B\u060D\u061B-\u064A\u066D-\u066F\u0671-\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u070D\u070F\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u07FE-\u0815\u081A\u0824\u0828\u0830-\u083E\u0840-\u0858\u085E\u0860-\u086A\u0870-\u088F\u08A0-\u08C9\u200F\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBC2\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFC\uFE70-\uFE74\uFE76-\uFEFC\u{10800}-\u{10805}\u{10808}\u{1080A}-\u{10835}\u{10837}\u{10838}\u{1083C}\u{1083F}-\u{10855}\u{10857}-\u{1089E}\u{108A7}-\u{108AF}\u{108E0}-\u{108F2}\u{108F4}\u{108F5}\u{108FB}-\u{1091B}\u{10920}-\u{10939}\u{1093F}-\u{10959}\u{10980}-\u{109B7}\u{109BC}-\u{109CF}\u{109D2}-\u{10A00}\u{10A10}-\u{10A13}\u{10A15}-\u{10A17}\u{10A19}-\u{10A35}\u{10A40}-\u{10A48}\u{10A50}-\u{10A58}\u{10A60}-\u{10A9F}\u{10AC0}-\u{10AE4}\u{10AEB}-\u{10AF6}\u{10B00}-\u{10B35}\u{10B40}-\u{10B55}\u{10B58}-\u{10B72}\u{10B78}-\u{10B91}\u{10B99}-\u{10B9C}\u{10BA9}-\u{10BAF}\u{10C00}-\u{10C48}\u{10C80}-\u{10CB2}\u{10CC0}-\u{10CF2}\u{10CFA}-\u{10D23}\u{10D4A}-\u{10D65}\u{10D6F}-\u{10D85}\u{10D8E}\u{10D8F}\u{10E80}-\u{10EA9}\u{10EAD}\u{10EB0}\u{10EB1}\u{10EC2}-\u{10EC7}\u{10F00}-\u{10F27}\u{10F30}-\u{10F45}\u{10F51}-\u{10F59}\u{10F70}-\u{10F81}\u{10F86}-\u{10F89}\u{10FB0}-\u{10FCB}\u{10FE0}-\u{10FF6}\u{1E800}-\u{1E8C4}\u{1E8C7}-\u{1E8CF}\u{1E900}-\u{1E943}\u{1E94B}\u{1E950}-\u{1E959}\u{1E95E}\u{1E95F}\u{1EC71}-\u{1ECB4}\u{1ED01}-\u{1ED3D}\u{1EE00}-\u{1EE03}\u{1EE05}-\u{1EE1F}\u{1EE21}\u{1EE22}\u{1EE24}\u{1EE27}\u{1EE29}-\u{1EE32}\u{1EE34}-\u{1EE37}\u{1EE39}\u{1EE3B}\u{1EE42}\u{1EE47}\u{1EE49}\u{1EE4B}\u{1EE4D}-\u{1EE4F}\u{1EE51}\u{1EE52}\u{1EE54}\u{1EE57}\u{1EE59}\u{1EE5B}\u{1EE5D}\u{1EE5F}\u{1EE61}\u{1EE62}\u{1EE64}\u{1EE67}-\u{1EE6A}\u{1EE6C}-\u{1EE72}\u{1EE74}-\u{1EE77}\u{1EE79}-\u{1EE7C}\u{1EE7E}\u{1EE80}-\u{1EE89}\u{1EE8B}-\u{1EE9B}\u{1EEA1}-\u{1EEA3}\u{1EEA5}-\u{1EEA9}\u{1EEAB}-\u{1EEBB}]/u; +const bidiS2 = /^[\0-\x08\x0E-\x1B!-@\[-`\{-\x84\x86-\xA9\xAB-\xB4\xB6-\xB9\xBB-\xBF\xD7\xF7\u02B9\u02BA\u02C2-\u02CF\u02D2-\u02DF\u02E5-\u02ED\u02EF-\u036F\u0374\u0375\u037E\u0384\u0385\u0387\u03F6\u0483-\u0489\u058A\u058D-\u058F\u0591-\u05C7\u05D0-\u05EA\u05EF-\u05F4\u0600-\u070D\u070F-\u074A\u074D-\u07B1\u07C0-\u07FA\u07FD-\u082D\u0830-\u083E\u0840-\u085B\u085E\u0860-\u086A\u0870-\u0891\u0897-\u0902\u093A\u093C\u0941-\u0948\u094D\u0951-\u0957\u0962\u0963\u0981\u09BC\u09C1-\u09C4\u09CD\u09E2\u09E3\u09F2\u09F3\u09FB\u09FE\u0A01\u0A02\u0A3C\u0A41\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81\u0A82\u0ABC\u0AC1-\u0AC5\u0AC7\u0AC8\u0ACD\u0AE2\u0AE3\u0AF1\u0AFA-\u0AFF\u0B01\u0B3C\u0B3F\u0B41-\u0B44\u0B4D\u0B55\u0B56\u0B62\u0B63\u0B82\u0BC0\u0BCD\u0BF3-\u0BFA\u0C00\u0C04\u0C3C\u0C3E-\u0C40\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C78-\u0C7E\u0C81\u0CBC\u0CCC\u0CCD\u0CE2\u0CE3\u0D00\u0D01\u0D3B\u0D3C\u0D41-\u0D44\u0D4D\u0D62\u0D63\u0D81\u0DCA\u0DD2-\u0DD4\u0DD6\u0E31\u0E34-\u0E3A\u0E3F\u0E47-\u0E4E\u0EB1\u0EB4-\u0EBC\u0EC8-\u0ECE\u0F18\u0F19\u0F35\u0F37\u0F39-\u0F3D\u0F71-\u0F7E\u0F80-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102D-\u1030\u1032-\u1037\u1039\u103A\u103D\u103E\u1058\u1059\u105E-\u1060\u1071-\u1074\u1082\u1085\u1086\u108D\u109D\u135D-\u135F\u1390-\u1399\u1400\u169B\u169C\u1712-\u1714\u1732\u1733\u1752\u1753\u1772\u1773\u17B4\u17B5\u17B7-\u17BD\u17C6\u17C9-\u17D3\u17DB\u17DD\u17F0-\u17F9\u1800-\u180F\u1885\u1886\u18A9\u1920-\u1922\u1927\u1928\u1932\u1939-\u193B\u1940\u1944\u1945\u19DE-\u19FF\u1A17\u1A18\u1A1B\u1A56\u1A58-\u1A5E\u1A60\u1A62\u1A65-\u1A6C\u1A73-\u1A7C\u1A7F\u1AB0-\u1ADD\u1AE0-\u1AEB\u1B00-\u1B03\u1B34\u1B36-\u1B3A\u1B3C\u1B42\u1B6B-\u1B73\u1B80\u1B81\u1BA2-\u1BA5\u1BA8\u1BA9\u1BAB-\u1BAD\u1BE6\u1BE8\u1BE9\u1BED\u1BEF-\u1BF1\u1C2C-\u1C33\u1C36\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE0\u1CE2-\u1CE8\u1CED\u1CF4\u1CF8\u1CF9\u1DC0-\u1DFF\u1FBD\u1FBF-\u1FC1\u1FCD-\u1FCF\u1FDD-\u1FDF\u1FED-\u1FEF\u1FFD\u1FFE\u200B-\u200D\u200F-\u2027\u202F-\u205E\u2060-\u2064\u206A-\u2070\u2074-\u207E\u2080-\u208E\u20A0-\u20C1\u20D0-\u20F0\u2100\u2101\u2103-\u2106\u2108\u2109\u2114\u2116-\u2118\u211E-\u2123\u2125\u2127\u2129\u212E\u213A\u213B\u2140-\u2144\u214A-\u214D\u2150-\u215F\u2189-\u218B\u2190-\u2335\u237B-\u2394\u2396-\u2429\u2440-\u244A\u2460-\u249B\u24EA-\u26AB\u26AD-\u27FF\u2900-\u2B73\u2B76-\u2BFF\u2CE5-\u2CEA\u2CEF-\u2CF1\u2CF9-\u2CFF\u2D7F\u2DE0-\u2E5D\u2E80-\u2E99\u2E9B-\u2EF3\u2F00-\u2FD5\u2FF0-\u2FFF\u3001-\u3004\u3008-\u3020\u302A-\u302D\u3030\u3036\u3037\u303D-\u303F\u3099-\u309C\u30A0\u30FB\u31C0-\u31E5\u31EF\u321D\u321E\u3250-\u325F\u327C-\u327E\u32B1-\u32BF\u32CC-\u32CF\u3377-\u337A\u33DE\u33DF\u33FF\u4DC0-\u4DFF\uA490-\uA4C6\uA60D-\uA60F\uA66F-\uA67F\uA69E\uA69F\uA6F0\uA6F1\uA700-\uA721\uA788\uA802\uA806\uA80B\uA825\uA826\uA828-\uA82C\uA838\uA839\uA874-\uA877\uA8C4\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA951\uA980-\uA982\uA9B3\uA9B6-\uA9B9\uA9BC\uA9BD\uA9E5\uAA29-\uAA2E\uAA31\uAA32\uAA35\uAA36\uAA43\uAA4C\uAA7C\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEC\uAAED\uAAF6\uAB6A\uAB6B\uABE5\uABE8\uABED\uFB1D-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFDCF\uFDF0-\uFE19\uFE20-\uFE52\uFE54-\uFE66\uFE68-\uFE6B\uFE70-\uFE74\uFE76-\uFEFC\uFEFF\uFF01-\uFF20\uFF3B-\uFF40\uFF5B-\uFF65\uFFE0-\uFFE6\uFFE8-\uFFEE\uFFF9-\uFFFD\u{10101}\u{10140}-\u{1018C}\u{10190}-\u{1019C}\u{101A0}\u{101FD}\u{102E0}-\u{102FB}\u{10376}-\u{1037A}\u{10800}-\u{10805}\u{10808}\u{1080A}-\u{10835}\u{10837}\u{10838}\u{1083C}\u{1083F}-\u{10855}\u{10857}-\u{1089E}\u{108A7}-\u{108AF}\u{108E0}-\u{108F2}\u{108F4}\u{108F5}\u{108FB}-\u{1091B}\u{1091F}-\u{10939}\u{1093F}-\u{10959}\u{10980}-\u{109B7}\u{109BC}-\u{109CF}\u{109D2}-\u{10A03}\u{10A05}\u{10A06}\u{10A0C}-\u{10A13}\u{10A15}-\u{10A17}\u{10A19}-\u{10A35}\u{10A38}-\u{10A3A}\u{10A3F}-\u{10A48}\u{10A50}-\u{10A58}\u{10A60}-\u{10A9F}\u{10AC0}-\u{10AE6}\u{10AEB}-\u{10AF6}\u{10B00}-\u{10B35}\u{10B39}-\u{10B55}\u{10B58}-\u{10B72}\u{10B78}-\u{10B91}\u{10B99}-\u{10B9C}\u{10BA9}-\u{10BAF}\u{10C00}-\u{10C48}\u{10C80}-\u{10CB2}\u{10CC0}-\u{10CF2}\u{10CFA}-\u{10D27}\u{10D30}-\u{10D39}\u{10D40}-\u{10D65}\u{10D69}-\u{10D85}\u{10D8E}\u{10D8F}\u{10E60}-\u{10E7E}\u{10E80}-\u{10EA9}\u{10EAB}-\u{10EAD}\u{10EB0}\u{10EB1}\u{10EC2}-\u{10EC7}\u{10ED0}-\u{10ED8}\u{10EFA}-\u{10F27}\u{10F30}-\u{10F59}\u{10F70}-\u{10F89}\u{10FB0}-\u{10FCB}\u{10FE0}-\u{10FF6}\u{11001}\u{11038}-\u{11046}\u{11052}-\u{11065}\u{11070}\u{11073}\u{11074}\u{1107F}-\u{11081}\u{110B3}-\u{110B6}\u{110B9}\u{110BA}\u{110C2}\u{11100}-\u{11102}\u{11127}-\u{1112B}\u{1112D}-\u{11134}\u{11173}\u{11180}\u{11181}\u{111B6}-\u{111BE}\u{111C9}-\u{111CC}\u{111CF}\u{1122F}-\u{11231}\u{11234}\u{11236}\u{11237}\u{1123E}\u{11241}\u{112DF}\u{112E3}-\u{112EA}\u{11300}\u{11301}\u{1133B}\u{1133C}\u{11340}\u{11366}-\u{1136C}\u{11370}-\u{11374}\u{113BB}-\u{113C0}\u{113CE}\u{113D0}\u{113D2}\u{113E1}\u{113E2}\u{11438}-\u{1143F}\u{11442}-\u{11444}\u{11446}\u{1145E}\u{114B3}-\u{114B8}\u{114BA}\u{114BF}\u{114C0}\u{114C2}\u{114C3}\u{115B2}-\u{115B5}\u{115BC}\u{115BD}\u{115BF}\u{115C0}\u{115DC}\u{115DD}\u{11633}-\u{1163A}\u{1163D}\u{1163F}\u{11640}\u{11660}-\u{1166C}\u{116AB}\u{116AD}\u{116B0}-\u{116B5}\u{116B7}\u{1171D}\u{1171F}\u{11722}-\u{11725}\u{11727}-\u{1172B}\u{1182F}-\u{11837}\u{11839}\u{1183A}\u{1193B}\u{1193C}\u{1193E}\u{11943}\u{119D4}-\u{119D7}\u{119DA}\u{119DB}\u{119E0}\u{11A01}-\u{11A06}\u{11A09}\u{11A0A}\u{11A33}-\u{11A38}\u{11A3B}-\u{11A3E}\u{11A47}\u{11A51}-\u{11A56}\u{11A59}-\u{11A5B}\u{11A8A}-\u{11A96}\u{11A98}\u{11A99}\u{11B60}\u{11B62}-\u{11B64}\u{11B66}\u{11C30}-\u{11C36}\u{11C38}-\u{11C3D}\u{11C92}-\u{11CA7}\u{11CAA}-\u{11CB0}\u{11CB2}\u{11CB3}\u{11CB5}\u{11CB6}\u{11D31}-\u{11D36}\u{11D3A}\u{11D3C}\u{11D3D}\u{11D3F}-\u{11D45}\u{11D47}\u{11D90}\u{11D91}\u{11D95}\u{11D97}\u{11EF3}\u{11EF4}\u{11F00}\u{11F01}\u{11F36}-\u{11F3A}\u{11F40}\u{11F42}\u{11F5A}\u{11FD5}-\u{11FF1}\u{13440}\u{13447}-\u{13455}\u{1611E}-\u{16129}\u{1612D}-\u{1612F}\u{16AF0}-\u{16AF4}\u{16B30}-\u{16B36}\u{16F4F}\u{16F8F}-\u{16F92}\u{16FE2}\u{16FE4}\u{1BC9D}\u{1BC9E}\u{1BCA0}-\u{1BCA3}\u{1CC00}-\u{1CCD5}\u{1CCF0}-\u{1CCFC}\u{1CD00}-\u{1CEB3}\u{1CEBA}-\u{1CED0}\u{1CEE0}-\u{1CEF0}\u{1CF00}-\u{1CF2D}\u{1CF30}-\u{1CF46}\u{1D167}-\u{1D169}\u{1D173}-\u{1D182}\u{1D185}-\u{1D18B}\u{1D1AA}-\u{1D1AD}\u{1D1E9}\u{1D1EA}\u{1D200}-\u{1D245}\u{1D300}-\u{1D356}\u{1D6C1}\u{1D6DB}\u{1D6FB}\u{1D715}\u{1D735}\u{1D74F}\u{1D76F}\u{1D789}\u{1D7A9}\u{1D7C3}\u{1D7CE}-\u{1D7FF}\u{1DA00}-\u{1DA36}\u{1DA3B}-\u{1DA6C}\u{1DA75}\u{1DA84}\u{1DA9B}-\u{1DA9F}\u{1DAA1}-\u{1DAAF}\u{1E000}-\u{1E006}\u{1E008}-\u{1E018}\u{1E01B}-\u{1E021}\u{1E023}\u{1E024}\u{1E026}-\u{1E02A}\u{1E08F}\u{1E130}-\u{1E136}\u{1E2AE}\u{1E2EC}-\u{1E2EF}\u{1E2FF}\u{1E4EC}-\u{1E4EF}\u{1E5EE}\u{1E5EF}\u{1E6E3}\u{1E6E6}\u{1E6EE}\u{1E6EF}\u{1E6F5}\u{1E800}-\u{1E8C4}\u{1E8C7}-\u{1E8D6}\u{1E900}-\u{1E94B}\u{1E950}-\u{1E959}\u{1E95E}\u{1E95F}\u{1EC71}-\u{1ECB4}\u{1ED01}-\u{1ED3D}\u{1EE00}-\u{1EE03}\u{1EE05}-\u{1EE1F}\u{1EE21}\u{1EE22}\u{1EE24}\u{1EE27}\u{1EE29}-\u{1EE32}\u{1EE34}-\u{1EE37}\u{1EE39}\u{1EE3B}\u{1EE42}\u{1EE47}\u{1EE49}\u{1EE4B}\u{1EE4D}-\u{1EE4F}\u{1EE51}\u{1EE52}\u{1EE54}\u{1EE57}\u{1EE59}\u{1EE5B}\u{1EE5D}\u{1EE5F}\u{1EE61}\u{1EE62}\u{1EE64}\u{1EE67}-\u{1EE6A}\u{1EE6C}-\u{1EE72}\u{1EE74}-\u{1EE77}\u{1EE79}-\u{1EE7C}\u{1EE7E}\u{1EE80}-\u{1EE89}\u{1EE8B}-\u{1EE9B}\u{1EEA1}-\u{1EEA3}\u{1EEA5}-\u{1EEA9}\u{1EEAB}-\u{1EEBB}\u{1EEF0}\u{1EEF1}\u{1F000}-\u{1F02B}\u{1F030}-\u{1F093}\u{1F0A0}-\u{1F0AE}\u{1F0B1}-\u{1F0BF}\u{1F0C1}-\u{1F0CF}\u{1F0D1}-\u{1F0F5}\u{1F100}-\u{1F10F}\u{1F12F}\u{1F16A}-\u{1F16F}\u{1F1AD}\u{1F260}-\u{1F265}\u{1F300}-\u{1F6D8}\u{1F6DC}-\u{1F6EC}\u{1F6F0}-\u{1F6FC}\u{1F700}-\u{1F7D9}\u{1F7E0}-\u{1F7EB}\u{1F7F0}\u{1F800}-\u{1F80B}\u{1F810}-\u{1F847}\u{1F850}-\u{1F859}\u{1F860}-\u{1F887}\u{1F890}-\u{1F8AD}\u{1F8B0}-\u{1F8BB}\u{1F8C0}\u{1F8C1}\u{1F8D0}-\u{1F8D8}\u{1F900}-\u{1FA57}\u{1FA60}-\u{1FA6D}\u{1FA70}-\u{1FA7C}\u{1FA80}-\u{1FA8A}\u{1FA8E}-\u{1FAC6}\u{1FAC8}\u{1FACD}-\u{1FADC}\u{1FADF}-\u{1FAEA}\u{1FAEF}-\u{1FAF8}\u{1FB00}-\u{1FB92}\u{1FB94}-\u{1FBFA}\u{E0001}\u{E0020}-\u{E007F}\u{E0100}-\u{E01EF}]*$/u; +const bidiS3 = /[0-9\xB2\xB3\xB9\u05BE\u05C0\u05C3\u05C6\u05D0-\u05EA\u05EF-\u05F4\u0600-\u0605\u0608\u060B\u060D\u061B-\u064A\u0660-\u0669\u066B-\u066F\u0671-\u06D5\u06DD\u06E5\u06E6\u06EE-\u070D\u070F\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u07FE-\u0815\u081A\u0824\u0828\u0830-\u083E\u0840-\u0858\u085E\u0860-\u086A\u0870-\u0891\u08A0-\u08C9\u08E2\u200F\u2070\u2074-\u2079\u2080-\u2089\u2488-\u249B\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBC2\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFC\uFE70-\uFE74\uFE76-\uFEFC\uFF10-\uFF19\u{102E1}-\u{102FB}\u{10800}-\u{10805}\u{10808}\u{1080A}-\u{10835}\u{10837}\u{10838}\u{1083C}\u{1083F}-\u{10855}\u{10857}-\u{1089E}\u{108A7}-\u{108AF}\u{108E0}-\u{108F2}\u{108F4}\u{108F5}\u{108FB}-\u{1091B}\u{10920}-\u{10939}\u{1093F}-\u{10959}\u{10980}-\u{109B7}\u{109BC}-\u{109CF}\u{109D2}-\u{10A00}\u{10A10}-\u{10A13}\u{10A15}-\u{10A17}\u{10A19}-\u{10A35}\u{10A40}-\u{10A48}\u{10A50}-\u{10A58}\u{10A60}-\u{10A9F}\u{10AC0}-\u{10AE4}\u{10AEB}-\u{10AF6}\u{10B00}-\u{10B35}\u{10B40}-\u{10B55}\u{10B58}-\u{10B72}\u{10B78}-\u{10B91}\u{10B99}-\u{10B9C}\u{10BA9}-\u{10BAF}\u{10C00}-\u{10C48}\u{10C80}-\u{10CB2}\u{10CC0}-\u{10CF2}\u{10CFA}-\u{10D23}\u{10D30}-\u{10D39}\u{10D40}-\u{10D65}\u{10D6F}-\u{10D85}\u{10D8E}\u{10D8F}\u{10E60}-\u{10E7E}\u{10E80}-\u{10EA9}\u{10EAD}\u{10EB0}\u{10EB1}\u{10EC2}-\u{10EC7}\u{10F00}-\u{10F27}\u{10F30}-\u{10F45}\u{10F51}-\u{10F59}\u{10F70}-\u{10F81}\u{10F86}-\u{10F89}\u{10FB0}-\u{10FCB}\u{10FE0}-\u{10FF6}\u{1CCF0}-\u{1CCF9}\u{1D7CE}-\u{1D7FF}\u{1E800}-\u{1E8C4}\u{1E8C7}-\u{1E8CF}\u{1E900}-\u{1E943}\u{1E94B}\u{1E950}-\u{1E959}\u{1E95E}\u{1E95F}\u{1EC71}-\u{1ECB4}\u{1ED01}-\u{1ED3D}\u{1EE00}-\u{1EE03}\u{1EE05}-\u{1EE1F}\u{1EE21}\u{1EE22}\u{1EE24}\u{1EE27}\u{1EE29}-\u{1EE32}\u{1EE34}-\u{1EE37}\u{1EE39}\u{1EE3B}\u{1EE42}\u{1EE47}\u{1EE49}\u{1EE4B}\u{1EE4D}-\u{1EE4F}\u{1EE51}\u{1EE52}\u{1EE54}\u{1EE57}\u{1EE59}\u{1EE5B}\u{1EE5D}\u{1EE5F}\u{1EE61}\u{1EE62}\u{1EE64}\u{1EE67}-\u{1EE6A}\u{1EE6C}-\u{1EE72}\u{1EE74}-\u{1EE77}\u{1EE79}-\u{1EE7C}\u{1EE7E}\u{1EE80}-\u{1EE89}\u{1EE8B}-\u{1EE9B}\u{1EEA1}-\u{1EEA3}\u{1EEA5}-\u{1EEA9}\u{1EEAB}-\u{1EEBB}\u{1F100}-\u{1F10A}\u{1FBF0}-\u{1FBF9}][\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u0897-\u089F\u08CA-\u08E1\u08E3-\u0902\u093A\u093C\u0941-\u0948\u094D\u0951-\u0957\u0962\u0963\u0981\u09BC\u09C1-\u09C4\u09CD\u09E2\u09E3\u09FE\u0A01\u0A02\u0A3C\u0A41\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81\u0A82\u0ABC\u0AC1-\u0AC5\u0AC7\u0AC8\u0ACD\u0AE2\u0AE3\u0AFA-\u0AFF\u0B01\u0B3C\u0B3F\u0B41-\u0B44\u0B4D\u0B55\u0B56\u0B62\u0B63\u0B82\u0BC0\u0BCD\u0C00\u0C04\u0C3C\u0C3E-\u0C40\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81\u0CBC\u0CCC\u0CCD\u0CE2\u0CE3\u0D00\u0D01\u0D3B\u0D3C\u0D41-\u0D44\u0D4D\u0D62\u0D63\u0D81\u0DCA\u0DD2-\u0DD4\u0DD6\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EBC\u0EC8-\u0ECE\u0F18\u0F19\u0F35\u0F37\u0F39\u0F71-\u0F7E\u0F80-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102D-\u1030\u1032-\u1037\u1039\u103A\u103D\u103E\u1058\u1059\u105E-\u1060\u1071-\u1074\u1082\u1085\u1086\u108D\u109D\u135D-\u135F\u1712-\u1714\u1732\u1733\u1752\u1753\u1772\u1773\u17B4\u17B5\u17B7-\u17BD\u17C6\u17C9-\u17D3\u17DD\u180B-\u180D\u180F\u1885\u1886\u18A9\u1920-\u1922\u1927\u1928\u1932\u1939-\u193B\u1A17\u1A18\u1A1B\u1A56\u1A58-\u1A5E\u1A60\u1A62\u1A65-\u1A6C\u1A73-\u1A7C\u1A7F\u1AB0-\u1ADD\u1AE0-\u1AEB\u1B00-\u1B03\u1B34\u1B36-\u1B3A\u1B3C\u1B42\u1B6B-\u1B73\u1B80\u1B81\u1BA2-\u1BA5\u1BA8\u1BA9\u1BAB-\u1BAD\u1BE6\u1BE8\u1BE9\u1BED\u1BEF-\u1BF1\u1C2C-\u1C33\u1C36\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE0\u1CE2-\u1CE8\u1CED\u1CF4\u1CF8\u1CF9\u1DC0-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302D\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA825\uA826\uA82C\uA8C4\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA951\uA980-\uA982\uA9B3\uA9B6-\uA9B9\uA9BC\uA9BD\uA9E5\uAA29-\uAA2E\uAA31\uAA32\uAA35\uAA36\uAA43\uAA4C\uAA7C\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEC\uAAED\uAAF6\uABE5\uABE8\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F\u{101FD}\u{102E0}\u{10376}-\u{1037A}\u{10A01}-\u{10A03}\u{10A05}\u{10A06}\u{10A0C}-\u{10A0F}\u{10A38}-\u{10A3A}\u{10A3F}\u{10AE5}\u{10AE6}\u{10D24}-\u{10D27}\u{10D69}-\u{10D6D}\u{10EAB}\u{10EAC}\u{10EFA}-\u{10EFF}\u{10F46}-\u{10F50}\u{10F82}-\u{10F85}\u{11001}\u{11038}-\u{11046}\u{11070}\u{11073}\u{11074}\u{1107F}-\u{11081}\u{110B3}-\u{110B6}\u{110B9}\u{110BA}\u{110C2}\u{11100}-\u{11102}\u{11127}-\u{1112B}\u{1112D}-\u{11134}\u{11173}\u{11180}\u{11181}\u{111B6}-\u{111BE}\u{111C9}-\u{111CC}\u{111CF}\u{1122F}-\u{11231}\u{11234}\u{11236}\u{11237}\u{1123E}\u{11241}\u{112DF}\u{112E3}-\u{112EA}\u{11300}\u{11301}\u{1133B}\u{1133C}\u{11340}\u{11366}-\u{1136C}\u{11370}-\u{11374}\u{113BB}-\u{113C0}\u{113CE}\u{113D0}\u{113D2}\u{113E1}\u{113E2}\u{11438}-\u{1143F}\u{11442}-\u{11444}\u{11446}\u{1145E}\u{114B3}-\u{114B8}\u{114BA}\u{114BF}\u{114C0}\u{114C2}\u{114C3}\u{115B2}-\u{115B5}\u{115BC}\u{115BD}\u{115BF}\u{115C0}\u{115DC}\u{115DD}\u{11633}-\u{1163A}\u{1163D}\u{1163F}\u{11640}\u{116AB}\u{116AD}\u{116B0}-\u{116B5}\u{116B7}\u{1171D}\u{1171F}\u{11722}-\u{11725}\u{11727}-\u{1172B}\u{1182F}-\u{11837}\u{11839}\u{1183A}\u{1193B}\u{1193C}\u{1193E}\u{11943}\u{119D4}-\u{119D7}\u{119DA}\u{119DB}\u{119E0}\u{11A01}-\u{11A06}\u{11A09}\u{11A0A}\u{11A33}-\u{11A38}\u{11A3B}-\u{11A3E}\u{11A47}\u{11A51}-\u{11A56}\u{11A59}-\u{11A5B}\u{11A8A}-\u{11A96}\u{11A98}\u{11A99}\u{11B60}\u{11B62}-\u{11B64}\u{11B66}\u{11C30}-\u{11C36}\u{11C38}-\u{11C3D}\u{11C92}-\u{11CA7}\u{11CAA}-\u{11CB0}\u{11CB2}\u{11CB3}\u{11CB5}\u{11CB6}\u{11D31}-\u{11D36}\u{11D3A}\u{11D3C}\u{11D3D}\u{11D3F}-\u{11D45}\u{11D47}\u{11D90}\u{11D91}\u{11D95}\u{11D97}\u{11EF3}\u{11EF4}\u{11F00}\u{11F01}\u{11F36}-\u{11F3A}\u{11F40}\u{11F42}\u{11F5A}\u{13440}\u{13447}-\u{13455}\u{1611E}-\u{16129}\u{1612D}-\u{1612F}\u{16AF0}-\u{16AF4}\u{16B30}-\u{16B36}\u{16F4F}\u{16F8F}-\u{16F92}\u{16FE4}\u{1BC9D}\u{1BC9E}\u{1CF00}-\u{1CF2D}\u{1CF30}-\u{1CF46}\u{1D167}-\u{1D169}\u{1D17B}-\u{1D182}\u{1D185}-\u{1D18B}\u{1D1AA}-\u{1D1AD}\u{1D242}-\u{1D244}\u{1DA00}-\u{1DA36}\u{1DA3B}-\u{1DA6C}\u{1DA75}\u{1DA84}\u{1DA9B}-\u{1DA9F}\u{1DAA1}-\u{1DAAF}\u{1E000}-\u{1E006}\u{1E008}-\u{1E018}\u{1E01B}-\u{1E021}\u{1E023}\u{1E024}\u{1E026}-\u{1E02A}\u{1E08F}\u{1E130}-\u{1E136}\u{1E2AE}\u{1E2EC}-\u{1E2EF}\u{1E4EC}-\u{1E4EF}\u{1E5EE}\u{1E5EF}\u{1E6E3}\u{1E6E6}\u{1E6EE}\u{1E6EF}\u{1E6F5}\u{1E8D0}-\u{1E8D6}\u{1E944}-\u{1E94A}\u{E0100}-\u{E01EF}]*$/u; +const bidiS4EN = /[0-9\xB2\xB3\xB9\u06F0-\u06F9\u2070\u2074-\u2079\u2080-\u2089\u2488-\u249B\uFF10-\uFF19\u{102E1}-\u{102FB}\u{1CCF0}-\u{1CCF9}\u{1D7CE}-\u{1D7FF}\u{1F100}-\u{1F10A}\u{1FBF0}-\u{1FBF9}]/u; +const bidiS4AN = /[\u0600-\u0605\u0660-\u0669\u066B\u066C\u06DD\u0890\u0891\u08E2\u{10D30}-\u{10D39}\u{10D40}-\u{10D49}\u{10E60}-\u{10E7E}]/u; +const bidiS5 = /^[\0-\x08\x0E-\x1B!-\x84\x86-\u0377\u037A-\u037F\u0384-\u038A\u038C\u038E-\u03A1\u03A3-\u052F\u0531-\u0556\u0559-\u058A\u058D-\u058F\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0606\u0607\u0609\u060A\u060C\u060E-\u061A\u064B-\u065F\u066A\u0670\u06D6-\u06DC\u06DE-\u06E4\u06E7-\u06ED\u06F0-\u06F9\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07F6-\u07F9\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u0897-\u089F\u08CA-\u08E1\u08E3-\u0983\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BC-\u09C4\u09C7\u09C8\u09CB-\u09CE\u09D7\u09DC\u09DD\u09DF-\u09E3\u09E6-\u09FE\u0A01-\u0A03\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A59-\u0A5C\u0A5E\u0A66-\u0A76\u0A81-\u0A83\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABC-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AD0\u0AE0-\u0AE3\u0AE6-\u0AF1\u0AF9-\u0AFF\u0B01-\u0B03\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3C-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B55-\u0B57\u0B5C\u0B5D\u0B5F-\u0B63\u0B66-\u0B77\u0B82\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD0\u0BD7\u0BE6-\u0BFA\u0C00-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3C-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C58-\u0C5A\u0C5C\u0C5D\u0C60-\u0C63\u0C66-\u0C6F\u0C77-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBC-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CDC-\u0CDE\u0CE0-\u0CE3\u0CE6-\u0CEF\u0CF1-\u0CF3\u0D00-\u0D0C\u0D0E-\u0D10\u0D12-\u0D44\u0D46-\u0D48\u0D4A-\u0D4F\u0D54-\u0D63\u0D66-\u0D7F\u0D81-\u0D83\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DE6-\u0DEF\u0DF2-\u0DF4\u0E01-\u0E3A\u0E3F-\u0E5B\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EBD\u0EC0-\u0EC4\u0EC6\u0EC8-\u0ECE\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00-\u0F47\u0F49-\u0F6C\u0F71-\u0F97\u0F99-\u0FBC\u0FBE-\u0FCC\u0FCE-\u0FDA\u1000-\u10C5\u10C7\u10CD\u10D0-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u135D-\u137C\u1380-\u1399\u13A0-\u13F5\u13F8-\u13FD\u1400-\u167F\u1681-\u169C\u16A0-\u16F8\u1700-\u1715\u171F-\u1736\u1740-\u1753\u1760-\u176C\u176E-\u1770\u1772\u1773\u1780-\u17DD\u17E0-\u17E9\u17F0-\u17F9\u1800-\u1819\u1820-\u1878\u1880-\u18AA\u18B0-\u18F5\u1900-\u191E\u1920-\u192B\u1930-\u193B\u1940\u1944-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u19DE-\u1A1B\u1A1E-\u1A5E\u1A60-\u1A7C\u1A7F-\u1A89\u1A90-\u1A99\u1AA0-\u1AAD\u1AB0-\u1ADD\u1AE0-\u1AEB\u1B00-\u1B4C\u1B4E-\u1BF3\u1BFC-\u1C37\u1C3B-\u1C49\u1C4D-\u1C8A\u1C90-\u1CBA\u1CBD-\u1CC7\u1CD0-\u1CFA\u1D00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FC4\u1FC6-\u1FD3\u1FD6-\u1FDB\u1FDD-\u1FEF\u1FF2-\u1FF4\u1FF6-\u1FFE\u200B-\u200E\u2010-\u2027\u202F-\u205E\u2060-\u2064\u206A-\u2071\u2074-\u208E\u2090-\u209C\u20A0-\u20C1\u20D0-\u20F0\u2100-\u218B\u2190-\u2429\u2440-\u244A\u2460-\u2B73\u2B76-\u2CF3\u2CF9-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D70\u2D7F-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2DE0-\u2E5D\u2E80-\u2E99\u2E9B-\u2EF3\u2F00-\u2FD5\u2FF0-\u2FFF\u3001-\u303F\u3041-\u3096\u3099-\u30FF\u3105-\u312F\u3131-\u318E\u3190-\u31E5\u31EF-\u321E\u3220-\uA48C\uA490-\uA4C6\uA4D0-\uA62B\uA640-\uA6F7\uA700-\uA7DC\uA7F1-\uA82C\uA830-\uA839\uA840-\uA877\uA880-\uA8C5\uA8CE-\uA8D9\uA8E0-\uA953\uA95F-\uA97C\uA980-\uA9CD\uA9CF-\uA9D9\uA9DE-\uA9FE\uAA00-\uAA36\uAA40-\uAA4D\uAA50-\uAA59\uAA5C-\uAAC2\uAADB-\uAAF6\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB6B\uAB70-\uABED\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uD800-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1E\uFB29\uFBC3-\uFBD2\uFD3E-\uFD4F\uFD90\uFD91\uFDC8-\uFDCF\uFDFD-\uFE19\uFE20-\uFE52\uFE54-\uFE66\uFE68-\uFE6B\uFEFF\uFF01-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC\uFFE0-\uFFE6\uFFE8-\uFFEE\uFFF9-\uFFFD\u{10000}-\u{1000B}\u{1000D}-\u{10026}\u{10028}-\u{1003A}\u{1003C}\u{1003D}\u{1003F}-\u{1004D}\u{10050}-\u{1005D}\u{10080}-\u{100FA}\u{10100}-\u{10102}\u{10107}-\u{10133}\u{10137}-\u{1018E}\u{10190}-\u{1019C}\u{101A0}\u{101D0}-\u{101FD}\u{10280}-\u{1029C}\u{102A0}-\u{102D0}\u{102E0}-\u{102FB}\u{10300}-\u{10323}\u{1032D}-\u{1034A}\u{10350}-\u{1037A}\u{10380}-\u{1039D}\u{1039F}-\u{103C3}\u{103C8}-\u{103D5}\u{10400}-\u{1049D}\u{104A0}-\u{104A9}\u{104B0}-\u{104D3}\u{104D8}-\u{104FB}\u{10500}-\u{10527}\u{10530}-\u{10563}\u{1056F}-\u{1057A}\u{1057C}-\u{1058A}\u{1058C}-\u{10592}\u{10594}\u{10595}\u{10597}-\u{105A1}\u{105A3}-\u{105B1}\u{105B3}-\u{105B9}\u{105BB}\u{105BC}\u{105C0}-\u{105F3}\u{10600}-\u{10736}\u{10740}-\u{10755}\u{10760}-\u{10767}\u{10780}-\u{10785}\u{10787}-\u{107B0}\u{107B2}-\u{107BA}\u{1091F}\u{10A01}-\u{10A03}\u{10A05}\u{10A06}\u{10A0C}-\u{10A0F}\u{10A38}-\u{10A3A}\u{10A3F}\u{10AE5}\u{10AE6}\u{10B39}-\u{10B3F}\u{10D24}-\u{10D27}\u{10D69}-\u{10D6E}\u{10EAB}\u{10EAC}\u{10ED0}-\u{10ED8}\u{10EFA}-\u{10EFF}\u{10F46}-\u{10F50}\u{10F82}-\u{10F85}\u{11000}-\u{1104D}\u{11052}-\u{11075}\u{1107F}-\u{110C2}\u{110CD}\u{110D0}-\u{110E8}\u{110F0}-\u{110F9}\u{11100}-\u{11134}\u{11136}-\u{11147}\u{11150}-\u{11176}\u{11180}-\u{111DF}\u{111E1}-\u{111F4}\u{11200}-\u{11211}\u{11213}-\u{11241}\u{11280}-\u{11286}\u{11288}\u{1128A}-\u{1128D}\u{1128F}-\u{1129D}\u{1129F}-\u{112A9}\u{112B0}-\u{112EA}\u{112F0}-\u{112F9}\u{11300}-\u{11303}\u{11305}-\u{1130C}\u{1130F}\u{11310}\u{11313}-\u{11328}\u{1132A}-\u{11330}\u{11332}\u{11333}\u{11335}-\u{11339}\u{1133B}-\u{11344}\u{11347}\u{11348}\u{1134B}-\u{1134D}\u{11350}\u{11357}\u{1135D}-\u{11363}\u{11366}-\u{1136C}\u{11370}-\u{11374}\u{11380}-\u{11389}\u{1138B}\u{1138E}\u{11390}-\u{113B5}\u{113B7}-\u{113C0}\u{113C2}\u{113C5}\u{113C7}-\u{113CA}\u{113CC}-\u{113D5}\u{113D7}\u{113D8}\u{113E1}\u{113E2}\u{11400}-\u{1145B}\u{1145D}-\u{11461}\u{11480}-\u{114C7}\u{114D0}-\u{114D9}\u{11580}-\u{115B5}\u{115B8}-\u{115DD}\u{11600}-\u{11644}\u{11650}-\u{11659}\u{11660}-\u{1166C}\u{11680}-\u{116B9}\u{116C0}-\u{116C9}\u{116D0}-\u{116E3}\u{11700}-\u{1171A}\u{1171D}-\u{1172B}\u{11730}-\u{11746}\u{11800}-\u{1183B}\u{118A0}-\u{118F2}\u{118FF}-\u{11906}\u{11909}\u{1190C}-\u{11913}\u{11915}\u{11916}\u{11918}-\u{11935}\u{11937}\u{11938}\u{1193B}-\u{11946}\u{11950}-\u{11959}\u{119A0}-\u{119A7}\u{119AA}-\u{119D7}\u{119DA}-\u{119E4}\u{11A00}-\u{11A47}\u{11A50}-\u{11AA2}\u{11AB0}-\u{11AF8}\u{11B00}-\u{11B09}\u{11B60}-\u{11B67}\u{11BC0}-\u{11BE1}\u{11BF0}-\u{11BF9}\u{11C00}-\u{11C08}\u{11C0A}-\u{11C36}\u{11C38}-\u{11C45}\u{11C50}-\u{11C6C}\u{11C70}-\u{11C8F}\u{11C92}-\u{11CA7}\u{11CA9}-\u{11CB6}\u{11D00}-\u{11D06}\u{11D08}\u{11D09}\u{11D0B}-\u{11D36}\u{11D3A}\u{11D3C}\u{11D3D}\u{11D3F}-\u{11D47}\u{11D50}-\u{11D59}\u{11D60}-\u{11D65}\u{11D67}\u{11D68}\u{11D6A}-\u{11D8E}\u{11D90}\u{11D91}\u{11D93}-\u{11D98}\u{11DA0}-\u{11DA9}\u{11DB0}-\u{11DDB}\u{11DE0}-\u{11DE9}\u{11EE0}-\u{11EF8}\u{11F00}-\u{11F10}\u{11F12}-\u{11F3A}\u{11F3E}-\u{11F5A}\u{11FB0}\u{11FC0}-\u{11FF1}\u{11FFF}-\u{12399}\u{12400}-\u{1246E}\u{12470}-\u{12474}\u{12480}-\u{12543}\u{12F90}-\u{12FF2}\u{13000}-\u{13455}\u{13460}-\u{143FA}\u{14400}-\u{14646}\u{16100}-\u{16139}\u{16800}-\u{16A38}\u{16A40}-\u{16A5E}\u{16A60}-\u{16A69}\u{16A6E}-\u{16ABE}\u{16AC0}-\u{16AC9}\u{16AD0}-\u{16AED}\u{16AF0}-\u{16AF5}\u{16B00}-\u{16B45}\u{16B50}-\u{16B59}\u{16B5B}-\u{16B61}\u{16B63}-\u{16B77}\u{16B7D}-\u{16B8F}\u{16D40}-\u{16D79}\u{16E40}-\u{16E9A}\u{16EA0}-\u{16EB8}\u{16EBB}-\u{16ED3}\u{16F00}-\u{16F4A}\u{16F4F}-\u{16F87}\u{16F8F}-\u{16F9F}\u{16FE0}-\u{16FE4}\u{16FF0}-\u{16FF6}\u{17000}-\u{18CD5}\u{18CFF}-\u{18D1E}\u{18D80}-\u{18DF2}\u{1AFF0}-\u{1AFF3}\u{1AFF5}-\u{1AFFB}\u{1AFFD}\u{1AFFE}\u{1B000}-\u{1B122}\u{1B132}\u{1B150}-\u{1B152}\u{1B155}\u{1B164}-\u{1B167}\u{1B170}-\u{1B2FB}\u{1BC00}-\u{1BC6A}\u{1BC70}-\u{1BC7C}\u{1BC80}-\u{1BC88}\u{1BC90}-\u{1BC99}\u{1BC9C}-\u{1BCA3}\u{1CC00}-\u{1CCFC}\u{1CD00}-\u{1CEB3}\u{1CEBA}-\u{1CED0}\u{1CEE0}-\u{1CEF0}\u{1CF00}-\u{1CF2D}\u{1CF30}-\u{1CF46}\u{1CF50}-\u{1CFC3}\u{1D000}-\u{1D0F5}\u{1D100}-\u{1D126}\u{1D129}-\u{1D1EA}\u{1D200}-\u{1D245}\u{1D2C0}-\u{1D2D3}\u{1D2E0}-\u{1D2F3}\u{1D300}-\u{1D356}\u{1D360}-\u{1D378}\u{1D400}-\u{1D454}\u{1D456}-\u{1D49C}\u{1D49E}\u{1D49F}\u{1D4A2}\u{1D4A5}\u{1D4A6}\u{1D4A9}-\u{1D4AC}\u{1D4AE}-\u{1D4B9}\u{1D4BB}\u{1D4BD}-\u{1D4C3}\u{1D4C5}-\u{1D505}\u{1D507}-\u{1D50A}\u{1D50D}-\u{1D514}\u{1D516}-\u{1D51C}\u{1D51E}-\u{1D539}\u{1D53B}-\u{1D53E}\u{1D540}-\u{1D544}\u{1D546}\u{1D54A}-\u{1D550}\u{1D552}-\u{1D6A5}\u{1D6A8}-\u{1D7CB}\u{1D7CE}-\u{1DA8B}\u{1DA9B}-\u{1DA9F}\u{1DAA1}-\u{1DAAF}\u{1DF00}-\u{1DF1E}\u{1DF25}-\u{1DF2A}\u{1E000}-\u{1E006}\u{1E008}-\u{1E018}\u{1E01B}-\u{1E021}\u{1E023}\u{1E024}\u{1E026}-\u{1E02A}\u{1E030}-\u{1E06D}\u{1E08F}\u{1E100}-\u{1E12C}\u{1E130}-\u{1E13D}\u{1E140}-\u{1E149}\u{1E14E}\u{1E14F}\u{1E290}-\u{1E2AE}\u{1E2C0}-\u{1E2F9}\u{1E2FF}\u{1E4D0}-\u{1E4F9}\u{1E5D0}-\u{1E5FA}\u{1E5FF}\u{1E6C0}-\u{1E6DE}\u{1E6E0}-\u{1E6F5}\u{1E6FE}\u{1E6FF}\u{1E7E0}-\u{1E7E6}\u{1E7E8}-\u{1E7EB}\u{1E7ED}\u{1E7EE}\u{1E7F0}-\u{1E7FE}\u{1E8D0}-\u{1E8D6}\u{1E944}-\u{1E94A}\u{1EEF0}\u{1EEF1}\u{1F000}-\u{1F02B}\u{1F030}-\u{1F093}\u{1F0A0}-\u{1F0AE}\u{1F0B1}-\u{1F0BF}\u{1F0C1}-\u{1F0CF}\u{1F0D1}-\u{1F0F5}\u{1F100}-\u{1F1AD}\u{1F1E6}-\u{1F202}\u{1F210}-\u{1F23B}\u{1F240}-\u{1F248}\u{1F250}\u{1F251}\u{1F260}-\u{1F265}\u{1F300}-\u{1F6D8}\u{1F6DC}-\u{1F6EC}\u{1F6F0}-\u{1F6FC}\u{1F700}-\u{1F7D9}\u{1F7E0}-\u{1F7EB}\u{1F7F0}\u{1F800}-\u{1F80B}\u{1F810}-\u{1F847}\u{1F850}-\u{1F859}\u{1F860}-\u{1F887}\u{1F890}-\u{1F8AD}\u{1F8B0}-\u{1F8BB}\u{1F8C0}\u{1F8C1}\u{1F8D0}-\u{1F8D8}\u{1F900}-\u{1FA57}\u{1FA60}-\u{1FA6D}\u{1FA70}-\u{1FA7C}\u{1FA80}-\u{1FA8A}\u{1FA8E}-\u{1FAC6}\u{1FAC8}\u{1FACD}-\u{1FADC}\u{1FADF}-\u{1FAEA}\u{1FAEF}-\u{1FAF8}\u{1FB00}-\u{1FB92}\u{1FB94}-\u{1FBFA}\u{20000}-\u{2A6DF}\u{2A700}-\u{2B81D}\u{2B820}-\u{2CEAD}\u{2CEB0}-\u{2EBE0}\u{2EBF0}-\u{2EE5D}\u{2F800}-\u{2FA1D}\u{30000}-\u{3134A}\u{31350}-\u{33479}\u{E0001}\u{E0020}-\u{E007F}\u{E0100}-\u{E01EF}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]*$/u; +const bidiS6 = /[0-9A-Za-z\xAA\xB2\xB3\xB5\xB9\xBA\xC0-\xD6\xD8-\xF6\xF8-\u02B8\u02BB-\u02C1\u02D0\u02D1\u02E0-\u02E4\u02EE\u0370-\u0373\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0482\u048A-\u052F\u0531-\u0556\u0559-\u0589\u06F0-\u06F9\u0903-\u0939\u093B\u093D-\u0940\u0949-\u094C\u094E-\u0950\u0958-\u0961\u0964-\u0980\u0982\u0983\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD-\u09C0\u09C7\u09C8\u09CB\u09CC\u09CE\u09D7\u09DC\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\u09FA\u09FC\u09FD\u0A03\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A3E-\u0A40\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A76\u0A83\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD-\u0AC0\u0AC9\u0ACB\u0ACC\u0AD0\u0AE0\u0AE1\u0AE6-\u0AF0\u0AF9\u0B02\u0B03\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B3E\u0B40\u0B47\u0B48\u0B4B\u0B4C\u0B57\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BBE\u0BBF\u0BC1\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCC\u0BD0\u0BD7\u0BE6-\u0BF2\u0C01-\u0C03\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C41-\u0C44\u0C58-\u0C5A\u0C5C\u0C5D\u0C60\u0C61\u0C66-\u0C6F\u0C77\u0C7F\u0C80\u0C82-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD-\u0CC4\u0CC6-\u0CC8\u0CCA\u0CCB\u0CD5\u0CD6\u0CDC-\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0CF1-\u0CF3\u0D02-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D-\u0D40\u0D46-\u0D48\u0D4A-\u0D4C\u0D4E\u0D4F\u0D54-\u0D61\u0D66-\u0D7F\u0D82\u0D83\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DCF-\u0DD1\u0DD8-\u0DDF\u0DE6-\u0DEF\u0DF2-\u0DF4\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E4F-\u0E5B\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00-\u0F17\u0F1A-\u0F34\u0F36\u0F38\u0F3E-\u0F47\u0F49-\u0F6C\u0F7F\u0F85\u0F88-\u0F8C\u0FBE-\u0FC5\u0FC7-\u0FCC\u0FCE-\u0FDA\u1000-\u102C\u1031\u1038\u103B\u103C\u103F-\u1057\u105A-\u105D\u1061-\u1070\u1075-\u1081\u1083\u1084\u1087-\u108C\u108E-\u109C\u109E-\u10C5\u10C7\u10CD\u10D0-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1360-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u167F\u1681-\u169A\u16A0-\u16F8\u1700-\u1711\u1715\u171F-\u1731\u1734-\u1736\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17B6\u17BE-\u17C5\u17C7\u17C8\u17D4-\u17DA\u17DC\u17E0-\u17E9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1923-\u1926\u1929-\u192B\u1930\u1931\u1933-\u1938\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A19\u1A1A\u1A1E-\u1A55\u1A57\u1A61\u1A63\u1A64\u1A6D-\u1A72\u1A80-\u1A89\u1A90-\u1A99\u1AA0-\u1AAD\u1B04-\u1B33\u1B35\u1B3B\u1B3D-\u1B41\u1B43-\u1B4C\u1B4E-\u1B6A\u1B74-\u1B7F\u1B82-\u1BA1\u1BA6\u1BA7\u1BAA\u1BAE-\u1BE5\u1BE7\u1BEA-\u1BEC\u1BEE\u1BF2\u1BF3\u1BFC-\u1C2B\u1C34\u1C35\u1C3B-\u1C49\u1C4D-\u1C8A\u1C90-\u1CBA\u1CBD-\u1CC7\u1CD3\u1CE1\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5-\u1CF7\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u200E\u2070\u2071\u2074-\u2079\u207F-\u2089\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u214F\u2160-\u2188\u2336-\u237A\u2395\u2488-\u24E9\u26AC\u2800-\u28FF\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D70\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u3005-\u3007\u3021-\u3029\u302E\u302F\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3190-\u31BF\u31F0-\u321C\u3220-\u324F\u3260-\u327B\u327F-\u32B0\u32C0-\u32CB\u32D0-\u3376\u337B-\u33DD\u33E0-\u33FE\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA60C\uA610-\uA62B\uA640-\uA66E\uA680-\uA69D\uA6A0-\uA6EF\uA6F2-\uA6F7\uA722-\uA787\uA789-\uA7DC\uA7F1-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA824\uA827\uA830-\uA837\uA840-\uA873\uA880-\uA8C3\uA8CE-\uA8D9\uA8F2-\uA8FE\uA900-\uA925\uA92E-\uA946\uA952\uA953\uA95F-\uA97C\uA983-\uA9B2\uA9B4\uA9B5\uA9BA\uA9BB\uA9BE-\uA9CD\uA9CF-\uA9D9\uA9DE-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA2F\uAA30\uAA33\uAA34\uAA40-\uAA42\uAA44-\uAA4B\uAA4D\uAA50-\uAA59\uAA5C-\uAA7B\uAA7D-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAAEB\uAAEE-\uAAF5\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB69\uAB70-\uABE4\uABE6\uABE7\uABE9-\uABEC\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uD800-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC\u{10000}-\u{1000B}\u{1000D}-\u{10026}\u{10028}-\u{1003A}\u{1003C}\u{1003D}\u{1003F}-\u{1004D}\u{10050}-\u{1005D}\u{10080}-\u{100FA}\u{10100}\u{10102}\u{10107}-\u{10133}\u{10137}-\u{1013F}\u{1018D}\u{1018E}\u{101D0}-\u{101FC}\u{10280}-\u{1029C}\u{102A0}-\u{102D0}\u{102E1}-\u{102FB}\u{10300}-\u{10323}\u{1032D}-\u{1034A}\u{10350}-\u{10375}\u{10380}-\u{1039D}\u{1039F}-\u{103C3}\u{103C8}-\u{103D5}\u{10400}-\u{1049D}\u{104A0}-\u{104A9}\u{104B0}-\u{104D3}\u{104D8}-\u{104FB}\u{10500}-\u{10527}\u{10530}-\u{10563}\u{1056F}-\u{1057A}\u{1057C}-\u{1058A}\u{1058C}-\u{10592}\u{10594}\u{10595}\u{10597}-\u{105A1}\u{105A3}-\u{105B1}\u{105B3}-\u{105B9}\u{105BB}\u{105BC}\u{105C0}-\u{105F3}\u{10600}-\u{10736}\u{10740}-\u{10755}\u{10760}-\u{10767}\u{10780}-\u{10785}\u{10787}-\u{107B0}\u{107B2}-\u{107BA}\u{11000}\u{11002}-\u{11037}\u{11047}-\u{1104D}\u{11066}-\u{1106F}\u{11071}\u{11072}\u{11075}\u{11082}-\u{110B2}\u{110B7}\u{110B8}\u{110BB}-\u{110C1}\u{110CD}\u{110D0}-\u{110E8}\u{110F0}-\u{110F9}\u{11103}-\u{11126}\u{1112C}\u{11136}-\u{11147}\u{11150}-\u{11172}\u{11174}-\u{11176}\u{11182}-\u{111B5}\u{111BF}-\u{111C8}\u{111CD}\u{111CE}\u{111D0}-\u{111DF}\u{111E1}-\u{111F4}\u{11200}-\u{11211}\u{11213}-\u{1122E}\u{11232}\u{11233}\u{11235}\u{11238}-\u{1123D}\u{1123F}\u{11240}\u{11280}-\u{11286}\u{11288}\u{1128A}-\u{1128D}\u{1128F}-\u{1129D}\u{1129F}-\u{112A9}\u{112B0}-\u{112DE}\u{112E0}-\u{112E2}\u{112F0}-\u{112F9}\u{11302}\u{11303}\u{11305}-\u{1130C}\u{1130F}\u{11310}\u{11313}-\u{11328}\u{1132A}-\u{11330}\u{11332}\u{11333}\u{11335}-\u{11339}\u{1133D}-\u{1133F}\u{11341}-\u{11344}\u{11347}\u{11348}\u{1134B}-\u{1134D}\u{11350}\u{11357}\u{1135D}-\u{11363}\u{11380}-\u{11389}\u{1138B}\u{1138E}\u{11390}-\u{113B5}\u{113B7}-\u{113BA}\u{113C2}\u{113C5}\u{113C7}-\u{113CA}\u{113CC}\u{113CD}\u{113CF}\u{113D1}\u{113D3}-\u{113D5}\u{113D7}\u{113D8}\u{11400}-\u{11437}\u{11440}\u{11441}\u{11445}\u{11447}-\u{1145B}\u{1145D}\u{1145F}-\u{11461}\u{11480}-\u{114B2}\u{114B9}\u{114BB}-\u{114BE}\u{114C1}\u{114C4}-\u{114C7}\u{114D0}-\u{114D9}\u{11580}-\u{115B1}\u{115B8}-\u{115BB}\u{115BE}\u{115C1}-\u{115DB}\u{11600}-\u{11632}\u{1163B}\u{1163C}\u{1163E}\u{11641}-\u{11644}\u{11650}-\u{11659}\u{11680}-\u{116AA}\u{116AC}\u{116AE}\u{116AF}\u{116B6}\u{116B8}\u{116B9}\u{116C0}-\u{116C9}\u{116D0}-\u{116E3}\u{11700}-\u{1171A}\u{1171E}\u{11720}\u{11721}\u{11726}\u{11730}-\u{11746}\u{11800}-\u{1182E}\u{11838}\u{1183B}\u{118A0}-\u{118F2}\u{118FF}-\u{11906}\u{11909}\u{1190C}-\u{11913}\u{11915}\u{11916}\u{11918}-\u{11935}\u{11937}\u{11938}\u{1193D}\u{1193F}-\u{11942}\u{11944}-\u{11946}\u{11950}-\u{11959}\u{119A0}-\u{119A7}\u{119AA}-\u{119D3}\u{119DC}-\u{119DF}\u{119E1}-\u{119E4}\u{11A00}\u{11A07}\u{11A08}\u{11A0B}-\u{11A32}\u{11A39}\u{11A3A}\u{11A3F}-\u{11A46}\u{11A50}\u{11A57}\u{11A58}\u{11A5C}-\u{11A89}\u{11A97}\u{11A9A}-\u{11AA2}\u{11AB0}-\u{11AF8}\u{11B00}-\u{11B09}\u{11B61}\u{11B65}\u{11B67}\u{11BC0}-\u{11BE1}\u{11BF0}-\u{11BF9}\u{11C00}-\u{11C08}\u{11C0A}-\u{11C2F}\u{11C3E}-\u{11C45}\u{11C50}-\u{11C6C}\u{11C70}-\u{11C8F}\u{11CA9}\u{11CB1}\u{11CB4}\u{11D00}-\u{11D06}\u{11D08}\u{11D09}\u{11D0B}-\u{11D30}\u{11D46}\u{11D50}-\u{11D59}\u{11D60}-\u{11D65}\u{11D67}\u{11D68}\u{11D6A}-\u{11D8E}\u{11D93}\u{11D94}\u{11D96}\u{11D98}\u{11DA0}-\u{11DA9}\u{11DB0}-\u{11DDB}\u{11DE0}-\u{11DE9}\u{11EE0}-\u{11EF2}\u{11EF5}-\u{11EF8}\u{11F02}-\u{11F10}\u{11F12}-\u{11F35}\u{11F3E}\u{11F3F}\u{11F41}\u{11F43}-\u{11F59}\u{11FB0}\u{11FC0}-\u{11FD4}\u{11FFF}-\u{12399}\u{12400}-\u{1246E}\u{12470}-\u{12474}\u{12480}-\u{12543}\u{12F90}-\u{12FF2}\u{13000}-\u{1343F}\u{13441}-\u{13446}\u{13460}-\u{143FA}\u{14400}-\u{14646}\u{16100}-\u{1611D}\u{1612A}-\u{1612C}\u{16130}-\u{16139}\u{16800}-\u{16A38}\u{16A40}-\u{16A5E}\u{16A60}-\u{16A69}\u{16A6E}-\u{16ABE}\u{16AC0}-\u{16AC9}\u{16AD0}-\u{16AED}\u{16AF5}\u{16B00}-\u{16B2F}\u{16B37}-\u{16B45}\u{16B50}-\u{16B59}\u{16B5B}-\u{16B61}\u{16B63}-\u{16B77}\u{16B7D}-\u{16B8F}\u{16D40}-\u{16D79}\u{16E40}-\u{16E9A}\u{16EA0}-\u{16EB8}\u{16EBB}-\u{16ED3}\u{16F00}-\u{16F4A}\u{16F50}-\u{16F87}\u{16F93}-\u{16F9F}\u{16FE0}\u{16FE1}\u{16FE3}\u{16FF0}-\u{16FF6}\u{17000}-\u{18CD5}\u{18CFF}-\u{18D1E}\u{18D80}-\u{18DF2}\u{1AFF0}-\u{1AFF3}\u{1AFF5}-\u{1AFFB}\u{1AFFD}\u{1AFFE}\u{1B000}-\u{1B122}\u{1B132}\u{1B150}-\u{1B152}\u{1B155}\u{1B164}-\u{1B167}\u{1B170}-\u{1B2FB}\u{1BC00}-\u{1BC6A}\u{1BC70}-\u{1BC7C}\u{1BC80}-\u{1BC88}\u{1BC90}-\u{1BC99}\u{1BC9C}\u{1BC9F}\u{1CCD6}-\u{1CCF9}\u{1CF50}-\u{1CFC3}\u{1D000}-\u{1D0F5}\u{1D100}-\u{1D126}\u{1D129}-\u{1D166}\u{1D16A}-\u{1D172}\u{1D183}\u{1D184}\u{1D18C}-\u{1D1A9}\u{1D1AE}-\u{1D1E8}\u{1D2C0}-\u{1D2D3}\u{1D2E0}-\u{1D2F3}\u{1D360}-\u{1D378}\u{1D400}-\u{1D454}\u{1D456}-\u{1D49C}\u{1D49E}\u{1D49F}\u{1D4A2}\u{1D4A5}\u{1D4A6}\u{1D4A9}-\u{1D4AC}\u{1D4AE}-\u{1D4B9}\u{1D4BB}\u{1D4BD}-\u{1D4C3}\u{1D4C5}-\u{1D505}\u{1D507}-\u{1D50A}\u{1D50D}-\u{1D514}\u{1D516}-\u{1D51C}\u{1D51E}-\u{1D539}\u{1D53B}-\u{1D53E}\u{1D540}-\u{1D544}\u{1D546}\u{1D54A}-\u{1D550}\u{1D552}-\u{1D6A5}\u{1D6A8}-\u{1D6C0}\u{1D6C2}-\u{1D6DA}\u{1D6DC}-\u{1D6FA}\u{1D6FC}-\u{1D714}\u{1D716}-\u{1D734}\u{1D736}-\u{1D74E}\u{1D750}-\u{1D76E}\u{1D770}-\u{1D788}\u{1D78A}-\u{1D7A8}\u{1D7AA}-\u{1D7C2}\u{1D7C4}-\u{1D7CB}\u{1D7CE}-\u{1D9FF}\u{1DA37}-\u{1DA3A}\u{1DA6D}-\u{1DA74}\u{1DA76}-\u{1DA83}\u{1DA85}-\u{1DA8B}\u{1DF00}-\u{1DF1E}\u{1DF25}-\u{1DF2A}\u{1E030}-\u{1E06D}\u{1E100}-\u{1E12C}\u{1E137}-\u{1E13D}\u{1E140}-\u{1E149}\u{1E14E}\u{1E14F}\u{1E290}-\u{1E2AD}\u{1E2C0}-\u{1E2EB}\u{1E2F0}-\u{1E2F9}\u{1E4D0}-\u{1E4EB}\u{1E4F0}-\u{1E4F9}\u{1E5D0}-\u{1E5ED}\u{1E5F0}-\u{1E5FA}\u{1E5FF}\u{1E6C0}-\u{1E6DE}\u{1E6E0}-\u{1E6E2}\u{1E6E4}\u{1E6E5}\u{1E6E7}-\u{1E6ED}\u{1E6F0}-\u{1E6F4}\u{1E6FE}\u{1E6FF}\u{1E7E0}-\u{1E7E6}\u{1E7E8}-\u{1E7EB}\u{1E7ED}\u{1E7EE}\u{1E7F0}-\u{1E7FE}\u{1F100}-\u{1F10A}\u{1F110}-\u{1F12E}\u{1F130}-\u{1F169}\u{1F170}-\u{1F1AC}\u{1F1E6}-\u{1F202}\u{1F210}-\u{1F23B}\u{1F240}-\u{1F248}\u{1F250}\u{1F251}\u{1FBF0}-\u{1FBF9}\u{20000}-\u{2A6DF}\u{2A700}-\u{2B81D}\u{2B820}-\u{2CEAD}\u{2CEB0}-\u{2EBE0}\u{2EBF0}-\u{2EE5D}\u{2F800}-\u{2FA1D}\u{30000}-\u{3134A}\u{31350}-\u{33479}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}][\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u0897-\u089F\u08CA-\u08E1\u08E3-\u0902\u093A\u093C\u0941-\u0948\u094D\u0951-\u0957\u0962\u0963\u0981\u09BC\u09C1-\u09C4\u09CD\u09E2\u09E3\u09FE\u0A01\u0A02\u0A3C\u0A41\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81\u0A82\u0ABC\u0AC1-\u0AC5\u0AC7\u0AC8\u0ACD\u0AE2\u0AE3\u0AFA-\u0AFF\u0B01\u0B3C\u0B3F\u0B41-\u0B44\u0B4D\u0B55\u0B56\u0B62\u0B63\u0B82\u0BC0\u0BCD\u0C00\u0C04\u0C3C\u0C3E-\u0C40\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81\u0CBC\u0CCC\u0CCD\u0CE2\u0CE3\u0D00\u0D01\u0D3B\u0D3C\u0D41-\u0D44\u0D4D\u0D62\u0D63\u0D81\u0DCA\u0DD2-\u0DD4\u0DD6\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EBC\u0EC8-\u0ECE\u0F18\u0F19\u0F35\u0F37\u0F39\u0F71-\u0F7E\u0F80-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102D-\u1030\u1032-\u1037\u1039\u103A\u103D\u103E\u1058\u1059\u105E-\u1060\u1071-\u1074\u1082\u1085\u1086\u108D\u109D\u135D-\u135F\u1712-\u1714\u1732\u1733\u1752\u1753\u1772\u1773\u17B4\u17B5\u17B7-\u17BD\u17C6\u17C9-\u17D3\u17DD\u180B-\u180D\u180F\u1885\u1886\u18A9\u1920-\u1922\u1927\u1928\u1932\u1939-\u193B\u1A17\u1A18\u1A1B\u1A56\u1A58-\u1A5E\u1A60\u1A62\u1A65-\u1A6C\u1A73-\u1A7C\u1A7F\u1AB0-\u1ADD\u1AE0-\u1AEB\u1B00-\u1B03\u1B34\u1B36-\u1B3A\u1B3C\u1B42\u1B6B-\u1B73\u1B80\u1B81\u1BA2-\u1BA5\u1BA8\u1BA9\u1BAB-\u1BAD\u1BE6\u1BE8\u1BE9\u1BED\u1BEF-\u1BF1\u1C2C-\u1C33\u1C36\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE0\u1CE2-\u1CE8\u1CED\u1CF4\u1CF8\u1CF9\u1DC0-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302D\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA825\uA826\uA82C\uA8C4\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA951\uA980-\uA982\uA9B3\uA9B6-\uA9B9\uA9BC\uA9BD\uA9E5\uAA29-\uAA2E\uAA31\uAA32\uAA35\uAA36\uAA43\uAA4C\uAA7C\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEC\uAAED\uAAF6\uABE5\uABE8\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F\u{101FD}\u{102E0}\u{10376}-\u{1037A}\u{10A01}-\u{10A03}\u{10A05}\u{10A06}\u{10A0C}-\u{10A0F}\u{10A38}-\u{10A3A}\u{10A3F}\u{10AE5}\u{10AE6}\u{10D24}-\u{10D27}\u{10D69}-\u{10D6D}\u{10EAB}\u{10EAC}\u{10EFA}-\u{10EFF}\u{10F46}-\u{10F50}\u{10F82}-\u{10F85}\u{11001}\u{11038}-\u{11046}\u{11070}\u{11073}\u{11074}\u{1107F}-\u{11081}\u{110B3}-\u{110B6}\u{110B9}\u{110BA}\u{110C2}\u{11100}-\u{11102}\u{11127}-\u{1112B}\u{1112D}-\u{11134}\u{11173}\u{11180}\u{11181}\u{111B6}-\u{111BE}\u{111C9}-\u{111CC}\u{111CF}\u{1122F}-\u{11231}\u{11234}\u{11236}\u{11237}\u{1123E}\u{11241}\u{112DF}\u{112E3}-\u{112EA}\u{11300}\u{11301}\u{1133B}\u{1133C}\u{11340}\u{11366}-\u{1136C}\u{11370}-\u{11374}\u{113BB}-\u{113C0}\u{113CE}\u{113D0}\u{113D2}\u{113E1}\u{113E2}\u{11438}-\u{1143F}\u{11442}-\u{11444}\u{11446}\u{1145E}\u{114B3}-\u{114B8}\u{114BA}\u{114BF}\u{114C0}\u{114C2}\u{114C3}\u{115B2}-\u{115B5}\u{115BC}\u{115BD}\u{115BF}\u{115C0}\u{115DC}\u{115DD}\u{11633}-\u{1163A}\u{1163D}\u{1163F}\u{11640}\u{116AB}\u{116AD}\u{116B0}-\u{116B5}\u{116B7}\u{1171D}\u{1171F}\u{11722}-\u{11725}\u{11727}-\u{1172B}\u{1182F}-\u{11837}\u{11839}\u{1183A}\u{1193B}\u{1193C}\u{1193E}\u{11943}\u{119D4}-\u{119D7}\u{119DA}\u{119DB}\u{119E0}\u{11A01}-\u{11A06}\u{11A09}\u{11A0A}\u{11A33}-\u{11A38}\u{11A3B}-\u{11A3E}\u{11A47}\u{11A51}-\u{11A56}\u{11A59}-\u{11A5B}\u{11A8A}-\u{11A96}\u{11A98}\u{11A99}\u{11B60}\u{11B62}-\u{11B64}\u{11B66}\u{11C30}-\u{11C36}\u{11C38}-\u{11C3D}\u{11C92}-\u{11CA7}\u{11CAA}-\u{11CB0}\u{11CB2}\u{11CB3}\u{11CB5}\u{11CB6}\u{11D31}-\u{11D36}\u{11D3A}\u{11D3C}\u{11D3D}\u{11D3F}-\u{11D45}\u{11D47}\u{11D90}\u{11D91}\u{11D95}\u{11D97}\u{11EF3}\u{11EF4}\u{11F00}\u{11F01}\u{11F36}-\u{11F3A}\u{11F40}\u{11F42}\u{11F5A}\u{13440}\u{13447}-\u{13455}\u{1611E}-\u{16129}\u{1612D}-\u{1612F}\u{16AF0}-\u{16AF4}\u{16B30}-\u{16B36}\u{16F4F}\u{16F8F}-\u{16F92}\u{16FE4}\u{1BC9D}\u{1BC9E}\u{1CF00}-\u{1CF2D}\u{1CF30}-\u{1CF46}\u{1D167}-\u{1D169}\u{1D17B}-\u{1D182}\u{1D185}-\u{1D18B}\u{1D1AA}-\u{1D1AD}\u{1D242}-\u{1D244}\u{1DA00}-\u{1DA36}\u{1DA3B}-\u{1DA6C}\u{1DA75}\u{1DA84}\u{1DA9B}-\u{1DA9F}\u{1DAA1}-\u{1DAAF}\u{1E000}-\u{1E006}\u{1E008}-\u{1E018}\u{1E01B}-\u{1E021}\u{1E023}\u{1E024}\u{1E026}-\u{1E02A}\u{1E08F}\u{1E130}-\u{1E136}\u{1E2AE}\u{1E2EC}-\u{1E2EF}\u{1E4EC}-\u{1E4EF}\u{1E5EE}\u{1E5EF}\u{1E6E3}\u{1E6E6}\u{1E6EE}\u{1E6EF}\u{1E6F5}\u{1E8D0}-\u{1E8D6}\u{1E944}-\u{1E94A}\u{E0100}-\u{E01EF}]*$/u; + +module.exports = { + combiningMarks, + combiningClassVirama, + validZWNJ, + bidiDomain, + bidiS1LTR, + bidiS1RTL, + bidiS2, + bidiS3, + bidiS4EN, + bidiS4AN, + bidiS5, + bidiS6 + };