diff --git a/contracts b/contracts index 65bd8e7..9244c74 160000 --- a/contracts +++ b/contracts @@ -1 +1 @@ -Subproject commit 65bd8e72539b18d804609f31c35cb96ca42fe71f +Subproject commit 9244c74c4160a30a8029d06dbe0a857f4e3b4a2a diff --git a/packages/astrid-sdk/src/net.ts b/packages/astrid-sdk/src/net.ts index e2460c6..6c21226 100644 --- a/packages/astrid-sdk/src/net.ts +++ b/packages/astrid-sdk/src/net.ts @@ -14,7 +14,23 @@ import { netRead as hostRead, netWrite as hostWrite, netCloseStream as hostCloseStream, + netConnectTcp as hostConnectTcp, + netReadBytes as hostReadBytes, + netWriteBytes as hostWriteBytes, + netPeek as hostPeek, + netShutdown as hostShutdown, + netPeerAddr as hostPeerAddr, + netLocalAddr as hostLocalAddr, + netSetNodelay as hostSetNodelay, + netNodelay as hostNodelay, + netSetReadTimeout as hostSetReadTimeout, + netReadTimeout as hostReadTimeout, + netSetWriteTimeout as hostSetWriteTimeout, + netWriteTimeout as hostWriteTimeout, + netSetTtl as hostSetTtl, + netTtl as hostTtl, type NetReadStatus, + type ShutdownHow, } from "astrid:capsule/net@0.1.0"; import { clockMs as hostClockMs } from "astrid:capsule/sys@0.1.0"; import { SysError, callHost } from "./errors.js"; @@ -43,6 +59,26 @@ export class SendError extends Error { } } +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Validate + convert a timeout to the host's `bigint | undefined`. + * Mirrors Rust SDK's `to_host_timeout`. Rejects `0` (would be + * ambiguous with "no timeout") matching + * `std::net::TcpStream::set_read_timeout`'s `Duration::ZERO` rule. + */ +function toHostTimeout(timeoutMs: number | undefined): bigint | undefined { + if (timeoutMs === undefined) return undefined; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw SysError.api( + `timeout must be a positive integer (got ${timeoutMs}); use undefined to clear`, + ); + } + return BigInt(Math.floor(timeoutMs)); +} + // --------------------------------------------------------------------------- // Handles // --------------------------------------------------------------------------- @@ -110,6 +146,133 @@ export class StreamHandle { } } + // ------------------------------------------------------------------------- + // Byte-stream surface (mirrors std::net::TcpStream / std::io::Read+Write) + // ------------------------------------------------------------------------- + + /** + * Read up to `maxBytes` without length-prefix framing. Mirrors + * `std::net::TcpStream::read`. + * + * Contract: + * - **Empty Uint8Array = EOF** (peer disconnected). Unambiguous. + * - Non-empty = data read (may be shorter than `maxBytes`). + * - Throws `SysError` with message containing `"would block"` if a + * read timeout was set via {@link setReadTimeout} and expired + * with no data. With no timeout set, blocks until data, EOF, or + * capsule unload. + */ + readBytes(maxBytes: number): Uint8Array { + this.#requireOpen(); + return callHost(`net.readBytes(${this.id}, ${maxBytes})`, () => + hostReadBytes(this.id, maxBytes), + ); + } + + /** + * Write `data` without framing. Returns bytes written (may be less than + * `data.length` when the kernel's socket buffer is full). Honours any + * timeout set via {@link setWriteTimeout}; with no timeout set, blocks + * until the write completes or the peer disconnects. + */ + writeBytes(data: Uint8Array): number { + this.#requireOpen(); + return callHost(`net.writeBytes(${this.id})`, () => hostWriteBytes(this.id, data)); + } + + /** + * Peek up to `maxBytes` without consuming them — the next + * {@link readBytes} returns the same data again. Same EOF / + * would-block semantics as {@link readBytes}. + */ + peek(maxBytes: number): Uint8Array { + this.#requireOpen(); + return callHost(`net.peek(${this.id}, ${maxBytes})`, () => hostPeek(this.id, maxBytes)); + } + + /** Half-close the read side, write side, or both. */ + shutdown(how: ShutdownHow): void { + this.#requireOpen(); + callHost(`net.shutdown(${this.id}, ${how})`, () => hostShutdown(this.id, how)); + } + + /** Remote peer address as `"ip:port"`. */ + peerAddr(): string { + this.#requireOpen(); + return callHost(`net.peerAddr(${this.id})`, () => hostPeerAddr(this.id)); + } + + /** Local socket address as `"ip:port"`. */ + localAddr(): string { + this.#requireOpen(); + return callHost(`net.localAddr(${this.id})`, () => hostLocalAddr(this.id)); + } + + /** Toggle `TCP_NODELAY` (Nagle's algorithm off when `true`). */ + setNodelay(nodelay: boolean): void { + this.#requireOpen(); + callHost(`net.setNodelay(${this.id}, ${nodelay})`, () => hostSetNodelay(this.id, nodelay)); + } + + /** Current `TCP_NODELAY` setting. */ + nodelay(): boolean { + this.#requireOpen(); + return callHost(`net.nodelay(${this.id})`, () => hostNodelay(this.id)); + } + + /** + * Set the read timeout (milliseconds). `undefined` clears the + * timeout (reads block indefinitely). `0` is rejected — matches + * `std::net::TcpStream::set_read_timeout` which errors on + * `Duration::ZERO`. + */ + setReadTimeout(timeoutMs: number | undefined): void { + this.#requireOpen(); + const ms = toHostTimeout(timeoutMs); + callHost(`net.setReadTimeout(${this.id}, ${timeoutMs})`, () => + hostSetReadTimeout(this.id, ms), + ); + } + + /** Current read timeout in milliseconds, or `undefined` if unset. */ + readTimeout(): number | undefined { + this.#requireOpen(); + const v = callHost(`net.readTimeout(${this.id})`, () => hostReadTimeout(this.id)); + return v === undefined ? undefined : Number(v); + } + + /** + * Set the write timeout (milliseconds). `undefined` clears it; + * `0` is rejected (matches + * `std::net::TcpStream::set_write_timeout`). + */ + setWriteTimeout(timeoutMs: number | undefined): void { + this.#requireOpen(); + const ms = toHostTimeout(timeoutMs); + callHost(`net.setWriteTimeout(${this.id}, ${timeoutMs})`, () => + hostSetWriteTimeout(this.id, ms), + ); + } + + /** Current write timeout in milliseconds, or `undefined` if unset. */ + writeTimeout(): number | undefined { + this.#requireOpen(); + const v = callHost(`net.writeTimeout(${this.id})`, () => hostWriteTimeout(this.id)); + return v === undefined ? undefined : Number(v); + } + + /** Set the IP `TTL` on outgoing packets. */ + setTtl(ttl: number): void { + this.#requireOpen(); + callHost(`net.setTtl(${this.id}, ${ttl})`, () => hostSetTtl(this.id, ttl)); + } + + /** Current IP `TTL`. */ + ttl(): number { + this.#requireOpen(); + return callHost(`net.ttl(${this.id})`, () => hostTtl(this.id)); + } + close(): void { if (this.#closed) return; this.#closed = true; @@ -163,6 +326,44 @@ export function tryAccept(listener: ListenerHandle): StreamHandle | undefined { return id === undefined ? undefined : new StreamHandle(id); } +// --------------------------------------------------------------------------- +// Outbound TCP — STUB pending host fn +// --------------------------------------------------------------------------- + +/** + * Open an outbound TCP connection to `host:port`. + * + * The returned {@link StreamHandle} flows through the same `recv` / + * `tryRecv` / `send` / `close` API as the `accept` path — Astrid's host + * ABI keeps inbound and outbound on one stream-handle type. + * + * The kernel runs three checks before the TCP syscall: + * + * 1. The capsule's `net_connect` allowlist in `Capsule.toml` must + * contain a pattern matching `"host:port"` (exact) or `"host:*"` + * (any port for the named host). Missing or empty list denies all + * outbound TCP (fail-closed). + * 2. DNS resolution rejects loopback, private, link-local, multicast, + * and unspecified IPs (same airlock as `http.request`). + * 3. Per-capsule active-stream cap (default 8, shared with inbound + * `accept`). + * + * Connect attempts are bounded to ~10s by the host. A stalled DNS or + * TCP handshake surfaces as a `SysError`, not an indefinite hang. + * + * @param host Hostname or IP, matched against the manifest allowlist. + * @param port TCP port (1–65535). + * + * Tracking issue: https://github.com/unicity-astrid/astrid/issues/745 + * RFC: https://github.com/unicity-astrid/rfcs/pull/27 + */ +export function connect(host: string, port: number): StreamHandle { + const id = callHost(`net.connect(${JSON.stringify(host)}, ${port})`, () => + hostConnectTcp(host, port), + ); + return new StreamHandle(id); +} + // --------------------------------------------------------------------------- // Sleep shim // --------------------------------------------------------------------------- diff --git a/packages/astrid-sdk/src/wit-imports.d.ts b/packages/astrid-sdk/src/wit-imports.d.ts index 2e09d2e..783f5b4 100644 --- a/packages/astrid-sdk/src/wit-imports.d.ts +++ b/packages/astrid-sdk/src/wit-imports.d.ts @@ -129,12 +129,29 @@ declare module "astrid:capsule/net@0.1.0" { | { tag: "closed" } | { tag: "pending" }; + export type ShutdownHow = "read" | "write" | "both"; + export function netBindUnix(listenerHandle: bigint): bigint; export function netAccept(listenerHandle: bigint): bigint; export function netPollAccept(listenerHandle: bigint): bigint | undefined; export function netRead(streamHandle: bigint): NetReadStatus; export function netWrite(streamHandle: bigint, data: Uint8Array): void; export function netCloseStream(streamHandle: bigint): void; + export function netConnectTcp(host: string, port: number): bigint; + export function netReadBytes(streamHandle: bigint, maxBytes: number): Uint8Array; + export function netWriteBytes(streamHandle: bigint, data: Uint8Array): number; + export function netPeek(streamHandle: bigint, maxBytes: number): Uint8Array; + export function netShutdown(streamHandle: bigint, how: ShutdownHow): void; + export function netPeerAddr(streamHandle: bigint): string; + export function netLocalAddr(streamHandle: bigint): string; + export function netSetNodelay(streamHandle: bigint, nodelay: boolean): void; + export function netNodelay(streamHandle: bigint): boolean; + export function netSetReadTimeout(streamHandle: bigint, timeoutMs: bigint | undefined): void; + export function netReadTimeout(streamHandle: bigint): bigint | undefined; + export function netSetWriteTimeout(streamHandle: bigint, timeoutMs: bigint | undefined): void; + export function netWriteTimeout(streamHandle: bigint): bigint | undefined; + export function netSetTtl(streamHandle: bigint, ttl: number): void; + export function netTtl(streamHandle: bigint): number; } // ----------------------------------------------------------------------