From a888daa85d12beb664dc9c12d057868d0836ceda Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:01:50 -0700 Subject: [PATCH 1/3] Support WASI socket components in the SDK and publisher tooling --- AGENTS.md | 5 +- docs/PUBLISHING.md | 10 +- docs/PUBLISHING.zh-CN.md | 10 +- docs/RUST.md | 123 +++++++++++++++++---- docs/RUST.zh-CN.md | 86 ++++++++++++-- sdk/rust/Cargo.lock | 7 ++ sdk/rust/Cargo.toml | 1 + sdk/rust/README.md | 9 ++ sdk/rust/README.zh-CN.md | 7 ++ sdk/rust/e2e/tests/components.rs | 123 ++++++++++++++++++++- sdk/rust/examples/socket-client/Cargo.toml | 13 +++ sdk/rust/examples/socket-client/src/lib.rs | 82 ++++++++++++++ sdk/rust/octosense-component/src/lib.rs | 8 +- templates/rust-component/src/lib.rs | 11 +- tools/check-publisher-toolchain.py | 32 ++++-- tools/octo | 69 +++++++++--- tools/publisher-toolchain.json | 2 +- tools/test_octo.py | 95 ++++++++++++++-- tools/wasm_component.py | 12 +- 19 files changed, 621 insertions(+), 84 deletions(-) create mode 100644 sdk/rust/examples/socket-client/Cargo.toml create mode 100644 sdk/rust/examples/socket-client/src/lib.rs diff --git a/AGENTS.md b/AGENTS.md index b0d06444..1c601487 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,7 +137,10 @@ says who runs each one and when. - A **component** (ADR 0014) is ordinary Rust built with this repository's SDK: `tools/octo wasm new`, then `tools/octo wasm build`, which also adds the required `wasm-components-v1` ABI and usage - disclosures (`storage` for files, `net` for HTTP). Missing disclosures + disclosures (`storage` for files, `net` for HTTP or sockets). Socket + imports additionally require `host-api-v1` and + `host_api.required: {"wasm.sockets": 1}`. This needs a forthcoming host; + released RC5 does not provide sockets. Missing disclosures must not become execution gates. OctoSense #453 merged component, HTTP and host-service integration; App Hub #186/#188 are also merged. Compatible release and actual app acceptance remain separate. diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index 7f4f3f89..92ac3ed6 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -248,15 +248,21 @@ updated. To refresh it, first preserve any local workflow customizations, then explicitly run `tools/octo publish-github "$APP" --replace` and review `git -C "$APP" diff -- .github/workflows/publish-app.yml` before committing. Replacement writes this generated file in full; it does not merge custom steps. -Do not replace unrelated workflows. The current pin, App Hub `7b36c8af`, admits +Do not replace unrelated workflows. The current pin, App Hub `caf37ca0`, admits HTTP and host-service component imports and shared components under the declaration-only policy; the older `769e9ee3` publisher does not. ABI requirements, bundle integrity and GitHub publisher-proof checks still apply. +This pin also includes [App Hub #209](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/209): +ordinary WASI TCP/UDP/DNS imports require `host-api-v1` and +`host_api.required: {"wasm.sockets": 1}`, which `tools/octo wasm build` adds. +RC5 can read the catalog but cannot install or run these socket apps; a +host implementing that API is still required ([Rust sockets](RUST.md#tcp-udp-and-dns)). + CI builds the exact [publisher pin](../tools/publisher-toolchain.json) and runs [the native CLI regression](../tools/check-publisher-toolchain.py) against real SDK components. It checks structural admission and publisher preparation, -including missing-ABI, unsupported-import and missing-proof refusals. This does +including missing-ABI, malformed-component and missing-proof refusals. This does not attest or publish an app, run its guests, or prove shell/device behavior. Keep the tested, unsigned `bundle/` as source. Review and commit the workflow, diff --git a/docs/PUBLISHING.zh-CN.md b/docs/PUBLISHING.zh-CN.md index fdc766fa..e12c0ebd 100644 --- a/docs/PUBLISHING.zh-CN.md +++ b/docs/PUBLISHING.zh-CN.md @@ -164,9 +164,15 @@ tools/octo publish-github "$APP" `tools/octo new` 也会安装 `.github/workflows/publish-app.yml`。这条命令只写入这个文件,不会推送、创建 Release、提交或批准应用。除非显式加上 `--replace`,否则它拒绝覆盖内容不同的已有工作流。不需要开发者签名 secret;工作流使用 GitHub 的短期任务令牌和 OIDC 权限生成证明并创建 Release。 -更新 App Flow 不会改变已有生成工作流固定的 Hub 版本。需要更新时,先保留自己定制的工作流步骤,再显式运行 `tools/octo publish-github "$APP" --replace`,并在提交前审阅 `git -C "$APP" diff -- .github/workflows/publish-app.yml`。替换会重写整个生成文件,不会合并定制步骤;不要用它覆盖无关工作流。当前固定的 App Hub `7b36c8af` 支持 HTTP、宿主服务组件导入和共享组件,并采用声明仅用于披露的策略;旧的 `769e9ee3` 发布工具不支持这些行为。ABI 要求、应用包完整性和 GitHub 发布者证明检查仍然有效。 +更新 App Flow 不会改变已有生成工作流固定的 Hub 版本。需要更新时,先保留自己定制的工作流步骤,再显式运行 `tools/octo publish-github "$APP" --replace`,并在提交前审阅 `git -C "$APP" diff -- .github/workflows/publish-app.yml`。替换会重写整个生成文件,不会合并定制步骤;不要用它覆盖无关工作流。当前固定的 App Hub `caf37ca0` 支持 HTTP、宿主服务组件导入和共享组件,并采用声明仅用于披露的策略;旧的 `769e9ee3` 发布工具不支持这些行为。ABI 要求、应用包完整性和 GitHub 发布者证明检查仍然有效。 -CI 会构建[固定版本的发布工具](../tools/publisher-toolchain.json),用真实 SDK 组件运行[原生 CLI 回归检查](../tools/check-publisher-toolchain.py),验证结构准入和发布准备,以及缺失 ABI、不支持的导入和缺失发布证明时的拒绝。这个检查不会生成证明、发布应用或执行组件,也不代表 shell 或设备功能验收。 +这个固定版本还包含 [App Hub #209](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/209): +普通 WASI TCP/UDP/DNS 导入要求 `host-api-v1` 和 +`host_api.required: {"wasm.sockets": 1}`,`tools/octo wasm build` 会自动加入。 +RC5 能读取目录,但不能安装或运行这些套接字应用;仍需要实现该 API 的宿主 +(见 [Rust 套接字](RUST.zh-CN.md#tcpudp-和-dns))。 + +CI 会构建[固定版本的发布工具](../tools/publisher-toolchain.json),用真实 SDK 组件运行[原生 CLI 回归检查](../tools/check-publisher-toolchain.py),验证结构准入和发布准备,以及缺失 ABI、组件格式错误和缺失发布证明时的拒绝。这个检查不会生成证明、发布应用或执行组件,也不代表 shell 或设备功能验收。 仓库中保留已测试、未签名的 `bundle/` 源码。在公开的应用仓库中审阅并 commit 工作流、源码、真实截图和 `.gitattributes`。为这个 commit 创建并推送一个**新**的 `v` tag,例如清单版本为 `0.1.0` 时使用 `v0.1.0`。绝不要移动或重建已用于 Release 的 tag。 diff --git a/docs/RUST.md b/docs/RUST.md index 585c9087..9f58f1fd 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -19,7 +19,12 @@ in a sandbox, and the app's script calls it by name. There are two kinds: nothing but their input. [Core modules](#core-modules-adr-0011) at the end of this page covers them. -**Current source and release boundary.** [Desktop RC4](../README.md#compatible-shell-download) +**Socket support is newer than RC5.** The tooling below accepts ordinary +TCP, UDP and DNS imports. Those apps require a forthcoming host advertising +`wasm.sockets@1`; RC5 does not provide it. See [TCP, UDP and DNS](#tcp-udp-and-dns). +No native library or privileged raw-IP socket access is added. + +**Recorded release baseline.** [Desktop RC4](../README.md#compatible-shell-download) is published at source `9266b008`, with component loading, HTTP, public host-service imports and shared catalog components. Its App Hub contract 1.11 uses `capabilities` and `network.hosts` as disclosures, not execution gates. @@ -38,7 +43,7 @@ retain their original scope. | Write and build it | Copy OctoSense's guest crate; `cargo build --target wasm32-unknown-unknown` | `tools/octo wasm new`, then `tools/octo wasm build` (this page) | | What it reaches | Its input only | The clock, random numbers, bounded private storage, HTTP and public host services under actual authorization. | | Between calls | Starts fresh every call | Keeps its state | -| The manifest | Disclose `wasm` | Require `wasm-components-v1`; disclose `wasm`, `storage` for files and `net` for HTTP | +| The manifest | Disclose `wasm` | Require `wasm-components-v1`; socket imports also require `host-api-v1` and `host_api.required: {"wasm.sockets": 1}`. Disclose `wasm`, `storage` for files and `net` for HTTP/sockets | | App Hub's gate (`hub check`) | Admits it from contract 1.7 | Current source admits supported components with `wasm-components-v1`; the 1.11 policy treats family declarations as disclosures. | | Current source (macOS, Windows, Linux, Android and OpenHarmony) | Included; OpenHarmony uses Pulley | Included, with phase 3 adapters and per-app shared-component loading; iOS excluded. Source inclusion does not establish device acceptance. | | `card-host` | Admits the app; calls answer `no service answers "wasm" on this device` | Also has no `wasm` service. Manifest admission does not make a component runnable there. | @@ -226,15 +231,16 @@ For each crate in `components/` (or each `--crate DIR`), it: 1. runs `cargo build --release --target wasm32-wasip2`; 2. reads the component's imports, and stops without changing the bundle if one is outside `wasi:cli`, `wasi:clocks`, `wasi:filesystem`, `wasi:http`, - `wasi:io`, `wasi:random` and `octosense:host`, the packages the `wasm` - service gives a component; + `wasi:io`, `wasi:random`, `wasi:sockets` and `octosense:host`, the supported + packages (socket imports require a newer host than RC5); 3. records in the component the crates it is built from ([The crates it is built from](#the-crates-it-is-built-from)); 4. copies it to `bundle/fns/.wasm`; 5. adds `wasm` to the manifest's `capabilities` and `wasm-components-v1` to its `requires`, `storage` when a component imports `wasi:filesystem`, - and `net` when one imports `wasi:http`, saying what it changed. It never - adds a host to `network.hosts` or asks for one ([Network](#network)); + and `net` when one imports `wasi:http` or `wasi:sockets`, saying what it changed. + Socket imports also add `host-api-v1` and `host_api.required: {"wasm.sockets": 1}` + so older hosts refuse this app before installing it. It never adds a host to `network.hosts` or asks for one ([Network](#network)); 6. warns about what the gate or the service would refuse later: more than 8 files in `fns/`, two files exporting one function name, a bundle over 8 MiB; @@ -257,9 +263,10 @@ The template's functions never read the time, but its component imports `wasi:clocks/monotonic-clock`, as every component built with Rust's standard library here does. -A function that opens a socket builds, but `tools/octo wasm build` refuses -the component. With `std::net::TcpStream::connect(addr)` in a function, it -prints: +**Historical output from tooling before socket support:** a function using +`std::net::TcpStream::connect(addr)` built, but the old `tools/octo wasm build` +refused it. Current tooling accepts it and records the required host API +instead ([TCP, UDP and DNS](#tcp-udp-and-dns)). The old output was: ```text octo: text-tools imports what no host gives a component, so the wasm service would refuse to load it and App Hub's gate refuses the bundle: @@ -427,7 +434,7 @@ shell, test the exact app and compatible build separately. | Files | The app's bounded private storage is `/`, read/write through `std::fs`; other device files remain outside the jail. Omitted `storage` does not remove this jail. | | stdout and stderr | They become the app's log lines | | The environment, arguments and stdin | Empty | -| The network | Phase 3: any host, over HTTPS or plain HTTP, through `wasi:http`, with `net` disclosed ([Network](#network)). It has no sockets: `wasi:sockets` is refused. | +| The network | Phase 3: any host, over HTTPS or plain HTTP, through `wasi:http`, with `net` disclosed ([Network](#network)). Ordinary TCP/UDP/DNS use `wasi:sockets` on a host advertising `wasm.sockets@1`; RC5 refuses these imports. | | Threads | No: `wasm32-wasip2` has none | | Host services | Phase 3: available public services under the same actual authorization as its script, through `octosense:host` ([Host services](#host-services)) | | Other apps | No | @@ -438,8 +445,8 @@ shell, test the exact app and compatible build separately. Wasmtime 49; they do not prove provider calls from an installed app. OctoSense source includes phase 3; app acceptance needs a compatible build. -A component reaches the network only through `wasi:http`, and it reaches any -host. Under OctoSense's ruling of 8 October 2026, an app's network +For HTTP, a component uses `wasi:http` and can reach any host. +[TCP, UDP and DNS](#tcp-udp-and-dns) are a separate newer-host path. Under OctoSense's ruling of 8 October 2026, an app's network declarations (`net`, `network.hosts`) are shown at install and not enforced while the app runs: the OS and the host's API surface are the boundary. The manifest should disclose `net` for review; the corrected gate can warn @@ -488,9 +495,10 @@ pub mod functions { A request blocks until the whole response has arrived. Any status is a response, so a `404` is `Ok`. An `Err` is a request that got no response, as readable text, and `?` makes it the call's error in the script. The -component imports `wasi:http` only when it calls these functions, and a -crate that opens sockets, such as `reqwest`, `ureq` or `tokio`'s `net`, -still does not work. +component imports `wasi:http` only when it calls these functions. This HTTP +adapter keeps working on RC5. A socket-based crate needs both a compatible +WASI backend and a newer host ([TCP, UDP and DNS](#tcp-udp-and-dns)); socket +support alone does not supply native TLS libraries or a threaded runtime. OctoSense's phase 3 runtime handles requests this way: @@ -521,6 +529,80 @@ bundle/manifest.json: added "net" to capabilities: api-client imports wasi:http, the network ``` +### TCP, UDP and DNS + +**Release boundary:** ordinary `std::net` sockets require a forthcoming +OctoSense build advertising the `wasm.sockets@1` host API. Released desktop +RC5 does not provide it. This adds TCP streams/listeners, UDP datagrams and +DNS through WASI, not privileged raw-IP sockets or native libraries. + +Rust's `wasm32-wasip2` standard library supplies the imports. There is no +OctoSense socket wrapper to learn: + +| Rust API | Use | +| --- | --- | +| `std::net::TcpStream` | Connect and exchange TCP bytes | +| `std::net::TcpListener` | Bind, listen and accept where the OS permits | +| `std::net::UdpSocket` | Bind and exchange UDP datagrams | +| `std::net::ToSocketAddrs` | Resolve a host and port into addresses | + +The complete [socket-client example](../sdk/rust/examples/socket-client/src/lib.rs) +exports `tcp_echo(address, message)`, `udp_echo(address, message)` and +`resolve(host, port)`. The echo clients talk to your own echo server, bound +messages to 1,024 bytes and drop their socket on return. TCP uses 500 ms +I/O timeouts. Rust’s WASI UDP backend does not implement `set_read_timeout` +or `set_write_timeout`; the example instead uses nonblocking receive with a +500 ms clock deadline and short sleeps, without spawning a thread. For example, from the app's Splash script: + +```splash +host.request("wasm.resolve", {host: "localhost", port: 8080}, fn(r){ + if r.is_ok { addresses = r.data } else { note = r.error } +}) +``` + +Build the crate using `tools/octo wasm build --app `. For **any** +`wasi:sockets` import, including DNS alone, the CLI adds this manifest +fragment while preserving other requirements: + +```json +{ + "requires": ["wasm-components-v1", "host-api-v1"], + "capabilities": ["wasm", "net"], + "host_api": {"required": {"wasm.sockets": 1}} +} +``` + +This is an ABI compatibility check, not permission to access an account or +a new network allowlist. The existing `host-api-v1` format lets RC5 read a +catalog containing the app while refusing this incompatible app at install. +Do not mark `wasm.sockets` optional: its imports are needed to load the +component. `wasm info` explains the requirement; `wasm build` promotes an +optional ABI 1 declaration to required and refuses conflicting ABI versions. + +Socket apps also need socket-capable App Hub admission. This checkout's +publisher-toolchain pin includes [App Hub #209](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/209) +at `caf37ca0`. Older gates and already-generated release workflows can still +refuse them. Regenerate the app's workflow with +`tools/octo publish-github --replace`; do not remove the compatibility +requirement to make an old gate pass. Admission does not supply a compatible +released host. + +TCP/UDP do not automatically encrypt traffic. Select a WASI-compatible TLS +or protocol implementation when needed, keep buffers bounded, set operation +timeouts and release sockets promptly. The host retains resource limits, +cancellation and teardown responsibility; sockets cannot bypass OS network +restrictions. Supporting sockets does not make a native TLS library, every +`tokio` feature, or every third-party network crate portable. For ordinary +HTTPS APIs, `octosense_component::http` remains the simpler RC5-compatible +route. + +**SDK validation:** `cargo test --locked --workspace` in `sdk/rust` compiles +the example into a real component, then checks TCP and UDP round trips, +`localhost` name resolution, TCP closure on return, a silent UDP peer and failure when the +harness does not enable sockets. This tests the SDK transport in Wasmtime +49. It is not acceptance of an installed app, public-network DNS, a phone, +production quotas or cancellation; those need host-specific tests. + ### Host services **Validation scope:** SDK tests use fake services in Wasmtime 49. They @@ -586,7 +668,8 @@ reports `none of the 6 crates it links is known not to build or run in a compone | The crate | Why | Instead | | --- | --- | --- | | C libraries: `openssl-sys`, `libsqlite3-sys`, and crates that compile C with `cc` or `cmake` | They need a C compiler for WASI, such as wasi-sdk's clang | A pure-Rust crate or feature: RustCrypto's `sha2`, `hmac` or `aes-gcm` for cryptography; files in the app's storage, or the script's storage, for data | -| The network: `reqwest`, `hyper`, `ureq`, `curl`, `mio`, `socket2`, `tungstenite`, `native-tls` | They open sockets, and a component has none | `octosense_component::http`, with network use disclosed ([Network](#network)), or fetch with `net` in Splash, then pass the data in | +| Socket-based crates: `reqwest`, `hyper`, `ureq`, `mio`, `socket2`, `tungstenite` | Their selected networking/TLS backend and features may not support WASI; `doctor` warns instead of rejecting the name | Build for `wasm32-wasip2`, test on a host with `wasm.sockets@1`, or use the RC5-compatible HTTP adapter ([Network](#network)) | +| `native-tls`, `curl`, `curl-sys`, `isahc` | Depend on native TLS or libcurl | `octosense_component::http`, or a WASI-compatible pure-Rust implementation | | Threads: `rayon` | `wasm32-wasip2` has no threads | Plain iterators, or the crate without its parallel feature | | `tokio` with `rt-multi-thread`, `net`, `fs`, `process` or `signal` | They need threads, the network or the device | Plain functions: a component's functions are ordinary calls, with no async runtime | | JavaScript bindings: `wasm-bindgen`, `js-sys`, `web-sys` | A component has no JavaScript | The crate without its `js` or `wasm-bindgen` feature | @@ -621,9 +704,9 @@ after its `[ok]` lines: The table holds only what this repository's docs say a store app may use. Engine services such as `photo`, `pdf` or `word` are for system apps, so -`doctor` offers none of them. A crate that opens network sockets, such as -`reqwest` or `ureq`, is a failure rather than a hint: `doctor` says that a -component reaches the network with `octosense_component::http` instead +`doctor` offers none of them. A socket-based crate such as `reqwest` or +`ureq` now produces a compatibility warning, not an automatic failure. +Its features, target build and runtime tests determine whether it works ([What cannot build or run](#what-cannot-build-or-run), [Network](#network)). ### The crates it is built from @@ -704,7 +787,7 @@ those two are: | `[refused] functions: fns/text-tools.wasm is a WebAssembly component; the manifest must require wasm-components-v1` | Add it to `requires`, or run `tools/octo wasm build`. | | Older gate refuses omitted `storage` disclosure | Use the compatible 1.11 gate; disclose file use for review. Bounded private storage still applies. | | Older gate refuses omitted `net` disclosure | Use the compatible 1.11 gate; disclose network use. No runtime host allowlist is inferred ([Network](#network)). | -| `[refused] contents-invalid (fns/netprobe.wasm): the component imports wasi:sockets/network@0.2.9; a component may import only wasi:cli, wasi:clocks, wasi:filesystem, wasi:http, wasi:io, wasi:random and octosense:host` | Remove what opens sockets ([What cannot build or run](#what-cannot-build-or-run)). | +| `[refused] contents-invalid (fns/netprobe.wasm): the component imports wasi:sockets/network@0.2.9; a component may import only wasi:cli, wasi:clocks, wasi:filesystem, wasi:http, wasi:io, wasi:random and octosense:host` | This is an older gate. Use a socket-capable gate and host, with `wasm.sockets@1` required, or keep the RC5-compatible HTTP route ([TCP, UDP and DNS](#tcp-udp-and-dns)). | The rules for every file in `fns/` (its name, at most 8 files, the 8 MiB bundle) are the same as for modules ([Build it](#build-it)). diff --git a/docs/RUST.zh-CN.md b/docs/RUST.zh-CN.md index 18444201..9fda029d 100644 --- a/docs/RUST.zh-CN.md +++ b/docs/RUST.zh-CN.md @@ -9,14 +9,16 @@ - **组件**([ADR 0014](https://github.com/OctoSense-org/OctoSense/blob/9266b0083544d86bd7636543b5ff60c61b26460f/docs/adr/0014-app-components-in-webassembly.zh-CN.md),已接受):用本仓库的 SDK 编写普通的 Rust,`tools/octo wasm build` 把它变成 `fns/.wasm`。脚本以 `wasm.` 加 JSON 调用每个 `pub fn`,因此不需要编写 WIT,也不需要胶水代码。组件在调用之间保留状态,能用时钟和随机数;还能访问受配额限制的私有文件。ADR 0014 的第 3 阶段再加两项:经 HTTP 访问主机(见[网络](#网络));以及通过实际授权检查调用可用的公开宿主服务(见[宿主服务](#宿主服务))。 - **核心模块**([ADR 0011](https://github.com/OctoSense-org/OctoSense/blob/main/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md)):处理字节或 JSON 的函数,用复制来的客体 crate 编写,只能看到自己的输入。本页末尾的[核心模块](#核心模块adr-0011)介绍它们。 -**当前源码与发布边界。** [桌面 RC4](../README.zh-CN.md#下载兼容-shell) 已发布,源码为 `9266b008`,包含组件加载、HTTP、公开宿主服务导入与签名目录中的共享组件。它使用 App Hub 契约 1.11,`capabilities` 与 `network.hosts` 披露用途,不作为执行开关。`requires`、受支持的导入、完整性、配额、应用与账户 scope、实际设备同意、原生写入审核及应用间共享检查继续生效。最终 Mac 归档包与通过 11/11 项 Store/组件检查的候选包字节一致;测试使用真实 GitHub 证明与经管理员证明的私有演练目录,不代表官方公开目录组件安装、真实模型或 Windows/Linux 界面验收。OnePlus 6 组件检查使用隔离的源码测试包,没有升级 Home。你的确切应用仍需单独验收。RC2 与 Home beta.2 只包含核心模块;下方历史观察记录保留原有范围。 +**套接字支持晚于 RC5。** 下述工具接受普通 TCP、UDP 和 DNS 导入,但这些应用需要即将提供的、声明 `wasm.sockets@1` 的宿主;RC5 不提供该功能。见 [TCP、UDP 和 DNS](#tcpudp-和-dns)。此变更不增加原生库或特权原始 IP 套接字访问。 + +**已记录的发布基线。** [桌面 RC4](../README.zh-CN.md#下载兼容-shell) 已发布,源码为 `9266b008`,包含组件加载、HTTP、公开宿主服务导入与签名目录中的共享组件。它使用 App Hub 契约 1.11,`capabilities` 与 `network.hosts` 披露用途,不作为执行开关。`requires`、受支持的导入、完整性、配额、应用与账户 scope、实际设备同意、原生写入审核及应用间共享检查继续生效。最终 Mac 归档包与通过 11/11 项 Store/组件检查的候选包字节一致;测试使用真实 GitHub 证明与经管理员证明的私有演练目录,不代表官方公开目录组件安装、真实模型或 Windows/Linux 界面验收。OnePlus 6 组件检查使用隔离的源码测试包,没有升级 Home。你的确切应用仍需单独验收。RC2 与 Home beta.2 只包含核心模块;下方历史观察记录保留原有范围。 | | 核心模块(ADR 0011) | 组件(ADR 0014) | | --- | --- | --- | | 编写和构建 | 复制 OctoSense 的客体 crate;`cargo build --target wasm32-unknown-unknown` | `tools/octo wasm new`,然后 `tools/octo wasm build`(本页) | | 能访问什么 | 只有自己的输入 | 时钟、随机数、有配额的私有存储、HTTP,以及通过实际授权检查的公开宿主服务。 | | 两次调用之间 | 每次调用都从头开始 | 保留状态 | -| 清单 | 披露 `wasm` | 要求 `wasm-components-v1`;披露 `wasm`、文件用途的 `storage` 和 HTTP 用途的 `net` | +| 清单 | 披露 `wasm` | 要求 `wasm-components-v1`;套接字导入还要求 `host-api-v1` 和 `host_api.required: {"wasm.sockets": 1}`。披露 `wasm`、文件用途的 `storage` 和 HTTP/套接字用途的 `net` | | App Hub 的准入检查(`hub check`) | 从契约 1.7 起接受 | 当前源码接受带有 `wasm-components-v1` 的受支持组件;1.11 策略把能力族声明视为披露。 | | 当前源码(macOS、Windows、Linux、Android 和 OpenHarmony) | 包含;OpenHarmony 使用 Pulley | 包含第 3 阶段适配器与逐应用共享组件加载;不含 iOS。源码包含不等于真机验收。 | | `card-host` | 接受应用;调用返回 `no service answers "wasm" on this device` | 同样没有 `wasm` 服务。清单通过准入不代表组件能在其中运行。 | @@ -153,10 +155,10 @@ tools/octo wasm build --app ~/apps/my-app 对 `components/` 中的每个 crate(或每个 `--crate DIR`),它会: 1. 运行 `cargo build --release --target wasm32-wasip2`; -2. 读取组件的导入,如果有导入不属于 `wasi:cli`、`wasi:clocks`、`wasi:filesystem`、`wasi:http`、`wasi:io`、`wasi:random` 和 `octosense:host` 这几个 `wasm` 服务提供给组件的包,就停下来,不改动应用包; +2. 读取组件的导入,如果有导入不属于 `wasi:cli`、`wasi:clocks`、`wasi:filesystem`、`wasi:http`、`wasi:io`、`wasi:random`、`wasi:sockets` 和 `octosense:host` 这些受支持的包(套接字导入需要比 RC5 更新的宿主),就停下来,不改动应用包; 3. 在组件中记录构建它所用的 crate(见[构建所用的 crate](#构建所用的-crate)); 4. 把它复制到 `bundle/fns/.wasm`; -5. 在清单的 `capabilities` 中加入 `wasm`、在 `requires` 中加入 `wasm-components-v1`;组件导入 `wasi:filesystem` 时再加入 `storage`;组件导入 `wasi:http` 时再加入 `net`,并说明改了什么。它从不往 `network.hosts` 中添加主机,也不要求你添加(见[网络](#网络)); +5. 在清单的 `capabilities` 中加入 `wasm`、在 `requires` 中加入 `wasm-components-v1`;组件导入 `wasi:filesystem` 时再加入 `storage`;组件导入 `wasi:http` 或 `wasi:sockets` 时再加入 `net`,并说明改了什么。套接字导入还会加入 `host-api-v1` 和 `host_api.required: {"wasm.sockets": 1}`,让较旧宿主在安装前拒绝这个应用。它从不往 `network.hosts` 中添加主机,也不要求你添加(见[网络](#网络)); 6. 对准入检查或服务之后会拒绝的情况发出警告:`fns/` 中超过 8 个文件、两个文件导出同名函数、应用包超过 8 MiB; 7. 找到 `hub` 时为应用包写入摘要。 @@ -175,7 +177,7 @@ bundle/manifest.json: 模板的函数从不读取时间,但它的组件导入了 `wasi:clocks/monotonic-clock`;本文用 Rust 标准库构建的每个组件都是如此。 -打开套接字的函数能通过编译,但 `tools/octo wasm build` 会拒绝这个组件。函数中有 `std::net::TcpStream::connect(addr)` 时,它输出: +**支持套接字之前的工具历史输出:** 使用 `std::net::TcpStream::connect(addr)` 的函数能通过编译,但旧版 `tools/octo wasm build` 会拒绝这个组件。当前工具接受它,改为记录所需宿主 API(见 [TCP、UDP 和 DNS](#tcpudp-和-dns))。旧版输出是: ```text octo: text-tools imports what no host gives a component, so the wasm service would refuse to load it and App Hub's gate refuses the bundle: @@ -282,7 +284,7 @@ ADR 0014 给组件提供下列 WASI 0.2 接口;第 3 阶段再加上 `octosens | 文件 | 有配额的私有存储作为 `/`,可通过 `std::fs` 读写;设备其他文件仍在隔离范围之外。遗漏 `storage` 不会移除这个隔离目录。 | | stdout 和 stderr | 成为应用的日志行 | | 环境变量、参数和 stdin | 都为空 | -| 网络 | 第 3 阶段:通过 `wasi:http`,经 HTTPS 或普通 HTTP 访问任何主机,清单用 `net` 披露用途(见[网络](#网络))。组件没有套接字:`wasi:sockets` 会被拒绝。 | +| 网络 | 第 3 阶段:通过 `wasi:http`,经 HTTPS 或普通 HTTP 访问任何主机,清单用 `net` 披露用途(见[网络](#网络))。普通 TCP/UDP/DNS 通过 `wasi:sockets` 使用,要求宿主声明 `wasm.sockets@1`;RC5 拒绝这些导入。 | | 线程 | 没有:`wasm32-wasip2` 没有线程 | | 宿主服务 | 第 3 阶段:通过 `octosense:host`,像应用的脚本那样通过实际授权检查调用可用的公开宿主服务(见[宿主服务](#宿主服务)) | | 其他应用 | 没有 | @@ -291,7 +293,7 @@ ADR 0014 给组件提供下列 WASI 0.2 接口;第 3 阶段再加上 `octosens **验证范围**:SDK 测试在 Wasmtime 49 中向本机服务器发出请求,不证明已安装应用能调用真实服务。OctoSense 源码包含第 3 阶段;应用验收须用兼容构建。 -组件只能通过 `wasi:http` 访问网络,而且可以访问任何主机。按 OctoSense 2026 年 10 月 8 日的裁定,应用的网络声明(`net`、`network.hosts`)在安装时展示,运行时不强制:边界是操作系统和宿主的 API 表面。清单应披露 `net` 供审核;修正后的准入检查可以对遗漏披露警告,但不会因此拒绝: +HTTP 使用 `wasi:http`,可以访问任何主机。[TCP、UDP 和 DNS](#tcpudp-和-dns) 是另一个需要较新宿主的路径。按 OctoSense 2026 年 10 月 8 日的裁定,应用的网络声明(`net`、`network.hosts`)在安装时展示,运行时不强制:边界是操作系统和宿主的 API 表面。清单应披露 `net` 供审核;修正后的准入检查可以对遗漏披露警告,但不会因此拒绝: ```json "capabilities": ["wasm", "net"] @@ -330,7 +332,7 @@ pub mod functions { | `Request::new(method, url)`、`Request::get(url)`、`Request::post(url)` | 构建任意请求:`.header(name, value)`、`.body(bytes)`、`.timeout(duration)`,最后 `.send()` | | `Response` | `status`、`headers` 和完整的 `body`;`.text()` 和 `.header(name)` | -请求会阻塞,直到完整的响应到达。任何状态码都算响应,所以 `404` 也是 `Ok`。`Err` 表示请求没有得到响应,内容是可读的文本,`?` 会把它变成脚本中这次调用的错误。只有调用了这些函数的组件才会导入 `wasi:http`;会打开套接字的 crate,例如 `reqwest`、`ureq` 或 `tokio` 的 `net`,仍然无法使用。 +请求会阻塞,直到完整的响应到达。任何状态码都算响应,所以 `404` 也是 `Ok`。`Err` 表示请求没有得到响应,内容是可读的文本,`?` 会把它变成脚本中这次调用的错误。只有调用了这些函数的组件才会导入 `wasi:http`。这个 HTTP 适配器在 RC5 上仍可使用。基于套接字的 crate 需要兼容 WASI 的后端以及较新宿主(见 [TCP、UDP 和 DNS](#tcpudp-和-dns));套接字支持本身不提供原生 TLS 库或多线程运行时。 OctoSense 第 3 阶段运行时这样处理请求: @@ -353,6 +355,67 @@ bundle/manifest.json: added "net" to capabilities: api-client imports wasi:http, the network ``` +### TCP、UDP 和 DNS + +**发布边界:** 普通 `std::net` 套接字需要即将提供的、声明 `wasm.sockets@1` +宿主 API 的 OctoSense 构建。已发布的桌面 RC5 不提供该功能。这通过 WASI 增加 +TCP 流与监听器、UDP 数据报和 DNS,不增加特权原始 IP 套接字或原生库。 + +Rust 的 `wasm32-wasip2` 标准库提供导入,不需要学习另一套 OctoSense 套接字封装: + +| Rust API | 用途 | +| --- | --- | +| `std::net::TcpStream` | 连接并交换 TCP 字节 | +| `std::net::TcpListener` | 在操作系统允许时绑定、监听并接受连接 | +| `std::net::UdpSocket` | 绑定并交换 UDP 数据报 | +| `std::net::ToSocketAddrs` | 把主机名和端口解析为地址 | + +完整的 [socket-client 示例](../sdk/rust/examples/socket-client/src/lib.rs) 导出 +`tcp_echo(address, message)`、`udp_echo(address, message)` 和 `resolve(host, port)`。 +回显客户端连接你自己的回显服务器,把消息限制为 1,024 字节,并在返回时释放套接字。 +TCP 使用 500 毫秒 I/O 超时。Rust 的 WASI UDP 后端不实现 `set_read_timeout` +或 `set_write_timeout`;示例改用非阻塞接收、500 毫秒时钟截止时间和短暂休眠, +不创建线程。例如,从应用的 Splash 脚本调用: + +```splash +host.request("wasm.resolve", {host: "localhost", port: 8080}, fn(r){ + if r.is_ok { addresses = r.data } else { note = r.error } +}) +``` + +用 `tools/octo wasm build --app ` 构建 crate。对于**任何** `wasi:sockets` +导入(包括只有 DNS),CLI 在保留其他要求的同时加入以下清单片段: + +```json +{ + "requires": ["wasm-components-v1", "host-api-v1"], + "capabilities": ["wasm", "net"], + "host_api": {"required": {"wasm.sockets": 1}} +} +``` + +这是 ABI 兼容性检查,不授予账户访问权限,也不引入网络白名单。使用现有的 +`host-api-v1` 格式,让 RC5 仍可读取包含这个应用的目录,只在安装时拒绝这个不兼容 +的应用。不要把 `wasm.sockets` 标为可选:组件加载时需要这些导入。`wasm info` +说明这个要求;`wasm build` 把可选的 ABI 1 声明提升为必需,并拒绝冲突的 ABI 版本。 + +套接字应用还需要支持套接字的 App Hub 准入工具。本检出版本的 publisher-toolchain +固定到包含 [App Hub #209](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/209) +的 `caf37ca0`。旧准入工具和已经生成的发布工作流仍可能拒绝它。用 +`tools/octo publish-github --replace` 重新生成工作流;不要为了通过旧准入工具 +而移除兼容性要求。准入通过不等于已经有兼容的宿主发布版。 + +TCP/UDP 不自动加密流量。需要时选择兼容 WASI 的 TLS 或协议实现,限制缓冲区大小、 +设置操作超时并及时释放套接字。宿主仍负责资源限制、取消与销毁;套接字不能绕过 +操作系统的网络限制。支持套接字不等于原生 TLS 库、所有 `tokio` 特性或每一个第三方 +网络 crate 都可移植。普通 HTTPS API 仍可使用更简单且兼容 RC5 的 +`octosense_component::http` 路径。 + +**SDK 验证:** 在 `sdk/rust` 中运行 `cargo test --locked --workspace`,会把示例编译 +成真实组件,再检查 TCP 与 UDP 往返、`localhost` 名称解析、返回时关闭 TCP 连接、不应答的 UDP 对端, +以及测试宿主没有启用套接字时的失败路径。它验证 Wasmtime 49 中的 SDK 传输,不代表 +已安装应用、公网 DNS、手机、生产配额或取消行为的验收;这些需要各宿主自己的测试。 + ### 宿主服务 **验证范围**:SDK 测试在 Wasmtime 49 中调用模拟服务,验证传输、身份与拒绝路径,不验证真实账户或设备同意。 @@ -391,7 +454,8 @@ pub mod functions { | crate | 原因 | 替代方案 | | --- | --- | --- | | C 库:`openssl-sys`、`libsqlite3-sys`,以及用 `cc` 或 `cmake` 编译 C 代码的 crate | 它们需要面向 WASI 的 C 编译器,例如 wasi-sdk 的 clang | 纯 Rust 的 crate 或特性:密码学用 RustCrypto 的 `sha2`、`hmac` 或 `aes-gcm`;数据存为应用存储中的文件,或交给脚本的存储 | -| 网络:`reqwest`、`hyper`、`ureq`、`curl`、`mio`、`socket2`、`tungstenite`、`native-tls` | 它们会打开套接字,而组件没有套接字 | 用 `octosense_component::http`(见[网络](#网络)),或在 Splash 中用 `net` 取回数据,再传进去 | +| 基于套接字的 crate:`reqwest`、`hyper`、`ureq`、`mio`、`socket2`、`tungstenite` | 所选网络/TLS 后端和特性可能不支持 WASI;`doctor` 警告,不因名称直接拒绝 | 为 `wasm32-wasip2` 构建,在提供 `wasm.sockets@1` 的宿主测试,或使用兼容 RC5 的 HTTP 适配器(见[网络](#网络)) | +| `native-tls`、`curl`、`curl-sys`、`isahc` | 依赖原生 TLS 或 libcurl | `octosense_component::http`,或兼容 WASI 的纯 Rust 实现 | | 线程:`rayon` | `wasm32-wasip2` 没有线程 | 普通迭代器,或关闭该 crate 的并行特性 | | 带 `rt-multi-thread`、`net`、`fs`、`process` 或 `signal` 的 `tokio` | 它们需要线程、网络或设备 | 普通函数:组件的函数就是普通调用,不需要异步运行时 | | JavaScript 绑定:`wasm-bindgen`、`js-sys`、`web-sys` | 组件里没有 JavaScript | 关闭该 crate 的 `js` 或 `wasm-bindgen` 特性 | @@ -415,7 +479,7 @@ OctoSense 以原生方式链接了许多 crate,例如 Makepad 的 Markdown 控 [info] pulldown-cmark 0.13.4: OctoSense renders Markdown itself: Splash's Markdown widget shows it, tables included. Ship the crate only to produce HTML or to read Markdown as data. (docs/SCRIPT-API.md#widgets-available-to-an-app) ``` -这张表只收录本仓库文档说明商店应用可以使用的功能。`photo`、`pdf`、`word` 等引擎服务只供系统应用使用,因此 `doctor` 一个也不推荐。会打开网络套接字的 crate(例如 `reqwest` 或 `ureq`)属于失败而不是提示:`doctor` 会说明组件应改用 `octosense_component::http` 访问网络(见[无法构建或运行的依赖](#无法构建或运行的依赖)和[网络](#网络))。 +这张表只收录本仓库文档说明商店应用可以使用的功能。`photo`、`pdf`、`word` 等引擎服务只供系统应用使用,因此 `doctor` 一个也不推荐。基于套接字的 crate(例如 `reqwest` 或 `ureq`)现在会得到兼容性警告,而不是直接失败。它的特性、目标构建和运行时测试决定是否可用(见[无法构建或运行的依赖](#无法构建或运行的依赖)和[网络](#网络))。 ### 构建所用的 crate @@ -465,7 +529,7 @@ built from 6 crates, as its octosense-crates section lists them: | `[refused] functions: fns/text-tools.wasm is a WebAssembly component; the manifest must require wasm-components-v1` | 把它加入 `requires`,或运行 `tools/octo wasm build`。 | | 较旧准入工具因遗漏 `storage` 披露而拒绝 | 使用兼容的 1.11 准入工具,披露文件用途供审核;有配额的私有存储仍然生效。 | | 较旧准入工具因遗漏 `net` 披露而拒绝 | 使用兼容的 1.11 准入工具,披露网络用途;不会推导运行时主机白名单(见[网络](#网络))。 | -| `[refused] contents-invalid (fns/netprobe.wasm): the component imports wasi:sockets/network@0.2.9; a component may import only wasi:cli, wasi:clocks, wasi:filesystem, wasi:http, wasi:io, wasi:random and octosense:host` | 去掉打开套接字的部分(见[无法构建或运行的依赖](#无法构建或运行的依赖))。 | +| `[refused] contents-invalid (fns/netprobe.wasm): the component imports wasi:sockets/network@0.2.9; a component may import only wasi:cli, wasi:clocks, wasi:filesystem, wasi:http, wasi:io, wasi:random and octosense:host` | 这是较旧的准入工具。使用支持套接字的准入工具和宿主,并要求 `wasm.sockets@1`,或保留兼容 RC5 的 HTTP 路径(见 [TCP、UDP 和 DNS](#tcpudp-和-dns))。 | `fns/` 中每个文件都要遵守的规则(文件名、最多 8 个文件、应用包 8 MiB)与模块相同(见[构建](#构建))。 diff --git a/sdk/rust/Cargo.lock b/sdk/rust/Cargo.lock index 8404f0ea..4f0c0b76 100644 --- a/sdk/rust/Cargo.lock +++ b/sdk/rust/Cargo.lock @@ -1320,6 +1320,13 @@ dependencies = [ "serde", ] +[[package]] +name = "socket-client" +version = "0.1.0" +dependencies = [ + "octosense-component", +] + [[package]] name = "socket2" version = "0.6.5" diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml index f526cc3a..59a10791 100644 --- a/sdk/rust/Cargo.toml +++ b/sdk/rust/Cargo.toml @@ -11,6 +11,7 @@ members = [ "examples/template", "examples/http-client", "examples/host-services", + "examples/socket-client", "e2e", ] # Cargo.lock keeps wasip2, behind octosense-component's `wasi`, at diff --git a/sdk/rust/README.md b/sdk/rust/README.md index 87830ed4..acfa0bd7 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -26,6 +26,7 @@ pub mod functions { | [examples/type-tour/](examples/type-tour/src/lib.rs) | Every type mapping both ways, random numbers through `getrandom`, and state kept between calls. | | [examples/template/](examples/template/src/lib.rs) | [templates/rust-component/src/lib.rs](../../templates/rust-component/src/lib.rs), which `tools/octo wasm new` copies, built as a member here. | | [examples/http-client/](examples/http-client/src/lib.rs) | `GET` and `POST` with `octosense_component::http`, a header and a timeout through its request builder (ADR 0014 phase 3). | +| [examples/socket-client/](examples/socket-client/src/lib.rs) | Bounded TCP/UDP echo requests and DNS using `std::net`, without an SDK socket wrapper. Needs the forthcoming `wasm.sockets@1` host API; RC5 does not support it. | | [examples/host-services/](examples/host-services/src/lib.rs) | Calls to the app's host services with `octosense_component::host` (ADR 0014 phase 3). | | [e2e/](e2e/tests/components.rs) | Builds the examples with plain cargo for `wasm32-wasip2`, loads them in Wasmtime 49 with WASI 0.2 as ADR 0014's runtime does, and calls every function. It links `wasi:http` (`wasmtime-wasi-http` 49) and `octosense:host` as OctoSense's phase 3 runtime does, with copies of its hooks: requests reach any host (here a local HTTP/1.1 server) and end at the call's deadline, and host calls reach fake services. | @@ -38,6 +39,14 @@ cargo test --locked --workspace CI's `rust-sdk` job runs them on Ubuntu, with `cargo fmt --all -- --check`. +The socket tests compile the example for `wasm32-wasip2` and exercise TCP, +UDP, local name resolution, connection closure and a host without socket +support in Wasmtime 49. They cover the SDK transport; production socket +quotas, cancellation, device acceptance and a compatible release are separate +host responsibilities. `tools/octo wasm build` adds `host-api-v1` and +`host_api.required: {"wasm.sockets": 1}` to socket apps, in addition to +`wasm-components-v1`; it discloses network use with `net`. + The SDK is not on crates.io yet. ADR 0014 publishes it there with a maintainer's approval; until then, a crate depends on it by a git commit of this repository or by path, and `tools/octo wasm new` writes either. diff --git a/sdk/rust/README.zh-CN.md b/sdk/rust/README.zh-CN.md index a516b9dd..ce3cc39b 100644 --- a/sdk/rust/README.zh-CN.md +++ b/sdk/rust/README.zh-CN.md @@ -21,6 +21,7 @@ pub mod functions { | [examples/type-tour/](examples/type-tour/src/lib.rs) | 双向的每一种类型映射、经由 `getrandom` 获取的随机数,以及在调用之间保留的状态。 | | [examples/template/](examples/template/src/lib.rs) | `tools/octo wasm new` 复制的 [templates/rust-component/src/lib.rs](../../templates/rust-component/src/lib.rs),在这里作为成员构建。 | | [examples/http-client/](examples/http-client/src/lib.rs) | 用 `octosense_component::http` 发送 `GET` 和 `POST`,并通过它的请求构建器设置请求头和超时(ADR 0014 第 3 阶段)。 | +| [examples/socket-client/](examples/socket-client/src/lib.rs) | 用 `std::net` 发送有界的 TCP/UDP 回显请求并解析 DNS,不增加 SDK 套接字封装。需要即将提供的 `wasm.sockets@1` 宿主 API;RC5 不支持。 | | [examples/host-services/](examples/host-services/src/lib.rs) | 用 `octosense_component::host` 调用应用的宿主服务(ADR 0014 第 3 阶段)。 | | [e2e/](e2e/tests/components.rs) | 用普通的 cargo 为 `wasm32-wasip2` 构建这些示例,像 ADR 0014 的运行时一样在 Wasmtime 49 中以 WASI 0.2 加载,并调用每个函数。它像 OctoSense 第 3 阶段的运行时一样链接 `wasi:http`(`wasmtime-wasi-http` 49)和 `octosense:host`,并照搬其钩子:请求可以访问任何主机(这里是本机的 HTTP/1.1 服务器),并随这次调用的截止时间结束;宿主调用由模拟的服务答复。 | @@ -32,4 +33,10 @@ cargo test --locked --workspace CI 的 `rust-sdk` 作业在 Ubuntu 上运行它们,并运行 `cargo fmt --all -- --check`。 +套接字测试把示例编译为 `wasm32-wasip2`,在 Wasmtime 49 中验证 TCP、UDP、 +本机名称解析、连接关闭和宿主未提供套接字的情形。它们验证 SDK 传输;生产环境的 +套接字配额、取消、设备验收及兼容发布版由宿主另行负责。对于套接字应用, +`tools/octo wasm build` 除了加入 `wasm-components-v1`,还会加入 +`host-api-v1` 和 `host_api.required: {"wasm.sockets": 1}`,并用 `net` 披露网络用途。 + SDK 尚未发布到 crates.io。ADR 0014 会在维护者批准后发布它;在此之前,crate 通过本仓库的 git 提交或路径依赖它,`tools/octo wasm new` 两种都能写。OctoSense #453 已合并组件、HTTP 和宿主服务的源码集成;真实应用验收和兼容的可下载宿主仍需分别完成。能力名称仅披露用途;ABI/导入检查、应用与账户作用域、配额、实际同意及原生审核仍生效(见 [docs/RUST.zh-CN.md](../../docs/RUST.zh-CN.md))。 diff --git a/sdk/rust/e2e/tests/components.rs b/sdk/rust/e2e/tests/components.rs index 777af882..1cc9ed88 100644 --- a/sdk/rust/e2e/tests/components.rs +++ b/sdk/rust/e2e/tests/components.rs @@ -14,7 +14,7 @@ use std::future::Future; use std::io::{BufRead, BufReader, Read, Write}; -use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::net::{SocketAddr, TcpListener, TcpStream, UdpSocket}; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, Instant}; @@ -252,13 +252,28 @@ fn compile(package: &str, file: &str) -> (Engine, Component) { /// Loads a component whose host services are `services`. fn load_with(storage: &Path, package: &str, file: &str, services: Option) -> Loaded { + load_with_sockets(storage, package, file, services, false) +} + +/// SDK transport test only. The production host owns socket quotas, +/// cancellation and lifecycle tests; this harness verifies std::net's ABI. +fn load_with_sockets( + storage: &Path, + package: &str, + file: &str, + services: Option, + sockets: bool, +) -> Loaded { let (engine, component) = compile(package, file); let stderr = MemoryOutputPipe::new(64 << 10); let mut wasi = WasiCtxBuilder::new(); wasi.stderr(stderr.clone()) - .allow_tcp(false) - .allow_udp(false) - .allow_ip_name_lookup(false); + .allow_tcp(sockets) + .allow_udp(sockets) + .allow_ip_name_lookup(sockets); + if sockets { + wasi.inherit_network(); + } wasi.preopened_dir(storage, "/", FsPerms::ReadWrite) .unwrap(); let mut store = Store::new( @@ -585,6 +600,106 @@ fn every_type_mapping_crosses_both_ways() { // ------------------------------------------------- phase 3: HTTP and host calls +#[test] +fn standard_library_tcp_udp_and_dns_run_in_a_component() { + let dir = storage(); + let mut c = load_with_sockets(&dir, "socket-client", "socket_client.wasm", None, true); + let tcp = TcpListener::bind("127.0.0.1:0").unwrap(); + let tcp_address = tcp.local_addr().unwrap(); + let tcp_server = std::thread::spawn(move || { + let (mut stream, _) = tcp.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut data = [0; 4]; + stream.read_exact(&mut data).unwrap(); + stream.write_all(&data).unwrap(); + // Component RAII closes its socket when the exported function returns. + assert_eq!(stream.read(&mut data).unwrap(), 0); + }); + assert_eq!( + call( + &mut c, + "tcp-echo", + &[s(&tcp_address.to_string()), s("ping")] + ), + Val::Result(Ok(Some(Box::new(s("ping"))))) + ); + tcp_server.join().unwrap(); + + let udp = UdpSocket::bind("127.0.0.1:0").unwrap(); + udp.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let udp_address = udp.local_addr().unwrap(); + let udp_server = std::thread::spawn(move || { + let mut data = [0; 1024]; + let (count, source) = udp.recv_from(&mut data).unwrap(); + udp.send_to(&data[..count], source).unwrap(); + }); + assert_eq!( + call( + &mut c, + "udp-echo", + &[s(&udp_address.to_string()), s("datagram")] + ), + Val::Result(Ok(Some(Box::new(s("datagram"))))) + ); + udp_server.join().unwrap(); + + // The WASI std UDP backend lacks set_read_timeout. The example's + // nonblocking loop must still stop when its peer never replies. + let silent = UdpSocket::bind("127.0.0.1:0").unwrap(); + let started = Instant::now(); + assert_eq!( + error(call( + &mut c, + "udp-echo", + &[s(&silent.local_addr().unwrap().to_string()), s("silence")] + )), + "UDP echo timed out" + ); + assert!(started.elapsed() < Duration::from_secs(5)); + + let resolved = call( + &mut c, + "resolve", + &[s("localhost"), Val::U16(tcp_address.port())], + ); + match resolved { + Val::Result(Ok(Some(addresses))) => match *addresses { + Val::List(addresses) => { + assert!(!addresses.is_empty()); + for address in addresses { + let Val::String(address) = address else { + panic!("not an address") + }; + let address: SocketAddr = address.parse().unwrap(); + assert!(address.ip().is_loopback()); + assert_eq!(address.port(), tcp_address.port()); + } + } + other => panic!("{other:?}"), + }, + other => panic!("{other:?}"), + } + drop(c); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn socket_client_reports_refusal_on_a_host_without_socket_support() { + let dir = storage(); + let mut c = load_component(&dir, "socket-client", "socket_client.wasm"); + for (function, args) in [ + ("tcp-echo", vec![s("127.0.0.1:9"), s("ping")]), + ("udp-echo", vec![s("127.0.0.1:9"), s("ping")]), + ("resolve", vec![s("localhost"), Val::U16(9)]), + ] { + assert!(!error(call(&mut c, function, &args)).is_empty()); + } + drop(c); + std::fs::remove_dir_all(dir).unwrap(); +} + /// A local HTTP/1.1 server, one request per connection: `POST` echoes its /// body with its content type; `GET /missing` answers 404; any other `GET` /// answers 200 with its request line and its `x-test` header as text. diff --git a/sdk/rust/examples/socket-client/Cargo.toml b/sdk/rust/examples/socket-client/Cargo.toml new file mode 100644 index 00000000..156851ab --- /dev/null +++ b/sdk/rust/examples/socket-client/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "socket-client" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +publish = false +description = "Ordinary std::net TCP, UDP and DNS in a WASI component." + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +octosense-component = { path = "../../octosense-component" } diff --git a/sdk/rust/examples/socket-client/src/lib.rs b/sdk/rust/examples/socket-client/src/lib.rs new file mode 100644 index 00000000..43864c55 --- /dev/null +++ b/sdk/rust/examples/socket-client/src/lib.rs @@ -0,0 +1,82 @@ +//! TCP and UDP echo clients, and DNS, using Rust's standard library. +//! +//! Build with `wasm32-wasip2`. `tools/octo wasm build` records the required +//! host API `wasm.sockets@1` and discloses `net`; desktop RC5 has no sockets. +//! No SDK networking wrapper or async runtime is needed. TCP/UDP carry +//! plaintext here: use a suitable TLS/protocol implementation for real data. + +#[octosense_component::export] +pub mod functions { + use std::io::{ErrorKind, Read, Write}; + use std::net::{SocketAddr, TcpStream, ToSocketAddrs, UdpSocket}; + use std::time::{Duration, Instant}; + + const TIMEOUT: Duration = Duration::from_millis(500); + const MAX_MESSAGE: usize = 1024; + + fn payload(message: &str) -> Result<&[u8], String> { + if message.is_empty() || message.len() > MAX_MESSAGE { + return Err("echo message must contain 1–1024 UTF-8 bytes".into()); + } + Ok(message.as_bytes()) + } + + /// DNS through WASI; return addresses as strings that cross the SDK ABI. + pub fn resolve(host: &str, port: u16) -> Result, String> { + let addresses = (host, port).to_socket_addrs().map_err(|e| e.to_string())?; + Ok(addresses.take(16).map(|addr| addr.to_string()).collect()) + } + + /// One bounded request to a TCP echo server at a numeric IP:port. + pub fn tcp_echo(address: &str, message: &str) -> Result { + let bytes = payload(message)?; + let address: SocketAddr = address.parse().map_err(|e| format!("address: {e}"))?; + let mut stream = + TcpStream::connect_timeout(&address, TIMEOUT).map_err(|e| e.to_string())?; + stream + .set_read_timeout(Some(TIMEOUT)) + .map_err(|e| e.to_string())?; + stream + .set_write_timeout(Some(TIMEOUT)) + .map_err(|e| e.to_string())?; + stream.write_all(bytes).map_err(|e| e.to_string())?; + let mut reply = vec![0; bytes.len()]; + stream.read_exact(&mut reply).map_err(|e| e.to_string())?; + String::from_utf8(reply).map_err(|e| e.to_string()) + // stream drops here, closing the socket even if a preceding ? returns. + } + + /// One bounded request to a UDP echo server at a numeric IP:port. + pub fn udp_echo(address: &str, message: &str) -> Result { + let bytes = payload(message)?; + let address: SocketAddr = address.parse().map_err(|e| format!("address: {e}"))?; + let local = if address.is_ipv4() { + "0.0.0.0:0" + } else { + "[::]:0" + }; + let socket = UdpSocket::bind(local).map_err(|e| format!("UDP bind: {e}"))?; + // Rust's WASI UDP backend does not implement set_read_timeout or + // set_write_timeout. Poll nonblocking with a clock deadline instead. + socket.set_nonblocking(true).map_err(|e| e.to_string())?; + socket + .connect(address) + .map_err(|e| format!("UDP connect: {e}"))?; + socket.send(bytes).map_err(|e| format!("UDP send: {e}"))?; + let mut reply = [0; MAX_MESSAGE]; + let deadline = Instant::now() + TIMEOUT; + let length = loop { + match socket.recv(&mut reply) { + Ok(length) => break length, + Err(error) if error.kind() == ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return Err("UDP echo timed out".into()); + } + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => return Err(format!("UDP receive: {error}")), + } + }; + String::from_utf8(reply[..length].to_vec()).map_err(|e| e.to_string()) + } +} diff --git a/sdk/rust/octosense-component/src/lib.rs b/sdk/rust/octosense-component/src/lib.rs index 0d55b94d..89bf5328 100644 --- a/sdk/rust/octosense-component/src/lib.rs +++ b/sdk/rust/octosense-component/src/lib.rs @@ -40,7 +40,7 @@ //! time, with what to use instead. //! //! The component runs in the OctoSense `wasm` service. It has clocks and -//! randomness, and with the app's `storage` grant, the app's own folder as +//! randomness, and the app's bounded private folder as //! `/` through `std::fs`. Its stdout and stderr ([`log`]) become log lines. //! ADR 0014's phase 3 adds two more, each imported only by a component that //! calls it: @@ -50,7 +50,11 @@ //! - [`host`]: the host services the app is granted, as its script calls //! them with `host.request`. //! -//! It has no sockets, environment or other host access. +//! Ordinary `std::net` TCP/UDP/DNS needs a host advertising `wasm.sockets@1`; +//! released RC5 does not provide it. `tools/octo wasm build` records that +//! required host ABI automatically when the component imports `wasi:sockets`. +//! There is no separate SDK socket wrapper. The environment is empty, and +//! native libraries or arbitrary OS APIs remain unavailable. pub mod host; pub mod http; diff --git a/templates/rust-component/src/lib.rs b/templates/rust-component/src/lib.rs index 984a0396..cad338be 100644 --- a/templates/rust-component/src/lib.rs +++ b/templates/rust-component/src/lib.rs @@ -10,10 +10,13 @@ //! ``` //! //! Use any crate that builds for `wasm32-wasip2` (`tools/octo wasm doctor` -//! names the ones that do not). Its code has the clock and random numbers, -//! and the app's storage folder as `/` through `std::fs` when the app has -//! `storage`. It has no sockets: `octosense_component::http` sends HTTP -//! requests, to any host, when the app has `net`, and +//! checks known dependencies; a successful build still needs runtime tests). +//! Its code has the clock, random numbers and bounded private storage as `/` +//! through `std::fs`. `octosense_component::http` sends HTTP requests. +//! Ordinary `std::net` TCP/UDP/DNS requires a host advertising wasm.sockets@1, +//! which released RC5 does not provide. `wasm build` adds that compatibility +//! requirement automatically. Disclose file/network use with `storage`/`net`. +//! //! `octosense_component::host` calls the host services the app is granted. //! What a function may take and return is in OctoSense App Flow's //! docs/RUST.md. diff --git a/tools/check-publisher-toolchain.py b/tools/check-publisher-toolchain.py index 4f797178..be9b7f69 100644 --- a/tools/check-publisher-toolchain.py +++ b/tools/check-publisher-toolchain.py @@ -2,7 +2,7 @@ """Build and exercise the pinned publisher; no network calls by guests or publication. Prepare the Hub checkout and its runtime siblings first (see CI). The SDK's -real HTTP, host-service and filesystem components are built from this checkout. +real HTTP, socket, host-service and filesystem components are built from this checkout. The one-pixel image is structural test data, never an app screenshot or UX proof. """ import argparse @@ -88,18 +88,28 @@ def main(): components = args.component_target_dir.resolve() subprocess.run(['cargo', 'build', '--locked', '--release', '--target', 'wasm32-wasip2', '--target-dir', str(components), '-p', 'http-client', '-p', 'host-services', - '-p', 'markdown-tools'], cwd=ROOT / 'sdk/rust', check=True) + '-p', 'markdown-tools', '-p', 'socket-client'], cwd=ROOT / 'sdk/rust', check=True) checks = [] with tempfile.TemporaryDirectory(prefix='publisher-toolchain-') as temp: for name, package in [('http_client', 'wasi:http/'), ('host_services', 'octosense:host/'), - ('markdown_tools', 'wasi:filesystem/')]: + ('markdown_tools', 'wasi:filesystem/'), + ('socket_client', 'wasi:sockets/')]: root = Path(temp) / name wasm = components / 'wasm32-wasip2/release' / (name + '.wasm') info = json.loads(run(hub, 'component-info', wasm)) assert info['kind'] == 'component' and any( value.startswith(package) for value in info['imports']), info bundle = bundle_at(root, wasm.read_bytes()) + if name == 'socket_client': + denied = check(hub, bundle, success=False) + assert any('wasm.sockets@1' in f['detail'] for f in denied['findings']), denied + manifest_file = bundle / 'manifest.json' + manifest = json.loads(manifest_file.read_text()) + manifest['requires'].append('host-api-v1') + manifest['host_api'] = {'required': {'wasm.sockets': 1}} + write_json(manifest_file, manifest) + checks.append('socket_client: missing socket host ABI refused') report = check(hub, bundle) assert any(f['check'] == 'functions' for f in report['findings']), report checks.append(name + ': bundled imports accepted without declarations') @@ -123,7 +133,7 @@ def main(): manifest_file = bundle / 'manifest.json' manifest = json.loads(manifest_file.read_text()) - manifest['requires'] = [] + manifest['requires'] = ['host-api-v1'] if name == 'socket_client' else [] write_json(manifest_file, manifest) denied = check(hub, bundle, success=False) assert any('must require wasm-components-v1' in f['detail'] for f in denied['findings']), denied @@ -131,6 +141,8 @@ def main(): manifest['components'] = [{'as': 'shared_probe', 'id': component['id'], 'version': component['version'], 'blake3': component['wasm_blake3']}] manifest['requires'] = ['wasm-components-v1', 'wasm-shared-components-v1'] + if name == 'socket_client': + manifest['requires'].append('host-api-v1') write_json(manifest_file, manifest) shared_app = check(hub, bundle) assert any(f['check'] == 'components' and 'not resolved' in f['detail'] @@ -144,6 +156,8 @@ def main(): sealed = json.loads(subject.read_text()) assert set(manifest['requires']) <= set(sealed['requires']), sealed assert sealed['components'] == manifest['components'], sealed + if name == 'socket_client': + assert sealed['host_api']['required'] == {'wasm.sockets': 1}, sealed denied = run(hub, 'publisher-verify', bundle, success=False) assert 'attestation' in denied.lower() or 'proof' in denied.lower(), denied pack = root / 'app.bundle.pack.json' @@ -151,17 +165,17 @@ def main(): assert not pack.exists(), 'an unattested app must not produce a release pack' checks.append(name + ': ABI required; shared pins preserved; unattested pack refused') - # The genuine negative component imports wasi:sockets, outside the - # host's supported component ABI. Broadening disclosures must not - # accidentally admit it or malformed component bytes. + # The genuine socket fixture must also require the host API. Admitting + # its import package must not admit an incompatible manifest or + # malformed component bytes. socket = source / 'crates/app-hub/tests/fixtures/netprobe.component.wasm' bundle = bundle_at(Path(temp) / 'sockets', socket.read_bytes()) denied = check(hub, bundle, success=False) - assert any('wasi:sockets' in f['detail'] for f in denied['findings']), denied + assert any('wasm.sockets@1' in f['detail'] for f in denied['findings']), denied (bundle / 'fns/probe.wasm').write_bytes(b'\0asm\x0d\x00\x01\x00\xff') denied = check(hub, bundle, success=False) assert any(f['check'] == 'functions' for f in denied['findings']), denied - checks.append('unsupported sockets and malformed components refused') + checks.append('socket imports without required host ABI and malformed components refused') print(json.dumps({'publisher_revision': head, 'checks': checks, 'passed': len(checks), 'scope': 'structural admission and preparation; no catalog resolution, attestation, publication or guest execution'}, indent=2)) diff --git a/tools/octo b/tools/octo index c2e5770d..5916878f 100755 --- a/tools/octo +++ b/tools/octo @@ -659,6 +659,7 @@ SDK_GIT = "https://github.com/OctoSense-org/OctoSense-App-Flow" SDK_REMOTE = re.compile(r"github\.com[:/]OctoSense-org/(OctoSense-App-Flow|OctoScript-App-Design-Flow)(\.git)?/?$", re.I) WASM_TARGET = "wasm32-wasip2" COMPONENTS_FEATURE = "wasm-components-v1" +SOCKETS_API = "wasm.sockets" # Rust builds components for wasm32-wasip2 from 1.82; the SDK's wit-bindgen # 0.62 needs 1.85, and the crates it builds with (wit-component, wit-parser # 0.259) need 1.88. @@ -676,25 +677,25 @@ MAX_BUNDLE_BYTES = 8 << 20 # Why a component imports what no host gives it, and what to do instead. IMPORT_HINTS = ( - ("wasi:sockets/", "network sockets (std::net, or a crate such as reqwest, ureq or tokio's net, which open " - "sockets): a component has none. It reaches the network over HTTP through wasi:http, " - "with octosense_component::http; disclose that use with `net`."), ("__wbindgen", "JavaScript bindings (wasm-bindgen): turn off the dependency's js or wasm-bindgen feature."), ) # Dependencies that cannot work in a component, and what to use instead. -NETWORK_HINT = ("opens network sockets, which a component does not have, so it does not work. A component " - "reaches the network over HTTP through wasi:http, with octosense_component::http, once the " - "manifest has `net`.") +NETWORK_HINT = ("may need a WASI-compatible networking/TLS backend or different features. Build and test " + "for wasm32-wasip2; a crate name alone cannot prove compatibility. Ordinary std::net " + "TCP/UDP/DNS needs a host advertising wasm.sockets@1 (not RC5). For HTTP, the SDK's " + "octosense_component::http uses wasi:http and also works on RC5.") +NETWORK_CRATES = frozenset(("reqwest", "hyper", "ureq", "surf", "attohttpc", "socket2", "mio", "tungstenite")) BLOCKING_CRATES = { "openssl-sys": "links OpenSSL, a C library, which does not build for wasm32-wasip2. " "For cryptography use pure-Rust crates such as RustCrypto's sha2, hmac or aes-gcm.", - "native-tls": NETWORK_HINT, + "native-tls": "uses the platform's native TLS library, which a WASI component cannot call. " + "Use octosense_component::http for HTTP, or a WASI-compatible pure-Rust TLS backend.", "libsqlite3-sys": "builds SQLite, a C library, which needs a C compiler for WASI. " "Keep the data in files in the app's storage (std::fs), or let the script store it.", - "reqwest": NETWORK_HINT, "hyper": NETWORK_HINT, "ureq": NETWORK_HINT, "curl": NETWORK_HINT, - "curl-sys": NETWORK_HINT, "isahc": NETWORK_HINT, "surf": NETWORK_HINT, "attohttpc": NETWORK_HINT, - "socket2": NETWORK_HINT, "mio": NETWORK_HINT, "tungstenite": NETWORK_HINT, + "curl": "uses native libcurl; use octosense_component::http for HTTP.", + "curl-sys": "links native libcurl; use octosense_component::http for HTTP.", + "isahc": "uses native libcurl; use octosense_component::http for HTTP.", "wasm-bindgen": "binds JavaScript, which a component does not have. " "Turn off the feature that pulls it in (often `js` or `wasm-bindgen`).", "js-sys": "binds JavaScript, which a component does not have.", @@ -921,6 +922,8 @@ def dependency_findings(cargo, crate): label = f"{name} {package['version']}" if name in BLOCKING_CRATES: problems.append(("fail", f"{label} {BLOCKING_CRATES[name]}")) + elif name in NETWORK_CRATES: + problems.append(("warn", f"{label} {NETWORK_HINT}")) elif name in THREAD_CRATES: problems.append(("warn", f"{label} {THREAD_CRATES[name]}")) elif name == "tokio": @@ -1078,13 +1081,23 @@ def script_call(export, kind="component"): return f"wasm.{name}({params})" + (f" -> {export['result']}" if export.get("result") else "") -def component_manifest_changes(manifest, files, http=None): +def component_manifest_changes(manifest, files, http=None, sockets=None): """Adds what a bundle with components needs to `manifest`; returns a note - per change. `files` and `http` name a component that imports - wasi:filesystem or wasi:http. It never touches network.hosts: an app's + per change. `files`, `http` and `sockets` name components that import + wasi:filesystem, wasi:http or wasi:sockets. It never touches network.hosts: an app's network declarations are shown at install and not enforced while it runs (OctoSense's ruling of 8 October 2026), so a component's requests reach - any host, and App Hub's gate asks only for `net`.""" + any host. Socket imports also need a host API compatibility requirement: + RC5 can still read the catalog and refuses only the incompatible app.""" + if sockets: + host_api = manifest.get("host_api") or {} + required = host_api.get("required") or {} + optional = host_api.get("optional") or {} + for declarations in (required, optional): + if SOCKETS_API in declarations and (type(declarations[SOCKETS_API]) is not int + or declarations[SOCKETS_API] != 1): + die(f'{SOCKETS_API} imports need ABI 1, but host_api declares {declarations[SOCKETS_API]!r}; ' + "resolve the incompatible declaration before building") notes = [] capabilities = manifest.setdefault("capabilities", []) requires = manifest.setdefault("requires", []) @@ -1099,9 +1112,25 @@ def component_manifest_changes(manifest, files, http=None): capabilities.append("storage") notes.append(f'added "storage" to capabilities: {files} imports wasi:filesystem, ' "and its `/` is the app's storage folder") - if http and "net" not in capabilities: + if (http or sockets) and "net" not in capabilities: capabilities.append("net") - notes.append(f'added "net" to capabilities: {http} imports wasi:http, the network') + name, interface = (http, "wasi:http") if http else (sockets, "wasi:sockets") + notes.append(f'added "net" to capabilities: {name} imports {interface}, the network') + if sockets: + if "host-api-v1" not in requires: + requires.append("host-api-v1") + notes.append('added "host-api-v1" to requires: socket compatibility is checked before install') + if manifest.get("host_api") is None: + manifest["host_api"] = {} + host_api = manifest["host_api"] + required = host_api.setdefault("required", {}) + if SOCKETS_API not in required: + required[SOCKETS_API] = 1 + notes.append(f'added "{SOCKETS_API}": 1 to host_api.required: {sockets} imports wasi:sockets; ' + "requires a forthcoming socket-capable host, not RC5") + if SOCKETS_API in host_api.get("optional", {}): + del host_api["optional"][SOCKETS_API] + notes.append(f'removed "{SOCKETS_API}" from host_api.optional: component imports make it required') return notes @@ -1143,7 +1172,8 @@ def cmd_wasm_build(args): # What the manifest needs, from what the components import. files = next((b["name"] for b in built if wasm_component.uses_files(b["info"]["imports"])), None) http = next((b["name"] for b in built if wasm_component.uses_http(b["info"]["imports"])), None) - notes = component_manifest_changes(manifest, files, http) + sockets = next((b["name"] for b in built if wasm_component.uses_sockets(b["info"]["imports"])), None) + notes = component_manifest_changes(manifest, files, http, sockets) fns = bundle / "fns" fns.mkdir(exist_ok=True) @@ -1269,11 +1299,16 @@ def cmd_wasm_info(args): print("\n".join(refusal_lines(refused))) else: print(f'the manifest requires "{COMPONENTS_FEATURE}" in requires (runtime ABI)') + if wasm_component.uses_sockets(imports): + print(f'the manifest also requires "host-api-v1" and host_api.required {{"{SOCKETS_API}": 1}}; ' + 'requires a forthcoming socket-capable host, not RC5') disclosures = ['"wasm"'] if wasm_component.uses_files(imports): disclosures.append('"storage" (it imports wasi:filesystem)') if wasm_component.uses_http(imports): disclosures.append('"net" (it imports wasi:http)') + elif wasm_component.uses_sockets(imports): + disclosures.append('"net" (it imports wasi:sockets)') print("usage disclosures in capabilities: " + ", ".join(disclosures) + "; omitted declarations do not deny execution in compatible hosts") if wasm_component.uses_host_services(imports): diff --git a/tools/publisher-toolchain.json b/tools/publisher-toolchain.json index 6299532a..31ce3943 100644 --- a/tools/publisher-toolchain.json +++ b/tools/publisher-toolchain.json @@ -1,5 +1,5 @@ { "schema": 1, "repository": "OctoSense-org/OctoSense-App-Hub", - "revision": "7b36c8afc2e4bad9dfa5d77692b6fd453f0b9785" + "revision": "caf37ca0ec18edb5b7d8eefed7a69b7f8a671ce7" } diff --git a/tools/test_octo.py b/tools/test_octo.py index 0bd4a1cc..8d82bfc1 100644 --- a/tools/test_octo.py +++ b/tools/test_octo.py @@ -154,6 +154,7 @@ def component(*imports): b'\x0a\x09\x01\x07\x00\x20\x00\x20\x01\x6a\x0b') ENV, FILES, TCP = 'wasi:cli/environment@0.2.9', 'wasi:filesystem/types@0.2.9', 'wasi:sockets/tcp@0.2.9' +UDP, DNS = 'wasi:sockets/udp@0.2.9', 'wasi:sockets/ip-name-lookup@0.2.9' CLOCK, RANDOM = 'wasi:clocks/wall-clock@0.2.9', 'wasi:random/random@0.2.9' HTTP, HTTP_TYPES = 'wasi:http/outgoing-handler@0.2.9', 'wasi:http/types@0.2.9' HOST_SERVICES = 'octosense:host/services@0.1.0' @@ -209,19 +210,24 @@ def test_a_component_s_imports_and_typed_exports(self): {'name': 'ping', 'params': [], 'result': None}, ], }) - self.assertEqual(wasm_component.refused_imports(info['imports']), [TCP]) + self.assertEqual(wasm_component.refused_imports(info['imports']), []) # A package name is matched whole, as App Hub's gate matches it. self.assertEqual(wasm_component.refused_imports(['wasi:clocksmith/x', 'my:pkg/host']), ['wasi:clocksmith/x', 'my:pkg/host']) - def test_phase_3_admits_http_and_host_services_and_nothing_else(self): + def test_import_packages_include_sockets_but_not_similarly_named_packages(self): imports = ['wasi:io/poll@0.2.9', 'wasi:sockets/network@0.2.9', HTTP_TYPES, 'my:pkg/host', HOST_SERVICES, 'octosense:hostile/x'] self.assertEqual(wasm_component.refused_imports(imports), - ['wasi:sockets/network@0.2.9', 'my:pkg/host', 'octosense:hostile/x']) + ['my:pkg/host', 'octosense:hostile/x']) self.assertTrue(wasm_component.uses_http(imports)) self.assertTrue(wasm_component.uses_host_services(imports)) self.assertFalse(wasm_component.uses_http([ENV, FILES, 'wasi:httpx/x'])) self.assertFalse(wasm_component.uses_host_services([ENV, 'octosense:hostile/x'])) + for imported in (TCP, UDP, DNS): + self.assertTrue(wasm_component.uses_sockets([imported])) + self.assertEqual(wasm_component.refused_imports([imported]), []) + self.assertFalse(wasm_component.uses_sockets(['wasi:sockets-extra/tcp'])) + self.assertEqual(wasm_component.refused_imports(['wasi:sockets-extra/tcp']), ['wasi:sockets-extra/tcp']) def test_the_reach_line_is_app_hub_s(self): # App Hub's `ComponentInfo::reach` tests, word for word. @@ -239,6 +245,8 @@ def test_the_reach_line_is_app_hub_s(self): "its app's host services, but no other app") # Any wasi:http interface is the network, even types alone. self.assertEqual(reach([HTTP_TYPES]), 'the network, but no files or other app') + for imported in (TCP, UDP, DNS): + self.assertEqual(reach([imported]), 'the network, but no files or other app') def test_a_core_module_s_imports_and_exports(self): self.assertEqual(wasm_component.describe(CORE_MODULE), { @@ -288,6 +296,15 @@ def test_built_phase_3_examples_import_only_what_they_call(self): ('host-apis', [], 'result'), ]) + def test_built_std_net_example_has_socket_imports_and_typed_exports(self): + found = [d / 'socket_client.wasm' for d in SDK_WASM if (d / 'socket_client.wasm').is_file()] + if not found: self.skipTest('build sdk/rust first: cargo test --locked --workspace') + info = wasm_component.describe(found[0].read_bytes()) + self.assertTrue(wasm_component.uses_sockets(info['imports'])) + self.assertFalse(wasm_component.uses_http(info['imports'])) + self.assertEqual(wasm_component.refused_imports(info['imports']), []) + self.assertEqual([export['name'] for export in info['exports']], ['resolve', 'tcp-echo', 'udp-echo']) + class WasmCommands(unittest.TestCase): def app(self, temp, capabilities=('storage',), hosts=None): @@ -441,11 +458,9 @@ def test_build_refuses_imports_no_host_gives_and_leaves_the_bundle_alone(self): with tempfile.TemporaryDirectory() as temp: app = self.app(temp) before = (app / 'bundle/manifest.json').read_text() - code, _, err = self.build(app, component(ENV, TCP)) + code, _, err = self.build(app, component(ENV, '__wbindgen_placeholder__.fetch')) self.assertEqual(code, 1) - self.assertIn(TCP, err) - self.assertIn('a component has none. It reaches the network over HTTP through wasi:http, ' - 'with octosense_component::http; disclose that use with `net`.', err) + self.assertIn('JavaScript bindings (wasm-bindgen)', err) self.assertNotIn('network.hosts', err) self.assertFalse((app / 'bundle/fns').exists()) self.assertEqual((app / 'bundle/manifest.json').read_text(), before) @@ -455,6 +470,47 @@ def test_build_refuses_imports_no_host_gives_and_leaves_the_bundle_alone(self): self.assertEqual(code, 1) self.assertIn('octosense:hostile/x:\n not an interface any host gives a component', err) + def test_build_sockets_requires_a_compatible_host_and_discloses_network(self): + for imported in (TCP, UDP, DNS): + with self.subTest(imported=imported), tempfile.TemporaryDirectory() as temp: + app = self.app(temp, capabilities=(), hosts=['example.org']) + code, out, err = self.build(app, component(CLOCK, imported)) + self.assertEqual(code, 0, out + err) + manifest = json.loads((app / 'bundle/manifest.json').read_text()) + self.assertEqual(manifest['requires'], ['wasm-components-v1', 'host-api-v1']) + self.assertEqual(manifest['host_api'], {'required': {'wasm.sockets': 1}}) + self.assertEqual(manifest['capabilities'], ['wasm', 'net']) + self.assertEqual(manifest['network'], {'hosts': ['example.org']}) + self.assertIn('requires a forthcoming socket-capable host, not RC5', out) + code, out, err = self.build_again(app, component(CLOCK, imported)) + self.assertEqual(code, 0, out + err) + self.assertNotIn('bundle/manifest.json:', out) + + def test_socket_requirements_preserve_other_apis_and_promote_optional_support(self): + manifest = {'requires': ['host-api-v1'], 'host_api': { + 'required': {'files.read': 1}, 'optional': {'wasm.sockets': 1, 'camera.capture': 1}}} + octo.component_manifest_changes(manifest, None, sockets='network-tools') + self.assertEqual(manifest['host_api'], { + 'required': {'files.read': 1, 'wasm.sockets': 1}, 'optional': {'camera.capture': 1}}) + self.assertEqual(manifest['requires'], ['host-api-v1', 'wasm-components-v1']) + null = {'host_api': None} + octo.component_manifest_changes(null, None, sockets='network-tools') + self.assertEqual(null['host_api'], {'required': {'wasm.sockets': 1}}) + + def test_conflicting_socket_abi_refuses_before_changing_the_bundle(self): + with tempfile.TemporaryDirectory() as temp: + app = self.app(temp) + path = app / 'bundle/manifest.json' + manifest = json.loads(path.read_text()) + manifest['host_api'] = {'required': {'wasm.sockets': 2}} + path.write_text(json.dumps(manifest)) + before = path.read_bytes() + code, _, err = self.build(app, component(TCP)) + self.assertEqual(code, 1) + self.assertIn('wasm.sockets imports need ABI 1', err) + self.assertEqual(path.read_bytes(), before) + self.assertFalse((app / 'bundle/fns').exists()) + def test_build_adds_net_for_http_and_never_a_host(self): # network.hosts is a declaration shown at install, not a limit # (OctoSense's ruling of 8 October 2026): a component that imports @@ -550,6 +606,27 @@ def test_what_octosense_provides_cites_a_doc_section_store_apps_may_use(self): # System apps' engine services are not a store app's. self.assertNotRegex(hint, r'\b(photo|pdf|word|llm)\.') + def test_doctor_warns_about_socket_crates_instead_of_refusing_their_names(self): + crate = Path('/apps/network-tools') + meta = { + 'packages': [ + {'id': 'root', 'name': 'network-tools', 'version': '0.1.0', + 'manifest_path': str(crate / 'Cargo.toml'), 'targets': [{'kind': ['cdylib']}], + 'dependencies': []}, + {'id': 'network', 'name': 'ureq', 'version': '3.0.0'}, + ], + 'resolve': {'nodes': [ + {'id': 'root', 'deps': [{'pkg': 'network', 'dep_kinds': [{'kind': None}]}]}, + {'id': 'network', 'deps': []}, + ]}, + } + done = subprocess.CompletedProcess([], 0, stdout=json.dumps(meta), stderr='') + with patch.object(octo.subprocess, 'run', return_value=done): + findings = octo.dependency_findings('cargo', crate) + self.assertFalse(any(level == 'fail' for level, _ in findings)) + self.assertTrue(any(level == 'warn' and 'ureq 3.0.0' in message + and 'wasm.sockets@1 (not RC5)' in message for level, message in findings)) + def test_info_reads_a_file_without_a_hub(self): with tempfile.TemporaryDirectory() as temp: path = Path(temp) / 'tools.wasm' @@ -559,7 +636,9 @@ def test_info_reads_a_file_without_a_hub(self): self.assertEqual(code, 0) self.assertIn("read by octo's own reader", out) self.assertIn('wasm.count_words(text: string) -> record { words: u32 }', out) - self.assertIn(f'{TCP} (refused)', out) + self.assertNotIn(f'{TCP} (refused)', out) + self.assertIn('host_api.required {"wasm.sockets": 1}', out) + self.assertIn('not RC5', out) self.assertIn('crates: not recorded;', out) code, out, _ = self.octo('wasm', 'info', str(path), '--json') self.assertEqual(json.loads(out), dict(wasm_component.describe(component(ENV, TCP)), crates=None)) diff --git a/tools/wasm_component.py b/tools/wasm_component.py index 9dbd9be2..e67c7568 100644 --- a/tools/wasm_component.py +++ b/tools/wasm_component.py @@ -31,14 +31,15 @@ # The packages a component may import (ADR 0014); the same list as App Hub's # ALLOWED_COMPONENT_IMPORTS. Files stay in the app's bounded private folder; -# wasi:http reaches the network. Capability and destination declarations +# wasi:http and wasi:sockets reach the network. Capability and destination declarations # disclose usage rather than authorize execution. octosense:host keeps the # app identity, actual service authorization and background-surface limits. # A package name is matched whole. ALLOWED_IMPORTS = ("wasi:cli/", "wasi:clocks/", "wasi:filesystem/", "wasi:http/", "wasi:io/", "wasi:random/", - "octosense:host/") + "wasi:sockets/", "octosense:host/") FILESYSTEM = "wasi:filesystem/" HTTP = "wasi:http/" +SOCKETS = "wasi:sockets/" HOST_SERVICES = "octosense:host/" PRIMITIVES = { @@ -621,6 +622,11 @@ def uses_http(imports): return any(name.startswith(HTTP) for name in imports) +def uses_sockets(imports): + """TCP, UDP or DNS: requires the host's wasm.sockets@1 compatibility API.""" + return any(name.startswith(SOCKETS) for name in imports) + + def uses_host_services(imports): """Whether it imports octosense:host: the public host services available to its app.""" return any(name.startswith(HOST_SERVICES) for name in imports) @@ -632,7 +638,7 @@ def reach(imports): network, but no other app".""" has = lambda prefix: any(name.startswith(prefix) for name in imports) # noqa: E731 files = uses_files(imports) - network = uses_http(imports) + network = uses_http(imports) or uses_sockets(imports) reaches = [] if has("wasi:clocks/"): reaches.append("the clock") From bb1b30baf023289ef1f6f7d84e4861860f1f0edc Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:13:16 -0700 Subject: [PATCH 2/3] Reject the connect export that collides with WASI libc --- docs/RUST.md | 8 +++++++ docs/RUST.zh-CN.md | 6 +++++ .../octosense-component-macros/src/lib.rs | 23 +++++++++++++++++++ sdk/rust/octosense-component/src/lib.rs | 11 +++++++++ templates/rust-component/src/lib.rs | 2 ++ 5 files changed, 50 insertions(+) diff --git a/docs/RUST.md b/docs/RUST.md index 9f58f1fd..b4e6b566 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -546,6 +546,14 @@ OctoSense socket wrapper to learn: | `std::net::UdpSocket` | Bind and exchange UDP datagrams | | `std::net::ToSocketAddrs` | Resolve a host and port into addresses | +Use descriptive exported names such as `tcp_connect` or `fetch_message`. +The SDK refuses an export named `connect`: wit-bindgen's bare world export +collides with wasi-libc's C `connect` symbol, causing ordinary `std::net` +calls to enter the wrong ABI and trap. Renaming the export fixes the verified +collision; a private Rust helper named `connect` is safe. Direct WIT authors +must use a different export name too. This is a naming issue, not a refusal +of TCP or hostname lookup. + The complete [socket-client example](../sdk/rust/examples/socket-client/src/lib.rs) exports `tcp_echo(address, message)`, `udp_echo(address, message)` and `resolve(host, port)`. The echo clients talk to your own echo server, bound diff --git a/docs/RUST.zh-CN.md b/docs/RUST.zh-CN.md index 9fda029d..84107d07 100644 --- a/docs/RUST.zh-CN.md +++ b/docs/RUST.zh-CN.md @@ -370,6 +370,12 @@ Rust 的 `wasm32-wasip2` 标准库提供导入,不需要学习另一套 OctoSe | `std::net::UdpSocket` | 绑定并交换 UDP 数据报 | | `std::net::ToSocketAddrs` | 把主机名和端口解析为地址 | +使用描述性导出名,例如 `tcp_connect` 或 `fetch_message`。SDK 拒绝名为 `connect` +的导出:wit-bindgen 的裸 world 导出会与 wasi-libc 的 C `connect` 符号冲突,使 +普通 `std::net` 调用进入错误的 ABI 并陷阱退出。重命名导出可修复已验证的冲突; +私有 Rust 辅助函数可以叫 `connect`。直接编写 WIT 的开发者也必须使用不同的导出 +名。这是命名问题,不是拒绝 TCP 或主机名解析。 + 完整的 [socket-client 示例](../sdk/rust/examples/socket-client/src/lib.rs) 导出 `tcp_echo(address, message)`、`udp_echo(address, message)` 和 `resolve(host, port)`。 回显客户端连接你自己的回显服务器,把消息限制为 1,024 字节,并在返回时释放套接字。 diff --git a/sdk/rust/octosense-component-macros/src/lib.rs b/sdk/rust/octosense-component-macros/src/lib.rs index 7a26d12a..b1a825bd 100644 --- a/sdk/rust/octosense-component-macros/src/lib.rs +++ b/sdk/rust/octosense-component-macros/src/lib.rs @@ -258,6 +258,17 @@ fn expand(module: ItemMod) -> syn::Result { app's functions); rename this one", )); } + // wit-bindgen emits a bare C-linkage export for a world + // function. `connect` then replaces wasi-libc's connect(), + // so std::net recurses into the incompatible guest ABI and + // can trap even when another export opens the socket. + if function.wit == "connect" { + return Err(syn::Error::new( + f.sig.ident.span(), + "`connect` collides with wasi-libc's socket symbol; rename the exported \ + function to `tcp_connect` or another prefixed name", + )); + } if wit_names .insert(function.wit.clone(), f.sig.ident.span()) .is_some() @@ -921,6 +932,10 @@ mod tests { "mod m { pub fn functions() {} }", "the wasm service's own method", ), + ( + "mod m { pub fn connect(addr: &str) -> String { addr.into() } }", + "`connect` collides with wasi-libc's socket symbol", + ), ( "mod m { pub fn f(a_b: u8, a__b: u8) {} }", "`a_b` and `a__b` are both `a-b`", @@ -936,4 +951,12 @@ mod tests { assert!(error.contains(hint), "{source}: {error}"); } } + + #[test] + fn prefixed_socket_exports_and_private_helpers_do_not_collide() { + let out = + expand_str("mod m { fn connect() {} pub fn tcp_connect() { connect() } }").unwrap(); + assert!(out.contains("export %tcp-connect: func();")); + assert!(!out.contains("export %connect:")); + } } diff --git a/sdk/rust/octosense-component/src/lib.rs b/sdk/rust/octosense-component/src/lib.rs index 89bf5328..87003e6e 100644 --- a/sdk/rust/octosense-component/src/lib.rs +++ b/sdk/rust/octosense-component/src/lib.rs @@ -39,6 +39,17 @@ //! Maps, references, generics, `usize` and `async` are refused at compile //! time, with what to use instead. //! +//! A world export named `connect` would replace wasi-libc's C socket symbol +//! and corrupt `std::net` calls. Use `tcp_connect` or another prefixed name; +//! the macro refuses the colliding export before linking: +//! +//! ```compile_fail +//! #[octosense_component::export] +//! mod tools { +//! pub fn connect(address: &str) -> String { address.into() } +//! } +//! ``` +//! //! The component runs in the OctoSense `wasm` service. It has clocks and //! randomness, and the app's bounded private folder as //! `/` through `std::fs`. Its stdout and stderr ([`log`]) become log lines. diff --git a/templates/rust-component/src/lib.rs b/templates/rust-component/src/lib.rs index cad338be..506a8a75 100644 --- a/templates/rust-component/src/lib.rs +++ b/templates/rust-component/src/lib.rs @@ -16,6 +16,8 @@ //! Ordinary `std::net` TCP/UDP/DNS requires a host advertising wasm.sockets@1, //! which released RC5 does not provide. `wasm build` adds that compatibility //! requirement automatically. Disclose file/network use with `storage`/`net`. +//! Name socket exports `tcp_connect`, not `connect`: a bare `connect` export +//! collides with wasi-libc and the SDK rejects it before linking. //! //! `octosense_component::host` calls the host services the app is granted. //! What a function may take and return is in OctoSense App Flow's From c343d3d3a56031defe0a34318f3aeabbb523cd3b Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:18:47 -0700 Subject: [PATCH 3/3] Pin final socket publisher and isolate lifetime fixes --- docs/PUBLISHING.md | 2 +- docs/PUBLISHING.zh-CN.md | 2 +- docs/RUST.md | 2 +- docs/RUST.zh-CN.md | 2 +- tools/publisher-toolchain.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index 92ac3ed6..7f319dc2 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -248,7 +248,7 @@ updated. To refresh it, first preserve any local workflow customizations, then explicitly run `tools/octo publish-github "$APP" --replace` and review `git -C "$APP" diff -- .github/workflows/publish-app.yml` before committing. Replacement writes this generated file in full; it does not merge custom steps. -Do not replace unrelated workflows. The current pin, App Hub `caf37ca0`, admits +Do not replace unrelated workflows. The current pin, App Hub `c317f787`, admits HTTP and host-service component imports and shared components under the declaration-only policy; the older `769e9ee3` publisher does not. ABI requirements, bundle integrity and GitHub publisher-proof checks still apply. diff --git a/docs/PUBLISHING.zh-CN.md b/docs/PUBLISHING.zh-CN.md index e12c0ebd..bf2e8221 100644 --- a/docs/PUBLISHING.zh-CN.md +++ b/docs/PUBLISHING.zh-CN.md @@ -164,7 +164,7 @@ tools/octo publish-github "$APP" `tools/octo new` 也会安装 `.github/workflows/publish-app.yml`。这条命令只写入这个文件,不会推送、创建 Release、提交或批准应用。除非显式加上 `--replace`,否则它拒绝覆盖内容不同的已有工作流。不需要开发者签名 secret;工作流使用 GitHub 的短期任务令牌和 OIDC 权限生成证明并创建 Release。 -更新 App Flow 不会改变已有生成工作流固定的 Hub 版本。需要更新时,先保留自己定制的工作流步骤,再显式运行 `tools/octo publish-github "$APP" --replace`,并在提交前审阅 `git -C "$APP" diff -- .github/workflows/publish-app.yml`。替换会重写整个生成文件,不会合并定制步骤;不要用它覆盖无关工作流。当前固定的 App Hub `caf37ca0` 支持 HTTP、宿主服务组件导入和共享组件,并采用声明仅用于披露的策略;旧的 `769e9ee3` 发布工具不支持这些行为。ABI 要求、应用包完整性和 GitHub 发布者证明检查仍然有效。 +更新 App Flow 不会改变已有生成工作流固定的 Hub 版本。需要更新时,先保留自己定制的工作流步骤,再显式运行 `tools/octo publish-github "$APP" --replace`,并在提交前审阅 `git -C "$APP" diff -- .github/workflows/publish-app.yml`。替换会重写整个生成文件,不会合并定制步骤;不要用它覆盖无关工作流。当前固定的 App Hub `c317f787` 支持 HTTP、宿主服务组件导入和共享组件,并采用声明仅用于披露的策略;旧的 `769e9ee3` 发布工具不支持这些行为。ABI 要求、应用包完整性和 GitHub 发布者证明检查仍然有效。 这个固定版本还包含 [App Hub #209](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/209): 普通 WASI TCP/UDP/DNS 导入要求 `host-api-v1` 和 diff --git a/docs/RUST.md b/docs/RUST.md index b4e6b566..48f8365a 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -589,7 +589,7 @@ optional ABI 1 declaration to required and refuses conflicting ABI versions. Socket apps also need socket-capable App Hub admission. This checkout's publisher-toolchain pin includes [App Hub #209](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/209) -at `caf37ca0`. Older gates and already-generated release workflows can still +at `c317f787`. Older gates and already-generated release workflows can still refuse them. Regenerate the app's workflow with `tools/octo publish-github --replace`; do not remove the compatibility requirement to make an old gate pass. Admission does not supply a compatible diff --git a/docs/RUST.zh-CN.md b/docs/RUST.zh-CN.md index 84107d07..194182a3 100644 --- a/docs/RUST.zh-CN.md +++ b/docs/RUST.zh-CN.md @@ -407,7 +407,7 @@ host.request("wasm.resolve", {host: "localhost", port: 8080}, fn(r){ 套接字应用还需要支持套接字的 App Hub 准入工具。本检出版本的 publisher-toolchain 固定到包含 [App Hub #209](https://github.com/OctoSense-org/OctoSense-App-Hub/pull/209) -的 `caf37ca0`。旧准入工具和已经生成的发布工作流仍可能拒绝它。用 +的 `c317f787`。旧准入工具和已经生成的发布工作流仍可能拒绝它。用 `tools/octo publish-github --replace` 重新生成工作流;不要为了通过旧准入工具 而移除兼容性要求。准入通过不等于已经有兼容的宿主发布版。 diff --git a/tools/publisher-toolchain.json b/tools/publisher-toolchain.json index 31ce3943..f21e8143 100644 --- a/tools/publisher-toolchain.json +++ b/tools/publisher-toolchain.json @@ -1,5 +1,5 @@ { "schema": 1, "repository": "OctoSense-org/OctoSense-App-Hub", - "revision": "caf37ca0ec18edb5b7d8eefed7a69b7f8a671ce7" + "revision": "c317f787ea2f620543000561c2470106644c1fc7" }